@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 @@
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;