@thotischner/observability-mcp 3.8.2 → 3.9.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/credentials.d.ts +19 -3
- package/dist/auth/credentials.js +17 -3
- package/dist/auth/credentials.test.js +11 -1
- package/dist/connectors/loader.d.ts +6 -0
- package/dist/connectors/loader.js +55 -6
- package/dist/connectors/loader.test.js +122 -1
- package/dist/context.d.ts +18 -0
- package/dist/context.js +19 -0
- package/dist/context.test.js +39 -1
- package/dist/enrich/rdap.d.ts +41 -2
- package/dist/enrich/rdap.js +71 -17
- package/dist/enrich/rdap.test.js +82 -0
- package/dist/index.js +15 -10
- package/dist/tools/enrich-ips.d.ts +6 -0
- package/dist/tools/enrich-ips.js +23 -5
- package/dist/tools/enrich-ips.test.js +37 -4
- package/package.json +1 -1
|
@@ -33,10 +33,21 @@
|
|
|
33
33
|
* # tools list = no restriction). Unlisted keys see
|
|
34
34
|
* # every registered tool — back-compat with the
|
|
35
35
|
* # pre-Products world. See docs/products.md.
|
|
36
|
+
* OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
|
|
37
|
+
* # optional per-key tool allow-list — same shape as
|
|
38
|
+
* # OMCP_KEY_SOURCES. When set, the credential's /mcp
|
|
39
|
+
* # tools/list (and dispatch) is scoped to exactly these
|
|
40
|
+
* # tool names. Composes with a bound Product by
|
|
41
|
+
* # INTERSECTION (most-restrictive wins). Unlisted keys
|
|
42
|
+
* # see every registered tool — back-compat. This is the
|
|
43
|
+
* # per-credential, source-symmetric counterpart to the
|
|
44
|
+
* # Product bundle: scope one API key to a few tools
|
|
45
|
+
* # without authoring a Product. See docs/products.md
|
|
46
|
+
* # ("Per-credential tool allow-list").
|
|
36
47
|
*
|
|
37
|
-
* Rich role-based access control (
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* Rich role-based access control (services/lookback/read-only, the full
|
|
49
|
+
* governance object) is intentionally NOT here — this is the authentication
|
|
50
|
+
* + identity + coarse source/tool-scoping primitive.
|
|
40
51
|
*/
|
|
41
52
|
export interface Credential {
|
|
42
53
|
name: string;
|
|
@@ -58,6 +69,11 @@ export interface Credential {
|
|
|
58
69
|
* is filtered to the Product's `tools` allow-list. Resolved against
|
|
59
70
|
* the credential's tenant so cross-tenant Products don't leak. */
|
|
60
71
|
productId?: string;
|
|
72
|
+
/** Per-credential tool allow-list (OMCP_KEY_TOOLS). When set, /mcp
|
|
73
|
+
* tools/list and dispatch are scoped to these tool names; composes
|
|
74
|
+
* with a bound Product by intersection (most-restrictive wins).
|
|
75
|
+
* Undefined → no per-credential tool restriction (back-compat). */
|
|
76
|
+
allowedTools?: string[];
|
|
61
77
|
}
|
|
62
78
|
/** Parse credentials from env. Returns an empty list when unconfigured. */
|
|
63
79
|
export declare function loadCredentials(env?: NodeJS.ProcessEnv): Credential[];
|
package/dist/auth/credentials.js
CHANGED
|
@@ -33,10 +33,21 @@
|
|
|
33
33
|
* # tools list = no restriction). Unlisted keys see
|
|
34
34
|
* # every registered tool — back-compat with the
|
|
35
35
|
* # pre-Products world. See docs/products.md.
|
|
36
|
+
* OMCP_KEY_TOOLS="agent=query_logs|get_service_health;ci=list_services"
|
|
37
|
+
* # optional per-key tool allow-list — same shape as
|
|
38
|
+
* # OMCP_KEY_SOURCES. When set, the credential's /mcp
|
|
39
|
+
* # tools/list (and dispatch) is scoped to exactly these
|
|
40
|
+
* # tool names. Composes with a bound Product by
|
|
41
|
+
* # INTERSECTION (most-restrictive wins). Unlisted keys
|
|
42
|
+
* # see every registered tool — back-compat. This is the
|
|
43
|
+
* # per-credential, source-symmetric counterpart to the
|
|
44
|
+
* # Product bundle: scope one API key to a few tools
|
|
45
|
+
* # without authoring a Product. See docs/products.md
|
|
46
|
+
* # ("Per-credential tool allow-list").
|
|
36
47
|
*
|
|
37
|
-
* Rich role-based access control (
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* Rich role-based access control (services/lookback/read-only, the full
|
|
49
|
+
* governance object) is intentionally NOT here — this is the authentication
|
|
50
|
+
* + identity + coarse source/tool-scoping primitive.
|
|
40
51
|
*/
|
|
41
52
|
import { parseKeyTenants } from "../tenancy/context.js";
|
|
42
53
|
function parseKeySources(raw) {
|
|
@@ -84,6 +95,8 @@ export function loadCredentials(env = process.env) {
|
|
|
84
95
|
const rawQueryNames = parseBypassSet(env.OMCP_KEY_RAW_QUERY);
|
|
85
96
|
const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
|
|
86
97
|
const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
|
|
98
|
+
// OMCP_KEY_TOOLS shares the OMCP_KEY_SOURCES grammar (`name=a|b;name2=c`).
|
|
99
|
+
const keyTools = parseKeySources(env.OMCP_KEY_TOOLS);
|
|
87
100
|
const creds = [];
|
|
88
101
|
for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
89
102
|
const idx = part.indexOf(":");
|
|
@@ -99,6 +112,7 @@ export function loadCredentials(env = process.env) {
|
|
|
99
112
|
allowRawQuery: rawQueryNames.has(name) || undefined,
|
|
100
113
|
tenant: keyTenants.get(name) || undefined,
|
|
101
114
|
productId: keyProducts.get(name) || undefined,
|
|
115
|
+
allowedTools: keyTools.get(name),
|
|
102
116
|
});
|
|
103
117
|
}
|
|
104
118
|
return creds;
|
|
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
|
|
|
11
11
|
it("parses name:token and bare token", () => {
|
|
12
12
|
const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
|
|
13
13
|
assert.equal(creds.length, 2);
|
|
14
|
-
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined });
|
|
14
|
+
assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, allowRawQuery: undefined, tenant: undefined, productId: undefined, allowedTools: undefined });
|
|
15
15
|
assert.equal(creds[1].name, "key");
|
|
16
16
|
assert.equal(creds[1].token, "tok_bare");
|
|
17
17
|
});
|
|
@@ -23,6 +23,16 @@ describe("single-tenant auth primitive", () => {
|
|
|
23
23
|
assert.deepEqual(creds[0].allowedSources, ["prom-prod", "loki-prod"]);
|
|
24
24
|
assert.deepEqual(creds[1].allowedSources, ["prom-staging"]);
|
|
25
25
|
});
|
|
26
|
+
it("parses per-key tool allow-list (OMCP_KEY_TOOLS)", () => {
|
|
27
|
+
const creds = loadCredentials({
|
|
28
|
+
OMCP_API_KEYS: "agent:tok1,ci:tok2,full:tok3",
|
|
29
|
+
OMCP_KEY_TOOLS: "agent=query_logs|get_service_health; ci=list_services",
|
|
30
|
+
});
|
|
31
|
+
assert.deepEqual(creds.find((c) => c.name === "agent")?.allowedTools, ["query_logs", "get_service_health"]);
|
|
32
|
+
assert.deepEqual(creds.find((c) => c.name === "ci")?.allowedTools, ["list_services"]);
|
|
33
|
+
// Unlisted key → undefined (no restriction, back-compat), not [].
|
|
34
|
+
assert.equal(creds.find((c) => c.name === "full")?.allowedTools, undefined);
|
|
35
|
+
});
|
|
26
36
|
it("parses OMCP_KEY_BYPASS_REDACTION → flags only the listed names", () => {
|
|
27
37
|
const creds = loadCredentials({
|
|
28
38
|
OMCP_API_KEYS: "agent:tok1,ci:tok2,unprivileged:tok3",
|
|
@@ -27,6 +27,7 @@ export declare class PluginLoader {
|
|
|
27
27
|
private verify;
|
|
28
28
|
private trustRootPath?;
|
|
29
29
|
private trustRoot?;
|
|
30
|
+
private requireSignature;
|
|
30
31
|
/** Optional HookRegistry — when set, the loader auto-registers
|
|
31
32
|
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
32
33
|
* unregisters them when a same-name plugin replaces it. Hooks
|
|
@@ -39,6 +40,7 @@ export declare class PluginLoader {
|
|
|
39
40
|
verify?: boolean;
|
|
40
41
|
trustRoot?: string;
|
|
41
42
|
hookRegistry?: HookRegistry;
|
|
43
|
+
requireSignature?: boolean;
|
|
42
44
|
});
|
|
43
45
|
load(): Promise<void>;
|
|
44
46
|
list(): LoadedConnector[];
|
|
@@ -49,6 +51,10 @@ export declare class PluginLoader {
|
|
|
49
51
|
create(name: string): ObservabilityConnector | undefined;
|
|
50
52
|
private loadBuiltins;
|
|
51
53
|
private loadFilesystem;
|
|
54
|
+
/** A filesystem plugin failed the verification gate. In strict mode
|
|
55
|
+
* (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
|
|
56
|
+
* warns and the caller skips the plugin (fail-closed but non-fatal). */
|
|
57
|
+
private gateFailure;
|
|
52
58
|
private loadFilesystemPlugin;
|
|
53
59
|
private register;
|
|
54
60
|
}
|
|
@@ -31,6 +31,15 @@ export class PluginLoader {
|
|
|
31
31
|
verify;
|
|
32
32
|
trustRootPath;
|
|
33
33
|
trustRoot;
|
|
34
|
+
// Strict mode (PLUGIN_REQUIRE_SIGNATURE=true). Default OFF. When OFF, a
|
|
35
|
+
// filesystem plugin that fails the verification gate is silently SKIPPED
|
|
36
|
+
// (warn + continue) — the server still boots, just without that connector.
|
|
37
|
+
// When ON, a present-but-unverifiable plugin (missing manifest / missing
|
|
38
|
+
// or invalid signature / integrity mismatch), or a missing/unloadable trust
|
|
39
|
+
// root, is a HARD error that aborts load() — so an operator who demands
|
|
40
|
+
// signed connectors fails to start rather than silently running a reduced
|
|
41
|
+
// set. Implies verify (you cannot require signatures without verifying).
|
|
42
|
+
requireSignature;
|
|
34
43
|
/** Optional HookRegistry — when set, the loader auto-registers
|
|
35
44
|
* every entry in `manifest.hooks[]` after the plugin loads, and
|
|
36
45
|
* unregisters them when a same-name plugin replaces it. Hooks
|
|
@@ -50,11 +59,24 @@ export class PluginLoader {
|
|
|
50
59
|
this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
|
|
51
60
|
this.verify = opts.verify ?? !/^(0|false|no|off)$/i.test(process.env.VERIFY_PLUGINS ?? "true");
|
|
52
61
|
this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
|
|
62
|
+
this.requireSignature =
|
|
63
|
+
opts.requireSignature ?? /^(1|true|yes|on)$/i.test(process.env.PLUGIN_REQUIRE_SIGNATURE ?? "");
|
|
64
|
+
// Requiring signatures implies verifying them — an operator who sets
|
|
65
|
+
// PLUGIN_REQUIRE_SIGNATURE=true but left VERIFY_PLUGINS=false meant the
|
|
66
|
+
// stricter posture; honour it rather than silently no-op.
|
|
67
|
+
if (this.requireSignature)
|
|
68
|
+
this.verify = true;
|
|
53
69
|
}
|
|
54
70
|
async load() {
|
|
55
71
|
this.loadBuiltins();
|
|
56
72
|
if (this.verify) {
|
|
57
73
|
if (!this.trustRootPath) {
|
|
74
|
+
// In strict mode this is a misconfiguration — the operator demanded
|
|
75
|
+
// signed connectors but gave no way to verify them. Fail hard rather
|
|
76
|
+
// than silently degrade to builtins-only.
|
|
77
|
+
if (this.requireSignature) {
|
|
78
|
+
throw new PluginVerificationError("PLUGIN_REQUIRE_SIGNATURE is on but PLUGIN_TRUST_ROOT is unset — cannot enforce signatures. Set a trust root or unset PLUGIN_REQUIRE_SIGNATURE.");
|
|
79
|
+
}
|
|
58
80
|
console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
|
|
59
81
|
return;
|
|
60
82
|
}
|
|
@@ -63,6 +85,9 @@ export class PluginLoader {
|
|
|
63
85
|
console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
|
|
64
86
|
}
|
|
65
87
|
catch (err) {
|
|
88
|
+
if (this.requireSignature) {
|
|
89
|
+
throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE is on but the trust root failed to load (${String(err)}) — cannot enforce signatures.`);
|
|
90
|
+
}
|
|
66
91
|
console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
|
|
67
92
|
return;
|
|
68
93
|
}
|
|
@@ -161,10 +186,23 @@ export class PluginLoader {
|
|
|
161
186
|
await this.loadFilesystemPlugin(pluginRoot);
|
|
162
187
|
}
|
|
163
188
|
catch (err) {
|
|
189
|
+
// In strict mode a verification failure must abort load() rather than
|
|
190
|
+
// be swallowed as a per-plugin warning — propagate it.
|
|
191
|
+
if (this.requireSignature && err instanceof PluginVerificationError)
|
|
192
|
+
throw err;
|
|
164
193
|
console.warn("Failed to load plugin %s: %s", sanitizeForLog(entry), sanitizeForLog(String(err)));
|
|
165
194
|
}
|
|
166
195
|
}
|
|
167
196
|
}
|
|
197
|
+
/** A filesystem plugin failed the verification gate. In strict mode
|
|
198
|
+
* (PLUGIN_REQUIRE_SIGNATURE) this throws to abort load(); otherwise it
|
|
199
|
+
* warns and the caller skips the plugin (fail-closed but non-fatal). */
|
|
200
|
+
gateFailure(name, reason) {
|
|
201
|
+
if (this.requireSignature) {
|
|
202
|
+
throw new PluginVerificationError(`PLUGIN_REQUIRE_SIGNATURE: plugin ${name} ${reason} — refusing to start.`);
|
|
203
|
+
}
|
|
204
|
+
console.warn("VERIFY_PLUGINS: plugin %s %s — skipping (fail-closed)", sanitizeForLog(name), sanitizeForLog(reason));
|
|
205
|
+
}
|
|
168
206
|
async loadFilesystemPlugin(pluginRoot) {
|
|
169
207
|
const pkgPath = join(pluginRoot, "package.json");
|
|
170
208
|
if (!existsSync(pkgPath))
|
|
@@ -180,18 +218,29 @@ export class PluginLoader {
|
|
|
180
218
|
manifestPath = resolve(pluginRoot, marker.manifest);
|
|
181
219
|
if (existsSync(manifestPath)) {
|
|
182
220
|
manifestBytes = readFileSync(manifestPath);
|
|
183
|
-
|
|
221
|
+
// A present-but-malformed manifest (corrupt JSON / schema-invalid /
|
|
222
|
+
// name mismatch) is "present but cannot be verified" — under strict
|
|
223
|
+
// mode gateFailure() aborts startup, so the connector can't silently
|
|
224
|
+
// go missing; otherwise it warns + skips as before.
|
|
225
|
+
let raw;
|
|
226
|
+
try {
|
|
227
|
+
raw = JSON.parse(manifestBytes.toString("utf8"));
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
this.gateFailure(marker.name, `has unparseable manifest.json (${String(err)})`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
184
233
|
const parsed = manifestSchema.safeParse(raw);
|
|
185
234
|
if (!parsed.success) {
|
|
186
235
|
const issues = parsed.error.issues
|
|
187
236
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
188
237
|
.join("; ");
|
|
189
|
-
|
|
238
|
+
this.gateFailure(marker.name, `has invalid manifest.json — ${issues}`);
|
|
190
239
|
return;
|
|
191
240
|
}
|
|
192
241
|
manifest = parsed.data;
|
|
193
242
|
if (manifest.name !== marker.name) {
|
|
194
|
-
|
|
243
|
+
this.gateFailure(marker.name, `package.json marker name does not match manifest.json (${manifest.name})`);
|
|
195
244
|
return;
|
|
196
245
|
}
|
|
197
246
|
}
|
|
@@ -208,12 +257,12 @@ export class PluginLoader {
|
|
|
208
257
|
// against the trust root. Everything is local — airgapped-safe.
|
|
209
258
|
if (this.verify) {
|
|
210
259
|
if (!manifest || !manifestPath || !manifestBytes) {
|
|
211
|
-
|
|
260
|
+
this.gateFailure(marker.name, "has no manifest.json");
|
|
212
261
|
return;
|
|
213
262
|
}
|
|
214
263
|
const sigPath = manifestPath + ".sig";
|
|
215
264
|
if (!existsSync(sigPath)) {
|
|
216
|
-
|
|
265
|
+
this.gateFailure(marker.name, `missing manifest signature ${marker.manifest + ".sig"}`);
|
|
217
266
|
return;
|
|
218
267
|
}
|
|
219
268
|
try {
|
|
@@ -222,7 +271,7 @@ export class PluginLoader {
|
|
|
222
271
|
}
|
|
223
272
|
catch (err) {
|
|
224
273
|
const detail = err instanceof PluginVerificationError ? err.message : String(err);
|
|
225
|
-
|
|
274
|
+
this.gateFailure(marker.name, `failed verification (${detail})`);
|
|
226
275
|
return;
|
|
227
276
|
}
|
|
228
277
|
console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
|
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { generateKeyPairSync, sign as cryptoSign } from "node:crypto";
|
|
6
7
|
import { PluginLoader } from "./loader.js";
|
|
8
|
+
import { PluginVerificationError, sha256Integrity } from "./verify.js";
|
|
7
9
|
function tmp() {
|
|
8
10
|
return mkdtempSync(join(tmpdir(), "loader-default-"));
|
|
9
11
|
}
|
|
12
|
+
const ENTRY_SRC = "export default () => ({});\n";
|
|
13
|
+
/** Write a public-key PEM trust root + return its path and the signing key. */
|
|
14
|
+
function makeTrustRoot() {
|
|
15
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "loader-trust-"));
|
|
17
|
+
const p = join(dir, "trust.pem");
|
|
18
|
+
writeFileSync(p, publicKey.export({ type: "spki", format: "pem" }));
|
|
19
|
+
return { path: p, privateKey };
|
|
20
|
+
}
|
|
21
|
+
/** Build a filesystem connector plugin dir under `pluginsDir`.
|
|
22
|
+
* opts.manifest=false → no manifest.json; opts.sign omitted → no .sig;
|
|
23
|
+
* opts.sign=key → a valid detached signature over the manifest bytes. */
|
|
24
|
+
function makePlugin(pluginsDir, name, opts = {}) {
|
|
25
|
+
const root = join(pluginsDir, name);
|
|
26
|
+
mkdirSync(root, { recursive: true });
|
|
27
|
+
writeFileSync(join(root, "index.js"), ENTRY_SRC);
|
|
28
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ main: "index.js", observabilityMcp: { kind: "connector", name, manifest: "manifest.json" } }));
|
|
29
|
+
if (opts.manifest === false)
|
|
30
|
+
return;
|
|
31
|
+
const manifestPath = join(root, "manifest.json");
|
|
32
|
+
const manifestBytes = opts.rawManifest !== undefined
|
|
33
|
+
? Buffer.from(opts.rawManifest)
|
|
34
|
+
: Buffer.from(JSON.stringify({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
name,
|
|
37
|
+
displayName: name,
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
description: `${name} test connector`,
|
|
40
|
+
signalTypes: ["metrics"],
|
|
41
|
+
integrity: opts.integrity ?? sha256Integrity(Buffer.from(ENTRY_SRC)),
|
|
42
|
+
}));
|
|
43
|
+
writeFileSync(manifestPath, manifestBytes);
|
|
44
|
+
if (opts.sign)
|
|
45
|
+
writeFileSync(manifestPath + ".sig", cryptoSign(null, manifestBytes, opts.sign));
|
|
46
|
+
}
|
|
10
47
|
function withEnv(overrides, fn) {
|
|
11
48
|
const saved = {};
|
|
12
49
|
for (const k of Object.keys(overrides)) {
|
|
@@ -87,3 +124,87 @@ test("PluginLoader.load(): builtins carry manifest metadata (description shows i
|
|
|
87
124
|
assert.equal(c.manifest.name, name);
|
|
88
125
|
}
|
|
89
126
|
});
|
|
127
|
+
// --- PLUGIN_REQUIRE_SIGNATURE (strict load-time enforcement) ---
|
|
128
|
+
test("PluginLoader: PLUGIN_REQUIRE_SIGNATURE=true sets requireSignature and forces verify on", () => {
|
|
129
|
+
for (const v of ["true", "1", "yes", "On"]) {
|
|
130
|
+
// Even with VERIFY_PLUGINS=false, requiring signatures implies verifying.
|
|
131
|
+
withEnv({ PLUGIN_REQUIRE_SIGNATURE: v, VERIFY_PLUGINS: "false" }, () => {
|
|
132
|
+
const loader = new PluginLoader({ pluginsDir: tmp() });
|
|
133
|
+
assert.equal(loader["requireSignature"], true, `value ${v} should enable strict mode`);
|
|
134
|
+
assert.equal(loader["verify"], true, "requireSignature implies verify");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
withEnv({ PLUGIN_REQUIRE_SIGNATURE: undefined }, () => {
|
|
138
|
+
assert.equal(new PluginLoader({ pluginsDir: tmp() })["requireSignature"], false, "default OFF");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
test("strict mode: a plugin with no manifest ABORTS load() (hard fail, not skip)", async () => {
|
|
142
|
+
const dir = tmp();
|
|
143
|
+
makePlugin(dir, "unsigned-conn", { manifest: false });
|
|
144
|
+
const trust = makeTrustRoot();
|
|
145
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
146
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
147
|
+
});
|
|
148
|
+
test("strict mode: a plugin with manifest but missing .sig ABORTS load()", async () => {
|
|
149
|
+
const dir = tmp();
|
|
150
|
+
makePlugin(dir, "nosig-conn", { /* manifest yes, sign no */});
|
|
151
|
+
const trust = makeTrustRoot();
|
|
152
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
153
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
154
|
+
});
|
|
155
|
+
test("strict mode: missing trust root ABORTS load() (misconfiguration)", async () => {
|
|
156
|
+
const loader = new PluginLoader({ pluginsDir: tmp(), verify: true, trustRoot: undefined, requireSignature: true });
|
|
157
|
+
await assert.rejects(() => loader.load(), /PLUGIN_TRUST_ROOT is unset/);
|
|
158
|
+
});
|
|
159
|
+
test("NON-strict (default): the same unverifiable plugin is skipped, builtins still load", async () => {
|
|
160
|
+
const dir = tmp();
|
|
161
|
+
makePlugin(dir, "unsigned-conn", { manifest: false });
|
|
162
|
+
const trust = makeTrustRoot();
|
|
163
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* requireSignature default false */ });
|
|
164
|
+
await loader.load(); // must NOT throw
|
|
165
|
+
const names = loader.supportedTypes();
|
|
166
|
+
assert.ok(names.includes("prometheus"), "builtins still load");
|
|
167
|
+
assert.ok(!names.includes("unsigned-conn"), "unverifiable plugin skipped, not registered");
|
|
168
|
+
});
|
|
169
|
+
test("strict mode: a correctly-signed plugin loads without error", async () => {
|
|
170
|
+
const dir = tmp();
|
|
171
|
+
const trust = makeTrustRoot();
|
|
172
|
+
makePlugin(dir, "good-conn", { sign: trust.privateKey });
|
|
173
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
174
|
+
await loader.load(); // valid signature + integrity → no throw
|
|
175
|
+
assert.ok(loader.supportedTypes().includes("good-conn"), "signed plugin registered under strict mode");
|
|
176
|
+
});
|
|
177
|
+
// A present-but-malformed manifest is "present but cannot be verified" — strict
|
|
178
|
+
// mode must hard-fail, not silently drop the connector.
|
|
179
|
+
test("strict mode: unparseable manifest.json ABORTS load()", async () => {
|
|
180
|
+
const dir = tmp();
|
|
181
|
+
makePlugin(dir, "corrupt-conn", { rawManifest: "{ this is not valid json" });
|
|
182
|
+
const trust = makeTrustRoot();
|
|
183
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
184
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
185
|
+
});
|
|
186
|
+
test("strict mode: schema-invalid manifest.json ABORTS load()", async () => {
|
|
187
|
+
const dir = tmp();
|
|
188
|
+
// Missing required fields (displayName/version/signalTypes/...).
|
|
189
|
+
makePlugin(dir, "badschema-conn", { rawManifest: JSON.stringify({ schemaVersion: 1, name: "badschema-conn" }) });
|
|
190
|
+
const trust = makeTrustRoot();
|
|
191
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
192
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
193
|
+
});
|
|
194
|
+
test("strict mode: integrity mismatch ABORTS load() (signed manifest, wrong digest)", async () => {
|
|
195
|
+
const dir = tmp();
|
|
196
|
+
const trust = makeTrustRoot();
|
|
197
|
+
// Validly signed manifest, but its integrity does not match index.js.
|
|
198
|
+
makePlugin(dir, "tampered-conn", { sign: trust.privateKey, integrity: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" });
|
|
199
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path, requireSignature: true });
|
|
200
|
+
await assert.rejects(() => loader.load(), PluginVerificationError);
|
|
201
|
+
});
|
|
202
|
+
test("NON-strict: a malformed manifest is still skipped (not fatal), builtins load", async () => {
|
|
203
|
+
const dir = tmp();
|
|
204
|
+
makePlugin(dir, "corrupt-conn", { rawManifest: "{ nope" });
|
|
205
|
+
const trust = makeTrustRoot();
|
|
206
|
+
const loader = new PluginLoader({ pluginsDir: dir, verify: true, trustRoot: trust.path /* non-strict */ });
|
|
207
|
+
await loader.load(); // must NOT throw
|
|
208
|
+
assert.ok(loader.supportedTypes().includes("prometheus"), "builtins still load");
|
|
209
|
+
assert.ok(!loader.supportedTypes().includes("corrupt-conn"), "malformed plugin skipped");
|
|
210
|
+
});
|
package/dist/context.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface RequestContext {
|
|
|
39
39
|
* Anonymous + Product-less credentials leave this unset and see
|
|
40
40
|
* every registered tool. */
|
|
41
41
|
allowedTools?: string[];
|
|
42
|
+
/** Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
|
|
43
|
+
* the Product `allowedTools`. The registration gate requires a tool to
|
|
44
|
+
* pass BOTH (each via allowsTool, undefined = no restriction), so the two
|
|
45
|
+
* compose by intersection without overloading the empty-list semantics.
|
|
46
|
+
* Unset for anonymous + unscoped credentials. */
|
|
47
|
+
credentialTools?: string[];
|
|
42
48
|
/** Correlates all tool calls within one transport request/session. */
|
|
43
49
|
correlationId: string;
|
|
44
50
|
}
|
|
@@ -59,6 +65,7 @@ export declare function principalContext(principalId: string, allowedSources?: s
|
|
|
59
65
|
allowRawQuery?: boolean;
|
|
60
66
|
tenant?: string;
|
|
61
67
|
allowedTools?: string[];
|
|
68
|
+
credentialTools?: string[];
|
|
62
69
|
}): RequestContext;
|
|
63
70
|
/** Context for an authenticated management-plane (browser / OIDC /
|
|
64
71
|
* basic-auth) request. The session-derived tenant flows into tool
|
|
@@ -85,3 +92,14 @@ export declare function sessionContext(session: {
|
|
|
85
92
|
* Tool names are compared case-sensitively; the MCP spec is
|
|
86
93
|
* case-sensitive on `name`. */
|
|
87
94
|
export declare function allowsTool(allowedTools: string[] | undefined, toolName: string): boolean;
|
|
95
|
+
/** Combine two tool allow-lists into the effective (most-restrictive) one.
|
|
96
|
+
* Both lists NARROW access, so the result is their intersection:
|
|
97
|
+
* - either side undefined → the other side wins (an absent list means
|
|
98
|
+
* "no restriction", so it can't tighten the other).
|
|
99
|
+
* - both set → only tools present in BOTH survive (so a per-credential
|
|
100
|
+
* OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
|
|
101
|
+
* without one silently widening the other; an empty intersection
|
|
102
|
+
* means the credential can call nothing through that binding).
|
|
103
|
+
* Used to fold the per-credential allow-list together with the Product
|
|
104
|
+
* allow-list at request entry. */
|
|
105
|
+
export declare function intersectAllowed(a: string[] | undefined, b: string[] | undefined): string[] | undefined;
|
package/dist/context.js
CHANGED
|
@@ -27,6 +27,7 @@ export function principalContext(principalId, allowedSources, opts = {}) {
|
|
|
27
27
|
allowRawQuery: opts.allowRawQuery || undefined,
|
|
28
28
|
tenant: normaliseTenant(opts.tenant),
|
|
29
29
|
allowedTools: opts.allowedTools && opts.allowedTools.length > 0 ? opts.allowedTools : undefined,
|
|
30
|
+
credentialTools: opts.credentialTools && opts.credentialTools.length > 0 ? opts.credentialTools : undefined,
|
|
30
31
|
correlationId: randomUUID(),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -64,3 +65,21 @@ export function allowsTool(allowedTools, toolName) {
|
|
|
64
65
|
return true;
|
|
65
66
|
return allowedTools.includes(toolName);
|
|
66
67
|
}
|
|
68
|
+
/** Combine two tool allow-lists into the effective (most-restrictive) one.
|
|
69
|
+
* Both lists NARROW access, so the result is their intersection:
|
|
70
|
+
* - either side undefined → the other side wins (an absent list means
|
|
71
|
+
* "no restriction", so it can't tighten the other).
|
|
72
|
+
* - both set → only tools present in BOTH survive (so a per-credential
|
|
73
|
+
* OMCP_KEY_TOOLS list and a bound Product's `tools` list compose
|
|
74
|
+
* without one silently widening the other; an empty intersection
|
|
75
|
+
* means the credential can call nothing through that binding).
|
|
76
|
+
* Used to fold the per-credential allow-list together with the Product
|
|
77
|
+
* allow-list at request entry. */
|
|
78
|
+
export function intersectAllowed(a, b) {
|
|
79
|
+
if (!a)
|
|
80
|
+
return b;
|
|
81
|
+
if (!b)
|
|
82
|
+
return a;
|
|
83
|
+
const bSet = new Set(b);
|
|
84
|
+
return a.filter((t) => bSet.has(t));
|
|
85
|
+
}
|
package/dist/context.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { allowsTool, defaultContext, principalContext } from "./context.js";
|
|
3
|
+
import { allowsTool, intersectAllowed, defaultContext, principalContext } from "./context.js";
|
|
4
4
|
test("allowsTool — undefined allow-list = no Product binding = every tool allowed", () => {
|
|
5
5
|
assert.equal(allowsTool(undefined, "list_sources"), true);
|
|
6
6
|
assert.equal(allowsTool(undefined, "query_logs"), true);
|
|
@@ -15,6 +15,44 @@ test("allowsTool — non-empty allow-list gates by exact match", () => {
|
|
|
15
15
|
assert.equal(allowsTool(allow, "query_logs"), false);
|
|
16
16
|
assert.equal(allowsTool(allow, "get_topology"), false);
|
|
17
17
|
});
|
|
18
|
+
// intersectAllowed folds a per-credential list (OMCP_KEY_TOOLS) with a Product
|
|
19
|
+
// list — most-restrictive wins.
|
|
20
|
+
test("intersectAllowed — either side undefined returns the other (no widening)", () => {
|
|
21
|
+
assert.equal(intersectAllowed(undefined, undefined), undefined);
|
|
22
|
+
assert.deepEqual(intersectAllowed(["a", "b"], undefined), ["a", "b"]);
|
|
23
|
+
assert.deepEqual(intersectAllowed(undefined, ["a", "b"]), ["a", "b"]);
|
|
24
|
+
});
|
|
25
|
+
test("intersectAllowed — both set → intersection only", () => {
|
|
26
|
+
assert.deepEqual(intersectAllowed(["query_logs", "list_services"], ["query_logs", "get_topology"]), ["query_logs"]);
|
|
27
|
+
// Disjoint → empty list: the credential can call nothing through that binding.
|
|
28
|
+
assert.deepEqual(intersectAllowed(["query_logs"], ["get_topology"]), []);
|
|
29
|
+
// Order follows the first (credential) list.
|
|
30
|
+
assert.deepEqual(intersectAllowed(["b", "a"], ["a", "b"]), ["b", "a"]);
|
|
31
|
+
});
|
|
32
|
+
// The registration gate ANDs two independent allowsTool axes (Product +
|
|
33
|
+
// per-credential OMCP_KEY_TOOLS). This is what makes disjoint lists deny
|
|
34
|
+
// everything WITHOUT routing through an overloaded empty intersection — an
|
|
35
|
+
// empty `[]` would be read by allowsTool as "allow all", which is why the two
|
|
36
|
+
// axes are kept separate rather than pre-intersected into one list.
|
|
37
|
+
function passesGate(productTools, credTools, name) {
|
|
38
|
+
return allowsTool(productTools, name) && allowsTool(credTools, name);
|
|
39
|
+
}
|
|
40
|
+
test("two-axis gate — disjoint Product and credential lists deny every tool", () => {
|
|
41
|
+
// Product allows get_topology; credential allows only query_logs → nothing passes.
|
|
42
|
+
assert.equal(passesGate(["get_topology"], ["query_logs"], "query_logs"), false);
|
|
43
|
+
assert.equal(passesGate(["get_topology"], ["query_logs"], "get_topology"), false);
|
|
44
|
+
});
|
|
45
|
+
test("two-axis gate — credential list narrows within an unrestricted Product axis", () => {
|
|
46
|
+
assert.equal(passesGate(undefined, ["query_logs"], "query_logs"), true);
|
|
47
|
+
assert.equal(passesGate(undefined, ["query_logs"], "get_topology"), false);
|
|
48
|
+
});
|
|
49
|
+
test("two-axis gate — overlapping lists allow only the overlap", () => {
|
|
50
|
+
const product = ["query_logs", "get_topology", "list_services"];
|
|
51
|
+
const cred = ["query_logs", "list_services"];
|
|
52
|
+
assert.equal(passesGate(product, cred, "query_logs"), true);
|
|
53
|
+
assert.equal(passesGate(product, cred, "list_services"), true);
|
|
54
|
+
assert.equal(passesGate(product, cred, "get_topology"), false); // in Product, not in credential
|
|
55
|
+
});
|
|
18
56
|
test("allowsTool — case-sensitive (matches MCP spec)", () => {
|
|
19
57
|
const allow = ["list_sources"];
|
|
20
58
|
assert.equal(allowsTool(allow, "List_Sources"), false);
|
package/dist/enrich/rdap.d.ts
CHANGED
|
@@ -6,7 +6,27 @@ export type FetchLike = (url: string, init?: {
|
|
|
6
6
|
ok: boolean;
|
|
7
7
|
status: number;
|
|
8
8
|
json: () => Promise<unknown>;
|
|
9
|
+
/** Optional — used to honor `Retry-After` on a throttle response. */
|
|
10
|
+
headers?: {
|
|
11
|
+
get(name: string): string | null;
|
|
12
|
+
};
|
|
9
13
|
}>;
|
|
14
|
+
/** Why a lookup failed in a way that is NOT a stable property of the address —
|
|
15
|
+
* retrying later (or in a smaller batch) may succeed. Issue #523. */
|
|
16
|
+
export type RdapTransientReason = "rate_limited" | "timeout" | "upstream_error" | "network_error";
|
|
17
|
+
/** Outcome of a single RDAP lookup. `not_found` is a genuine negative (the
|
|
18
|
+
* address is not in any registry / carries no enrichment) and is safe to
|
|
19
|
+
* cache; `transient` is an upstream failure (throttle, timeout, 5xx) that must
|
|
20
|
+
* NOT be conflated with a negative and is never cached. */
|
|
21
|
+
export type RdapOutcome = {
|
|
22
|
+
status: "ok";
|
|
23
|
+
value: IpEnrichment;
|
|
24
|
+
} | {
|
|
25
|
+
status: "not_found";
|
|
26
|
+
} | {
|
|
27
|
+
status: "transient";
|
|
28
|
+
reason: RdapTransientReason;
|
|
29
|
+
};
|
|
10
30
|
export interface RdapResolverOptions {
|
|
11
31
|
/** Bootstrap base; rdap.org redirects to the authoritative RIR. */
|
|
12
32
|
baseUrl?: string;
|
|
@@ -18,6 +38,14 @@ export interface RdapResolverOptions {
|
|
|
18
38
|
fetch?: FetchLike;
|
|
19
39
|
/** Max cache entries (LRU-ish trim). */
|
|
20
40
|
maxCache?: number;
|
|
41
|
+
/** Retries on a TRANSIENT failure (throttle/timeout/5xx). Default 2. A true
|
|
42
|
+
* negative (404 / no-data) is never retried. */
|
|
43
|
+
maxRetries?: number;
|
|
44
|
+
/** Base backoff in ms; attempt N waits min(base * 2^N, 5000), unless the
|
|
45
|
+
* throttle response carries a usable `Retry-After`. Default 250. */
|
|
46
|
+
backoffMs?: number;
|
|
47
|
+
/** Injected sleep (tests pass a no-op to stay fast). Defaults to setTimeout. */
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
21
49
|
}
|
|
22
50
|
/** Parse an RDAP IP-network response into our enrichment shape. country +
|
|
23
51
|
* org/name only; RDAP carries no city or hosting flag. */
|
|
@@ -29,12 +57,23 @@ export declare class RdapResolver {
|
|
|
29
57
|
private readonly timeoutMs;
|
|
30
58
|
private readonly fetch;
|
|
31
59
|
private readonly maxCache;
|
|
60
|
+
private readonly maxRetries;
|
|
61
|
+
private readonly backoffMs;
|
|
62
|
+
private readonly sleep;
|
|
32
63
|
private cache;
|
|
33
64
|
/** Monotonic clock injected for tests; defaults to Date.now via a getter. */
|
|
34
65
|
now: () => number;
|
|
35
66
|
constructor(opts?: RdapResolverOptions);
|
|
36
|
-
/** Look up one IP via RDAP. Returns
|
|
37
|
-
*
|
|
67
|
+
/** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
|
|
68
|
+
* miss OR a transient failure (never throws). Back-compat shim — callers
|
|
69
|
+
* that need to tell a true negative from a throttle should use {@link resolve}. */
|
|
38
70
|
lookup(ip: string): Promise<IpEnrichment | null>;
|
|
71
|
+
/** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
|
|
72
|
+
* cached) from a transient upstream failure (`transient`, never cached so a
|
|
73
|
+
* later retry can succeed). Bounded retry with backoff on transient. Never
|
|
74
|
+
* throws — a flaky RIR must not fail the batch (issue #523). */
|
|
75
|
+
resolve(ip: string): Promise<RdapOutcome>;
|
|
76
|
+
/** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
|
|
77
|
+
private attempt;
|
|
39
78
|
private put;
|
|
40
79
|
}
|
package/dist/enrich/rdap.js
CHANGED
|
@@ -71,6 +71,9 @@ export class RdapResolver {
|
|
|
71
71
|
timeoutMs;
|
|
72
72
|
fetch;
|
|
73
73
|
maxCache;
|
|
74
|
+
maxRetries;
|
|
75
|
+
backoffMs;
|
|
76
|
+
sleep;
|
|
74
77
|
cache = new Map();
|
|
75
78
|
/** Monotonic clock injected for tests; defaults to Date.now via a getter. */
|
|
76
79
|
now;
|
|
@@ -81,34 +84,74 @@ export class RdapResolver {
|
|
|
81
84
|
this.timeoutMs = opts.timeoutMs ?? 4000;
|
|
82
85
|
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
83
86
|
this.maxCache = opts.maxCache ?? 10_000;
|
|
87
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
88
|
+
this.backoffMs = opts.backoffMs ?? 250;
|
|
89
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
84
90
|
this.now = () => Date.now();
|
|
85
91
|
}
|
|
86
|
-
/** Look up one IP via RDAP. Returns
|
|
87
|
-
*
|
|
92
|
+
/** Look up one IP via RDAP. Returns the enrichment on a hit, or null on a
|
|
93
|
+
* miss OR a transient failure (never throws). Back-compat shim — callers
|
|
94
|
+
* that need to tell a true negative from a throttle should use {@link resolve}. */
|
|
88
95
|
async lookup(ip) {
|
|
96
|
+
const o = await this.resolve(ip);
|
|
97
|
+
return o.status === "ok" ? o.value : null;
|
|
98
|
+
}
|
|
99
|
+
/** Look up one IP via RDAP, distinguishing a genuine negative (`not_found`,
|
|
100
|
+
* cached) from a transient upstream failure (`transient`, never cached so a
|
|
101
|
+
* later retry can succeed). Bounded retry with backoff on transient. Never
|
|
102
|
+
* throws — a flaky RIR must not fail the batch (issue #523). */
|
|
103
|
+
async resolve(ip) {
|
|
89
104
|
if (ipv4ToInt(ip) === null && ipv6ToBigInt(ip) === null)
|
|
90
|
-
return
|
|
105
|
+
return { status: "not_found" };
|
|
91
106
|
const cached = this.cache.get(ip);
|
|
92
|
-
if (cached && cached.expiresAt > this.now())
|
|
93
|
-
return cached.value;
|
|
94
|
-
|
|
107
|
+
if (cached && cached.expiresAt > this.now()) {
|
|
108
|
+
return cached.value ? { status: "ok", value: cached.value } : { status: "not_found" };
|
|
109
|
+
}
|
|
110
|
+
for (let attempt = 0;; attempt++) {
|
|
111
|
+
const { outcome, retryAfterMs } = await this.attempt(ip);
|
|
112
|
+
if (outcome.status === "ok") {
|
|
113
|
+
this.put(ip, { value: outcome.value, expiresAt: this.now() + this.ttlMs });
|
|
114
|
+
return outcome;
|
|
115
|
+
}
|
|
116
|
+
if (outcome.status === "not_found") {
|
|
117
|
+
this.put(ip, { value: null, expiresAt: this.now() + this.negTtlMs });
|
|
118
|
+
return outcome;
|
|
119
|
+
}
|
|
120
|
+
// transient — retry with backoff, but never cache it as a negative.
|
|
121
|
+
if (attempt >= this.maxRetries)
|
|
122
|
+
return outcome;
|
|
123
|
+
const backoff = retryAfterMs ?? Math.min(this.backoffMs * 2 ** attempt, 5000);
|
|
124
|
+
await this.sleep(backoff);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** One RDAP HTTP attempt mapped to an outcome (+ a Retry-After hint). */
|
|
128
|
+
async attempt(ip) {
|
|
129
|
+
const ac = new AbortController();
|
|
130
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
95
131
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
value = parseRdapResponse(await res.json());
|
|
132
|
+
const res = await this.fetch(`${this.baseUrl}/ip/${encodeURIComponent(ip)}`, { signal: ac.signal });
|
|
133
|
+
if (res.ok) {
|
|
134
|
+
const parsed = parseRdapResponse(await res.json());
|
|
135
|
+
// A 2xx with no country/org is a genuine "no enrichment for this IP".
|
|
136
|
+
return { outcome: parsed ? { status: "ok", value: parsed } : { status: "not_found" } };
|
|
102
137
|
}
|
|
103
|
-
|
|
104
|
-
|
|
138
|
+
// 429/403 are the throttle responses RIRs use; 5xx is upstream trouble —
|
|
139
|
+
// both are transient. 404 (and other malformed-query 4xx) is a genuine
|
|
140
|
+
// negative for this address.
|
|
141
|
+
if (res.status === 429 || res.status === 403) {
|
|
142
|
+
return { outcome: { status: "transient", reason: "rate_limited" }, retryAfterMs: retryAfter(res) };
|
|
105
143
|
}
|
|
144
|
+
if (res.status >= 500)
|
|
145
|
+
return { outcome: { status: "transient", reason: "upstream_error" } };
|
|
146
|
+
return { outcome: { status: "not_found" } };
|
|
106
147
|
}
|
|
107
148
|
catch {
|
|
108
|
-
|
|
149
|
+
// AbortController fired → our own timeout; otherwise a network error.
|
|
150
|
+
return { outcome: { status: "transient", reason: ac.signal.aborted ? "timeout" : "network_error" } };
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
clearTimeout(timer);
|
|
109
154
|
}
|
|
110
|
-
this.put(ip, { value, expiresAt: this.now() + (value ? this.ttlMs : this.negTtlMs) });
|
|
111
|
-
return value;
|
|
112
155
|
}
|
|
113
156
|
put(ip, entry) {
|
|
114
157
|
if (this.cache.size >= this.maxCache) {
|
|
@@ -120,3 +163,14 @@ export class RdapResolver {
|
|
|
120
163
|
this.cache.set(ip, entry);
|
|
121
164
|
}
|
|
122
165
|
}
|
|
166
|
+
/** Parse a `Retry-After` header (delta-seconds form) into ms, capped at 5s so a
|
|
167
|
+
* hostile/huge value can't stall a batch. Ignores the HTTP-date form. */
|
|
168
|
+
function retryAfter(res) {
|
|
169
|
+
const raw = res.headers?.get?.("retry-after");
|
|
170
|
+
if (!raw)
|
|
171
|
+
return undefined;
|
|
172
|
+
const secs = Number(raw.trim());
|
|
173
|
+
if (!Number.isFinite(secs) || secs < 0)
|
|
174
|
+
return undefined;
|
|
175
|
+
return Math.min(secs * 1000, 5000);
|
|
176
|
+
}
|
package/dist/enrich/rdap.test.js
CHANGED
|
@@ -76,3 +76,85 @@ describe("RdapResolver", () => {
|
|
|
76
76
|
assert.equal(await r.lookup("8.8.8.8"), null);
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
|
+
// Issue #523: a rate-limit / upstream failure must be distinguishable from a
|
|
80
|
+
// true negative, and must NOT poison the cache as one.
|
|
81
|
+
describe("RdapResolver.resolve — transient vs true negative (#523)", () => {
|
|
82
|
+
const noSleep = async () => { };
|
|
83
|
+
it("maps a 200 hit to ok", async () => {
|
|
84
|
+
const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: RDAP_GOOGLE }));
|
|
85
|
+
const r = new RdapResolver({ fetch, sleep: noSleep });
|
|
86
|
+
assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
|
|
87
|
+
});
|
|
88
|
+
it("maps a 404 to a true negative (not_found)", async () => {
|
|
89
|
+
const { fetch } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
|
|
90
|
+
const r = new RdapResolver({ fetch, sleep: noSleep });
|
|
91
|
+
assert.deepEqual(await r.resolve("203.0.113.7"), { status: "not_found" });
|
|
92
|
+
});
|
|
93
|
+
it("maps a 200 with no country/org to not_found, not a bogus hit", async () => {
|
|
94
|
+
const { fetch } = stubFetch(() => ({ ok: true, status: 200, body: { handle: "x" } }));
|
|
95
|
+
const r = new RdapResolver({ fetch, sleep: noSleep });
|
|
96
|
+
assert.deepEqual(await r.resolve("203.0.113.8"), { status: "not_found" });
|
|
97
|
+
});
|
|
98
|
+
it("maps a 429 to transient:rate_limited after exhausting retries", async () => {
|
|
99
|
+
const { fetch, calls } = stubFetch(() => ({ ok: false, status: 429, body: {} }));
|
|
100
|
+
const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
|
|
101
|
+
assert.deepEqual(await r.resolve("203.0.113.10"), { status: "transient", reason: "rate_limited" });
|
|
102
|
+
assert.equal(calls.length, 3, "1 initial + 2 retries");
|
|
103
|
+
});
|
|
104
|
+
it("maps a 5xx to transient:upstream_error", async () => {
|
|
105
|
+
const { fetch } = stubFetch(() => ({ ok: false, status: 503, body: {} }));
|
|
106
|
+
const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
|
|
107
|
+
assert.deepEqual(await r.resolve("203.0.113.11"), { status: "transient", reason: "upstream_error" });
|
|
108
|
+
});
|
|
109
|
+
it("maps a thrown network error to transient:network_error", async () => {
|
|
110
|
+
const r = new RdapResolver({ fetch: (async () => { throw new Error("ECONNRESET"); }), sleep: noSleep, maxRetries: 0 });
|
|
111
|
+
assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "network_error" });
|
|
112
|
+
});
|
|
113
|
+
it("does NOT cache a transient failure — a later success resolves", async () => {
|
|
114
|
+
let mode = "throttle";
|
|
115
|
+
const calls = [];
|
|
116
|
+
const fetch = async (url) => {
|
|
117
|
+
calls.push(url);
|
|
118
|
+
return mode === "throttle"
|
|
119
|
+
? { ok: false, status: 429, json: async () => ({}) }
|
|
120
|
+
: { ok: true, status: 200, json: async () => RDAP_GOOGLE };
|
|
121
|
+
};
|
|
122
|
+
const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 0 });
|
|
123
|
+
assert.deepEqual(await r.resolve("8.8.8.8"), { status: "transient", reason: "rate_limited" });
|
|
124
|
+
mode = "ok";
|
|
125
|
+
assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
|
|
126
|
+
assert.equal(calls.length, 2, "transient was not cached, so the retry re-fetched");
|
|
127
|
+
});
|
|
128
|
+
it("retries a transient then succeeds, returning ok", async () => {
|
|
129
|
+
let n = 0;
|
|
130
|
+
const fetch = async () => {
|
|
131
|
+
n++;
|
|
132
|
+
return n === 1
|
|
133
|
+
? { ok: false, status: 429, json: async () => ({}) }
|
|
134
|
+
: { ok: true, status: 200, json: async () => RDAP_GOOGLE };
|
|
135
|
+
};
|
|
136
|
+
const r = new RdapResolver({ fetch, sleep: noSleep, maxRetries: 2 });
|
|
137
|
+
assert.deepEqual(await r.resolve("8.8.8.8"), { status: "ok", value: { country: "US", org: "Google LLC" } });
|
|
138
|
+
assert.equal(n, 2, "succeeded on the first retry");
|
|
139
|
+
});
|
|
140
|
+
it("honors a numeric Retry-After for backoff (capped)", async () => {
|
|
141
|
+
const slept = [];
|
|
142
|
+
let n = 0;
|
|
143
|
+
const fetch = async () => {
|
|
144
|
+
n++;
|
|
145
|
+
if (n === 1)
|
|
146
|
+
return { ok: false, status: 429, json: async () => ({}), headers: { get: (h) => (h.toLowerCase() === "retry-after" ? "2" : null) } };
|
|
147
|
+
return { ok: true, status: 200, json: async () => RDAP_GOOGLE };
|
|
148
|
+
};
|
|
149
|
+
const r = new RdapResolver({ fetch, sleep: async (ms) => { slept.push(ms); }, maxRetries: 1 });
|
|
150
|
+
await r.resolve("8.8.8.8");
|
|
151
|
+
assert.deepEqual(slept, [2000], "waited the Retry-After delta (2s) before retrying");
|
|
152
|
+
});
|
|
153
|
+
it("caches a true negative (no re-fetch within negTtl)", async () => {
|
|
154
|
+
const { fetch, calls } = stubFetch(() => ({ ok: false, status: 404, body: {} }));
|
|
155
|
+
const r = new RdapResolver({ fetch, sleep: noSleep });
|
|
156
|
+
await r.resolve("203.0.113.7");
|
|
157
|
+
await r.resolve("203.0.113.7");
|
|
158
|
+
assert.equal(calls.length, 1, "negative cached");
|
|
159
|
+
});
|
|
160
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
|
|
10
10
|
import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
|
|
11
11
|
import { isTopologyProvider } from "./connectors/interface.js";
|
|
12
|
-
import { defaultContext, principalContext, sessionContext, allowsTool } from "./context.js";
|
|
12
|
+
import { defaultContext, principalContext, sessionContext, allowsTool, intersectAllowed } from "./context.js";
|
|
13
13
|
import { parseKeyTenants, isMultiTenantConfigured } from "./tenancy/context.js";
|
|
14
14
|
import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, inspectEnforceEntitled, featureEntitled, entitledFeatures, } from "./enterprise-gate.js";
|
|
15
15
|
import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
|
|
@@ -382,8 +382,13 @@ async function main() {
|
|
|
382
382
|
// reaches the caller. When no hooks are registered (the default in
|
|
383
383
|
// the OSS demo) the wrapper is a thin pass-through.
|
|
384
384
|
const registerTool = ((name, ...rest) => {
|
|
385
|
-
|
|
385
|
+
// Two independent allow-list axes must BOTH pass: the Product binding
|
|
386
|
+
// (ctx.allowedTools, OMCP_KEY_PRODUCTS) and the per-credential list
|
|
387
|
+
// (ctx.credentialTools, OMCP_KEY_TOOLS). Either undefined = no restriction
|
|
388
|
+
// on that axis; disjoint non-empty lists therefore deny every tool.
|
|
389
|
+
if (!allowsTool(ctx.allowedTools, name) || !allowsTool(ctx.credentialTools, name)) {
|
|
386
390
|
return undefined;
|
|
391
|
+
}
|
|
387
392
|
if (rest.length > 0 && typeof rest[rest.length - 1] === "function") {
|
|
388
393
|
const originalHandler = rest[rest.length - 1];
|
|
389
394
|
const wrappedHandler = wrapToolHandler(hookRegistry, { principal: ctx.principalId, tenant: ctx.tenant || "default", target: name }, originalHandler);
|
|
@@ -794,6 +799,7 @@ async function main() {
|
|
|
794
799
|
"Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
|
|
795
800
|
"When to use: answering 'where are these visitors from?' or 'which of these IPs are bots / datacenter / VPN exit nodes?' over access logs, without an out-of-band geo-API call per IP. Both IPv4 and IPv6 clients are resolved — don't pre-filter v6 out.",
|
|
796
801
|
"Behavior: read-only. By default looks each IP up in a LOCAL offline dataset the operator configured (OMCP_IP_ENRICH_FILE) with NO external network call — safe in air-gapped deployments. Optionally, if the operator enabled OMCP_IP_ENRICH_RDAP, IPs the dataset doesn't cover fall back to an online RDAP query (country/org only) and the result carries via:'rdap'; the offline dataset is always preferred. Returns one row per input IP with found=true/false plus any known fields. If neither is configured it returns a clear notice explaining how to enable them.",
|
|
802
|
+
"RDAP rate-limits: a row with found=false AND transient:true (error names the cause, e.g. 'rate_limited') is NOT a confirmed negative — the registry throttled or failed the lookup, so the IP may resolve on a later retry or in a smaller batch. Such rows are counted in summary.transient (separate from summary.unmatched) and a top-level `note` is added. Don't treat transient rows as 'unknown/suspicious'; retry them (results are cached, so repeats are cheap).",
|
|
797
803
|
"Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
|
|
798
804
|
].join(" "), {
|
|
799
805
|
ips: z
|
|
@@ -1848,6 +1854,7 @@ async function main() {
|
|
|
1848
1854
|
productId: c.productId,
|
|
1849
1855
|
bypassRedaction: !!c.bypassRedaction,
|
|
1850
1856
|
allowedSources: c.allowedSources,
|
|
1857
|
+
allowedTools: c.allowedTools,
|
|
1851
1858
|
});
|
|
1852
1859
|
}
|
|
1853
1860
|
// OIDC groups → role mappings.
|
|
@@ -3669,6 +3676,11 @@ async function main() {
|
|
|
3669
3676
|
allowRawQuery: cred.allowRawQuery,
|
|
3670
3677
|
tenant: cred.tenant,
|
|
3671
3678
|
allowedTools,
|
|
3679
|
+
// Per-credential tool allow-list (OMCP_KEY_TOOLS) — a SEPARATE axis from
|
|
3680
|
+
// the Product one. The registration gate requires a tool to pass BOTH,
|
|
3681
|
+
// so disjoint lists deny everything without overloading the empty-list
|
|
3682
|
+
// "allow all" semantics allowsTool uses for the Product axis.
|
|
3683
|
+
credentialTools: cred.allowedTools,
|
|
3672
3684
|
});
|
|
3673
3685
|
}
|
|
3674
3686
|
app.post("/mcp", async (req, res) => {
|
|
@@ -3740,14 +3752,6 @@ async function main() {
|
|
|
3740
3752
|
// registerTool gate, so the surface a /mcp/v/<slug> client sees is
|
|
3741
3753
|
// strictly product.tools (intersected with any pre-existing
|
|
3742
3754
|
// allowedTools the credential already carries).
|
|
3743
|
-
function intersectAllowed(a, b) {
|
|
3744
|
-
if (!a)
|
|
3745
|
-
return b;
|
|
3746
|
-
if (!b)
|
|
3747
|
-
return a;
|
|
3748
|
-
const bSet = new Set(b);
|
|
3749
|
-
return a.filter((t) => bSet.has(t));
|
|
3750
|
-
}
|
|
3751
3755
|
async function resolveVirtualProduct(req, res, baseCtx) {
|
|
3752
3756
|
const slug = req.params.slug;
|
|
3753
3757
|
if (!slug || typeof slug !== "string") {
|
|
@@ -3910,6 +3914,7 @@ async function main() {
|
|
|
3910
3914
|
allowRawQuery: cred.allowRawQuery,
|
|
3911
3915
|
tenant: cred.tenant,
|
|
3912
3916
|
allowedTools,
|
|
3917
|
+
credentialTools: cred.allowedTools,
|
|
3913
3918
|
}),
|
|
3914
3919
|
selectedSubprotocol,
|
|
3915
3920
|
};
|
|
@@ -19,6 +19,12 @@ export interface IpEnrichmentResult {
|
|
|
19
19
|
/** Which backend produced the hit — "dataset" (offline CSV) or "rdap"
|
|
20
20
|
* (online fallback). Absent when not found. */
|
|
21
21
|
via?: "dataset" | "rdap";
|
|
22
|
+
/** True when `found:false` is NOT a confirmed negative but an RDAP upstream
|
|
23
|
+
* failure (throttle/timeout/5xx) — the address may resolve on a later retry.
|
|
24
|
+
* Issue #523: never conflate a rate-limit with "not in any registry". */
|
|
25
|
+
transient?: boolean;
|
|
26
|
+
/** Machine-readable reason when `transient` — e.g. "rate_limited". */
|
|
27
|
+
error?: string;
|
|
22
28
|
}
|
|
23
29
|
export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext, rdap?: RdapResolver | null): Promise<{
|
|
24
30
|
content: {
|
package/dist/tools/enrich-ips.js
CHANGED
|
@@ -38,6 +38,7 @@ rdap) {
|
|
|
38
38
|
let invalid = 0;
|
|
39
39
|
let matched = 0;
|
|
40
40
|
let viaRdap = 0;
|
|
41
|
+
let transient = 0;
|
|
41
42
|
for (const ip of ips) {
|
|
42
43
|
if (typeof ip !== "string" || !isValidIp(ip)) {
|
|
43
44
|
invalid++;
|
|
@@ -53,16 +54,26 @@ rdap) {
|
|
|
53
54
|
continue;
|
|
54
55
|
}
|
|
55
56
|
if (rdap) {
|
|
56
|
-
const r = await rdap.
|
|
57
|
-
if (r) {
|
|
57
|
+
const r = await rdap.resolve(ip);
|
|
58
|
+
if (r.status === "ok") {
|
|
58
59
|
matched++;
|
|
59
60
|
viaRdap++;
|
|
60
|
-
results.push({ ip, found: true, via: "rdap", ...r });
|
|
61
|
+
results.push({ ip, found: true, via: "rdap", ...r.value });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (r.status === "transient") {
|
|
65
|
+
// NOT a confirmed negative — an RDAP throttle/timeout/5xx. Mark it so an
|
|
66
|
+
// agent doesn't treat the IP as "unknown" and can retry later (#523).
|
|
67
|
+
transient++;
|
|
68
|
+
results.push({ ip, found: false, transient: true, error: r.reason });
|
|
61
69
|
continue;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
results.push({ ip, found: false });
|
|
65
73
|
}
|
|
74
|
+
// unmatched = confirmed negatives only; transient failures are reported
|
|
75
|
+
// separately so the all-clear can't silently absorb a wall of rate-limits.
|
|
76
|
+
const unmatched = ips.length - matched - invalid - transient;
|
|
66
77
|
return {
|
|
67
78
|
content: [
|
|
68
79
|
{
|
|
@@ -72,12 +83,19 @@ rdap) {
|
|
|
72
83
|
summary: {
|
|
73
84
|
total: ips.length,
|
|
74
85
|
matched,
|
|
75
|
-
unmatched
|
|
86
|
+
unmatched,
|
|
76
87
|
invalid,
|
|
77
|
-
...(rdap ? { viaRdap } : {}),
|
|
88
|
+
...(rdap ? { viaRdap, transient } : {}),
|
|
78
89
|
},
|
|
79
90
|
datasetSize: dataset?.size ?? 0,
|
|
80
91
|
...(rdap ? { rdapEnabled: true } : {}),
|
|
92
|
+
...(transient > 0
|
|
93
|
+
? {
|
|
94
|
+
note: `${transient} RDAP lookup(s) failed transiently (e.g. rate-limited by the ` +
|
|
95
|
+
`registry) and are marked transient:true — these are NOT confirmed negatives. ` +
|
|
96
|
+
`Retry them later or in a smaller batch; results are cached so repeats are cheap.`,
|
|
97
|
+
}
|
|
98
|
+
: {}),
|
|
81
99
|
}, null, 2),
|
|
82
100
|
},
|
|
83
101
|
],
|
|
@@ -48,13 +48,21 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
|
|
51
|
-
// Minimal RdapResolver stub: returns a fixed hit for one IP,
|
|
52
|
-
// and records which IPs it was asked about (to prove CSV-first).
|
|
53
|
-
|
|
51
|
+
// Minimal RdapResolver stub: returns a fixed hit for one IP, a true negative
|
|
52
|
+
// otherwise, and records which IPs it was asked about (to prove CSV-first).
|
|
53
|
+
// `transient` IPs resolve to a transient outcome (e.g. rate-limited) — #523.
|
|
54
|
+
function rdapStub(hit, transient = {}) {
|
|
54
55
|
const asked = [];
|
|
55
56
|
return {
|
|
56
57
|
asked,
|
|
57
|
-
resolver: {
|
|
58
|
+
resolver: {
|
|
59
|
+
resolve: async (ip) => {
|
|
60
|
+
asked.push(ip);
|
|
61
|
+
if (transient[ip])
|
|
62
|
+
return { status: "transient", reason: transient[ip] };
|
|
63
|
+
return hit[ip] ? { status: "ok", value: hit[ip] } : { status: "not_found" };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
58
66
|
};
|
|
59
67
|
}
|
|
60
68
|
it("with no dataset but RDAP enabled → not 'not configured'; resolves via RDAP", async () => {
|
|
@@ -90,4 +98,29 @@ describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
|
|
|
90
98
|
assert.match(out.error, /not configured/i);
|
|
91
99
|
assert.match(out.error, /OMCP_IP_ENRICH_RDAP/);
|
|
92
100
|
});
|
|
101
|
+
// Issue #523: a rate-limited lookup must NOT masquerade as a confirmed negative.
|
|
102
|
+
it("marks a rate-limited lookup transient (not a confirmed negative)", async () => {
|
|
103
|
+
const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } }, { "203.0.113.10": "rate_limited" });
|
|
104
|
+
const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.10"] }, undefined, resolver));
|
|
105
|
+
const hit = out.results.find((r) => r.ip === "8.8.8.8");
|
|
106
|
+
assert.equal(hit.found, true);
|
|
107
|
+
const throttled = out.results.find((r) => r.ip === "203.0.113.10");
|
|
108
|
+
assert.equal(throttled.found, false);
|
|
109
|
+
assert.equal(throttled.transient, true);
|
|
110
|
+
assert.equal(throttled.error, "rate_limited");
|
|
111
|
+
// The throttled IP is counted as transient, NOT folded into `unmatched`.
|
|
112
|
+
assert.equal(out.summary.matched, 1);
|
|
113
|
+
assert.equal(out.summary.transient, 1);
|
|
114
|
+
assert.equal(out.summary.unmatched, 0);
|
|
115
|
+
assert.match(out.note, /NOT confirmed negatives/i);
|
|
116
|
+
});
|
|
117
|
+
it("a genuine miss stays a clean negative — no transient marker, no note", async () => {
|
|
118
|
+
const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } });
|
|
119
|
+
const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.9"] }, undefined, resolver));
|
|
120
|
+
const miss = out.results.find((r) => r.ip === "203.0.113.9");
|
|
121
|
+
assert.equal(miss.found, false);
|
|
122
|
+
assert.equal(miss.transient, undefined);
|
|
123
|
+
assert.equal(out.summary.transient, 0);
|
|
124
|
+
assert.equal(out.note, undefined);
|
|
125
|
+
});
|
|
93
126
|
});
|
package/package.json
CHANGED