mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
203
bin/cli/commands/audit.mjs
Normal file
203
bin/cli/commands/audit.mjs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
193
tests/unit/cli-audit-commands.test.ts
Normal file
193
tests/unit/cli-audit-commands.test.ts
Normal 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("****"));
|
||||
});
|
||||
Reference in New Issue
Block a user