Backend refactor reconciliation + ASPA verification fix + silent-failure audit #2
@ -72,6 +72,9 @@ async function getAnnouncedPrefixes(asn) {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns null on DB error (check could not run) vs [] for a genuine single/no-origin
|
||||
// result -- a caller treating both as "no hijack" would silently disable hijack
|
||||
// detection on every transient DB hiccup while still showing the check as clean.
|
||||
async function checkBgpHijack(prefix) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
@ -81,7 +84,7 @@ async function checkBgpHijack(prefix) {
|
||||
return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Hijack Check Error:', error.message);
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -249,7 +252,7 @@ async function getRipeStatAnnouncedPrefixes(asn) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message);
|
||||
return { status: 'ok', data: { resource: `AS${asn}`, prefixes: [] } };
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, prefixes: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,7 +278,7 @@ async function getRipeStatAsnNeighbours(asn) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message);
|
||||
return { status: 'ok', data: { resource: `AS${asn}`, neighbours: [] } };
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, neighbours: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -302,7 +305,7 @@ async function getRipeStatAsOverview(asn) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] AS Overview Error:', error.message);
|
||||
return { status: 'ok', data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,7 +339,7 @@ async function getRipeStatVisibility(asn) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Visibility Error:', error.message);
|
||||
return { status: 'ok', data: { resource: `AS${asn}`, visibility: {} } };
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, visibility: {} } };
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,7 +372,7 @@ async function getRipeStatPrefixSizeDistribution(asn) {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message);
|
||||
return { status: 'ok', data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
394
server.js
394
server.js
@ -369,12 +369,18 @@ async function notifyWebhooks(asn, alert) {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns null (not []) when the prefix lookup itself failed -- distinct from a
|
||||
// genuine zero-prefix result. runHijackCheck must not treat these the same way:
|
||||
// setting an empty baseline from a transient failure would permanently disable
|
||||
// hijack detection for that subscription (baseline.size stays 0 forever after,
|
||||
// so "unexpected" never fires again -- see the baseline.size > 0 guard below).
|
||||
async function checkHijacksForAsn(asn) {
|
||||
try {
|
||||
const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
|
||||
if (data && data.status === 'error') return null;
|
||||
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
|
||||
return prefixes;
|
||||
} catch (_) { return []; }
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function runHijackCheck() {
|
||||
@ -384,6 +390,12 @@ async function runHijackCheck() {
|
||||
let changed = false;
|
||||
for (const sub of subs) {
|
||||
const current = await checkHijacksForAsn(sub.asn);
|
||||
if (current === null) {
|
||||
// Lookup failed this cycle -- skip both anomaly detection and baseline
|
||||
// initialization; retry on the next 30-minute run rather than locking in
|
||||
// an empty baseline that would silently disable detection forever.
|
||||
continue;
|
||||
}
|
||||
const baseline = new Set(sub.prefixes || []);
|
||||
const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
|
||||
const missing = [...baseline].filter(p => !current.includes(p));
|
||||
@ -917,7 +929,7 @@ function buildPdfHtml(data, aspa, format) {
|
||||
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
|
||||
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
|
||||
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
|
||||
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not-found' || v.status === 'unknown').length;
|
||||
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
|
||||
|
||||
const passedChecks = checks.filter(c => c.status === 'pass').length;
|
||||
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||
@ -1552,6 +1564,7 @@ const TIER1_ASNS = new Set([
|
||||
]);
|
||||
|
||||
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
|
||||
const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts
|
||||
const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours
|
||||
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
|
||||
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
|
||||
@ -1577,16 +1590,22 @@ function rdapCacheSet(asn, data) {
|
||||
// ============================================================
|
||||
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
|
||||
// ============================================================
|
||||
const whoisCache = new Map(); // key: asn string, value: { data, ts }
|
||||
const whoisCache = new Map(); // key: asn string, value: { data, ts, ttl }
|
||||
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
// A "not found" result after all 5 RIR sources failed is far more often a shared
|
||||
// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real
|
||||
// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't
|
||||
// tell timeout/network-error apart from a genuine 404, so cache that specific
|
||||
// conclusion much more briefly than a confirmed, parsed WHOIS record.
|
||||
const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000;
|
||||
function whoisCacheGet(asn) {
|
||||
const e = whoisCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data;
|
||||
if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function whoisCacheSet(asn, data) {
|
||||
function whoisCacheSet(asn, data, ttl) {
|
||||
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
|
||||
whoisCache.set(String(asn), { data, ts: Date.now() });
|
||||
whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -2050,7 +2069,18 @@ class Semaphore {
|
||||
const ripeStatSemaphore = new Semaphore(15);
|
||||
|
||||
// Cached + throttled RIPE Stat fetch
|
||||
async function fetchRipeStatCached(url, options) {
|
||||
// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact
|
||||
// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres
|
||||
// refactor. In JS, two `function` declarations sharing a name silently collapse to
|
||||
// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle
|
||||
// implementation below was completely shadowed, and every call to
|
||||
// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours,
|
||||
// as-overview, visibility, prefix-size-distribution} was unconditionally returning
|
||||
// null instead of ever reaching this code. That silently broke blocklist,
|
||||
// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois,
|
||||
// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's
|
||||
// fallback call to this function for the fix.
|
||||
async function fetchRipeStatCachedFromApi(url, options) {
|
||||
// Extract endpoint name from URL for TTL lookup
|
||||
const match = url.match(/\/data\/([^/]+)\//);
|
||||
const endpoint = match ? match[1] : "default";
|
||||
@ -2190,14 +2220,19 @@ async function ensureAspaCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup ASPA object for a given ASN from the RPKI feed cache
|
||||
// Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded
|
||||
// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object"
|
||||
// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" --
|
||||
// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so
|
||||
// 0 means every attempt so far has failed and exists:false here is not trustworthy.
|
||||
function lookupAspaFromRpki(asn) {
|
||||
const asnNum = Number(asn);
|
||||
const feedLoaded = rpkiAspaLastFetch > 0;
|
||||
if (rpkiAspaMap.has(asnNum)) {
|
||||
const providers = rpkiAspaMap.get(asnNum);
|
||||
return { exists: true, providers: [...providers].sort((a, b) => a - b) };
|
||||
return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded };
|
||||
}
|
||||
return { exists: false, providers: [] };
|
||||
return { exists: false, providers: [], feedLoaded };
|
||||
}
|
||||
|
||||
|
||||
@ -2430,9 +2465,13 @@ async function fetchRipeStatCached(url, options = {}) {
|
||||
if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
|
||||
}
|
||||
|
||||
// For other RIPE Stat endpoints (not in localDb): return empty/null gracefully
|
||||
// Examples: rir-stats-country, bgp-updates, reverse-dns-consistency, routing-status, maxmind-geo-lite, etc.
|
||||
return Promise.resolve(null);
|
||||
// For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
|
||||
// e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
|
||||
// whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the
|
||||
// real RIPE Stat API with its own cache/throttle/never-cache-null protections,
|
||||
// instead of returning null unconditionally (see fetchRipeStatCachedFromApi's
|
||||
// header comment for how this silently broke ~9 checks until 2026-07-16).
|
||||
return fetchRipeStatCachedFromApi(url, options);
|
||||
} catch (e) {
|
||||
console.error("[fetchRipeStatCached] Error:", e.message);
|
||||
return Promise.resolve(null);
|
||||
@ -2442,24 +2481,27 @@ async function fetchRipeStatCached(url, options = {}) {
|
||||
// RPKI per-prefix validation — uses local ROA store (instant, no API calls)
|
||||
// Falls back to RIPE Stat only if ROA store is not yet loaded (cold start)
|
||||
// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
|
||||
// Returns: { prefix, status: "valid"|"invalid"|"not_found", validating_roas: N }
|
||||
// Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N }
|
||||
// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result.
|
||||
// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know.
|
||||
// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse-
|
||||
// sounding finding). Callers computing coverage percentages should exclude "unavailable".
|
||||
async function validateRPKIWithCache(asn, prefix) {
|
||||
try {
|
||||
// Query local database (sub-10ms, no external API calls)
|
||||
const result = await localDb.validateRpki(prefix, asn);
|
||||
|
||||
// Adapt response to match expected format
|
||||
if (result.status === 'valid') {
|
||||
return { prefix, status: "valid", validating_roas: 1 };
|
||||
} else if (result.status === 'invalid') {
|
||||
return { prefix, status: "invalid", validating_roas: 1 };
|
||||
} else if (result.status === 'unknown') {
|
||||
return { prefix, status: "unavailable", validating_roas: 0 };
|
||||
} else {
|
||||
// 'not-found' or 'unknown'
|
||||
// 'not-found': genuinely no covering ROA
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
}
|
||||
} catch (_e) {
|
||||
console.error("[RPKI] Error validating " + prefix + ":", _e.message);
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
return { prefix, status: "unavailable", validating_roas: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -2478,6 +2520,9 @@ function hasAsSet(asPath) {
|
||||
|
||||
// Hop Check function (core of ASPA verification)
|
||||
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
|
||||
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
||||
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
||||
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
||||
function hopCheck(asI, asJ, aspaStore) {
|
||||
const providers = aspaStore.get(asI);
|
||||
if (!providers) return "NoAttestation";
|
||||
@ -2499,7 +2544,9 @@ function verifyUpstream(asPath, aspaStore, rawPathStr) {
|
||||
let hasNoAttestation = false;
|
||||
|
||||
for (let i = 1; i < collapsed.length; i++) {
|
||||
const check = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
|
||||
// collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
|
||||
// the validator) is the provider it's declaring -- customer goes first in hopCheck.
|
||||
const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
||||
hops.push({
|
||||
from: collapsed[i - 1],
|
||||
to: collapsed[i],
|
||||
@ -2518,7 +2565,16 @@ function verifyUpstream(asPath, aspaStore, rawPathStr) {
|
||||
};
|
||||
}
|
||||
|
||||
// Downstream Verification (RFC Section 6.2)
|
||||
// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
|
||||
// Unlike upstream verification, a downstream path may legitimately contain both
|
||||
// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
|
||||
// (provider->customer hops, near the validator), transitioning at most once --
|
||||
// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
|
||||
// validator. Section 5.3 defines four ramp lengths computed from two independent
|
||||
// scans, verified 2026-07-16 against a worked example fetched from the draft
|
||||
// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
|
||||
// to Valid, and a path with definite failures on both ends correctly resolves
|
||||
// to Invalid, matching this implementation).
|
||||
function verifyDownstream(asPath, aspaStore, rawPathStr) {
|
||||
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
||||
const collapsed = collapsePrepends(asPath);
|
||||
@ -2530,58 +2586,57 @@ function verifyDownstream(asPath, aspaStore, rawPathStr) {
|
||||
hops.push({
|
||||
from: collapsed[i - 1],
|
||||
to: collapsed[i],
|
||||
result: hopCheck(collapsed[i - 1], collapsed[i], aspaStore),
|
||||
result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
|
||||
});
|
||||
}
|
||||
|
||||
// Find u_min: first index where forward hop is NotProviderPlus
|
||||
let uMin = N + 1;
|
||||
for (let u = 1; u < N; u++) {
|
||||
if (hopCheck(collapsed[u - 1], collapsed[u], aspaStore) === "NotProviderPlus") {
|
||||
uMin = u;
|
||||
break;
|
||||
}
|
||||
// Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
|
||||
// (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
|
||||
// maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
|
||||
// minUpRamp stops at the first hop that isn't confirmed ProviderPlus
|
||||
// (including unattested "NoAttestation" hops, which don't disprove a leak
|
||||
// but also don't prove the path is clean).
|
||||
let maxUpRamp = N;
|
||||
let minUpRamp = N;
|
||||
for (let k = N - 1; k >= 1; k--) {
|
||||
const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
|
||||
const I = N - k;
|
||||
if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
|
||||
if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
|
||||
if (maxUpRamp !== N && minUpRamp !== N) break;
|
||||
}
|
||||
|
||||
// Find v_max: last index where reverse hop is NotProviderPlus
|
||||
let vMax = 0;
|
||||
for (let v = N - 2; v >= 0; v--) {
|
||||
if (hopCheck(collapsed[v + 1], collapsed[v], aspaStore) === "NotProviderPlus") {
|
||||
vMax = v;
|
||||
break;
|
||||
}
|
||||
// Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
|
||||
// toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
|
||||
let maxDownRamp = N;
|
||||
let minDownRamp = N;
|
||||
for (let k = 0; k <= N - 2; k++) {
|
||||
const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
|
||||
const rampLen = k + 1; // = N - J + 1 for the corresponding J
|
||||
if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
|
||||
if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
|
||||
if (maxDownRamp !== N && minDownRamp !== N) break;
|
||||
}
|
||||
|
||||
if (uMin <= vMax) {
|
||||
return { result: "Invalid", reason: "uMin(" + uMin + ") <= vMax(" + vMax + "): valley detected", hops };
|
||||
if (maxUpRamp + maxDownRamp < N) {
|
||||
return {
|
||||
result: "Invalid",
|
||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
// Compute up-ramp K
|
||||
let K = 0;
|
||||
for (let i = 1; i < N; i++) {
|
||||
if (hopCheck(collapsed[i - 1], collapsed[i], aspaStore) === "ProviderPlus") {
|
||||
K = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (minUpRamp + minDownRamp < N) {
|
||||
return {
|
||||
result: "Unknown",
|
||||
reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
// Compute down-ramp L
|
||||
let L = N - 1;
|
||||
for (let j = N - 2; j >= 0; j--) {
|
||||
if (hopCheck(collapsed[j + 1], collapsed[j], aspaStore) === "ProviderPlus") {
|
||||
L = j;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const gap = L - K;
|
||||
if (gap <= 1) {
|
||||
return { result: "Valid", reason: "Valid up-down path (K=" + K + ", L=" + L + ")", hops };
|
||||
}
|
||||
|
||||
return { result: "Unknown", reason: "Gap between up-ramp and down-ramp (K=" + K + ", L=" + L + ", gap=" + gap + ")", hops };
|
||||
return {
|
||||
result: "Valid",
|
||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
// Valley Detection: scan path for up-down-up pattern (route leak indicator)
|
||||
@ -2688,10 +2743,13 @@ async function fetchRipeRpkiValidator(asn, prefix) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel)
|
||||
// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel).
|
||||
// agreement_pct only counts pairs where BOTH sources actually returned a real
|
||||
// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct:
|
||||
// null (not a fabricated 100), since nothing was actually cross-checked.
|
||||
async function crossCheckRpki(asn, prefixes, localResults) {
|
||||
const sample = prefixes.slice(0, 5);
|
||||
if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
|
||||
if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
|
||||
|
||||
const ripeResults = await Promise.all(
|
||||
sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
|
||||
@ -2705,6 +2763,7 @@ async function crossCheckRpki(asn, prefixes, localResults) {
|
||||
let cloudflareValid = 0;
|
||||
let ripeValid = 0;
|
||||
let agreements = 0;
|
||||
let comparable = 0;
|
||||
const disagreements = [];
|
||||
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
@ -2718,12 +2777,13 @@ async function crossCheckRpki(asn, prefixes, localResults) {
|
||||
if (cfIsValid) cloudflareValid++;
|
||||
if (ripeIsValid) ripeValid++;
|
||||
|
||||
// Skip comparison if RIPE returned error/unknown
|
||||
// A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from
|
||||
// the agreement percentage instead of counting it as a silent "agreement".
|
||||
if (ripeState === "error" || ripeState === "unknown") {
|
||||
agreements++; // Don't count failed lookups as disagreements
|
||||
continue;
|
||||
}
|
||||
|
||||
comparable++;
|
||||
if (cfIsValid === ripeIsValid) {
|
||||
agreements++;
|
||||
} else {
|
||||
@ -2735,8 +2795,8 @@ async function crossCheckRpki(asn, prefixes, localResults) {
|
||||
}
|
||||
}
|
||||
|
||||
const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100;
|
||||
return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length };
|
||||
const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null;
|
||||
return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -3049,7 +3109,9 @@ async function fetchWhois(resource) {
|
||||
whoisCacheSet(asn, result.data);
|
||||
} else {
|
||||
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
|
||||
whoisCacheSet(asn, null); // cache miss to avoid repeated hammering
|
||||
// Short TTL: this "not found" likely reflects a transient failure across
|
||||
// all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
|
||||
whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
|
||||
}
|
||||
}
|
||||
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
|
||||
@ -3651,15 +3713,17 @@ const server = http.createServer(async (req, res) => {
|
||||
const aspaObjectExists = aspaLookup.exists;
|
||||
const aspaDeclaredProviders = aspaLookup.providers;
|
||||
|
||||
// Build ASPA store from RPKI feed data (real ASPA objects)
|
||||
// Build ASPA store from RPKI feed data (real ASPA objects only). If the
|
||||
// target has no real ASPA object, its hops must show "NoAttestation" --
|
||||
// NOT a fabricated declaration built from heuristically-detected BGP
|
||||
// neighbours, which would contradict the aspa_object_exists:false this
|
||||
// same response reports and falsely present unattested hops as
|
||||
// "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
|
||||
// any ASN with no entry in aspaStore, so simply not setting one here is
|
||||
// correct and sufficient -- no fallback needed.
|
||||
const aspaStore = new Map();
|
||||
// Add the target ASN's RPKI-declared providers
|
||||
if (aspaObjectExists) {
|
||||
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
|
||||
} else {
|
||||
// Fallback: use detected providers for path verification
|
||||
const providerSet = new Set(detectedProviders.map((p) => p.asn));
|
||||
aspaStore.set(targetAsn, providerSet);
|
||||
}
|
||||
// Also populate store with all known ASPA objects from the RPKI feed
|
||||
// for providers that have their own ASPA objects (enables full path verification)
|
||||
@ -3668,14 +3732,13 @@ const server = http.createServer(async (req, res) => {
|
||||
aspaStore.set(cas, provSet);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add reverse relationships for providers we know about
|
||||
// (each provider has the target as customer)
|
||||
detectedProviders.forEach((p) => {
|
||||
if (!aspaStore.has(p.asn)) {
|
||||
aspaStore.set(p.asn, new Set());
|
||||
}
|
||||
});
|
||||
// Deliberately NOT adding empty Set() entries for detected-but-unattested
|
||||
// providers here: aspaStore.get() returning undefined for them makes
|
||||
// hopCheck() correctly report "NoAttestation". An empty (but present) Set
|
||||
// would make providers.has(x) return false, misreporting "NotProviderPlus"
|
||||
// (implying a real ASPA object that denies this hop) instead of "we simply
|
||||
// don't know" -- see the 2026-07-16 audit for how this previously caused
|
||||
// clean, unattested paths to be misclassified as route leaks.
|
||||
|
||||
// Sample paths for verification (up to 50)
|
||||
const samplePaths = allPaths.slice(0, 50);
|
||||
@ -3754,8 +3817,11 @@ const server = http.createServer(async (req, res) => {
|
||||
await ensureAspaCache();
|
||||
const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
|
||||
const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
|
||||
const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length;
|
||||
const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.length) * 100) : 0;
|
||||
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
||||
// must not lower a network's displayed ASPA readiness score.
|
||||
const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
|
||||
const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
|
||||
const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
|
||||
|
||||
// Calculate readiness score
|
||||
const readinessScore = calculateAspaReadinessScore({
|
||||
@ -3777,6 +3843,7 @@ const server = http.createServer(async (req, res) => {
|
||||
asn: targetAsn,
|
||||
readiness_score: readinessScore,
|
||||
aspa_object_exists: aspaObjectExists,
|
||||
aspa_feed_healthy: aspaLookup.feedLoaded,
|
||||
detected_providers: detectedProviders.map((p) => ({
|
||||
...p,
|
||||
frequency: providerFrequency.get(p.asn) || 0,
|
||||
@ -3919,6 +3986,7 @@ const server = http.createServer(async (req, res) => {
|
||||
detected_providers: detectedProviders,
|
||||
provider_count: detectedProviders.length,
|
||||
aspa_object_exists: aspaObjectExists,
|
||||
aspa_feed_healthy: aspaLookup.feedLoaded,
|
||||
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||
aspa_declared_count: aspaDeclaredProviders.length,
|
||||
recommended_aspa: recommendedAspa,
|
||||
@ -3977,9 +4045,12 @@ const server = http.createServer(async (req, res) => {
|
||||
last_seen: bgpStatus.last_seen,
|
||||
source: "local_bgp",
|
||||
};
|
||||
// Check for hijack (multiple origin ASNs)
|
||||
// Check for hijack (multiple origin ASNs). null means the check itself
|
||||
// failed (DB error) -- distinct from a genuine single/no-origin result.
|
||||
const hijackAsns = await localDb.checkBgpHijack(prefix);
|
||||
if (hijackAsns.length > 1) {
|
||||
if (hijackAsns === null) {
|
||||
result.bgp_status.hijack_check_unavailable = true;
|
||||
} else if (hijackAsns.length > 1) {
|
||||
result.bgp_status.hijack_warning = {
|
||||
detected: true,
|
||||
origin_asns: hijackAsns,
|
||||
@ -4115,18 +4186,29 @@ const server = http.createServer(async (req, res) => {
|
||||
var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
|
||||
var asnInPaths = neighbours.map(function(n) { return n.asn; });
|
||||
var bogonAsns = asnInPaths.filter(checkBogonAsn);
|
||||
// "pass" must mean prefixes/neighbours were actually fetched and checked clean --
|
||||
// not that the upstream RIPE Stat calls failed and left nothing to check.
|
||||
var bogonDataUnavailable = !prefixData && !neighbourData;
|
||||
var bogonResult = {
|
||||
status: bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail",
|
||||
status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
|
||||
bogon_prefixes: bogonPrefixes,
|
||||
bogon_asns_in_paths: bogonAsns,
|
||||
total_prefixes_checked: allPrefixes.length,
|
||||
prefixes_source_unavailable: !prefixData,
|
||||
neighbours_source_unavailable: !neighbourData,
|
||||
};
|
||||
|
||||
// Phase 2: All API-dependent validations in parallel
|
||||
var validationPromises = {};
|
||||
|
||||
// 12. IRR Validation
|
||||
// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
|
||||
// rejecting, so the .catch() below never sees the common failure mode -- a
|
||||
// dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
|
||||
validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
|
||||
if (irrData === null) {
|
||||
return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
|
||||
}
|
||||
var entries = Array.isArray(irrData) ? irrData : [];
|
||||
var mismatches = entries.filter(function(e) {
|
||||
if (!e.bgpOrigins && !e.bgp_origins) return false;
|
||||
@ -4152,16 +4234,22 @@ const server = http.createServer(async (req, res) => {
|
||||
validationPromises.rpki_completeness = Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(rpkiResults) {
|
||||
var withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; });
|
||||
var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0;
|
||||
var overSpecific = rpkiResults.filter(function(r) {
|
||||
var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var unavailableCount = rpkiResults.length - checkedResults.length;
|
||||
var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
|
||||
var overSpecific = checkedResults.filter(function(r) {
|
||||
var mask = parseInt((r.prefix || "").split("/")[1] || "0");
|
||||
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
|
||||
});
|
||||
// If the local RPKI DB was unreachable for every prefix, we haven't actually
|
||||
// checked anything -- say so instead of reporting a misleading pass/fail.
|
||||
var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
|
||||
status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
|
||||
coverage_pct: coverage,
|
||||
total_checked: rpkiResults.length,
|
||||
total_checked: checkedResults.length,
|
||||
db_unavailable_count: unavailableCount,
|
||||
with_roa: withRoa.length,
|
||||
over_specific: overSpecific.map(function(r) { return r.prefix; }),
|
||||
details: rpkiResults,
|
||||
@ -4170,6 +4258,7 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// 14. Abuse Contact Validation
|
||||
validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
|
||||
if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
|
||||
var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
|
||||
var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
|
||||
return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
|
||||
@ -4179,14 +4268,25 @@ const server = http.createServer(async (req, res) => {
|
||||
validationPromises.blocklist = Promise.all(
|
||||
samplePrefixes.slice(0, 5).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
|
||||
// fetchRipeStatCached resolves(null) on timeout/network-error rather than
|
||||
// throwing, so the .catch() below never sees that case -- distinguish it
|
||||
// explicitly instead of letting a failed lookup read as "not listed".
|
||||
if (data === null) return { prefix: pfx, listed: false, checked: false };
|
||||
var sources = data && data.data && data.data.sources ? data.data.sources : [];
|
||||
var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
|
||||
return { prefix: pfx, listed: listed.length > 0, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
|
||||
}).catch(function() { return { prefix: pfx, listed: false, error: true }; });
|
||||
return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
|
||||
}).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var listedPrefixes = results.filter(function(r) { return r.listed; });
|
||||
return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes };
|
||||
var checkedResults = results.filter(function(r) { return r.checked; });
|
||||
var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
|
||||
var allUnchecked = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
listed_prefixes: listedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 16. MANRS Compliance — scraped from public participants list (24h cache)
|
||||
@ -4199,6 +4299,7 @@ const server = http.createServer(async (req, res) => {
|
||||
validationPromises.rdns = Promise.all(
|
||||
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
|
||||
if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
|
||||
var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
|
||||
var hasDelegation = false;
|
||||
var details = [];
|
||||
@ -4226,11 +4327,19 @@ const server = http.createServer(async (req, res) => {
|
||||
}).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var withRdns = results.filter(function(r) { return r.has_rdns; });
|
||||
var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0;
|
||||
// Include details of what failed
|
||||
var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; });
|
||||
return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.length, results: results, failed_prefixes: failedPrefixes };
|
||||
var checkedResults = results.filter(function(r) { return !r.error; });
|
||||
var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
|
||||
var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
|
||||
var allUnavailable = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
|
||||
coverage_pct: coverage,
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
results: results,
|
||||
failed_prefixes: failedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
|
||||
@ -4252,7 +4361,9 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
|
||||
}).catch(function() {
|
||||
return { status: "fail", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
|
||||
// Neither RIPE routing-status nor the bgproutes.io fallback returned data --
|
||||
// this network hasn't actually been measured, not confirmed at 0% visibility.
|
||||
return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
|
||||
});
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
|
||||
@ -4371,8 +4482,10 @@ const server = http.createServer(async (req, res) => {
|
||||
validationPromises.resource_cert = Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(results) {
|
||||
var hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
|
||||
return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.filter(function(r) { return r.status === "valid"; }).length };
|
||||
var checked = results.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
|
||||
var allUnavailable = results.length > 0 && checked.length === 0;
|
||||
return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// Geolocation cross-ref with PeeringDB facilities
|
||||
@ -4604,6 +4717,16 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
|
||||
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
|
||||
|
||||
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
|
||||
// the local Postgres DB query itself failed -- track which sources actually
|
||||
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
|
||||
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
|
||||
const degradedSources = [
|
||||
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
|
||||
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
|
||||
["RIPE Stat PrefixSize", prefixSizeData],
|
||||
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
|
||||
|
||||
const prefixes = prefixData?.data?.prefixes || [];
|
||||
const neighbours = neighbourData?.data?.neighbours || [];
|
||||
const overview = overviewData?.data || {};
|
||||
@ -4708,8 +4831,11 @@ const server = http.createServer(async (req, res) => {
|
||||
const rpkiStatuses = rpkiAllResults;
|
||||
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
|
||||
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
|
||||
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length;
|
||||
const rpkiTotal = rpkiStatuses.length;
|
||||
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
|
||||
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
|
||||
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
||||
// must not lower a network's displayed RPKI coverage percentage.
|
||||
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
|
||||
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
|
||||
|
||||
let upstreams = neighbours
|
||||
@ -4908,7 +5034,7 @@ const server = http.createServer(async (req, res) => {
|
||||
// ============================================================
|
||||
// Multi-source cross-checks (run in parallel, non-blocking)
|
||||
// ============================================================
|
||||
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
|
||||
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
|
||||
let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
|
||||
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
|
||||
|
||||
@ -4922,6 +5048,13 @@ const server = http.createServer(async (req, res) => {
|
||||
if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
|
||||
} catch (_e) { /* cross-check failed, keep defaults */ }
|
||||
|
||||
// bgp.he.net fetching is deliberately disabled (see the hardcoded
|
||||
// Promise.resolve(null) a few hundred lines up, "bgp.he.net" timedFetch) --
|
||||
// bgpHeData is therefore always null and this whole branch, along with the
|
||||
// prefix/neighbour cross-checks below, never actually runs. Left in place
|
||||
// (not deleted) in case bgp.he.net fetching is re-enabled later, but
|
||||
// overall_confidence below must not claim a 2-source agreement that never
|
||||
// happened -- see the sources:1/2 handling further down.
|
||||
// Prefix count cross-check: compare RIPE Stat vs bgp.he.net
|
||||
if (bgpHeData) {
|
||||
const heV4 = bgpHeData.prefixes_v4 || 0;
|
||||
@ -4950,10 +5083,12 @@ const server = http.createServer(async (req, res) => {
|
||||
prefixCrossCheck.note = "bgp.he.net data unavailable";
|
||||
}
|
||||
|
||||
// Compute overall data quality
|
||||
// Compute overall data quality. Only push a score for a cross-check that
|
||||
// actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
|
||||
// not silently count as a perfect 100 in the average, and confidence must
|
||||
// reflect "nothing was actually cross-checked" rather than defaulting high.
|
||||
const crossCheckScores = [];
|
||||
// RPKI agreement
|
||||
crossCheckScores.push(rpkiCrossCheck.agreement_pct);
|
||||
if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
|
||||
// Prefix agreement: convert to percentage
|
||||
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
|
||||
const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
|
||||
@ -4966,15 +5101,15 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
const avgAgreement = crossCheckScores.length > 0
|
||||
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
|
||||
: 100;
|
||||
const overallConfidence = avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
|
||||
: null;
|
||||
const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
|
||||
|
||||
const dataQuality = {
|
||||
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
|
||||
cross_checks: {
|
||||
rpki: { sources: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements },
|
||||
prefixes: { sources: 2, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
|
||||
neighbours: { sources: 2, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
|
||||
rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
|
||||
prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
|
||||
neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
|
||||
},
|
||||
overall_confidence: overallConfidence,
|
||||
overall_agreement_pct: avgAgreement,
|
||||
@ -5026,6 +5161,7 @@ const server = http.createServer(async (req, res) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
rpki_prefixes_checked: rpkiTotal,
|
||||
total_prefixes: prefixes.length,
|
||||
degraded_sources: degradedSources,
|
||||
},
|
||||
network: {
|
||||
asn: parseInt(asn),
|
||||
@ -5065,6 +5201,7 @@ const server = http.createServer(async (req, res) => {
|
||||
valid: rpkiValid,
|
||||
invalid: rpkiInvalid,
|
||||
not_found: rpkiNotFound,
|
||||
unavailable: rpkiUnavailable,
|
||||
checked: rpkiTotal,
|
||||
details: rpkiStatuses,
|
||||
cross_check: rpkiCrossCheck,
|
||||
@ -5150,7 +5287,9 @@ const server = http.createServer(async (req, res) => {
|
||||
// Update duration to include cross-check time
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
|
||||
cacheSet(cacheKey, result, CACHE_TTL_LOOKUP);
|
||||
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
|
||||
// retry soon instead of repeating a degraded result to every visitor.
|
||||
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
|
||||
res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
const duration = Date.now() - start;
|
||||
@ -5254,7 +5393,10 @@ const server = http.createServer(async (req, res) => {
|
||||
source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
|
||||
};
|
||||
|
||||
cacheSet(cacheKey, result, 10 * 60 * 1000); // 10 min cache
|
||||
// A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
|
||||
// otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
|
||||
// match the existing /api/validate precedent (90s TTL for suspicious zero counts).
|
||||
cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
@ -6045,7 +6187,9 @@ ${html}
|
||||
const hijackSubs = loadHijackSubs();
|
||||
if (!hijackSubs.find(s => s.asn === asn)) {
|
||||
checkHijacksForAsn(asn).then(prefixes => {
|
||||
hijackSubs.push({ asn, prefixes, subscribed: new Date().toISOString() });
|
||||
// null (lookup failed) becomes [] here so this record's shape stays
|
||||
// consistent; runHijackCheck retries the real baseline next cycle.
|
||||
hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(hijackSubs);
|
||||
});
|
||||
}
|
||||
@ -6160,12 +6304,16 @@ ${html}
|
||||
const exists = subs.find(s => s.asn === asnNum);
|
||||
if (!exists) {
|
||||
const prefixes = await checkHijacksForAsn(asnNum);
|
||||
subs.push({ asn: asnNum, prefixes, subscribed: new Date().toISOString() });
|
||||
// prefixes may be null if the lookup failed just now -- store an empty
|
||||
// array so this record shape stays consistent; runHijackCheck's own
|
||||
// "!sub.prefixes.length" check will retry establishing the real
|
||||
// baseline on its next cycle rather than treating this as permanent.
|
||||
subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(subs);
|
||||
}
|
||||
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: sub.prefixes.length }));
|
||||
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
|
||||
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
|
||||
});
|
||||
return;
|
||||
|
||||
@ -242,15 +242,22 @@ export function validateUpstream(
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an AS path in the downstream direction per draft-ietf-sidrops-aspa-verification.
|
||||
* Validate an AS path in the downstream direction per
|
||||
* draft-ietf-sidrops-aspa-verification Section 5.5.
|
||||
*
|
||||
* Reverses the path and applies the upstream validation procedure.
|
||||
* Downstream validation is used when the validating AS is receiving
|
||||
* a route from a customer rather than a provider.
|
||||
* Unlike upstream validation, a downstream path may legitimately contain
|
||||
* BOTH an up-ramp (customer->provider hops near the origin) AND a down-ramp
|
||||
* (provider->customer hops near the validator), transitioning at most once
|
||||
* -- e.g. origin -> its providers -> a peering exchange -> validator's
|
||||
* providers -> validator. A naive "reverse the path and reapply upstream
|
||||
* validation" does not implement this: it would reject any path with a
|
||||
* legitimate down-ramp segment. This computes the four ramp lengths the
|
||||
* draft actually defines (max/min up-ramp, max/min down-ramp) from two
|
||||
* independent scans and applies the draft's three halt conditions in order.
|
||||
*
|
||||
* Per draft-ietf-sidrops-aspa-verification, the downstream verification is the mirror image of upstream:
|
||||
* - Reverse the path so the "origin" from the downstream perspective is leftmost.
|
||||
* - Apply the same provider-authorization checks.
|
||||
* Verified 2026-07-16 against a worked example fetched from the draft (an
|
||||
* N=4 path with a single unattested "kink" resolves to valid; a path with
|
||||
* definite authorization failures on both ends resolves to invalid).
|
||||
*
|
||||
* @param path - AS path to validate (leftmost = closest to validator)
|
||||
* @param aspaObjects - Map of customer ASN to ASPA object
|
||||
@ -260,23 +267,103 @@ export function validateDownstream(
|
||||
path: ReadonlyArray<number>,
|
||||
aspaObjects: ReadonlyMap<number, ASPAObject>
|
||||
): ASPAValidationResult {
|
||||
// Downstream: reverse the path and apply upstream logic.
|
||||
const reversedPath = [...path].reverse();
|
||||
const result = validateUpstream(reversedPath, aspaObjects);
|
||||
const dedupedPath = deduplicatePath(path);
|
||||
const N = dedupedPath.length;
|
||||
|
||||
// Map violations back to original path positions
|
||||
const originalLength = deduplicatePath(path).length;
|
||||
const remappedViolations: ReadonlyArray<ASPAViolation> = result.violations.map(
|
||||
(v) => ({
|
||||
...v,
|
||||
position: originalLength - 1 - v.position,
|
||||
})
|
||||
);
|
||||
if (N <= 2) {
|
||||
return {
|
||||
status: "valid",
|
||||
path: [...dedupedPath],
|
||||
violations: [],
|
||||
leakDetected: false,
|
||||
confidence: 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
// Up-ramp scan: walk from the origin (index N-1) toward the validator
|
||||
// (index 0). dedupedPath[k] is the customer, dedupedPath[k-1] the
|
||||
// provider it's declaring -- authorized(A(I), A(I+1)) in draft terms.
|
||||
let maxUpRamp = N;
|
||||
let minUpRamp = N;
|
||||
let firstUpViolation: { position: number; asn: number; nextHop: number } | undefined;
|
||||
for (let k = N - 1; k >= 1; k--) {
|
||||
const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects);
|
||||
const I = N - k;
|
||||
if (check === "not-provider" && maxUpRamp === N) {
|
||||
maxUpRamp = I;
|
||||
firstUpViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k - 1] };
|
||||
}
|
||||
if (check !== "provider" && minUpRamp === N) minUpRamp = I;
|
||||
if (maxUpRamp !== N && minUpRamp !== N) break;
|
||||
}
|
||||
|
||||
// Down-ramp scan: the mirror image, walking from the validator (index 0)
|
||||
// toward the origin -- authorized(A(J), A(J-1)) in draft terms.
|
||||
let maxDownRamp = N;
|
||||
let minDownRamp = N;
|
||||
let firstDownViolation: { position: number; asn: number; nextHop: number } | undefined;
|
||||
for (let k = 0; k <= N - 2; k++) {
|
||||
const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k + 1], aspaObjects);
|
||||
const rampLen = k + 1;
|
||||
if (check === "not-provider" && maxDownRamp === N) {
|
||||
maxDownRamp = rampLen;
|
||||
firstDownViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k + 1] };
|
||||
}
|
||||
if (check !== "provider" && minDownRamp === N) minDownRamp = rampLen;
|
||||
if (maxDownRamp !== N && minDownRamp !== N) break;
|
||||
}
|
||||
|
||||
// Confidence: fraction of hops that returned a definitive (non-"no-attestation") result.
|
||||
const totalHops = N - 1;
|
||||
let attestedHops = 0;
|
||||
for (let k = N - 1; k >= 1; k--) {
|
||||
if (checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects) !== "no-attestation") {
|
||||
attestedHops++;
|
||||
}
|
||||
}
|
||||
const confidence = totalHops > 0 ? attestedHops / totalHops : 1.0;
|
||||
|
||||
if (maxUpRamp + maxDownRamp < N) {
|
||||
const violation = firstUpViolation ?? firstDownViolation;
|
||||
const violations: ASPAViolation[] = violation
|
||||
? [
|
||||
{
|
||||
position: violation.position,
|
||||
asn: violation.asn,
|
||||
expectedProviders: (aspaObjects.get(violation.asn)?.providers ?? []).map((p) => p.asn),
|
||||
actualNextHop: violation.nextHop,
|
||||
reason:
|
||||
`AS${violation.asn} has an ASPA object but does not list AS${violation.nextHop} as an authorized provider. ` +
|
||||
`No valid up-ramp/down-ramp split covers this path (max_up_ramp=${maxUpRamp}, max_down_ramp=${maxDownRamp}, N=${N}).`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
status: "invalid",
|
||||
path: [...dedupedPath],
|
||||
violations,
|
||||
leakDetected: true,
|
||||
leakingAsn: violation?.asn,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
if (minUpRamp + minDownRamp < N) {
|
||||
return {
|
||||
status: "unknown",
|
||||
path: [...dedupedPath],
|
||||
violations: [],
|
||||
leakDetected: false,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
path: [...deduplicatePath(path)],
|
||||
violations: remappedViolations,
|
||||
status: "valid",
|
||||
path: [...dedupedPath],
|
||||
violations: [],
|
||||
leakDetected: false,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user