@thotischner/observability-mcp 3.6.0 → 3.7.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/conformance/mcp-2025-11-25.test.js +10 -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/index.js +14 -5
- package/dist/net/egress-policy.js +1 -0
- 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/package.json +1 -1
|
@@ -439,3 +439,13 @@ test("E2E: initialize advertises non-empty instructions pointing at the usage gu
|
|
|
439
439
|
assert.match(r.instructions, /omcp:\/\/guide\/agent-usage/, "must point at the usage-guide resource");
|
|
440
440
|
assert.match(r.instructions, /aggregate/i, "must carry the filter+aggregate golden rule");
|
|
441
441
|
});
|
|
442
|
+
test("E2E: enrich_ips advertises IPv6 in its description + ips param (issue #476)", opts, async () => {
|
|
443
|
+
const session = await newSession();
|
|
444
|
+
const { response } = await jsonRpc("tools/list", {}, { id: 40, session });
|
|
445
|
+
const r = response.result;
|
|
446
|
+
const tool = r.tools?.find((t) => t.name === "enrich_ips");
|
|
447
|
+
assert.ok(tool, "enrich_ips must be advertised");
|
|
448
|
+
assert.match(tool.description ?? "", /IPv6/, "description must mention IPv6 (not IPv4-only)");
|
|
449
|
+
const ipsDesc = tool.inputSchema?.properties?.ips?.description ?? "";
|
|
450
|
+
assert.match(ipsDesc, /IPv6/, "the ips param must mention IPv6");
|
|
451
|
+
});
|
|
@@ -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
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,7 @@ import { queryMetricsHandler } from "./tools/query-metrics.js";
|
|
|
61
61
|
import { queryLogsHandler } from "./tools/query-logs.js";
|
|
62
62
|
import { enrichIpsHandler } from "./tools/enrich-ips.js";
|
|
63
63
|
import { IpEnrichmentDataset } from "./enrich/ip-dataset.js";
|
|
64
|
+
import { RdapResolver } from "./enrich/rdap.js";
|
|
64
65
|
import { queryTracesHandler } from "./tools/query-traces.js";
|
|
65
66
|
import { getAnomalyHistoryHandler } from "./tools/get-anomaly-history.js";
|
|
66
67
|
import { generatePostmortemHandler } from "./tools/generate-postmortem.js";
|
|
@@ -299,6 +300,14 @@ async function main() {
|
|
|
299
300
|
console.error(`[enrich] failed to load OMCP_IP_ENRICH_FILE (${ipEnrichFile}): ${err instanceof Error ? err.message : String(err)} — enrich_ips will report 'not configured'`);
|
|
300
301
|
}
|
|
301
302
|
}
|
|
303
|
+
// Optional ONLINE RDAP fallback (issue #477) — OFF by default to keep the
|
|
304
|
+
// air-gapped guarantee. Built only when OMCP_IP_ENRICH_RDAP is truthy; the
|
|
305
|
+
// offline CSV stays preferred and RDAP only fills gaps it didn't cover.
|
|
306
|
+
let ipRdap = null;
|
|
307
|
+
if (["on", "true", "1"].includes(String(process.env.OMCP_IP_ENRICH_RDAP ?? "").toLowerCase())) {
|
|
308
|
+
ipRdap = new RdapResolver({ baseUrl: process.env.OMCP_IP_ENRICH_RDAP_URL?.trim() || undefined });
|
|
309
|
+
console.log("[enrich] RDAP online fallback ENABLED (OMCP_IP_ENRICH_RDAP) — enrich_ips will query rdap.org for gaps the offline dataset doesn't cover");
|
|
310
|
+
}
|
|
302
311
|
function redactToolText(result, opts = {}) {
|
|
303
312
|
if (!REDACTION_ENABLED)
|
|
304
313
|
return result;
|
|
@@ -781,17 +790,17 @@ async function main() {
|
|
|
781
790
|
return withToolMetrics("get_blast_radius", () => getBlastRadiusHandler(registry, args, ctx));
|
|
782
791
|
});
|
|
783
792
|
registerTool("enrich_ips", [
|
|
784
|
-
"Resolve a batch of IPv4 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
|
|
785
|
-
"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.",
|
|
786
|
-
"Behavior: read-only.
|
|
793
|
+
"Resolve a batch of IPv4 or IPv6 addresses to geo (country/city), ASN/org, and a hosting/proxy flag.",
|
|
794
|
+
"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.",
|
|
795
|
+
"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.",
|
|
787
796
|
"Related: pull the IPs from `query_logs` (use `labels`/`aggregate` to find the IPs of interest first).",
|
|
788
797
|
].join(" "), {
|
|
789
798
|
ips: z
|
|
790
799
|
.array(z.string())
|
|
791
|
-
.describe("Required. IPv4 address strings to enrich (e.g. ['203.0.113.5','
|
|
800
|
+
.describe("Required. IPv4 or IPv6 address strings to enrich (e.g. ['203.0.113.5','2001:db8::1']). Max 1000 per call; invalid entries are returned with found=false rather than failing the batch."),
|
|
792
801
|
}, { title: "Enrich IPs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async (args) => {
|
|
793
802
|
await enforceEntitledAccess(ctx, { tool: "enrich_ips" });
|
|
794
|
-
return withToolMetrics("enrich_ips", async () => enrichIpsHandler(ipEnrichment, args, ctx));
|
|
803
|
+
return withToolMetrics("enrich_ips", async () => enrichIpsHandler(ipEnrichment, args, ctx, ipRdap));
|
|
795
804
|
});
|
|
796
805
|
// Phase F10: federated tools — every upstream MCP server's tools
|
|
797
806
|
// show up here under `<prefix>.<upstream-tool>`. The handler is a
|
|
@@ -27,6 +27,7 @@ export const EGRESS_ALLOWLIST = [
|
|
|
27
27
|
{ prefix: "index.ts", reason: "connector-hub plugin install of an operator/registry-requested tarball URL" },
|
|
28
28
|
{ prefix: "auth/oidc/", reason: "OIDC client calls the operator-configured OMCP_OIDC_ISSUER for discovery, JWKS, and code-exchange" },
|
|
29
29
|
{ prefix: "auth/policy/", reason: "OpaPolicyEngine queries the operator-configured OMCP_OPA_URL on every RBAC decision" },
|
|
30
|
+
{ prefix: "enrich/rdap.ts", reason: "enrich_ips RDAP fallback — OFF by default; only when the operator opts in with OMCP_IP_ENRICH_RDAP does it query the RDAP bootstrap. No call otherwise; the air-gapped default is preserved" },
|
|
30
31
|
];
|
|
31
32
|
/**
|
|
32
33
|
* Hard-blocked analytics/telemetry SDKs — matches an *import/require of the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IpEnrichmentDataset } from "../enrich/ip-dataset.js";
|
|
2
|
+
import type { RdapResolver } from "../enrich/rdap.js";
|
|
2
3
|
import { type RequestContext } from "../context.js";
|
|
3
4
|
export declare const enrichIpsDefinition: {
|
|
4
5
|
name: "enrich_ips";
|
|
@@ -15,8 +16,11 @@ export interface IpEnrichmentResult {
|
|
|
15
16
|
asn?: string;
|
|
16
17
|
org?: string;
|
|
17
18
|
hosting?: boolean;
|
|
19
|
+
/** Which backend produced the hit — "dataset" (offline CSV) or "rdap"
|
|
20
|
+
* (online fallback). Absent when not found. */
|
|
21
|
+
via?: "dataset" | "rdap";
|
|
18
22
|
}
|
|
19
|
-
export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext): {
|
|
23
|
+
export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext, rdap?: RdapResolver | null): Promise<{
|
|
20
24
|
content: {
|
|
21
25
|
type: "text";
|
|
22
26
|
text: string;
|
|
@@ -27,4 +31,4 @@ export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, ar
|
|
|
27
31
|
type: "text";
|
|
28
32
|
text: string;
|
|
29
33
|
}[];
|
|
30
|
-
}
|
|
34
|
+
}>;
|
package/dist/tools/enrich-ips.js
CHANGED
|
@@ -14,15 +14,18 @@ const MAX_IPS = 1000;
|
|
|
14
14
|
function isValidIp(ip) {
|
|
15
15
|
return ipv4ToInt(ip) !== null || ipv6ToBigInt(ip) !== null;
|
|
16
16
|
}
|
|
17
|
-
export function enrichIpsHandler(dataset, args,
|
|
17
|
+
export async function enrichIpsHandler(dataset, args,
|
|
18
18
|
// The RequestContext seam — enrich_ips doesn't scope by tenant today (the
|
|
19
19
|
// dataset is a single process-wide table), but every tool handler threads
|
|
20
20
|
// ctx so access-control / audit can attach without a signature change later.
|
|
21
|
-
_ctx = defaultContext()
|
|
22
|
-
|
|
21
|
+
_ctx = defaultContext(),
|
|
22
|
+
// Optional online RDAP fallback (issue #477). Present only when the operator
|
|
23
|
+
// set OMCP_IP_ENRICH_RDAP=on; absent → no external call (air-gapped default).
|
|
24
|
+
rdap) {
|
|
25
|
+
if (!dataset && !rdap) {
|
|
23
26
|
return errorResponse("IP enrichment is not configured. Set OMCP_IP_ENRICH_FILE to a local CSV " +
|
|
24
|
-
"(network,country,city,asn,org,hosting)
|
|
25
|
-
"
|
|
27
|
+
"(network,country,city,asn,org,hosting) for offline lookups (air-gapped), " +
|
|
28
|
+
"or OMCP_IP_ENRICH_RDAP=on for an online RDAP fallback (country/org only).");
|
|
26
29
|
}
|
|
27
30
|
const ips = args.ips;
|
|
28
31
|
if (!Array.isArray(ips) || ips.length === 0) {
|
|
@@ -34,20 +37,31 @@ _ctx = defaultContext()) {
|
|
|
34
37
|
const results = [];
|
|
35
38
|
let invalid = 0;
|
|
36
39
|
let matched = 0;
|
|
40
|
+
let viaRdap = 0;
|
|
37
41
|
for (const ip of ips) {
|
|
38
42
|
if (typeof ip !== "string" || !isValidIp(ip)) {
|
|
39
43
|
invalid++;
|
|
40
44
|
results.push({ ip: String(ip), found: false });
|
|
41
45
|
continue;
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
// Offline CSV is preferred (city precision, air-gapped). RDAP only fills
|
|
48
|
+
// gaps the dataset didn't cover, and only when the operator opted in.
|
|
49
|
+
const hit = dataset ? dataset.lookup(ip) : null;
|
|
44
50
|
if (hit) {
|
|
45
51
|
matched++;
|
|
46
|
-
results.push({ ip, found: true, ...hit });
|
|
52
|
+
results.push({ ip, found: true, via: "dataset", ...hit });
|
|
53
|
+
continue;
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
if (rdap) {
|
|
56
|
+
const r = await rdap.lookup(ip);
|
|
57
|
+
if (r) {
|
|
58
|
+
matched++;
|
|
59
|
+
viaRdap++;
|
|
60
|
+
results.push({ ip, found: true, via: "rdap", ...r });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
50
63
|
}
|
|
64
|
+
results.push({ ip, found: false });
|
|
51
65
|
}
|
|
52
66
|
return {
|
|
53
67
|
content: [
|
|
@@ -55,8 +69,15 @@ _ctx = defaultContext()) {
|
|
|
55
69
|
type: "text",
|
|
56
70
|
text: JSON.stringify({
|
|
57
71
|
results,
|
|
58
|
-
summary: {
|
|
59
|
-
|
|
72
|
+
summary: {
|
|
73
|
+
total: ips.length,
|
|
74
|
+
matched,
|
|
75
|
+
unmatched: ips.length - matched - invalid,
|
|
76
|
+
invalid,
|
|
77
|
+
...(rdap ? { viaRdap } : {}),
|
|
78
|
+
},
|
|
79
|
+
datasetSize: dataset?.size ?? 0,
|
|
80
|
+
...(rdap ? { rdapEnabled: true } : {}),
|
|
60
81
|
}, null, 2),
|
|
61
82
|
},
|
|
62
83
|
],
|
|
@@ -7,21 +7,21 @@ function parse(result) {
|
|
|
7
7
|
}
|
|
8
8
|
const ds = IpEnrichmentDataset.fromCsv(["1.2.3.0/24,US,Ashburn,AS14618,Example Cloud,true", "203.0.113.5,DE,Berlin,AS3320,Example ISP,false"].join("\n"));
|
|
9
9
|
describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
|
|
10
|
-
it("returns a clear 'not configured' notice when no dataset is loaded", () => {
|
|
11
|
-
const out = parse(enrichIpsHandler(null, { ips: ["1.2.3.4"] }));
|
|
10
|
+
it("returns a clear 'not configured' notice when no dataset is loaded", async () => {
|
|
11
|
+
const out = parse(await enrichIpsHandler(null, { ips: ["1.2.3.4"] }));
|
|
12
12
|
assert.match(out.error, /not configured/i);
|
|
13
13
|
assert.match(out.error, /OMCP_IP_ENRICH_FILE/);
|
|
14
14
|
});
|
|
15
|
-
it("rejects empty / missing ips", () => {
|
|
16
|
-
assert.match(parse(enrichIpsHandler(ds, { ips: [] })).error, /non-empty array/i);
|
|
17
|
-
assert.match(parse(enrichIpsHandler(ds, {})).error, /non-empty array/i);
|
|
15
|
+
it("rejects empty / missing ips", async () => {
|
|
16
|
+
assert.match(parse(await enrichIpsHandler(ds, { ips: [] })).error, /non-empty array/i);
|
|
17
|
+
assert.match(parse(await enrichIpsHandler(ds, {})).error, /non-empty array/i);
|
|
18
18
|
});
|
|
19
|
-
it("rejects an over-large batch", () => {
|
|
19
|
+
it("rejects an over-large batch", async () => {
|
|
20
20
|
const many = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 255}`);
|
|
21
|
-
assert.match(parse(enrichIpsHandler(ds, { ips: many })).error, /Too many IPs/i);
|
|
21
|
+
assert.match(parse(await enrichIpsHandler(ds, { ips: many })).error, /Too many IPs/i);
|
|
22
22
|
});
|
|
23
|
-
it("enriches known IPs and reports found=false for misses + invalid", () => {
|
|
24
|
-
const out = parse(enrichIpsHandler(ds, { ips: ["1.2.3.99", "8.8.8.8", "not-an-ip"] }));
|
|
23
|
+
it("enriches known IPs and reports found=false for misses + invalid", async () => {
|
|
24
|
+
const out = parse(await enrichIpsHandler(ds, { ips: ["1.2.3.99", "8.8.8.8", "not-an-ip"] }));
|
|
25
25
|
assert.equal(out.results.length, 3);
|
|
26
26
|
const matched = out.results.find((r) => r.ip === "1.2.3.99");
|
|
27
27
|
assert.equal(matched.found, true);
|
|
@@ -35,9 +35,9 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
|
|
|
35
35
|
assert.deepEqual(out.summary, { total: 3, matched: 1, unmatched: 1, invalid: 1 });
|
|
36
36
|
assert.equal(out.datasetSize, 2);
|
|
37
37
|
});
|
|
38
|
-
it("accepts IPv6 inputs and enriches them (not counted invalid)", () => {
|
|
38
|
+
it("accepts IPv6 inputs and enriches them (not counted invalid)", async () => {
|
|
39
39
|
const ds6 = IpEnrichmentDataset.fromCsv(["2001:db8::/32,US,,AS14618,Example Cloud,true", "1.2.3.0/24,DE,Berlin,AS3320,Example ISP,false"].join("\n"));
|
|
40
|
-
const out = parse(enrichIpsHandler(ds6, { ips: ["2001:db8::1", "2606:4700::1", "1.2.3.9"] }));
|
|
40
|
+
const out = parse(await enrichIpsHandler(ds6, { ips: ["2001:db8::1", "2606:4700::1", "1.2.3.9"] }));
|
|
41
41
|
const v6hit = out.results.find((r) => r.ip === "2001:db8::1");
|
|
42
42
|
assert.equal(v6hit.found, true);
|
|
43
43
|
assert.equal(v6hit.org, "Example Cloud");
|
|
@@ -47,3 +47,47 @@ describe("enrichIpsHandler (R6, issue #415 Gap B)", () => {
|
|
|
47
47
|
assert.deepEqual(out.summary, { total: 3, matched: 2, unmatched: 1, invalid: 0 });
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
describe("enrichIpsHandler — optional RDAP fallback (issue #477)", () => {
|
|
51
|
+
// Minimal RdapResolver stub: returns a fixed hit for one IP, null otherwise,
|
|
52
|
+
// and records which IPs it was asked about (to prove CSV-first).
|
|
53
|
+
function rdapStub(hit) {
|
|
54
|
+
const asked = [];
|
|
55
|
+
return {
|
|
56
|
+
asked,
|
|
57
|
+
resolver: { lookup: async (ip) => { asked.push(ip); return hit[ip] ?? null; } },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
it("with no dataset but RDAP enabled → not 'not configured'; resolves via RDAP", async () => {
|
|
61
|
+
const { resolver } = rdapStub({ "8.8.8.8": { country: "US", org: "Google LLC" } });
|
|
62
|
+
const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8", "203.0.113.9"] }, undefined, resolver));
|
|
63
|
+
const hit = out.results.find((r) => r.ip === "8.8.8.8");
|
|
64
|
+
assert.equal(hit.found, true);
|
|
65
|
+
assert.equal(hit.via, "rdap");
|
|
66
|
+
assert.equal(hit.org, "Google LLC");
|
|
67
|
+
assert.equal(out.results.find((r) => r.ip === "203.0.113.9").found, false);
|
|
68
|
+
assert.equal(out.summary.viaRdap, 1);
|
|
69
|
+
assert.equal(out.rdapEnabled, true);
|
|
70
|
+
});
|
|
71
|
+
it("offline dataset is PREFERRED — RDAP is not consulted for a covered IP", async () => {
|
|
72
|
+
const { asked, resolver } = rdapStub({ "1.2.3.9": { country: "XX", org: "should-not-be-used" } });
|
|
73
|
+
const out = parse(await enrichIpsHandler(ds, { ips: ["1.2.3.9"] }, undefined, resolver));
|
|
74
|
+
const hit = out.results.find((r) => r.ip === "1.2.3.9");
|
|
75
|
+
assert.equal(hit.via, "dataset");
|
|
76
|
+
assert.equal(hit.city, "Ashburn"); // from the CSV, not RDAP
|
|
77
|
+
assert.deepEqual(asked, [], "RDAP must not be queried for a dataset-covered IP");
|
|
78
|
+
});
|
|
79
|
+
it("RDAP fills only the gaps the dataset didn't cover", async () => {
|
|
80
|
+
const { asked, resolver } = rdapStub({ "9.9.9.9": { country: "US", org: "Quad9" } });
|
|
81
|
+
const out = parse(await enrichIpsHandler(ds, { ips: ["1.2.3.9", "9.9.9.9"] }, undefined, resolver));
|
|
82
|
+
assert.equal(out.results.find((r) => r.ip === "1.2.3.9").via, "dataset");
|
|
83
|
+
assert.equal(out.results.find((r) => r.ip === "9.9.9.9").via, "rdap");
|
|
84
|
+
assert.deepEqual(asked, ["9.9.9.9"], "RDAP queried only for the uncovered IP");
|
|
85
|
+
assert.equal(out.summary.matched, 2);
|
|
86
|
+
assert.equal(out.summary.viaRdap, 1);
|
|
87
|
+
});
|
|
88
|
+
it("no dataset and no RDAP → still 'not configured', and names both options", async () => {
|
|
89
|
+
const out = parse(await enrichIpsHandler(null, { ips: ["8.8.8.8"] }));
|
|
90
|
+
assert.match(out.error, /not configured/i);
|
|
91
|
+
assert.match(out.error, /OMCP_IP_ENRICH_RDAP/);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/package.json
CHANGED