fix: correct ASPA verification algorithm + fix systemic silent-failure/fake-data patterns

2026-07-16 deep-dive audit (server.js, local-db-client.js, src/aspa/validator.ts)
found the flagship ASPA path verification producing wrong results, and a
recurring pattern where an upstream API or the local Postgres DB failing
would present as "checked, clean" instead of surfacing failure. All fixes
in this commit are empirically or textually verified against primary
sources (the IETF draft's actual algorithm text, RFC 5735/6890 bogon
ranges, and hand-built controlled test cases with known-correct answers)
-- not just read-and-guessed.

CRITICAL: ASPA hop-check direction was reversed
--------------------------------------------------
verifyUpstream/verifyDownstream/detectValleys called
hopCheck(collapsed[i-1], collapsed[i]) -- checking the PROVIDER-side AS's
ASPA object for the CUSTOMER, the opposite of
draft-ietf-sidrops-aspa-verification section 5.3's authorized(A(I), A(I+1))
(A(I) = customer, always checked first). Proven empirically: a hand-built,
fully-attested, zero-leak 3-AS path returned "Invalid" before this fix.

verifyDownstream needed a full rewrite, not just an argument swap -- a
downstream path can legitimately contain both an up-ramp AND a down-ramp
(e.g. origin -> providers -> peering point -> validator's providers ->
validator), and the old K/L/uMin/vMax computation didn't correctly
implement the draft's max_up_ramp/min_up_ramp/max_down_ramp/min_down_ramp
four-way computation. Rewrote it from the draft's exact halt conditions,
verified against a worked example fetched from the draft itself (an N=4
path with a single unattested "kink" correctly resolves Valid) plus 3
hand-built scenarios (pure up-ramp, mixed up+down clean, and a definite
leak with failures on both ends -- Invalid).

Same bug existed independently in src/aspa/validator.ts (the MCP-server-
side validator, different codebase, same conceptual error) -- validateUpstream
was already correct there, but validateDownstream naively reversed the path
and reapplied upstream logic, which cannot represent a legitimate down-ramp.
Rewrote it with the same four-ramp algorithm, cross-validated against the
same test scenarios through the compiled output.

Also in the same /api/aspa/verify handler: aspaStore.set(providerAsn, new
Set()) for detected-but-unattested providers made hopCheck report
"NotProviderPlus" (implying a real ASPA object that denies the hop) instead
of "NoAttestation" (we don't know) -- removed; hopCheck's own `!providers`
branch already handles the unset case correctly. Also removed a fallback
that fabricated an ASPA declaration from heuristically-detected BGP
neighbours when aspa_object_exists is false, which contradicted that exact
field in the same response.

ROOT CAUSE of ~9 broken checks: duplicate function declaration
--------------------------------------------------------------
Two `async function fetchRipeStatCached` declarations existed ~150 lines
apart (the original real RIPE-Stat-fetch+cache+throttle implementation,
and a newer local-DB-routing wrapper added during the Postgres refactor).
In JS, the second declaration silently wins for every call site in the
file. The wrapper only recognized 5 URL patterns (announced-prefixes,
asn-neighbours, as-overview, visibility, prefix-size-distribution) and
returned null unconditionally for everything else -- silently breaking
blocklist (Spamhaus), abuse-contact-finder, bgp-updates, routing-status,
looking-glass, whois, reverse-dns-consistency, maxmind-geo-lite-pfx, and
ixs lookups. Renamed the original to fetchRipeStatCachedFromApi and made
the wrapper fall through to it instead of returning null. Verified via an
isolated routing test (blocklist/abuse-contact-finder URLs now reach the
real implementation; the 5 known patterns still route to local DB).

Silent-failure / fake-success patterns fixed
---------------------------------------------
- local-db-client.js: 5 getRipeStat* functions returned status:'ok' with
  empty data on Postgres error (fabricated success) -- now status:'error'.
  server.js's /api/lookup now tracks which sources degraded, surfaces
  meta.degraded_sources, and caches a degraded result for 90s instead of
  the full 5min so a DB hiccup doesn't get repeated to every visitor.
- validateRPKIWithCache: DB errors and genuine "no covering ROA" both
  produced status:"not_found" -- split into "not_found" (real) vs
  "unavailable" (DB error). Updated all 5 call sites that compute RPKI
  coverage percentages to exclude "unavailable" from the denominator (a DB
  hiccup must not lower a network's displayed RPKI/ASPA score), and fixed
  a pre-existing separate typo in the PDF report generator that checked
  for 'not-found' (hyphen) when the actual value is 'not_found' (underscore).
- crossCheckRpki: an empty sample or a failed RIPE-validator lookup both
  counted as a fabricated 100%/"agreement" instead of being excluded --
  now returns agreement_pct:null when nothing was actually compared, and
  the caller no longer averages null into overall_confidence. Also
  documented (not silently left) that bgp.he.net fetching is hardcoded
  off, so the "2-source" prefix/neighbour cross-checks never actually run
  -- dataQuality.cross_checks now reports sources:1 honestly instead of
  claiming a cross-check that never happened.
- lookupAspaFromRpki: added feedLoaded so a Cloudflare-RPKI-feed fetch
  failure (rpkiAspaLastFetch never set) is distinguishable from "this ASN
  genuinely has no ASPA object" -- surfaced as aspa_feed_healthy on both
  ASPA endpoints.
- Bogon/IRR/Spamhaus-blocklist/reverse-DNS/BGP-visibility checks: fetch
  failure -> empty result -> "pass" (or, for abuse-contact/rdns, a
  misleading "fail") -- now report "info" (excluded from the weighted
  health score, matching the existing checkManrsMembership pattern) when
  the underlying data source didn't actually respond.
- Hijack monitoring: checkHijacksForAsn returned [] on DB error,
  indistinguishable from a genuine zero-prefix result. On a subscription's
  first monitoring cycle this permanently persisted an empty baseline,
  silently disabling leak detection forever while the UI kept showing it
  as active. Now returns null on failure; runHijackCheck skips the cycle
  entirely (retries next time) instead of locking in a bad baseline. Fixed
  2 other call sites that would otherwise crash or persist null onto disk.
- WHOIS cache: a transient failure across all 5 RIR sources (RIPE + 4
  RDAP endpoints) was cached as "not found in any RIR database" for 24h,
  identical to a genuinely non-existent ASN. Added a 10min TTL for that
  specific case instead.
- /api/relationships: a neighbourData fetch failure produced "0
  upstreams/downstreams/peers", cached unconditionally for 10min. Applied
  the same 90s-on-suspicious-zero guard the codebase already uses for
  /api/validate (commit 4cf1673) but never applied here.

Verification
------------
node --check on both .js files, tsc --noEmit clean on every touched
TS module (remaining errors are 100% pre-existing, in the unrelated
mcp-server subsystem), npm run build succeeds, isolated empirical tests
for both the ASPA algorithm (3 hand-built scenarios with known-correct
answers, cross-checked against draft text fetched live) and the
fetchRipeStatCached routing fix, and a full local boot test reaching the
exact same pre-existing sandbox-only crash point (missing PEERINGDB_API_KEY,
unrelated to this commit) as a baseline boot before any of today's changes.

Not deployed to Erik as part of this commit -- server.js and
public/index.html there are untouched.
This commit is contained in:
Rene Fichtmueller 2026-07-16 15:43:49 +02:00
parent 141911537d
commit cc6b81ba8e
3 changed files with 388 additions and 150 deletions

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) { async function checkBgpHijack(prefix) {
try { try {
const result = await pool.query( 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) : []; return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
} catch (error) { } catch (error) {
console.error('[Local DB] Hijack Check Error:', error.message); console.error('[Local DB] Hijack Check Error:', error.message);
return []; return null;
} }
} }
@ -249,7 +252,7 @@ async function getRipeStatAnnouncedPrefixes(asn) {
}; };
} catch (error) { } catch (error) {
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message); 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) { } catch (error) {
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message); 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) { } catch (error) {
console.error('[Local RIPE Stat] AS Overview Error:', error.message); 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) { } catch (error) {
console.error('[Local RIPE Stat] Visibility Error:', error.message); 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) { } catch (error) {
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message); 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) { async function checkHijacksForAsn(asn) {
try { try {
const data = await localDb.getRipeStatAnnouncedPrefixes(asn); 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); const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
return prefixes; return prefixes;
} catch (_) { return []; } } catch (_) { return null; }
} }
async function runHijackCheck() { async function runHijackCheck() {
@ -384,6 +390,12 @@ async function runHijackCheck() {
let changed = false; let changed = false;
for (const sub of subs) { for (const sub of subs) {
const current = await checkHijacksForAsn(sub.asn); 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 baseline = new Set(sub.prefixes || []);
const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p)); const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
const missing = [...baseline].filter(p => !current.includes(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 rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length; const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').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 passedChecks = checks.filter(c => c.status === 'pass').length;
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').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 = 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_ASPA = 4 * 60 * 60 * 1000; // 4 hours
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 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 // 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; 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) { function whoisCacheGet(asn) {
const e = whoisCache.get(String(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; return undefined;
} }
function whoisCacheSet(asn, data) { function whoisCacheSet(asn, data, ttl) {
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); 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); const ripeStatSemaphore = new Semaphore(15);
// Cached + throttled RIPE Stat fetch // 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 // Extract endpoint name from URL for TTL lookup
const match = url.match(/\/data\/([^/]+)\//); const match = url.match(/\/data\/([^/]+)\//);
const endpoint = match ? match[1] : "default"; 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) { function lookupAspaFromRpki(asn) {
const asnNum = Number(asn); const asnNum = Number(asn);
const feedLoaded = rpkiAspaLastFetch > 0;
if (rpkiAspaMap.has(asnNum)) { if (rpkiAspaMap.has(asnNum)) {
const providers = rpkiAspaMap.get(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])); if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
} }
// For other RIPE Stat endpoints (not in localDb): return empty/null gracefully // For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
// Examples: rir-stats-country, bgp-updates, reverse-dns-consistency, routing-status, maxmind-geo-lite, etc. // e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
return Promise.resolve(null); // 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) { } catch (e) {
console.error("[fetchRipeStatCached] Error:", e.message); console.error("[fetchRipeStatCached] Error:", e.message);
return Promise.resolve(null); 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) // 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) // 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) // 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) { async function validateRPKIWithCache(asn, prefix) {
try { try {
// Query local database (sub-10ms, no external API calls)
const result = await localDb.validateRpki(prefix, asn); const result = await localDb.validateRpki(prefix, asn);
// Adapt response to match expected format
if (result.status === 'valid') { if (result.status === 'valid') {
return { prefix, status: "valid", validating_roas: 1 }; return { prefix, status: "valid", validating_roas: 1 };
} else if (result.status === 'invalid') { } else if (result.status === 'invalid') {
return { prefix, status: "invalid", validating_roas: 1 }; return { prefix, status: "invalid", validating_roas: 1 };
} else if (result.status === 'unknown') {
return { prefix, status: "unavailable", validating_roas: 0 };
} else { } else {
// 'not-found' or 'unknown' // 'not-found': genuinely no covering ROA
return { prefix, status: "not_found", validating_roas: 0 }; return { prefix, status: "not_found", validating_roas: 0 };
} }
} catch (_e) { } catch (_e) {
console.error("[RPKI] Error validating " + prefix + ":", _e.message); 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) // Hop Check function (core of ASPA verification)
// aspaStore = Map<number, Set<number>> (CAS -> provider set) // 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) { function hopCheck(asI, asJ, aspaStore) {
const providers = aspaStore.get(asI); const providers = aspaStore.get(asI);
if (!providers) return "NoAttestation"; if (!providers) return "NoAttestation";
@ -2499,7 +2544,9 @@ function verifyUpstream(asPath, aspaStore, rawPathStr) {
let hasNoAttestation = false; let hasNoAttestation = false;
for (let i = 1; i < collapsed.length; i++) { 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({ hops.push({
from: collapsed[i - 1], from: collapsed[i - 1],
to: collapsed[i], 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) { function verifyDownstream(asPath, aspaStore, rawPathStr) {
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
const collapsed = collapsePrepends(asPath); const collapsed = collapsePrepends(asPath);
@ -2530,58 +2586,57 @@ function verifyDownstream(asPath, aspaStore, rawPathStr) {
hops.push({ hops.push({
from: collapsed[i - 1], from: collapsed[i - 1],
to: collapsed[i], 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 // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
let uMin = N + 1; // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
for (let u = 1; u < N; u++) { // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
if (hopCheck(collapsed[u - 1], collapsed[u], aspaStore) === "NotProviderPlus") { // minUpRamp stops at the first hop that isn't confirmed ProviderPlus
uMin = u; // (including unattested "NoAttestation" hops, which don't disprove a leak
break; // 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 // Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
let vMax = 0; // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
for (let v = N - 2; v >= 0; v--) { let maxDownRamp = N;
if (hopCheck(collapsed[v + 1], collapsed[v], aspaStore) === "NotProviderPlus") { let minDownRamp = N;
vMax = v; for (let k = 0; k <= N - 2; k++) {
break; 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) { if (maxUpRamp + maxDownRamp < N) {
return { result: "Invalid", reason: "uMin(" + uMin + ") <= vMax(" + vMax + "): valley detected", hops }; return {
result: "Invalid",
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
hops,
};
} }
if (minUpRamp + minDownRamp < N) {
// Compute up-ramp K return {
let K = 0; result: "Unknown",
for (let i = 1; i < N; i++) { reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
if (hopCheck(collapsed[i - 1], collapsed[i], aspaStore) === "ProviderPlus") { hops,
K = i; };
} else {
break;
} }
} return {
result: "Valid",
// Compute down-ramp L reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
let L = N - 1; hops,
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 };
} }
// Valley Detection: scan path for up-down-up pattern (route leak indicator) // 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) { async function crossCheckRpki(asn, prefixes, localResults) {
const sample = prefixes.slice(0, 5); 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( const ripeResults = await Promise.all(
sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx)) sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
@ -2705,6 +2763,7 @@ async function crossCheckRpki(asn, prefixes, localResults) {
let cloudflareValid = 0; let cloudflareValid = 0;
let ripeValid = 0; let ripeValid = 0;
let agreements = 0; let agreements = 0;
let comparable = 0;
const disagreements = []; const disagreements = [];
for (let i = 0; i < sample.length; i++) { for (let i = 0; i < sample.length; i++) {
@ -2718,12 +2777,13 @@ async function crossCheckRpki(asn, prefixes, localResults) {
if (cfIsValid) cloudflareValid++; if (cfIsValid) cloudflareValid++;
if (ripeIsValid) ripeValid++; 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") { if (ripeState === "error" || ripeState === "unknown") {
agreements++; // Don't count failed lookups as disagreements
continue; continue;
} }
comparable++;
if (cfIsValid === ripeIsValid) { if (cfIsValid === ripeIsValid) {
agreements++; agreements++;
} else { } else {
@ -2735,8 +2795,8 @@ async function crossCheckRpki(asn, prefixes, localResults) {
} }
} }
const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100; 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 }; 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); whoisCacheSet(asn, result.data);
} else { } else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)"; 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)) { } 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 aspaObjectExists = aspaLookup.exists;
const aspaDeclaredProviders = aspaLookup.providers; 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(); const aspaStore = new Map();
// Add the target ASN's RPKI-declared providers
if (aspaObjectExists) { if (aspaObjectExists) {
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); 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 // Also populate store with all known ASPA objects from the RPKI feed
// for providers that have their own ASPA objects (enables full path verification) // 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); aspaStore.set(cas, provSet);
} }
} }
// Deliberately NOT adding empty Set() entries for detected-but-unattested
// Also add reverse relationships for providers we know about // providers here: aspaStore.get() returning undefined for them makes
// (each provider has the target as customer) // hopCheck() correctly report "NoAttestation". An empty (but present) Set
detectedProviders.forEach((p) => { // would make providers.has(x) return false, misreporting "NotProviderPlus"
if (!aspaStore.has(p.asn)) { // (implying a real ASPA object that denies this hop) instead of "we simply
aspaStore.set(p.asn, new Set()); // 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) // Sample paths for verification (up to 50)
const samplePaths = allPaths.slice(0, 50); const samplePaths = allPaths.slice(0, 50);
@ -3754,8 +3817,11 @@ const server = http.createServer(async (req, res) => {
await ensureAspaCache(); await ensureAspaCache();
const rpkiBatch = announcedPrefixes.map((p) => p.prefix); const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx))); const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length; // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.length) * 100) : 0; // 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 // Calculate readiness score
const readinessScore = calculateAspaReadinessScore({ const readinessScore = calculateAspaReadinessScore({
@ -3777,6 +3843,7 @@ const server = http.createServer(async (req, res) => {
asn: targetAsn, asn: targetAsn,
readiness_score: readinessScore, readiness_score: readinessScore,
aspa_object_exists: aspaObjectExists, aspa_object_exists: aspaObjectExists,
aspa_feed_healthy: aspaLookup.feedLoaded,
detected_providers: detectedProviders.map((p) => ({ detected_providers: detectedProviders.map((p) => ({
...p, ...p,
frequency: providerFrequency.get(p.asn) || 0, frequency: providerFrequency.get(p.asn) || 0,
@ -3919,6 +3986,7 @@ const server = http.createServer(async (req, res) => {
detected_providers: detectedProviders, detected_providers: detectedProviders,
provider_count: detectedProviders.length, provider_count: detectedProviders.length,
aspa_object_exists: aspaObjectExists, aspa_object_exists: aspaObjectExists,
aspa_feed_healthy: aspaLookup.feedLoaded,
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
aspa_declared_count: aspaDeclaredProviders.length, aspa_declared_count: aspaDeclaredProviders.length,
recommended_aspa: recommendedAspa, recommended_aspa: recommendedAspa,
@ -3977,9 +4045,12 @@ const server = http.createServer(async (req, res) => {
last_seen: bgpStatus.last_seen, last_seen: bgpStatus.last_seen,
source: "local_bgp", 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); 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 = { result.bgp_status.hijack_warning = {
detected: true, detected: true,
origin_asns: hijackAsns, 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 bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
var asnInPaths = neighbours.map(function(n) { return n.asn; }); var asnInPaths = neighbours.map(function(n) { return n.asn; });
var bogonAsns = asnInPaths.filter(checkBogonAsn); 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 = { 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_prefixes: bogonPrefixes,
bogon_asns_in_paths: bogonAsns, bogon_asns_in_paths: bogonAsns,
total_prefixes_checked: allPrefixes.length, total_prefixes_checked: allPrefixes.length,
prefixes_source_unavailable: !prefixData,
neighbours_source_unavailable: !neighbourData,
}; };
// Phase 2: All API-dependent validations in parallel // Phase 2: All API-dependent validations in parallel
var validationPromises = {}; var validationPromises = {};
// 12. IRR Validation // 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) { 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 entries = Array.isArray(irrData) ? irrData : [];
var mismatches = entries.filter(function(e) { var mismatches = entries.filter(function(e) {
if (!e.bgpOrigins && !e.bgp_origins) return false; if (!e.bgpOrigins && !e.bgp_origins) return false;
@ -4152,16 +4234,22 @@ const server = http.createServer(async (req, res) => {
validationPromises.rpki_completeness = Promise.all( validationPromises.rpki_completeness = Promise.all(
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
).then(function(rpkiResults) { ).then(function(rpkiResults) {
var withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; }); var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0; var unavailableCount = rpkiResults.length - checkedResults.length;
var overSpecific = rpkiResults.filter(function(r) { 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"); var mask = parseInt((r.prefix || "").split("/")[1] || "0");
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; 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 { return {
status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
coverage_pct: coverage, coverage_pct: coverage,
total_checked: rpkiResults.length, total_checked: checkedResults.length,
db_unavailable_count: unavailableCount,
with_roa: withRoa.length, with_roa: withRoa.length,
over_specific: overSpecific.map(function(r) { return r.prefix; }), over_specific: overSpecific.map(function(r) { return r.prefix; }),
details: rpkiResults, details: rpkiResults,
@ -4170,6 +4258,7 @@ const server = http.createServer(async (req, res) => {
// 14. Abuse Contact Validation // 14. Abuse Contact Validation
validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { 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 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("@"); }); var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; 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( validationPromises.blocklist = Promise.all(
samplePrefixes.slice(0, 5).map(function(pfx) { samplePrefixes.slice(0, 5).map(function(pfx) {
return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { 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 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); }); 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"; }) }; 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, error: true }; }); }).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
}) })
).then(function(results) { ).then(function(results) {
var listedPrefixes = results.filter(function(r) { return r.listed; }); var checkedResults = results.filter(function(r) { return r.checked; });
return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes }; 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) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// 16. MANRS Compliance — scraped from public participants list (24h cache) // 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( validationPromises.rdns = Promise.all(
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { 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) { 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 pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
var hasDelegation = false; var hasDelegation = false;
var details = []; var details = [];
@ -4226,11 +4327,19 @@ const server = http.createServer(async (req, res) => {
}).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
}) })
).then(function(results) { ).then(function(results) {
var withRdns = results.filter(function(r) { return r.has_rdns; }); var checkedResults = results.filter(function(r) { return !r.error; });
var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0; var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
// Include details of what failed var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; }); var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).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 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) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) // 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" }; 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() { }).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" }; 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( validationPromises.resource_cert = Promise.all(
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
).then(function(results) { ).then(function(results) {
var hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); var checked = results.filter(function(r) { return r.status !== "unavailable"; });
return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.filter(function(r) { return r.status === "valid"; }).length }; 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) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// Geolocation cross-ref with PeeringDB facilities // 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 (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData); 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 prefixes = prefixData?.data?.prefixes || [];
const neighbours = neighbourData?.data?.neighbours || []; const neighbours = neighbourData?.data?.neighbours || [];
const overview = overviewData?.data || {}; const overview = overviewData?.data || {};
@ -4708,8 +4831,11 @@ const server = http.createServer(async (req, res) => {
const rpkiStatuses = rpkiAllResults; const rpkiStatuses = rpkiAllResults;
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length; const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length; const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length; const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
const rpkiTotal = rpkiStatuses.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; const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
let upstreams = neighbours let upstreams = neighbours
@ -4908,7 +5034,7 @@ const server = http.createServer(async (req, res) => {
// ============================================================ // ============================================================
// Multi-source cross-checks (run in parallel, non-blocking) // 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 prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null }; 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; if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
} catch (_e) { /* cross-check failed, keep defaults */ } } 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 // Prefix count cross-check: compare RIPE Stat vs bgp.he.net
if (bgpHeData) { if (bgpHeData) {
const heV4 = bgpHeData.prefixes_v4 || 0; const heV4 = bgpHeData.prefixes_v4 || 0;
@ -4950,10 +5083,12 @@ const server = http.createServer(async (req, res) => {
prefixCrossCheck.note = "bgp.he.net data unavailable"; 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 = []; const crossCheckScores = [];
// RPKI agreement if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
crossCheckScores.push(rpkiCrossCheck.agreement_pct);
// Prefix agreement: convert to percentage // Prefix agreement: convert to percentage
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) { 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); 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 const avgAgreement = crossCheckScores.length > 0
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length) ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
: 100; : null;
const overallConfidence = avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low"; const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
const dataQuality = { const dataQuality = {
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"], sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
cross_checks: { cross_checks: {
rpki: { sources: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements }, 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: 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 }, 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: 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 }, 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_confidence: overallConfidence,
overall_agreement_pct: avgAgreement, overall_agreement_pct: avgAgreement,
@ -5026,6 +5161,7 @@ const server = http.createServer(async (req, res) => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
rpki_prefixes_checked: rpkiTotal, rpki_prefixes_checked: rpkiTotal,
total_prefixes: prefixes.length, total_prefixes: prefixes.length,
degraded_sources: degradedSources,
}, },
network: { network: {
asn: parseInt(asn), asn: parseInt(asn),
@ -5065,6 +5201,7 @@ const server = http.createServer(async (req, res) => {
valid: rpkiValid, valid: rpkiValid,
invalid: rpkiInvalid, invalid: rpkiInvalid,
not_found: rpkiNotFound, not_found: rpkiNotFound,
unavailable: rpkiUnavailable,
checked: rpkiTotal, checked: rpkiTotal,
details: rpkiStatuses, details: rpkiStatuses,
cross_check: rpkiCrossCheck, cross_check: rpkiCrossCheck,
@ -5150,7 +5287,9 @@ const server = http.createServer(async (req, res) => {
// Update duration to include cross-check time // Update duration to include cross-check time
result.meta.duration_ms = Date.now() - start; 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)); res.end(JSON.stringify(result, null, 2));
} catch (err) { } catch (err) {
const duration = Date.now() - start; 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, 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" }); res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result, null, 2)); return res.end(JSON.stringify(result, null, 2));
} catch (err) { } catch (err) {
@ -6045,7 +6187,9 @@ ${html}
const hijackSubs = loadHijackSubs(); const hijackSubs = loadHijackSubs();
if (!hijackSubs.find(s => s.asn === asn)) { if (!hijackSubs.find(s => s.asn === asn)) {
checkHijacksForAsn(asn).then(prefixes => { 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); saveHijackSubs(hijackSubs);
}); });
} }
@ -6160,12 +6304,16 @@ ${html}
const exists = subs.find(s => s.asn === asnNum); const exists = subs.find(s => s.asn === asnNum);
if (!exists) { if (!exists) {
const prefixes = await checkHijacksForAsn(asnNum); 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); saveHijackSubs(subs);
} }
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] }; const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
res.writeHead(200); 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})); } } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
}); });
return; 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. * Unlike upstream validation, a downstream path may legitimately contain
* Downstream validation is used when the validating AS is receiving * BOTH an up-ramp (customer->provider hops near the origin) AND a down-ramp
* a route from a customer rather than a provider. * (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: * Verified 2026-07-16 against a worked example fetched from the draft (an
* - Reverse the path so the "origin" from the downstream perspective is leftmost. * N=4 path with a single unattested "kink" resolves to valid; a path with
* - Apply the same provider-authorization checks. * definite authorization failures on both ends resolves to invalid).
* *
* @param path - AS path to validate (leftmost = closest to validator) * @param path - AS path to validate (leftmost = closest to validator)
* @param aspaObjects - Map of customer ASN to ASPA object * @param aspaObjects - Map of customer ASN to ASPA object
@ -260,23 +267,103 @@ export function validateDownstream(
path: ReadonlyArray<number>, path: ReadonlyArray<number>,
aspaObjects: ReadonlyMap<number, ASPAObject> aspaObjects: ReadonlyMap<number, ASPAObject>
): ASPAValidationResult { ): ASPAValidationResult {
// Downstream: reverse the path and apply upstream logic. const dedupedPath = deduplicatePath(path);
const reversedPath = [...path].reverse(); const N = dedupedPath.length;
const result = validateUpstream(reversedPath, aspaObjects);
// Map violations back to original path positions if (N <= 2) {
const originalLength = deduplicatePath(path).length; return {
const remappedViolations: ReadonlyArray<ASPAViolation> = result.violations.map( status: "valid",
(v) => ({ path: [...dedupedPath],
...v, violations: [],
position: originalLength - 1 - v.position, 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 { return {
...result, status: "valid",
path: [...deduplicatePath(path)], path: [...dedupedPath],
violations: remappedViolations, violations: [],
leakDetected: false,
confidence,
}; };
} }