const { fetchRipeStatCached } = require("../services/ripe-stat"); const { hasAsSet, verifyUpstream, verifyDownstream, detectValleys, collapsePrepends, calculateAspaReadinessScore } = require("../aspa-verification/engine"); const { resolveASNames } = require("../resolve-as-names"); const { ensureAspaCache, lookupAspaFromRpki, rpkiAspaMap, validateRPKIWithCache } = require("../services/rpki"); const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches"); // ASPA Deep Verification endpoint: /api/aspa/verify?asn=X async function handleAspaVerify(req, res, url) { const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); } const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn); if (cachedVerify !== undefined) { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(cachedVerify)); } const targetAsn = parseInt(rawAsn); const start = Date.now(); try { // Fetch neighbour and prefix data first const [neighbourData, prefixData] = await Promise.all([ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), ]); // Use looking-glass with actual prefixes to get BGP paths const announcedPrefixes = prefixData?.data?.prefixes || []; const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3 // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each) const lgResults = await Promise.all( samplePrefixes.map((pfx) => fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null) ) ); // Extract AS paths from looking glass results const allPaths = []; const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths lgResults.forEach((lgData) => { const rrcs = lgData?.data?.rrcs || []; rrcs.forEach((rrc) => { const peers = rrc.peers || []; peers.forEach((peer) => { const rawPath = peer.as_path || ""; const pathArr = rawPath.split(" ").map(Number).filter(Boolean); if (pathArr.length > 1) { allPaths.push({ rrc: rrc.rrc, path: pathArr, rawPath: rawPath, prefix: peer.prefix || "", hasAsSet: hasAsSet(rawPath), }); const idx = pathArr.indexOf(targetAsn); if (idx > 0) { const neighbour = pathArr[idx - 1]; pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1); } } }); }); }); // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams) // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source const neighbours = neighbourData?.data?.neighbours || []; const leftNeighbours = neighbours.filter((n) => n.type === "left"); const upstreamSet = new Set(); leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); // Classify left neighbours: high-power = likely upstream, low-power = likely peer const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1); const detectedProviders = [...upstreamSet].map((asn) => { const nb = leftNeighbours.find((n) => n.asn === asn); const power = nb ? (nb.power || 0) : 0; const powerPct = Math.round((power / maxPower) * 100); const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer"; return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification }; }); await resolveASNames(detectedProviders); // Count how often each provider appears in paths const providerFrequency = new Map(); allPaths.forEach((p) => { const idx = p.path.indexOf(targetAsn); if (idx > 0) { const prov = p.path[idx - 1]; providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1); } }); // Check Cloudflare RPKI feed for ASPA object await ensureAspaCache(); const aspaLookup = lookupAspaFromRpki(targetAsn); const aspaObjectExists = aspaLookup.exists; const aspaDeclaredProviders = aspaLookup.providers; // 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(); if (aspaObjectExists) { aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); } // 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 (const [cas, provSet] of rpkiAspaMap) { if (!aspaStore.has(cas)) { aspaStore.set(cas, provSet); } } // 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); const pathResults = samplePaths.map((p) => { const upstream = verifyUpstream(p.path, aspaStore, p.rawPath); const downstream = verifyDownstream(p.path, aspaStore, p.rawPath); const valleys = detectValleys(p.path, aspaStore); return { rrc: p.rrc, prefix: p.prefix, path: p.path.map((a) => "AS" + a).join(" "), collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "), has_as_set: p.hasAsSet, upstream_verification: upstream, downstream_verification: downstream, valleys: valleys, overall: p.hasAsSet ? "Invalid" : upstream.result === "Valid" && downstream.result === "Valid" ? "Valid" : upstream.result === "Invalid" || downstream.result === "Invalid" ? "Invalid" : "Unknown", }; }); // Calculate statistics const validPaths = pathResults.filter((p) => p.overall === "Valid").length; const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length; const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length; const asSetPaths = pathResults.filter((p) => p.has_as_set).length; const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length; // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal), // only Invalid actually indicates problems const pathNotInvalidPct = pathResults.length > 0 ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100) : 0; const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0; // Provider audit: compare detected vs declared const detectedSet = new Set(detectedProviders.map((p) => p.asn)); const declaredSet = new Set(aspaDeclaredProviders); const missingFromAspa = detectedProviders .filter((p) => !declaredSet.has(p.asn)) .map((p) => ({ asn: p.asn, name: p.name, frequency: providerFrequency.get(p.asn) || 0, frequency_pct: allPaths.length > 0 ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) : 0, })) .sort((a, b) => b.frequency - a.frequency); const extraInAspa = aspaDeclaredProviders .filter((asn) => !detectedSet.has(asn)) .map((asn) => ({ asn, name: "", seen_in_paths: false, })); const providerCompleteness = detectedProviders.length > 0 ? Math.round( (detectedProviders.filter((p) => declaredSet.has(p.asn)).length / detectedProviders.length) * 100 ) : aspaObjectExists ? 100 : 0; // Get RPKI coverage for readiness score // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs) await ensureAspaCache(); const rpkiBatch = announcedPrefixes.map((p) => p.prefix); const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx))); // 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({ rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct: pathNotInvalidPct, }); const duration = Date.now() - start; const verifyResult = { meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), paths_analyzed: pathResults.length, total_paths_seen: allPaths.length, }, 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, frequency_pct: allPaths.length > 0 ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100) : 0, })), provider_audit: { declared_count: aspaDeclaredProviders.length, detected_count: detectedProviders.length, completeness_pct: providerCompleteness, missing_from_aspa: missingFromAspa, extra_in_aspa: extraInAspa, }, path_verification: { total: pathResults.length, valid: validPaths, invalid: invalidPaths, unknown: unknownPaths, as_set_flagged: asSetPaths, valley_detected: valleyPaths, valid_pct: pathValidPct, not_invalid_pct: pathNotInvalidPct, results: pathResults, }, rpki_coverage: rpkiCoverage, }; resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult); return res.end(JSON.stringify(verifyResult, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message })); } } module.exports = { handleAspaVerify };