/** * device-audit.ts — read the local device_agent audit DB. * * The device_agent runs on the same WSL host as the tests and writes audit * entries to /tmp/device_audit.db (configurable via DEVICE_AUDIT_DB env). * * Two tables: * audit_log — id, ts, request_id, capability, args_hash, * exit_code, prev_hash, this_hash (hash-chained) * audit_shell_eval — audit_id, cmd, cwd, shell, stdout_b64, stderr_b64 * * Used by DoD Capa 2 to *cross-check* that tools the bot claims to have * invoked actually ran on the device. * * NOTE: better-sqlite3 is a native binary; if unavailable on this system the * fallback path is `sqlite3` CLI via execFileSync. */ import { execFileSync } from "node:child_process"; import * as crypto from "node:crypto"; export interface AuditEntry { id: number; ts: number; requestId: string; capability: string; argsHash: string; exitCode: number; prevHash: string; thisHash: string; } export interface ShellEvalAudit { auditId: number; cmd: string; cwd: string; shell: string; stdoutPreview: string; stderrPreview: string; } const DEFAULT_DB = process.env.DEVICE_AUDIT_DB ?? "/tmp/device_audit.db"; // ---------- sqlite shim: better-sqlite3 if installed, else CLI ---------- type Row = Record; function queryViaCli(dbPath: string, sql: string): Row[] { // We use sqlite3 -json. We pass the SQL as argv to avoid shell interpolation. // The runner is invoked via execFileSync (no shell), but sqlite3's own arg // parsing handles quoting. let out: string; try { out = execFileSync("sqlite3", ["-json", dbPath, sql], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, }); } catch (err: any) { throw new Error( `sqlite3 query failed on ${dbPath}: ${err.message}\n` + `stderr=${err?.stderr?.toString?.() ?? ""}`, ); } const trimmed = out.trim(); if (!trimmed) return []; try { return JSON.parse(trimmed) as Row[]; } catch { return []; } } interface DbHandle { prepare(sql: string): { all: (...params: unknown[]) => Row[]; get: (...params: unknown[]) => Row | undefined; }; } function openDb(dbPath: string): DbHandle { try { // Prefer better-sqlite3 when available (faster, no subprocess). // eslint-disable-next-line @typescript-eslint/no-var-requires const Better = require("better-sqlite3"); const db = new Better(dbPath, { readonly: true, fileMustExist: true }); return { prepare(sql: string) { const stmt = db.prepare(sql); return { all: (...params: unknown[]) => stmt.all(...params) as Row[], get: (...params: unknown[]) => stmt.get(...params) as Row | undefined, }; }, }; } catch { // Fallback to sqlite3 CLI. We cannot bind parameters via CLI cleanly with // arbitrary types, so we inline only numeric/string sanitized fragments. return { prepare(sql: string) { return { all: (...params: unknown[]) => queryViaCli(dbPath, interpolate(sql, params)), get: (...params: unknown[]) => queryViaCli(dbPath, interpolate(sql, params))[0], }; }, }; } } /** Naive parameter inliner — used ONLY against a local trusted DB path. */ function interpolate(sql: string, params: unknown[]): string { let idx = 0; return sql.replace(/\?/g, () => { const v = params[idx++]; if (v === null || v === undefined) return "NULL"; if (typeof v === "number") return String(v); if (typeof v === "boolean") return v ? "1" : "0"; // Escape single quotes for SQL string literal return `'${String(v).replace(/'/g, "''")}'`; }); } // ---------- public API ---------- export interface FetchAuditOptions { dbPath?: string; sinceSeconds?: number; capability?: string; limit?: number; } function rowToAudit(r: Row): AuditEntry { return { id: Number(r.id), ts: Number(r.ts), requestId: String(r.request_id ?? ""), capability: String(r.capability ?? ""), argsHash: String(r.args_hash ?? ""), exitCode: Number(r.exit_code), prevHash: String(r.prev_hash ?? ""), thisHash: String(r.this_hash ?? ""), }; } export async function fetchRecentAudit( opts: FetchAuditOptions = {}, ): Promise { const dbPath = opts.dbPath ?? DEFAULT_DB; const sinceSeconds = opts.sinceSeconds ?? 120; const limit = opts.limit ?? 50; const tsCutoff = Math.floor(Date.now() / 1000) - sinceSeconds; const db = openDb(dbPath); let sql = "SELECT id, ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash " + "FROM audit_log WHERE ts >= ?"; const params: unknown[] = [tsCutoff]; if (opts.capability) { sql += " AND capability = ?"; params.push(opts.capability); } sql += " ORDER BY id DESC LIMIT ?"; params.push(limit); const rows = db.prepare(sql).all(...params); return rows.map(rowToAudit); } /** * Validate the hash chain from `fromId` to the latest row. * Returns the first BROKEN entry (the one whose this_hash != recomputed) or null. * * The chain rule comes from audit.go: * canonical = prev_hash | ts | request_id | capability | args_hash | exit_code * this_hash = sha256(canonical) * with prev_hash = "" for the very first row. */ export async function verifyHashChain(opts: { dbPath?: string; fromId?: number; } = {}): Promise { const dbPath = opts.dbPath ?? DEFAULT_DB; const db = openDb(dbPath); const fromId = opts.fromId ?? 0; const rows = db .prepare( "SELECT id, ts, request_id, capability, args_hash, exit_code, prev_hash, this_hash " + "FROM audit_log WHERE id >= ? ORDER BY id ASC", ) .all(fromId); let expectedPrev: string | null = null; for (const r of rows) { const entry = rowToAudit(r); if (expectedPrev === null) { // First row in the window: trust its prev_hash as the anchor. // We can't verify prev_hash without history before fromId, but we still // verify the computed this_hash matches. expectedPrev = entry.prevHash; } else if (entry.prevHash !== expectedPrev) { return entry; } const canonical = `${entry.prevHash}|${entry.ts}|${entry.requestId}|${entry.capability}|${entry.argsHash}|${entry.exitCode}`; const recomputed = crypto.createHash("sha256").update(canonical).digest("hex"); if (recomputed !== entry.thisHash) { return entry; } expectedPrev = entry.thisHash; } return null; } function decodeBlob(s: string | null | undefined, max = 200): string { if (!s) return ""; // The Go side uses prefix "plain:" (<=4KB) or "gz:" (gzip) before base64. if (s.startsWith("plain:")) { try { const buf = Buffer.from(s.slice("plain:".length), "base64"); return buf.toString("utf8").slice(0, max); } catch { return s.slice(0, max); } } if (s.startsWith("gz:")) { try { const zlib = require("node:zlib"); const buf = zlib.gunzipSync(Buffer.from(s.slice("gz:".length), "base64")); return buf.toString("utf8").slice(0, max); } catch { return "[gz decode failed]"; } } return s.slice(0, max); } export async function fetchRecentShellEval(opts: { dbPath?: string; sinceSeconds?: number; limit?: number; } = {}): Promise { const dbPath = opts.dbPath ?? DEFAULT_DB; const sinceSeconds = opts.sinceSeconds ?? 120; const limit = opts.limit ?? 50; const tsCutoff = Math.floor(Date.now() / 1000) - sinceSeconds; const db = openDb(dbPath); const rows = db .prepare( "SELECT s.audit_id AS audit_id, s.cmd AS cmd, s.cwd AS cwd, s.shell AS shell, " + " s.stdout_b64 AS stdout_b64, s.stderr_b64 AS stderr_b64 " + "FROM audit_shell_eval s JOIN audit_log a ON a.id = s.audit_id " + "WHERE a.ts >= ? ORDER BY s.audit_id DESC LIMIT ?", ) .all(tsCutoff, limit); return rows.map((r) => ({ auditId: Number(r.audit_id), cmd: String(r.cmd ?? ""), cwd: String(r.cwd ?? ""), shell: String(r.shell ?? ""), stdoutPreview: decodeBlob(r.stdout_b64 as string), stderrPreview: decodeBlob(r.stderr_b64 as string), })); } /** Quick sanity probe: does the DB exist and have rows? */ export async function auditDbReady(dbPath = DEFAULT_DB): Promise { try { const db = openDb(dbPath); const row = db.prepare("SELECT COUNT(*) AS n FROM audit_log").get(); return Boolean(row); } catch { return false; } }