@thotischner/observability-mcp 1.7.0 → 1.8.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/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -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/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -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/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 +124 -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/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -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 +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -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/connectors/kubernetes.d.ts +1 -0
- package/dist/connectors/kubernetes.js +12 -2
- package/dist/connectors/topology-vocabulary.d.ts +41 -0
- package/dist/connectors/topology-vocabulary.js +120 -0
- package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
- package/dist/connectors/topology-vocabulary.test.js +63 -0
- package/dist/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -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/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -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/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/ui/index.html +1454 -88
- package/package.json +20 -3
|
@@ -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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Products — curated, agent-facing collections of tools.
|
|
3
|
+
*
|
|
4
|
+
* A Product is a named bundle that ships with branding metadata
|
|
5
|
+
* (icon, description, version) plus a list of allowed MCP tools.
|
|
6
|
+
* The agent calling /mcp can be told which Product it belongs to
|
|
7
|
+
* (via a future header / arg, slice 2+), and the server can filter
|
|
8
|
+
* tools/list and tools/call responses accordingly.
|
|
9
|
+
*
|
|
10
|
+
* Today's surface (slice 1):
|
|
11
|
+
* - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
|
|
12
|
+
* (YAML or JSON). Missing/empty file → empty catalog.
|
|
13
|
+
* - Strict validation: unknown action / unknown resource /
|
|
14
|
+
* unexpected keys reject loudly.
|
|
15
|
+
* - Hot-reload on next /api/products call (slice 2 wires the
|
|
16
|
+
* reload trigger; for now the file is read once at boot).
|
|
17
|
+
*/
|
|
18
|
+
export interface Product {
|
|
19
|
+
/** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Display name shown in the UI / agent dropdown. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** One-sentence description. */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Allowed MCP tool names. Empty / undefined → all tools allowed. */
|
|
26
|
+
tools?: string[];
|
|
27
|
+
/** Operator-defined version label, e.g. "1.0.0" or "preview". */
|
|
28
|
+
version?: string;
|
|
29
|
+
/** Free-form branding metadata for the UI — icon URL, theme colour, etc. */
|
|
30
|
+
branding?: {
|
|
31
|
+
iconUrl?: string;
|
|
32
|
+
color?: string;
|
|
33
|
+
};
|
|
34
|
+
/** Lifecycle stage: published = visible to agents; staging = admin-only. */
|
|
35
|
+
status?: "published" | "staging";
|
|
36
|
+
/** Tenant this product belongs to. Omitted → "default". */
|
|
37
|
+
tenant?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ProductsFile {
|
|
40
|
+
products: Product[];
|
|
41
|
+
}
|
|
42
|
+
export declare class ProductsLoadError extends Error {
|
|
43
|
+
constructor(msg: string);
|
|
44
|
+
}
|
|
45
|
+
export declare function readProductsFile(path: string | undefined): Promise<ProductsFile>;
|
|
46
|
+
export declare function parseProductsText(text: string, origin: string): ProductsFile;
|
|
47
|
+
/** In-memory store with tenant- and status-aware queries. */
|
|
48
|
+
export declare class ProductsStore {
|
|
49
|
+
private file;
|
|
50
|
+
constructor(file?: ProductsFile);
|
|
51
|
+
/** Return the product list. When `tenant` is set, filters to that
|
|
52
|
+
* tenant (entries without a tenant field treated as "default").
|
|
53
|
+
* When `includeStaging` is false (default), staging products are
|
|
54
|
+
* hidden from the result — admins should pass true. */
|
|
55
|
+
list(opts?: {
|
|
56
|
+
tenant?: string;
|
|
57
|
+
includeStaging?: boolean;
|
|
58
|
+
}): Product[];
|
|
59
|
+
/** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
|
|
60
|
+
get(id: string, tenant?: string): Product | undefined;
|
|
61
|
+
count(tenant?: string): number;
|
|
62
|
+
replace(file: ProductsFile): void;
|
|
63
|
+
/** Upsert (replace if id exists, else append). Returns the new
|
|
64
|
+
* ProductsFile so the caller can persist it. */
|
|
65
|
+
upsert(product: Product): ProductsFile;
|
|
66
|
+
/** Remove by id. Returns true when the product existed, false
|
|
67
|
+
* otherwise. Caller persists the resulting file. */
|
|
68
|
+
delete(id: string): {
|
|
69
|
+
removed: boolean;
|
|
70
|
+
file: ProductsFile;
|
|
71
|
+
};
|
|
72
|
+
/** Snapshot of the current file (for tests / persistence). */
|
|
73
|
+
snapshot(): ProductsFile;
|
|
74
|
+
}
|
|
75
|
+
/** Validate a single product entry by routing it through the same
|
|
76
|
+
* parser as the file format. Throws ProductsLoadError on any
|
|
77
|
+
* shape problem. Used by PUT /api/products/:id so a typo / wrong
|
|
78
|
+
* type / unknown key gets the same loud rejection a malformed
|
|
79
|
+
* file would. */
|
|
80
|
+
export declare function validateProduct(input: unknown, origin?: string): Product;
|
|
81
|
+
/** Atomic write of the products file. Same tmp+rename pattern as
|
|
82
|
+
* the audit-chain + token-budget snapshot, so a crash mid-write
|
|
83
|
+
* leaves the previous file intact. */
|
|
84
|
+
export declare function writeProductsFile(path: string, file: ProductsFile): Promise<void>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Products — curated, agent-facing collections of tools.
|
|
3
|
+
*
|
|
4
|
+
* A Product is a named bundle that ships with branding metadata
|
|
5
|
+
* (icon, description, version) plus a list of allowed MCP tools.
|
|
6
|
+
* The agent calling /mcp can be told which Product it belongs to
|
|
7
|
+
* (via a future header / arg, slice 2+), and the server can filter
|
|
8
|
+
* tools/list and tools/call responses accordingly.
|
|
9
|
+
*
|
|
10
|
+
* Today's surface (slice 1):
|
|
11
|
+
* - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
|
|
12
|
+
* (YAML or JSON). Missing/empty file → empty catalog.
|
|
13
|
+
* - Strict validation: unknown action / unknown resource /
|
|
14
|
+
* unexpected keys reject loudly.
|
|
15
|
+
* - Hot-reload on next /api/products call (slice 2 wires the
|
|
16
|
+
* reload trigger; for now the file is read once at boot).
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
19
|
+
import yaml from "js-yaml";
|
|
20
|
+
const EMPTY = { products: [] };
|
|
21
|
+
const VALID_STATUS = new Set(["published", "staging"]);
|
|
22
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
23
|
+
export class ProductsLoadError extends Error {
|
|
24
|
+
constructor(msg) {
|
|
25
|
+
super(msg);
|
|
26
|
+
this.name = "ProductsLoadError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function readProductsFile(path) {
|
|
30
|
+
if (!path)
|
|
31
|
+
return EMPTY;
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await readFile(path, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
const code = e.code;
|
|
38
|
+
if (code === "ENOENT")
|
|
39
|
+
return EMPTY;
|
|
40
|
+
console.warn(`[products] could not read ${path}: ${e.message} — starting with empty catalog`);
|
|
41
|
+
return EMPTY;
|
|
42
|
+
}
|
|
43
|
+
return parseProductsText(text, path);
|
|
44
|
+
}
|
|
45
|
+
export function parseProductsText(text, origin) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = yaml.load(text);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
throw new ProductsLoadError(`${origin}: not valid YAML/JSON: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
54
|
+
throw new ProductsLoadError(`${origin}: root must be an object with a 'products' array`);
|
|
55
|
+
}
|
|
56
|
+
const root = parsed;
|
|
57
|
+
const rawProducts = root.products;
|
|
58
|
+
if (!Array.isArray(rawProducts)) {
|
|
59
|
+
throw new ProductsLoadError(`${origin}: 'products' must be an array`);
|
|
60
|
+
}
|
|
61
|
+
const out = [];
|
|
62
|
+
const seenIds = new Set();
|
|
63
|
+
for (let i = 0; i < rawProducts.length; i++) {
|
|
64
|
+
const e = rawProducts[i];
|
|
65
|
+
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
|
66
|
+
throw new ProductsLoadError(`${origin}: products[${i}] must be an object`);
|
|
67
|
+
}
|
|
68
|
+
const r = e;
|
|
69
|
+
if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
|
|
70
|
+
throw new ProductsLoadError(`${origin}: products[${i}].id must be a string matching ${ID_RE}`);
|
|
71
|
+
}
|
|
72
|
+
if (seenIds.has(r.id)) {
|
|
73
|
+
throw new ProductsLoadError(`${origin}: duplicate product id '${r.id}'`);
|
|
74
|
+
}
|
|
75
|
+
seenIds.add(r.id);
|
|
76
|
+
if (typeof r.name !== "string" || !r.name) {
|
|
77
|
+
throw new ProductsLoadError(`${origin}: products[${i}].name must be a non-empty string`);
|
|
78
|
+
}
|
|
79
|
+
const p = { id: r.id, name: r.name };
|
|
80
|
+
if (r.description !== undefined) {
|
|
81
|
+
if (typeof r.description !== "string")
|
|
82
|
+
throw new ProductsLoadError(`${origin}: products[${i}].description must be a string`);
|
|
83
|
+
p.description = r.description;
|
|
84
|
+
}
|
|
85
|
+
if (r.tools !== undefined) {
|
|
86
|
+
if (!Array.isArray(r.tools) || !r.tools.every((t) => typeof t === "string")) {
|
|
87
|
+
throw new ProductsLoadError(`${origin}: products[${i}].tools must be an array of strings`);
|
|
88
|
+
}
|
|
89
|
+
p.tools = r.tools;
|
|
90
|
+
}
|
|
91
|
+
if (r.version !== undefined) {
|
|
92
|
+
if (typeof r.version !== "string")
|
|
93
|
+
throw new ProductsLoadError(`${origin}: products[${i}].version must be a string`);
|
|
94
|
+
p.version = r.version;
|
|
95
|
+
}
|
|
96
|
+
if (r.status !== undefined) {
|
|
97
|
+
if (typeof r.status !== "string" || !VALID_STATUS.has(r.status)) {
|
|
98
|
+
throw new ProductsLoadError(`${origin}: products[${i}].status must be one of ${[...VALID_STATUS].join(", ")}`);
|
|
99
|
+
}
|
|
100
|
+
p.status = r.status;
|
|
101
|
+
}
|
|
102
|
+
if (r.tenant !== undefined) {
|
|
103
|
+
if (typeof r.tenant !== "string")
|
|
104
|
+
throw new ProductsLoadError(`${origin}: products[${i}].tenant must be a string`);
|
|
105
|
+
p.tenant = r.tenant;
|
|
106
|
+
}
|
|
107
|
+
if (r.branding !== undefined) {
|
|
108
|
+
if (!r.branding || typeof r.branding !== "object" || Array.isArray(r.branding)) {
|
|
109
|
+
throw new ProductsLoadError(`${origin}: products[${i}].branding must be an object`);
|
|
110
|
+
}
|
|
111
|
+
const b = r.branding;
|
|
112
|
+
p.branding = {};
|
|
113
|
+
if (b.iconUrl !== undefined) {
|
|
114
|
+
if (typeof b.iconUrl !== "string")
|
|
115
|
+
throw new ProductsLoadError(`${origin}: products[${i}].branding.iconUrl must be a string`);
|
|
116
|
+
p.branding.iconUrl = b.iconUrl;
|
|
117
|
+
}
|
|
118
|
+
if (b.color !== undefined) {
|
|
119
|
+
if (typeof b.color !== "string")
|
|
120
|
+
throw new ProductsLoadError(`${origin}: products[${i}].branding.color must be a string`);
|
|
121
|
+
p.branding.color = b.color;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Reject unexpected top-level keys — operator typo guard
|
|
125
|
+
for (const k of Object.keys(r)) {
|
|
126
|
+
if (!["id", "name", "description", "tools", "version", "branding", "status", "tenant"].includes(k)) {
|
|
127
|
+
throw new ProductsLoadError(`${origin}: products[${i}] has unexpected key '${k}'`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out.push(p);
|
|
131
|
+
}
|
|
132
|
+
return { products: out };
|
|
133
|
+
}
|
|
134
|
+
/** In-memory store with tenant- and status-aware queries. */
|
|
135
|
+
export class ProductsStore {
|
|
136
|
+
file;
|
|
137
|
+
constructor(file = EMPTY) {
|
|
138
|
+
this.file = file;
|
|
139
|
+
}
|
|
140
|
+
/** Return the product list. When `tenant` is set, filters to that
|
|
141
|
+
* tenant (entries without a tenant field treated as "default").
|
|
142
|
+
* When `includeStaging` is false (default), staging products are
|
|
143
|
+
* hidden from the result — admins should pass true. */
|
|
144
|
+
list(opts = {}) {
|
|
145
|
+
return this.file.products.filter((p) => {
|
|
146
|
+
if (opts.tenant) {
|
|
147
|
+
const pt = p.tenant || "default";
|
|
148
|
+
if (pt !== opts.tenant)
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (!opts.includeStaging && p.status === "staging")
|
|
152
|
+
return false;
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
|
|
157
|
+
get(id, tenant) {
|
|
158
|
+
const p = this.file.products.find((x) => x.id === id);
|
|
159
|
+
if (!p)
|
|
160
|
+
return undefined;
|
|
161
|
+
if (tenant && (p.tenant || "default") !== tenant)
|
|
162
|
+
return undefined;
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
count(tenant) {
|
|
166
|
+
return this.list({ tenant, includeStaging: true }).length;
|
|
167
|
+
}
|
|
168
|
+
replace(file) {
|
|
169
|
+
this.file = file;
|
|
170
|
+
}
|
|
171
|
+
/** Upsert (replace if id exists, else append). Returns the new
|
|
172
|
+
* ProductsFile so the caller can persist it. */
|
|
173
|
+
upsert(product) {
|
|
174
|
+
const i = this.file.products.findIndex((p) => p.id === product.id);
|
|
175
|
+
const next = this.file.products.slice();
|
|
176
|
+
if (i >= 0)
|
|
177
|
+
next[i] = product;
|
|
178
|
+
else
|
|
179
|
+
next.push(product);
|
|
180
|
+
this.file = { products: next };
|
|
181
|
+
return this.file;
|
|
182
|
+
}
|
|
183
|
+
/** Remove by id. Returns true when the product existed, false
|
|
184
|
+
* otherwise. Caller persists the resulting file. */
|
|
185
|
+
delete(id) {
|
|
186
|
+
const i = this.file.products.findIndex((p) => p.id === id);
|
|
187
|
+
if (i < 0)
|
|
188
|
+
return { removed: false, file: this.file };
|
|
189
|
+
const next = this.file.products.slice();
|
|
190
|
+
next.splice(i, 1);
|
|
191
|
+
this.file = { products: next };
|
|
192
|
+
return { removed: true, file: this.file };
|
|
193
|
+
}
|
|
194
|
+
/** Snapshot of the current file (for tests / persistence). */
|
|
195
|
+
snapshot() {
|
|
196
|
+
return { products: this.file.products.slice() };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Validate a single product entry by routing it through the same
|
|
200
|
+
* parser as the file format. Throws ProductsLoadError on any
|
|
201
|
+
* shape problem. Used by PUT /api/products/:id so a typo / wrong
|
|
202
|
+
* type / unknown key gets the same loud rejection a malformed
|
|
203
|
+
* file would. */
|
|
204
|
+
export function validateProduct(input, origin = "input") {
|
|
205
|
+
const wrapped = parseProductsText(yaml.dump({ products: [input] }), origin);
|
|
206
|
+
return wrapped.products[0];
|
|
207
|
+
}
|
|
208
|
+
/** Atomic write of the products file. Same tmp+rename pattern as
|
|
209
|
+
* the audit-chain + token-budget snapshot, so a crash mid-write
|
|
210
|
+
* leaves the previous file intact. */
|
|
211
|
+
export async function writeProductsFile(path, file) {
|
|
212
|
+
const text = yaml.dump(file, { sortKeys: false, lineWidth: 100 });
|
|
213
|
+
const tmp = path + ".tmp";
|
|
214
|
+
await writeFile(tmp, text, "utf8");
|
|
215
|
+
await rename(tmp, path);
|
|
216
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|