diff --git a/packages/secubox-toolbox/conf/i18n/social.en.json b/packages/secubox-toolbox/conf/i18n/social.en.json index d0cc358c..388be8ea 100644 --- a/packages/secubox-toolbox/conf/i18n/social.en.json +++ b/packages/secubox-toolbox/conf/i18n/social.en.json @@ -33,5 +33,7 @@ "card_evidence_active": "Compliance analysis: trackers before consent (GDPR art. 6.1.a + 7) and extra-EU transfers (art. 44). See the PDF report for details.", "node_detail_cdn": "CDN / cache", "node_detail_antibot": "Anti-bot", - "antibot_alert": "⚠ {n} site(s) challenged that you are human" + "antibot_alert": "⚠ {n} site(s) challenged that you are human", + "node_detail_opgrade": "Operator / state", + "opgrade_alert": "⛔ {n} operator-grade / state-adjacent surface(s)" } \ No newline at end of file diff --git a/packages/secubox-toolbox/conf/i18n/social.fr.json b/packages/secubox-toolbox/conf/i18n/social.fr.json index 48f6501c..57d5fdd1 100644 --- a/packages/secubox-toolbox/conf/i18n/social.fr.json +++ b/packages/secubox-toolbox/conf/i18n/social.fr.json @@ -33,5 +33,7 @@ "card_evidence_active": "Analyse de conformité : traqueurs avant consentement (RGPD art. 6.1.a + 7) et transferts hors UE (art. 44). Voir le rapport PDF pour le détail.", "node_detail_cdn": "CDN / cache", "node_detail_antibot": "Anti-bot", - "antibot_alert": "⚠ {n} site(s) ont vérifié que vous êtes humain" + "antibot_alert": "⚠ {n} site(s) ont vérifié que vous êtes humain", + "node_detail_opgrade": "Opérateur / état", + "opgrade_alert": "⛔ {n} surface(s) opérateur-grade / proche-État" } \ No newline at end of file diff --git a/packages/secubox-toolbox/conf/social_view.html.j2 b/packages/secubox-toolbox/conf/social_view.html.j2 index 6ecde01b..4eb639b1 100644 --- a/packages/secubox-toolbox/conf/social_view.html.j2 +++ b/packages/secubox-toolbox/conf/social_view.html.j2 @@ -4,7 +4,7 @@ {{ t.page_title }} — VILLAGE3B - + - + @@ -34,6 +34,7 @@
0{{ t.stats_total_sites }}

{{ t.graph_swipe_hint }}

+ @@ -52,6 +53,8 @@
{{ t.node_detail_antibot }}
+
{{ t.node_detail_opgrade }}
+
{{ t.node_detail_sites }}
{{ t.node_detail_first_seen }}
diff --git a/packages/secubox-toolbox/debian/changelog b/packages/secubox-toolbox/debian/changelog index 72942e66..e853adad 100644 --- a/packages/secubox-toolbox/debian/changelog +++ b/packages/secubox-toolbox/debian/changelog @@ -1,3 +1,29 @@ +secubox-toolbox (2.6.7-1~bookworm1) bookworm; urgency=medium + + * Phase 12.C (#518, parent #514) — operator-grade / state-adjacent + identity detection (extends Utiq #500). Passive, top-severity tier. + - social_graph.py detect_operator_grade(): telco header-enrichment + (MSISDN / x-acr / WAP bearer), operator-consortium IDs (Utiq / + TrustPid), and data-broker / state-adjacent analytics hosts + (LiveRamp / Oracle-BlueKai / Acxiom / Neustar / Tapad / Experian + / Palantir-class). Returns (vendor, category) for severity tier. + - social.py: social_host_meta.opgrade_vendor + opgrade_category; + new social_opgrade per-client table; record_host_opgrade + + record_opgrade_event + opgrade_for_client; fetch_graph carries + it + stats; aggregate by_opgrade + opgrade_clients; wipe_mac + clears social_opgrade. + - social.js: top-tier void-purple lens (above anti-bot cinnabar) + + pulsing double ring + ⛔ "operator-grade / state-adjacent" + banner; node-detail shows vendor + category. + - index.html operator tab: opérateur-grade breakdown card. + - social_report.py: operator-grade evidence section in the PDF. + - i18n: node_detail_opgrade + opgrade_alert (FR/EN). Assets + cache-busted ?v=26c. + Detection only — no bypass. Factual network-surface flagging, no + claim about any specific entity. + + -- Gerald KERMA Wed, 10 Jun 2026 12:00:00 +0200 + secubox-toolbox (2.6.6-1~bookworm1) bookworm; urgency=medium * Phase 12.B follow-up (#516) — fix the Clients-tab 🕸️ Carto link diff --git a/packages/secubox-toolbox/mitmproxy_addons/social_graph.py b/packages/secubox-toolbox/mitmproxy_addons/social_graph.py index af2d2dfc..97db4503 100644 --- a/packages/secubox-toolbox/mitmproxy_addons/social_graph.py +++ b/packages/secubox-toolbox/mitmproxy_addons/social_graph.py @@ -382,6 +382,59 @@ def detect_antibot(flow) -> Optional[str]: return None +# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ── +# The heaviest identity surfaces : carrier header-enrichment (the network +# itself injects your phone number / subscriber id), operator-consortium +# IDs (Utiq / TrustPid), and known data-broker / state-adjacent analytics +# aggregation endpoints. We flag the network SURFACE factually — never a +# claim about a specific entity. Returns (vendor, category) or (None, None). +# category ∈ {telco-enrichment, consortium, data-broker} for severity tiering. +_OPGRADE_HEADERS = ( # carrier-injected request headers + ("telco-enrichment", "MSISDN-header", ( + "x-msisdn", "x-up-calling-line-id", "x-nokia-msisdn", "x-wap-msisdn", + "x-up-subno", "msisdn", "x-forwarded-for-msisdn", "x-network-info")), + ("telco-enrichment", "Verizon/AT&T-ACR", ("x-acr",)), + ("telco-enrichment", "WAP-bearer", ("x-up-bearer-type",)), + ("consortium", "TrustPid", ("x-trustpid",)), +) +_OPGRADE_HOSTS = ( # host eTLD+1 fragments + ("consortium", "Utiq", ("utiq.com", "consenthub.utiq")), + ("consortium", "TrustPid", ("trustpid.com", "trustpid")), + ("data-broker", "LiveRamp", ("rlcdn.com", "liveramp.com", "liveramp")), + ("data-broker", "Oracle-BlueKai", ("bluekai.com", "bkrtx.com")), + ("data-broker", "Acxiom", ("acxiom.com", "acxiom-online")), + ("data-broker", "Neustar", ("neustar.biz", "agkn.com")), + ("data-broker", "Tapad", ("tapad.com",)), + ("data-broker", "Experian", ("experian.com", "tapestry.experian")), + ("data-broker", "Palantir-class", ("palantir.com", "palantircloud", "foundry.palantir")), +) + + +def detect_operator_grade(flow) -> tuple: + """Return (vendor, category) for an operator-grade / state-adjacent + identity surface, or (None, None). Passive ; factual. + """ + try: + # 1) Carrier-injected request headers (the strongest signal — + # the NETWORK enriched the request with subscriber identity). + try: + keys = " ".join(k.lower() for k in flow.request.headers.keys()) + except Exception: + keys = "" + for category, vendor, hdrs in _OPGRADE_HEADERS: + if any(h in keys for h in hdrs): + return vendor, category + + # 2) Host-based : consortium IDs + data-broker / state-adjacent. + host = (flow.request.host or "").lower() + for category, vendor, frags in _OPGRADE_HOSTS: + if any(f in host for f in frags): + return vendor, category + except Exception: + pass + return None, None + + # ─── JA4 lookup ─── def _ja4_hash(flow) -> Optional[str]: """Pull the JA4 fingerprint set by the ja4 addon, if present.""" @@ -449,6 +502,18 @@ class SocialGraph: client_mac_hash=mac_hash, src_site=src_site, antibot_vendor=antibot, ) + # Phase 12.C (#518) — operator-grade / state-adjacent identity. + op_vendor, op_category = detect_operator_grade(flow) + if op_vendor: + if resp_host: + _social.record_host_opgrade( + domain=resp_host, opgrade_vendor=op_vendor, + opgrade_category=op_category, + ) + _social.record_opgrade_event( + client_mac_hash=mac_hash, src_site=src_site, + opgrade_vendor=op_vendor, category=op_category, + ) except Exception: pass diff --git a/packages/secubox-toolbox/secubox_toolbox/social.py b/packages/secubox-toolbox/secubox_toolbox/social.py index 1dd2a1ff..04101c04 100644 --- a/packages/secubox-toolbox/secubox_toolbox/social.py +++ b/packages/secubox-toolbox/secubox_toolbox/social.py @@ -112,13 +112,16 @@ CREATE TABLE IF NOT EXISTS social_links ( ); -- Phase 12.A (#515) — host-stable CDN/edge metadata, mac-independent. --- Phase 12.B (#516) adds antibot_vendor (anti-bot / CAPTCHA vendor). +-- Phase 12.B (#516) adds antibot_vendor. Phase 12.C (#518) adds +-- opgrade_vendor + opgrade_category (operator-grade / state-adjacent). CREATE TABLE IF NOT EXISTS social_host_meta ( - tracker_domain TEXT PRIMARY KEY, - cdn_vendor TEXT, - cache_status TEXT, - antibot_vendor TEXT, - last_seen INTEGER NOT NULL + tracker_domain TEXT PRIMARY KEY, + cdn_vendor TEXT, + cache_status TEXT, + antibot_vendor TEXT, + opgrade_vendor TEXT, + opgrade_category TEXT, + last_seen INTEGER NOT NULL ); -- Phase 12.B (#516) — per-client "challenged your humanity" log. @@ -130,6 +133,17 @@ CREATE TABLE IF NOT EXISTS social_antibot ( last_seen INTEGER NOT NULL, PRIMARY KEY (client_mac_hash, src_site, antibot_vendor) ); + +-- Phase 12.C (#518) — per-client operator-grade / state-adjacent log. +CREATE TABLE IF NOT EXISTS social_opgrade ( + client_mac_hash TEXT NOT NULL, + src_site TEXT NOT NULL, + opgrade_vendor TEXT NOT NULL, + category TEXT NOT NULL, + hits INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL, + PRIMARY KEY (client_mac_hash, src_site, opgrade_vendor) +); """ @@ -156,6 +170,9 @@ _PHASE11C_MIGRATIONS = ( # Phase 12.B (#516) — anti-bot vendor column on the host-meta table # (created by 12.A ; additive on a 2.6.3/2.6.4 → upgrade). ("social_host_meta", "antibot_vendor", "TEXT"), + # Phase 12.C (#518) — operator-grade / state-adjacent columns. + ("social_host_meta", "opgrade_vendor", "TEXT"), + ("social_host_meta", "opgrade_category", "TEXT"), ) @@ -412,6 +429,83 @@ def antibot_for_client(mac_hash: str, since_seconds: int = 86400) -> List[Dict]: return out +# ── Phase 12.C (#518) — operator-grade / state-adjacent recording ── + +def _record_host_opgrade_sync(domain, vendor, category) -> None: + try: + with _conn() as c: + c.execute( + "INSERT INTO social_host_meta(tracker_domain, opgrade_vendor, " + "opgrade_category, last_seen) VALUES (?, ?, ?, ?) " + "ON CONFLICT(tracker_domain) DO UPDATE SET " + "opgrade_vendor=excluded.opgrade_vendor, " + "opgrade_category=excluded.opgrade_category, " + "last_seen=excluded.last_seen", + (domain, vendor, category, int(time.time())), + ) + except Exception as e: # pragma: no cover + log.warning("record_host_opgrade failed: %s", e) + + +def record_host_opgrade(*, domain: str, opgrade_vendor: str, + opgrade_category: Optional[str] = None) -> None: + if not (domain and opgrade_vendor): + return + try: + _executor.submit(_record_host_opgrade_sync, domain, opgrade_vendor, + opgrade_category) + except RuntimeError: + pass + + +def _record_opgrade_event_sync(mac_hash, src_site, vendor, category) -> None: + try: + with _conn() as c: + c.execute( + "INSERT INTO social_opgrade(client_mac_hash, src_site, " + "opgrade_vendor, category, hits, last_seen) " + "VALUES (?, ?, ?, ?, 1, ?) " + "ON CONFLICT(client_mac_hash, src_site, opgrade_vendor) DO UPDATE " + "SET hits = hits + 1, category = excluded.category, " + "last_seen = excluded.last_seen", + (mac_hash, src_site, vendor, category, int(time.time())), + ) + except Exception as e: # pragma: no cover + log.warning("record_opgrade_event failed: %s", e) + + +def record_opgrade_event(*, client_mac_hash: str, src_site: str, + opgrade_vendor: str, category: str) -> None: + if not (client_mac_hash and src_site and opgrade_vendor): + return + try: + _executor.submit(_record_opgrade_event_sync, client_mac_hash, + src_site, opgrade_vendor, category or "") + except RuntimeError: + pass + + +def opgrade_for_client(mac_hash: str, since_seconds: int = 86400) -> List[Dict]: + """Per-client operator-grade / state-adjacent list for the alert tile + + the PDF evidence section.""" + since = int(time.time()) - max(since_seconds, 3600) + out: List[Dict] = [] + if not mac_hash: + return out + try: + with _conn() as c: + for r in c.execute( + "SELECT src_site, opgrade_vendor, category, hits, last_seen " + "FROM social_opgrade WHERE client_mac_hash = ? AND last_seen >= ? " + "ORDER BY last_seen DESC LIMIT 100", + (mac_hash, since), + ).fetchall(): + out.append(dict(r)) + except Exception as e: # pragma: no cover + log.warning("opgrade_for_client failed: %s", e) + return out + + def fold_recent(window_seconds: int = 300) -> Tuple[int, int]: """Fold raw edges from the last `window_seconds` into the node + link aggregate tables. Returns (nodes_touched, links_touched). @@ -606,7 +700,8 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict: # can colour/label by edge-network vendor. for r in c.execute( "SELECT n.tracker_domain, n.hits, n.first_seen, n.last_seen, " - "n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor " + "n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor, " + "m.opgrade_vendor, m.opgrade_category " "FROM social_nodes n " "LEFT JOIN social_host_meta m ON m.tracker_domain = n.tracker_domain " "WHERE n.client_mac_hash = ? AND n.last_seen >= ? " @@ -629,6 +724,8 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict: "cdn_vendor": r["cdn_vendor"], "cache_status": r["cache_status"], "antibot_vendor": r["antibot_vendor"], + "opgrade_vendor": r["opgrade_vendor"], + "opgrade_category": r["opgrade_category"], } ) @@ -670,6 +767,9 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict: # Phase 12.B — per-client anti-bot challenges for the alert tile. antibot = antibot_for_client(mac_hash, since_seconds=since_seconds) out["antibot"] = antibot + # Phase 12.C — operator-grade / state-adjacent surfaces. + opgrade = opgrade_for_client(mac_hash, since_seconds=since_seconds) + out["opgrade"] = opgrade out["stats"] = { "total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0, "total_sites": sites_count, @@ -677,6 +777,8 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict: "last_seen": stats_row["last_seen"] if stats_row else None, "antibot_sites": len({a["src_site"] for a in antibot}), "antibot_vendors": sorted({a["antibot_vendor"] for a in antibot}), + "opgrade_sites": len({o["src_site"] for o in opgrade}), + "opgrade_vendors": sorted({o["opgrade_vendor"] for o in opgrade}), } except Exception as e: # pragma: no cover log.warning("fetch_graph failed: %s", e) @@ -694,7 +796,7 @@ def wipe_mac(mac_hash: str) -> int: try: with _conn() as c: for table in ("social_edges", "social_nodes", "social_links", - "social_antibot"): + "social_antibot", "social_opgrade"): cur = c.execute( f"DELETE FROM {table} WHERE client_mac_hash = ?", (mac_hash,) ) @@ -721,6 +823,8 @@ def aggregate(hours: int = 24) -> Dict: "by_cdn": [], # Phase 12.A "by_antibot": [], # Phase 12.B "antibot_clients": 0, # Phase 12.B + "by_opgrade": [], # Phase 12.C + "opgrade_clients": 0, # Phase 12.C } try: with _conn() as c: @@ -787,6 +891,24 @@ def aggregate(hours: int = 24) -> Dict: "WHERE last_seen >= ?", (since,), ).fetchone()[0] + # Phase 12.C — operator-grade / state-adjacent breakdown. + out["by_opgrade"] = [ + dict(r) + for r in c.execute( + "SELECT opgrade_vendor, category, " + "COUNT(DISTINCT src_site) AS sites, " + "COUNT(DISTINCT client_mac_hash) AS clients, " + "SUM(hits) AS events " + "FROM social_opgrade WHERE last_seen >= ? " + "GROUP BY opgrade_vendor ORDER BY events DESC LIMIT 25", + (since,), + ).fetchall() + ] + out["opgrade_clients"] = c.execute( + "SELECT COUNT(DISTINCT client_mac_hash) FROM social_opgrade " + "WHERE last_seen >= ?", + (since,), + ).fetchone()[0] except Exception as e: # pragma: no cover log.warning("aggregate failed: %s", e) return out diff --git a/packages/secubox-toolbox/secubox_toolbox/social_report.py b/packages/secubox-toolbox/secubox_toolbox/social_report.py index 575f4f47..601602a6 100644 --- a/packages/secubox-toolbox/secubox_toolbox/social_report.py +++ b/packages/secubox-toolbox/secubox_toolbox/social_report.py @@ -49,15 +49,28 @@ _L = { "Nous rapportons le fait observé, pas l'absence de garanties (SCC).", "Legal basis: GDPR art. 44+ (international transfers). We report the " "observed fact, not the absence of safeguards (SCC)."), + "ev_opgrade": ("Évidence : surfaces opérateur-grade / proche-État", + "Evidence: operator-grade / state-adjacent surfaces"), + "ev_opgrade_basis": ("Enrichissement réseau (MSISDN/ACR), identifiants de " + "consortium opérateur, et courtiers de données / analytics " + "proches-État. Fait réseau observé, sans qualification d'entité.", + "Network enrichment (MSISDN/ACR), operator-consortium IDs, and " + "data-broker / state-adjacent analytics. Observed network fact, " + "no entity qualification."), "col_tracker": ("Traqueur", "Tracker"), "col_sites": ("Sites", "Sites"), "col_hits": ("Occurrences", "Hits"), "col_country": ("Pays", "Country"), "col_asn": ("Hébergeur (ASN)", "Host (ASN)"), + "col_opvendor": ("Surface", "Surface"), + "col_opcat": ("Catégorie", "Category"), + "col_site": ("Site", "Site"), "none_pre": ("Aucun traqueur déclenché avant consentement détecté.", "No tracker fired before consent detected."), "none_eu": ("Aucun transfert hors UE/EEE détecté.", "No extra-EU/EEA transfer detected."), + "none_op": ("Aucune surface opérateur-grade / proche-État détectée.", + "No operator-grade / state-adjacent surface detected."), "footer": ("Rapport factuel — données calculées localement, aucune donnée externe. " "Anonyme : aucune valeur de cookie brute conservée. Droit à l'effacement : RGPD art. 17.", "Factual report — computed locally, no external data. Anonymous: no raw cookie " @@ -87,6 +100,8 @@ def build_social_report(mac_hash: str, since_seconds: int = 7 * 86400) -> Dict: "total_sites": stats.get("total_sites", 0), "pre_consent": evidence.get("pre_consent", []), "extra_eu": evidence.get("extra_eu", []), + # Phase 12.C — operator-grade / state-adjacent surfaces. + "opgrade": _social.opgrade_for_client(mac_hash, since_seconds=since_seconds), } @@ -181,6 +196,21 @@ def render_social_pdf(report: Dict) -> bytes: ]) else: _note(pdf, family, _bi("none_eu")) + pdf.ln(3) + + # ── Evidence : operator-grade / state-adjacent (Phase 12.C) ── + bi("ev_opgrade", 12, "B", 110, 64, 201, gap=0.3) + bi("ev_opgrade_basis", 8, "", 120, 120, 120, gap=1) + op = report.get("opgrade", []) + if op: + _table(pdf, family, op, [ + (_bi("col_opvendor"), "opgrade_vendor", 45), + (_bi("col_opcat"), "category", 40), + (_bi("col_site"), "src_site", 55), + (_bi("col_hits"), "hits", 25), + ]) + else: + _note(pdf, family, _bi("none_op")) pdf.ln(4) # ── Footer ── diff --git a/packages/secubox-toolbox/www/toolbox/index.html b/packages/secubox-toolbox/www/toolbox/index.html index 6d1ab6c6..9f6c3769 100644 --- a/packages/secubox-toolbox/www/toolbox/index.html +++ b/packages/secubox-toolbox/www/toolbox/index.html @@ -144,6 +144,10 @@

🤖 Anti-bot / "prouvez que vous êtes humain"

loading…
+
+

📡 Opérateur-grade / proche-État

+
loading…
+

🎯 Top tracker domains

loading…
@@ -405,6 +409,16 @@ async function loadSocial() { '' : '
aucun anti-bot détecté
'; } + const og = document.getElementById('social-opgrade'); + if (og) { + const bo = agg.by_opgrade || []; + og.innerHTML = bo.length + ? `

${agg.opgrade_clients||0} client(s) exposé(s)

` + + '' + + bo.map(r => ``).join('') + + '
Vendorcatégoriesitesclientsevents
📡 ${r.opgrade_vendor}${r.category}${r.sites}${r.clients}${r.events}
' + : '
aucune surface opérateur-grade détectée
'; + } } async function refreshAll() { diff --git a/packages/secubox-toolbox/www/toolbox/social.css b/packages/secubox-toolbox/www/toolbox/social.css index 05d94add..f42c4c25 100644 --- a/packages/secubox-toolbox/www/toolbox/social.css +++ b/packages/secubox-toolbox/www/toolbox/social.css @@ -327,3 +327,27 @@ dialog h2 { .ring-guides circle { fill: none; stroke-width: 1; pointer-events: none; } .ring-inner { stroke: var(--gold-hermetic); stroke-opacity: .18; stroke-dasharray: 2 4; } .ring-outer { stroke: var(--cyber-cyan); stroke-opacity: .14; stroke-dasharray: 2 6; } + +/* ── Operator-grade / state-adjacent (Phase 12.C) — top severity ── */ +.opgrade-ring { + fill: none; + stroke: var(--void-purple); + stroke-width: 2; + opacity: .9; + animation: opgrade-pulse 1.8s ease-in-out infinite; + transform-origin: center; + transform-box: fill-box; +} +@keyframes opgrade-pulse { 0%,100% { r: 13px; opacity: .5; } 50% { r: 17px; opacity: 1; } } +.opgrade-alert { + position: absolute; top: 8px; left: 50%; transform: translateX(-50%); + z-index: 7; + background: var(--void-purple); + color: #fff; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; font-weight: bold; + padding: 5px 12px; border-radius: 3px; + max-width: 90%; text-align: center; + box-shadow: 0 2px 12px rgba(110,64,201,.5); +} +.opgrade-alert + .antibot-alert { top: 38px; } diff --git a/packages/secubox-toolbox/www/toolbox/social.js b/packages/secubox-toolbox/www/toolbox/social.js index 42846602..95932273 100644 --- a/packages/secubox-toolbox/www/toolbox/social.js +++ b/packages/secubox-toolbox/www/toolbox/social.js @@ -58,6 +58,16 @@ el.hidden = false; } + // Phase 12.C — top-severity operator-grade / state-adjacent banner. + function updateOpgradeTile(sites, vendors) { + const el = document.getElementById('opgrade-alert'); + if (!el) return; + if (!sites) { el.hidden = true; return; } + const v = (vendors || []).join(', '); + el.textContent = t('opgrade_alert', { n: sites }) + (v ? ' — ' + v : ''); + el.hidden = false; + } + // ─── graph state ─── let simulation = null; @@ -83,6 +93,7 @@ bind('total_sites', graph.stats.total_sites || 0); // Phase 12.B — "challenged your humanity" alert tile. updateAntibotTile(graph.stats.antibot_sites || 0, graph.stats.antibot_vendors || []); + updateOpgradeTile(graph.stats.opgrade_sites || 0, graph.stats.opgrade_vendors || []); // Empty graph → just return ; the stats tiles already show 0/0 and // the user knows. No persistent overlay message. @@ -122,6 +133,8 @@ cdn_vendor: n.cdn_vendor || null, cache_status: n.cache_status || null, antibot_vendor: n.antibot_vendor || null, + opgrade_vendor: n.opgrade_vendor || null, + opgrade_category: n.opgrade_category || null, }); } @@ -214,7 +227,10 @@ function nodeColor(d) { if (d.kind === 'eye') return 'var(--cinnabar)'; if (d.kind === 'site') return 'var(--gold-hermetic)'; - // Phase 12.B — anti-bot hosts get the highest-severity lens. + // Severity tiers (highest first): + // 12.C operator-grade / state-adjacent = void-purple (top). + if (d.opgrade_vendor) return 'var(--void-purple)'; + // 12.B anti-bot = cinnabar. if (d.antibot_vendor) return 'var(--cinnabar)'; if (d.cdn_vendor && CDN_COLORS[d.cdn_vendor]) return CDN_COLORS[d.cdn_vendor]; return 'var(--cyber-cyan)'; @@ -231,6 +247,10 @@ nodeG.filter(d => d.kind === 'tracker' && d.antibot_vendor) .append('circle').attr('class', 'antibot-ring').attr('r', 12); + // Phase 12.C — operator-grade hosts get the top-tier double ring. + nodeG.filter(d => d.kind === 'tracker' && d.opgrade_vendor) + .append('circle').attr('class', 'opgrade-ring').attr('r', 14); + // Site + tracker nodes. nodeG.filter(d => d.kind !== 'eye').append('circle') .attr('r', d => d.kind === 'tracker' ? 7 : 10) @@ -327,6 +347,7 @@ bind('nd_asn', '—'); bind('nd_cdn', node.cdn_vendor ? (node.cdn_vendor + (node.cache_status ? ' · ' + node.cache_status : '')) : '—'); bind('nd_antibot', node.antibot_vendor ? ('🤖 ' + node.antibot_vendor) : '—'); + bind('nd_opgrade', node.opgrade_vendor ? ('📡 ' + node.opgrade_vendor + (node.opgrade_category ? ' (' + node.opgrade_category + ')' : '')) : '—'); bind('nd_sites', (node.sites || []).join(', ') || '—'); bind('nd_first_seen', node.first_seen ? new Date(node.first_seen * 1000).toISOString().slice(0, 16).replace('T', ' ') : '—'); bind('nd_last_seen', node.last_seen ? new Date(node.last_seen * 1000).toISOString().slice(0, 16).replace('T', ' ') : '—');