@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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildOpenApiSpec } from "./openapi.js";
|
|
4
|
+
test("openapi — every user-visible /api path is documented", () => {
|
|
5
|
+
// If a future PR adds an endpoint here, also document it in
|
|
6
|
+
// openapi.ts. The list intentionally excludes admin-only routes the
|
|
7
|
+
// spec deliberately keeps internal — add to that allow-list rather
|
|
8
|
+
// than mass-adding routes to the spec.
|
|
9
|
+
const documentedRoutes = [
|
|
10
|
+
"/api/health",
|
|
11
|
+
"/api/services",
|
|
12
|
+
"/api/sources",
|
|
13
|
+
"/api/sources/{name}",
|
|
14
|
+
"/api/sources/{name}/metrics",
|
|
15
|
+
"/api/source-types",
|
|
16
|
+
"/api/tools/registry",
|
|
17
|
+
"/api/settings",
|
|
18
|
+
"/api/health-thresholds",
|
|
19
|
+
"/api/me",
|
|
20
|
+
"/api/auth/login",
|
|
21
|
+
"/api/auth/logout",
|
|
22
|
+
"/api/auth/oidc/login",
|
|
23
|
+
"/api/auth/oidc/callback",
|
|
24
|
+
"/api/auth/oidc/logout",
|
|
25
|
+
"/api/audit",
|
|
26
|
+
"/api/usage",
|
|
27
|
+
"/api/policy",
|
|
28
|
+
"/api/subjects",
|
|
29
|
+
"/api/users/{username}/roles",
|
|
30
|
+
"/api/policy/roles/{name}",
|
|
31
|
+
"/api/catalog",
|
|
32
|
+
"/api/products",
|
|
33
|
+
"/api/products/{id}",
|
|
34
|
+
"/api/products/{id}/preview",
|
|
35
|
+
"/api/info",
|
|
36
|
+
"/api/openapi.json",
|
|
37
|
+
];
|
|
38
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
39
|
+
const paths = Object.keys(spec.paths || {});
|
|
40
|
+
for (const route of documentedRoutes) {
|
|
41
|
+
assert.ok(paths.includes(route), `expected ${route} to be in the OpenAPI spec, paths=${paths.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
test("openapi — /api/info governance block schema documents every field the handler returns", () => {
|
|
45
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
46
|
+
const info = spec.paths?.["/api/info"]?.get;
|
|
47
|
+
assert.ok(info, "/api/info should be documented");
|
|
48
|
+
// Walk down to the governance properties; the schema is inlined so
|
|
49
|
+
// we don't have to chase $refs.
|
|
50
|
+
const schema = info.responses["200"].content["application/json"].schema;
|
|
51
|
+
const gov = schema.properties?.governance?.properties;
|
|
52
|
+
assert.ok(gov, "governance block should be a documented object schema");
|
|
53
|
+
for (const field of [
|
|
54
|
+
"authMode",
|
|
55
|
+
"authSecretEphemeral",
|
|
56
|
+
"oidcIssuer",
|
|
57
|
+
"auditPersisted",
|
|
58
|
+
"catalogConfigured",
|
|
59
|
+
"redaction",
|
|
60
|
+
"trustProxy",
|
|
61
|
+
"toolRatePerMin",
|
|
62
|
+
]) {
|
|
63
|
+
assert.ok(field in gov, `governance.${field} should be in the schema (got: ${Object.keys(gov).join(", ")})`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
test("openapi — info.version is the version string the caller passed in", () => {
|
|
67
|
+
const spec = buildOpenApiSpec("9.9.9-test");
|
|
68
|
+
assert.equal(spec.info?.version, "9.9.9-test");
|
|
69
|
+
});
|
|
70
|
+
test("openapi — SOURCE_SCHEMA exposes the tenant field (tenant-aware sources contract)", () => {
|
|
71
|
+
// Source entries gained a `tenant` field when per-tenant connector
|
|
72
|
+
// scoping shipped. The spec is the contract operators write
|
|
73
|
+
// generated clients against — drift = broken downstream clients.
|
|
74
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
75
|
+
// SOURCE_SCHEMA is inlined into both `items` of GET /api/sources and
|
|
76
|
+
// the requestBody of POST/PUT — pick the GET response, the canonical
|
|
77
|
+
// read path.
|
|
78
|
+
const sources = spec.paths?.["/api/sources"]?.get;
|
|
79
|
+
const items = sources.responses["200"].content["application/json"].schema.items;
|
|
80
|
+
assert.ok(items.properties.tenant, "SOURCE_SCHEMA must document `tenant` (added when per-tenant scoping shipped)");
|
|
81
|
+
});
|
|
82
|
+
test("openapi — /api/sources GET documents the admin `?tenant=` drill-down query param", () => {
|
|
83
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
84
|
+
const get = spec.paths?.["/api/sources"]?.get;
|
|
85
|
+
const params = get.parameters || [];
|
|
86
|
+
const tenantParam = params.find((p) => p.name === "tenant" && p.in === "query");
|
|
87
|
+
assert.ok(tenantParam, "GET /api/sources must document the admin `?tenant=` drill-down param");
|
|
88
|
+
});
|
|
89
|
+
test("openapi — /api/policy GET documents the `?tenant=` probe param + `tenantAware` snapshot field", () => {
|
|
90
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
91
|
+
const get = spec.paths?.["/api/policy"]?.get;
|
|
92
|
+
const params = get.parameters || [];
|
|
93
|
+
assert.ok(params.some((p) => p.name === "tenant" && p.in === "query"), "GET /api/policy must document the `?tenant=` probe param");
|
|
94
|
+
const schema = get.responses["200"].content["application/json"].schema;
|
|
95
|
+
assert.ok(schema.properties.tenantAware, "snapshot must document the `tenantAware` field");
|
|
96
|
+
const dryRun = schema.properties.dryRun;
|
|
97
|
+
assert.ok(dryRun?.properties?.tenant, "dryRun must echo the `tenant` field so operators see which tenant the verdict ran under");
|
|
98
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative PII / secret redaction for tool outputs that may contain
|
|
3
|
+
* arbitrary log payloads.
|
|
4
|
+
*
|
|
5
|
+
* Scope of this module: pure string redaction with deterministic
|
|
6
|
+
* patterns. Returns the rewritten string plus a per-category count so
|
|
7
|
+
* callers can surface a "redacted N matches" hint to the user / agent
|
|
8
|
+
* without leaking what was matched. Designed to be safe-by-default
|
|
9
|
+
* (over-redact rather than under-redact) and explicit (each category
|
|
10
|
+
* tagged in the replacement marker, e.g. `[redacted-email]`).
|
|
11
|
+
*
|
|
12
|
+
* Bypass today is process-wide only: set `OMCP_REDACTION=off` if the
|
|
13
|
+
* upstream is already PII-clean. A per-request `redaction:bypass` RBAC
|
|
14
|
+
* permission for interactive admin sessions is on the roadmap — see
|
|
15
|
+
* docs/access-control.md "Why are my logs returning [redacted-email]?"
|
|
16
|
+
* and docs/redaction.md for the current state.
|
|
17
|
+
*/
|
|
18
|
+
export type RedactionCategory = "email" | "ipv4" | "ipv6" | "bearer" | "jwt" | "api-key" | "aws-key" | "slack-token" | "private-key" | "gh-pat" | "credit-card";
|
|
19
|
+
export interface RedactionResult {
|
|
20
|
+
text: string;
|
|
21
|
+
matches: Record<RedactionCategory, number>;
|
|
22
|
+
totalMatches: number;
|
|
23
|
+
}
|
|
24
|
+
/** Run all patterns in a deterministic order; later patterns won't
|
|
25
|
+
* re-match content already replaced by an earlier one (the marker
|
|
26
|
+
* starts with `[redacted-` which none of the patterns match). */
|
|
27
|
+
export declare function redactText(input: string): RedactionResult;
|
|
28
|
+
/** Maximum nesting depth the walker will descend into. Operational
|
|
29
|
+
* log payloads are essentially flat (objects of strings + a few
|
|
30
|
+
* nested arrays); a pathologically deep structure is almost certainly
|
|
31
|
+
* a bug or an attack, and stack-overflowing the auth path is worse
|
|
32
|
+
* than truncating. The cap is generous — well above anything a
|
|
33
|
+
* Prometheus / Loki record would ever produce. */
|
|
34
|
+
export declare const MAX_REDACT_DEPTH = 64;
|
|
35
|
+
/** Walk an arbitrary parsed-JSON value and redact every string leaf,
|
|
36
|
+
* accumulating match counts. Non-string leaves and structural keys are
|
|
37
|
+
* left untouched. Returns a new value (does not mutate input). Bails
|
|
38
|
+
* out below `MAX_REDACT_DEPTH` levels of nesting and returns the raw
|
|
39
|
+
* sub-tree untouched at that point. */
|
|
40
|
+
export declare function redactValue(input: unknown): {
|
|
41
|
+
value: unknown;
|
|
42
|
+
matches: Record<RedactionCategory, number>;
|
|
43
|
+
totalMatches: number;
|
|
44
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative PII / secret redaction for tool outputs that may contain
|
|
3
|
+
* arbitrary log payloads.
|
|
4
|
+
*
|
|
5
|
+
* Scope of this module: pure string redaction with deterministic
|
|
6
|
+
* patterns. Returns the rewritten string plus a per-category count so
|
|
7
|
+
* callers can surface a "redacted N matches" hint to the user / agent
|
|
8
|
+
* without leaking what was matched. Designed to be safe-by-default
|
|
9
|
+
* (over-redact rather than under-redact) and explicit (each category
|
|
10
|
+
* tagged in the replacement marker, e.g. `[redacted-email]`).
|
|
11
|
+
*
|
|
12
|
+
* Bypass today is process-wide only: set `OMCP_REDACTION=off` if the
|
|
13
|
+
* upstream is already PII-clean. A per-request `redaction:bypass` RBAC
|
|
14
|
+
* permission for interactive admin sessions is on the roadmap — see
|
|
15
|
+
* docs/access-control.md "Why are my logs returning [redacted-email]?"
|
|
16
|
+
* and docs/redaction.md for the current state.
|
|
17
|
+
*/
|
|
18
|
+
// Patterns chosen for low false-positive on operational log text:
|
|
19
|
+
// - Email: standard local@domain.tld with limited TLD chars.
|
|
20
|
+
// - IPv4: strict 0-255 quads to avoid matching version numbers etc.
|
|
21
|
+
// - IPv6: full / compressed; we accept the common forms only.
|
|
22
|
+
// - Bearer: "Authorization: Bearer <token>" — pulls the token out.
|
|
23
|
+
// - JWT: 3-part base64url joined by dots.
|
|
24
|
+
// - Generic API-key: long alnum + base64-ish run after a key= marker.
|
|
25
|
+
const PATTERNS = [
|
|
26
|
+
// High-confidence cloud-vendor secrets go first — their prefixes are
|
|
27
|
+
// distinctive enough that they don't conflict with generic patterns.
|
|
28
|
+
// - AWS access key id: 16-32 chars after AKIA/ASIA/AROA prefix.
|
|
29
|
+
// - Slack tokens: xoxa-/xoxb-/xoxp-/xoxr-/xoxs- + 10+ chars.
|
|
30
|
+
// - GitHub PAT: github_pat_<base62 segments> or ghp_/gho_/ghs_/ghu_/ghr_ + 36 chars.
|
|
31
|
+
// - PEM private-key blocks: greedy match across newlines.
|
|
32
|
+
{ category: "aws-key", re: /\b(?:AKIA|ASIA|AROA)[0-9A-Z]{16,20}\b/g },
|
|
33
|
+
{ category: "slack-token", re: /\bxox[abprsu]-[A-Za-z0-9-]{10,}\b/g },
|
|
34
|
+
{ category: "gh-pat", re: /\b(?:github_pat_[A-Za-z0-9_]{40,}|gh[opsuru]_[A-Za-z0-9]{36})\b/g },
|
|
35
|
+
{ category: "private-key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
36
|
+
// emails before other patterns so they don't get eaten partially
|
|
37
|
+
{ category: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
38
|
+
{ category: "jwt", re: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
|
|
39
|
+
{ category: "bearer", re: /\b[Bb]earer\s+[A-Za-z0-9._\-+/=]{12,}\b/g },
|
|
40
|
+
{ category: "api-key", re: /\b(?:api[_-]?key|x-api-key|token|secret)[=:]\s*['"]?[A-Za-z0-9._\-+/=]{16,}['"]?/gi },
|
|
41
|
+
// ipv6 — covers full, mid-compressed, leading "::loopback" / "::ffff:v4"
|
|
42
|
+
// mapped forms, and "::1". Trailing-only `xxxx::` shapes are rare in
|
|
43
|
+
// operational logs and intentionally not covered. MUST run before
|
|
44
|
+
// ipv4 so that the IPv4-mapped form (`::ffff:192.168.1.42`) is
|
|
45
|
+
// classified as IPv6 rather than having ipv4 eat the dotted tail
|
|
46
|
+
// and leave a half-redacted `::ffff:[redacted-ipv4]` token.
|
|
47
|
+
{ category: "ipv6", re: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}(?::[0-9a-fA-F]{1,4}){1,6}\b|::1\b|::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g },
|
|
48
|
+
{ category: "ipv4", re: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g },
|
|
49
|
+
];
|
|
50
|
+
function emptyCounts() {
|
|
51
|
+
return {
|
|
52
|
+
email: 0, ipv4: 0, ipv6: 0, bearer: 0, jwt: 0, "api-key": 0,
|
|
53
|
+
"aws-key": 0, "slack-token": 0, "private-key": 0, "gh-pat": 0,
|
|
54
|
+
"credit-card": 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Luhn check — accepts digits-only string of 13–19 chars. Used to
|
|
58
|
+
* keep the credit-card redaction from over-matching random digit
|
|
59
|
+
* strings (order IDs, timestamps, etc.). */
|
|
60
|
+
function luhn(digits) {
|
|
61
|
+
let sum = 0;
|
|
62
|
+
let alt = false;
|
|
63
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
64
|
+
let n = digits.charCodeAt(i) - 48;
|
|
65
|
+
if (n < 0 || n > 9)
|
|
66
|
+
return false;
|
|
67
|
+
if (alt) {
|
|
68
|
+
n *= 2;
|
|
69
|
+
if (n > 9)
|
|
70
|
+
n -= 9;
|
|
71
|
+
}
|
|
72
|
+
sum += n;
|
|
73
|
+
alt = !alt;
|
|
74
|
+
}
|
|
75
|
+
return sum % 10 === 0;
|
|
76
|
+
}
|
|
77
|
+
/** Run all patterns in a deterministic order; later patterns won't
|
|
78
|
+
* re-match content already replaced by an earlier one (the marker
|
|
79
|
+
* starts with `[redacted-` which none of the patterns match). */
|
|
80
|
+
export function redactText(input) {
|
|
81
|
+
const matches = emptyCounts();
|
|
82
|
+
let text = input;
|
|
83
|
+
for (const { category, re } of PATTERNS) {
|
|
84
|
+
text = text.replace(re, () => {
|
|
85
|
+
matches[category] += 1;
|
|
86
|
+
return `[redacted-${category}]`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Credit-card pass runs last so an inner-substring of a longer
|
|
90
|
+
// already-redacted token can't accidentally match. Luhn-validated
|
|
91
|
+
// so order numbers / timestamps / random digit strings stay intact.
|
|
92
|
+
text = text.replace(/\b(?:\d[ -]?){12,18}\d\b/g, (match) => {
|
|
93
|
+
const digits = match.replace(/[ -]/g, "");
|
|
94
|
+
if (digits.length < 13 || digits.length > 19)
|
|
95
|
+
return match;
|
|
96
|
+
if (!luhn(digits))
|
|
97
|
+
return match;
|
|
98
|
+
matches["credit-card"] += 1;
|
|
99
|
+
return "[redacted-credit-card]";
|
|
100
|
+
});
|
|
101
|
+
let total = 0;
|
|
102
|
+
for (const k of Object.keys(matches))
|
|
103
|
+
total += matches[k];
|
|
104
|
+
return { text, matches, totalMatches: total };
|
|
105
|
+
}
|
|
106
|
+
/** Maximum nesting depth the walker will descend into. Operational
|
|
107
|
+
* log payloads are essentially flat (objects of strings + a few
|
|
108
|
+
* nested arrays); a pathologically deep structure is almost certainly
|
|
109
|
+
* a bug or an attack, and stack-overflowing the auth path is worse
|
|
110
|
+
* than truncating. The cap is generous — well above anything a
|
|
111
|
+
* Prometheus / Loki record would ever produce. */
|
|
112
|
+
export const MAX_REDACT_DEPTH = 64;
|
|
113
|
+
/** Walk an arbitrary parsed-JSON value and redact every string leaf,
|
|
114
|
+
* accumulating match counts. Non-string leaves and structural keys are
|
|
115
|
+
* left untouched. Returns a new value (does not mutate input). Bails
|
|
116
|
+
* out below `MAX_REDACT_DEPTH` levels of nesting and returns the raw
|
|
117
|
+
* sub-tree untouched at that point. */
|
|
118
|
+
export function redactValue(input) {
|
|
119
|
+
const counts = emptyCounts();
|
|
120
|
+
function walk(v, depth) {
|
|
121
|
+
if (depth > MAX_REDACT_DEPTH)
|
|
122
|
+
return v;
|
|
123
|
+
if (typeof v === "string") {
|
|
124
|
+
const r = redactText(v);
|
|
125
|
+
for (const k of Object.keys(counts))
|
|
126
|
+
counts[k] += r.matches[k];
|
|
127
|
+
return r.text;
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(v))
|
|
130
|
+
return v.map((x) => walk(x, depth + 1));
|
|
131
|
+
if (v && typeof v === "object") {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [k, vv] of Object.entries(v))
|
|
134
|
+
out[k] = walk(vv, depth + 1);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
const value = walk(input, 0);
|
|
140
|
+
let total = 0;
|
|
141
|
+
for (const k of Object.keys(counts))
|
|
142
|
+
total += counts[k];
|
|
143
|
+
return { value, matches: counts, totalMatches: total };
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { redactText, redactValue } from "./redact.js";
|
|
4
|
+
test("redactText — emails are redacted, counted", () => {
|
|
5
|
+
const r = redactText("alert from alice@example.com to bob@corp.co.uk");
|
|
6
|
+
assert.equal(r.matches.email, 2);
|
|
7
|
+
assert.equal(r.totalMatches, 2);
|
|
8
|
+
assert.match(r.text, /\[redacted-email\].*\[redacted-email\]/);
|
|
9
|
+
});
|
|
10
|
+
test("redactText — IPv4 quads redacted, version numbers left alone", () => {
|
|
11
|
+
const r = redactText("client 192.168.1.42 connected to 10.0.0.1; version 1.2.3.4");
|
|
12
|
+
// "1.2.3.4" technically matches as IPv4 — that's fine, it's a valid IPv4
|
|
13
|
+
// and our threat model errs on the side of over-redaction.
|
|
14
|
+
assert.ok(r.matches.ipv4 >= 2);
|
|
15
|
+
assert.match(r.text, /\[redacted-ipv4\]/);
|
|
16
|
+
});
|
|
17
|
+
test("redactText — bearer tokens stripped", () => {
|
|
18
|
+
const r = redactText('GET /api/foo Authorization: Bearer abcdef1234567890XYZ');
|
|
19
|
+
assert.equal(r.matches.bearer, 1);
|
|
20
|
+
assert.match(r.text, /\[redacted-bearer\]/);
|
|
21
|
+
assert.doesNotMatch(r.text, /abcdef1234567890XYZ/);
|
|
22
|
+
});
|
|
23
|
+
test("redactText — JWTs detected by eyJ prefix + three-part shape", () => {
|
|
24
|
+
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdefghijk-_ABC";
|
|
25
|
+
const r = redactText(`token=${jwt} for user`);
|
|
26
|
+
assert.ok(r.matches.jwt >= 1);
|
|
27
|
+
assert.doesNotMatch(r.text, /eyJ/);
|
|
28
|
+
});
|
|
29
|
+
test("redactText — api-key / cloud-token style assignments", () => {
|
|
30
|
+
// r1: generic prefix-based api-key match.
|
|
31
|
+
// r2: x-api-key with an opaque body — falls through to api-key.
|
|
32
|
+
// r3: token= with a Slack-shape value — the new slack-token pattern
|
|
33
|
+
// wins because it runs before api-key; either outcome is fine,
|
|
34
|
+
// the contract is "the secret is gone after one pass".
|
|
35
|
+
const r1 = redactText('api_key="abc123def456ghi789jkl"');
|
|
36
|
+
const r2 = redactText("x-api-key: sk_test_abcdefghijklmnopqrstuvwxyz");
|
|
37
|
+
const r3 = redactText("token=xoxb-1234567890-abcdefghijklm");
|
|
38
|
+
assert.ok(r1.totalMatches >= 1, "expected r1 to be redacted somewhere");
|
|
39
|
+
assert.ok(r2.totalMatches >= 1, "expected r2 to be redacted somewhere");
|
|
40
|
+
assert.ok(r3.totalMatches >= 1, "expected r3 to be redacted somewhere");
|
|
41
|
+
assert.doesNotMatch(r1.text, /abc123def456ghi789jkl/);
|
|
42
|
+
assert.doesNotMatch(r2.text, /sk_test_abcdefghijklmnopqrstuvwxyz/);
|
|
43
|
+
assert.doesNotMatch(r3.text, /xoxb-1234567890/);
|
|
44
|
+
});
|
|
45
|
+
test("redactText — leaves harmless text alone", () => {
|
|
46
|
+
const r = redactText("the order-service replied with 200 OK after 45ms");
|
|
47
|
+
assert.equal(r.totalMatches, 0);
|
|
48
|
+
assert.equal(r.text, "the order-service replied with 200 OK after 45ms");
|
|
49
|
+
});
|
|
50
|
+
test("redactText — long digit strings without Luhn don't trigger credit-card", () => {
|
|
51
|
+
// 16-digit telemetry sequence (UNIX nanos + service id) — should pass through.
|
|
52
|
+
const r = redactText("ts=1717000000000000000 seq=4242424242424242 user=test");
|
|
53
|
+
// 4242424242424242 IS Luhn-valid (Visa test number), so that counts as a hit;
|
|
54
|
+
// but ts=1717... starts with a non-bordered digit run that includes "1717" pattern.
|
|
55
|
+
// The assertion: only the Luhn-valid 16-digit string is counted as credit-card.
|
|
56
|
+
assert.equal(r.matches["credit-card"], 1);
|
|
57
|
+
});
|
|
58
|
+
test("redactText — already-redacted markers don't re-match in further passes", () => {
|
|
59
|
+
// Run the redactor twice; the second pass should be a no-op.
|
|
60
|
+
const first = redactText("contact alice@example.com");
|
|
61
|
+
const second = redactText(first.text);
|
|
62
|
+
assert.equal(second.totalMatches, 0);
|
|
63
|
+
});
|
|
64
|
+
test("redactValue — walks nested objects / arrays, mutates only strings", () => {
|
|
65
|
+
const input = {
|
|
66
|
+
user: "bob@corp.co.uk",
|
|
67
|
+
nested: {
|
|
68
|
+
ip: "10.0.0.1",
|
|
69
|
+
count: 42,
|
|
70
|
+
tags: ["audit", "client=alice@example.com"],
|
|
71
|
+
},
|
|
72
|
+
flag: true,
|
|
73
|
+
};
|
|
74
|
+
const r = redactValue(input);
|
|
75
|
+
const v = r.value;
|
|
76
|
+
assert.equal(v.user, "[redacted-email]");
|
|
77
|
+
assert.equal(v.nested.ip, "[redacted-ipv4]");
|
|
78
|
+
assert.equal(v.nested.count, 42);
|
|
79
|
+
assert.equal(v.nested.tags[0], "audit");
|
|
80
|
+
assert.equal(v.nested.tags[1], "client=[redacted-email]");
|
|
81
|
+
assert.equal(v.flag, true);
|
|
82
|
+
assert.equal(r.matches.email, 2);
|
|
83
|
+
assert.equal(r.matches.ipv4, 1);
|
|
84
|
+
assert.equal(r.totalMatches, 3);
|
|
85
|
+
});
|
|
86
|
+
test("redactText — AWS access key IDs (AKIA / ASIA / AROA) are redacted", () => {
|
|
87
|
+
const r1 = redactText("log: assumed role AKIAIOSFODNN7EXAMPLE today");
|
|
88
|
+
const r2 = redactText("temporary creds ASIAY34FZKBOKMUTVV7A logged");
|
|
89
|
+
const r3 = redactText("role-arn AROAIIAFOO2ZBADBCEXAMPLE");
|
|
90
|
+
assert.equal(r1.matches["aws-key"], 1);
|
|
91
|
+
assert.equal(r2.matches["aws-key"], 1);
|
|
92
|
+
assert.equal(r3.matches["aws-key"], 1);
|
|
93
|
+
assert.match(r1.text, /\[redacted-aws-key\]/);
|
|
94
|
+
assert.doesNotMatch(r1.text, /AKIAIOSFODNN7EXAMPLE/);
|
|
95
|
+
});
|
|
96
|
+
test("redactText — Slack tokens (xoxa / xoxb / xoxp / …) are redacted", () => {
|
|
97
|
+
const r = redactText("slack notify: token=xoxb-1234567890-abcdefghijklm result: ok");
|
|
98
|
+
assert.equal(r.matches["slack-token"], 1);
|
|
99
|
+
assert.doesNotMatch(r.text, /xoxb-1234567890/);
|
|
100
|
+
});
|
|
101
|
+
test("redactText — GitHub PATs are redacted (ghp_ / github_pat_)", () => {
|
|
102
|
+
const r1 = redactText("git remote set-url origin https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789@github.com/x/y.git");
|
|
103
|
+
// Use a 40-char body, which matches `[A-Za-z0-9_]{40,}` (note: includes underscore)
|
|
104
|
+
const r2 = redactText("token github_pat_ABCDEFGH_IJKLMNOPQRSTUVWXYZ012345678ABCDEFGHIJKLMNOP");
|
|
105
|
+
assert.equal(r1.matches["gh-pat"], 1);
|
|
106
|
+
assert.equal(r2.matches["gh-pat"], 1);
|
|
107
|
+
});
|
|
108
|
+
test("redactText — Luhn-valid credit-card numbers are redacted, invalid ones pass through", () => {
|
|
109
|
+
// Visa test number 4111 1111 1111 1111 (Luhn-valid)
|
|
110
|
+
const r1 = redactText("charge attempted on card 4111111111111111 for $42.00");
|
|
111
|
+
assert.equal(r1.matches["credit-card"], 1);
|
|
112
|
+
assert.match(r1.text, /\[redacted-credit-card\]/);
|
|
113
|
+
// Same number with separators
|
|
114
|
+
const r2 = redactText("card 4111-1111-1111-1111 declined");
|
|
115
|
+
assert.equal(r2.matches["credit-card"], 1);
|
|
116
|
+
assert.doesNotMatch(r2.text, /4111-1111-1111-1111/);
|
|
117
|
+
// Random 16 digits that DON'T pass Luhn → left alone (e.g. order ID)
|
|
118
|
+
const r3 = redactText("order 1234567890123456 created");
|
|
119
|
+
assert.equal(r3.matches["credit-card"], 0);
|
|
120
|
+
assert.match(r3.text, /1234567890123456/);
|
|
121
|
+
// Too short / too long stays as-is
|
|
122
|
+
const r4 = redactText("seq 123456789012 and 12345678901234567890");
|
|
123
|
+
assert.equal(r4.matches["credit-card"], 0);
|
|
124
|
+
});
|
|
125
|
+
test("redactText — PEM private-key blocks are redacted greedily", () => {
|
|
126
|
+
const pem = `-----BEGIN RSA PRIVATE KEY-----
|
|
127
|
+
MIIEpAIBAAKCAQEAwLPVKj…
|
|
128
|
+
-----END RSA PRIVATE KEY-----`;
|
|
129
|
+
const r = redactText(`config:\n${pem}\nend`);
|
|
130
|
+
assert.equal(r.matches["private-key"], 1);
|
|
131
|
+
assert.doesNotMatch(r.text, /MIIEpAIBAA/);
|
|
132
|
+
});
|
|
133
|
+
test("redactValue — pathologically deep nesting hits the depth cap, returns sub-tree untouched", () => {
|
|
134
|
+
// Build a structure deeper than MAX_REDACT_DEPTH (64). At the cap,
|
|
135
|
+
// the walker should return the remaining sub-tree as-is — no
|
|
136
|
+
// mutations beyond depth 64, and no stack overflow.
|
|
137
|
+
let leaf = "alice@example.com";
|
|
138
|
+
for (let i = 0; i < 200; i++)
|
|
139
|
+
leaf = { wrap: leaf };
|
|
140
|
+
// Wrap the deep sub-tree inside a shallow root so a string near
|
|
141
|
+
// the surface still gets redacted.
|
|
142
|
+
const r = redactValue({ shallow: "bob@example.com", deep: leaf });
|
|
143
|
+
const v = r.value;
|
|
144
|
+
assert.equal(v.shallow, "[redacted-email]", "shallow leaf still redacted");
|
|
145
|
+
assert.equal(r.matches.email, 1, "only the shallow email is redacted past the depth cap");
|
|
146
|
+
});
|
|
147
|
+
test("redactText — IPv6 addresses are redacted across full / compressed / mapped forms", () => {
|
|
148
|
+
// Full 8-group address
|
|
149
|
+
const r1 = redactText("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected");
|
|
150
|
+
assert.ok(r1.matches.ipv6 >= 1, "expected full IPv6 to be redacted");
|
|
151
|
+
assert.doesNotMatch(r1.text, /2001:0db8/);
|
|
152
|
+
// Mid-compressed (single :: collapsing one zero run)
|
|
153
|
+
const r2 = redactText("client 2001:db8::8a2e:370:7334 disconnected");
|
|
154
|
+
assert.ok(r2.matches.ipv6 >= 1, "expected compressed IPv6 to be redacted");
|
|
155
|
+
// Loopback
|
|
156
|
+
const r3 = redactText("listening on ::1 port 8080");
|
|
157
|
+
assert.ok(r3.matches.ipv6 >= 1, "expected ::1 loopback to be redacted");
|
|
158
|
+
assert.doesNotMatch(r3.text, /::1\b/);
|
|
159
|
+
// IPv4-mapped IPv6
|
|
160
|
+
const r4 = redactText("source ::ffff:192.168.1.42 forwarded");
|
|
161
|
+
assert.ok(r4.matches.ipv6 >= 1, "expected ::ffff: mapped form to be redacted");
|
|
162
|
+
// Pure version string — must NOT match IPv6 (only two colons, not 7-group)
|
|
163
|
+
const r5 = redactText("server version 1.2.3 build 4");
|
|
164
|
+
assert.equal(r5.matches.ipv6, 0, "version string must not look like IPv6");
|
|
165
|
+
});
|
|
166
|
+
test("redactValue — null / undefined leaves are preserved", () => {
|
|
167
|
+
const r = redactValue({ a: null, b: undefined, c: "alice@example.com" });
|
|
168
|
+
const v = r.value;
|
|
169
|
+
assert.equal(v.a, null);
|
|
170
|
+
assert.equal(v.b, undefined);
|
|
171
|
+
assert.equal(v.c, "[redacted-email]");
|
|
172
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface AnomalySample {
|
|
2
|
+
ts: string;
|
|
3
|
+
service: string;
|
|
4
|
+
score: number;
|
|
5
|
+
method: string;
|
|
6
|
+
severity: string;
|
|
7
|
+
signal?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BlastRadiusNode {
|
|
10
|
+
id: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** Whether this node is the suspected root cause (the input service). */
|
|
14
|
+
root?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface TraceSummary {
|
|
17
|
+
traceId: string;
|
|
18
|
+
rootName: string;
|
|
19
|
+
rootService: string;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
hasError: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface PostmortemInput {
|
|
24
|
+
/** Suspected root-cause service (the operator's first guess). */
|
|
25
|
+
service: string;
|
|
26
|
+
/** Rolling window the incident took place in, e.g. "2h", "6h". */
|
|
27
|
+
window: string;
|
|
28
|
+
/** Tenant the incident occurred in. */
|
|
29
|
+
tenant: string;
|
|
30
|
+
/** RFC-3339 start + end of the incident window for human display. */
|
|
31
|
+
fromIso: string;
|
|
32
|
+
toIso: string;
|
|
33
|
+
/** Live anomaly samples within the window. */
|
|
34
|
+
anomalies: AnomalySample[];
|
|
35
|
+
/** Blast-radius graph at peak. */
|
|
36
|
+
blastRadius: {
|
|
37
|
+
nodes: BlastRadiusNode[];
|
|
38
|
+
edges: Array<{
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
relation: string;
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
/** Trace summaries (top by duration). */
|
|
45
|
+
traces: TraceSummary[];
|
|
46
|
+
/** Optional log-error summary lines, e.g. ["payment-service: 412 5xx in window"]. */
|
|
47
|
+
logHighlights?: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface PostmortemReport {
|
|
50
|
+
service: string;
|
|
51
|
+
window: string;
|
|
52
|
+
fromIso: string;
|
|
53
|
+
toIso: string;
|
|
54
|
+
/** Compact synopsis the UI puts at the top of the report. */
|
|
55
|
+
synopsis: string;
|
|
56
|
+
/** Markdown body of the full report. */
|
|
57
|
+
markdown: string;
|
|
58
|
+
/** Structured form for callers that want to render their own UI. */
|
|
59
|
+
sections: {
|
|
60
|
+
timeline: Array<{
|
|
61
|
+
ts: string;
|
|
62
|
+
service: string;
|
|
63
|
+
score: number;
|
|
64
|
+
severity: string;
|
|
65
|
+
method: string;
|
|
66
|
+
}>;
|
|
67
|
+
blastRadius: {
|
|
68
|
+
nodes: BlastRadiusNode[];
|
|
69
|
+
edgeCount: number;
|
|
70
|
+
};
|
|
71
|
+
topTraces: TraceSummary[];
|
|
72
|
+
contributingSignals: Array<{
|
|
73
|
+
signal: string;
|
|
74
|
+
count: number;
|
|
75
|
+
meanScore: number;
|
|
76
|
+
}>;
|
|
77
|
+
followUps: string[];
|
|
78
|
+
logHighlights: string[];
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Synthesise one report from already-fetched primitives. Pure
|
|
82
|
+
* compute — no I/O. */
|
|
83
|
+
export declare function synthesizePostmortem(input: PostmortemInput): PostmortemReport;
|