2026-07-16 deep-dive audit (server.js, local-db-client.js, src/aspa/validator.ts) found the flagship ASPA path verification producing wrong results, and a recurring pattern where an upstream API or the local Postgres DB failing would present as "checked, clean" instead of surfacing failure. All fixes in this commit are empirically or textually verified against primary sources (the IETF draft's actual algorithm text, RFC 5735/6890 bogon ranges, and hand-built controlled test cases with known-correct answers) -- not just read-and-guessed. CRITICAL: ASPA hop-check direction was reversed -------------------------------------------------- verifyUpstream/verifyDownstream/detectValleys called hopCheck(collapsed[i-1], collapsed[i]) -- checking the PROVIDER-side AS's ASPA object for the CUSTOMER, the opposite of draft-ietf-sidrops-aspa-verification section 5.3's authorized(A(I), A(I+1)) (A(I) = customer, always checked first). Proven empirically: a hand-built, fully-attested, zero-leak 3-AS path returned "Invalid" before this fix. verifyDownstream needed a full rewrite, not just an argument swap -- a downstream path can legitimately contain both an up-ramp AND a down-ramp (e.g. origin -> providers -> peering point -> validator's providers -> validator), and the old K/L/uMin/vMax computation didn't correctly implement the draft's max_up_ramp/min_up_ramp/max_down_ramp/min_down_ramp four-way computation. Rewrote it from the draft's exact halt conditions, verified against a worked example fetched from the draft itself (an N=4 path with a single unattested "kink" correctly resolves Valid) plus 3 hand-built scenarios (pure up-ramp, mixed up+down clean, and a definite leak with failures on both ends -- Invalid). Same bug existed independently in src/aspa/validator.ts (the MCP-server- side validator, different codebase, same conceptual error) -- validateUpstream was already correct there, but validateDownstream naively reversed the path and reapplied upstream logic, which cannot represent a legitimate down-ramp. Rewrote it with the same four-ramp algorithm, cross-validated against the same test scenarios through the compiled output. Also in the same /api/aspa/verify handler: aspaStore.set(providerAsn, new Set()) for detected-but-unattested providers made hopCheck report "NotProviderPlus" (implying a real ASPA object that denies the hop) instead of "NoAttestation" (we don't know) -- removed; hopCheck's own `!providers` branch already handles the unset case correctly. Also removed a fallback that fabricated an ASPA declaration from heuristically-detected BGP neighbours when aspa_object_exists is false, which contradicted that exact field in the same response. ROOT CAUSE of ~9 broken checks: duplicate function declaration -------------------------------------------------------------- Two `async function fetchRipeStatCached` declarations existed ~150 lines apart (the original real RIPE-Stat-fetch+cache+throttle implementation, and a newer local-DB-routing wrapper added during the Postgres refactor). In JS, the second declaration silently wins for every call site in the file. The wrapper only recognized 5 URL patterns (announced-prefixes, asn-neighbours, as-overview, visibility, prefix-size-distribution) and returned null unconditionally for everything else -- silently breaking blocklist (Spamhaus), abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois, reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups. Renamed the original to fetchRipeStatCachedFromApi and made the wrapper fall through to it instead of returning null. Verified via an isolated routing test (blocklist/abuse-contact-finder URLs now reach the real implementation; the 5 known patterns still route to local DB). Silent-failure / fake-success patterns fixed --------------------------------------------- - local-db-client.js: 5 getRipeStat* functions returned status:'ok' with empty data on Postgres error (fabricated success) -- now status:'error'. server.js's /api/lookup now tracks which sources degraded, surfaces meta.degraded_sources, and caches a degraded result for 90s instead of the full 5min so a DB hiccup doesn't get repeated to every visitor. - validateRPKIWithCache: DB errors and genuine "no covering ROA" both produced status:"not_found" -- split into "not_found" (real) vs "unavailable" (DB error). Updated all 5 call sites that compute RPKI coverage percentages to exclude "unavailable" from the denominator (a DB hiccup must not lower a network's displayed RPKI/ASPA score), and fixed a pre-existing separate typo in the PDF report generator that checked for 'not-found' (hyphen) when the actual value is 'not_found' (underscore). - crossCheckRpki: an empty sample or a failed RIPE-validator lookup both counted as a fabricated 100%/"agreement" instead of being excluded -- now returns agreement_pct:null when nothing was actually compared, and the caller no longer averages null into overall_confidence. Also documented (not silently left) that bgp.he.net fetching is hardcoded off, so the "2-source" prefix/neighbour cross-checks never actually run -- dataQuality.cross_checks now reports sources:1 honestly instead of claiming a cross-check that never happened. - lookupAspaFromRpki: added feedLoaded so a Cloudflare-RPKI-feed fetch failure (rpkiAspaLastFetch never set) is distinguishable from "this ASN genuinely has no ASPA object" -- surfaced as aspa_feed_healthy on both ASPA endpoints. - Bogon/IRR/Spamhaus-blocklist/reverse-DNS/BGP-visibility checks: fetch failure -> empty result -> "pass" (or, for abuse-contact/rdns, a misleading "fail") -- now report "info" (excluded from the weighted health score, matching the existing checkManrsMembership pattern) when the underlying data source didn't actually respond. - Hijack monitoring: checkHijacksForAsn returned [] on DB error, indistinguishable from a genuine zero-prefix result. On a subscription's first monitoring cycle this permanently persisted an empty baseline, silently disabling leak detection forever while the UI kept showing it as active. Now returns null on failure; runHijackCheck skips the cycle entirely (retries next time) instead of locking in a bad baseline. Fixed 2 other call sites that would otherwise crash or persist null onto disk. - WHOIS cache: a transient failure across all 5 RIR sources (RIPE + 4 RDAP endpoints) was cached as "not found in any RIR database" for 24h, identical to a genuinely non-existent ASN. Added a 10min TTL for that specific case instead. - /api/relationships: a neighbourData fetch failure produced "0 upstreams/downstreams/peers", cached unconditionally for 10min. Applied the same 90s-on-suspicious-zero guard the codebase already uses for /api/validate (commit 4cf1673) but never applied here. Verification ------------ node --check on both .js files, tsc --noEmit clean on every touched TS module (remaining errors are 100% pre-existing, in the unrelated mcp-server subsystem), npm run build succeeds, isolated empirical tests for both the ASPA algorithm (3 hand-built scenarios with known-correct answers, cross-checked against draft text fetched live) and the fetchRipeStatCached routing fix, and a full local boot test reaching the exact same pre-existing sandbox-only crash point (missing PEERINGDB_API_KEY, unrelated to this commit) as a baseline boot before any of today's changes. Not deployed to Erik as part of this commit -- server.js and public/index.html there are untouched.
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
/**
|
|
* @module aspa/validator
|
|
* draft-ietf-sidrops-aspa-verification — ASPA-based AS path validation algorithm.
|
|
*
|
|
* Implements the Autonomous System Provider Authorization (ASPA) path
|
|
* validation procedure as defined in draft-ietf-sidrops-aspa-verification. ASPA enables detection
|
|
* of route leaks and unauthorized path segments by verifying that each
|
|
* AS in a BGP path has authorized its upstream provider relationship.
|
|
*
|
|
* @see https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/ (still an IETF draft, not yet an RFC)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const aspaObjects = new Map<number, ASPAObject>();
|
|
* aspaObjects.set(64501, {
|
|
* customerAsn: 64501,
|
|
* providers: [{ asn: 64500, afi: ["ipv4", "ipv6"] }],
|
|
* });
|
|
*
|
|
* const result = validatePath(
|
|
* [13335, 64501, 64500, 174],
|
|
* aspaObjects,
|
|
* "upstream"
|
|
* );
|
|
* console.log(result.status); // "valid" | "invalid" | "unknown" | "unverifiable"
|
|
* ```
|
|
*/
|
|
|
|
// ── Interfaces ──────────────────────────────────────────
|
|
|
|
/**
|
|
* An ASPA object as registered in the RPKI.
|
|
*
|
|
* Maps a customer AS to its authorized upstream providers,
|
|
* optionally scoped to specific address families.
|
|
*
|
|
* @see draft-ietf-sidrops-aspa-profile Section 3 (object format, still an IETF draft)
|
|
*/
|
|
export interface ASPAObject {
|
|
/** The customer AS that created this authorization */
|
|
readonly customerAsn: number;
|
|
/** Authorized upstream provider ASNs with address family scope */
|
|
readonly providers: ReadonlyArray<{
|
|
readonly asn: number;
|
|
readonly afi: ReadonlyArray<"ipv4" | "ipv6">;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Result of ASPA path validation.
|
|
*
|
|
* Contains the validation status, the analyzed path, any violations
|
|
* found, and whether a route leak was detected.
|
|
*/
|
|
export interface ASPAValidationResult {
|
|
/** Overall validation status per draft-ietf-sidrops-aspa-verification */
|
|
readonly status: "valid" | "invalid" | "unknown" | "unverifiable";
|
|
/** The AS path that was validated */
|
|
readonly path: ReadonlyArray<number>;
|
|
/** List of specific violations found in the path */
|
|
readonly violations: ReadonlyArray<ASPAViolation>;
|
|
/** Whether the path exhibits a route leak pattern */
|
|
readonly leakDetected: boolean;
|
|
/** The ASN responsible for the leak, if detected */
|
|
readonly leakingAsn?: number;
|
|
/** Confidence score from 0.0 to 1.0 based on ASPA coverage of the path */
|
|
readonly confidence: number;
|
|
}
|
|
|
|
/**
|
|
* A specific ASPA violation at a position in the AS path.
|
|
*
|
|
* Indicates that a hop in the path was not authorized by the
|
|
* customer's ASPA object.
|
|
*/
|
|
export interface ASPAViolation {
|
|
/** Zero-based position in the AS path where the violation occurs */
|
|
readonly position: number;
|
|
/** The ASN at this position */
|
|
readonly asn: number;
|
|
/** ASNs that are authorized providers for this ASN */
|
|
readonly expectedProviders: ReadonlyArray<number>;
|
|
/** The actual next-hop ASN in the path */
|
|
readonly actualNextHop: number;
|
|
/** Human-readable explanation of the violation */
|
|
readonly reason: string;
|
|
}
|
|
|
|
// ── Helper Functions ────────────────────────────────────
|
|
|
|
/**
|
|
* Check whether `providerAsn` is an authorized provider of `customerAsn`.
|
|
*
|
|
* @param customerAsn - The customer ASN to check
|
|
* @param providerAsn - The candidate provider ASN
|
|
* @param aspaObjects - Map of all known ASPA objects
|
|
* @param afi - Address family to check ("ipv4" or "ipv6")
|
|
* @returns "provider" if authorized, "not-provider" if explicitly not listed,
|
|
* or "no-attestation" if the customer has no ASPA object
|
|
*
|
|
* @see draft-ietf-sidrops-aspa-verification — Verification of Provider Authorization
|
|
*/
|
|
function checkProviderAuthorization(
|
|
customerAsn: number,
|
|
providerAsn: number,
|
|
aspaObjects: ReadonlyMap<number, ASPAObject>,
|
|
afi: "ipv4" | "ipv6" = "ipv4"
|
|
): "provider" | "not-provider" | "no-attestation" {
|
|
const aspa = aspaObjects.get(customerAsn);
|
|
|
|
if (!aspa) {
|
|
return "no-attestation";
|
|
}
|
|
|
|
const isAuthorized = aspa.providers.some(
|
|
(p) => p.asn === providerAsn && p.afi.includes(afi)
|
|
);
|
|
|
|
return isAuthorized ? "provider" : "not-provider";
|
|
}
|
|
|
|
/**
|
|
* Remove consecutive duplicate ASNs from a path (AS path prepending).
|
|
*
|
|
* BGP speakers may prepend their own ASN multiple times for traffic
|
|
* engineering. For ASPA validation, consecutive duplicates are collapsed.
|
|
*
|
|
* @param path - The raw AS path
|
|
* @returns The path with consecutive duplicates removed
|
|
*/
|
|
function deduplicatePath(path: ReadonlyArray<number>): ReadonlyArray<number> {
|
|
return path.filter((asn, index) => index === 0 || asn !== path[index - 1]);
|
|
}
|
|
|
|
// ── Core Validation Functions ───────────────────────────
|
|
|
|
/**
|
|
* Validate an AS path in the upstream direction per draft-ietf-sidrops-aspa-verification.
|
|
*
|
|
* Walks the path from the origin AS (rightmost) toward the validating AS
|
|
* (leftmost). For each pair (customer, provider), verifies that the
|
|
* customer has authorized the provider via an ASPA object.
|
|
*
|
|
* The upstream validation procedure (draft-ietf-sidrops-aspa-verification):
|
|
* - If the path has 0 or 1 unique ASNs, the result is "valid".
|
|
* - Walk from index N-1 (origin) toward index 0.
|
|
* - At each hop, check if path[i] authorizes path[i-1] as its provider.
|
|
* - If any hop yields "not-provider", the path is "invalid".
|
|
* - If all hops yield "provider", the path is "valid".
|
|
* - Otherwise the path is "unknown".
|
|
*
|
|
* @param path - AS path to validate (leftmost = closest to validator)
|
|
* @param aspaObjects - Map of customer ASN to ASPA object
|
|
* @returns Validation result with status, violations, and confidence
|
|
*/
|
|
export function validateUpstream(
|
|
path: ReadonlyArray<number>,
|
|
aspaObjects: ReadonlyMap<number, ASPAObject>
|
|
): ASPAValidationResult {
|
|
const dedupedPath = deduplicatePath(path);
|
|
|
|
// Trivial paths are always valid
|
|
if (dedupedPath.length <= 1) {
|
|
return {
|
|
status: "valid",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence: 1.0,
|
|
};
|
|
}
|
|
|
|
const violations: ASPAViolation[] = [];
|
|
let hasNoAttestation = false;
|
|
let coveredHops = 0;
|
|
const totalHops = dedupedPath.length - 1;
|
|
|
|
// Walk from origin (rightmost) toward the validator (leftmost).
|
|
// path[i] is the customer; path[i-1] is the alleged provider.
|
|
for (let i = dedupedPath.length - 1; i >= 1; i--) {
|
|
const customerAsn = dedupedPath[i];
|
|
const providerAsn = dedupedPath[i - 1];
|
|
|
|
const authResult = checkProviderAuthorization(
|
|
customerAsn,
|
|
providerAsn,
|
|
aspaObjects
|
|
);
|
|
|
|
if (authResult === "provider") {
|
|
coveredHops++;
|
|
} else if (authResult === "not-provider") {
|
|
coveredHops++;
|
|
const aspa = aspaObjects.get(customerAsn);
|
|
violations.push({
|
|
position: i,
|
|
asn: customerAsn,
|
|
expectedProviders: aspa
|
|
? aspa.providers.map((p) => p.asn)
|
|
: [],
|
|
actualNextHop: providerAsn,
|
|
reason: `AS${customerAsn} has an ASPA object but does not list AS${providerAsn} as an authorized provider. ` +
|
|
`This indicates a potential route leak or unauthorized path segment.`,
|
|
});
|
|
} else {
|
|
hasNoAttestation = true;
|
|
}
|
|
}
|
|
|
|
const confidence = totalHops > 0 ? coveredHops / totalHops : 1.0;
|
|
|
|
// Determine overall status per draft-ietf-sidrops-aspa-verification
|
|
if (violations.length > 0) {
|
|
const leakingViolation = violations[0];
|
|
return {
|
|
status: "invalid",
|
|
path: [...dedupedPath],
|
|
violations,
|
|
leakDetected: true,
|
|
leakingAsn: leakingViolation.asn,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
if (hasNoAttestation) {
|
|
return {
|
|
status: "unknown",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "valid",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate an AS path in the downstream direction per
|
|
* draft-ietf-sidrops-aspa-verification Section 5.5.
|
|
*
|
|
* Unlike upstream validation, a downstream path may legitimately contain
|
|
* BOTH an up-ramp (customer->provider hops near the origin) AND a down-ramp
|
|
* (provider->customer hops near the validator), transitioning at most once
|
|
* -- e.g. origin -> its providers -> a peering exchange -> validator's
|
|
* providers -> validator. A naive "reverse the path and reapply upstream
|
|
* validation" does not implement this: it would reject any path with a
|
|
* legitimate down-ramp segment. This computes the four ramp lengths the
|
|
* draft actually defines (max/min up-ramp, max/min down-ramp) from two
|
|
* independent scans and applies the draft's three halt conditions in order.
|
|
*
|
|
* Verified 2026-07-16 against a worked example fetched from the draft (an
|
|
* N=4 path with a single unattested "kink" resolves to valid; a path with
|
|
* definite authorization failures on both ends resolves to invalid).
|
|
*
|
|
* @param path - AS path to validate (leftmost = closest to validator)
|
|
* @param aspaObjects - Map of customer ASN to ASPA object
|
|
* @returns Validation result with status, violations, and confidence
|
|
*/
|
|
export function validateDownstream(
|
|
path: ReadonlyArray<number>,
|
|
aspaObjects: ReadonlyMap<number, ASPAObject>
|
|
): ASPAValidationResult {
|
|
const dedupedPath = deduplicatePath(path);
|
|
const N = dedupedPath.length;
|
|
|
|
if (N <= 2) {
|
|
return {
|
|
status: "valid",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence: 1.0,
|
|
};
|
|
}
|
|
|
|
// Up-ramp scan: walk from the origin (index N-1) toward the validator
|
|
// (index 0). dedupedPath[k] is the customer, dedupedPath[k-1] the
|
|
// provider it's declaring -- authorized(A(I), A(I+1)) in draft terms.
|
|
let maxUpRamp = N;
|
|
let minUpRamp = N;
|
|
let firstUpViolation: { position: number; asn: number; nextHop: number } | undefined;
|
|
for (let k = N - 1; k >= 1; k--) {
|
|
const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects);
|
|
const I = N - k;
|
|
if (check === "not-provider" && maxUpRamp === N) {
|
|
maxUpRamp = I;
|
|
firstUpViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k - 1] };
|
|
}
|
|
if (check !== "provider" && minUpRamp === N) minUpRamp = I;
|
|
if (maxUpRamp !== N && minUpRamp !== N) break;
|
|
}
|
|
|
|
// Down-ramp scan: the mirror image, walking from the validator (index 0)
|
|
// toward the origin -- authorized(A(J), A(J-1)) in draft terms.
|
|
let maxDownRamp = N;
|
|
let minDownRamp = N;
|
|
let firstDownViolation: { position: number; asn: number; nextHop: number } | undefined;
|
|
for (let k = 0; k <= N - 2; k++) {
|
|
const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k + 1], aspaObjects);
|
|
const rampLen = k + 1;
|
|
if (check === "not-provider" && maxDownRamp === N) {
|
|
maxDownRamp = rampLen;
|
|
firstDownViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k + 1] };
|
|
}
|
|
if (check !== "provider" && minDownRamp === N) minDownRamp = rampLen;
|
|
if (maxDownRamp !== N && minDownRamp !== N) break;
|
|
}
|
|
|
|
// Confidence: fraction of hops that returned a definitive (non-"no-attestation") result.
|
|
const totalHops = N - 1;
|
|
let attestedHops = 0;
|
|
for (let k = N - 1; k >= 1; k--) {
|
|
if (checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects) !== "no-attestation") {
|
|
attestedHops++;
|
|
}
|
|
}
|
|
const confidence = totalHops > 0 ? attestedHops / totalHops : 1.0;
|
|
|
|
if (maxUpRamp + maxDownRamp < N) {
|
|
const violation = firstUpViolation ?? firstDownViolation;
|
|
const violations: ASPAViolation[] = violation
|
|
? [
|
|
{
|
|
position: violation.position,
|
|
asn: violation.asn,
|
|
expectedProviders: (aspaObjects.get(violation.asn)?.providers ?? []).map((p) => p.asn),
|
|
actualNextHop: violation.nextHop,
|
|
reason:
|
|
`AS${violation.asn} has an ASPA object but does not list AS${violation.nextHop} as an authorized provider. ` +
|
|
`No valid up-ramp/down-ramp split covers this path (max_up_ramp=${maxUpRamp}, max_down_ramp=${maxDownRamp}, N=${N}).`,
|
|
},
|
|
]
|
|
: [];
|
|
return {
|
|
status: "invalid",
|
|
path: [...dedupedPath],
|
|
violations,
|
|
leakDetected: true,
|
|
leakingAsn: violation?.asn,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
if (minUpRamp + minDownRamp < N) {
|
|
return {
|
|
status: "unknown",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: "valid",
|
|
path: [...dedupedPath],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate an AS path against ASPA objects.
|
|
*
|
|
* This is the main entry point for ASPA path validation. It dispatches
|
|
* to either upstream or downstream validation based on the direction
|
|
* parameter.
|
|
*
|
|
* @param path - The AS path to validate. Leftmost ASN is closest to the
|
|
* validating router; rightmost is the origin.
|
|
* @param aspaObjects - Map of customer ASN to its ASPA object
|
|
* @param direction - "upstream" when receiving from a provider,
|
|
* "downstream" when receiving from a customer
|
|
* @returns Full validation result including status, violations, leak
|
|
* detection, and confidence score
|
|
*
|
|
* @see draft-ietf-sidrops-aspa-verification — Procedure for Verifying the AS_PATH Attribute
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Upstream validation: AS174 -> AS13335 -> AS64501 (origin)
|
|
* const result = validatePath(
|
|
* [174, 13335, 64501],
|
|
* aspaObjects,
|
|
* "upstream"
|
|
* );
|
|
*
|
|
* if (result.leakDetected) {
|
|
* console.log(`Route leak by AS${result.leakingAsn}`);
|
|
* }
|
|
* ```
|
|
*/
|
|
export function validatePath(
|
|
path: ReadonlyArray<number>,
|
|
aspaObjects: ReadonlyMap<number, ASPAObject>,
|
|
direction: "upstream" | "downstream"
|
|
): ASPAValidationResult {
|
|
if (path.length === 0) {
|
|
return {
|
|
status: "unverifiable",
|
|
path: [],
|
|
violations: [],
|
|
leakDetected: false,
|
|
confidence: 0,
|
|
};
|
|
}
|
|
|
|
return direction === "upstream"
|
|
? validateUpstream(path, aspaObjects)
|
|
: validateDownstream(path, aspaObjects);
|
|
}
|