@thotischner/observability-mcp 1.7.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/products.yaml.example +48 -0
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +45 -1
- package/dist/context.js +40 -1
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +2124 -73
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/net/egress-policy.js +2 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +3083 -88
- package/package.json +32 -5
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only audit log for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from the enterprise-gate audit (`enterprise/audit/`) which
|
|
5
|
+
* records every gated MCP tool call. This one records every mutating
|
|
6
|
+
* `/api/*` request (sources/settings/health-thresholds/connectors)
|
|
7
|
+
* so an operator can answer "who changed what, when".
|
|
8
|
+
*
|
|
9
|
+
* On-disk format is JSONL with one entry per line. Each line includes
|
|
10
|
+
* `prevHash` and `hash` (SHA-256 over the canonical JSON of the entry
|
|
11
|
+
* without the hash field itself, plus the previous hash) — a
|
|
12
|
+
* tamper-evident chain that `scripts/verify-audit.mjs` can walk.
|
|
13
|
+
*
|
|
14
|
+
* In-memory mode (no `OMCP_MGMT_AUDIT_FILE`) keeps the last
|
|
15
|
+
* `inMemoryCap` entries in a ring buffer so the UI's audit tab still
|
|
16
|
+
* shows something even without persistence configured. This is the
|
|
17
|
+
* default in the demo / single-user case.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
21
|
+
export const DEFAULT_IN_MEMORY_CAP = 500;
|
|
22
|
+
const GENESIS_HASH = "0".repeat(64);
|
|
23
|
+
export class AuditLog {
|
|
24
|
+
cap;
|
|
25
|
+
file;
|
|
26
|
+
sinks;
|
|
27
|
+
ring = [];
|
|
28
|
+
lastHash = GENESIS_HASH;
|
|
29
|
+
seq = 0;
|
|
30
|
+
writeQueue = Promise.resolve();
|
|
31
|
+
bootstrapped = null;
|
|
32
|
+
constructor(cfg = {}) {
|
|
33
|
+
this.cap = cfg.inMemoryCap ?? DEFAULT_IN_MEMORY_CAP;
|
|
34
|
+
this.file = cfg.file;
|
|
35
|
+
this.sinks = cfg.sinks ?? [];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* If a file is configured, replay it to recover seq + lastHash so a
|
|
39
|
+
* server restart picks up the chain exactly where it left off.
|
|
40
|
+
* Safe to call multiple times — bootstraps once and caches.
|
|
41
|
+
*/
|
|
42
|
+
async bootstrap() {
|
|
43
|
+
if (!this.file)
|
|
44
|
+
return;
|
|
45
|
+
if (this.bootstrapped)
|
|
46
|
+
return this.bootstrapped;
|
|
47
|
+
this.bootstrapped = (async () => {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(this.file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return; // first run, fine
|
|
54
|
+
}
|
|
55
|
+
for (const line of raw.split("\n")) {
|
|
56
|
+
const t = line.trim();
|
|
57
|
+
if (!t)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const entry = JSON.parse(t);
|
|
61
|
+
if (typeof entry.seq === "number")
|
|
62
|
+
this.seq = Math.max(this.seq, entry.seq);
|
|
63
|
+
if (typeof entry.hash === "string")
|
|
64
|
+
this.lastHash = entry.hash;
|
|
65
|
+
if (this.ring.length === this.cap)
|
|
66
|
+
this.ring.shift();
|
|
67
|
+
this.ring.push(entry);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// skip malformed line — don't fail boot on a single corrupt entry
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
return this.bootstrapped;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record an event. Returns the canonical entry (with chain fields)
|
|
78
|
+
* once enqueued; persistence to disk completes asynchronously.
|
|
79
|
+
*/
|
|
80
|
+
async record(input) {
|
|
81
|
+
if (this.bootstrapped)
|
|
82
|
+
await this.bootstrapped;
|
|
83
|
+
this.seq += 1;
|
|
84
|
+
const ts = new Date().toISOString();
|
|
85
|
+
const base = { ts, seq: this.seq, prevHash: this.lastHash, ...input };
|
|
86
|
+
const hash = chainHash(base);
|
|
87
|
+
const entry = { ...base, hash };
|
|
88
|
+
this.lastHash = hash;
|
|
89
|
+
if (this.ring.length === this.cap)
|
|
90
|
+
this.ring.shift();
|
|
91
|
+
this.ring.push(entry);
|
|
92
|
+
if (this.file) {
|
|
93
|
+
const file = this.file;
|
|
94
|
+
// Serialize disk writes so concurrent records don't interleave bytes.
|
|
95
|
+
this.writeQueue = this.writeQueue.then(() => appendFile(file, JSON.stringify(entry) + "\n", "utf8").catch(() => {
|
|
96
|
+
// intentionally swallow — losing a single audit line is
|
|
97
|
+
// strictly better than crashing the management plane.
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
// Fan out to any external sinks (webhook, archive, ...). Sinks
|
|
101
|
+
// are mirrors: failures are swallowed inside the sink so a sick
|
|
102
|
+
// SIEM never takes down the gateway. The JSONL master remains
|
|
103
|
+
// the authoritative chain.
|
|
104
|
+
for (const sink of this.sinks) {
|
|
105
|
+
// Intentionally not awaited: record() must stay fast and
|
|
106
|
+
// independent of receiver health.
|
|
107
|
+
sink.write(entry).catch((err) => {
|
|
108
|
+
console.warn("AuditLog: sink %s write failed: %s", sink.name, err instanceof Error ? err.message : String(err));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
/** Flush every configured sink. Called from SIGTERM and tests. */
|
|
114
|
+
async flushSinks() {
|
|
115
|
+
await this.writeQueue;
|
|
116
|
+
await Promise.all(this.sinks.map((s) => s.flush
|
|
117
|
+
? s.flush().catch((err) => console.warn("AuditLog: sink %s flush failed: %s", s.name, err instanceof Error ? err.message : String(err)))
|
|
118
|
+
: Promise.resolve()));
|
|
119
|
+
}
|
|
120
|
+
/** Snapshot of the in-memory ring (most recent last). */
|
|
121
|
+
list(opts = {}) {
|
|
122
|
+
// Coerce non-finite / non-positive limits (NaN from a bad query
|
|
123
|
+
// string, negative, undefined) to the 100 default. Previously
|
|
124
|
+
// NaN propagated through Math.min / Math.max and the comparison
|
|
125
|
+
// `out.length < NaN` was always false, so `?limit=foo` returned
|
|
126
|
+
// an empty array instead of the default page.
|
|
127
|
+
const requested = typeof opts.limit === "number" && Number.isFinite(opts.limit) && opts.limit > 0
|
|
128
|
+
? Math.floor(opts.limit)
|
|
129
|
+
: 100;
|
|
130
|
+
const lim = Math.min(requested, this.cap);
|
|
131
|
+
const out = [];
|
|
132
|
+
for (let i = this.ring.length - 1; i >= 0 && out.length < lim; i--) {
|
|
133
|
+
const e = this.ring[i];
|
|
134
|
+
if (opts.from && e.ts < opts.from)
|
|
135
|
+
continue;
|
|
136
|
+
if (opts.to && e.ts > opts.to)
|
|
137
|
+
continue;
|
|
138
|
+
if (opts.actor && e.actor.sub !== opts.actor)
|
|
139
|
+
continue;
|
|
140
|
+
if (opts.action && e.action !== opts.action)
|
|
141
|
+
continue;
|
|
142
|
+
if (opts.tenant) {
|
|
143
|
+
const entryTenant = e.tenant || "default";
|
|
144
|
+
if (entryTenant !== opts.tenant)
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
out.push(e);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** For verification scripts. */
|
|
152
|
+
get tipHash() { return this.lastHash; }
|
|
153
|
+
get nextSeq() { return this.seq + 1; }
|
|
154
|
+
}
|
|
155
|
+
/** Stable hash of an entry-without-hash, against `prevHash` already present. */
|
|
156
|
+
export function chainHash(entryWithoutHash) {
|
|
157
|
+
// Canonical JSON: recursively sort keys so the digest is reproducible
|
|
158
|
+
// independent of insertion order. Cannot use JSON.stringify's
|
|
159
|
+
// string-array replacer here — that one filters at every level, which
|
|
160
|
+
// would strip nested properties (e.g. actor.sub) and collapse two
|
|
161
|
+
// distinct entries to the same canonical form.
|
|
162
|
+
return createHash("sha256").update(canonicalJson(entryWithoutHash)).digest("hex");
|
|
163
|
+
}
|
|
164
|
+
/** Deterministic JSON for hashing — keys sorted at every depth.
|
|
165
|
+
* Mirrors JSON.stringify's "drop undefined values" rule so a freshly-
|
|
166
|
+
* recorded entry and a round-tripped JSON.parse() of the same entry
|
|
167
|
+
* (which silently drops undefineds) hash identically. */
|
|
168
|
+
function canonicalJson(v) {
|
|
169
|
+
if (v === undefined)
|
|
170
|
+
return ""; // caller must filter at the parent level
|
|
171
|
+
if (v === null || typeof v !== "object")
|
|
172
|
+
return JSON.stringify(v);
|
|
173
|
+
if (Array.isArray(v))
|
|
174
|
+
return "[" + v.map((x) => canonicalJson(x ?? null)).join(",") + "]";
|
|
175
|
+
const o = v;
|
|
176
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
|
|
177
|
+
return "{" +
|
|
178
|
+
keys
|
|
179
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalJson(o[k]))
|
|
180
|
+
.join(",") +
|
|
181
|
+
"}";
|
|
182
|
+
}
|
|
183
|
+
/** Walk a JSONL file end-to-end and confirm every entry's hash matches
|
|
184
|
+
* the chain. Used by the offline verifier CLI. */
|
|
185
|
+
export function verifyChain(entries) {
|
|
186
|
+
let prev = GENESIS_HASH;
|
|
187
|
+
for (let i = 0; i < entries.length; i++) {
|
|
188
|
+
const e = entries[i];
|
|
189
|
+
if (e.prevHash !== prev) {
|
|
190
|
+
return { ok: false, brokenAt: i, reason: `prevHash mismatch (expected ${prev}, got ${e.prevHash})` };
|
|
191
|
+
}
|
|
192
|
+
const { hash: _ignored, ...without } = e;
|
|
193
|
+
const expectedHash = chainHash(without);
|
|
194
|
+
if (e.hash !== expectedHash) {
|
|
195
|
+
return { ok: false, brokenAt: i, reason: `hash mismatch (expected ${expectedHash}, got ${e.hash})` };
|
|
196
|
+
}
|
|
197
|
+
prev = e.hash;
|
|
198
|
+
}
|
|
199
|
+
return { ok: true };
|
|
200
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { AuditLog, verifyChain, chainHash } from "./log.js";
|
|
7
|
+
function sample(seq, prevHash) {
|
|
8
|
+
const base = {
|
|
9
|
+
ts: `2026-05-28T00:00:${String(seq).padStart(2, "0")}Z`,
|
|
10
|
+
seq,
|
|
11
|
+
prevHash,
|
|
12
|
+
actor: { sub: "alice" },
|
|
13
|
+
resource: "sources",
|
|
14
|
+
action: "write",
|
|
15
|
+
method: "POST",
|
|
16
|
+
path: "/api/sources",
|
|
17
|
+
status: 200,
|
|
18
|
+
};
|
|
19
|
+
const hash = chainHash(base);
|
|
20
|
+
return { ...base, hash };
|
|
21
|
+
}
|
|
22
|
+
test("AuditLog in-memory — record + list", async () => {
|
|
23
|
+
const log = new AuditLog();
|
|
24
|
+
await log.record({
|
|
25
|
+
actor: { sub: "alice", name: "Alice" },
|
|
26
|
+
resource: "sources",
|
|
27
|
+
action: "write",
|
|
28
|
+
method: "POST",
|
|
29
|
+
path: "/api/sources",
|
|
30
|
+
status: 200,
|
|
31
|
+
});
|
|
32
|
+
await log.record({
|
|
33
|
+
actor: { sub: "bob" },
|
|
34
|
+
resource: "settings",
|
|
35
|
+
action: "write",
|
|
36
|
+
method: "PUT",
|
|
37
|
+
path: "/api/settings",
|
|
38
|
+
status: 200,
|
|
39
|
+
});
|
|
40
|
+
const entries = log.list();
|
|
41
|
+
assert.equal(entries.length, 2);
|
|
42
|
+
// Most recent first.
|
|
43
|
+
assert.equal(entries[0].actor.sub, "bob");
|
|
44
|
+
assert.equal(entries[1].actor.sub, "alice");
|
|
45
|
+
// Chain is intact: prevHash of entry 2 equals hash of entry 1
|
|
46
|
+
// (entries[1] is the FIRST chronologically here).
|
|
47
|
+
assert.equal(entries[0].prevHash, entries[1].hash);
|
|
48
|
+
});
|
|
49
|
+
test("AuditLog — list filters by actor + action + window", async () => {
|
|
50
|
+
const log = new AuditLog();
|
|
51
|
+
await log.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
52
|
+
await log.record({ actor: { sub: "bob" }, resource: "sources", action: "delete", method: "DELETE", path: "/api/sources/x", status: 200 });
|
|
53
|
+
await log.record({ actor: { sub: "alice" }, resource: "settings", action: "write", method: "PUT", path: "/api/settings", status: 200 });
|
|
54
|
+
assert.equal(log.list({ actor: "alice" }).length, 2);
|
|
55
|
+
assert.equal(log.list({ action: "delete" }).length, 1);
|
|
56
|
+
assert.equal(log.list({ actor: "bob", action: "delete" }).length, 1);
|
|
57
|
+
// Empty window → nothing
|
|
58
|
+
assert.equal(log.list({ from: "2099-01-01", to: "2099-12-31" }).length, 0);
|
|
59
|
+
});
|
|
60
|
+
test("AuditLog — tenant filter scopes results; entries with no tenant treated as 'default'", async () => {
|
|
61
|
+
const log = new AuditLog();
|
|
62
|
+
await log.record({ actor: { sub: "alice" }, tenant: "acme", resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
63
|
+
await log.record({ actor: { sub: "bob" }, tenant: "bigco", resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
64
|
+
// No tenant on this entry → treated as "default" when filtering
|
|
65
|
+
await log.record({ actor: { sub: "carol" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
66
|
+
assert.equal(log.list({ tenant: "acme" }).length, 1);
|
|
67
|
+
assert.equal(log.list({ tenant: "bigco" }).length, 1);
|
|
68
|
+
assert.equal(log.list({ tenant: "default" }).length, 1);
|
|
69
|
+
// No tenant filter → all three visible
|
|
70
|
+
assert.equal(log.list({}).length, 3);
|
|
71
|
+
});
|
|
72
|
+
test("AuditLog — in-memory ring honours cap", async () => {
|
|
73
|
+
const log = new AuditLog({ inMemoryCap: 3 });
|
|
74
|
+
for (let i = 0; i < 10; i++) {
|
|
75
|
+
await log.record({ actor: { sub: `u${i}` }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
76
|
+
}
|
|
77
|
+
const entries = log.list({ limit: 100 });
|
|
78
|
+
assert.equal(entries.length, 3);
|
|
79
|
+
// Only the three most recent should survive.
|
|
80
|
+
assert.deepEqual(entries.map((e) => e.actor.sub), ["u9", "u8", "u7"]);
|
|
81
|
+
});
|
|
82
|
+
test("AuditLog.list — non-finite / non-positive limit falls back to the 100 default", async () => {
|
|
83
|
+
const log = new AuditLog({ inMemoryCap: 10 });
|
|
84
|
+
for (let i = 0; i < 5; i++) {
|
|
85
|
+
await log.record({ actor: { sub: `u${i}` }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
86
|
+
}
|
|
87
|
+
// NaN — what /api/audit?limit=foo previously produced via parseInt
|
|
88
|
+
assert.equal(log.list({ limit: Number.NaN }).length, 5);
|
|
89
|
+
// Negative — no point pretending the caller meant "minus three entries"
|
|
90
|
+
assert.equal(log.list({ limit: -3 }).length, 5);
|
|
91
|
+
// Zero — same. The default keeps the response useful.
|
|
92
|
+
assert.equal(log.list({ limit: 0 }).length, 5);
|
|
93
|
+
// Sanity: a sensible positive limit still constrains the result
|
|
94
|
+
assert.equal(log.list({ limit: 2 }).length, 2);
|
|
95
|
+
// Decimal positive — floored, so 2.9 → 2
|
|
96
|
+
assert.equal(log.list({ limit: 2.9 }).length, 2);
|
|
97
|
+
});
|
|
98
|
+
test("AuditLog — file mode persists and bootstraps", async () => {
|
|
99
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-audit-"));
|
|
100
|
+
const file = join(dir, "audit.jsonl");
|
|
101
|
+
try {
|
|
102
|
+
const log1 = new AuditLog({ file });
|
|
103
|
+
await log1.bootstrap();
|
|
104
|
+
await log1.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
105
|
+
await log1.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
106
|
+
// Allow the write queue to flush.
|
|
107
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
108
|
+
const raw = await readFile(file, "utf8");
|
|
109
|
+
const lines = raw.trim().split("\n");
|
|
110
|
+
assert.equal(lines.length, 2);
|
|
111
|
+
// Restart: bootstrap should pick up seq + lastHash.
|
|
112
|
+
const log2 = new AuditLog({ file });
|
|
113
|
+
await log2.bootstrap();
|
|
114
|
+
assert.equal(log2.nextSeq, 3, "expected nextSeq to resume at 3 after replay");
|
|
115
|
+
const next = await log2.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
|
|
116
|
+
assert.equal(next.seq, 3);
|
|
117
|
+
// prevHash chain continued from the replayed tip.
|
|
118
|
+
const firstChain = JSON.parse(lines[0]);
|
|
119
|
+
const secondChain = JSON.parse(lines[1]);
|
|
120
|
+
assert.equal(secondChain.prevHash, firstChain.hash);
|
|
121
|
+
assert.equal(next.prevHash, secondChain.hash);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
test("verifyChain — accepts a clean chain", () => {
|
|
128
|
+
const e1 = sample(1, "0".repeat(64));
|
|
129
|
+
const e2 = sample(2, e1.hash);
|
|
130
|
+
const r = verifyChain([e1, e2]);
|
|
131
|
+
assert.deepEqual(r, { ok: true });
|
|
132
|
+
});
|
|
133
|
+
test("verifyChain — rejects a flipped prevHash", () => {
|
|
134
|
+
const e1 = sample(1, "0".repeat(64));
|
|
135
|
+
const e2 = { ...sample(2, e1.hash), prevHash: "deadbeef".repeat(8) };
|
|
136
|
+
const r = verifyChain([e1, e2]);
|
|
137
|
+
assert.equal(r.ok, false);
|
|
138
|
+
if (!r.ok)
|
|
139
|
+
assert.equal(r.brokenAt, 1);
|
|
140
|
+
});
|
|
141
|
+
test("verifyChain — rejects an entry whose hash doesn't match its content", () => {
|
|
142
|
+
const e1 = sample(1, "0".repeat(64));
|
|
143
|
+
// Tamper with `actor` AFTER hashing.
|
|
144
|
+
const e1Tampered = { ...e1, actor: { sub: "mallory" } };
|
|
145
|
+
const r = verifyChain([e1Tampered]);
|
|
146
|
+
assert.equal(r.ok, false);
|
|
147
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware that records one audit entry per mutating
|
|
3
|
+
* /api/* request after the response has been sent. Skips read-only
|
|
4
|
+
* methods. In anonymous mode the actor is reported as
|
|
5
|
+
* `anonymous`; in basic mode the session's sub/name are used.
|
|
6
|
+
*/
|
|
7
|
+
import type { RequestHandler } from "express";
|
|
8
|
+
import { AuditLog } from "./log.js";
|
|
9
|
+
export interface AuditMiddlewareConfig {
|
|
10
|
+
audit: AuditLog;
|
|
11
|
+
resource: string;
|
|
12
|
+
action: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build a per-route audit middleware. Pairs cleanly with the RBAC
|
|
16
|
+
* `need(resource, action)` calls already on the route — pass the same
|
|
17
|
+
* (resource, action) pair so the audit entry matches the policy
|
|
18
|
+
* decision that just ran.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildAuditMiddleware(cfg: AuditMiddlewareConfig): RequestHandler;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express middleware that records one audit entry per mutating
|
|
3
|
+
* /api/* request after the response has been sent. Skips read-only
|
|
4
|
+
* methods. In anonymous mode the actor is reported as
|
|
5
|
+
* `anonymous`; in basic mode the session's sub/name are used.
|
|
6
|
+
*/
|
|
7
|
+
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
8
|
+
/**
|
|
9
|
+
* Build a per-route audit middleware. Pairs cleanly with the RBAC
|
|
10
|
+
* `need(resource, action)` calls already on the route — pass the same
|
|
11
|
+
* (resource, action) pair so the audit entry matches the policy
|
|
12
|
+
* decision that just ran.
|
|
13
|
+
*/
|
|
14
|
+
export function buildAuditMiddleware(cfg) {
|
|
15
|
+
return function audit(req, res, next) {
|
|
16
|
+
if (!MUTATING.has(req.method)) {
|
|
17
|
+
next();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.on("finish", () => {
|
|
21
|
+
const sess = req.session;
|
|
22
|
+
// Pick the most-likely identifier from the route params so the
|
|
23
|
+
// audit entry's `target` lines up with what the operator
|
|
24
|
+
// typed. Most routes use `:name`; products use `:id`; fall
|
|
25
|
+
// through if neither (the entry still records method+path).
|
|
26
|
+
const target = typeof req.params?.name === "string"
|
|
27
|
+
? req.params.name
|
|
28
|
+
: typeof req.params?.id === "string" ? req.params.id : undefined;
|
|
29
|
+
cfg.audit
|
|
30
|
+
.record({
|
|
31
|
+
actor: sess
|
|
32
|
+
? { sub: sess.sub, name: sess.name }
|
|
33
|
+
: { sub: "anonymous" },
|
|
34
|
+
tenant: sess?.tenant || "default",
|
|
35
|
+
resource: cfg.resource,
|
|
36
|
+
action: cfg.action,
|
|
37
|
+
method: req.method,
|
|
38
|
+
path: req.path,
|
|
39
|
+
status: res.statusCode,
|
|
40
|
+
ip: req.ip || undefined,
|
|
41
|
+
target,
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
// record() already swallows file errors — this catch only
|
|
45
|
+
// covers the synchronous Promise wiring.
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the redaction-bypass forensic trail.
|
|
3
|
+
*
|
|
4
|
+
* The bypass surface deliberately writes to TWO channels:
|
|
5
|
+
*
|
|
6
|
+
* 1. A sanitised stderr breadcrumb — for SIEM tail-and-forward
|
|
7
|
+
* setups that ingest the container's stdio. It carries the
|
|
8
|
+
* correlationId so an investigator can join it with the
|
|
9
|
+
* tamper-evident chain entry, but it intentionally does NOT
|
|
10
|
+
* include the credential name / token / actor sub — those would
|
|
11
|
+
* land in unstructured operator logs that CodeQL flags as a
|
|
12
|
+
* taint sink.
|
|
13
|
+
*
|
|
14
|
+
* 2. The management-plane audit chain — full identity (actor.sub +
|
|
15
|
+
* tenant), hashed alongside every other mutating /api/* call.
|
|
16
|
+
* Survives a process restart when OMCP_MGMT_AUDIT_FILE is set;
|
|
17
|
+
* otherwise lives in the 500-entry in-memory ring.
|
|
18
|
+
*
|
|
19
|
+
* The bypass code path is small but security-critical: a regression
|
|
20
|
+
* that drops either channel weakens the audit story. The pure
|
|
21
|
+
* helpers below let unit tests pin the shape of both records, plus
|
|
22
|
+
* the boundary properties:
|
|
23
|
+
*
|
|
24
|
+
* - resource === "redaction", action === "bypass" (RBAC vocabulary)
|
|
25
|
+
* - status === 200 on engage, 403 on deny
|
|
26
|
+
* - stderr breadcrumb omits anything credential-shaped
|
|
27
|
+
* - target === args.service (when present)
|
|
28
|
+
*/
|
|
29
|
+
import type { RequestContext } from "../context.js";
|
|
30
|
+
export type BypassEvent = "redaction_bypass_engaged" | "redaction_bypass_denied";
|
|
31
|
+
/** Stderr-breadcrumb payload. JSON-serialised by the caller. */
|
|
32
|
+
export interface BypassBreadcrumb {
|
|
33
|
+
event: BypassEvent;
|
|
34
|
+
ts: string;
|
|
35
|
+
auth: RequestContext["auth"];
|
|
36
|
+
tool: string;
|
|
37
|
+
service: string | null;
|
|
38
|
+
correlationId: string;
|
|
39
|
+
/** Only populated on the deny branch — explains why the request
|
|
40
|
+
* was rejected, never carries the credential name itself. */
|
|
41
|
+
reason?: "credential_not_in_OMCP_KEY_BYPASS_REDACTION";
|
|
42
|
+
}
|
|
43
|
+
export interface BypassAuditParams {
|
|
44
|
+
actor: {
|
|
45
|
+
sub: string;
|
|
46
|
+
};
|
|
47
|
+
tenant: string;
|
|
48
|
+
resource: "redaction";
|
|
49
|
+
action: "bypass";
|
|
50
|
+
method: "MCP";
|
|
51
|
+
path: string;
|
|
52
|
+
status: 200 | 403;
|
|
53
|
+
target?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Shape the stderr breadcrumb. Deliberately credential-free; the
|
|
56
|
+
* joining key to the audit chain is the correlationId. */
|
|
57
|
+
export declare function buildBypassBreadcrumb(event: BypassEvent, ctx: RequestContext, args: unknown, opts?: {
|
|
58
|
+
tool?: string;
|
|
59
|
+
nowIso?: string;
|
|
60
|
+
}): BypassBreadcrumb;
|
|
61
|
+
/** Shape the management-plane audit record. Engaged = 200 (the
|
|
62
|
+
* bypass actually fired), denied = 403 (the agent asked but the
|
|
63
|
+
* credential wasn't allow-listed). Either way the chain captures
|
|
64
|
+
* the attempt. */
|
|
65
|
+
export declare function buildBypassAuditParams(engaged: boolean, ctx: RequestContext, args: unknown, opts?: {
|
|
66
|
+
tool?: string;
|
|
67
|
+
}): BypassAuditParams;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the redaction-bypass forensic trail.
|
|
3
|
+
*
|
|
4
|
+
* The bypass surface deliberately writes to TWO channels:
|
|
5
|
+
*
|
|
6
|
+
* 1. A sanitised stderr breadcrumb — for SIEM tail-and-forward
|
|
7
|
+
* setups that ingest the container's stdio. It carries the
|
|
8
|
+
* correlationId so an investigator can join it with the
|
|
9
|
+
* tamper-evident chain entry, but it intentionally does NOT
|
|
10
|
+
* include the credential name / token / actor sub — those would
|
|
11
|
+
* land in unstructured operator logs that CodeQL flags as a
|
|
12
|
+
* taint sink.
|
|
13
|
+
*
|
|
14
|
+
* 2. The management-plane audit chain — full identity (actor.sub +
|
|
15
|
+
* tenant), hashed alongside every other mutating /api/* call.
|
|
16
|
+
* Survives a process restart when OMCP_MGMT_AUDIT_FILE is set;
|
|
17
|
+
* otherwise lives in the 500-entry in-memory ring.
|
|
18
|
+
*
|
|
19
|
+
* The bypass code path is small but security-critical: a regression
|
|
20
|
+
* that drops either channel weakens the audit story. The pure
|
|
21
|
+
* helpers below let unit tests pin the shape of both records, plus
|
|
22
|
+
* the boundary properties:
|
|
23
|
+
*
|
|
24
|
+
* - resource === "redaction", action === "bypass" (RBAC vocabulary)
|
|
25
|
+
* - status === 200 on engage, 403 on deny
|
|
26
|
+
* - stderr breadcrumb omits anything credential-shaped
|
|
27
|
+
* - target === args.service (when present)
|
|
28
|
+
*/
|
|
29
|
+
/** Shape the stderr breadcrumb. Deliberately credential-free; the
|
|
30
|
+
* joining key to the audit chain is the correlationId. */
|
|
31
|
+
export function buildBypassBreadcrumb(event, ctx, args, opts = {}) {
|
|
32
|
+
const out = {
|
|
33
|
+
event,
|
|
34
|
+
ts: opts.nowIso ?? new Date().toISOString(),
|
|
35
|
+
auth: ctx.auth,
|
|
36
|
+
tool: opts.tool ?? "query_logs",
|
|
37
|
+
service: args?.service ?? null,
|
|
38
|
+
correlationId: ctx.correlationId,
|
|
39
|
+
};
|
|
40
|
+
if (event === "redaction_bypass_denied") {
|
|
41
|
+
out.reason = "credential_not_in_OMCP_KEY_BYPASS_REDACTION";
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Shape the management-plane audit record. Engaged = 200 (the
|
|
46
|
+
* bypass actually fired), denied = 403 (the agent asked but the
|
|
47
|
+
* credential wasn't allow-listed). Either way the chain captures
|
|
48
|
+
* the attempt. */
|
|
49
|
+
export function buildBypassAuditParams(engaged, ctx, args, opts = {}) {
|
|
50
|
+
const tool = opts.tool ?? "query_logs";
|
|
51
|
+
const params = {
|
|
52
|
+
actor: { sub: ctx.principalId },
|
|
53
|
+
tenant: ctx.tenant,
|
|
54
|
+
resource: "redaction",
|
|
55
|
+
action: "bypass",
|
|
56
|
+
method: "MCP",
|
|
57
|
+
path: `/mcp/${tool}`,
|
|
58
|
+
status: engaged ? 200 : 403,
|
|
59
|
+
};
|
|
60
|
+
const service = args?.service;
|
|
61
|
+
if (typeof service === "string" && service)
|
|
62
|
+
params.target = service;
|
|
63
|
+
return params;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildBypassBreadcrumb, buildBypassAuditParams, } from "./redaction-bypass.js";
|
|
4
|
+
function ctxFor(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
principalId: "agent",
|
|
7
|
+
auth: "apikey",
|
|
8
|
+
tenant: "acme",
|
|
9
|
+
correlationId: "corr-123",
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
test("buildBypassBreadcrumb — engaged path carries auth + tool + service + correlationId, NO credential fields", () => {
|
|
14
|
+
const bc = buildBypassBreadcrumb("redaction_bypass_engaged", ctxFor(), { service: "payment-service" }, { nowIso: "2026-06-03T00:00:00.000Z" });
|
|
15
|
+
assert.equal(bc.event, "redaction_bypass_engaged");
|
|
16
|
+
assert.equal(bc.ts, "2026-06-03T00:00:00.000Z");
|
|
17
|
+
assert.equal(bc.auth, "apikey");
|
|
18
|
+
assert.equal(bc.tool, "query_logs");
|
|
19
|
+
assert.equal(bc.service, "payment-service");
|
|
20
|
+
assert.equal(bc.correlationId, "corr-123");
|
|
21
|
+
assert.equal(bc.reason, undefined, "engaged path must not carry a deny reason");
|
|
22
|
+
// Guard: no credential-shaped fields. If a future edit adds the
|
|
23
|
+
// principalId / token / cred-name to the breadcrumb, this fails.
|
|
24
|
+
// We match KEY names (followed by ":") not value-string contents,
|
|
25
|
+
// so the legitimate auth: "apikey" field survives.
|
|
26
|
+
const serialised = JSON.stringify(bc);
|
|
27
|
+
assert.doesNotMatch(serialised, /"(principalId|token|credential|apiKey|api_key|sub|name)"\s*:/i);
|
|
28
|
+
});
|
|
29
|
+
test("buildBypassBreadcrumb — denied path adds the deny reason; still no credential leak", () => {
|
|
30
|
+
const bc = buildBypassBreadcrumb("redaction_bypass_denied", ctxFor({ principalId: "ci-bot" }), { service: "svc" });
|
|
31
|
+
assert.equal(bc.event, "redaction_bypass_denied");
|
|
32
|
+
assert.equal(bc.reason, "credential_not_in_OMCP_KEY_BYPASS_REDACTION");
|
|
33
|
+
const serialised = JSON.stringify(bc);
|
|
34
|
+
assert.doesNotMatch(serialised, /ci-bot/, "principalId must not appear in stderr breadcrumb");
|
|
35
|
+
});
|
|
36
|
+
test("buildBypassBreadcrumb — missing service becomes explicit null (not undefined)", () => {
|
|
37
|
+
const bc = buildBypassBreadcrumb("redaction_bypass_engaged", ctxFor(), {});
|
|
38
|
+
// Important: JSON.stringify omits undefined keys; null serialises
|
|
39
|
+
// as `"service":null`, which downstream SIEM parsers can match on.
|
|
40
|
+
assert.equal(bc.service, null);
|
|
41
|
+
assert.match(JSON.stringify(bc), /"service":null/);
|
|
42
|
+
});
|
|
43
|
+
test("buildBypassAuditParams — engaged status 200, RBAC vocabulary, full identity", () => {
|
|
44
|
+
const p = buildBypassAuditParams(true, ctxFor(), { service: "payment-service" });
|
|
45
|
+
assert.equal(p.status, 200);
|
|
46
|
+
assert.equal(p.resource, "redaction");
|
|
47
|
+
assert.equal(p.action, "bypass");
|
|
48
|
+
assert.equal(p.method, "MCP");
|
|
49
|
+
assert.equal(p.path, "/mcp/query_logs");
|
|
50
|
+
assert.equal(p.actor.sub, "agent");
|
|
51
|
+
assert.equal(p.tenant, "acme");
|
|
52
|
+
assert.equal(p.target, "payment-service");
|
|
53
|
+
});
|
|
54
|
+
test("buildBypassAuditParams — denied status 403 (not 200) so audit-log readers can distinguish attempt vs success", () => {
|
|
55
|
+
const p = buildBypassAuditParams(false, ctxFor(), { service: "svc" });
|
|
56
|
+
assert.equal(p.status, 403);
|
|
57
|
+
// Critical: the deny path must still record actor.sub + tenant so
|
|
58
|
+
// an investigator can see WHO tried, not just THAT someone tried.
|
|
59
|
+
assert.equal(p.actor.sub, "agent");
|
|
60
|
+
assert.equal(p.tenant, "acme");
|
|
61
|
+
});
|
|
62
|
+
test("buildBypassAuditParams — omits target when args.service is absent (not empty-string)", () => {
|
|
63
|
+
const p = buildBypassAuditParams(true, ctxFor(), {});
|
|
64
|
+
assert.equal(p.target, undefined);
|
|
65
|
+
// Defence: an empty-string service should not become an audit target.
|
|
66
|
+
const p2 = buildBypassAuditParams(true, ctxFor(), { service: "" });
|
|
67
|
+
assert.equal(p2.target, undefined);
|
|
68
|
+
});
|
|
69
|
+
test("buildBypassAuditParams — tool name flows into the path so audit readers can filter per-tool", () => {
|
|
70
|
+
const p = buildBypassAuditParams(true, ctxFor(), { service: "s" }, { tool: "query_metrics" });
|
|
71
|
+
assert.equal(p.path, "/mcp/query_metrics");
|
|
72
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AuditEntry } from "../log.js";
|
|
2
|
+
/**
|
|
3
|
+
* A destination that receives every chained audit entry. The on-disk
|
|
4
|
+
* JSONL chain stays the authoritative master (so the hash chain is
|
|
5
|
+
* never split-brain across sinks); sinks are mirrors for SIEM /
|
|
6
|
+
* archive / webhook fan-out.
|
|
7
|
+
*
|
|
8
|
+
* write() must NEVER throw — sinks log+swallow internally. A sink that
|
|
9
|
+
* dies must not take down the management plane.
|
|
10
|
+
*/
|
|
11
|
+
export interface AuditSink {
|
|
12
|
+
/** Stable identifier, used in logs and env selection. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** Persist one chained entry. Best-effort; failures are logged. */
|
|
15
|
+
write(entry: AuditEntry): Promise<void>;
|
|
16
|
+
/** Flush any buffered state. Called on SIGTERM and in tests. */
|
|
17
|
+
flush?(): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|