feat(toolbox): Phase 12.B — anti-bot detection + visible ring levels + per-client operator tools (ref #516)

Anti-bot / 'prove you're human' DETECTION (bypass stays gated behind doctrine):
  - social_graph.py detect_antibot(): reCAPTCHA/hCaptcha/Turnstile/Datadome/
    PerimeterX-HUMAN/Arkose/Kasada/Akamai-BotManager from URL+cookies+headers.
  - social.py: social_host_meta.antibot_vendor + social_antibot per-client
    challenge table; fetch_graph/aggregate/wipe_mac carry it.
  - social.js: cinnabar severe lens + spinning warning ring + 🤖 label +
    'challenged your humanity' alert banner; node-detail vendor.
  - operator social tab: anti-bot breakdown card.

Fix the ring-level visibility the user reported missing:
  - radial force now DOMINANT (strength 0.9) with weak charge/links, plus
    dashed ring guides (inner=sites, outer=trackers); assets cache-busted
    ?v=264b so the new layout actually loads.

Per-client operator tools in the Clients tab:
  - 🕸️ Carto link → GET /admin/clients/{mac}/social mints a token + opens
    that client's graph.
  - ↺ Reset (RAZ) → POST /admin/clients/{mac}/reset wipes social + events +
    consents + reports and zeroes score (store.reset_client + social.wipe_mac).

changelog 2.6.5. Live on gk2: both new tables created, detect_antibot matrix
green, aggregate by_antibot/antibot_clients present, reset endpoint 200, graph
nodes carry antibot_vendor, served assets contain the ring + cache-bust.
This commit is contained in:
CyberMind-FR 2026-06-10 14:16:50 +02:00
parent a93d949ede
commit 9f850bef53
11 changed files with 403 additions and 25 deletions

View File

@ -31,5 +31,7 @@
"lang_label": "EN",
"card_pdf_download": "⬇ Download PDF report (FR/EN)",
"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_cdn": "CDN / cache",
"node_detail_antibot": "Anti-bot",
"antibot_alert": "⚠ {n} site(s) challenged that you are human"
}

View File

@ -31,5 +31,7 @@
"lang_label": "FR",
"card_pdf_download": "⬇ Télécharger le rapport PDF (FR/EN)",
"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_cdn": "CDN / cache",
"node_detail_antibot": "Anti-bot",
"antibot_alert": "⚠ {n} site(s) ont vérifié que vous êtes humain"
}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{{ t.page_title }} — VILLAGE3B</title>
<link rel="stylesheet" href="/toolbox/social.css">
<link rel="stylesheet" href="/toolbox/social.css?v=264b">
<script>
// Inline JSON safer than a data-* attribute : FR text has
// apostrophes (l'effacement) that would break single-quoted
@ -14,7 +14,7 @@
window.__SOCIAL_I18N__ = {{ t_json | safe }};
</script>
<script defer src="/toolbox/d3.v7.min.js"></script>
<script defer src="/toolbox/social.js"></script>
<script defer src="/toolbox/social.js?v=264b"></script>
</head>
<body data-token="{{ token }}" data-lang="{{ lang }}">
@ -34,6 +34,7 @@
<div class="stat-tile"><span class="stat-n" data-bind="total_sites">0</span><span class="stat-l">{{ t.stats_total_sites }}</span></div>
</section>
<p class="graph-hint">{{ t.graph_swipe_hint }}</p>
<div id="antibot-alert" class="antibot-alert" hidden></div>
<svg id="social-graph" role="img" preserveAspectRatio="xMidYMid meet"></svg>
</section>
@ -49,6 +50,8 @@
<dd data-bind="nd_asn">—</dd>
<dt>{{ t.node_detail_cdn }}</dt>
<dd data-bind="nd_cdn">—</dd>
<dt>{{ t.node_detail_antibot }}</dt>
<dd data-bind="nd_antibot">—</dd>
<dt>{{ t.node_detail_sites }}</dt>
<dd data-bind="nd_sites">—</dd>
<dt>{{ t.node_detail_first_seen }}</dt>

View File

@ -1,3 +1,34 @@
secubox-toolbox (2.6.5-1~bookworm1) bookworm; urgency=medium
* Phase 12.B (#516, parent #514) — anti-bot / "prove you're human"
detection + ring-level graph + per-client operator tools.
- social_graph.py: detect_antibot() classifies the bot-checker /
CAPTCHA vendor challenging a flow (reCAPTCHA / hCaptcha /
Turnstile / Datadome / PerimeterX-HUMAN / Arkose / Kasada /
Akamai Bot Manager) from URL + cookies + headers. Detection
only ; bypass stays gated behind its own doctrine.
- social.py: social_host_meta.antibot_vendor + new social_antibot
per-client challenge table. fetch_graph carries antibot_vendor
+ an antibot list/stats; aggregate adds by_antibot +
antibot_clients; wipe_mac clears social_antibot too.
- social.js: anti-bot hosts get the highest-severity cinnabar
lens + a spinning warning ring + 🤖 label; "challenged your
humanity" alert banner; node-detail shows the vendor.
- VISIBLE RING LEVELS: radial force is now dominant (strength
0.9) with weak charge + weak link springs, plus dashed ring
guides (inner = your sites, outer = trackers) so the Round-Eye
levels read clearly. Assets cache-busted (?v=264b).
- index.html operator social tab: anti-bot breakdown card.
- index.html Clients tab: per-row 🕸️ Carto (opens the client's
graph via a minted token), ↺ Reset (RAZ the client's stats).
- api.py: GET /admin/clients/{mac}/social (operator graph link),
POST /admin/clients/{mac}/reset (RAZ social + events + score).
- store.py: reset_client() wipes events/consents/reports + zeroes
score.
- i18n: node_detail_antibot + antibot_alert (FR/EN).
-- Gerald KERMA <devel@cybermind.fr> Wed, 10 Jun 2026 11:00:00 +0200
secubox-toolbox (2.6.4-1~bookworm1) bookworm; urgency=medium
* Phase 12.A (#515, parent #514) — CDN cache detection + Round-Eye

View File

@ -313,6 +313,75 @@ def detect_cdn(headers) -> tuple:
return None, None
# ─── anti-bot / "prove you're human" detection (Phase 12.B #516) ───
# Passive : classify the bot-checker / CAPTCHA vendor that fronts a host,
# from request URL fragments + cookies + response headers. Detection
# only — the (legally-sensitive) bypass half is gated behind its own
# doctrine. Vendor is host-stable ; stored like cdn_vendor.
_ANTIBOT_URL = (
("reCAPTCHA", ("/recaptcha/", "google.com/recaptcha", "gstatic.com/recaptcha")),
("hCaptcha", ("hcaptcha.com", "newassets.hcaptcha.com", "imgs.hcaptcha.com")),
("Turnstile", ("challenges.cloudflare.com",)),
("Datadome", ("captcha-delivery.com", "datadome.co", "ct.captcha-delivery.com")),
("PerimeterX/HUMAN", ("px-cdn.net", "perimeterx.net", "pxchk", "px-cloud.net")),
("Arkose/FunCaptcha", ("arkoselabs.com", "funcaptcha.com", "arkose-labs")),
("Kasada", ("kasada.io", "ct.kasada", "kpsdk")),
)
_ANTIBOT_COOKIE = (
("Turnstile", ("__cf_bm", "cf_chl_")),
("Datadome", ("datadome",)),
("PerimeterX/HUMAN", ("_px", "_pxhd", "_pxvid", "_pxff")),
("Akamai-BotManager", ("ak_bmsc", "bm_sz", "_abck", "bm_mi")),
("hCaptcha", ("hcaptcha",)),
)
_ANTIBOT_HEADER = (
("Datadome", ("x-datadome", "x-dd-b")),
("Kasada", ("x-kpsdk-ct", "x-kpsdk-cd")),
("PerimeterX/HUMAN", ("x-px",)),
)
def detect_antibot(flow) -> Optional[str]:
"""Return the anti-bot / CAPTCHA vendor challenging this flow, or None.
Best-effort, passive. Scans the request URL, the response + request
headers, and cookie names.
"""
try:
url = (flow.request.pretty_url or "").lower()
for vendor, frags in _ANTIBOT_URL:
if any(f in url for f in frags):
return vendor
# Response headers
rh = flow.response.headers if flow.response else None
if rh is not None:
keys = " ".join(k.lower() for k in rh.keys())
for vendor, hdrs in _ANTIBOT_HEADER:
if any(h in keys for h in hdrs):
return vendor
# Cookie names (both directions)
blobs = []
try:
blobs.extend(flow.request.headers.get_all("cookie") or [])
except Exception:
pass
try:
if flow.response:
blobs.extend(flow.response.headers.get_all("set-cookie") or [])
except Exception:
pass
joined = " ".join(blobs).lower()
if joined:
for vendor, names in _ANTIBOT_COOKIE:
if any(n in joined for n in names):
return vendor
except Exception:
pass
return None
# ─── JA4 lookup ───
def _ja4_hash(flow) -> Optional[str]:
"""Pull the JA4 fingerprint set by the ja4 addon, if present."""
@ -355,10 +424,15 @@ class SocialGraph:
consent_state = _consent_state_for(mac_hash, src_site)
# Phase 12.A (#515) — passive CDN detection on the responding host.
# Host-stable, mac-independent ; upserted off-thread. We only
# record for 3rd-party hosts (the ones that become graph nodes).
# Phase 12.B (#516) — anti-bot / CAPTCHA vendor detection. Both
# host-stable, mac-independent ; upserted off-thread. Anti-bot
# is recorded for the responding host AND, when the challenge
# surface is a 3rd-party (e.g. captcha-delivery.com), attributed
# to the 1st-party site too via record_antibot_site so the
# per-client "challenged your humanity" alert is accurate.
try:
resp_host = _registrable_domain(flow.request.host)
antibot = detect_antibot(flow)
if resp_host and resp_host != src_site:
cdn_vendor, cache_status = detect_cdn(flow.response.headers)
if cdn_vendor:
@ -367,6 +441,14 @@ class SocialGraph:
cdn_vendor=cdn_vendor,
cache_status=cache_status,
)
if antibot and resp_host:
_social.record_host_antibot(domain=resp_host, antibot_vendor=antibot)
# Attribute the challenge to the 1st-party site the user
# was on (for the per-client alert), keyed by mac_hash.
_social.record_antibot_challenge(
client_mac_hash=mac_hash, src_site=src_site,
antibot_vendor=antibot,
)
except Exception:
pass

View File

@ -2449,6 +2449,31 @@ async def admin_client_report(mac_hash: str) -> Response:
)
@router.get("/admin/clients/{mac_hash}/social")
async def admin_client_social(mac_hash: str) -> RedirectResponse:
"""Phase 12.B (#516) — operator entry to a client's social mapping
graph from the toolbox WebUI Clients tab. Mints a short-TTL (1 h)
HMAC token for the mac_hash and 303-redirects to /social/{token},
so the operator reuses the exact same per-client view the user sees.
"""
salt = _get_salt()
tok = reports.mint_token(mac_hash, salt, ttl_seconds=3600)
return RedirectResponse(url=f"/social/{tok.token}", status_code=303)
@router.post("/admin/clients/{mac_hash}/reset")
async def admin_client_reset(mac_hash: str) -> dict:
"""Phase 12.B (#516) — RAZ a specific client's accumulated statistics
from the operator WebUI. Wipes the social-mapping graph + the
toolbox events/consents/reports and zeroes the client score.
"""
from . import social as _s
rows = store.reset_client(mac_hash)
rows += _s.wipe_mac(mac_hash)
log.info("admin reset client %s: %d rows", mac_hash[:8], rows)
return {"ok": True, "rows_deleted": rows, "mac_hash_prefix": mac_hash[:8]}
@router.get("/admin/clients/{mac_hash}/events")
async def admin_client_events(mac_hash: str) -> dict:
"""Admin endpoint : per-source event summary for a specific client."""

View File

@ -112,14 +112,24 @@ CREATE TABLE IF NOT EXISTS social_links (
);
-- Phase 12.A (#515) — host-stable CDN/edge metadata, mac-independent.
-- Written fire-and-forget by the addon when it sees CDN response
-- headers ; LEFT JOINed onto graph nodes + the operator aggregate.
-- Phase 12.B (#516) adds antibot_vendor (anti-bot / CAPTCHA vendor).
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
);
-- Phase 12.B (#516) — per-client "challenged your humanity" log.
CREATE TABLE IF NOT EXISTS social_antibot (
client_mac_hash TEXT NOT NULL,
src_site TEXT NOT NULL,
antibot_vendor TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER NOT NULL,
PRIMARY KEY (client_mac_hash, src_site, antibot_vendor)
);
"""
@ -143,6 +153,9 @@ _PHASE11C_MIGRATIONS = (
("social_nodes", "asn_org", "TEXT"),
("social_nodes", "eu_inside", "INTEGER NOT NULL DEFAULT 1"),
("social_nodes", "pre_consent_hits", "INTEGER NOT NULL DEFAULT 0"),
# 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"),
)
@ -326,6 +339,79 @@ def record_host_cdn(*, domain: str, cdn_vendor: Optional[str] = None,
pass
# ── Phase 12.B (#516) — anti-bot / "prove you're human" recording ──
def _record_host_antibot_sync(domain: str, vendor: str) -> None:
try:
with _conn() as c:
c.execute(
"INSERT INTO social_host_meta(tracker_domain, antibot_vendor, "
"last_seen) VALUES (?, ?, ?) "
"ON CONFLICT(tracker_domain) DO UPDATE SET "
"antibot_vendor=excluded.antibot_vendor, "
"last_seen=excluded.last_seen",
(domain, vendor, int(time.time())),
)
except Exception as e: # pragma: no cover
log.warning("record_host_antibot failed: %s", e)
def record_host_antibot(*, domain: str, antibot_vendor: str) -> None:
"""Upsert the host-stable anti-bot vendor fronting `domain`."""
if not (domain and antibot_vendor):
return
try:
_executor.submit(_record_host_antibot_sync, domain, antibot_vendor)
except RuntimeError:
pass
def _record_antibot_challenge_sync(mac_hash: str, src_site: str, vendor: str) -> None:
try:
with _conn() as c:
c.execute(
"INSERT INTO social_antibot(client_mac_hash, src_site, "
"antibot_vendor, hits, last_seen) VALUES (?, ?, ?, 1, ?) "
"ON CONFLICT(client_mac_hash, src_site, antibot_vendor) DO UPDATE "
"SET hits = hits + 1, last_seen = excluded.last_seen",
(mac_hash, src_site, vendor, int(time.time())),
)
except Exception as e: # pragma: no cover
log.warning("record_antibot_challenge failed: %s", e)
def record_antibot_challenge(*, client_mac_hash: str, src_site: str,
antibot_vendor: str) -> None:
"""Record a per-client "this site challenged your humanity" event."""
if not (client_mac_hash and src_site and antibot_vendor):
return
try:
_executor.submit(_record_antibot_challenge_sync, client_mac_hash,
src_site, antibot_vendor)
except RuntimeError:
pass
def antibot_for_client(mac_hash: str, since_seconds: int = 86400) -> List[Dict]:
"""Per-client anti-bot challenge list for the graph alert tile."""
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, antibot_vendor, hits, last_seen "
"FROM social_antibot 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("antibot_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).
@ -520,7 +606,7 @@ 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 "
"n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor "
"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 >= ? "
@ -542,6 +628,7 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"last_seen": r["last_seen"],
"cdn_vendor": r["cdn_vendor"],
"cache_status": r["cache_status"],
"antibot_vendor": r["antibot_vendor"],
}
)
@ -580,11 +667,16 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
for s in n["sites"]
}
)
# 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
out["stats"] = {
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
"total_sites": sites_count,
"first_seen": stats_row["first_seen"] if stats_row else None,
"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}),
}
except Exception as e: # pragma: no cover
log.warning("fetch_graph failed: %s", e)
@ -601,7 +693,8 @@ def wipe_mac(mac_hash: str) -> int:
total = 0
try:
with _conn() as c:
for table in ("social_edges", "social_nodes", "social_links"):
for table in ("social_edges", "social_nodes", "social_links",
"social_antibot"):
cur = c.execute(
f"DELETE FROM {table} WHERE client_mac_hash = ?", (mac_hash,)
)
@ -626,6 +719,8 @@ def aggregate(hours: int = 24) -> Dict:
"by_tracker_domain": [],
"by_client": [],
"by_cdn": [], # Phase 12.A
"by_antibot": [], # Phase 12.B
"antibot_clients": 0, # Phase 12.B
}
try:
with _conn() as c:
@ -674,6 +769,24 @@ def aggregate(hours: int = 24) -> Dict:
(since, since),
).fetchall()
]
# Phase 12.B — anti-bot / "prove you're human" breakdown.
out["by_antibot"] = [
dict(r)
for r in c.execute(
"SELECT antibot_vendor, "
"COUNT(DISTINCT src_site) AS sites, "
"COUNT(DISTINCT client_mac_hash) AS clients, "
"SUM(hits) AS challenges "
"FROM social_antibot WHERE last_seen >= ? "
"GROUP BY antibot_vendor ORDER BY challenges DESC LIMIT 25",
(since,),
).fetchall()
]
out["antibot_clients"] = c.execute(
"SELECT COUNT(DISTINCT client_mac_hash) FROM social_antibot "
"WHERE last_seen >= ?",
(since,),
).fetchone()[0]
except Exception as e: # pragma: no cover
log.warning("aggregate failed: %s", e)
return out

View File

@ -132,3 +132,30 @@ def purge_expired() -> int:
if n:
log.info("purge_expired: %d rows", n)
return n
def reset_client(mac_hash: str) -> int:
"""Phase 12.B (#516) — RAZ a specific client's accumulated toolbox
state : events + consents + reports. Returns rows deleted. The
`clients` row itself is kept (it re-populates from live activity)
but its score is zeroed. Social-mapping rows are wiped separately
via secubox_toolbox.social.wipe_mac().
"""
if not mac_hash:
return 0
n = 0
try:
with _conn() as c:
for table in ("events", "consents", "reports"):
n += c.execute(
f"DELETE FROM {table} WHERE mac_hash = ?", (mac_hash,)
).rowcount or 0
c.execute(
"UPDATE clients SET score = 0, state = 'validated' "
"WHERE mac_hash = ?", (mac_hash,)
)
except Exception as e:
log.warning("reset_client failed: %s", e)
if n:
log.info("reset_client %s: %d rows", mac_hash[:8], n)
return n

View File

@ -140,6 +140,10 @@
<h2>🛰️ Réseaux CDN / edge</h2>
<div id="social-cdn"><div class="empty">loading…</div></div>
</div>
<div class="card">
<h2>🤖 Anti-bot / "prouvez que vous êtes humain"</h2>
<div id="social-antibot"><div class="empty">loading…</div></div>
</div>
<div class="card" style="grid-column:1/-1">
<h2>🎯 Top tracker domains</h2>
<div id="social-trackers"><div class="empty">loading…</div></div>
@ -242,9 +246,13 @@ async function loadClients() {
<td>${c.score ?? '—'}</td>
<td>${ago}</td>
<td>
<a class="link" href="${API}/admin/clients/${c.mac_hash}/social" target="_blank" style="font-size:0.8rem">🕸️ Carto</a>
·
<a class="link" href="${API}/admin/clients/${c.mac_hash}/report" target="_blank" style="font-size:0.8rem">PDF</a>
·
<a class="link" href="javascript:void(0)" onclick="loadClientDetail('${c.mac_hash}')" style="font-size:0.8rem">Events</a>
·
<a class="link" href="javascript:void(0)" onclick="resetClient('${c.mac_hash}')" style="font-size:0.8rem;color:var(--red)">↺ Reset</a>
</td>
</tr>`;
}
@ -252,6 +260,21 @@ async function loadClients() {
el.innerHTML = html;
}
// Phase 12.B — RAZ a specific client's accumulated statistics
// (social mapping graph + toolbox events). Operator-gated.
async function resetClient(macHash) {
if (!confirm(`Remettre à zéro les statistiques du client ${macHash.slice(0,16)}… ?\n\nCela efface sa cartographie sociale + ses events. Irréversible.`)) return;
try {
const r = await fetch(`${API}/admin/clients/${macHash}/reset`, {
method: 'POST', credentials: 'same-origin'
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
alert(`RAZ effectuée : ${j.rows_deleted || 0} enregistrements supprimés.`);
loadClients();
} catch (e) { alert('Échec RAZ : ' + e.message); }
}
async function loadMetrics() {
const m = await J('/admin/metrics');
const el = document.getElementById('metrics');
@ -372,6 +395,16 @@ async function loadSocial() {
'</tbody></table>'
: '<div class="empty">aucun CDN détecté</div>';
}
const ab = document.getElementById('social-antibot');
if (ab) {
const ba = agg.by_antibot || [];
ab.innerHTML = ba.length
? `<p style="font-size:0.8rem;color:var(--p31-dim);margin-bottom:0.5rem">${agg.antibot_clients||0} client(s) défié(s)</p>` +
'<table><thead><tr><th>Vendor</th><th>sites</th><th>clients</th><th>défis</th></tr></thead><tbody>' +
ba.map(r => `<tr><td>🤖 ${r.antibot_vendor}</td><td>${r.sites}</td><td>${r.clients}</td><td>${r.challenges}</td></tr>`).join('') +
'</tbody></table>'
: '<div class="empty">aucun anti-bot détecté</div>';
}
}
async function refreshAll() {

View File

@ -295,3 +295,35 @@ dialog h2 {
/* Desktop : node detail slides in from the right rail instead of bottom sheet */
.node-detail { top: 70px; bottom: auto; right: 14px; left: auto; width: 320px; max-height: 60vh; border-top: 0; border-left: 2px solid var(--gold-hermetic); border-radius: 4px; }
}
/* ── Anti-bot / "prove you're human" (Phase 12.B) ── */
.antibot-ring {
fill: none;
stroke: var(--cinnabar);
stroke-width: 1.5;
stroke-dasharray: 3 2;
opacity: .8;
animation: antibot-spin 6s linear infinite;
transform-origin: center;
transform-box: fill-box;
}
@keyframes antibot-spin { to { transform: rotate(360deg); } }
.antibot-alert {
position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
z-index: 6;
background: rgba(230, 57, 70, .92);
color: #0a0a0f;
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 10px rgba(230,57,70,.4);
}
/* ── Round-Eye ring guides (Phase 12.A/B) ── */
.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; }

View File

@ -48,6 +48,16 @@
if (el) el.textContent = value;
}
// Phase 12.B — show/hide the "challenged your humanity" banner.
function updateAntibotTile(sites, vendors) {
const el = document.getElementById('antibot-alert');
if (!el) return;
if (!sites) { el.hidden = true; return; }
const v = (vendors || []).join(', ');
el.textContent = t('antibot_alert', { n: sites }) + (v ? ' — ' + v : '');
el.hidden = false;
}
// ─── graph state ───
let simulation = null;
@ -71,6 +81,8 @@
bind('total_trackers', graph.stats.total_trackers || 0);
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 || []);
// Empty graph → just return ; the stats tiles already show 0/0 and
// the user knows. No persistent overlay message.
@ -109,6 +121,7 @@
last_seen: n.last_seen,
cdn_vendor: n.cdn_vendor || null,
cache_status: n.cache_status || null,
antibot_vendor: n.antibot_vendor || null,
});
}
@ -147,27 +160,35 @@
// with node count and pre-warm the simulation synchronously before
// first render so layout is already settled.
const N = nodes.length;
const linkDist = N > 80 ? 40 : N > 30 ? 55 : 70;
const chargeStr = N > 80 ? -60 : N > 30 ? -120 : -180;
const chargeStr = N > 80 ? -28 : N > 30 ? -55 : -90;
const R = Math.min(W, H) / 2;
// Three concentric rings : eye (centre) → sites (inner) → trackers
// (outer). The radial force is now the DOMINANT force (strong pull
// to the ring), charge is weak (just spreads nodes along the ring),
// and links are weak springs so they don't yank nodes off-ring.
const ringR = d => d.kind === 'eye' ? 0 : d.kind === 'site' ? R * 0.40 : R * 0.80;
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink([...links, ...accentLinks]).id(d => d.id)
.distance(d => d.spoke ? R * 0.42 : linkDist))
.force('charge', d3.forceManyBody().strength(chargeStr))
// Radial ring : sites on an inner orbit around the eye, trackers
// pushed to an outer ring — produces the "central hotspot" look.
.force('radial', d3.forceRadial(
d => d.kind === 'eye' ? 0 : d.kind === 'site' ? R * 0.42 : R * 0.78,
W / 2, H / 2,
).strength(d => d.kind === 'eye' ? 0 : d.kind === 'site' ? 0.25 : 0.12))
.force('collide', d3.forceCollide().radius(N > 80 ? 14 : 22))
.alphaDecay(0.05); // settle faster than the 0.0228 default
.distance(d => d.spoke ? R * 0.40 : 24).strength(0.08))
.force('charge', d3.forceManyBody().strength(chargeStr).distanceMax(R * 0.6))
.force('radial', d3.forceRadial(ringR, W / 2, H / 2)
.strength(d => d.kind === 'eye' ? 0 : 0.9))
.force('collide', d3.forceCollide().radius(N > 120 ? 9 : N > 60 ? 13 : 20))
.alphaDecay(0.04);
// Phase 11.B v3 — content group that owns links + nodes ; the
// d3.zoom behavior applies its transform here so pan/pinch don't
// move the SVG itself (or its viewBox).
const content = svg.append('g').attr('class', 'content');
// Visible ring guides — the "Round-Eye" levels : inner = your sites,
// outer = the trackers reaching in. Drawn first so they sit behind.
const guides = content.append('g').attr('class', 'ring-guides');
[['ring-inner', R * 0.40], ['ring-outer', R * 0.80]].forEach(([cls, rad]) => {
guides.append('circle').attr('class', cls)
.attr('cx', W / 2).attr('cy', H / 2).attr('r', rad);
});
const linkSel = content.append('g').attr('class', 'links')
.selectAll('line').data([...links, ...accentLinks]).join('line')
.attr('class', d => d.accent ? 'edge accent' : 'edge')
@ -193,6 +214,8 @@
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.
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)';
}
@ -204,16 +227,20 @@
eyeSel.append('circle').attr('class', 'eye-iris').attr('r', 7);
eyeSel.append('circle').attr('class', 'eye-pupil').attr('r', 3);
// Phase 12.B — anti-bot hosts get a severe pulsing warning ring.
nodeG.filter(d => d.kind === 'tracker' && d.antibot_vendor)
.append('circle').attr('class', 'antibot-ring').attr('r', 12);
// Site + tracker nodes.
nodeG.filter(d => d.kind !== 'eye').append('circle')
.attr('r', d => d.kind === 'tracker' ? 7 : 10)
.attr('fill', nodeColor)
.attr('stroke', d => (d.kind === 'tracker' && d.cdn_vendor) ? '#0a0a0f' : null)
.attr('stroke-width', d => (d.kind === 'tracker' && d.cdn_vendor) ? 1.5 : 0);
.attr('stroke', d => (d.kind === 'tracker' && (d.cdn_vendor || d.antibot_vendor)) ? '#0a0a0f' : null)
.attr('stroke-width', d => (d.kind === 'tracker' && (d.cdn_vendor || d.antibot_vendor)) ? 1.5 : 0);
nodeG.filter(d => d.kind !== 'eye').append('text')
.attr('x', 12).attr('y', 4)
.text(d => d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label);
.text(d => (d.antibot_vendor ? '🤖 ' : '') + (d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label));
// ─── pan + pinch-zoom on the SVG (transform applies to content) ──
// Drag on a node calls d3.drag, drag on empty SVG calls d3.zoom's
@ -299,6 +326,7 @@
bind('nd_country', '—'); // Phase C dependency (GeoIP)
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_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', ' ') : '—');