refactor: extract Phase A modules from server.js (static data + pure logic)
Zero shared mutable state, no I/O -- behavior-preserving, verified via the smoke-test harness (28/28 endpoints match baseline). - server/data/*: CITY_COORDS, RIR_DELEGATION_URLS, TIER1_ASNS, IX_CITY_MAP, RIR country-code sets, UA constant - server/aspa-verification/engine.js: the RFC ASPA verification engine (hopCheck, verifyUpstream, verifyDownstream, detectValleys, etc.) - server/resilience-score.js, server/route-leak.js: pure scoring/heuristic functions server.js: 6838 -> 6454 lines. Part of the module-split plan at ~/.claude/plans/linked-riding-newell.md.
This commit is contained in:
parent
0e6fb4f2b8
commit
1ab6f54d10
420
server.js
420
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<number, Set<number>> (CAS -> provider set)
|
||||
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
||||
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
||||
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
||||
function hopCheck(asI, asJ, aspaStore) {
|
||||
const providers = aspaStore.get(asI);
|
||||
if (!providers) return "NoAttestation";
|
||||
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];
|
||||
|
||||
224
server/aspa-verification/engine.js
Normal file
224
server/aspa-verification/engine.js
Normal file
@ -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<number, Set<number>> (CAS -> provider set)
|
||||
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
||||
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
||||
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
||||
function hopCheck(asI, asJ, aspaStore) {
|
||||
const providers = aspaStore.get(asI);
|
||||
if (!providers) return "NoAttestation";
|
||||
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,
|
||||
};
|
||||
26
server/data/city-coords.js
Normal file
26
server/data/city-coords.js
Normal file
@ -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 };
|
||||
3
server/data/constants.js
Normal file
3
server/data/constants.js
Normal file
@ -0,0 +1,3 @@
|
||||
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
||||
|
||||
module.exports = { UA };
|
||||
4
server/data/ix-city-map.js
Normal file
4
server/data/ix-city-map.js
Normal file
@ -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 };
|
||||
6
server/data/rir-country-codes.js
Normal file
6
server/data/rir-country-codes.js
Normal file
@ -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 };
|
||||
9
server/data/rir-delegation-urls.js
Normal file
9
server/data/rir-delegation-urls.js
Normal file
@ -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 };
|
||||
26
server/data/tier1-asns.js
Normal file
26
server/data/tier1-asns.js
Normal file
@ -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 };
|
||||
75
server/resilience-score.js
Normal file
75
server/resilience-score.js
Normal file
@ -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 };
|
||||
54
server/route-leak.js
Normal file
54
server/route-leak.js
Normal file
@ -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 };
|
||||
Loading…
x
Reference in New Issue
Block a user