#!/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()}")