@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.
Files changed (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. 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 {};