@thotischner/observability-mcp 1.8.1 → 3.0.1
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/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 +9 -0
- package/dist/audit/log.js +20 -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/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -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 +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -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 +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- 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/endpoints.js +44 -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 +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -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 +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -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 +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -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.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -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 +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- 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/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 +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -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 +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- 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 +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -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/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -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/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -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 +15 -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/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- 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 +33 -11
- package/dist/tools/topology.test.js +45 -0
- 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/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -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 +2529 -145
- package/package.json +13 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface AnomalyRecord {
|
|
2
|
+
/** ISO-8601 timestamp (the moment the score was computed). */
|
|
3
|
+
ts: string;
|
|
4
|
+
service: string;
|
|
5
|
+
tenant: string;
|
|
6
|
+
/** Anomaly score, 0..1 typically. Numeric — the sample written to the TSDB. */
|
|
7
|
+
score: number;
|
|
8
|
+
/** "mad" | "seasonality" | "correlator" — the method that produced the score. */
|
|
9
|
+
method: string;
|
|
10
|
+
/** "info" | "warn" | "critical". */
|
|
11
|
+
severity: string;
|
|
12
|
+
/** Optional source label (which signal the score applied to). */
|
|
13
|
+
signal?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AnomalyHistoryConfig {
|
|
16
|
+
/** Remote-write URL. Setting this enables the history sink. */
|
|
17
|
+
url?: string;
|
|
18
|
+
/** Comma-separated key=value pairs for extra request headers. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/** Bearer token forwarded as Authorization header. */
|
|
21
|
+
bearerToken?: string;
|
|
22
|
+
/** Flush interval in ms. Default 10 000. */
|
|
23
|
+
flushIntervalMs?: number;
|
|
24
|
+
/** Max buffer size before a synchronous flush. Default 500. */
|
|
25
|
+
maxBufferSize?: number;
|
|
26
|
+
/** Per-request timeout (ms). Default 5 000. */
|
|
27
|
+
requestTimeoutMs?: number;
|
|
28
|
+
/** Inject fetch for tests. */
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
export declare function fromEnv(env?: NodeJS.ProcessEnv): AnomalyHistoryConfig;
|
|
32
|
+
/**
|
|
33
|
+
* In-process buffer + remote-write client. Use one instance per
|
|
34
|
+
* gateway process; `record()` is called from the anomaly detector;
|
|
35
|
+
* `flush()` runs on the interval AND on SIGTERM.
|
|
36
|
+
*/
|
|
37
|
+
export declare class AnomalyHistory {
|
|
38
|
+
private readonly url?;
|
|
39
|
+
private readonly headers;
|
|
40
|
+
private readonly bearerToken?;
|
|
41
|
+
private readonly flushIntervalMs;
|
|
42
|
+
private readonly maxBufferSize;
|
|
43
|
+
private readonly requestTimeoutMs;
|
|
44
|
+
private readonly fetchImpl;
|
|
45
|
+
private buffer;
|
|
46
|
+
private timer?;
|
|
47
|
+
private flushing;
|
|
48
|
+
constructor(cfg?: AnomalyHistoryConfig);
|
|
49
|
+
/** Whether the history sink is enabled (URL configured). */
|
|
50
|
+
isEnabled(): boolean;
|
|
51
|
+
/** Begin the flush timer. Idempotent. No-op when sink is disabled. */
|
|
52
|
+
start(): void;
|
|
53
|
+
/** Stop the flush timer + flush one last time. */
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
/** Add one anomaly to the buffer. Silently drops when disabled.
|
|
56
|
+
* Triggers a synchronous flush if the buffer crosses maxBufferSize. */
|
|
57
|
+
record(entry: AnomalyRecord): Promise<void>;
|
|
58
|
+
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
59
|
+
* buffer on success OR failure — history is best-effort. */
|
|
60
|
+
flush(): Promise<void>;
|
|
61
|
+
/** Test seam: number of currently-buffered entries. */
|
|
62
|
+
bufferSize(): number;
|
|
63
|
+
/** Visible-for-test: format the buffer as a JSON payload mirroring
|
|
64
|
+
* the Prometheus remote-write metric shape. Real remote-write uses
|
|
65
|
+
* Snappy-compressed protobuf — we ship JSON here as a portable
|
|
66
|
+
* baseline that any TSDB-receiving collector can ingest via a tiny
|
|
67
|
+
* shim. A protobuf+Snappy fast path is a follow-up. */
|
|
68
|
+
formatBatch(batch: AnomalyRecord[]): unknown;
|
|
69
|
+
private sendBatch;
|
|
70
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Anomaly history — persists per-anomaly scores to an external TSDB
|
|
2
|
+
// via Prometheus remote-write so post-mortems can replay what the
|
|
3
|
+
// gateway saw at a specific time. Opt-in via
|
|
4
|
+
// OMCP_ANOMALY_HISTORY_REMOTE_WRITE; default OFF preserves the
|
|
5
|
+
// pre-F15 "scores live in process memory only" behaviour.
|
|
6
|
+
//
|
|
7
|
+
// Wire format: a single time-series sample per recorded anomaly,
|
|
8
|
+
// labelled with service / tenant / signal / method (mad / seasonality
|
|
9
|
+
// / correlator) / severity. The TSDB-side query then becomes a
|
|
10
|
+
// `omcp_anomaly_score{service="payment"}` PromQL.
|
|
11
|
+
//
|
|
12
|
+
// Buffering: writes are batched on a fixed flush interval so the
|
|
13
|
+
// remote-write side never sees a request per anomaly. Failure to
|
|
14
|
+
// flush logs once and drops the buffer — the gateway must never
|
|
15
|
+
// block on a sick TSDB.
|
|
16
|
+
export function fromEnv(env = process.env) {
|
|
17
|
+
const url = env.OMCP_ANOMALY_HISTORY_REMOTE_WRITE?.trim();
|
|
18
|
+
const headers = {};
|
|
19
|
+
const raw = env.OMCP_ANOMALY_HISTORY_HEADERS;
|
|
20
|
+
if (raw) {
|
|
21
|
+
for (const part of raw.split(",")) {
|
|
22
|
+
const [k, ...rest] = part.split("=");
|
|
23
|
+
const key = k?.trim();
|
|
24
|
+
const value = rest.join("=").trim();
|
|
25
|
+
if (key && value)
|
|
26
|
+
headers[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
url: url || undefined,
|
|
31
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
32
|
+
bearerToken: env.OMCP_ANOMALY_HISTORY_TOKEN?.trim() || undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* In-process buffer + remote-write client. Use one instance per
|
|
37
|
+
* gateway process; `record()` is called from the anomaly detector;
|
|
38
|
+
* `flush()` runs on the interval AND on SIGTERM.
|
|
39
|
+
*/
|
|
40
|
+
export class AnomalyHistory {
|
|
41
|
+
url;
|
|
42
|
+
headers;
|
|
43
|
+
bearerToken;
|
|
44
|
+
flushIntervalMs;
|
|
45
|
+
maxBufferSize;
|
|
46
|
+
requestTimeoutMs;
|
|
47
|
+
fetchImpl;
|
|
48
|
+
buffer = [];
|
|
49
|
+
timer;
|
|
50
|
+
flushing = false;
|
|
51
|
+
constructor(cfg = {}) {
|
|
52
|
+
this.url = cfg.url;
|
|
53
|
+
this.headers = cfg.headers ?? {};
|
|
54
|
+
this.bearerToken = cfg.bearerToken;
|
|
55
|
+
this.flushIntervalMs = cfg.flushIntervalMs ?? 10_000;
|
|
56
|
+
this.maxBufferSize = cfg.maxBufferSize ?? 500;
|
|
57
|
+
this.requestTimeoutMs = cfg.requestTimeoutMs ?? 5_000;
|
|
58
|
+
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
|
59
|
+
}
|
|
60
|
+
/** Whether the history sink is enabled (URL configured). */
|
|
61
|
+
isEnabled() {
|
|
62
|
+
return Boolean(this.url);
|
|
63
|
+
}
|
|
64
|
+
/** Begin the flush timer. Idempotent. No-op when sink is disabled. */
|
|
65
|
+
start() {
|
|
66
|
+
if (!this.isEnabled())
|
|
67
|
+
return;
|
|
68
|
+
if (this.timer)
|
|
69
|
+
return;
|
|
70
|
+
this.timer = setInterval(() => {
|
|
71
|
+
void this.flush().catch(() => {
|
|
72
|
+
/* swallow — flush() already logs */
|
|
73
|
+
});
|
|
74
|
+
}, this.flushIntervalMs);
|
|
75
|
+
// unref so the timer doesn't keep the process alive when the
|
|
76
|
+
// main loop is otherwise idle.
|
|
77
|
+
this.timer.unref?.();
|
|
78
|
+
}
|
|
79
|
+
/** Stop the flush timer + flush one last time. */
|
|
80
|
+
async stop() {
|
|
81
|
+
if (this.timer)
|
|
82
|
+
clearInterval(this.timer);
|
|
83
|
+
this.timer = undefined;
|
|
84
|
+
if (this.isEnabled())
|
|
85
|
+
await this.flush().catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
/** Add one anomaly to the buffer. Silently drops when disabled.
|
|
88
|
+
* Triggers a synchronous flush if the buffer crosses maxBufferSize. */
|
|
89
|
+
async record(entry) {
|
|
90
|
+
if (!this.isEnabled())
|
|
91
|
+
return;
|
|
92
|
+
this.buffer.push(entry);
|
|
93
|
+
if (this.buffer.length >= this.maxBufferSize) {
|
|
94
|
+
await this.flush().catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
98
|
+
* buffer on success OR failure — history is best-effort. */
|
|
99
|
+
async flush() {
|
|
100
|
+
if (!this.isEnabled() || this.buffer.length === 0 || this.flushing)
|
|
101
|
+
return;
|
|
102
|
+
this.flushing = true;
|
|
103
|
+
const batch = this.buffer.splice(0, this.buffer.length);
|
|
104
|
+
try {
|
|
105
|
+
await this.sendBatch(batch);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.warn("AnomalyHistory: flush dropped %d entries (%s). History is best-effort; check the remote-write endpoint.", batch.length, err instanceof Error ? err.message : String(err));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.flushing = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Test seam: number of currently-buffered entries. */
|
|
115
|
+
bufferSize() {
|
|
116
|
+
return this.buffer.length;
|
|
117
|
+
}
|
|
118
|
+
/** Visible-for-test: format the buffer as a JSON payload mirroring
|
|
119
|
+
* the Prometheus remote-write metric shape. Real remote-write uses
|
|
120
|
+
* Snappy-compressed protobuf — we ship JSON here as a portable
|
|
121
|
+
* baseline that any TSDB-receiving collector can ingest via a tiny
|
|
122
|
+
* shim. A protobuf+Snappy fast path is a follow-up. */
|
|
123
|
+
formatBatch(batch) {
|
|
124
|
+
return {
|
|
125
|
+
// The schema mirrors prometheus.WriteRequest as a JSON object
|
|
126
|
+
// so collectors that already know "labels + samples" can ingest
|
|
127
|
+
// it directly.
|
|
128
|
+
timeseries: batch.map((r) => ({
|
|
129
|
+
labels: {
|
|
130
|
+
__name__: "omcp_anomaly_score",
|
|
131
|
+
service: r.service,
|
|
132
|
+
tenant: r.tenant,
|
|
133
|
+
method: r.method,
|
|
134
|
+
severity: r.severity,
|
|
135
|
+
...(r.signal ? { signal: r.signal } : {}),
|
|
136
|
+
},
|
|
137
|
+
samples: [{ value: r.score, timestamp: Date.parse(r.ts) }],
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async sendBatch(batch) {
|
|
142
|
+
if (!this.url)
|
|
143
|
+
return;
|
|
144
|
+
const body = JSON.stringify(this.formatBatch(batch));
|
|
145
|
+
const headers = {
|
|
146
|
+
"content-type": "application/json",
|
|
147
|
+
...this.headers,
|
|
148
|
+
};
|
|
149
|
+
if (this.bearerToken)
|
|
150
|
+
headers["authorization"] = `Bearer ${this.bearerToken}`;
|
|
151
|
+
const ctl = new AbortController();
|
|
152
|
+
const t = setTimeout(() => ctl.abort(), this.requestTimeoutMs).unref?.();
|
|
153
|
+
try {
|
|
154
|
+
const res = await this.fetchImpl(this.url, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers,
|
|
157
|
+
body,
|
|
158
|
+
signal: ctl.signal,
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const snippet = (await res.text().catch(() => "")).slice(0, 200);
|
|
162
|
+
throw new Error(`remote-write returned ${res.status} ${res.statusText}: ${snippet}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (typeof t === "object" && t)
|
|
167
|
+
clearTimeout(t);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { AnomalyHistory, fromEnv } from "./history.js";
|
|
4
|
+
function entry(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
ts: "2026-06-06T00:00:00.000Z",
|
|
7
|
+
service: "payment",
|
|
8
|
+
tenant: "default",
|
|
9
|
+
score: 0.87,
|
|
10
|
+
method: "mad",
|
|
11
|
+
severity: "warn",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function captureFetch(captureBody) {
|
|
16
|
+
const f = (async (_url, init) => {
|
|
17
|
+
if (captureBody) {
|
|
18
|
+
captureBody.body = JSON.parse(init.body);
|
|
19
|
+
captureBody.headers = init.headers;
|
|
20
|
+
}
|
|
21
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
22
|
+
});
|
|
23
|
+
return f;
|
|
24
|
+
}
|
|
25
|
+
test("fromEnv: missing URL → disabled config", () => {
|
|
26
|
+
assert.equal(fromEnv({}).url, undefined);
|
|
27
|
+
});
|
|
28
|
+
test("fromEnv: parses URL + headers + token", () => {
|
|
29
|
+
const cfg = fromEnv({
|
|
30
|
+
OMCP_ANOMALY_HISTORY_REMOTE_WRITE: "https://tsdb/api/v1/write",
|
|
31
|
+
OMCP_ANOMALY_HISTORY_HEADERS: "x-scope=tenant-a,x-extra=foo=bar",
|
|
32
|
+
OMCP_ANOMALY_HISTORY_TOKEN: "secret-token",
|
|
33
|
+
});
|
|
34
|
+
assert.equal(cfg.url, "https://tsdb/api/v1/write");
|
|
35
|
+
assert.deepEqual(cfg.headers, { "x-scope": "tenant-a", "x-extra": "foo=bar" });
|
|
36
|
+
assert.equal(cfg.bearerToken, "secret-token");
|
|
37
|
+
});
|
|
38
|
+
test("isEnabled: false without url, true with url", () => {
|
|
39
|
+
assert.equal(new AnomalyHistory({}).isEnabled(), false);
|
|
40
|
+
assert.equal(new AnomalyHistory({ url: "https://x" }).isEnabled(), true);
|
|
41
|
+
});
|
|
42
|
+
test("record: disabled instance silently drops, buffer stays empty", async () => {
|
|
43
|
+
const h = new AnomalyHistory({});
|
|
44
|
+
await h.record(entry());
|
|
45
|
+
assert.equal(h.bufferSize(), 0);
|
|
46
|
+
});
|
|
47
|
+
test("record + flush: posts the buffer as a remote-write-shaped JSON", async () => {
|
|
48
|
+
const captured = {};
|
|
49
|
+
const h = new AnomalyHistory({
|
|
50
|
+
url: "https://tsdb/api/v1/write",
|
|
51
|
+
fetchImpl: captureFetch(captured),
|
|
52
|
+
});
|
|
53
|
+
await h.record(entry({ score: 0.9 }));
|
|
54
|
+
await h.record(entry({ service: "orders", score: 0.42 }));
|
|
55
|
+
await h.flush();
|
|
56
|
+
const body = captured.body;
|
|
57
|
+
assert.equal(body.timeseries.length, 2);
|
|
58
|
+
assert.equal(body.timeseries[0].labels.__name__, "omcp_anomaly_score");
|
|
59
|
+
assert.equal(body.timeseries[0].labels.service, "payment");
|
|
60
|
+
assert.equal(body.timeseries[0].samples[0].value, 0.9);
|
|
61
|
+
assert.equal(body.timeseries[1].labels.service, "orders");
|
|
62
|
+
});
|
|
63
|
+
test("flush: bearer token forwarded as Authorization header", async () => {
|
|
64
|
+
const captured = {};
|
|
65
|
+
const h = new AnomalyHistory({
|
|
66
|
+
url: "https://tsdb/api/v1/write",
|
|
67
|
+
bearerToken: "tok-abc",
|
|
68
|
+
fetchImpl: captureFetch(captured),
|
|
69
|
+
});
|
|
70
|
+
await h.record(entry());
|
|
71
|
+
await h.flush();
|
|
72
|
+
assert.equal(captured.headers["authorization"], "Bearer tok-abc");
|
|
73
|
+
});
|
|
74
|
+
test("flush: clears the buffer on success", async () => {
|
|
75
|
+
const h = new AnomalyHistory({
|
|
76
|
+
url: "https://tsdb/api/v1/write",
|
|
77
|
+
fetchImpl: captureFetch(),
|
|
78
|
+
});
|
|
79
|
+
await h.record(entry());
|
|
80
|
+
await h.record(entry());
|
|
81
|
+
assert.equal(h.bufferSize(), 2);
|
|
82
|
+
await h.flush();
|
|
83
|
+
assert.equal(h.bufferSize(), 0);
|
|
84
|
+
});
|
|
85
|
+
test("flush: HTTP error logs + drops buffer (does NOT retry)", async () => {
|
|
86
|
+
const f = (async () => ({
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 503,
|
|
89
|
+
statusText: "Service Unavailable",
|
|
90
|
+
text: async () => "tsdb overloaded",
|
|
91
|
+
}));
|
|
92
|
+
const h = new AnomalyHistory({
|
|
93
|
+
url: "https://tsdb/api/v1/write",
|
|
94
|
+
fetchImpl: f,
|
|
95
|
+
});
|
|
96
|
+
await h.record(entry());
|
|
97
|
+
await h.flush();
|
|
98
|
+
// Best-effort policy: buffer cleared even on error.
|
|
99
|
+
assert.equal(h.bufferSize(), 0);
|
|
100
|
+
});
|
|
101
|
+
test("flush: empty buffer is a no-op (no fetch call)", async () => {
|
|
102
|
+
let called = 0;
|
|
103
|
+
const f = (async () => {
|
|
104
|
+
called++;
|
|
105
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
106
|
+
});
|
|
107
|
+
const h = new AnomalyHistory({
|
|
108
|
+
url: "https://tsdb/api/v1/write",
|
|
109
|
+
fetchImpl: f,
|
|
110
|
+
});
|
|
111
|
+
await h.flush();
|
|
112
|
+
assert.equal(called, 0);
|
|
113
|
+
});
|
|
114
|
+
test("record: synchronous auto-flush triggers when buffer crosses maxBufferSize", async () => {
|
|
115
|
+
let calls = 0;
|
|
116
|
+
const f = (async () => {
|
|
117
|
+
calls++;
|
|
118
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => "" };
|
|
119
|
+
});
|
|
120
|
+
const h = new AnomalyHistory({
|
|
121
|
+
url: "https://tsdb/api/v1/write",
|
|
122
|
+
fetchImpl: f,
|
|
123
|
+
maxBufferSize: 3,
|
|
124
|
+
});
|
|
125
|
+
await h.record(entry());
|
|
126
|
+
await h.record(entry());
|
|
127
|
+
assert.equal(calls, 0);
|
|
128
|
+
await h.record(entry()); // crosses threshold → auto-flush
|
|
129
|
+
assert.equal(calls, 1);
|
|
130
|
+
assert.equal(h.bufferSize(), 0);
|
|
131
|
+
});
|
|
132
|
+
test("formatBatch: omits empty optional signal label", async () => {
|
|
133
|
+
const h = new AnomalyHistory({ url: "https://x" });
|
|
134
|
+
const out = h.formatBatch([entry({ signal: "" })]);
|
|
135
|
+
assert.equal("signal" in out.timeseries[0].labels, false);
|
|
136
|
+
});
|
|
137
|
+
test("formatBatch: includes signal label when set", () => {
|
|
138
|
+
const h = new AnomalyHistory({ url: "https://x" });
|
|
139
|
+
const out = h.formatBatch([entry({ signal: "request_latency" })]);
|
|
140
|
+
assert.equal(out.timeseries[0].labels.signal, "request_latency");
|
|
141
|
+
});
|
package/dist/audit/log.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* shows something even without persistence configured. This is the
|
|
17
17
|
* default in the demo / single-user case.
|
|
18
18
|
*/
|
|
19
|
+
import type { AuditSink } from "./sinks/types.js";
|
|
19
20
|
export interface AuditEntry {
|
|
20
21
|
/** RFC-3339 UTC timestamp. */
|
|
21
22
|
ts: string;
|
|
@@ -51,11 +52,17 @@ export interface AuditLogConfig {
|
|
|
51
52
|
file?: string;
|
|
52
53
|
/** How many recent entries to keep in memory regardless of file mode. */
|
|
53
54
|
inMemoryCap?: number;
|
|
55
|
+
/** Mirror every chained entry to one or more external sinks
|
|
56
|
+
* (Splunk/SIEM webhook, S3 archive, ...). The on-disk JSONL chain
|
|
57
|
+
* stays the authoritative master — sinks are best-effort mirrors
|
|
58
|
+
* and never block record(). */
|
|
59
|
+
sinks?: AuditSink[];
|
|
54
60
|
}
|
|
55
61
|
export declare const DEFAULT_IN_MEMORY_CAP = 500;
|
|
56
62
|
export declare class AuditLog {
|
|
57
63
|
private readonly cap;
|
|
58
64
|
private readonly file;
|
|
65
|
+
private readonly sinks;
|
|
59
66
|
private ring;
|
|
60
67
|
private lastHash;
|
|
61
68
|
private seq;
|
|
@@ -73,6 +80,8 @@ export declare class AuditLog {
|
|
|
73
80
|
* once enqueued; persistence to disk completes asynchronously.
|
|
74
81
|
*/
|
|
75
82
|
record(input: Omit<AuditEntry, "ts" | "seq" | "prevHash" | "hash">): Promise<AuditEntry>;
|
|
83
|
+
/** Flush every configured sink. Called from SIGTERM and tests. */
|
|
84
|
+
flushSinks(): Promise<void>;
|
|
76
85
|
/** Snapshot of the in-memory ring (most recent last). */
|
|
77
86
|
list(opts?: {
|
|
78
87
|
from?: string;
|
package/dist/audit/log.js
CHANGED
|
@@ -23,6 +23,7 @@ const GENESIS_HASH = "0".repeat(64);
|
|
|
23
23
|
export class AuditLog {
|
|
24
24
|
cap;
|
|
25
25
|
file;
|
|
26
|
+
sinks;
|
|
26
27
|
ring = [];
|
|
27
28
|
lastHash = GENESIS_HASH;
|
|
28
29
|
seq = 0;
|
|
@@ -31,6 +32,7 @@ export class AuditLog {
|
|
|
31
32
|
constructor(cfg = {}) {
|
|
32
33
|
this.cap = cfg.inMemoryCap ?? DEFAULT_IN_MEMORY_CAP;
|
|
33
34
|
this.file = cfg.file;
|
|
35
|
+
this.sinks = cfg.sinks ?? [];
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* If a file is configured, replay it to recover seq + lastHash so a
|
|
@@ -95,8 +97,26 @@ export class AuditLog {
|
|
|
95
97
|
// strictly better than crashing the management plane.
|
|
96
98
|
}));
|
|
97
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
|
+
}
|
|
98
111
|
return entry;
|
|
99
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
|
+
}
|
|
100
120
|
/** Snapshot of the in-memory ring (most recent last). */
|
|
101
121
|
list(opts = {}) {
|
|
102
122
|
// Coerce non-finite / non-positive limits (NaN from a bad query
|
|
@@ -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
|
+
});
|