Compare commits

..

12 Commits

Author SHA1 Message Date
CyberMind
18f727b7d7
Merge pull request #565 from CyberMind-FR/feature/564-detect-antibot-split-deployment-vs-chall
Some checks are pending
License Headers / check (push) Waiting to run
detect_antibot: deployment vs challenge, response-level signals (#564, ref #516)
2026-06-13 18:27:01 +02:00
9bd5a958f2 fix(toolbox): detect_antibot deployment vs challenge (response-level) — ref #516 (closes #564)
detect_antibot(flow) -> (vendor, is_challenge). vendor = WAF/anti-bot
DEPLOYED (URL/cookie/header presence) -> deployment map, always recorded.
is_challenge = a challenge actually ISSUED on this response (response-level
only): Cloudflare cf-mitigated, or non-200 (403/429/503) text/html small
body with __cf_chl / challenges.cloudflare.com / cdn-cgi/challenge-platform
/ vendor block markers. Presence on a 200 is no longer counted as a
challenge (kills false positives from bm_sz/_abck, _px*, datadome cookie,
embedded reCAPTCHA). Only is_challenge feeds the per-client alert/severity.
Unit-tested 8 cases. secubox-toolbox 2.6.22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:26:39 +02:00
CyberMind
bbcfe7ad1a
Merge pull request #563 from CyberMind-FR/feature/562-ca-fingerprint-surface-the-r3-ca-on-the
fix(ca): /ca/fingerprint surfaces the R3 CA on the tunnel (#562)
2026-06-13 18:14:47 +02:00
dbe255c31e fix(toolbox): /ca/fingerprint surfaces the R3 CA on the tunnel (closes #562)
It always read the R1/R2 captive CA, so R3 users verifying their installed
cert saw the wrong fingerprint. Now ?ca=wg|default|auto; auto returns the
R3 CA (/etc/secubox/toolbox/ca-wg/mitmproxy-ca-cert.pem) when the request
arrives over the R3 tunnel (X-R3-Peer / 10.99.1.x), else R1/R2; response
gains a 'ca' field. Landing cert-probe fetches ?ca=wg. secubox-toolbox 2.6.21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:14:22 +02:00
CyberMind
52e51b2766
Merge pull request #561 from CyberMind-FR/feature/560-toolbox-protective-mode-tracker-alerting
Toolbox protective mode — tracker alerting + active spoofer (default OFF) (#560)
2026-06-13 16:03:44 +02:00
87614c7143 feat(toolbox): protective mode — tracker alerting + active spoofer, default OFF (closes #560)
New mitmproxy_addons/protective_mode.py. SECUBOX_PROTECTIVE_MODE =
off (default) | alert | spoof. Spoof neutralises classified 3rd-party
tracker hosts ONLY (1st-party untouched): strips operator-grade/tracking
headers (MSISDN, x-acr, x-up-*, x-wap-*, forwarded IPs), drops the Cookie
header + referer to the tracker, asserts DNT:1 + Sec-GPC:1. Alerting +
spoof actions → /var/log/secubox/audit.log; counts → /run/secubox/protective.json.
Wired into the mitm-wg launcher + mitm.service addon list (inert until
opted in). GET /admin/protective exposes mode + counters.

Doctrine: opt-in, default off, logged, reversible — not enabled on any
board by this package. secubox-toolbox 2.6.20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:03:33 +02:00
CyberMind
93cf0ebafa
Merge pull request #556 from CyberMind-FR/feature/555-favicons-of-major-sites-in-the-xpi-popup
Favicons of major sites in cartographie + XPI popup (drop IPs) (#555)
2026-06-13 16:02:52 +02:00
CyberMind
94f40c9162
Merge pull request #559 from CyberMind-FR/feature/558-apk-make-zero-tap-auto-run-robust-real-f
Android: real zero-tap full-auto onboarding (launch + boot) (#558)
2026-06-13 15:44:58 +02:00
c1fa245a6a feat(android): real zero-tap full-auto onboarding — launch + boot, no gate (closes #558)
Strengthen the zero-tap root onboarding (keeps it, doesn't kill it):
- launch auto-run: drop the per-host 'onboarded' gate (runs every launch,
  idempotent) + retry reachability ~9s so a WiFi/tunnel race no longer
  aborts the auto-run.
- boot auto-run: BootReceiver (BOOT_COMPLETED) → OnboardService foreground
  service runs the silent sequence WITHOUT opening the app (retries
  reachability ~30s), then stops. Rooted device self-onboards after a reboot.
-  button kept as a manual re-trigger.
Unavoidable (Android-mandated, not bugs): sideload install confirm + first
su grant. versionName 0.3.0 / versionCode 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:33:00 +02:00
6e83a4a065 feat(toolbox+webext): favicons of major sites in cartographie + popup, never IPs (closes #555)
- social.js eye-view: site + tracker nodes render the site favicon via the
  same-origin /social/favicon/{domain} proxy (7d cached, transparent 1×1
  fallback so the tier circle shows through), clipped to the bubble.
- webext popup top-tracker list gains a 16px favicon per row (api.faviconUrl
  helper). clients/webext-toolbox 0.1.2 ; /wg/toolbox.xpi tag-pin → webext-v0.1.2.
No IP/ASN displayed anywhere. secubox-toolbox 2.6.19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:27:57 +02:00
CyberMind
92b203cbbc
Merge pull request #554 from CyberMind-FR/feature/553-cartographie-graph-impact-sized-icon-bub
Cartographie donut-bubble view + geography rollups (#553)
2026-06-13 15:18:15 +02:00
7bd265fe1a feat(toolbox): cartographie donut-bubble view + geography rollups (closes #553)
User vision: 'bubble of donuts graph' — impact-sized bubbles, each a donut
by tier, agglomerated by country with continent bubbles; IP/ASN dropped.

Backend (social.py fetch_graph, additive, tested):
- nodes gain country_iso / country_flag / continent / tier
- by_country (flag + per-tier breakdown), by_continent, by_tier
- stats: total_countries / total_continents ; helpers _flag_emoji /
  _continent_of / _tracker_tier (no GeoIP/publicsuffix dep)

Frontend (social.js):
- '🍩 Donuts' view (default) ⇄ '👁️ Œil' toggle. d3.pack: continent
  backdrop bubbles → country donuts sized by impact, ring split by
  severity tier, flag in the hole, tier legend, click→country summary.
- Eye force-graph kept as fallback; detail panel now shows country flag.
secubox-toolbox 2.6.18.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:15:32 +02:00
23 changed files with 837 additions and 74 deletions

View File

@ -12,13 +12,20 @@ import the WireGuard profile, verify the tunnel, then open the live
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
5. **Live metrics** — opens `/social/me` (cartographie sociale).
## Root path — fully-automated silent onboarding (#538, #551)
When the device is **rooted**, the app runs the whole onboarding with **zero
taps**: on launch it auto-detects root and, if this cabine host hasn't been
onboarded yet, starts the silent sequence automatically (`RootAuto` step,
streaming log). The **⚡ Installation automatique (root)** button stays for
re-runs. The "already onboarded" flag is persisted per host (SharedPreferences)
so reopening the app doesn't redo it. Steps:
## Root path — real zero-tap, fully automated (#538, #551, #558)
On a **rooted** device the app onboards with **zero taps**, two ways:
- **On launch** — auto-detects root and runs the silent sequence immediately
every launch (no gate), retrying reachability while WiFi/tunnel settle.
- **On boot** — a `BOOT_COMPLETED` receiver starts a short foreground service
(`OnboardService`) that runs the same silent sequence **without opening the
app**, then stops. After one reboot the device self-onboards.
The **⚡ Installation automatique (root)** button remains as a manual
re-trigger. Two interactions are **mandated by Android and unavoidable** for
any app: the sideload install confirm ("install unknown apps") and the
first-time superuser (Magisk/su) grant prompt. Everything after those is
zero-tap. Steps:
1. **System CA install** — downloads `/wg/ca.pem`, computes the OpenSSL
`subject_hash_old` in pure Kotlin, and bind-mounts a populated copy of

View File

@ -12,8 +12,8 @@ android {
applicationId = "in.secubox.toolbox"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "0.2.0"
versionCode = 3
versionName = "0.3.0"
}
buildTypes {

View File

@ -4,6 +4,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- #558 full-auto: run the silent onboarding on device boot, no app open. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Query the WireGuard app so we can hand it the generated profile. -->
<queries>
<package android:name="com.wireguard.android" />
@ -26,6 +31,26 @@
</intent-filter>
</activity>
<!-- #558 : boot-completed → start the onboarding foreground service
so a rooted device self-onboards with zero taps after a reboot. -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".OnboardService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Silent R3 onboarding on a rooted, operator-owned cabine device" />
</service>
<!-- FileProvider to share the downloaded CA + WG .conf with the
system cert installer / the WireGuard app. -->
<provider

View File

@ -0,0 +1,23 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// #558 — boot-completed → kick the onboarding foreground service so a
// rooted, operator-owned cabine device self-onboards with zero taps after
// a reboot (no need to open the app).
package `in`.secubox.toolbox
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val a = intent?.action ?: return
if (a == Intent.ACTION_BOOT_COMPLETED || a == Intent.ACTION_LOCKED_BOOT_COMPLETED) {
ContextCompat.startForegroundService(
context, Intent(context, OnboardService::class.java),
)
}
}
}

View File

@ -70,12 +70,20 @@ fun OnboardApp() {
var autoTried by remember { mutableStateOf(false) }
// The whole root-mode silent run, reused by the ⚡ button AND the
// zero-tap auto-launch (#551). Persists an onboarded flag per host on
// success so reopening the app doesn't redo it.
// zero-tap auto-launch (#551/#558). NO onboarded gate — it auto-runs
// every launch (idempotent: re-asserts CA + WG). Reachability is
// RETRIED so a WiFi/tunnel race at launch doesn't kill the auto-run.
val runRootAuto: () -> Unit = {
busy = true; status = ""; rootLog.clear()
scope.launch {
val ok = withContext(Dispatchers.IO) { api.reachable() }
// poll reachability up to ~9 s (network may still be settling)
var ok = false
for (attempt in 1..6) {
ok = withContext(Dispatchers.IO) { api.reachable() }
if (ok) break
status = "Recherche de la borne… ($attempt)"
kotlinx.coroutines.delay(1500)
}
if (!ok) {
busy = false; status = "Borne injoignable — vérifie le réseau."
} else {
@ -86,7 +94,6 @@ fun OnboardApp() {
}
busy = false
onTunnel = out.verified
if (out.verified) prefs.edit().putBoolean("onboarded:$host", true).apply()
when {
out.verified -> step = Step.Done
out.wgViaApp -> { step = Step.ImportProfile
@ -100,12 +107,13 @@ fun OnboardApp() {
// Detect root once, off the main thread.
LaunchedEffect(Unit) { rootAvail = withContext(Dispatchers.IO) { RootShell.available() } }
// Zero-tap (#551): on a rooted device, auto-run the silent onboarding
// once on launch — unless this host was already onboarded.
// Zero-tap (#558): on a rooted device, auto-run the silent onboarding
// on every launch — no gate. (Boot-time auto-run is handled by
// BootReceiver + OnboardService so it runs without opening the app.)
LaunchedEffect(rootAvail) {
if (rootAvail && !autoTried && step == Step.Discover) {
autoTried = true
if (!prefs.getBoolean("onboarded:$host", false)) runRootAuto()
runRootAuto()
}
}

View File

@ -0,0 +1,77 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// #558 — full-auto onboarding service. Started on boot (BootReceiver). On a
// rooted device it runs the silent R3 onboarding (system CA + native WG +
// verify) with zero taps, retrying reachability while the network settles,
// then stops itself. Non-root / unreachable → it just stops (the launcher
// activity remains the manual path).
package `in`.secubox.toolbox
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class OnboardService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val CHAN = "sbx-onboard"
private val NID = 4201
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NID, buildNotification())
scope.launch {
try { runOnce() } finally { stopSelf() }
}
return START_NOT_STICKY
}
private suspend fun runOnce() {
// root is the precondition for the silent path; bail quietly otherwise.
if (!RootShell.available()) return
val host = getSharedPreferences("secubox-toolbox", Context.MODE_PRIVATE)
.getString("host", null) ?: "kbin.gk2.secubox.in"
val api = ToolboxApi(host)
// network may still be coming up after boot — retry ~30 s.
var ok = false
for (i in 1..15) {
ok = api.reachable()
if (ok) break
kotlinx.coroutines.delay(2000)
}
if (!ok) return
RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ }
}
private fun buildNotification(): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java)
nm?.createNotificationChannel(
NotificationChannel(CHAN, "SecuBox onboarding",
NotificationManager.IMPORTANCE_LOW),
)
}
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
Notification.Builder(this, CHAN) else @Suppress("DEPRECATION") Notification.Builder(this)
return b.setContentTitle("VILLAGE3B")
.setContentText("Activation R3 automatique…")
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.build()
}
override fun onDestroy() {
super.onDestroy()
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
}

View File

@ -31,7 +31,7 @@ to your cabine over the R3 tunnel — no third-party calls.
Published release `.xpi` (downloadable directly):
```
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.2/secubox-toolbox-webext.xpi
```
The toolbox also serves it from the cabine:

View File

@ -89,6 +89,12 @@ async function wipe(host, token) {
return await resp.json();
}
// Favicon of a major site/tracker via the cabine's server-side proxy
// (7-day cached PNG, transparent 1×1 fallback) — no third-party call.
function faviconUrl(host, domain) {
return `${baseUrl(host)}/social/favicon/${encodeURIComponent(domain || "")}`;
}
function socialUrl(host, token) {
return `${baseUrl(host)}/social/${token}`;
}
@ -106,6 +112,7 @@ const SbxApi = {
r3Check,
graph,
wipe,
faviconUrl,
socialUrl,
reportUrl,
tokenFromUrl,

View File

@ -15,7 +15,7 @@
set -euo pipefail
DEFAULT_HOST="kbin.gk2.secubox.in"
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi"
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.2/secubox-toolbox-webext.xpi"
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
say(){ printf '\033[1;36m▸\033[0m %s\n' "$*"; }

View File

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "SecuBox ToolBoX — Cartographie sociale",
"version": "0.1.1",
"version": "0.1.2",
"description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.",
"browser_specific_settings": {
"gecko": {

View File

@ -66,6 +66,7 @@ button.danger { color: var(--cinnabar); border-color: var(--cinnabar); }
display: flex; align-items: center; gap: 6px; padding: 3px 2px;
border-bottom: 1px solid #1a1a22; font-size: 11px;
}
.row .fav { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; background: #1a1a22; object-fit: contain; }
.row .dom { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .hits { color: var(--muted); }
.tier { font-size: 9px; padding: 1px 4px; border-radius: 3px; }

View File

@ -8,6 +8,7 @@
// SyntaxError that aborts popup.js. Use api.ext instead.
const api = globalThis.SbxApi;
const $ = (id) => document.getElementById(id);
let curHost = api.DEFAULTS.host; // for favicon URLs (#555)
function show(which) {
$("pair").hidden = which !== "pair";
@ -24,6 +25,14 @@ function fillTopList(nodes) {
.forEach((n) => {
const row = document.createElement("div");
row.className = "row";
// favicon of the major site/tracker (cabine proxy) — not an IP (#555)
const fav = document.createElement("img");
fav.className = "fav";
fav.loading = "lazy";
fav.alt = "";
fav.src = api.faviconUrl(curHost, n.domain || n.id);
fav.addEventListener("error", () => { fav.style.visibility = "hidden"; });
row.appendChild(fav);
const dom = document.createElement("span");
dom.className = "dom";
dom.textContent = n.domain || n.id;
@ -57,6 +66,7 @@ function paint(data) {
async function load() {
const cfg = await api.getConfig();
curHost = cfg.host || api.DEFAULTS.host;
$("ver").textContent = "v" + (api.ext.runtime.getManifest().version || "");
// tunnel indicator

View File

@ -423,7 +423,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
var fp = document.getElementById('cert-fp-r3');
if (!btn) return;
fetch('/ca/fingerprint').then(function(r){return r.json();}).then(function(d){
fetch('/ca/fingerprint?ca=wg').then(function(r){return r.json();}).then(function(d){
fp.textContent = d.sha1 || d.sha256 || '?';
}).catch(function(){fp.textContent='?';});

View File

@ -1,3 +1,84 @@
secubox-toolbox (2.6.22-1~bookworm1) bookworm; urgency=medium
* detect_antibot: deployment vs challenge, response-level signals (#564,
review of #516).
- detect_antibot(flow) → (vendor, is_challenge). vendor = WAF/anti-bot
DEPLOYED (URL script / cookie / header presence) → the "which sites
moved behind which WAF over time" map (record_host_antibot, always).
- is_challenge = an ACTUAL challenge issued on THIS response, response-
level only: Cloudflare cf-mitigated header (definitive), or a non-200
(403/429/503) text/html small body carrying __cf_chl /
challenges.cloudflare.com / cdn-cgi/challenge-platform / vendor block
markers. Presence on a 200 is NOT a challenge → kills the big false-
positive class (bm_sz/_abck, _px*, datadome cookie, embedded
reCAPTCHA all ride normal 200s). Only is_challenge feeds the per-
client alert + severity (record_antibot_challenge).
- Detection stays cleanly separate from bypass (doctrine).
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 17:15:00 +0200
secubox-toolbox (2.6.21-1~bookworm1) bookworm; urgency=medium
* fix(ca): /ca/fingerprint surfaces the R3 CA on the tunnel (#562).
- Was always reading the R1/R2 captive CA (/etc/secubox/toolbox/ca/
ca.pem, "Gondwana ToolBoX CA") — so R3 users verifying their
installed cert saw the WRONG fingerprint. The real R3 CA is
/etc/secubox/toolbox/ca-wg/mitmproxy-ca-cert.pem ("R3 CA").
- /ca/fingerprint now takes ?ca=wg|default|auto ; auto returns the
R3 CA when the request arrives over the R3 tunnel (X-R3-Peer /
10.99.1.x), else R1/R2. Response gains a "ca" field. Landing
cert-probe fetches ?ca=wg (it is the R3 probe).
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 16:45:00 +0200
secubox-toolbox (2.6.20-1~bookworm1) bookworm; urgency=medium
* Protective mode — tracker alerting + active spoofer (#560), DEFAULT OFF.
- mitmproxy_addons/protective_mode.py : env SECUBOX_PROTECTIVE_MODE =
off (default) | alert (detect + audit-log + count) | spoof (alert +
neutralise on classified 3rd-party tracker hosts only — strip
operator-grade/tracking headers MSISDN/x-acr/x-up-*/x-wap-*/
forwarded-IPs, drop the Cookie header to the tracker, strip referer,
assert DNT:1 + Sec-GPC:1). 1st-party traffic never touched.
- Every spoof action appended to /var/log/secubox/audit.log (CSPN) ;
live counts in /run/secubox/protective.json.
- Wired into the mitm-wg launcher + mitm.service addon list (inert
until opted in). GET /admin/protective : mode + counters.
Doctrine : opt-in, default off, logged, reversible (CM-WALL ; mirrors
Phase 13.D + Phase 8).
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 16:00:00 +0200
secubox-toolbox (2.6.19-1~bookworm1) bookworm; urgency=medium
* Favicons of major sites in the cartographie (#555) — never IPs.
- social.js eye-view: site + tracker nodes render the site favicon
(same-origin /social/favicon/{domain} proxy, 7-day cached,
transparent 1×1 fallback → the tier-coloured circle shows through),
clipped to the bubble. No IP/ASN displayed anywhere.
- Companion webext popup gains favicons in its top-tracker list
(clients/webext-toolbox 0.1.2). /wg/toolbox.xpi tag-pin → webext-v0.1.2.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 15:30:00 +0200
secubox-toolbox (2.6.18-1~bookworm1) bookworm; urgency=medium
* Cartographie donut-bubble view + geography rollups (#553).
- social.py fetch_graph: nodes gain country_iso / country_flag /
continent / tier ; new by_country, by_continent (flags + per-tier
breakdown) and by_tier aggregations (additive, read-time). stats
gains total_countries / total_continents. IP/ASN intentionally
NOT used as a graph dimension. Helpers _flag_emoji / _continent_of
/ _tracker_tier (no GeoIP/publicsuffix dep).
- social.js: new "🍩 Donuts" view (default, ⇄ "👁️ Œil" toggle) —
a d3.pack of continent backdrop bubbles, each country a donut sized
by tracking impact (hits), ring split by severity tier, flag in the
hole ; tier legend ; click → country summary in the detail panel.
Eye force-graph kept as one-click fallback ; node detail now fills
the country flag.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 14:30:00 +0200
secubox-toolbox (2.6.17-1~bookworm1) bookworm; urgency=medium
* Social correlation: domain-rollup + history + target↔tracker (#549).

View File

@ -0,0 +1,141 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# Phase 14 sketch (#560, refs #525/#514/#500) — Toolbox PROTECTIVE MODE :
# tracker alerting + active spoofer.
#
# Doctrine (CM-WALL) : active interference is OPT-IN, DEFAULT OFF, LOGGED,
# REVERSIBLE — mirrors Phase 13.D escalate + Phase 8 utiq_defense. It only
# ever touches classified **3rd-party tracker** hosts ; 1st-party traffic is
# never modified, so pages keep working.
#
# Levels — env `SECUBOX_PROTECTIVE_MODE` (default `off`) :
# off passthrough (no-op).
# alert detect + audit-log + count tracker flows. No modification.
# spoof alert + neutralise on tracker hosts only :
# - strip operator-grade / tracking request headers
# (MSISDN, x-acr, x-up-calling-line-id, x-wap-*, forwarded IPs)
# - drop the Cookie header sent to the tracker (kills cookie reuse)
# - assert DNT:1 + Sec-GPC:1
# Every spoof action is appended to /var/log/secubox/audit.log.
from __future__ import annotations
import logging
import os
import re
import time
from mitmproxy import http
log = logging.getLogger("secubox.toolbox.protective")
_AUDIT = "/var/log/secubox/audit.log"
_STATS = "/run/secubox/protective.json"
# 3rd-party tracker hosts (mirror of inject_banner's _TRACKER_PATTERNS).
_TRACKER = re.compile(
r"(?:^|\.)(?:"
r"doubleclick|googlesyndication|googleadservices|googletagmanager|"
r"google-analytics|googletagservices|adservice\.google|"
r"facebook\.com/tr|connect\.facebook\.net|facebook\.net|"
r"scorecardresearch|chartbeat|hotjar|mixpanel|amplitude|"
r"segment\.com|segment\.io|criteo|adnxs|rubiconproject|"
r"taboola|outbrain|smartadserver|optimizely|fullstory|"
r"newrelic|datadog|sentry|amazon-adsystem|adsrvr|adform|"
r"yieldlove|moatads|adsystem|adserver|liveramp|bluekai|"
r"krxd|demdex|agkn|tapad|exelator|utiq"
r")",
re.IGNORECASE,
)
# Operator-grade / tracking request headers stripped in spoof mode.
_STRIP = (
"msisdn", "x-msisdn", "x-up-calling-line-id", "x-up-subno",
"x-nokia-msisdn", "x-acr", "x-vf-acr", "x-amobee-1", "x-amobee-2",
"tm-user-id", "x-wap-profile", "x-wap-msisdn", "x-network-info",
"x-forwarded-for", "forwarded", "x-real-ip", "via",
)
_counts = {"alerts": 0, "spoofs": 0, "since": int(time.time())}
_last_flush = 0.0
def _level() -> str:
v = (os.environ.get("SECUBOX_PROTECTIVE_MODE") or "off").strip().lower()
return v if v in ("off", "alert", "spoof") else "off"
def _is_tracker(host: str) -> bool:
return bool(host) and bool(_TRACKER.search(host))
def _audit(action: str, host: str, detail: str) -> None:
try:
line = "%s protective %s host=%s %s\n" % (
time.strftime("%Y-%m-%dT%H:%M:%S%z"), action, host, detail)
with open(_AUDIT, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass # audit is best-effort ; never break the flow
def _flush_stats(force: bool = False) -> None:
global _last_flush
now = time.time()
if not force and (now - _last_flush) < 5:
return
_last_flush = now
try:
import json
os.makedirs(os.path.dirname(_STATS), exist_ok=True)
with open(_STATS, "w", encoding="utf-8") as f:
json.dump({**_counts, "mode": _level(), "updated": int(now)}, f)
except Exception:
pass
class ProtectiveMode:
"""Alert on, and (spoof level) actively neutralise, tracker flows."""
def requestheaders(self, flow: http.HTTPFlow) -> None:
level = _level()
if level == "off":
return
host = flow.request.pretty_host or ""
if not _is_tracker(host):
return
_counts["alerts"] += 1
if level == "alert":
_audit("alert", host, "path=%s" % (flow.request.path or "")[:120])
_flush_stats()
return
# ── spoof ──
stripped = []
for h in _STRIP:
if h in flow.request.headers:
del flow.request.headers[h]
stripped.append(h)
# kill cookie reuse to the tracker
had_cookie = "cookie" in flow.request.headers
if had_cookie:
del flow.request.headers["cookie"]
# strip a referer that would leak the 1st-party page to the tracker
if "referer" in flow.request.headers:
del flow.request.headers["referer"]
stripped.append("referer")
# assert the opt-out signals
flow.request.headers["DNT"] = "1"
flow.request.headers["Sec-GPC"] = "1"
flow.request.headers["X-SecuBox-Protected"] = "spoof"
_counts["spoofs"] += 1
_audit("spoof", host, "stripped=%s cookie=%s" % (
",".join(stripped) or "-", "drop" if had_cookie else "-"))
_flush_stats()
log.info("[protective spoof] %s stripped=%d cookie=%s",
host, len(stripped), had_cookie)
addons = [ProtectiveMode()]

View File

@ -340,28 +340,62 @@ _ANTIBOT_HEADER = (
("PerimeterX/HUMAN", ("x-px",)),
)
# Response-level CHALLENGE signals (#516) — distinct from deployment above.
_CHALLENGE_STATUSES = {403, 429, 503}
# Markers found in an actual challenge/block interstitial body.
_CHALLENGE_BODY = (
b"__cf_chl", b"challenges.cloudflare.com", b"/cdn-cgi/challenge-platform",
b"cf-challenge", b"px-captcha", b"_pxcaptcha", b"datadome",
b"captcha-delivery", b"verify you are human", b"are you a robot",
b"enable javascript and cookies",
)
_MAX_CHALLENGE_PEEK = 262144 # 256 KiB — challenge pages are small
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.
def detect_antibot(flow) -> tuple:
"""Return ``(vendor, is_challenge)``.
Per #516, keep DEPLOYMENT separate from an actual CHALLENGE:
- **vendor** the anti-bot / WAF vendor *deployed* on this flow,
from request-URL script fragments, cookie names, or response
headers. Presence only (e.g. a ``bm_sz`` cookie or an embedded
reCAPTCHA on a normal 200). Used for the "which sites moved behind
which WAF over time" map. ``None`` if no vendor signature.
- **is_challenge** True only when a challenge was actually *issued*
on THIS response, a response-level signal (never content on a 200):
* Cloudflare ``cf-mitigated`` header (definitive only set on a
challenge response ; its value carries the cType), or
* a non-200 (403/429/503) ``text/html`` response whose small body
carries a challenge token / script.
A large 200 body that merely *mentions* a vendor is NOT a challenge.
Best-effort, passive.
"""
vendor = None
is_challenge = False
try:
url = (flow.request.pretty_url or "").lower()
for vendor, frags in _ANTIBOT_URL:
for v, frags in _ANTIBOT_URL:
if any(f in url for f in frags):
return vendor
vendor = v
break
# 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
status = flow.response.status_code if flow.response else 0
# Cookie names (both directions)
if rh is not None:
# Cloudflare's definitive signal — present only on a challenge.
if rh.get("cf-mitigated"):
vendor = vendor or "Cloudflare"
is_challenge = True
keys = " ".join(k.lower() for k in rh.keys())
for v, hdrs in _ANTIBOT_HEADER:
if any(h in keys for h in hdrs):
vendor = vendor or v
break
# Cookie names (both directions) — DEPLOYMENT signal.
blobs = []
try:
blobs.extend(flow.request.headers.get_all("cookie") or [])
@ -374,12 +408,37 @@ def detect_antibot(flow) -> Optional[str]:
pass
joined = " ".join(blobs).lower()
if joined:
for vendor, names in _ANTIBOT_COOKIE:
for v, names in _ANTIBOT_COOKIE:
if any(n in joined for n in names):
return vendor
vendor = vendor or v
break
# Challenge by status + small text/html body. This is the
# false-positive killer: only non-200 interstitials count.
if not is_challenge and status in _CHALLENGE_STATUSES and rh is not None:
ct = (rh.get("content-type", "") or "").lower()
if "text/html" in ct:
try:
cl = int(rh.get("content-length", "0") or "0")
except (TypeError, ValueError):
cl = 0
if cl <= _MAX_CHALLENGE_PEEK:
try:
body = (flow.response.content or b"")[:_MAX_CHALLENGE_PEEK].lower()
except Exception:
body = b""
if any(tok in body for tok in _CHALLENGE_BODY):
is_challenge = True
if vendor is None and (b"cf" in body or b"cloudflare" in body
or b"cdn-cgi" in body):
vendor = "Cloudflare"
elif vendor is not None:
# non-200 from a known WAF vendor + tiny html page
# → a block / challenge interstitial.
is_challenge = True
except Exception:
pass
return None
return vendor, is_challenge
# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ──
@ -485,7 +544,7 @@ class SocialGraph:
# per-client "challenged your humanity" alert is accurate.
try:
resp_host = _registrable_domain(flow.request.host)
antibot = detect_antibot(flow)
antibot, antibot_challenge = detect_antibot(flow)
if resp_host and resp_host != src_site:
cdn_vendor, cache_status = detect_cdn(flow.response.headers)
if cdn_vendor:
@ -495,13 +554,18 @@ class SocialGraph:
cache_status=cache_status,
)
if antibot and resp_host:
# DEPLOYMENT — always: this host sits behind WAF `antibot`
# (the "which sites moved behind which WAF" map). #516
_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,
)
# CHALLENGE — only when one was actually ISSUED on this
# response. Presence on a 200 is deployment, not a challenge,
# so it must NOT inflate the per-client "challenged your
# humanity" alert / severity.
if antibot_challenge:
_social.record_antibot_challenge(
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:

View File

@ -16,7 +16,7 @@ DEST_DIR="/var/lib/secubox/toolbox/webext"
DEST="${DEST_DIR}/secubox-toolbox-webext.xpi"
# Tag-pinned (not /latest/): the webext release is make_latest:false so it
# doesn't steal "latest" from the Android APK release. Bump on new webext-v*.
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi"
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.2/secubox-toolbox-webext.xpi"
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }

View File

@ -104,7 +104,10 @@ fi
# must run BEFORE inject_banner so the banner cookies our addon
# emits don't pollute the graph
# - cert_pin_detect auto-learns pinned hosts (Phase 6.N)
for addon in inject_xff utiq_defense local_store social_graph inject_banner dpi cookies avatar ja4 soc_relay cert_pin_detect; do
# protective_mode (#560) runs right after utiq_defense — early, so spoof-level
# header/cookie stripping happens before the logging addons record the flow.
# Inert unless SECUBOX_PROTECTIVE_MODE=alert|spoof (default off).
for addon in inject_xff utiq_defense protective_mode local_store social_graph inject_banner dpi cookies avatar ja4 soc_relay cert_pin_detect; do
ARGS+=(-s "$ADDON_DIR/${addon}.py")
done

View File

@ -944,31 +944,52 @@ async def admin_filter_regex() -> dict:
return {"regex": regex, "count": len(entries)}
@router.get("/ca/fingerprint")
async def ca_fingerprint() -> dict:
"""Expose CA SHA1/SHA256 fingerprints so user can verify against their
iPhone Settings Cert Trust UI. CSPN R2 transparency requirement."""
def _ca_fp(ca_pem) -> dict:
"""SHA1/SHA256/subject of a CA pem via openssl. '?' on any failure."""
import subprocess
out = {"sha1": "?", "sha256": "?", "subject": "?"}
if not ca_pem.exists():
return out
try:
for key, flag in (("sha1", "-sha1"), ("sha256", "-sha256")):
out[key] = subprocess.run(
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", flag],
capture_output=True, text=True, timeout=2, check=False,
).stdout.split("=", 1)[-1].strip()
out["subject"] = subprocess.run(
["openssl", "x509", "-in", str(ca_pem), "-noout", "-subject"],
capture_output=True, text=True, timeout=2, check=False,
).stdout.split("=", 1)[-1].strip()
except Exception:
pass
return out
@router.get("/ca/fingerprint")
async def ca_fingerprint(request: Request, ca: str = "auto") -> dict:
"""Expose CA SHA1/SHA256 fingerprints so the user can verify against
their device's Cert Trust UI. CSPN R2/R3 transparency requirement.
?ca=wg the R3 WireGuard CA ; ?ca=default the R1/R2 captive CA ;
auto (default) R3 when the request arrives over the R3 tunnel
(X-R3-Peer / 10.99.1.x), else R1/R2. Fixes the landing cert-probe
showing the wrong (R1/R2) fingerprint to R3 users.
"""
from pathlib import Path
ca_pem = Path("/etc/secubox/toolbox/ca/ca.pem")
sha1 = sha256 = subject = "?"
if ca_pem.exists():
try:
sha1 = subprocess.run(
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", "-sha1"],
capture_output=True, text=True, timeout=2, check=False,
).stdout.split("=", 1)[-1].strip()
sha256 = subprocess.run(
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", "-sha256"],
capture_output=True, text=True, timeout=2, check=False,
).stdout.split("=", 1)[-1].strip()
subject = subprocess.run(
["openssl", "x509", "-in", str(ca_pem), "-noout", "-subject"],
capture_output=True, text=True, timeout=2, check=False,
).stdout.split("=", 1)[-1].strip()
except Exception:
pass
return {"sha1": sha1, "sha256": sha256, "subject": subject}
r3 = Path("/etc/secubox/toolbox/ca-wg/mitmproxy-ca-cert.pem")
default = Path("/etc/secubox/toolbox/ca/ca.pem")
want_wg = False
if ca == "wg":
want_wg = True
elif ca == "auto":
peer = request.headers.get("x-r3-peer", "") or ""
xff = request.headers.get("x-forwarded-for", "") or ""
if peer.startswith("10.99.1.") or "10.99.1." in xff:
want_wg = True
path = r3 if (want_wg and r3.exists()) else default
out = _ca_fp(path)
out["ca"] = "r3" if path == r3 else "default"
return out
# Phase 3.x (#497) : 3 QR code endpoints — splash, cert install, webclip
@ -1374,7 +1395,7 @@ async def wg_toolbox_apk() -> Response:
_WEBEXT_XPI = Path("/var/lib/secubox/toolbox/webext/secubox-toolbox-webext.xpi")
_WEBEXT_XPI_RELEASE = (
"https://github.com/CyberMind-FR/secubox-deb/releases/download/"
"webext-v0.1.1/secubox-toolbox-webext.xpi"
"webext-v0.1.2/secubox-toolbox-webext.xpi"
)
@ -2382,6 +2403,28 @@ async def admin_escalate() -> dict:
return out
@router.get("/admin/protective")
async def admin_protective() -> dict:
"""#560 — protective-mode status + counters. Read-only.
mode comes from SECUBOX_PROTECTIVE_MODE (default off) ; the live
alert/spoof counts from the addon's state file.
"""
import json as _json
import os as _os
from pathlib import Path as _P
out: dict = {
"mode": (_os.environ.get("SECUBOX_PROTECTIVE_MODE") or "off").lower(),
"alerts": 0, "spoofs": 0, "since": None, "updated": None,
}
try:
st = _P("/run/secubox/protective.json")
if st.exists():
out.update(_json.loads(st.read_text()))
except Exception:
pass
return out
@router.get("/social/report/{token}.pdf")
async def social_report_pdf(token: str) -> Response:
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.

View File

@ -708,6 +708,58 @@ def _registrable_domain(host: str) -> str:
return last_two
# ── geography helpers (#553) : flag + continent rollup, no IP/ASN ──
def _flag_emoji(iso2: Optional[str]) -> str:
"""ISO-3166 alpha-2 → flag emoji (regional indicator pair). '' if unknown."""
if not iso2 or len(iso2) != 2 or not iso2.isalpha():
return ""
base = 0x1F1E6
return "".join(chr(base + (ord(ch) - ord("A"))) for ch in iso2.upper())
# Compact ISO-3166 alpha-2 → continent map (covers the countries a French
# civic kiosk realistically surfaces ; unknowns fall back to "Autre").
_CONTINENT = {
# Europe
"FR": "Europe", "DE": "Europe", "GB": "Europe", "UK": "Europe", "IE": "Europe",
"NL": "Europe", "BE": "Europe", "LU": "Europe", "ES": "Europe", "PT": "Europe",
"IT": "Europe", "CH": "Europe", "AT": "Europe", "SE": "Europe", "NO": "Europe",
"FI": "Europe", "DK": "Europe", "PL": "Europe", "CZ": "Europe", "RO": "Europe",
"BG": "Europe", "GR": "Europe", "HU": "Europe", "SK": "Europe", "HR": "Europe",
"RS": "Europe", "UA": "Europe", "RU": "Europe", "IS": "Europe", "EE": "Europe",
"LV": "Europe", "LT": "Europe", "SI": "Europe",
# Americas
"US": "Amériques", "CA": "Amériques", "MX": "Amériques", "BR": "Amériques",
"AR": "Amériques", "CL": "Amériques", "CO": "Amériques", "PE": "Amériques",
# Asia
"CN": "Asie", "JP": "Asie", "KR": "Asie", "IN": "Asie", "SG": "Asie",
"HK": "Asie", "TW": "Asie", "TH": "Asie", "ID": "Asie", "VN": "Asie",
"IL": "Asie", "TR": "Asie", "AE": "Asie", "SA": "Asie",
# Africa
"ZA": "Afrique", "EG": "Afrique", "MA": "Afrique", "DZ": "Afrique",
"TN": "Afrique", "NG": "Afrique", "KE": "Afrique", "SN": "Afrique",
# Oceania
"AU": "Océanie", "NZ": "Océanie",
}
def _continent_of(iso2: Optional[str]) -> str:
if not iso2:
return "Inconnu"
return _CONTINENT.get(iso2.upper(), "Autre")
def _tracker_tier(opgrade, antibot, cdn) -> str:
"""Severity tier used for the donut breakdown (highest wins)."""
if opgrade:
return "opgrade"
if antibot:
return "antibot"
if cdn:
return "cdn"
return "other"
def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"""Return the per-client graph JSON contract.
@ -727,8 +779,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, "
"m.opgrade_vendor, m.opgrade_category "
"n.sites_jsonl, n.country_iso, 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 >= ? "
@ -739,6 +791,7 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
sites = json.loads(r["sites_jsonl"])
except Exception:
sites = []
cc = (r["country_iso"] or "").upper() or None
out["nodes"].append(
{
"id": r["tracker_domain"],
@ -748,6 +801,11 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"sites": sites,
"first_seen": r["first_seen"],
"last_seen": r["last_seen"],
"country_iso": cc,
"country_flag": _flag_emoji(cc),
"continent": _continent_of(cc),
"tier": _tracker_tier(r["opgrade_vendor"], r["antibot_vendor"],
r["cdn_vendor"]),
"cdn_vendor": r["cdn_vendor"],
"cache_status": r["cache_status"],
"antibot_vendor": r["antibot_vendor"],
@ -871,10 +929,52 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
})
out["history"] = history
# (d) #553 geography + tier rollups for the donut-bubble view.
_TIERS = ("opgrade", "antibot", "cdn", "other")
tier_tot = {t: 0 for t in _TIERS}
_ctry: Dict[str, dict] = {}
_cont: Dict[str, dict] = {}
for n in out["nodes"]:
tier = n["tier"]; hits = n["hits"] or 0
tier_tot[tier] += hits
cc = n["country_iso"] or "??"
ct = _ctry.setdefault(cc, {
"country_iso": None if cc == "??" else cc,
"flag": n["country_flag"], "continent": n["continent"],
"tracker_count": 0, "hits": 0,
"tiers": {t: 0 for t in _TIERS}, "_sites": set(),
})
ct["tracker_count"] += 1; ct["hits"] += hits
ct["tiers"][tier] += hits; ct["_sites"].update(n["sites"])
co = _cont.setdefault(n["continent"], {
"continent": n["continent"], "country_count_set": set(),
"tracker_count": 0, "hits": 0, "tiers": {t: 0 for t in _TIERS},
})
co["country_count_set"].add(cc)
co["tracker_count"] += 1; co["hits"] += hits
co["tiers"][tier] += hits
by_country = sorted(
({"country_iso": v["country_iso"], "flag": v["flag"],
"continent": v["continent"], "tracker_count": v["tracker_count"],
"hits": v["hits"], "sites_count": len(v["_sites"]),
"tiers": v["tiers"]} for v in _ctry.values()),
key=lambda x: -x["hits"])
by_continent = sorted(
({"continent": v["continent"],
"country_count": len(v["country_count_set"]),
"tracker_count": v["tracker_count"], "hits": v["hits"],
"tiers": v["tiers"]} for v in _cont.values()),
key=lambda x: -x["hits"])
out["by_country"] = by_country
out["by_continent"] = by_continent
out["by_tier"] = tier_tot
out["stats"] = {
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
"total_sites": sites_count,
"total_domains": len(by_domain),
"total_countries": len([c for c in by_country if c["country_iso"]]),
"total_continents": len([c for c in by_continent if c["continent"] not in ("Inconnu",)]),
"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}),

View File

@ -25,6 +25,7 @@ ExecStart=/usr/bin/mitmdump \
-s /usr/lib/secubox/toolbox/mitmproxy_addons/ja4.py \
-s /usr/lib/secubox/toolbox/mitmproxy_addons/soc_relay.py \
-s /usr/lib/secubox/toolbox/mitmproxy_addons/inject_banner.py \
-s /usr/lib/secubox/toolbox/mitmproxy_addons/protective_mode.py \
-s /usr/lib/secubox/toolbox/mitmproxy_addons/local_store.py
Restart=on-failure
RestartSec=5

View File

@ -70,6 +70,54 @@
// ─── graph state ───
let simulation = null;
let lastGraph = null;
let view = 'donuts'; // #553 : 'donuts' (default) | 'eye'
// Severity-tier palette shared by the donut view + legend.
const TIER = {
opgrade: { c: 'var(--void-purple)', label: '📡 opérateur' },
antibot: { c: 'var(--cinnabar)', label: '🤖 anti-bot' },
cdn: { c: 'var(--cyber-cyan)', label: '☁ CDN' },
other: { c: 'var(--gold-hermetic)', label: '• autre' },
};
const TIER_ORDER = ['opgrade', 'antibot', 'cdn', 'other'];
function draw(graph) {
if (!graph) return;
if (view === 'donuts') renderDonuts(graph);
else render(graph);
}
// Inject the view toggle (🍩 donuts ⇄ 👁️ œil) once, above the svg.
function ensureToggle() {
if (document.getElementById('view-toggle') || !svgEl) return;
const bar = document.createElement('div');
bar.id = 'view-toggle';
bar.style.cssText = 'display:flex;gap:.4rem;justify-content:center;margin:.4rem 0';
const mk = (id, txt) => {
const b = document.createElement('button');
b.type = 'button'; b.dataset.view = id; b.textContent = txt;
b.style.cssText = 'cursor:pointer;border:1px solid var(--void-purple,#6e40c9);'
+ 'background:transparent;color:var(--text-primary,#e8e6d9);border-radius:6px;'
+ 'padding:.35rem .8rem;font:inherit';
b.addEventListener('click', () => {
view = id; syncToggle();
draw(lastGraph);
});
return b;
};
bar.appendChild(mk('donuts', '🍩 Donuts'));
bar.appendChild(mk('eye', '👁️ Œil'));
svgEl.parentNode.insertBefore(bar, svgEl);
syncToggle();
}
function syncToggle() {
document.querySelectorAll('#view-toggle button').forEach((b) => {
const on = b.dataset.view === view;
b.style.background = on ? 'var(--void-purple,#6e40c9)' : 'transparent';
b.style.color = on ? '#0a0a0f' : 'var(--text-primary,#e8e6d9)';
});
}
function svgSize() {
// Measure actual rendered size so the force center scales with the
@ -130,6 +178,8 @@
sites: n.sites,
first_seen: n.first_seen,
last_seen: n.last_seen,
country_iso: n.country_iso || null,
country_flag: n.country_flag || '',
cdn_vendor: n.cdn_vendor || null,
cache_status: n.cache_status || null,
antibot_vendor: n.antibot_vendor || null,
@ -258,6 +308,19 @@
.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);
// #555 — favicon of the major site/tracker (same-origin cabine proxy,
// 7-day cached, transparent 1×1 fallback so the coloured tier circle
// shows through when there's no icon). No IP shown anywhere.
nodeG.filter(d => d.kind !== 'eye').append('image')
.attr('href', d => '/social/favicon/' + encodeURIComponent(d.label || ''))
.attr('width', d => (d.kind === 'tracker' ? 7 : 10) * 1.7)
.attr('height', d => (d.kind === 'tracker' ? 7 : 10) * 1.7)
.attr('x', d => -(d.kind === 'tracker' ? 7 : 10) * 0.85)
.attr('y', d => -(d.kind === 'tracker' ? 7 : 10) * 0.85)
.attr('preserveAspectRatio', 'xMidYMid slice')
.attr('clip-path', 'circle()')
.attr('pointer-events', 'none');
nodeG.filter(d => d.kind !== 'eye').append('text')
.attr('x', 12).attr('y', 4)
.text(d => (d.antibot_vendor ? '🤖 ' : '') + (d.label.length > 22 ? d.label.slice(0, 21) + '…' : d.label));
@ -339,11 +402,118 @@
});
}
// ─── #553 donut-bubble view ───
// Continents are faint backdrop bubbles ; each country is a donut sized
// by tracking impact (hits), its ring split by severity tier, with the
// flag in the centre. No IP/ASN — geography + impact + tier only.
function renderDonuts(graph) {
clearGraph();
const { W, H } = svgSize();
svg.attr('viewBox', `0 0 ${W} ${H}`);
bind('total_trackers', graph.stats.total_trackers || 0);
bind('total_sites', graph.stats.total_sites || 0);
updateAntibotTile(graph.stats.antibot_sites || 0, graph.stats.antibot_vendors || []);
updateOpgradeTile(graph.stats.opgrade_sites || 0, graph.stats.opgrade_vendors || []);
const countries = graph.by_country || [];
if (!countries.length) return;
// hierarchy : root → continent → country(leaf, value=hits)
const byCont = new Map();
for (const c of countries) {
const k = c.continent || 'Autre';
if (!byCont.has(k)) byCont.set(k, []);
byCont.get(k).push(c);
}
const root = {
name: 'root',
children: [...byCont.entries()].map(([cont, list]) => ({
name: cont, continent: true,
children: list.map(c => ({
leaf: true, name: c.country_iso || '??', flag: c.flag || '🏴',
value: Math.max(c.hits || 0, 1), tiers: c.tiers || {},
tracker_count: c.tracker_count || 0,
})),
})),
};
const h = d3.hierarchy(root).sum(d => d.value || 0)
.sort((a, b) => (b.value || 0) - (a.value || 0));
d3.pack().size([W, H]).padding(d => d.depth === 1 ? 14 : 4)(h);
const content = svg.append('g').attr('class', 'content');
// continent backdrop bubbles + labels
content.append('g').selectAll('circle.cont')
.data(h.descendants().filter(d => d.depth === 1)).join('circle')
.attr('class', 'cont')
.attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => d.r)
.attr('fill', 'rgba(110,64,201,0.06)')
.attr('stroke', 'rgba(110,64,201,0.45)').attr('stroke-dasharray', '3,3');
content.append('g').selectAll('text.cont')
.data(h.descendants().filter(d => d.depth === 1)).join('text')
.attr('class', 'cont').attr('x', d => d.x).attr('y', d => d.y - d.r + 14)
.attr('text-anchor', 'middle').attr('fill', 'var(--void-purple,#9e76ff)')
.attr('font-size', 12).attr('font-weight', 'bold')
.text(d => '🌍 ' + d.data.name);
// country donut bubbles
const pie = d3.pie().sort(null).value(d => d[1]);
const leaves = content.append('g').selectAll('g.country')
.data(h.leaves()).join('g')
.attr('class', 'country node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.style('cursor', 'pointer')
.on('click', (ev, d) => focusCountry(d.data));
leaves.each(function (d) {
const g = d3.select(this);
const r = d.r;
const inner = Math.max(r * 0.5, r - 7);
const arc = d3.arc().innerRadius(inner).outerRadius(r);
const data = TIER_ORDER.map(k => [k, (d.data.tiers && d.data.tiers[k]) || 0])
.filter(e => e[1] > 0);
const slices = data.length ? pie(data) : pie([['other', 1]]);
g.selectAll('path').data(slices).join('path')
.attr('d', arc)
.attr('fill', s => (TIER[s.data[0]] || TIER.other).c)
.attr('stroke', '#0a0a0f').attr('stroke-width', 0.6);
// flag (or ISO) in the hole
g.append('text').attr('text-anchor', 'middle').attr('dy', '.35em')
.attr('font-size', Math.max(9, Math.min(inner * 1.1, 26)))
.text(d.data.flag || d.data.name);
});
// tier legend (bottom-left)
const lg = content.append('g').attr('transform', `translate(12,${H - 12 - TIER_ORDER.length * 16})`);
TIER_ORDER.forEach((k, i) => {
const row = lg.append('g').attr('transform', `translate(0,${i * 16})`);
row.append('rect').attr('width', 10).attr('height', 10).attr('rx', 2)
.attr('fill', TIER[k].c);
row.append('text').attr('x', 15).attr('y', 9).attr('font-size', 10)
.attr('fill', 'var(--text-muted,#6b6b7a)').text(TIER[k].label);
});
}
// Donut country click → reuse the detail panel with a country summary.
function focusCountry(c) {
if (!ndEl) return;
bind('nd_domain', (c.flag || '') + ' ' + (c.name || '?'));
bind('nd_country', (c.flag || '') + ' ' + (c.name || '—'));
bind('nd_asn', '—');
const tier = TIER_ORDER.filter(k => (c.tiers || {})[k])
.map(k => `${TIER[k].label}:${c.tiers[k]}`).join(' · ') || '—';
bind('nd_cdn', tier);
bind('nd_antibot', (c.tiers && c.tiers.antibot) ? '🤖 ' + c.tiers.antibot : '—');
bind('nd_opgrade', (c.tiers && c.tiers.opgrade) ? '📡 ' + c.tiers.opgrade : '—');
bind('nd_sites', (c.tracker_count || 0) + ' traceurs');
bind('nd_first_seen', '—'); bind('nd_last_seen', '—');
ndEl.hidden = false;
}
// ─── focus / detail panel ───
function focusNode(node, linkSel) {
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }
bind('nd_domain', node.label);
bind('nd_country', '—'); // Phase C dependency (GeoIP)
bind('nd_country', node.country_flag ? (node.country_flag + ' ' + (node.country_iso || '')) : '—');
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) : '—');
@ -413,7 +583,9 @@
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
if (!r.ok) throw new Error('http ' + r.status);
const g = await r.json();
render(g);
lastGraph = g;
ensureToggle();
draw(g);
} catch (e) {
console.error('[social] fetch failed', e);
}

View File

@ -18,7 +18,7 @@ R3 tunnel — no third-party calls.
Published release `.xpi` (downloadable directly):
```
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.2/secubox-toolbox-webext.xpi
```
The toolbox also serves it from the cabine: