@thotischner/observability-mcp 3.6.1 → 3.8.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/dist/auth/policy/loader.js +1 -1
- package/dist/auth/rbac.d.ts +1 -1
- package/dist/auth/rbac.js +3 -1
- package/dist/auth/rbac.test.js +5 -3
- package/dist/conformance/inspect-e2e.test.d.ts +1 -0
- package/dist/conformance/inspect-e2e.test.js +104 -0
- package/dist/connectors/loader.js +30 -0
- package/dist/connectors/loader.test.js +11 -0
- package/dist/enrich/rdap.d.ts +40 -0
- package/dist/enrich/rdap.js +122 -0
- package/dist/enrich/rdap.test.d.ts +1 -0
- package/dist/enrich/rdap.test.js +78 -0
- package/dist/enterprise-gate.d.ts +28 -0
- package/dist/enterprise-gate.js +51 -0
- package/dist/enterprise-gate.test.js +21 -1
- package/dist/index.js +296 -8
- package/dist/inspect/enforcer.d.ts +19 -0
- package/dist/inspect/enforcer.js +69 -0
- package/dist/inspect/enforcer.test.d.ts +1 -0
- package/dist/inspect/enforcer.test.js +76 -0
- package/dist/inspect/graph.d.ts +33 -0
- package/dist/inspect/graph.js +0 -0
- package/dist/inspect/graph.test.d.ts +1 -0
- package/dist/inspect/graph.test.js +74 -0
- package/dist/inspect/index.d.ts +8 -0
- package/dist/inspect/index.js +13 -0
- package/dist/inspect/mode.d.ts +20 -0
- package/dist/inspect/mode.js +57 -0
- package/dist/inspect/mode.test.d.ts +1 -0
- package/dist/inspect/mode.test.js +53 -0
- package/dist/inspect/profile-store.d.ts +42 -0
- package/dist/inspect/profile-store.js +139 -0
- package/dist/inspect/profile-store.test.d.ts +1 -0
- package/dist/inspect/profile-store.test.js +82 -0
- package/dist/inspect/profile.d.ts +51 -0
- package/dist/inspect/profile.js +111 -0
- package/dist/inspect/profile.test.d.ts +1 -0
- package/dist/inspect/profile.test.js +96 -0
- package/dist/inspect/recorder.d.ts +42 -0
- package/dist/inspect/recorder.js +72 -0
- package/dist/inspect/recorder.test.d.ts +1 -0
- package/dist/inspect/recorder.test.js +112 -0
- package/dist/inspect/signature.d.ts +32 -0
- package/dist/inspect/signature.js +200 -0
- package/dist/inspect/signature.test.d.ts +1 -0
- package/dist/inspect/signature.test.js +136 -0
- package/dist/inspect/store.d.ts +62 -0
- package/dist/inspect/store.js +76 -0
- package/dist/inspect/store.test.d.ts +1 -0
- package/dist/inspect/store.test.js +78 -0
- package/dist/metrics/self.d.ts +3 -0
- package/dist/metrics/self.js +19 -0
- package/dist/net/egress-policy.js +1 -0
- package/dist/tenancy/context.d.ts +7 -0
- package/dist/tenancy/context.js +15 -0
- package/dist/tenancy/context.test.js +18 -1
- package/dist/tools/enrich-ips.d.ts +6 -2
- package/dist/tools/enrich-ips.js +32 -11
- package/dist/tools/enrich-ips.test.js +55 -11
- package/dist/ui/index.html +742 -0
- package/package.json +3 -2
|
@@ -27,7 +27,7 @@ import { BuiltinPolicyEngine } from "./engine.js";
|
|
|
27
27
|
export const VALID_RESOURCES = new Set([
|
|
28
28
|
"sources", "services", "health", "topology", "settings",
|
|
29
29
|
"connectors", "audit", "catalog", "users", "redaction",
|
|
30
|
-
"products",
|
|
30
|
+
"products", "inspection",
|
|
31
31
|
]);
|
|
32
32
|
export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
|
|
33
33
|
export class PolicyLoadError extends Error {
|
package/dist/auth/rbac.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { RequestHandler } from "express";
|
|
|
18
18
|
import type { AuthRuntime } from "./middleware.js";
|
|
19
19
|
import type { PolicyEngine } from "./policy/engine.js";
|
|
20
20
|
export type Action = "read" | "write" | "delete" | "bypass";
|
|
21
|
-
export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products";
|
|
21
|
+
export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products" | "inspection";
|
|
22
22
|
export interface Permission {
|
|
23
23
|
resource: Resource;
|
|
24
24
|
action: Action;
|
package/dist/auth/rbac.js
CHANGED
|
@@ -30,6 +30,7 @@ export const DEFAULT_POLICY = {
|
|
|
30
30
|
{ resource: "audit", action: "read" },
|
|
31
31
|
{ resource: "catalog", action: "read" },
|
|
32
32
|
{ resource: "products", action: "read" },
|
|
33
|
+
{ resource: "inspection", action: "read" },
|
|
33
34
|
],
|
|
34
35
|
operator: [
|
|
35
36
|
// Inherits viewer's read set + write on operational resources.
|
|
@@ -46,10 +47,11 @@ export const DEFAULT_POLICY = {
|
|
|
46
47
|
{ resource: "catalog", action: "read" },
|
|
47
48
|
{ resource: "products", action: "read" },
|
|
48
49
|
{ resource: "products", action: "write" },
|
|
50
|
+
{ resource: "inspection", action: "read" },
|
|
49
51
|
],
|
|
50
52
|
admin: [
|
|
51
53
|
// Full surface — readable + writable + deletable.
|
|
52
|
-
...["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products"]
|
|
54
|
+
...["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products", "inspection"]
|
|
53
55
|
.flatMap((r) => ["read", "write", "delete"].map((a) => ({ resource: r, action: a }))),
|
|
54
56
|
// Special: admins may bypass log-redaction on per-call MCP tool
|
|
55
57
|
// invocations (when the bearer credential ALSO opts in via
|
package/dist/auth/rbac.test.js
CHANGED
|
@@ -108,11 +108,13 @@ test("listGrantedPermissions — deduplicates across overlapping roles", () => {
|
|
|
108
108
|
});
|
|
109
109
|
test("listGrantedPermissions — admin lists every (resource, action) once", () => {
|
|
110
110
|
const p = listGrantedPermissions(["admin"]);
|
|
111
|
-
//
|
|
112
|
-
// =
|
|
113
|
-
|
|
111
|
+
// 11 resources (added 'products' in the products-RBAC phase, 'inspection'
|
|
112
|
+
// in the Inspect phase) * 3 actions = 33, plus the special redaction:bypass
|
|
113
|
+
// entry = 34.
|
|
114
|
+
assert.equal(p.length, 34);
|
|
114
115
|
assert.ok(p.some((g) => g.resource === "redaction" && g.action === "bypass"));
|
|
115
116
|
assert.ok(p.some((g) => g.resource === "products" && g.action === "delete"));
|
|
117
|
+
assert.ok(p.some((g) => g.resource === "inspection" && g.action === "write"));
|
|
116
118
|
});
|
|
117
119
|
test("DEFAULT_POLICY shape — has the three built-in roles", () => {
|
|
118
120
|
assert.ok(DEFAULT_POLICY.viewer);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Inspect observe-path E2E (live).
|
|
2
|
+
//
|
|
3
|
+
// Proves the full chain: a real tools/call over the Streamable HTTP transport
|
|
4
|
+
// is captured by the observe recorder and surfaces on /api/inspect/events +
|
|
5
|
+
// /api/inspect/flows. Runs against a booted gateway via OMCP_CONFORMANCE_URL
|
|
6
|
+
// (the same env the spec-conformance harness uses); skips entirely when unset
|
|
7
|
+
// so a plain unit-test run stays hermetic.
|
|
8
|
+
//
|
|
9
|
+
// OMCP_CONFORMANCE_URL=http://localhost:3000/mcp \
|
|
10
|
+
// npx tsx --test src/conformance/inspect-e2e.test.ts
|
|
11
|
+
//
|
|
12
|
+
// integration.yml runs this after booting the demo stack.
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
const URL_ENV = process.env.OMCP_CONFORMANCE_URL;
|
|
16
|
+
const skip = !URL_ENV;
|
|
17
|
+
const opts = skip ? { skip: "OMCP_CONFORMANCE_URL not set" } : {};
|
|
18
|
+
const base = (URL_ENV ?? "").replace(/\/mcp\/?$/, "");
|
|
19
|
+
async function rpc(method, params, session) {
|
|
20
|
+
const headers = {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
accept: "application/json, text/event-stream",
|
|
23
|
+
};
|
|
24
|
+
if (session)
|
|
25
|
+
headers["mcp-session-id"] = session;
|
|
26
|
+
const res = await fetch(URL_ENV, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
30
|
+
});
|
|
31
|
+
return { text: await res.text(), session: res.headers.get("mcp-session-id") ?? session };
|
|
32
|
+
}
|
|
33
|
+
test("observe: a real tools/call surfaces on /api/inspect/events and /flows", opts, async () => {
|
|
34
|
+
// 1. Handshake — open an MCP session.
|
|
35
|
+
const init = await rpc("initialize", {
|
|
36
|
+
protocolVersion: "2025-11-25",
|
|
37
|
+
capabilities: {},
|
|
38
|
+
clientInfo: { name: "inspect-e2e", version: "0" },
|
|
39
|
+
});
|
|
40
|
+
const session = init.session;
|
|
41
|
+
assert.ok(session, "server returned a session id");
|
|
42
|
+
await fetch(URL_ENV, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-session-id": session },
|
|
45
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
|
|
46
|
+
});
|
|
47
|
+
// 2. Make an observable tool call. list_sources is always registered and
|
|
48
|
+
// needs no backend data, so it's a stable probe.
|
|
49
|
+
await rpc("tools/call", { name: "list_sources", arguments: {} }, session);
|
|
50
|
+
// 3. The observe recorder is synchronous on the post-invoke hook, but give
|
|
51
|
+
// the event loop a tick before reading back.
|
|
52
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
53
|
+
// 4. /api/inspect/events shows the call.
|
|
54
|
+
const evRes = await fetch(`${base}/api/inspect/events?tool=list_sources&limit=50`);
|
|
55
|
+
assert.equal(evRes.status, 200, "events endpoint reachable");
|
|
56
|
+
const ev = (await evRes.json());
|
|
57
|
+
assert.ok(["observe", "dryrun", "enforce"].includes(ev.mode), `recording mode active (got ${ev.mode})`);
|
|
58
|
+
assert.ok(ev.events.some((e) => e.tool === "list_sources" && e.decision === "allow"), "list_sources call was observed");
|
|
59
|
+
// 5. /api/inspect/flows includes the tool node + an edge into it.
|
|
60
|
+
const flRes = await fetch(`${base}/api/inspect/flows?window=1h`);
|
|
61
|
+
assert.equal(flRes.status, 200, "flows endpoint reachable");
|
|
62
|
+
const fl = (await flRes.json());
|
|
63
|
+
assert.ok(fl.total >= 1, "flow graph has traffic");
|
|
64
|
+
assert.ok(fl.nodes.some((n) => n.id === "tool:list_sources"), "tool node present in the flow graph");
|
|
65
|
+
});
|
|
66
|
+
test("/api/inspect/mode reports a recording mode", opts, async () => {
|
|
67
|
+
const res = await fetch(`${base}/api/inspect/mode`);
|
|
68
|
+
assert.equal(res.status, 200);
|
|
69
|
+
const m = (await res.json());
|
|
70
|
+
assert.ok(["off", "observe", "dryrun", "enforce"].includes(m.mode));
|
|
71
|
+
});
|
|
72
|
+
test("enforce is gated by entitlement; observe/dry-run are free", opts, async () => {
|
|
73
|
+
// CSRF double-submit: grab the issued token, echo it on every mutation.
|
|
74
|
+
const probe = await fetch(`${base}/api/inspect/mode`);
|
|
75
|
+
const sc = probe.headers.get("set-cookie") || "";
|
|
76
|
+
const tok = (sc.match(/omcp-csrf=([^;]+)/) || [])[1];
|
|
77
|
+
const csrf = tok ? { "x-csrf-token": decodeURIComponent(tok), cookie: `omcp-csrf=${tok}` } : {};
|
|
78
|
+
const write = (path, method, body) => fetch(`${base}${path}`, { method, headers: { "content-type": "application/json", ...csrf }, body: JSON.stringify(body) });
|
|
79
|
+
const modeBody = (await probe.json());
|
|
80
|
+
// Dry-run is always available (OSS).
|
|
81
|
+
const dry = await write("/api/inspect/mode", "PUT", { mode: "dryrun" });
|
|
82
|
+
assert.equal(dry.status, 200, "dry-run is free");
|
|
83
|
+
try {
|
|
84
|
+
const enf = await write("/api/inspect/mode", "PUT", { mode: "enforce" });
|
|
85
|
+
if (modeBody.enforceEntitled) {
|
|
86
|
+
// Licensed: enforce switches on and blocks out-of-profile calls.
|
|
87
|
+
assert.equal(enf.status, 200, "enforce accepted when entitled");
|
|
88
|
+
const init = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "inspect-e2e", version: "0" } });
|
|
89
|
+
const session = init.session;
|
|
90
|
+
await fetch(URL_ENV, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-session-id": session }, body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) });
|
|
91
|
+
const blocked = await rpc("tools/call", { name: "list_services", arguments: {} }, session);
|
|
92
|
+
assert.match(blocked.text, /Blocked by the inspection profile|inspection profile/, "out-of-profile call blocked when entitled");
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// OSS default: enforce is refused with a clear entitlement error.
|
|
96
|
+
assert.equal(enf.status, 403, "enforce refused without entitlement");
|
|
97
|
+
const body = (await enf.json());
|
|
98
|
+
assert.equal(body.code, "OMCP_ENTITLEMENT_REQUIRED");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await write("/api/inspect/mode", "PUT", { mode: "observe" }).catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -96,19 +96,49 @@ export class PluginLoader {
|
|
|
96
96
|
return instrumentConnector(c);
|
|
97
97
|
}
|
|
98
98
|
loadBuiltins() {
|
|
99
|
+
// Builtins carry inline manifest metadata so the Installed Connectors UI
|
|
100
|
+
// shows a name/description/version like filesystem plugins do (without it,
|
|
101
|
+
// describeInstalled() falls back to an empty description). Mirrors
|
|
102
|
+
// plugins/<name>/manifest.json — keep the description text in sync.
|
|
99
103
|
this.register({
|
|
100
104
|
name: "prometheus",
|
|
101
105
|
source: "builtin",
|
|
106
|
+
manifest: {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
name: "prometheus",
|
|
109
|
+
displayName: "Prometheus",
|
|
110
|
+
version: "1.0.0",
|
|
111
|
+
description: "PromQL-based metrics backend with prom-client default scrape support and dynamic service-label resolution.",
|
|
112
|
+
signalTypes: ["metrics"],
|
|
113
|
+
capabilities: { queryMetrics: true, listServices: true, listAvailableMetrics: true },
|
|
114
|
+
},
|
|
102
115
|
factory: () => new PrometheusConnector(),
|
|
103
116
|
});
|
|
104
117
|
this.register({
|
|
105
118
|
name: "loki",
|
|
106
119
|
source: "builtin",
|
|
120
|
+
manifest: {
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
name: "loki",
|
|
123
|
+
displayName: "Loki",
|
|
124
|
+
version: "1.0.0",
|
|
125
|
+
description: "LogQL-based log backend with dynamic service-label discovery (service_name / service / job / app / container).",
|
|
126
|
+
signalTypes: ["logs"],
|
|
127
|
+
capabilities: { queryLogs: true, listServices: true },
|
|
128
|
+
},
|
|
107
129
|
factory: () => new LokiConnector(),
|
|
108
130
|
});
|
|
109
131
|
this.register({
|
|
110
132
|
name: "kubernetes",
|
|
111
133
|
source: "builtin",
|
|
134
|
+
manifest: {
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
name: "kubernetes",
|
|
137
|
+
displayName: "Kubernetes",
|
|
138
|
+
version: "0.1.0",
|
|
139
|
+
description: "Watches a Kubernetes cluster (in-cluster or via kubeconfig) and exposes pods, nodes, deployments, replicasets and namespaces as an infrastructure topology graph. Edges: RUNS_ON (pod→node), OWNED_BY (pod→rs→deployment), IN_NAMESPACE.",
|
|
140
|
+
signalTypes: ["topology"],
|
|
141
|
+
},
|
|
112
142
|
factory: () => new KubernetesConnector(),
|
|
113
143
|
});
|
|
114
144
|
}
|
|
@@ -76,3 +76,14 @@ test("PluginLoader.load(): with verify on + no trust root → builtins still loa
|
|
|
76
76
|
assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
|
+
test("PluginLoader.load(): builtins carry manifest metadata (description shows in Installed Connectors)", async () => {
|
|
80
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
81
|
+
await loader.load();
|
|
82
|
+
for (const name of ["prometheus", "loki", "kubernetes"]) {
|
|
83
|
+
const c = loader.get(name);
|
|
84
|
+
assert.ok(c, `${name} builtin present`);
|
|
85
|
+
assert.ok(c.manifest, `${name} builtin has a manifest`);
|
|
86
|
+
assert.ok((c.manifest.description || "").length > 10, `${name} builtin has a non-empty description`);
|
|
87
|
+
assert.equal(c.manifest.name, name);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IpEnrichment } from "./ip-dataset.js";
|
|
2
|
+
/** Minimal fetch surface so tests can inject a stub (no real network). */
|
|
3
|
+
export type FetchLike = (url: string, init?: {
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
}) => Promise<{
|
|
6
|
+
ok: boolean;
|
|
7
|
+
status: number;
|
|
8
|
+
json: () => Promise<unknown>;
|
|
9
|
+
}>;
|
|
10
|
+
export interface RdapResolverOptions {
|
|
11
|
+
/** Bootstrap base; rdap.org redirects to the authoritative RIR. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Cache TTL in ms (default 1h). Negative results cached for a shorter time. */
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
/** Per-request timeout in ms (default 4000). */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Injected fetch (defaults to global fetch). */
|
|
18
|
+
fetch?: FetchLike;
|
|
19
|
+
/** Max cache entries (LRU-ish trim). */
|
|
20
|
+
maxCache?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Parse an RDAP IP-network response into our enrichment shape. country +
|
|
23
|
+
* org/name only; RDAP carries no city or hosting flag. */
|
|
24
|
+
export declare function parseRdapResponse(body: unknown): IpEnrichment | null;
|
|
25
|
+
export declare class RdapResolver {
|
|
26
|
+
private readonly baseUrl;
|
|
27
|
+
private readonly ttlMs;
|
|
28
|
+
private readonly negTtlMs;
|
|
29
|
+
private readonly timeoutMs;
|
|
30
|
+
private readonly fetch;
|
|
31
|
+
private readonly maxCache;
|
|
32
|
+
private cache;
|
|
33
|
+
/** Monotonic clock injected for tests; defaults to Date.now via a getter. */
|
|
34
|
+
now: () => number;
|
|
35
|
+
constructor(opts?: RdapResolverOptions);
|
|
36
|
+
/** Look up one IP via RDAP. Returns null on miss/error (never throws —
|
|
37
|
+
* a flaky RIR must not fail the batch). Cached by IP with a TTL. */
|
|
38
|
+
lookup(ip: string): Promise<IpEnrichment | null>;
|
|
39
|
+
private put;
|
|
40
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Optional ONLINE IP enrichment via RDAP (RFC 9082/9083) — issue #477.
|
|
2
|
+
//
|
|
3
|
+
// OFF by default. The offline OMCP_IP_ENRICH_FILE dataset is the preferred,
|
|
4
|
+
// air-gapped path; RDAP is a zero-setup fallback for non-air-gapped operators
|
|
5
|
+
// who don't want to provision a MaxMind licence just to answer "where is this
|
|
6
|
+
// IP / is it a datacenter". When enabled (OMCP_IP_ENRICH_RDAP=on) the gateway
|
|
7
|
+
// queries the authoritative RIR over HTTPS via the rdap.org bootstrap.
|
|
8
|
+
//
|
|
9
|
+
// Privacy: RDAP queries the authoritative registry (not a third-party geo
|
|
10
|
+
// broker) and yields country + org/network-name only (no city, no hosting
|
|
11
|
+
// flag — same limits called out in #477). Results are cached with a TTL to
|
|
12
|
+
// respect RIR rate limits.
|
|
13
|
+
//
|
|
14
|
+
// This module makes NO network call unless an operator has opted in and the
|
|
15
|
+
// resolver is actually constructed (see index.ts) — the air-gapped default of
|
|
16
|
+
// enrich_ips is preserved.
|
|
17
|
+
import { ipv4ToInt, ipv6ToBigInt } from "./ip-dataset.js";
|
|
18
|
+
/** Pull a display org name out of an RDAP entity's jCard (vcardArray). */
|
|
19
|
+
function orgFromEntities(entities) {
|
|
20
|
+
if (!Array.isArray(entities))
|
|
21
|
+
return undefined;
|
|
22
|
+
// Prefer registrant, then any entity with an fn.
|
|
23
|
+
const ordered = [...entities].sort((a, b) => roleRank(b) - roleRank(a));
|
|
24
|
+
for (const e of ordered) {
|
|
25
|
+
const fn = fnFromVcard(e.vcardArray);
|
|
26
|
+
if (fn)
|
|
27
|
+
return fn;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function roleRank(e) {
|
|
32
|
+
const roles = e.roles;
|
|
33
|
+
if (Array.isArray(roles) && roles.includes("registrant"))
|
|
34
|
+
return 2;
|
|
35
|
+
if (Array.isArray(roles) && roles.includes("registrar"))
|
|
36
|
+
return 1;
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
function fnFromVcard(vcardArray) {
|
|
40
|
+
// jCard shape: ["vcard", [ ["version",{},"text","4.0"], ["fn",{},"text","Google LLC"], ... ]]
|
|
41
|
+
if (!Array.isArray(vcardArray) || vcardArray.length < 2 || !Array.isArray(vcardArray[1]))
|
|
42
|
+
return undefined;
|
|
43
|
+
for (const prop of vcardArray[1]) {
|
|
44
|
+
if (Array.isArray(prop) && prop[0] === "fn" && typeof prop[3] === "string" && prop[3].trim()) {
|
|
45
|
+
return prop[3].trim();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Parse an RDAP IP-network response into our enrichment shape. country +
|
|
51
|
+
* org/name only; RDAP carries no city or hosting flag. */
|
|
52
|
+
export function parseRdapResponse(body) {
|
|
53
|
+
if (!body || typeof body !== "object")
|
|
54
|
+
return null;
|
|
55
|
+
const b = body;
|
|
56
|
+
const country = typeof b.country === "string" && b.country.trim() ? b.country.trim() : undefined;
|
|
57
|
+
const org = orgFromEntities(b.entities) || (typeof b.name === "string" && b.name.trim() ? b.name.trim() : undefined);
|
|
58
|
+
if (!country && !org)
|
|
59
|
+
return null;
|
|
60
|
+
const out = {};
|
|
61
|
+
if (country)
|
|
62
|
+
out.country = country;
|
|
63
|
+
if (org)
|
|
64
|
+
out.org = org;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
export class RdapResolver {
|
|
68
|
+
baseUrl;
|
|
69
|
+
ttlMs;
|
|
70
|
+
negTtlMs;
|
|
71
|
+
timeoutMs;
|
|
72
|
+
fetch;
|
|
73
|
+
maxCache;
|
|
74
|
+
cache = new Map();
|
|
75
|
+
/** Monotonic clock injected for tests; defaults to Date.now via a getter. */
|
|
76
|
+
now;
|
|
77
|
+
constructor(opts = {}) {
|
|
78
|
+
this.baseUrl = (opts.baseUrl || "https://rdap.org").replace(/\/$/, "");
|
|
79
|
+
this.ttlMs = opts.ttlMs ?? 3_600_000;
|
|
80
|
+
this.negTtlMs = Math.min(this.ttlMs, 300_000);
|
|
81
|
+
this.timeoutMs = opts.timeoutMs ?? 4000;
|
|
82
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
83
|
+
this.maxCache = opts.maxCache ?? 10_000;
|
|
84
|
+
this.now = () => Date.now();
|
|
85
|
+
}
|
|
86
|
+
/** Look up one IP via RDAP. Returns null on miss/error (never throws —
|
|
87
|
+
* a flaky RIR must not fail the batch). Cached by IP with a TTL. */
|
|
88
|
+
async lookup(ip) {
|
|
89
|
+
if (ipv4ToInt(ip) === null && ipv6ToBigInt(ip) === null)
|
|
90
|
+
return null;
|
|
91
|
+
const cached = this.cache.get(ip);
|
|
92
|
+
if (cached && cached.expiresAt > this.now())
|
|
93
|
+
return cached.value;
|
|
94
|
+
let value = null;
|
|
95
|
+
try {
|
|
96
|
+
const ac = new AbortController();
|
|
97
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
98
|
+
try {
|
|
99
|
+
const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
|
|
100
|
+
if (res.ok)
|
|
101
|
+
value = parseRdapResponse(await res.json());
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
value = null; // network/timeout/parse — treat as a miss
|
|
109
|
+
}
|
|
110
|
+
this.put(ip, { value, expiresAt: this.now() + (value ? this.ttlMs : this.negTtlMs) });
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
put(ip, entry) {
|
|
114
|
+
if (this.cache.size >= this.maxCache) {
|
|
115
|
+
// Drop the oldest insertion (Map preserves insertion order).
|
|
116
|
+
const oldest = this.cache.keys().next().value;
|
|
117
|
+
if (oldest !== undefined)
|
|
118
|
+
this.cache.delete(oldest);
|
|
119
|
+
}
|
|
120
|
+
this.cache.set(ip, entry);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseRdapResponse, RdapResolver } from "./rdap.js";
|
|
4
|
+
// A realistic RDAP IP-network response (trimmed). org comes from the
|
|
5
|
+
// registrant entity's jCard fn; country is top-level.
|
|
6
|
+
const RDAP_GOOGLE = {
|
|
7
|
+
handle: "GOGL",
|
|
8
|
+
name: "GOGL",
|
|
9
|
+
country: "US",
|
|
10
|
+
entities: [
|
|
11
|
+
{ roles: ["registrant"], vcardArray: ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Google LLC"]]] },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
describe("parseRdapResponse", () => {
|
|
15
|
+
it("extracts country + org (entity fn preferred over name)", () => {
|
|
16
|
+
assert.deepEqual(parseRdapResponse(RDAP_GOOGLE), { country: "US", org: "Google LLC" });
|
|
17
|
+
});
|
|
18
|
+
it("falls back to network `name` when no entity fn", () => {
|
|
19
|
+
assert.deepEqual(parseRdapResponse({ country: "DE", name: "DTAG" }), { country: "DE", org: "DTAG" });
|
|
20
|
+
});
|
|
21
|
+
it("returns null when neither country nor org is present", () => {
|
|
22
|
+
assert.equal(parseRdapResponse({ handle: "x" }), null);
|
|
23
|
+
assert.equal(parseRdapResponse(null), null);
|
|
24
|
+
assert.equal(parseRdapResponse("nope"), null);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
function stubFetch(handler) {
|
|
28
|
+
const calls = [];
|
|
29
|
+
const fetch = async (url) => {
|
|
30
|
+
calls.push(url);
|
|
31
|
+
const r = handler(url);
|
|
32
|
+
return { ok: r.ok, status: r.status, json: async () => r.body };
|
|
33
|
+
};
|
|
34
|
+
return { fetch, calls };
|
|
35
|
+
}
|
|
36
|
+
describe("RdapResolver", () => {
|
|
37
|
+
it("looks up an IP and parses the response", async () => {
|
|
38
|
+
const { fetch, calls } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
|
|
39
|
+
const r = new RdapResolver({ fetch });
|
|
40
|
+
assert.deepEqual(await r.lookup("8.8.8.8"), { country: "US", org: "Google LLC" });
|
|
41
|
+
assert.equal(calls.length, 1);
|
|
42
|
+
assert.match(calls[0], /\/ip\/8\.8\.8\.8$/);
|
|
43
|
+
});
|
|
44
|
+
it("caches a hit — second lookup does not re-fetch", async () => {
|
|
45
|
+
const { fetch, calls } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
|
|
46
|
+
const r = new RdapResolver({ fetch });
|
|
47
|
+
await r.lookup("8.8.8.8");
|
|
48
|
+
await r.lookup("8.8.8.8");
|
|
49
|
+
assert.equal(calls.length, 1, "second lookup served from cache");
|
|
50
|
+
});
|
|
51
|
+
it("caches a negative result (miss) too", async () => {
|
|
52
|
+
const { fetch, calls } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
|
|
53
|
+
const r = new RdapResolver({ fetch });
|
|
54
|
+
assert.equal(await r.lookup("203.0.113.7"), null);
|
|
55
|
+
assert.equal(await r.lookup("203.0.113.7"), null);
|
|
56
|
+
assert.equal(calls.length, 1, "negative result cached");
|
|
57
|
+
});
|
|
58
|
+
it("re-fetches after the TTL expires", async () => {
|
|
59
|
+
const { fetch, calls } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
|
|
60
|
+
const r = new RdapResolver({ fetch, ttlMs: 1000 });
|
|
61
|
+
let t = 1000;
|
|
62
|
+
r.now = () => t;
|
|
63
|
+
await r.lookup("8.8.8.8");
|
|
64
|
+
t = 2001; // past TTL
|
|
65
|
+
await r.lookup("8.8.8.8");
|
|
66
|
+
assert.equal(calls.length, 2, "expired entry re-fetched");
|
|
67
|
+
});
|
|
68
|
+
it("returns null for an invalid IP WITHOUT making a request", async () => {
|
|
69
|
+
const { fetch, calls } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
|
|
70
|
+
const r = new RdapResolver({ fetch });
|
|
71
|
+
assert.equal(await r.lookup("not-an-ip"), null);
|
|
72
|
+
assert.equal(calls.length, 0, "no network call for an invalid IP");
|
|
73
|
+
});
|
|
74
|
+
it("never throws on a fetch error — returns null", async () => {
|
|
75
|
+
const r = new RdapResolver({ fetch: (async () => { throw new Error("network down"); }) });
|
|
76
|
+
assert.equal(await r.lookup("8.8.8.8"), null);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -13,6 +13,8 @@ type GateState = {
|
|
|
13
13
|
mode: "active";
|
|
14
14
|
claims: Record<string, unknown>;
|
|
15
15
|
accessControl: boolean;
|
|
16
|
+
inspectEnforce: boolean;
|
|
17
|
+
hasFeature: (feature: string) => boolean;
|
|
16
18
|
enforceRbac?: (policy: unknown, ctx: unknown, req: unknown) => unknown;
|
|
17
19
|
enforceCatalog?: (catalog: unknown, ctx: unknown, req: unknown) => unknown;
|
|
18
20
|
rbacPolicy?: unknown;
|
|
@@ -25,6 +27,32 @@ type GateState = {
|
|
|
25
27
|
export declare function _resetEnterpriseAudit(): void;
|
|
26
28
|
/** Reset memoised state (tests only). */
|
|
27
29
|
export declare function _resetEnterpriseGate(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Is the Inspect ENFORCE control entitled? Inspect observe/dry-run are free
|
|
32
|
+
* (OSS); only blocking enforcement requires the "inspect-enforce" feature on a
|
|
33
|
+
* valid entitlement token. Default-OFF (no token) → false, so OSS deployments
|
|
34
|
+
* keep observe/dry-run and simply can't switch to blocking enforce.
|
|
35
|
+
*/
|
|
36
|
+
export declare function inspectEnforceEntitled(): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Is a named entitlement feature active? True only with a valid entitlement
|
|
39
|
+
* token that carries the feature. Default-OFF (no token / enterprise modules
|
|
40
|
+
* absent) → false, never throws — so OSS deployments keep their free surface
|
|
41
|
+
* and a feature is only gated when the operator has actively configured it.
|
|
42
|
+
* Used to license SSO/OIDC ("sso"), SCIM ("scim"), multi-tenancy ("tenancy").
|
|
43
|
+
*/
|
|
44
|
+
export declare function featureEntitled(feature: string): Promise<boolean>;
|
|
45
|
+
/** Every entitlement feature the product knows about — the closed vocabulary
|
|
46
|
+
* the UI renders lock badges for. Keep in sync with the gates in index.ts. */
|
|
47
|
+
export declare const ENTITLEABLE_FEATURES: readonly ["access-control", "audit", "inspect-enforce", "sso", "scim", "tenancy"];
|
|
48
|
+
/**
|
|
49
|
+
* Resolve every known feature to whether it is currently entitled — one flat
|
|
50
|
+
* map for the UI to drive a consistent lock optic. Default-OFF (no token /
|
|
51
|
+
* enterprise modules absent) → all false; never throws. This is purely
|
|
52
|
+
* informational (visibility is free); the real enforcement lives in each
|
|
53
|
+
* feature's own gate.
|
|
54
|
+
*/
|
|
55
|
+
export declare function entitledFeatures(): Promise<Record<string, boolean>>;
|
|
28
56
|
/** Gate mode — for diagnostics (/api/info). */
|
|
29
57
|
export declare function enterpriseGateStatus(): Promise<{
|
|
30
58
|
active: boolean;
|
package/dist/enterprise-gate.js
CHANGED
|
@@ -109,6 +109,8 @@ async function buildGate() {
|
|
|
109
109
|
mode: "active",
|
|
110
110
|
claims,
|
|
111
111
|
accessControl: has("access-control"),
|
|
112
|
+
inspectEnforce: has("inspect-enforce"),
|
|
113
|
+
hasFeature: has,
|
|
112
114
|
};
|
|
113
115
|
// Audit (best-effort; only if entitled and the module loads). The log
|
|
114
116
|
// is a PROCESS singleton, deliberately decoupled from the gate memo:
|
|
@@ -137,6 +139,55 @@ async function buildGate() {
|
|
|
137
139
|
export function _resetEnterpriseGate() {
|
|
138
140
|
gatePromise = null;
|
|
139
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Is the Inspect ENFORCE control entitled? Inspect observe/dry-run are free
|
|
144
|
+
* (OSS); only blocking enforcement requires the "inspect-enforce" feature on a
|
|
145
|
+
* valid entitlement token. Default-OFF (no token) → false, so OSS deployments
|
|
146
|
+
* keep observe/dry-run and simply can't switch to blocking enforce.
|
|
147
|
+
*/
|
|
148
|
+
export async function inspectEnforceEntitled() {
|
|
149
|
+
return featureEntitled("inspect-enforce");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Is a named entitlement feature active? True only with a valid entitlement
|
|
153
|
+
* token that carries the feature. Default-OFF (no token / enterprise modules
|
|
154
|
+
* absent) → false, never throws — so OSS deployments keep their free surface
|
|
155
|
+
* and a feature is only gated when the operator has actively configured it.
|
|
156
|
+
* Used to license SSO/OIDC ("sso"), SCIM ("scim"), multi-tenancy ("tenancy").
|
|
157
|
+
*/
|
|
158
|
+
export async function featureEntitled(feature) {
|
|
159
|
+
if (!gatePromise)
|
|
160
|
+
gatePromise = buildGate();
|
|
161
|
+
const g = await gatePromise;
|
|
162
|
+
return g.mode === "active" && g.hasFeature(feature);
|
|
163
|
+
}
|
|
164
|
+
/** Every entitlement feature the product knows about — the closed vocabulary
|
|
165
|
+
* the UI renders lock badges for. Keep in sync with the gates in index.ts. */
|
|
166
|
+
export const ENTITLEABLE_FEATURES = [
|
|
167
|
+
"access-control",
|
|
168
|
+
"audit",
|
|
169
|
+
"inspect-enforce",
|
|
170
|
+
"sso",
|
|
171
|
+
"scim",
|
|
172
|
+
"tenancy",
|
|
173
|
+
];
|
|
174
|
+
/**
|
|
175
|
+
* Resolve every known feature to whether it is currently entitled — one flat
|
|
176
|
+
* map for the UI to drive a consistent lock optic. Default-OFF (no token /
|
|
177
|
+
* enterprise modules absent) → all false; never throws. This is purely
|
|
178
|
+
* informational (visibility is free); the real enforcement lives in each
|
|
179
|
+
* feature's own gate.
|
|
180
|
+
*/
|
|
181
|
+
export async function entitledFeatures() {
|
|
182
|
+
if (!gatePromise)
|
|
183
|
+
gatePromise = buildGate();
|
|
184
|
+
const g = await gatePromise;
|
|
185
|
+
const active = g.mode === "active";
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const f of ENTITLEABLE_FEATURES)
|
|
188
|
+
out[f] = active && g.hasFeature(f);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
140
191
|
/** Gate mode — for diagnostics (/api/info). */
|
|
141
192
|
export async function enterpriseGateStatus() {
|
|
142
193
|
if (!gatePromise)
|
|
@@ -4,7 +4,7 @@ import { writeFileSync, mkdtempSync } from "node:fs";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { defaultContext } from "./context.js";
|
|
7
|
-
import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, _resetEnterpriseGate, } from "./enterprise-gate.js";
|
|
7
|
+
import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, featureEntitled, inspectEnforceEntitled, entitledFeatures, ENTITLEABLE_FEATURES, _resetEnterpriseGate, } from "./enterprise-gate.js";
|
|
8
8
|
// These tests run in the mcp-server sandbox where enterprise/ is ABSENT
|
|
9
9
|
// (it is excluded from the npm package and the Docker build context) —
|
|
10
10
|
// exactly the published-artifact state. They pin the security contract:
|
|
@@ -55,6 +55,26 @@ describe("enterprise-gate — OFF (no opt-in, published-artifact state)", () =>
|
|
|
55
55
|
await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool }));
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
|
+
it("featureEntitled is false for every feature when OFF (OSS default)", async () => {
|
|
59
|
+
clearEnv();
|
|
60
|
+
// Default-OFF means no entitled feature is active — SSO/SCIM/tenancy/
|
|
61
|
+
// inspect-enforce all stay locked, so the OSS surface is unchanged and
|
|
62
|
+
// a feature only ever gates when the operator has actively licensed it.
|
|
63
|
+
for (const feature of ["sso", "scim", "tenancy", "inspect-enforce", "anything"]) {
|
|
64
|
+
assert.equal(await featureEntitled(feature), false, `feature ${feature} must be locked when OFF`);
|
|
65
|
+
}
|
|
66
|
+
assert.equal(await inspectEnforceEntitled(), false, "inspectEnforceEntitled mirrors featureEntitled");
|
|
67
|
+
});
|
|
68
|
+
it("entitledFeatures returns the full vocabulary, all false when OFF", async () => {
|
|
69
|
+
clearEnv();
|
|
70
|
+
const map = await entitledFeatures();
|
|
71
|
+
// Every known feature is present as a key (so the UI can render a badge
|
|
72
|
+
// for each) and false on the OSS default.
|
|
73
|
+
for (const f of ENTITLEABLE_FEATURES) {
|
|
74
|
+
assert.equal(map[f], false, `feature ${f} must be false when OFF`);
|
|
75
|
+
}
|
|
76
|
+
assert.deepEqual(Object.keys(map).sort(), [...ENTITLEABLE_FEATURES].sort());
|
|
77
|
+
});
|
|
58
78
|
it("gate state is memoised across calls", async () => {
|
|
59
79
|
clearEnv();
|
|
60
80
|
assert.deepEqual(await enterpriseGateStatus(), await enterpriseGateStatus());
|