From 28629bf7f0d1bf9bc0d9df0bf03881864cd4bd2a Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 01:30:18 -0300 Subject: [PATCH] feat(cli): adicionar omniroute audit (Fase 3.4) 5 subcomandos: tail, search, export, stats, get. Mescla compliance e MCP por timestamp. --follow faz polling 2s. --source filtra entre compliance|mcp|all. Mascaramento de actor. --- bin/cli/commands/audit.mjs | 203 ++++++++++++++++++++++++++ bin/cli/commands/registry.mjs | 2 + bin/cli/locales/en.json | 28 ++++ bin/cli/locales/pt-BR.json | 28 ++++ tests/unit/cli-audit-commands.test.ts | 193 ++++++++++++++++++++++++ 5 files changed, 454 insertions(+) create mode 100644 bin/cli/commands/audit.mjs create mode 100644 tests/unit/cli-audit-commands.test.ts diff --git a/bin/cli/commands/audit.mjs b/bin/cli/commands/audit.mjs new file mode 100644 index 0000000000..fdd392308e --- /dev/null +++ b/bin/cli/commands/audit.mjs @@ -0,0 +1,203 @@ +import { writeFileSync } from "node:fs"; +import { setTimeout as sleep } from "node:timers/promises"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function truncate(v, len = 40) { + if (v == null) return "-"; + const s = String(v); + return s.length > len ? s.slice(0, len - 1) + "…" : s; +} + +function fmtTs(v) { + if (!v) return "-"; + try { + return new Date(v).toLocaleString(); + } catch { + return String(v); + } +} + +function maskActor(v) { + if (!v) return "-"; + const s = String(v); + if (s.length <= 8) return s; + return `${s.slice(0, 4)}****${s.slice(-4)}`; +} + +const auditSchema = [ + { key: "timestamp", header: "Time", width: 22, formatter: fmtTs }, + { key: "source", header: "Source", width: 10 }, + { key: "actor", header: "Actor", width: 16, formatter: maskActor }, + { key: "action", header: "Action", width: 28 }, + { key: "resource", header: "Resource", width: 32, formatter: truncate }, + { key: "result", header: "Result", formatter: (v) => (v === "success" ? "✓" : "✗") }, + { key: "details", header: "Details", formatter: truncate }, +]; + +function endpointFor(source) { + return source === "mcp" ? "/api/mcp/audit" : "/api/compliance/audit-log"; +} + +async function fetchAuditEntries(sources, params) { + const entries = []; + for (const src of sources) { + const endpoint = endpointFor(src); + const res = await apiFetch(`${endpoint}?${params}`); + if (!res.ok) continue; + const data = await res.json(); + for (const e of data.items ?? data) { + entries.push({ ...e, source: src }); + } + } + return entries; +} + +function resolveSources(source) { + if (source === "all") return ["compliance", "mcp"]; + return [source ?? "compliance"]; +} + +export async function runAuditTail(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const sources = resolveSources(opts.source); + const params = new URLSearchParams({ limit: String(opts.limit ?? 100) }); + const entries = await fetchAuditEntries(sources, params); + entries.sort((a, b) => String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? ""))); + emit(entries.slice(0, opts.limit ?? 100), globalOpts, auditSchema); + + if (opts.follow) { + process.stderr.write("\n[following — Ctrl+C to exit]\n"); + let lastTs = entries[0]?.timestamp ?? new Date().toISOString(); + const loop = async () => { + while (true) { + await sleep(2000); + for (const src of sources) { + const endpoint = endpointFor(src); + const res = await apiFetch(`${endpoint}?since=${encodeURIComponent(lastTs)}&limit=50`); + if (!res.ok) continue; + const data = await res.json(); + const newEntries = (data.items ?? data) + .map((e) => ({ ...e, source: src })) + .filter((e) => String(e.timestamp ?? "") > String(lastTs)); + for (const e of newEntries) { + if (String(e.timestamp ?? "") > String(lastTs)) lastTs = e.timestamp; + emit([e], globalOpts, auditSchema); + } + } + } + }; + process.on("SIGINT", () => process.exit(0)); + await loop(); + } +} + +export async function runAuditSearch(query, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const sources = resolveSources(opts.source); + const params = new URLSearchParams({ q: query, limit: String(opts.limit ?? 200) }); + if (opts.since) params.set("since", opts.since); + if (opts.until) params.set("until", opts.until); + if (opts.actor) params.set("actor", opts.actor); + if (opts.action) params.set("action", opts.action); + const entries = await fetchAuditEntries(sources, params); + entries.sort((a, b) => String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? ""))); + emit(entries, globalOpts, auditSchema); +} + +export async function runAuditExport(file, opts, cmd) { + const sources = resolveSources(opts.source === "all" ? "compliance" : opts.source); + const format = opts.format ?? "jsonl"; + const params = new URLSearchParams({ format }); + if (opts.since) params.set("since", opts.since); + if (opts.until) params.set("until", opts.until); + + const allLines = []; + for (const src of sources) { + const endpoint = endpointFor(src); + const res = await apiFetch(`${endpoint}?${params}`); + if (!res.ok) { + process.stderr.write(`Error fetching ${src}: ${res.status}\n`); + continue; + } + const body = await res.text(); + allLines.push(body); + } + + const combined = allLines.join("\n"); + writeFileSync(file, combined); + process.stdout.write(`Exported to ${file} (${combined.length} bytes)\n`); +} + +export async function runAuditStats(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const source = opts.source ?? "mcp"; + const params = new URLSearchParams({ period: opts.period ?? "7d" }); + const endpoint = source === "mcp" ? "/api/mcp/audit/stats" : "/api/compliance/audit-log/stats"; + const res = await apiFetch(`${endpoint}?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data, globalOpts); +} + +export async function runAuditGet(id, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const source = opts.source ?? "compliance"; + const endpoint = endpointFor(source); + const res = await apiFetch(`${endpoint}/${id}`); + if (!res.ok) { + process.stderr.write(`Not found: ${id}\n`); + process.exit(1); + } + const data = await res.json(); + emit(data, globalOpts, auditSchema); +} + +export function registerAudit(program) { + const audit = program.command("audit").description(t("audit.description")); + + audit + .command("tail") + .description(t("audit.tail.description")) + .option("--source ", t("audit.source"), "all") + .option("--follow", t("audit.tail.follow")) + .option("--limit ", t("audit.tail.limit"), parseInt, 100) + .action(runAuditTail); + + audit + .command("search ") + .description(t("audit.search.description")) + .option("--source ", t("audit.source"), "all") + .option("--since ", t("audit.since")) + .option("--until ", t("audit.until")) + .option("--limit ", t("audit.search.limit"), parseInt, 200) + .option("--actor ", t("audit.search.actor")) + .option("--action ", t("audit.search.action")) + .action(runAuditSearch); + + audit + .command("export ") + .description(t("audit.export.description")) + .option("--source ", t("audit.source"), "all") + .option("--format ", t("audit.export.format"), "jsonl") + .option("--since ", t("audit.since")) + .option("--until ", t("audit.until")) + .action(runAuditExport); + + audit + .command("stats") + .description(t("audit.stats.description")) + .option("--source ", t("audit.source"), "mcp") + .option("--period

", t("audit.stats.period"), "7d") + .action(runAuditStats); + + audit + .command("get ") + .description(t("audit.get.description")) + .option("--source ", t("audit.source"), "compliance") + .action(runAuditGet); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index d3eb5b7d50..7d4e3c07db 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -1,5 +1,6 @@ import { registerMemory } from "./memory.mjs"; import { registerSkills } from "./skills.mjs"; +import { registerAudit } from "./audit.mjs"; import { registerChat } from "./chat.mjs"; import { registerStream } from "./stream.mjs"; import { registerSimulate } from "./simulate.mjs"; @@ -34,6 +35,7 @@ import { registerCompletion } from "./completion.mjs"; export function registerCommands(program) { registerMemory(program); registerSkills(program); + registerAudit(program); registerChat(program); registerStream(program); registerSimulate(program); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index b000ef1f46..69f3615ff5 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -274,6 +274,34 @@ "noServer": "Server not running. Start with: omniroute serve", "noModels": "No models found." }, + "audit": { + "description": "Access compliance and MCP audit logs", + "source": "Log source: all|compliance|mcp (default: all)", + "since": "Return entries since this timestamp (ISO 8601)", + "until": "Return entries until this timestamp (ISO 8601)", + "tail": { + "description": "Show recent audit log entries", + "follow": "Continuously tail new entries (2s poll)", + "limit": "Maximum entries to show (default: 100)" + }, + "search": { + "description": "Search audit log entries by keyword", + "limit": "Maximum results (default: 200)", + "actor": "Filter by actor ID", + "action": "Filter by action name" + }, + "export": { + "description": "Export audit log to file", + "format": "Output format: jsonl|csv (default: jsonl)" + }, + "stats": { + "description": "Show audit log statistics", + "period": "Time period: 1d|7d|30d (default: 7d)" + }, + "get": { + "description": "Get a single audit log entry by ID" + } + }, "skills": { "description": "Manage OmniRoute skills (sandbox, builtin, custom, hybrid, skillssh)", "list": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 87b02ea04d..e4dc3203ec 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -274,6 +274,34 @@ "noServer": "Servidor não está em execução. Inicie com: omniroute serve", "noModels": "Nenhum modelo encontrado." }, + "audit": { + "description": "Acessar logs de auditoria de compliance e MCP", + "source": "Fonte do log: all|compliance|mcp (padrão: all)", + "since": "Retornar entradas desde este timestamp (ISO 8601)", + "until": "Retornar entradas até este timestamp (ISO 8601)", + "tail": { + "description": "Exibir entradas recentes do log de auditoria", + "follow": "Fazer tail contínuo de novas entradas (poll 2s)", + "limit": "Máximo de entradas (padrão: 100)" + }, + "search": { + "description": "Buscar entradas do log de auditoria por palavra-chave", + "limit": "Máximo de resultados (padrão: 200)", + "actor": "Filtrar por ID de ator", + "action": "Filtrar por nome de ação" + }, + "export": { + "description": "Exportar log de auditoria para arquivo", + "format": "Formato de saída: jsonl|csv (padrão: jsonl)" + }, + "stats": { + "description": "Exibir estatísticas do log de auditoria", + "period": "Período: 1d|7d|30d (padrão: 7d)" + }, + "get": { + "description": "Obter uma entrada do log de auditoria por ID" + } + }, "skills": { "description": "Gerenciar skills do OmniRoute (sandbox, builtin, custom, hybrid, skillssh)", "list": { diff --git a/tests/unit/cli-audit-commands.test.ts b/tests/unit/cli-audit-commands.test.ts new file mode 100644 index 0000000000..0d300cbf25 --- /dev/null +++ b/tests/unit/cli-audit-commands.test.ts @@ -0,0 +1,193 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const COMPLIANCE_ENTRIES = [ + { + id: "c1", + timestamp: "2026-05-10T10:00:00Z", + actor: "admin@acme.com", + action: "user.login", + resource: "/admin", + result: "success", + }, + { + id: "c2", + timestamp: "2026-05-10T09:00:00Z", + actor: "admin@acme.com", + action: "key.delete", + resource: "sk-xxx", + result: "success", + }, +]; + +const MCP_ENTRIES = [ + { + id: "m1", + timestamp: "2026-05-10T11:00:00Z", + actor: "cli_client", + action: "omniroute_memory_search", + resource: null, + result: "success", + }, +]; + +const MCP_STATS = { + period: "7d", + totalCalls: 150, + byTool: [{ tool: "omniroute_memory_search", count: 40 }], + byResult: { success: 140, error: 10 }, +}; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +function mockFetch(overrides: Record = {}) { + return (url: string) => { + if (url.includes("/api/mcp/audit/stats")) + return Promise.resolve(makeResp(overrides.stats ?? MCP_STATS)); + if (url.includes("/api/mcp/audit")) + return Promise.resolve(makeResp({ items: overrides.mcp ?? MCP_ENTRIES })); + if (url.includes("/api/compliance/audit-log")) + return Promise.resolve(makeResp({ items: overrides.compliance ?? COMPLIANCE_ENTRIES })); + return Promise.resolve(makeResp({}, 404)); + }; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("runAuditTail --source all mescla compliance e mcp", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runAuditTail } = await import("../../bin/cli/commands/audit.mjs"); + const out = await captureStdout(() => + runAuditTail({ source: "all", limit: 50 }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.ok(parsed.some((e: any) => e.source === "compliance")); + assert.ok(parsed.some((e: any) => e.source === "mcp")); +}); + +test("runAuditTail --source compliance retorna apenas compliance", async () => { + let capturedUrls: string[] = []; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrls.push(url); + return Promise.resolve(makeResp({ items: COMPLIANCE_ENTRIES })); + }) as any; + + const { runAuditTail } = await import("../../bin/cli/commands/audit.mjs"); + const out = await captureStdout(() => + runAuditTail({ source: "compliance", limit: 50 }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrls.every((u) => u.includes("/api/compliance/audit-log"))); + const parsed = JSON.parse(out); + assert.ok(parsed.every((e: any) => e.source === "compliance")); +}); + +test("runAuditSearch envia q e filtros na query", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: COMPLIANCE_ENTRIES })); + }) as any; + + const { runAuditSearch } = await import("../../bin/cli/commands/audit.mjs"); + await captureStdout(() => + runAuditSearch( + "scope_denied", + { source: "compliance", limit: 100, actor: "admin" }, + makeCmd() as any + ) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("q=scope_denied")); + assert.ok(capturedUrl.includes("actor=admin")); +}); + +test("runAuditStats consulta endpoint stats do mcp", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp(MCP_STATS)); + }) as any; + + const { runAuditStats } = await import("../../bin/cli/commands/audit.mjs"); + const out = await captureStdout(() => + runAuditStats({ source: "mcp", period: "7d" }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/mcp/audit/stats")); + assert.ok(capturedUrl.includes("period=7d")); + const parsed = JSON.parse(out); + assert.equal(parsed.totalCalls, 150); +}); + +test("runAuditGet busca entrada por id", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp(COMPLIANCE_ENTRIES[0])); + }) as any; + + const { runAuditGet } = await import("../../bin/cli/commands/audit.mjs"); + const out = await captureStdout(() => + runAuditGet("c1", { source: "compliance" }, makeCmd() as any) + ); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/compliance/audit-log/c1")); + const parsed = JSON.parse(out); + assert.equal(parsed.id, "c1"); +}); + +test("runAuditTail mascaramento de actor em output table", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runAuditTail } = await import("../../bin/cli/commands/audit.mjs"); + const out = await captureStdout(() => + runAuditTail({ source: "compliance", limit: 10 }, makeCmd("table") as any) + ); + + globalThis.fetch = origFetch; + assert.ok(!out.includes("admin@acme.com") || out.includes("****")); +});