PeerCortex/audit/rotating_audit.py
Rene Fichtmueller 2766872aad snapshot: preserve production-only changes (Izzy PDF export, adoption tracker, audit scripts)
Working tree was 3+ months ahead of git in uncommitted local edits, sitting
on a detached HEAD at d3611a8 (2026-04-09), never reconciled with main.
Committing as-is on its own branch, without touching main or the detached
HEAD, to stop these files existing only on this one server:

- public/index.html + server.js: PDF export add-on gated to client Izzy,
  full Adoption Tracker overlay (ASPA + IPv6 tabs) -- neither exists
  anywhere in git history on any branch until now
- audit/: daily/rotating audit scripts + email report sender, previously
  untracked
- scripts/: tunnel-cleanup.sh, refresh-peeringdb.sh, previously untracked
- deploy-from-scp.sh, webhook-subs.json, hijack-alerts.json,
  aspa-adoption-history.json: previously untracked runtime/deploy state
- public/public/: duplicate mirror of the HTML variants found alongside
  the real public/ dir, preserved as found

No reconciliation with main attempted here -- that needs a deliberate pass,
not a side effect of a backup commit.
2026-07-16 06:42:03 +00:00

271 lines
12 KiB
Python

#!/usr/bin/env python3
"""
PeerCortex Rotating Daily Audit
- Pool of 500+ ASNs
- Each day: deterministic 100-ASN selection (seeded by date → reproducible, never same day twice)
- Each ASN: PeerCortex response cross-validated against RIPE Stat + PeeringDB externally
- Math checks: prefix sums, RPKI sums, IX dedup, facility counts
- Results saved to /var/log/peercortex/audit-YYYY-MM-DD.json
"""
import json, urllib.request, urllib.error, time, sys, os
from datetime import datetime, timezone
from hashlib import sha256
# ── ASN Pool (500+ diverse networks across all RIRs) ──────────────────────────
ASN_POOL = [
# Tier-1 / Large Transit
1, 174, 209, 286, 701, 702, 1239, 1273, 1280, 1299, 2914, 3257, 3320,
3356, 3491, 3549, 3561, 4134, 4637, 4657, 5511, 6453, 6461, 6762, 6830,
7018, 7473, 7922, 8220, 8928, 9002, 9304, 12956, 13030,
# Cloud / Hyperscaler
714, 2906, 8075, 13335, 15169, 16509, 16591, 20940, 21342, 32934, 36351,
54113, 136907, 14061, 19604, 63949, 22616,
# European ISPs
1257, 1853, 2119, 2603, 3209, 3215, 3216, 3233, 3243, 3269, 3292, 3301,
3303, 3308, 3327, 5089, 5390, 5432, 5483, 6057, 6128, 6147, 6695, 6728,
6830, 6939, 8220, 8359, 8400, 8422, 8447, 8468, 8473, 8551, 8708, 9121,
9145, 9158, 12714, 13237, 15435, 15547, 15600, 15692, 15830, 16276,
20562, 20932, 21202, 24940, 24961, 25091, 28716, 29049, 29208, 33891,
34465, 34549, 35369, 39122, 41327, 42, 42739, 43350, 47692, 48159,
50629, 51088, 58057, 197540, 199121,
# APNIC
2497, 2516, 4766, 7545, 9318, 9498, 17676, 18403, 23969, 24115, 38001,
45899, 55836, 132335, 136907, 137409, 139070, 4713, 9269, 17488, 23944,
38197, 45352, 63927, 131090, 133481, 134762, 137718, 149440,
# ARIN
701, 1239, 1280, 2828, 3549, 3561, 6128, 6939, 7018, 7922, 10429, 10796,
11427, 13977, 14080, 15133, 19281, 20115, 20325, 21928, 22773, 22822,
23106, 32934, 33070, 35911, 36351, 40191, 43996, 54113, 62240, 64271,
393398, 11404, 14618, 14593, 7046, 26101, 10310, 32590,
# LACNIC
6147, 8167, 10429, 10474, 11419, 11644, 14080, 18881, 22927, 23106,
28573, 52320, 53013, 61832, 262589, 263702,
# AFRINIC
10474, 12091, 23889, 24835, 30844, 36864, 36903, 37100, 37271, 37468,
55330, 327814,
# IXPs / Route Servers
6695, 24115, 34307, 42476, 43366, 47498, 56393,
# CDN / Hosting
2906, 8100, 13335, 15133, 16509, 20940, 22822, 24429, 46489, 54113,
60068, 60501, 62240, 64271, 132335,
# Security / DNS
19281, 15169, 13335, 714, 20473,
# Mixed / Small
29791, 34224, 35332, 39386, 41695, 42473, 44574, 47065, 48858, 50300,
56630, 57695, 58057, 59947, 60068, 60501, 63023, 64271, 132335,
# More European
1200, 2611, 3265, 5388, 5430, 5444, 6128, 8309, 8365, 8560, 8607,
9009, 12897, 13285, 15516, 16150, 20738, 21263, 24586, 24875, 25369,
28917, 29017, 30036, 30781, 31025, 31400, 33986, 34006, 34224, 34655,
35432, 39386, 39737, 41327, 41552, 42652, 43531, 44574, 47065,
# Telcos worldwide
2516, 3320, 3786, 4766, 4800, 5483, 7473, 8151, 8708, 9121, 9304,
9318, 9498, 10429, 12479, 12714, 15704, 16276, 17676, 18403, 22773,
]
# Deduplicate while preserving order
seen = set()
ASN_POOL_DEDUP = [x for x in ASN_POOL if not (x in seen or seen.add(x))]
def load_pool():
"""Load ASN pool from file, fall back to built-in list."""
pool_file = os.path.join(os.path.dirname(__file__), 'asn_pool.json')
try:
with open(pool_file) as f:
return json.load(f)
except:
return ASN_POOL_DEDUP
def select_daily_asns(date_str, count=100):
"""Deterministic daily selection — same date always gives same 100 ASNs."""
import random
pool = load_pool()
seed = int(sha256(date_str.encode()).hexdigest(), 16)
rng = random.Random(seed)
rng.shuffle(pool)
return sorted(pool[:count])
def fetch(url, timeout=12):
try:
req = urllib.request.Request(url, headers={"User-Agent": "PeerCortex-Audit/5.0"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
except:
return None
def fetch_pc(asn, timeout=20):
try:
with urllib.request.urlopen(f"http://localhost:3101/api/lookup?asn={asn}", timeout=timeout) as r:
return json.loads(r.read())
except:
return None
def audit_asn(asn):
issues = []
result = {"asn": asn, "status": "ok", "issues": [], "data": {}}
# ── 1. PeerCortex response ───────────────────────────────────────────────
d = fetch_pc(asn)
if not d or "network" not in d or d.get("error"):
result["status"] = "skip"
result["issues"].append(("SKIP", "no_response", "PeerCortex did not respond"))
return result
n = d.get("network", {})
p = d.get("prefixes", {})
r = d.get("rpki", {})
ix = d.get("ix_presence", {})
fac = d.get("facilities", {})
meta= d.get("meta", {})
result["data"] = {
"name": n.get("name"),
"rir": n.get("rir"),
"cc": n.get("country"),
"pfx_total": p.get("total", 0),
"rpki_pct": r.get("coverage_percent", 0),
"ix_cnt": ix.get("total_connections", 0),
"fac_cnt": fac.get("total", 0),
"version": meta.get("version"),
}
# ── 2. Math checks (internal consistency) ───────────────────────────────
if p.get("ipv4", 0) + p.get("ipv6", 0) != p.get("total", -1):
issues.append(("CRITICAL", "prefix_math",
f"v4({p.get('ipv4')})+v6({p.get('ipv6')}) ≠ total({p.get('total')})"))
rpki_sum = r.get("valid", 0) + r.get("invalid", 0) + r.get("not_found", 0)
if rpki_sum != r.get("checked", 0):
issues.append(("CRITICAL", "rpki_sum",
f"{rpki_sum} ≠ checked({r.get('checked')})"))
if r.get("checked", 0) > 0:
exp = round(r.get("valid", 0) / r.get("checked") * 100)
if abs(exp - r.get("coverage_percent", 0)) > 1:
issues.append(("CRITICAL", "rpki_pct",
f"expected {exp}% got {r.get('coverage_percent')}%"))
conns = ix.get("connections", [])
dedup = len(set(c.get("ix_id") for c in conns if c.get("ix_id")))
if dedup != ix.get("unique_ixps", 0):
issues.append(("CRITICAL", "ix_dedup",
f"dedup={dedup} ≠ unique_ixps={ix.get('unique_ixps')}"))
if len(conns) != ix.get("total_connections", 0):
issues.append(("WARNING", "ix_total",
f"len={len(conns)} ≠ total_connections={ix.get('total_connections')}"))
if len(fac.get("list", [])) != fac.get("total", 0):
issues.append(("WARNING", "fac_total",
f"len={len(fac.get('list',[]))} ≠ total={fac.get('total')}"))
# ── 3. Mandatory fields ──────────────────────────────────────────────────
if not n.get("name") or n.get("name") == "Unknown":
issues.append(("CRITICAL", "name_empty", f"name={n.get('name')!r}"))
if not n.get("asn"):
issues.append(("CRITICAL", "asn_field", "ASN field missing in response"))
if not n.get("rir"):
issues.append(("WARNING", "rir_empty", f"rir empty, country={n.get('country')!r}"))
if not n.get("country"):
issues.append(("WARNING", "country_empty", "country field empty"))
# ── 4. External cross-validation: RIPE Stat prefix count ────────────────
ripe = fetch(f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}")
if ripe and ripe.get("data", {}).get("prefixes") is not None:
ripe_pfx = len(ripe["data"]["prefixes"])
pc_pfx = p.get("total", 0)
result["data"]["ripe_stat_pfx"] = ripe_pfx
if ripe_pfx > 0 and pc_pfx > 0:
ratio = min(ripe_pfx, pc_pfx) / max(ripe_pfx, pc_pfx)
if ratio < 0.85:
issues.append(("WARNING", "prefix_ext_mismatch",
f"PC={pc_pfx} vs RIPE Stat={ripe_pfx} ({round((1-ratio)*100)}% diff)"))
elif ripe_pfx == 0 and pc_pfx > 0:
issues.append(("WARNING", "prefix_ext_zero",
f"RIPE Stat reports 0 prefixes, PC shows {pc_pfx}"))
# ── 5. External cross-validation: PeeringDB IX count ────────────────────
pdb = fetch(f"https://www.peeringdb.com/api/netixlan?asn={asn}&depth=0")
if pdb and isinstance(pdb.get("data"), list):
pdb_ix = len(pdb["data"])
pc_ix = ix.get("total_connections", 0)
result["data"]["pdb_ix_cnt"] = pdb_ix
if pdb_ix > 0 and pc_ix > 0:
ratio = min(pdb_ix, pc_ix) / max(pdb_ix, pc_ix)
if ratio < 0.85:
issues.append(("WARNING", "ix_ext_mismatch",
f"PC={pc_ix} vs PeeringDB={pdb_ix} ({round((1-ratio)*100)}% diff)"))
# ── Categorize ───────────────────────────────────────────────────────────
result["issues"] = issues
crits = [x for x in issues if x[0] == "CRITICAL"]
warns = [x for x in issues if x[0] == "WARNING"]
if crits:
result["status"] = "critical"
elif warns:
result["status"] = "warning"
else:
result["status"] = "ok"
return result
# ── Main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
asns = select_daily_asns(date_str, 100)
start = time.time()
print(f"PeerCortex Rotating Audit — {date_str}")
print(f"Pool: {len(ASN_POOL_DEDUP)} ASNs | Today: {len(asns)} selected")
print("=" * 70)
results = []
counts = {"ok": 0, "warning": 0, "critical": 0, "skip": 0}
for i, asn in enumerate(asns):
sys.stdout.write(f"\r [{i+1:3d}/100] AS{asn}... ")
sys.stdout.flush()
r = audit_asn(asn)
results.append(r)
counts[r["status"]] += 1
time.sleep(0.3) # be gentle to external APIs
elapsed = time.time() - start
print(f"\r Done in {elapsed:.0f}s" + " " * 30)
# ── Summary ───────────────────────────────────────────────────────────────
print()
print("=" * 70)
print(f"SUMMARY — {date_str}")
print("=" * 70)
print(f" ✓ PERFECT: {counts['ok']:3d}")
print(f" ⚠ WARNING: {counts['warning']:3d}")
print(f" ✗ CRITICAL: {counts['critical']:3d}")
print(f" ⏭ SKIP: {counts['skip']:3d}")
issues_found = [r for r in results if r["status"] in ("critical", "warning")]
if issues_found:
print(f"\n ISSUES ({len(issues_found)} ASNs):")
for r in issues_found:
for sev, field, msg in r["issues"]:
print(f" [{sev[:4]}] AS{r['asn']:>7} | {field:30s} | {msg[:55]}")
# ── Save JSON report ──────────────────────────────────────────────────────
report = {
"date": date_str,
"elapsed_s": round(elapsed),
"asns_tested": asns,
"pool_size": len(ASN_POOL_DEDUP),
"counts": counts,
"results": results,
"generated": datetime.now(timezone.utc).isoformat(),
}
out_path = f"/var/log/peercortex/audit-{date_str}.json"
os.makedirs("/var/log/peercortex", exist_ok=True)
with open(out_path, "w") as f:
json.dump(report, f, indent=2)
print(f"\nReport: {out_path}")
print(f"Audit complete: {datetime.now(timezone.utc).isoformat()}")