Backend refactor reconciliation + ASPA verification fix + silent-failure audit #2

Merged
rene.fichtmueller merged 12 commits from merge-prod-snapshot into main 2026-07-16 20:18:49 +00:00
3 changed files with 388 additions and 150 deletions
Showing only changes of commit cc6b81ba8e - Show all commits

View File

@ -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
View File

@ -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;

View File

@ -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,
};
}