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

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

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

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

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

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

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

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

Not deployed to Erik as part of this commit -- server.js and
public/index.html there are untouched.
2026-07-16 15:43:49 +02:00

438 lines
14 KiB
JavaScript

/**
* Local Database Client for PeerCortex
* Replaces external API calls with local PostgreSQL queries
* BGP + RPKI + Threat Intel + RDAP caching
*/
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER || 'llm',
password: process.env.DB_PASSWORD || 'llm_secure_2026',
host: process.env.DB_HOST || '192.168.178.82',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'llm_gateway',
});
// RDAP Cache (in-memory for this session)
const rdapCache = new Map();
const RDAP_CACHE_TTL = 3600000; // 1 hour
// ══════════════════════════════════════════════════════════════
// BGP FUNCTIONS
// ══════════════════════════════════════════════════════════════
async function getBgpStatus(prefix) {
try {
const result = await pool.query(
`SELECT DISTINCT origin_asn, MAX(visibility_percent) as visibility_percent, MAX(last_seen) as last_seen
FROM bgp_routes
WHERE prefix = $1::cidr
GROUP BY origin_asn`,
[prefix]
);
if (result.rows.length === 0) {
return {
announced: false,
origin_asns: [],
visibility_percent: 0,
last_seen: new Date().toISOString(),
source: 'local_bgp',
};
}
return {
announced: true,
origin_asns: result.rows.map(r => r.origin_asn),
visibility_percent: Math.max(...result.rows.map(r => parseFloat(r.visibility_percent) || 0)),
last_seen: result.rows[0].last_seen || new Date().toISOString(),
source: 'local_bgp',
};
} catch (error) {
console.error('[Local DB] BGP Status Error:', error.message);
return null;
}
}
async function getAnnouncedPrefixes(asn) {
try {
const result = await pool.query(
`SELECT prefix, origin_asn, visibility_percent, last_seen
FROM bgp_routes
WHERE origin_asn = $1
ORDER BY visibility_percent DESC
LIMIT 100`,
[asn]
);
return result.rows;
} catch (error) {
console.error('[Local DB] Announced Prefixes Error:', error.message);
return [];
}
}
// Returns null on DB error (check could not run) vs [] for a genuine single/no-origin
// result -- a caller treating both as "no hijack" would silently disable hijack
// detection on every transient DB hiccup while still showing the check as clean.
async function checkBgpHijack(prefix) {
try {
const result = await pool.query(
`SELECT DISTINCT origin_asn FROM bgp_routes WHERE prefix = $1::cidr`,
[prefix]
);
return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
} catch (error) {
console.error('[Local DB] Hijack Check Error:', error.message);
return null;
}
}
// ══════════════════════════════════════════════════════════════
// RPKI FUNCTIONS
// ══════════════════════════════════════════════════════════════
async function validateRpki(prefix, originAsn) {
try {
const prefixParts = prefix.split('/');
if (prefixParts.length !== 2) {
return { status: 'unknown', description: 'Invalid CIDR format' };
}
const prefixLength = parseInt(prefixParts[1]);
// Query for covering ROAs
const result = await pool.query(
`SELECT * FROM rpki_roas
WHERE $1::cidr << (prefix || '/' || max_length)::cidr
AND origin_asn = $2
AND expires > NOW()
LIMIT 10`,
[prefix, originAsn]
);
if (result.rows.length === 0) {
const anyRoa = await pool.query(
`SELECT 1 FROM rpki_roas WHERE $1::cidr << prefix AND expires > NOW() LIMIT 1`,
[prefix]
);
if (anyRoa.rows.length > 0) {
return {
status: 'invalid',
prefix,
asn: originAsn,
description: `RPKI INVALID: ROAs exist but origin ASN ${originAsn} not authorized`,
};
}
return {
status: 'not-found',
prefix,
asn: originAsn,
description: 'No matching ROA found (unprotected)',
};
}
const roa = result.rows[0];
if (prefixLength > roa.max_length) {
return {
status: 'invalid',
prefix,
asn: originAsn,
max_length: roa.max_length,
description: `RPKI INVALID: Prefix length ${prefixLength} > max_length ${roa.max_length}`,
};
}
return {
status: 'valid',
prefix,
asn: originAsn,
max_length: roa.max_length,
expires: roa.expires,
description: `RPKI VALID: Origin ASN ${originAsn} authorized`,
};
} catch (error) {
console.error('[Local DB] RPKI Validation Error:', error.message);
return { status: 'unknown', description: 'RPKI validation error' };
}
}
async function getRoasForAsn(asn) {
try {
const result = await pool.query(
`SELECT prefix, max_length, expires FROM rpki_roas
WHERE origin_asn = $1 AND expires > NOW()
ORDER BY prefix`,
[asn]
);
return result.rows;
} catch (error) {
console.error('[Local DB] ROAs for ASN Error:', error.message);
return [];
}
}
// ══════════════════════════════════════════════════════════════
// THREAT INTEL FUNCTIONS
// ══════════════════════════════════════════════════════════════
async function getThreatIntel(ip) {
try {
const result = await pool.query(
`SELECT ip_address, threat_level, confidence_score, source, details, cached_at
FROM threat_intel
WHERE ip_address = $1::inet
AND expires_at > NOW()
LIMIT 1`,
[ip]
);
return result.rows.length > 0 ? result.rows[0] : null;
} catch (error) {
console.error('[Local DB] Threat Intel Error:', error.message);
return null;
}
}
async function isMaliciousIp(ip) {
try {
const result = await pool.query(
`SELECT 1 FROM threat_intel
WHERE ip_address = $1::inet
AND threat_level IN ('CRITICAL', 'HIGH')
AND expires_at > NOW()
LIMIT 1`,
[ip]
);
return result.rows.length > 0;
} catch (error) {
console.error('[Local DB] Malicious IP Check Error:', error.message);
return false;
}
}
// ══════════════════════════════════════════════════════════════
// RDAP CACHING (in-memory)
// ══════════════════════════════════════════════════════════════
function getRdapCached(resource) {
const cached = rdapCache.get(resource);
if (cached && Date.now() - cached.timestamp < RDAP_CACHE_TTL) {
console.log(`[RDAP Cache] HIT: ${resource}`);
return cached.data;
}
if (cached) rdapCache.delete(resource);
return null;
}
function setRdapCached(resource, data) {
rdapCache.set(resource, { data, timestamp: Date.now() });
console.log(`[RDAP Cache] SET: ${resource} (TTL: 1h)`);
}
// ══════════════════════════════════════════════════════════════
// RIPE STAT API WRAPPER (Drop-in replacements for external API calls)
// ══════════════════════════════════════════════════════════════
async function getRipeStatAnnouncedPrefixes(asn) {
try {
const prefixes = await getAnnouncedPrefixes(asn);
return {
status: 'ok',
cached: false,
data: {
resource: `AS${asn}`,
prefixes: prefixes.map(p => ({
prefix: p.prefix,
origin_asn: p.origin_asn,
})),
},
};
} catch (error) {
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message);
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, prefixes: [] } };
}
}
async function getRipeStatAsnNeighbours(asn) {
try {
const result = await pool.query(
`SELECT DISTINCT origin_asn FROM bgp_routes LIMIT 200`
);
const neighbours = result.rows.map(r => ({
asn: r.origin_asn,
type: 'unknown',
prefixes: 0,
}));
return {
status: 'ok',
cached: false,
data: {
resource: `AS${asn}`,
neighbours: neighbours,
},
};
} catch (error) {
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message);
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, neighbours: [] } };
}
}
async function getRipeStatAsOverview(asn) {
try {
const prefixes = await getAnnouncedPrefixes(asn);
const roasCount = await pool.query(
`SELECT COUNT(*) as count FROM rpki_roas WHERE origin_asn = $1 AND expires > NOW()`,
[asn]
);
return {
status: 'ok',
cached: false,
data: {
resource: `AS${asn}`,
asn: asn,
holder: 'Unknown',
announced_prefixes_count: prefixes.length,
description: [{ descr: 'Local Database ASN Overview' }],
type: 'asn',
rpki_status: roasCount.rows[0].count > 0 ? 'signed' : 'not-signed',
},
};
} catch (error) {
console.error('[Local RIPE Stat] AS Overview Error:', error.message);
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
}
}
async function getRipeStatVisibility(asn) {
try {
const result = await pool.query(
`SELECT COALESCE(AVG(visibility_percent), 0) as avg_visibility
FROM bgp_routes
WHERE origin_asn = $1`,
[asn]
);
return {
status: 'ok',
cached: false,
data: {
resource: `AS${asn}`,
visibility: {
ipv4: {
ris_peers_seeing: Math.round(result.rows[0].avg_visibility),
total_ris_peers: 100,
sees_ris_peers: Math.round(result.rows[0].avg_visibility),
},
ipv6: {
ris_peers_seeing: 0,
total_ris_peers: 100,
sees_ris_peers: 0,
},
},
},
};
} catch (error) {
console.error('[Local RIPE Stat] Visibility Error:', error.message);
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, visibility: {} } };
}
}
async function getRipeStatPrefixSizeDistribution(asn) {
try {
const result = await pool.query(
`SELECT masklen(prefix) as prefix_len
FROM bgp_routes
WHERE origin_asn = $1
ORDER BY masklen(prefix)`,
[asn]
);
const distribution = {};
result.rows.forEach(row => {
const len = row.prefix_len;
distribution[len] = (distribution[len] || 0) + 1;
});
return {
status: 'ok',
cached: false,
data: {
resource: `AS${asn}`,
ipv4_prefix_size: Object.keys(distribution)
.map(len => ({ prefix_length: parseInt(len), count: distribution[len] }))
.sort((a, b) => a.prefix_length - b.prefix_length),
ipv6_prefix_size: [],
},
};
} catch (error) {
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message);
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
}
}
// ══════════════════════════════════════════════════════════════
// STATS & HEALTH CHECK
// ══════════════════════════════════════════════════════════════
async function getLocalDbStats() {
try {
const bgp = await pool.query(`SELECT COUNT(*) as count FROM bgp_routes`);
const rpki = await pool.query(`SELECT COUNT(*) as count FROM rpki_roas WHERE expires > NOW()`);
const threat = await pool.query(`SELECT COUNT(*) as count FROM threat_intel WHERE expires_at > NOW()`);
return {
bgp_routes: parseInt(bgp.rows[0].count),
rpki_roas: parseInt(rpki.rows[0].count),
threat_intel: parseInt(threat.rows[0].count),
rdap_cache_entries: rdapCache.size,
};
} catch (error) {
console.error('[Local DB] Stats Error:', error.message);
return null;
}
}
async function cleanup() {
await pool.end();
}
// ══════════════════════════════════════════════════════════════
// EXPORTS
// ══════════════════════════════════════════════════════════════
module.exports = {
// BGP
getBgpStatus,
getAnnouncedPrefixes,
checkBgpHijack,
// RPKI
validateRpki,
getRoasForAsn,
// Threat Intel
getThreatIntel,
isMaliciousIp,
// RIPE Stat API Wrappers
getRipeStatAnnouncedPrefixes,
getRipeStatAsnNeighbours,
getRipeStatAsOverview,
getRipeStatVisibility,
getRipeStatPrefixSizeDistribution,
// RDAP Cache
getRdapCached,
setRdapCached,
// Health
getLocalDbStats,
cleanup,
};