diff --git a/server.js b/server.js index 3599053..224033f 100644 --- a/server.js +++ b/server.js @@ -721,13 +721,7 @@ new Chart(document.getElementById('asnChart'), { let ipv6StatsCache = null; const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h -const RIR_DELEGATION_URLS = { - RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest', - ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', - APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest', - AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest', - LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest', -}; +const { RIR_DELEGATION_URLS } = require("./server/data/rir-delegation-urls"); /** * Fetch and parse one RIR delegation file. @@ -804,32 +798,8 @@ async function fetchIpv6AdoptionStats(force) { // Pre-fetch on startup (non-blocking) fetchIpv6AdoptionStats().catch(() => {}); -const UA = "PeerCortex/0.5.0 (+https://peercortex.org)"; - -// Static geocode cache for major networking cities (fallback when PDB facility coords missing) -const CITY_COORDS = { - "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821], - "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417], - "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820], - "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122], - "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384], - "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393], - "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025], - "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784], - "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198], - "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093], - "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298], - "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874], - "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832], - "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940], - "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735], - "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052], - "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903], - "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207], - "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780], - "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456], - "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883], -}; +const { UA } = require("./server/data/constants"); +const { CITY_COORDS } = require("./server/data/city-coords"); // ============================================================ // FEATURE 2: PDF Export — template generators + puppeteer renderer @@ -1538,30 +1508,7 @@ function cacheSet(key, data, ttlMs) { } -// ── Tier-1 ASN Set (used for route leak heuristics) ───────────────────────── -const TIER1_ASNS = new Set([ - 174, // Cogent - 209, // CenturyLink/Lumen - 286, // KPN - 701, // Verizon/UUNET - 702, // Verizon - 1239, // Sprint - 1273, // Vodafone - 1280, // Internet Systems Consortium - 1299, // Arelion (Telia) - 2914, // NTT - 3257, // GTT - 3320, // Deutsche Telekom - 3356, // Lumen (Level3) - 3491, // PCCW - 5511, // Orange - 6453, // TATA - 6461, // Zayo - 6762, // Telecom Italia Sparkle - 7018, // AT&T - 7473, // SingTel - 12956, // Telxius -]); +const { TIER1_ASNS } = require("./server/data/tier1-asns"); 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 @@ -2506,220 +2453,16 @@ async function validateRPKIWithCache(asn, prefix) { } -// ============================================================ -// RFC-Compliant ASPA Verification Engine -// ============================================================ - -// Check if AS path contains AS_SET segments (curly braces indicate sets) -function hasAsSet(asPath) { - if (typeof asPath === "string") { - return asPath.includes("{") || asPath.includes("}"); - } - return false; -} - -// Hop Check function (core of ASPA verification) -// aspaStore = Map> (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"; - return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus"; -} - -// Collapse AS path prepends (remove consecutive duplicates) -function collapsePrepends(path) { - return path.filter((as, i) => i === 0 || as !== path[i - 1]); -} - -// Upstream Verification (RFC Section 6.1) -function verifyUpstream(asPath, aspaStore, rawPathStr) { - if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; - const collapsed = collapsePrepends(asPath); - if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" }; - - const hops = []; - let hasNoAttestation = false; - - for (let i = 1; i < collapsed.length; i++) { - // 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], - result: check, - }); - if (check === "NotProviderPlus") { - return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops }; - } - if (check === "NoAttestation") hasNoAttestation = true; - } - - return { - result: hasNoAttestation ? "Unknown" : "Valid", - reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus", - hops, - }; -} - -// 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); - const N = collapsed.length; - if (N <= 2) return { result: "Valid", reason: "Path length <= 2" }; - - const hops = []; - for (let i = 1; i < N; i++) { - hops.push({ - from: collapsed[i - 1], - to: collapsed[i], - result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore), - }); - } - - // 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; - } - - // 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 (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, - }; - } - if (minUpRamp + minDownRamp < N) { - return { - result: "Unknown", - reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation", - 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) -function detectValleys(asPath, aspaStore) { - const collapsed = collapsePrepends(asPath); - if (collapsed.length < 4) return []; - - const valleys = []; - // Walk the path and look at relationship transitions - const relationships = []; - for (let i = 1; i < collapsed.length; i++) { - const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore); - const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore); - let rel = "unknown"; - if (fwd === "ProviderPlus") rel = "customer-to-provider"; - else if (rev === "ProviderPlus") rel = "provider-to-customer"; - else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer"; - relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel }); - } - - // Detect c2p -> p2c -> c2p pattern - for (let i = 0; i < relationships.length - 2; i++) { - if ( - relationships[i].rel === "customer-to-provider" && - relationships[i + 1].rel === "provider-to-customer" && - relationships[i + 2].rel === "customer-to-provider" - ) { - valleys.push({ - position: i, - path_segment: [ - relationships[i].from, - relationships[i].to, - relationships[i + 1].to, - relationships[i + 2].to, - ].map((a) => "AS" + a), - description: - "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to + - " (c2p) -> AS" + relationships[i + 1].to + - " (p2c) -> AS" + relationships[i + 2].to + " (c2p)", - }); - } - } - - return valleys; -} - -// Build ASPA store from detected provider relationships -function buildAspaStore(detectedProviders, targetAsn) { - const store = new Map(); - // Add the target ASN's providers - if (detectedProviders.length > 0) { - const providerSet = new Set(detectedProviders.map((p) => p.asn)); - store.set(targetAsn, providerSet); - } - return store; -} - -// Calculate ASPA Readiness Score (0-100) -function calculateAspaReadinessScore(params) { - const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params; - - // ROA coverage (0-25 points) - const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25); - - // ASPA object exists (0-25 points) - const aspaScore = aspaObjectExists ? 25 : 0; - - // Provider completeness (0-25 points) - const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25); - - // Path validation results (0-25 points) - const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25); - - return { - total: roaScore + aspaScore + provScore + pathScore, - breakdown: { - roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage }, - aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists }, - provider_completeness: { score: provScore, max: 25, value: providerCompleteness }, - path_validation: { score: pathScore, max: 25, value: pathValidationPct }, - }, - }; -} +const { + hasAsSet, + hopCheck, + collapsePrepends, + verifyUpstream, + verifyDownstream, + detectValleys, + buildAspaStore, + calculateAspaReadinessScore, +} = require("./server/aspa-verification/engine"); // ============================================================ @@ -2897,132 +2640,8 @@ async function fetchTopology(targetAsn, depth) { // Feature 27: WHOIS via RIPE DB // ============================================================ -// ── Resilience Score ───────────────────────────────────────────────────────── -// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25% -// Hard cap at 5.0 when single transit provider detected. -// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. -function computeResilienceScore(upstreams, peers, ixConnections, prefixes) { - const upstreamCount = upstreams.length; - const peerCount = peers.length; - const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length; - const prefixCount = prefixes.length; - - // Transit Diversity (0-10) - let transitRaw = 0; - if (upstreamCount === 0) transitRaw = 0; - else if (upstreamCount === 1) transitRaw = 2; - else if (upstreamCount === 2) transitRaw = 5; - else if (upstreamCount <= 4) transitRaw = 7; - else transitRaw = 10; - - // Peering Breadth (0-10) - let peeringRaw = 0; - if (peerCount >= 100) peeringRaw = 10; - else if (peerCount >= 50) peeringRaw = 8; - else if (peerCount >= 20) peeringRaw = 6; - else if (peerCount >= 5) peeringRaw = 4; - else if (peerCount >= 1) peeringRaw = 2; - - // IXP Presence (0-10) - let ixpRaw = 0; - if (ixCount >= 10) ixpRaw = 10; - else if (ixCount >= 6) ixpRaw = 8; - else if (ixCount >= 3) ixpRaw = 6; - else if (ixCount >= 1) ixpRaw = 4; - - // Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination - let pathRaw = 0; - if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10; - else if (upstreamCount >= 2) pathRaw = 7; - else if (ixCount >= 2) pathRaw = 6; - else if (upstreamCount === 1) pathRaw = 3; - else if (prefixCount > 0) pathRaw = 1; - - const weighted = - transitRaw * 0.30 + - peeringRaw * 0.25 + - ixpRaw * 0.20 + - pathRaw * 0.25; - - const singleTransitCap = upstreamCount === 1; - let score = Math.round(weighted * 10) / 10; - if (singleTransitCap) score = Math.min(score, 5.0); - score = Math.max(1.0, Math.min(10.0, score)); - - // Only return null if truly no data at all - if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) { - return null; - } - - return { - score, - breakdown: { - transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount }, - peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount }, - ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount }, - path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount }, - }, - single_transit_cap_applied: singleTransitCap, - _provenance: { - source: "RIPE Stat asn-neighbours + PeeringDB netixlan", - validation: "cross-validated", - confidence: "high", - note: "All inputs independently validated daily against external sources", - }, - }; -} - -// ── Route Leak Detection ───────────────────────────────────────────────────── -// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data. -// NOT real-time. False positives possible for large networks with many Tier-1 relationships. -// Confidence: MEDIUM — pattern-based, not path-level analysis. -function computeRouteLeakDetection(upstreams, downstreams, peers) { - const upstreamAsns = new Set(upstreams.map(n => n.asn)); - const downstreamAsns = new Set(downstreams.map(n => n.asn)); - - const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn)); - const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn)); - - const patterns = []; - - // Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate - const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn)); - sandwich.forEach(n => { - patterns.push({ - type: "sandwich_candidate", - asn: n.asn, - name: n.name, - description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`, - }); - }); - - // Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s) - tier1Downstreams.forEach(n => { - if (!upstreamAsns.has(n.asn)) { - patterns.push({ - type: "tier1_downstream", - asn: n.asn, - name: n.name, - description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`, - }); - } - }); - - const detected = patterns.length > 0; - - return { - detected, - patterns, - tier1_upstream_count: tier1Upstreams.length, - tier1_downstream_count: tier1Downstreams.length, - _provenance: { - source: "RIPE Stat asn-neighbours", - validation: "heuristic", - confidence: "medium", - note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.", - }, - }; -} +const { computeResilienceScore } = require("./server/resilience-score"); +const { computeRouteLeakDetection } = require("./server/route-leak"); async function fetchWhois(resource) { const result = { resource, type: null, data: null, error: null }; @@ -4957,10 +4576,7 @@ const server = http.createServer(async (req, res) => { } // Last resort: derive RIR from country code (common assignments) if (!rir && country) { - const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]); - const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]); - const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]); - const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]); + const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("./server/data/rir-country-codes"); if (ARIN_CC.has(country)) rir = "ARIN"; else if (APNIC_CC.has(country)) rir = "APNIC"; else if (LACNIC_CC.has(country)) rir = "LACNIC"; @@ -5149,7 +4765,7 @@ const server = http.createServer(async (req, res) => { } }); // Hard-coded IX ID → city for well-known IXPs whose names don't contain city - var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" }; + var { IX_CITY_MAP } = require("./server/data/ix-city-map"); ixConnections.forEach(function(conn) { if (ixIdsWithCoords.has(conn.ix_id)) return; var city = IX_CITY_MAP[conn.ix_id]; diff --git a/server/aspa-verification/engine.js b/server/aspa-verification/engine.js new file mode 100644 index 0000000..b4767da --- /dev/null +++ b/server/aspa-verification/engine.js @@ -0,0 +1,224 @@ +// RFC-Compliant ASPA Verification Engine +// Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification. + +// Check if AS path contains AS_SET segments (curly braces indicate sets) +function hasAsSet(asPath) { + if (typeof asPath === "string") { + return asPath.includes("{") || asPath.includes("}"); + } + return false; +} + +// Hop Check function (core of ASPA verification) +// aspaStore = Map> (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"; + return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus"; +} + +// Collapse AS path prepends (remove consecutive duplicates) +function collapsePrepends(path) { + return path.filter((as, i) => i === 0 || as !== path[i - 1]); +} + +// Upstream Verification (RFC Section 6.1) +function verifyUpstream(asPath, aspaStore, rawPathStr) { + if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; + const collapsed = collapsePrepends(asPath); + if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" }; + + const hops = []; + let hasNoAttestation = false; + + for (let i = 1; i < collapsed.length; i++) { + // 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], + result: check, + }); + if (check === "NotProviderPlus") { + return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops }; + } + if (check === "NoAttestation") hasNoAttestation = true; + } + + return { + result: hasNoAttestation ? "Unknown" : "Valid", + reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus", + hops, + }; +} + +// 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); + const N = collapsed.length; + if (N <= 2) return { result: "Valid", reason: "Path length <= 2" }; + + const hops = []; + for (let i = 1; i < N; i++) { + hops.push({ + from: collapsed[i - 1], + to: collapsed[i], + result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore), + }); + } + + // 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; + } + + // 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 (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, + }; + } + if (minUpRamp + minDownRamp < N) { + return { + result: "Unknown", + reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation", + 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) +function detectValleys(asPath, aspaStore) { + const collapsed = collapsePrepends(asPath); + if (collapsed.length < 4) return []; + + const valleys = []; + // Walk the path and look at relationship transitions + const relationships = []; + for (let i = 1; i < collapsed.length; i++) { + const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore); + const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore); + let rel = "unknown"; + if (fwd === "ProviderPlus") rel = "customer-to-provider"; + else if (rev === "ProviderPlus") rel = "provider-to-customer"; + else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer"; + relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel }); + } + + // Detect c2p -> p2c -> c2p pattern + for (let i = 0; i < relationships.length - 2; i++) { + if ( + relationships[i].rel === "customer-to-provider" && + relationships[i + 1].rel === "provider-to-customer" && + relationships[i + 2].rel === "customer-to-provider" + ) { + valleys.push({ + position: i, + path_segment: [ + relationships[i].from, + relationships[i].to, + relationships[i + 1].to, + relationships[i + 2].to, + ].map((a) => "AS" + a), + description: + "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to + + " (c2p) -> AS" + relationships[i + 1].to + + " (p2c) -> AS" + relationships[i + 2].to + " (c2p)", + }); + } + } + + return valleys; +} + +// Build ASPA store from detected provider relationships +function buildAspaStore(detectedProviders, targetAsn) { + const store = new Map(); + // Add the target ASN's providers + if (detectedProviders.length > 0) { + const providerSet = new Set(detectedProviders.map((p) => p.asn)); + store.set(targetAsn, providerSet); + } + return store; +} + +// Calculate ASPA Readiness Score (0-100) +function calculateAspaReadinessScore(params) { + const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params; + + // ROA coverage (0-25 points) + const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25); + + // ASPA object exists (0-25 points) + const aspaScore = aspaObjectExists ? 25 : 0; + + // Provider completeness (0-25 points) + const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25); + + // Path validation results (0-25 points) + const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25); + + return { + total: roaScore + aspaScore + provScore + pathScore, + breakdown: { + roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage }, + aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists }, + provider_completeness: { score: provScore, max: 25, value: providerCompleteness }, + path_validation: { score: pathScore, max: 25, value: pathValidationPct }, + }, + }; +} + +module.exports = { + hasAsSet, + hopCheck, + collapsePrepends, + verifyUpstream, + verifyDownstream, + detectValleys, + buildAspaStore, + calculateAspaReadinessScore, +}; diff --git a/server/data/city-coords.js b/server/data/city-coords.js new file mode 100644 index 0000000..89686ca --- /dev/null +++ b/server/data/city-coords.js @@ -0,0 +1,26 @@ +// Static geocode cache for major networking cities (fallback when PDB facility coords missing) +const CITY_COORDS = { + "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821], + "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417], + "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820], + "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122], + "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384], + "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393], + "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025], + "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784], + "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198], + "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093], + "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298], + "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874], + "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832], + "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940], + "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735], + "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052], + "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903], + "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207], + "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780], + "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456], + "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883], +}; + +module.exports = { CITY_COORDS }; diff --git a/server/data/constants.js b/server/data/constants.js new file mode 100644 index 0000000..a2e7a7b --- /dev/null +++ b/server/data/constants.js @@ -0,0 +1,3 @@ +const UA = "PeerCortex/0.5.0 (+https://peercortex.org)"; + +module.exports = { UA }; diff --git a/server/data/ix-city-map.js b/server/data/ix-city-map.js new file mode 100644 index 0000000..74856ac --- /dev/null +++ b/server/data/ix-city-map.js @@ -0,0 +1,4 @@ +// Small ASN/IX-id → city fallback map used by the /api/lookup facility geocoder +const IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" }; + +module.exports = { IX_CITY_MAP }; diff --git a/server/data/rir-country-codes.js b/server/data/rir-country-codes.js new file mode 100644 index 0000000..28a825a --- /dev/null +++ b/server/data/rir-country-codes.js @@ -0,0 +1,6 @@ +const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]); +const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]); +const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]); +const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]); + +module.exports = { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC }; diff --git a/server/data/rir-delegation-urls.js b/server/data/rir-delegation-urls.js new file mode 100644 index 0000000..44160db --- /dev/null +++ b/server/data/rir-delegation-urls.js @@ -0,0 +1,9 @@ +const RIR_DELEGATION_URLS = { + RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest', + ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', + APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest', + AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest', + LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest', +}; + +module.exports = { RIR_DELEGATION_URLS }; diff --git a/server/data/tier1-asns.js b/server/data/tier1-asns.js new file mode 100644 index 0000000..21f1fac --- /dev/null +++ b/server/data/tier1-asns.js @@ -0,0 +1,26 @@ +// Tier-1 ASN Set (used for route leak heuristics) +const TIER1_ASNS = new Set([ + 174, // Cogent + 209, // CenturyLink/Lumen + 286, // KPN + 701, // Verizon/UUNET + 702, // Verizon + 1239, // Sprint + 1273, // Vodafone + 1280, // Internet Systems Consortium + 1299, // Arelion (Telia) + 2914, // NTT + 3257, // GTT + 3320, // Deutsche Telekom + 3356, // Lumen (Level3) + 3491, // PCCW + 5511, // Orange + 6453, // TATA + 6461, // Zayo + 6762, // Telecom Italia Sparkle + 7018, // AT&T + 7473, // SingTel + 12956, // Telxius +]); + +module.exports = { TIER1_ASNS }; diff --git a/server/resilience-score.js b/server/resilience-score.js new file mode 100644 index 0000000..a31ae35 --- /dev/null +++ b/server/resilience-score.js @@ -0,0 +1,75 @@ +// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25% +// Hard cap at 5.0 when single transit provider detected. +// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. +function computeResilienceScore(upstreams, peers, ixConnections, prefixes) { + const upstreamCount = upstreams.length; + const peerCount = peers.length; + const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length; + const prefixCount = prefixes.length; + + // Transit Diversity (0-10) + let transitRaw = 0; + if (upstreamCount === 0) transitRaw = 0; + else if (upstreamCount === 1) transitRaw = 2; + else if (upstreamCount === 2) transitRaw = 5; + else if (upstreamCount <= 4) transitRaw = 7; + else transitRaw = 10; + + // Peering Breadth (0-10) + let peeringRaw = 0; + if (peerCount >= 100) peeringRaw = 10; + else if (peerCount >= 50) peeringRaw = 8; + else if (peerCount >= 20) peeringRaw = 6; + else if (peerCount >= 5) peeringRaw = 4; + else if (peerCount >= 1) peeringRaw = 2; + + // IXP Presence (0-10) + let ixpRaw = 0; + if (ixCount >= 10) ixpRaw = 10; + else if (ixCount >= 6) ixpRaw = 8; + else if (ixCount >= 3) ixpRaw = 6; + else if (ixCount >= 1) ixpRaw = 4; + + // Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination + let pathRaw = 0; + if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10; + else if (upstreamCount >= 2) pathRaw = 7; + else if (ixCount >= 2) pathRaw = 6; + else if (upstreamCount === 1) pathRaw = 3; + else if (prefixCount > 0) pathRaw = 1; + + const weighted = + transitRaw * 0.30 + + peeringRaw * 0.25 + + ixpRaw * 0.20 + + pathRaw * 0.25; + + const singleTransitCap = upstreamCount === 1; + let score = Math.round(weighted * 10) / 10; + if (singleTransitCap) score = Math.min(score, 5.0); + score = Math.max(1.0, Math.min(10.0, score)); + + // Only return null if truly no data at all + if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) { + return null; + } + + return { + score, + breakdown: { + transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount }, + peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount }, + ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount }, + path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount }, + }, + single_transit_cap_applied: singleTransitCap, + _provenance: { + source: "RIPE Stat asn-neighbours + PeeringDB netixlan", + validation: "cross-validated", + confidence: "high", + note: "All inputs independently validated daily against external sources", + }, + }; +} + +module.exports = { computeResilienceScore }; diff --git a/server/route-leak.js b/server/route-leak.js new file mode 100644 index 0000000..862d771 --- /dev/null +++ b/server/route-leak.js @@ -0,0 +1,54 @@ +const { TIER1_ASNS } = require("./data/tier1-asns"); + +// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data. +// NOT real-time. False positives possible for large networks with many Tier-1 relationships. +// Confidence: MEDIUM — pattern-based, not path-level analysis. +function computeRouteLeakDetection(upstreams, downstreams, peers) { + const upstreamAsns = new Set(upstreams.map(n => n.asn)); + const downstreamAsns = new Set(downstreams.map(n => n.asn)); + + const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn)); + const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn)); + + const patterns = []; + + // Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate + const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn)); + sandwich.forEach(n => { + patterns.push({ + type: "sandwich_candidate", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`, + }); + }); + + // Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s) + tier1Downstreams.forEach(n => { + if (!upstreamAsns.has(n.asn)) { + patterns.push({ + type: "tier1_downstream", + asn: n.asn, + name: n.name, + description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`, + }); + } + }); + + const detected = patterns.length > 0; + + return { + detected, + patterns, + tier1_upstream_count: tier1Upstreams.length, + tier1_downstream_count: tier1Downstreams.length, + _provenance: { + source: "RIPE Stat asn-neighbours", + validation: "heuristic", + confidence: "medium", + note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.", + }, + }; +} + +module.exports = { computeRouteLeakDetection };