mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
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:
parent
a93d949ede
commit
9f850bef53
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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', ' ') : '—');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user