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.
This commit is contained in:
diegosouzapw
2026-05-15 01:30:18 -03:00
parent 2468ebf8f7
commit 28629bf7f0
5 changed files with 454 additions and 0 deletions

203
bin/cli/commands/audit.mjs Normal file
View File

@@ -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 <s>", t("audit.source"), "all")
.option("--follow", t("audit.tail.follow"))
.option("--limit <n>", t("audit.tail.limit"), parseInt, 100)
.action(runAuditTail);
audit
.command("search <query>")
.description(t("audit.search.description"))
.option("--source <s>", t("audit.source"), "all")
.option("--since <ts>", t("audit.since"))
.option("--until <ts>", t("audit.until"))
.option("--limit <n>", t("audit.search.limit"), parseInt, 200)
.option("--actor <id>", t("audit.search.actor"))
.option("--action <a>", t("audit.search.action"))
.action(runAuditSearch);
audit
.command("export <file>")
.description(t("audit.export.description"))
.option("--source <s>", t("audit.source"), "all")
.option("--format <f>", t("audit.export.format"), "jsonl")
.option("--since <ts>", t("audit.since"))
.option("--until <ts>", t("audit.until"))
.action(runAuditExport);
audit
.command("stats")
.description(t("audit.stats.description"))
.option("--source <s>", t("audit.source"), "mcp")
.option("--period <p>", t("audit.stats.period"), "7d")
.action(runAuditStats);
audit
.command("get <id>")
.description(t("audit.get.description"))
.option("--source <s>", t("audit.source"), "compliance")
.action(runAuditGet);
}

View File

@@ -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);

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<string, unknown> = {}) {
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<void>): Promise<string> {
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("****"));
});