@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,15 @@
|
|
|
1
|
+
export interface SsrfGuardConfig {
|
|
2
|
+
allowPrivateBackends: boolean;
|
|
3
|
+
}
|
|
4
|
+
export declare function ssrfGuardFromEnv(env?: NodeJS.ProcessEnv): SsrfGuardConfig;
|
|
5
|
+
export interface SsrfGuardVerdict {
|
|
6
|
+
allow: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Inspect a URL and decide whether the connector layer should be
|
|
10
|
+
* allowed to dial it. Cheap, synchronous, hostname-only — does not
|
|
11
|
+
* resolve DNS. (DNS resolution would still leak the request, so the
|
|
12
|
+
* guard would have to additionally pin the resolved IP at connect
|
|
13
|
+
* time. That's worth doing in a follow-up; for now we catch the
|
|
14
|
+
* most-frequent typed-IP cases and document the resolver limitation.) */
|
|
15
|
+
export declare function checkOutboundUrl(rawUrl: string, cfg: SsrfGuardConfig): SsrfGuardVerdict;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SSRF strict-mode for operator-supplied URLs.
|
|
2
|
+
//
|
|
3
|
+
// Connectors (Prometheus, Loki, Tempo, Grafana, generic webhook
|
|
4
|
+
// targets) all take a URL the operator types in. Without a guard, an
|
|
5
|
+
// admin who can add a source can also probe cloud-metadata endpoints
|
|
6
|
+
// (169.254.169.254 → IAM creds, GCE service accounts, etc.) or other
|
|
7
|
+
// in-cluster services that should not be reachable from the gateway.
|
|
8
|
+
//
|
|
9
|
+
// This module rejects URLs whose hostname:
|
|
10
|
+
// - is a private/loopback/link-local IPv4 or IPv6 literal, OR
|
|
11
|
+
// - is the well-known cloud-metadata IP, OR
|
|
12
|
+
// - resolves (when an explicit IP is set) to one of the above
|
|
13
|
+
//
|
|
14
|
+
// Operators who legitimately need to reach an in-cluster Prometheus
|
|
15
|
+
// (a frequent case) opt out via OMCP_ALLOW_PRIVATE_BACKENDS=true.
|
|
16
|
+
import { isIP } from "node:net";
|
|
17
|
+
// IPv4 IMDS (AWS / GCE / Azure / Oracle) all share 169.254.169.254.
|
|
18
|
+
// fd00:ec2::254 is the AWS IMDS IPv6 address.
|
|
19
|
+
const METADATA_IPS = new Set(["169.254.169.254", "fd00:ec2::254"]);
|
|
20
|
+
// Private IPv4 ranges per RFC 1918/3927/6890. String-prefix matching
|
|
21
|
+
// is intentionally lo-fi — the guard catches typed-by-hand URLs, not
|
|
22
|
+
// every numerical corner. A DNS-resolved guard is on the F11b list.
|
|
23
|
+
const IPV4_PREFIXES = [
|
|
24
|
+
"10.", // RFC1918
|
|
25
|
+
"172.16.", "172.17.", "172.18.", "172.19.",
|
|
26
|
+
"172.20.", "172.21.", "172.22.", "172.23.",
|
|
27
|
+
"172.24.", "172.25.", "172.26.", "172.27.",
|
|
28
|
+
"172.28.", "172.29.", "172.30.", "172.31.",
|
|
29
|
+
"192.168.", // RFC1918
|
|
30
|
+
"169.254.", // link-local + cloud metadata
|
|
31
|
+
"127.", // loopback
|
|
32
|
+
"0.", // 0.0.0.0/8
|
|
33
|
+
];
|
|
34
|
+
const IPV6_PRIVATE_PREFIXES = [
|
|
35
|
+
"::1", // loopback
|
|
36
|
+
"fc", // unique-local (fc00::/7)
|
|
37
|
+
"fd", // unique-local
|
|
38
|
+
"fe8", // link-local (fe80::/10)
|
|
39
|
+
"fe9",
|
|
40
|
+
"fea",
|
|
41
|
+
"feb",
|
|
42
|
+
];
|
|
43
|
+
export function ssrfGuardFromEnv(env = process.env) {
|
|
44
|
+
return {
|
|
45
|
+
allowPrivateBackends: /^(1|true|yes|on)$/i.test(env.OMCP_ALLOW_PRIVATE_BACKENDS ?? ""),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Inspect a URL and decide whether the connector layer should be
|
|
49
|
+
* allowed to dial it. Cheap, synchronous, hostname-only — does not
|
|
50
|
+
* resolve DNS. (DNS resolution would still leak the request, so the
|
|
51
|
+
* guard would have to additionally pin the resolved IP at connect
|
|
52
|
+
* time. That's worth doing in a follow-up; for now we catch the
|
|
53
|
+
* most-frequent typed-IP cases and document the resolver limitation.) */
|
|
54
|
+
export function checkOutboundUrl(rawUrl, cfg) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(rawUrl);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return { allow: false, reason: `invalid URL: ${err.message}` };
|
|
61
|
+
}
|
|
62
|
+
if (!/^https?:$/i.test(parsed.protocol)) {
|
|
63
|
+
return { allow: false, reason: `unsupported protocol: ${parsed.protocol}` };
|
|
64
|
+
}
|
|
65
|
+
// URL parser keeps IPv6 brackets on .hostname ("[::1]"); strip
|
|
66
|
+
// them before isIP / prefix matching so the rest of the guard is
|
|
67
|
+
// shape-agnostic.
|
|
68
|
+
let hostname = parsed.hostname.toLowerCase();
|
|
69
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
70
|
+
hostname = hostname.slice(1, -1);
|
|
71
|
+
}
|
|
72
|
+
if (METADATA_IPS.has(hostname)) {
|
|
73
|
+
return {
|
|
74
|
+
allow: false,
|
|
75
|
+
reason: `cloud-metadata IP ${hostname} is rejected (set OMCP_ALLOW_PRIVATE_BACKENDS=true to override)`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (cfg.allowPrivateBackends) {
|
|
79
|
+
return { allow: true };
|
|
80
|
+
}
|
|
81
|
+
const v = isIP(hostname);
|
|
82
|
+
if (v === 4) {
|
|
83
|
+
for (const p of IPV4_PREFIXES) {
|
|
84
|
+
if (hostname.startsWith(p)) {
|
|
85
|
+
return {
|
|
86
|
+
allow: false,
|
|
87
|
+
reason: `private IPv4 ${hostname} is rejected (set OMCP_ALLOW_PRIVATE_BACKENDS=true to allow in-cluster backends)`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (v === 6) {
|
|
93
|
+
for (const p of IPV6_PRIVATE_PREFIXES) {
|
|
94
|
+
if (hostname.startsWith(p)) {
|
|
95
|
+
return {
|
|
96
|
+
allow: false,
|
|
97
|
+
reason: `private IPv6 ${hostname} is rejected (set OMCP_ALLOW_PRIVATE_BACKENDS=true to allow in-cluster backends)`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { allow: true };
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { checkOutboundUrl, ssrfGuardFromEnv } from "./ssrfGuard.js";
|
|
4
|
+
const STRICT = { allowPrivateBackends: false };
|
|
5
|
+
const LAX = { allowPrivateBackends: true };
|
|
6
|
+
test("ssrfGuardFromEnv: defaults strict, opts in on truthy", () => {
|
|
7
|
+
assert.equal(ssrfGuardFromEnv({}).allowPrivateBackends, false);
|
|
8
|
+
for (const v of ["1", "true", "yes", "on", "TRUE"]) {
|
|
9
|
+
assert.equal(ssrfGuardFromEnv({ OMCP_ALLOW_PRIVATE_BACKENDS: v }).allowPrivateBackends, true, v);
|
|
10
|
+
}
|
|
11
|
+
for (const v of ["", "0", "false", "no", "off"]) {
|
|
12
|
+
assert.equal(ssrfGuardFromEnv({ OMCP_ALLOW_PRIVATE_BACKENDS: v }).allowPrivateBackends, false, v);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
test("rejects malformed URLs", () => {
|
|
16
|
+
const r = checkOutboundUrl("not-a-url", STRICT);
|
|
17
|
+
assert.equal(r.allow, false);
|
|
18
|
+
});
|
|
19
|
+
test("rejects non-http(s) schemes", () => {
|
|
20
|
+
assert.equal(checkOutboundUrl("ftp://example.com", STRICT).allow, false);
|
|
21
|
+
assert.equal(checkOutboundUrl("file:///etc/passwd", STRICT).allow, false);
|
|
22
|
+
});
|
|
23
|
+
test("rejects AWS cloud-metadata IP regardless of allowPrivateBackends", () => {
|
|
24
|
+
for (const cfg of [STRICT, LAX]) {
|
|
25
|
+
const r = checkOutboundUrl("http://169.254.169.254/latest/meta-data/", cfg);
|
|
26
|
+
assert.equal(r.allow, false, JSON.stringify(cfg));
|
|
27
|
+
assert.match(r.reason ?? "", /cloud-metadata/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
test("rejects private IPv4 ranges in strict mode", () => {
|
|
31
|
+
for (const url of [
|
|
32
|
+
"http://10.0.0.1/",
|
|
33
|
+
"http://172.16.0.5/",
|
|
34
|
+
"http://172.31.255.1/",
|
|
35
|
+
"http://192.168.1.1/",
|
|
36
|
+
"http://127.0.0.1:9090/",
|
|
37
|
+
"http://169.254.10.1/",
|
|
38
|
+
]) {
|
|
39
|
+
const r = checkOutboundUrl(url, STRICT);
|
|
40
|
+
assert.equal(r.allow, false, url);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test("ACCEPTS private IPs when allowPrivateBackends=true (in-cluster opt-out)", () => {
|
|
44
|
+
for (const url of [
|
|
45
|
+
"http://prometheus.monitoring.svc.cluster.local:9090/",
|
|
46
|
+
"http://10.0.0.1:9090/",
|
|
47
|
+
"http://172.20.0.1:9090/",
|
|
48
|
+
]) {
|
|
49
|
+
assert.equal(checkOutboundUrl(url, LAX).allow, true, url);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
test("accepts public IPv4 / hostnames in strict mode", () => {
|
|
53
|
+
for (const url of [
|
|
54
|
+
"https://prometheus.example.com/api/v1/query",
|
|
55
|
+
"https://8.8.8.8/x",
|
|
56
|
+
"https://1.1.1.1/x",
|
|
57
|
+
]) {
|
|
58
|
+
assert.equal(checkOutboundUrl(url, STRICT).allow, true, url);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("rejects IPv6 loopback + link-local + unique-local in strict mode", () => {
|
|
62
|
+
for (const url of [
|
|
63
|
+
"http://[::1]/",
|
|
64
|
+
"http://[fc00::1]/",
|
|
65
|
+
"http://[fd00::1]/",
|
|
66
|
+
"http://[fe80::1]/",
|
|
67
|
+
]) {
|
|
68
|
+
assert.equal(checkOutboundUrl(url, STRICT).allow, false, url);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
test("172.{16-31} private range edge cases", () => {
|
|
72
|
+
// 172.15 is public; 172.16-172.31 is private; 172.32 is public.
|
|
73
|
+
assert.equal(checkOutboundUrl("http://172.15.0.1/", STRICT).allow, true);
|
|
74
|
+
assert.equal(checkOutboundUrl("http://172.16.0.1/", STRICT).allow, false);
|
|
75
|
+
assert.equal(checkOutboundUrl("http://172.31.0.1/", STRICT).allow, false);
|
|
76
|
+
assert.equal(checkOutboundUrl("http://172.32.0.1/", STRICT).allow, true);
|
|
77
|
+
});
|
|
78
|
+
test("uppercase IPv6 hostnames are still caught", () => {
|
|
79
|
+
// URL parser lowercases hostnames, but we still test for safety.
|
|
80
|
+
assert.equal(checkOutboundUrl("http://[FE80::1]/", STRICT).allow, false);
|
|
81
|
+
});
|
|
@@ -25,6 +25,8 @@ export const EGRESS_ALLOWLIST = [
|
|
|
25
25
|
{ prefix: "connectors/", reason: "connectors query operator-configured source backends" },
|
|
26
26
|
{ prefix: "cli/index.ts", reason: "CLI fetches a source location the operator passed explicitly" },
|
|
27
27
|
{ prefix: "index.ts", reason: "connector-hub plugin install of an operator/registry-requested tarball URL" },
|
|
28
|
+
{ prefix: "auth/oidc/", reason: "OIDC client calls the operator-configured OMCP_OIDC_ISSUER for discovery, JWKS, and code-exchange" },
|
|
29
|
+
{ prefix: "auth/policy/", reason: "OpaPolicyEngine queries the operator-configured OMCP_OPA_URL on every RBAC decision" },
|
|
28
30
|
];
|
|
29
31
|
/**
|
|
30
32
|
* Hard-blocked analytics/telemetry SDKs — matches an *import/require of the
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface OtelInitResult {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
endpoint?: string;
|
|
4
|
+
serviceName?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function isOtelEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
8
|
+
export declare function otelStatus(): OtelInitResult;
|
|
9
|
+
/**
|
|
10
|
+
* Idempotent init. Returns synchronously; the SDK starts in the
|
|
11
|
+
* background. Safe to call multiple times — the second call is a
|
|
12
|
+
* no-op.
|
|
13
|
+
*/
|
|
14
|
+
export declare function initOtel(opts?: {
|
|
15
|
+
serviceVersion?: string;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
}): Promise<OtelInitResult>;
|
|
18
|
+
export declare function parseOtelHeaders(raw: string | undefined): Record<string, string> | undefined;
|
|
19
|
+
/** Test-only — resets internal state so re-init can be exercised. */
|
|
20
|
+
export declare function _resetOtelForTests(): void;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// OpenTelemetry self-instrumentation of the gateway.
|
|
2
|
+
//
|
|
3
|
+
// Opt-in via OMCP_OTEL_ENABLED=true; default off so the OSS demo and
|
|
4
|
+
// any deployment that does not run a collector stays silent. When on,
|
|
5
|
+
// the Node SDK auto-instruments HTTP (covers /api/* and /mcp routes
|
|
6
|
+
// out of the box) and exports to an OTLP/HTTP endpoint.
|
|
7
|
+
//
|
|
8
|
+
// Env:
|
|
9
|
+
// OMCP_OTEL_ENABLED true|1|yes to enable (default off)
|
|
10
|
+
// OMCP_OTEL_ENDPOINT OTLP/HTTP traces URL (default http://localhost:4318/v1/traces)
|
|
11
|
+
// OMCP_OTEL_HEADERS "key1=val1,key2=val2" for collector auth
|
|
12
|
+
// OMCP_OTEL_SERVICE_NAME override resource service.name (default observability-mcp)
|
|
13
|
+
// OMCP_OTEL_SERVICE_VERSION override resource service.version (default from package.json at runtime)
|
|
14
|
+
//
|
|
15
|
+
// Imports are dynamic so the @opentelemetry/* packages stay outside the
|
|
16
|
+
// startup hot path when otel is disabled.
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
let initialized = false;
|
|
19
|
+
let result = { enabled: false, reason: "init not called" };
|
|
20
|
+
export function isOtelEnabled(env = process.env) {
|
|
21
|
+
return /^(1|true|yes|on)$/i.test(env.OMCP_OTEL_ENABLED ?? "");
|
|
22
|
+
}
|
|
23
|
+
export function otelStatus() {
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Idempotent init. Returns synchronously; the SDK starts in the
|
|
28
|
+
* background. Safe to call multiple times — the second call is a
|
|
29
|
+
* no-op.
|
|
30
|
+
*/
|
|
31
|
+
export async function initOtel(opts = {}) {
|
|
32
|
+
if (initialized)
|
|
33
|
+
return result;
|
|
34
|
+
initialized = true;
|
|
35
|
+
const env = opts.env ?? process.env;
|
|
36
|
+
if (!isOtelEnabled(env)) {
|
|
37
|
+
result = { enabled: false, reason: "OMCP_OTEL_ENABLED is off" };
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const endpoint = env.OMCP_OTEL_ENDPOINT ?? "http://localhost:4318/v1/traces";
|
|
41
|
+
const serviceName = env.OMCP_OTEL_SERVICE_NAME ?? "observability-mcp";
|
|
42
|
+
const serviceVersion = env.OMCP_OTEL_SERVICE_VERSION ?? opts.serviceVersion ?? "unknown";
|
|
43
|
+
const headers = parseOtelHeaders(env.OMCP_OTEL_HEADERS);
|
|
44
|
+
try {
|
|
45
|
+
// Dynamic imports keep the cold-start overhead off the
|
|
46
|
+
// OTEL-disabled path. Failures here log + degrade gracefully —
|
|
47
|
+
// the gateway must never refuse to boot because tracing is
|
|
48
|
+
// misconfigured.
|
|
49
|
+
const { NodeSDK } = await import("@opentelemetry/sdk-node");
|
|
50
|
+
const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
|
|
51
|
+
const { resourceFromAttributes } = await import("@opentelemetry/resources");
|
|
52
|
+
const { getNodeAutoInstrumentations } = await import("@opentelemetry/auto-instrumentations-node");
|
|
53
|
+
const semconv = await import("@opentelemetry/semantic-conventions");
|
|
54
|
+
const ATTR_SERVICE_NAME = semconv.ATTR_SERVICE_NAME ??
|
|
55
|
+
semconv.SEMRESATTRS_SERVICE_NAME ??
|
|
56
|
+
"service.name";
|
|
57
|
+
const ATTR_SERVICE_VERSION = semconv.ATTR_SERVICE_VERSION ??
|
|
58
|
+
semconv.SEMRESATTRS_SERVICE_VERSION ??
|
|
59
|
+
"service.version";
|
|
60
|
+
const ATTR_SERVICE_INSTANCE_ID = semconv.ATTR_SERVICE_INSTANCE_ID ??
|
|
61
|
+
semconv.SEMRESATTRS_SERVICE_INSTANCE_ID ??
|
|
62
|
+
"service.instance.id";
|
|
63
|
+
const sdk = new NodeSDK({
|
|
64
|
+
resource: resourceFromAttributes({
|
|
65
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
66
|
+
[ATTR_SERVICE_VERSION]: serviceVersion,
|
|
67
|
+
[ATTR_SERVICE_INSTANCE_ID]: hostname(),
|
|
68
|
+
}),
|
|
69
|
+
traceExporter: new OTLPTraceExporter({ url: endpoint, headers }),
|
|
70
|
+
instrumentations: [
|
|
71
|
+
getNodeAutoInstrumentations({
|
|
72
|
+
// Filesystem instrumentation generates a span per fs call —
|
|
73
|
+
// it explodes the trace volume for negligible value at the
|
|
74
|
+
// gateway level. Off by default; operators can re-enable
|
|
75
|
+
// via direct SDK config if they need it.
|
|
76
|
+
"@opentelemetry/instrumentation-fs": { enabled: false },
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
sdk.start();
|
|
81
|
+
// Best-effort flush on shutdown so in-flight spans reach the
|
|
82
|
+
// collector during a rolling restart.
|
|
83
|
+
process.on("SIGTERM", () => {
|
|
84
|
+
sdk
|
|
85
|
+
.shutdown()
|
|
86
|
+
.catch((err) => console.warn("OTel SDK shutdown failed:", err));
|
|
87
|
+
});
|
|
88
|
+
result = { enabled: true, endpoint, serviceName };
|
|
89
|
+
console.log("OTel self-tracing enabled: exporting to %s as service.name=%s", endpoint, serviceName);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
result = {
|
|
94
|
+
enabled: false,
|
|
95
|
+
reason: `OTel init failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
+
};
|
|
97
|
+
console.warn("OTel self-tracing requested but init failed; gateway continues without tracing. %s", result.reason);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function parseOtelHeaders(raw) {
|
|
102
|
+
if (!raw)
|
|
103
|
+
return undefined;
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const pair of raw.split(",")) {
|
|
106
|
+
const [k, ...rest] = pair.split("=");
|
|
107
|
+
const key = k?.trim();
|
|
108
|
+
const value = rest.join("=").trim();
|
|
109
|
+
if (key && value)
|
|
110
|
+
out[key] = value;
|
|
111
|
+
}
|
|
112
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
113
|
+
}
|
|
114
|
+
/** Test-only — resets internal state so re-init can be exercised. */
|
|
115
|
+
export function _resetOtelForTests() {
|
|
116
|
+
initialized = false;
|
|
117
|
+
result = { enabled: false, reason: "init not called" };
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { initOtel, isOtelEnabled, parseOtelHeaders, otelStatus, _resetOtelForTests, } from "./otel.js";
|
|
4
|
+
test("isOtelEnabled — accepts true/1/yes/on (any case), rejects others", () => {
|
|
5
|
+
for (const v of ["true", "1", "yes", "on", "TRUE", "Yes", "ON"]) {
|
|
6
|
+
assert.equal(isOtelEnabled({ OMCP_OTEL_ENABLED: v }), true, v);
|
|
7
|
+
}
|
|
8
|
+
for (const v of ["", "false", "0", "no", "off", "anything-else"]) {
|
|
9
|
+
assert.equal(isOtelEnabled({ OMCP_OTEL_ENABLED: v }), false, v);
|
|
10
|
+
}
|
|
11
|
+
assert.equal(isOtelEnabled({}), false, "unset env");
|
|
12
|
+
});
|
|
13
|
+
test("parseOtelHeaders — splits comma-separated key=value pairs", () => {
|
|
14
|
+
assert.deepEqual(parseOtelHeaders("a=1,b=2"), { a: "1", b: "2" });
|
|
15
|
+
assert.deepEqual(parseOtelHeaders(" a = 1 , b = 2 "), { a: "1", b: "2" });
|
|
16
|
+
// Value containing `=` is preserved
|
|
17
|
+
assert.deepEqual(parseOtelHeaders("Authorization=Bearer abc=def"), {
|
|
18
|
+
Authorization: "Bearer abc=def",
|
|
19
|
+
});
|
|
20
|
+
assert.equal(parseOtelHeaders(""), undefined);
|
|
21
|
+
assert.equal(parseOtelHeaders(undefined), undefined);
|
|
22
|
+
// Drops malformed entries silently rather than throwing
|
|
23
|
+
assert.deepEqual(parseOtelHeaders("nokey,b=2"), { b: "2" });
|
|
24
|
+
});
|
|
25
|
+
test("initOtel — no-op when OMCP_OTEL_ENABLED is off", async () => {
|
|
26
|
+
_resetOtelForTests();
|
|
27
|
+
const r = await initOtel({ env: { OMCP_OTEL_ENABLED: undefined } });
|
|
28
|
+
assert.equal(r.enabled, false);
|
|
29
|
+
assert.match(r.reason ?? "", /OMCP_OTEL_ENABLED is off/);
|
|
30
|
+
});
|
|
31
|
+
test("initOtel — second call is idempotent (returns cached result)", async () => {
|
|
32
|
+
_resetOtelForTests();
|
|
33
|
+
const r1 = await initOtel({ env: { OMCP_OTEL_ENABLED: "false" } });
|
|
34
|
+
const r2 = await initOtel({ env: { OMCP_OTEL_ENABLED: "true" } });
|
|
35
|
+
// Second call MUST return the cached result, not re-init
|
|
36
|
+
assert.equal(r1.enabled, false);
|
|
37
|
+
assert.equal(r2.enabled, false, "second init returned cached disabled state");
|
|
38
|
+
assert.deepEqual(otelStatus(), r1);
|
|
39
|
+
});
|
|
40
|
+
test("initOtel — failure to import OTel packages degrades to disabled, not throw", async () => {
|
|
41
|
+
// We cannot easily simulate a missing dep in this sandbox, but we
|
|
42
|
+
// confirm the contract: on init failure, the result has enabled=false
|
|
43
|
+
// and a reason; nothing is thrown. With the actual deps installed the
|
|
44
|
+
// happy path is exercised in integration tests.
|
|
45
|
+
_resetOtelForTests();
|
|
46
|
+
const r = await initOtel({ env: { OMCP_OTEL_ENABLED: "true" } });
|
|
47
|
+
// Either it succeeded (deps present, enabled=true) or it failed
|
|
48
|
+
// gracefully (enabled=false + reason). Both are acceptable.
|
|
49
|
+
if (r.enabled) {
|
|
50
|
+
assert.ok(r.endpoint, "endpoint must be set when enabled");
|
|
51
|
+
assert.equal(r.serviceName, "observability-mcp");
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
assert.ok(r.reason, "must include a reason on failure");
|
|
55
|
+
}
|
|
56
|
+
});
|