feat: resilience score, route leak detection, data provenance, MCP server

- Resilience Score (1-10): weighted 4-factor model (transit diversity 30%,
  peering breadth 25%, IXP presence 20%, path redundancy 25%), hard cap at
  5.0 on single transit provider. Confidence: HIGH (cross-validated data).
- Route Leak Detection: heuristic Tier-1 sandwich/downstream pattern check.
  Confidence: MEDIUM — pattern-based, not real-time, false positives flagged.
- Data Provenance System: every API response field includes source, validation
  method and confidence level. UI shows green/orange provenance badges.
- MCP Server: exposes PeerCortex as Claude Desktop/Code tools (lookup_asn,
  compare_networks, get_health_report, search_network, get_resilience_score).
This commit is contained in:
Rene Fichtmueller 2026-04-04 23:46:36 +02:00
parent a5335257a7
commit f6168f1329
4 changed files with 482 additions and 4 deletions

View File

@ -139,3 +139,21 @@ All notable changes to PeerCortex are documented here.
### Infrastructure ### Infrastructure
- Daily automated audit introduced: 103 ASNs validated every 24h - Daily automated audit introduced: 103 ASNs validated every 24h
## [0.6.9] — 2026-04-04
### Added
- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%),
Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%).
Hard cap at 5.0 when single transit provider detected.
Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
- **Route Leak Detection**: Heuristic pattern detection for suspicious routing
relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM —
pattern-based, not real-time. False positives possible.
- **Data Provenance System**: Every data point in the API response now includes
a _provenance field: source, validation method (cross-validated / heuristic /
computed / single-source), and confidence level (high / medium / experimental).
Visible in UI as colour-coded badges: green = validated, orange = indicative.
- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude
Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report,
search_network, get_resilience_score. All responses include provenance metadata.

265
mcp-server.js Normal file
View File

@ -0,0 +1,265 @@
#!/usr/bin/env node
/**
* PeerCortex MCP Server
* Exposes PeerCortex API as MCP tools for Claude Desktop / Claude Code.
* Communicates via stdio. Every response includes _provenance metadata.
*
* Usage in claude_desktop_config.json:
* { "mcpServers": { "peercortex": { "command": "node", "args": ["/opt/peercortex-app/mcp-server.js"] } } }
*/
const PEERCORTEX_BASE = process.env.PEERCORTEX_BASE || "http://localhost:3101";
// ── Minimal MCP stdio server (no SDK dependency issues) ──────────────────────
const readline = require("readline");
const http = require("http");
const https = require("https");
function callApi(path) {
return new Promise((resolve, reject) => {
const url = PEERCORTEX_BASE + path;
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { headers: { "User-Agent": "PeerCortex-MCP/1.0" } }, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error("JSON parse error: " + e.message)); }
});
});
req.on("error", reject);
req.setTimeout(30000, () => { req.destroy(new Error("timeout")); });
});
}
// Summarize a full lookup into a compact MCP-friendly object
function summarizeLookup(d) {
if (!d || !d.network) return null;
const n = d.network || {};
const p = d.prefixes || {};
const r = d.rpki || {};
const ix = d.ix_presence || {};
const nb = d.neighbours || {};
const rs = d.resilience_score;
const rl = d.route_leak;
const dq = d.data_quality || {};
const prov= d._provenance || {};
return {
asn: n.asn,
name: n.name,
org: n.org_name,
website: n.website,
rir: n.rir,
country: n.country,
type: n.type,
policy: n.policy,
prefixes: {
total: p.total,
ipv4: p.ipv4,
ipv6: p.ipv6,
},
rpki: {
coverage_pct: r.coverage_percent,
valid: r.valid,
invalid: r.invalid,
not_found: r.not_found,
},
ix_presence: {
unique_ixps: ix.unique_ixps,
total_connections: ix.total_connections,
},
neighbours: {
upstream_count: nb.upstream_count,
downstream_count: nb.downstream_count,
peer_count: nb.peer_count,
upstreams: (nb.upstreams || []).slice(0, 10).map(u => `AS${u.asn} ${u.name}`),
},
resilience_score: rs ? {
score: rs.score,
single_transit_cap: rs.single_transit_cap_applied,
breakdown: rs.breakdown,
_provenance: rs._provenance,
} : null,
route_leak: rl ? {
detected: rl.detected,
patterns: rl.patterns,
_provenance: rl._provenance,
} : null,
data_quality: {
score: dq.score,
confidence: dq.confidence,
},
_provenance: prov,
};
}
// ── MCP Tool Definitions ──────────────────────────────────────────────────────
const TOOLS = [
{
name: "lookup_asn",
description: "Look up comprehensive ASN data including prefixes, RPKI, IX presence, neighbours, resilience score, and route leak detection. All data points include provenance metadata (source, validation method, confidence level).",
inputSchema: {
type: "object",
properties: {
asn: { type: "string", description: "ASN number, e.g. '24940' or 'AS24940'" },
},
required: ["asn"],
},
},
{
name: "compare_networks",
description: "Compare two ASNs side by side — prefixes, RPKI coverage, IX presence, resilience scores, peering policy.",
inputSchema: {
type: "object",
properties: {
asn1: { type: "string", description: "First ASN" },
asn2: { type: "string", description: "Second ASN" },
},
required: ["asn1", "asn2"],
},
},
{
name: "get_health_report",
description: "Run the PeerCortex 13-point health report for an ASN: RPKI, ASPA, ROA coverage, IRR, MANRS, route visibility, and more.",
inputSchema: {
type: "object",
properties: {
asn: { type: "string", description: "ASN number" },
},
required: ["asn"],
},
},
{
name: "search_network",
description: "Search for a network by name or organization. Returns matching ASNs with basic info.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Network name or organization to search for" },
},
required: ["query"],
},
},
{
name: "get_resilience_score",
description: "Get the weighted resilience score (1-10) for an ASN with full breakdown: transit diversity, peering breadth, IXP presence, path redundancy.",
inputSchema: {
type: "object",
properties: {
asn: { type: "string", description: "ASN number" },
},
required: ["asn"],
},
},
];
// ── MCP Request Handlers ──────────────────────────────────────────────────────
async function handleToolCall(name, args) {
try {
if (name === "lookup_asn") {
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
const d = await callApi(`/api/lookup?asn=${asnNum}`);
const summary = summarizeLookup(d);
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
}
if (name === "compare_networks") {
const a1 = String(args.asn1).replace(/[^0-9]/g, "");
const a2 = String(args.asn2).replace(/[^0-9]/g, "");
const [d1, d2] = await Promise.all([
callApi(`/api/lookup?asn=${a1}`),
callApi(`/api/lookup?asn=${a2}`),
]);
const comparison = {
asn1: summarizeLookup(d1),
asn2: summarizeLookup(d2),
comparison: {
prefixes: { [a1]: d1?.prefixes?.total, [a2]: d2?.prefixes?.total },
rpki_coverage: { [a1]: d1?.rpki?.coverage_percent + "%", [a2]: d2?.rpki?.coverage_percent + "%" },
unique_ixps: { [a1]: d1?.ix_presence?.unique_ixps, [a2]: d2?.ix_presence?.unique_ixps },
upstream_count: { [a1]: d1?.neighbours?.upstream_count, [a2]: d2?.neighbours?.upstream_count },
resilience_score: { [a1]: d1?.resilience_score?.score, [a2]: d2?.resilience_score?.score },
route_leak: { [a1]: d1?.route_leak?.detected, [a2]: d2?.route_leak?.detected },
},
};
return { content: [{ type: "text", text: JSON.stringify(comparison, null, 2) }] };
}
if (name === "get_health_report") {
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
const d = await callApi(`/api/validate?asn=${asnNum}`);
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
}
if (name === "search_network") {
const q = encodeURIComponent(args.query);
const d = await callApi(`/api/search?q=${q}`);
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
}
if (name === "get_resilience_score") {
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
const d = await callApi(`/api/lookup?asn=${asnNum}`);
const rs = d?.resilience_score;
const n = d?.network || {};
if (!rs) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No resilience data available for AS" + asnNum }) }] };
}
return { content: [{ type: "text", text: JSON.stringify({
asn: asnNum,
name: n.name,
resilience_score: rs,
_note: "Score 1-10. Hard cap at 5.0 if single transit provider detected.",
}, null, 2) }] };
}
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
} catch (err) {
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
}
}
// ── MCP stdio protocol ────────────────────────────────────────────────────────
const rl = readline.createInterface({ input: process.stdin });
let buffer = "";
rl.on("line", async (line) => {
buffer += line;
let msg;
try { msg = JSON.parse(buffer); buffer = ""; }
catch (e) { return; } // not complete JSON yet
const { jsonrpc, id, method, params } = msg;
let response;
if (method === "initialize") {
response = {
jsonrpc: "2.0", id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "peercortex", version: "0.6.9" },
},
};
} else if (method === "tools/list") {
response = { jsonrpc: "2.0", id, result: { tools: TOOLS } };
} else if (method === "tools/call") {
const result = await handleToolCall(params.name, params.arguments || {});
response = { jsonrpc: "2.0", id, result };
} else if (method === "notifications/initialized") {
return; // no response needed
} else {
response = {
jsonrpc: "2.0", id,
error: { code: -32601, message: `Method not found: ${method}` },
};
}
process.stdout.write(JSON.stringify(response) + "\n");
});
process.on("uncaughtException", (err) => {
process.stderr.write("PeerCortex MCP error: " + err.message + "\n");
});

View File

@ -153,7 +153,23 @@ a:hover{color:var(--purple)}
.scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:0} .scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:0}
/* ─── RPKI ─────────────────────────────────────────────── */ /* ─── RPKI ─────────────────────────────────────────────── */
.rpki-valid{color:var(--green)}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--dim)} .rpki-valid{color:var(--green)}
.prov-badge{display:inline-flex;align-items:center;gap:5px;vertical-align:middle;cursor:help}
.prov-dot{display:inline-block;width:8px;height:8px;border-radius:50%}
.prov-high{background:var(--green)}
.prov-medium{background:var(--orange)}
.prov-experimental{background:transparent;border:1.5px solid var(--dim);width:7px;height:7px}
.prov-heuristic{background:var(--orange);opacity:0.6}
.prov-label{font-size:10px;color:var(--dim);font-family:monospace;letter-spacing:.3px}
.res-bar-wrap{display:flex;align-items:center;gap:8px;margin:4px 0}
.res-bar-bg{flex:1;height:6px;background:#1e293b;border-radius:3px;overflow:hidden}
.res-bar-fill{height:100%;border-radius:3px;transition:width .4s ease}
.res-score-big{font-size:48px;font-weight:800;letter-spacing:-1px;line-height:1}
.res-score-label{font-size:11px;color:var(--dim);margin-top:2px}
.leak-detected{color:var(--orange);font-weight:700}
.leak-clean{color:var(--green)}
.leak-pattern{background:#1a1f2e;border-radius:6px;padding:8px 12px;margin:6px 0;font-size:12px}
.leak-pattern-type{font-size:10px;color:var(--dim);font-family:monospace;margin-bottom:3px}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--dim)}
/* ─── Status ───────────────────────────────────────────── */ /* ─── Status ───────────────────────────────────────────── */
.status-yes{color:var(--green);font-weight:600} .status-yes{color:var(--green);font-weight:600}
@ -837,7 +853,15 @@ body.dark .card{border-top-color:#e8e4dc}
</section> </section>
<!-- Data Sources Timing --> <!-- Data Sources Timing -->
<section class="card hidden" id="sourceTimingCard" title="Response time for each data source queried during this ASN lookup — useful for debugging and understanding data freshness"> <section class="card" id="resilienceCard" style="display:none">
<div class="card-title">Resilience Score <span id="resilienceProvBadge" style="float:right"></span></div>
<div id="resilienceContent"></div>
</section>
<section class="card" id="routeLeakCard" style="display:none">
<div class="card-title">Route Leak Detection <span id="routeLeakProvBadge" style="float:right"></span></div>
<div id="routeLeakContent"></div>
</section>
<section class="card hidden" id="sourceTimingCard" title="Response time for each data source queried during this ASN lookup — useful for debugging and understanding data freshness">
<div class="card-title"> <div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Data Sources Data Sources
@ -1133,6 +1157,8 @@ async function doLookup() {
window._lastLookupData = d; window._lastLookupData = d;
renderContacts(d); renderContacts(d);
renderSourceTiming(d); renderSourceTiming(d);
renderResilienceScore(d.resilience_score);
renderRouteLeak(d.route_leak);
// Load ASPA and bgproutes.io data asynchronously // Load ASPA and bgproutes.io data asynchronously
loadOverviewEnrichment(raw, d.network ? d.network.name : '', d.network ? d.network.website : ''); loadOverviewEnrichment(raw, d.network ? d.network.name : '', d.network ? d.network.website : '');

173
server.js
View File

@ -446,6 +446,32 @@ function cacheSet(key, data, ttlMs) {
} }
} }
// ── Tier-1 ASN Set (used for route leak heuristics) ─────────────────────────
const TIER1_ASNS = new Set([
174, // Cogent
209, // CenturyLink/Lumen
286, // KPN
701, // Verizon/UUNET
702, // Verizon
1239, // Sprint
1273, // Vodafone
1280, // Internet Systems Consortium
1299, // Arelion (Telia)
2914, // NTT
3257, // GTT
3320, // Deutsche Telekom
3356, // Lumen (Level3)
3491, // PCCW
5511, // Orange
6453, // TATA
6461, // Zayo
6762, // Telecom Italia Sparkle
7018, // AT&T
7473, // SingTel
12956, // Telxius
]);
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
@ -1629,6 +1655,134 @@ async function fetchTopology(targetAsn, depth) {
// ============================================================ // ============================================================
// Feature 27: WHOIS via RIPE DB // Feature 27: WHOIS via RIPE DB
// ============================================================ // ============================================================
// ── Resilience Score ─────────────────────────────────────────────────────────
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
// Hard cap at 5.0 when single transit provider detected.
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
const upstreamCount = upstreams.length;
const peerCount = peers.length;
const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
const prefixCount = prefixes.length;
// Transit Diversity (0-10)
let transitRaw = 0;
if (upstreamCount === 0) transitRaw = 0;
else if (upstreamCount === 1) transitRaw = 2;
else if (upstreamCount === 2) transitRaw = 5;
else if (upstreamCount <= 4) transitRaw = 7;
else transitRaw = 10;
// Peering Breadth (0-10)
let peeringRaw = 0;
if (peerCount >= 100) peeringRaw = 10;
else if (peerCount >= 50) peeringRaw = 8;
else if (peerCount >= 20) peeringRaw = 6;
else if (peerCount >= 5) peeringRaw = 4;
else if (peerCount >= 1) peeringRaw = 2;
// IXP Presence (0-10)
let ixpRaw = 0;
if (ixCount >= 10) ixpRaw = 10;
else if (ixCount >= 6) ixpRaw = 8;
else if (ixCount >= 3) ixpRaw = 6;
else if (ixCount >= 1) ixpRaw = 4;
// Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
let pathRaw = 0;
if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
else if (upstreamCount >= 2) pathRaw = 7;
else if (ixCount >= 2) pathRaw = 6;
else if (upstreamCount === 1) pathRaw = 3;
else if (prefixCount > 0) pathRaw = 1;
const weighted =
transitRaw * 0.30 +
peeringRaw * 0.25 +
ixpRaw * 0.20 +
pathRaw * 0.25;
const singleTransitCap = upstreamCount === 1;
let score = Math.round(weighted * 10) / 10;
if (singleTransitCap) score = Math.min(score, 5.0);
score = Math.max(1.0, Math.min(10.0, score));
// Only return null if truly no data at all
if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
return null;
}
return {
score,
breakdown: {
transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount },
path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount },
},
single_transit_cap_applied: singleTransitCap,
_provenance: {
source: "RIPE Stat asn-neighbours + PeeringDB netixlan",
validation: "cross-validated",
confidence: "high",
note: "All inputs independently validated daily against external sources",
},
};
}
// ── Route Leak Detection ─────────────────────────────────────────────────────
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
// Confidence: MEDIUM — pattern-based, not path-level analysis.
function computeRouteLeakDetection(upstreams, downstreams, peers) {
const upstreamAsns = new Set(upstreams.map(n => n.asn));
const downstreamAsns = new Set(downstreams.map(n => n.asn));
const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn));
const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));
const patterns = [];
// Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
sandwich.forEach(n => {
patterns.push({
type: "sandwich_candidate",
asn: n.asn,
name: n.name,
description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
});
});
// Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
tier1Downstreams.forEach(n => {
if (!upstreamAsns.has(n.asn)) {
patterns.push({
type: "tier1_downstream",
asn: n.asn,
name: n.name,
description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
});
}
});
const detected = patterns.length > 0;
return {
detected,
patterns,
tier1_upstream_count: tier1Upstreams.length,
tier1_downstream_count: tier1Downstreams.length,
_provenance: {
source: "RIPE Stat asn-neighbours",
validation: "heuristic",
confidence: "medium",
note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
},
};
}
async function fetchWhois(resource) { async function fetchWhois(resource) {
const result = { resource, type: null, data: null, error: null }; const result = { resource, type: null, data: null, error: null };
try { try {
@ -2101,7 +2255,7 @@ const server = http.createServer(async (req, res) => {
JSON.stringify({ JSON.stringify({
status, status,
service: "PeerCortex", service: "PeerCortex",
version: "0.6.8", version: "0.6.9",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()), uptime_seconds: Math.floor(process.uptime()),
memory_mb: Math.round(mem.heapUsed / 1024 / 1024), memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
@ -3551,7 +3705,7 @@ const server = http.createServer(async (req, res) => {
const result = { const result = {
meta: { meta: {
service: "PeerCortex", service: "PeerCortex",
version: "0.6.8", version: "0.6.9",
query: "AS" + asn, query: "AS" + asn,
duration_ms: duration, duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
@ -3622,6 +3776,8 @@ const server = http.createServer(async (req, res) => {
list: facilities, list: facilities,
}, },
routing: routingInfo, routing: routingInfo,
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
bgp_he_net: bgpHeData || null, bgp_he_net: bgpHeData || null,
atlas: { atlas: {
total_probes: atlasProbes.length, total_probes: atlasProbes.length,
@ -3662,6 +3818,19 @@ const server = http.createServer(async (req, res) => {
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "", rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
}; };
})(), })(),
_provenance: {
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
},
}; };
// Update duration to include cross-check time // Update duration to include cross-check time