PeerCortex/bgp-hijack-monitor.js
Rene Fichtmueller 5554c1a53e feat: BGP Hijack Alerting + Webhooks (Feature 1)
- Deterministic Classification: MOAS/HIJACK/LEAK type detection
- Severity scoring: CRITICAL/HIGH/MEDIUM/LOW based on prefix length
- Optional Ollama enrichment (qwen2.5:3b) for CRITICAL only (5s timeout)
- PostgreSQL backend: hijack_events, webhook_subscriptions, webhook_deliveries
- HMAC-SHA256 webhook signing with exponential backoff retry
- Retry scheduler: node-cron job every 5 minutes
- 6 API endpoints: POST/GET/DELETE webhooks, test delivery, list/resolve hijacks
- 22 comprehensive tests (80%+ coverage)
- Zero external API costs (deterministic + local Ollama only)
2026-04-29 07:45:15 +02:00

200 lines
6.0 KiB
JavaScript

/**
* BGP Hijack Monitor
*
* Background job that monitors own ASNs for unexpected BGP origin changes.
* Runs periodically (every 6 hours via systemd timer) to detect potential hijacks.
*
* Usage:
* node bgp-hijack-monitor.js
*
* Environment Variables:
* - DB_HOST: PostgreSQL host (default: 192.168.178.82)
* - DB_PORT: PostgreSQL port (default: 5432)
* - DB_NAME: Database name (default: llm_gateway)
* - DB_USER: Database user (default: llm)
* - DB_PASSWORD: Database password (default: llm_secure_2026)
* - OWN_ASNS: Comma-separated list of ASNs to monitor (e.g., "13335,15169,32787")
* - FINDINGS_TABLE: Table to write findings to (default: findings)
*/
const pg = require('pg');
const pool = new pg.Pool({
host: process.env.DB_HOST || '192.168.178.82',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'llm_gateway',
user: process.env.DB_USER || 'llm',
password: process.env.DB_PASSWORD || 'llm_secure_2026',
max: 5,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 5000,
});
const OWN_ASNS = (process.env.OWN_ASNS || '13335,15169').split(',').map(a => a.trim());
const FINDINGS_TABLE = process.env.FINDINGS_TABLE || 'findings';
/**
* Get all prefixes announced by an ASN from BGP routes table
*/
async function getBaselinePrefixes(asn) {
const client = await pool.connect();
try {
const query = `
SELECT DISTINCT prefix, origin_asn
FROM bgp_routes
WHERE origin_asn = $1
LIMIT 1000
`;
const result = await client.query(query, [asn]);
return result.rows;
} finally {
client.release();
}
}
/**
* Get current BGP routes for a set of prefixes
*/
async function getCurrentRoutes(prefixes) {
if (prefixes.length === 0) return [];
const client = await pool.connect();
try {
const placeholders = prefixes.map((_, i) => `$${i + 1}`).join(',');
const query = `
SELECT prefix, origin_asn
FROM bgp_routes
WHERE prefix = ANY(ARRAY[${placeholders}]::CIDR[])
AND last_seen > NOW() - INTERVAL '1 hour'
`;
const result = await client.query(query, prefixes);
return result.rows;
} finally {
client.release();
}
}
/**
* Check for hijacks: unexpected origin ASNs for known prefixes
*/
async function checkForHijacks(asn) {
console.log(`[BGP Hijack Monitor] Checking ASN ${asn} for unexpected origin changes...`);
try {
// Get baseline (expected) prefixes for this ASN
const baseline = await getBaselinePrefixes(asn);
if (baseline.length === 0) {
console.log(`[BGP Hijack Monitor] No baseline prefixes found for ASN ${asn}`);
return;
}
console.log(`[BGP Hijack Monitor] Baseline: ${baseline.length} prefixes for AS${asn}`);
// Get current routes for these prefixes
const prefixList = baseline.map(r => r.prefix);
const current = await getCurrentRoutes(prefixList);
// Group current routes by prefix
const currentByPrefix = {};
current.forEach(r => {
if (!currentByPrefix[r.prefix]) {
currentByPrefix[r.prefix] = [];
}
currentByPrefix[r.prefix].push(r.origin_asn);
});
// Compare: detect unexpected origin ASNs
const hijackCandidates = [];
baseline.forEach(expectedRoute => {
const prefix = expectedRoute.prefix;
const expectedAsn = expectedRoute.origin_asn;
if (currentByPrefix[prefix]) {
const currentAsns = currentByPrefix[prefix];
// If the expected ASN is NOT in the current set of origin ASNs, it's a hijack
if (!currentAsns.includes(expectedAsn)) {
hijackCandidates.push({
prefix,
expectedAsn,
foundAsns: currentAsns,
});
}
// If there are MULTIPLE origin ASNs (not just the expected one), it's also suspicious (MOAS)
if (currentAsns.length > 1) {
hijackCandidates.push({
prefix,
expectedAsn,
foundAsns: currentAsns,
type: 'MOAS', // Multiple Origin ASNs
});
}
}
});
if (hijackCandidates.length > 0) {
console.log(`[BGP Hijack Monitor] ⚠️ DETECTED ${hijackCandidates.length} hijack candidates`);
// Log each finding to the findings table
const client = await pool.connect();
try {
for (const hijack of hijackCandidates) {
const description = hijack.type === 'MOAS'
? `MOAS detected: Multiple origin ASNs (${hijack.foundAsns.join(', ')}) announcing ${hijack.prefix}`
: `BGP Hijack: AS${hijack.expectedAsn} expected but AS${hijack.foundAsns.join(', AS')} found announcing ${hijack.prefix}`;
const findingQuery = `
INSERT INTO ${FINDINGS_TABLE}
(timestamp, source, severity, asn, description, details)
VALUES (NOW(), 'bgp_hijack_monitor', 'HIGH', $1, $2, $3)
ON CONFLICT DO NOTHING
`;
const details = JSON.stringify({
prefix: hijack.prefix,
expected_asn: hijack.expectedAsn,
found_asns: hijack.foundAsns,
type: hijack.type || 'HIJACK',
detected_at: new Date().toISOString(),
});
await client.query(findingQuery, [asn, description, details]);
}
} finally {
client.release();
}
} else {
console.log(`[BGP Hijack Monitor] ✓ No hijacks detected for AS${asn}`);
}
} catch (err) {
console.error(`[BGP Hijack Monitor] Error checking ASN ${asn}:`, err.message);
}
}
/**
* Main: Check all monitored ASNs
*/
async function main() {
console.log(`[BGP Hijack Monitor] Starting hijack detection for ASNs: ${OWN_ASNS.join(', ')}`);
for (const asn of OWN_ASNS) {
await checkForHijacks(asn);
}
console.log('[BGP Hijack Monitor] Hijack detection complete');
await pool.end();
process.exit(0);
}
// Error handling
process.on('error', (err) => {
console.error('[BGP Hijack Monitor] Fatal error:', err);
process.exit(1);
});
main().catch(err => {
console.error('[BGP Hijack Monitor] Main loop error:', err);
process.exit(1);
});