mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 07:26:08 +00:00
Compare commits
12 Commits
dc6505a2f2
...
18f727b7d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f727b7d7 | ||
| 9bd5a958f2 | |||
|
|
bbcfe7ad1a | ||
| dbe255c31e | |||
|
|
52e51b2766 | ||
| 87614c7143 | |||
|
|
93cf0ebafa | ||
|
|
94f40c9162 | ||
| c1fa245a6a | |||
| 6e83a4a065 | |||
|
|
92b203cbbc | ||
| 7bd265fe1a |
|
|
@ -12,13 +12,20 @@ import the WireGuard profile, verify the tunnel, then open the live
|
||||||
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
|
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
|
||||||
5. **Live metrics** — opens `/social/me` (cartographie sociale).
|
5. **Live metrics** — opens `/social/me` (cartographie sociale).
|
||||||
|
|
||||||
## Root path — fully-automated silent onboarding (#538, #551)
|
## Root path — real zero-tap, fully automated (#538, #551, #558)
|
||||||
When the device is **rooted**, the app runs the whole onboarding with **zero
|
On a **rooted** device the app onboards with **zero taps**, two ways:
|
||||||
taps**: on launch it auto-detects root and, if this cabine host hasn't been
|
|
||||||
onboarded yet, starts the silent sequence automatically (`RootAuto` step,
|
- **On launch** — auto-detects root and runs the silent sequence immediately
|
||||||
streaming log). The **⚡ Installation automatique (root)** button stays for
|
every launch (no gate), retrying reachability while WiFi/tunnel settle.
|
||||||
re-runs. The "already onboarded" flag is persisted per host (SharedPreferences)
|
- **On boot** — a `BOOT_COMPLETED` receiver starts a short foreground service
|
||||||
so reopening the app doesn't redo it. Steps:
|
(`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
|
1. **System CA install** — downloads `/wg/ca.pem`, computes the OpenSSL
|
||||||
`subject_hash_old` in pure Kotlin, and bind-mounts a populated copy of
|
`subject_hash_old` in pure Kotlin, and bind-mounts a populated copy of
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId = "in.secubox.toolbox"
|
applicationId = "in.secubox.toolbox"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 2
|
versionCode = 3
|
||||||
versionName = "0.2.0"
|
versionName = "0.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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. -->
|
<!-- Query the WireGuard app so we can hand it the generated profile. -->
|
||||||
<queries>
|
<queries>
|
||||||
<package android:name="com.wireguard.android" />
|
<package android:name="com.wireguard.android" />
|
||||||
|
|
@ -26,6 +31,26 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<!-- FileProvider to share the downloaded CA + WG .conf with the
|
||||||
system cert installer / the WireGuard app. -->
|
system cert installer / the WireGuard app. -->
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,12 +70,20 @@ fun OnboardApp() {
|
||||||
var autoTried by remember { mutableStateOf(false) }
|
var autoTried by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// The whole root-mode silent run, reused by the ⚡ button AND the
|
// The whole root-mode silent run, reused by the ⚡ button AND the
|
||||||
// zero-tap auto-launch (#551). Persists an onboarded flag per host on
|
// zero-tap auto-launch (#551/#558). NO onboarded gate — it auto-runs
|
||||||
// success so reopening the app doesn't redo it.
|
// 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 = {
|
val runRootAuto: () -> Unit = {
|
||||||
busy = true; status = ""; rootLog.clear()
|
busy = true; status = ""; rootLog.clear()
|
||||||
scope.launch {
|
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) {
|
if (!ok) {
|
||||||
busy = false; status = "Borne injoignable — vérifie le réseau."
|
busy = false; status = "Borne injoignable — vérifie le réseau."
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -86,7 +94,6 @@ fun OnboardApp() {
|
||||||
}
|
}
|
||||||
busy = false
|
busy = false
|
||||||
onTunnel = out.verified
|
onTunnel = out.verified
|
||||||
if (out.verified) prefs.edit().putBoolean("onboarded:$host", true).apply()
|
|
||||||
when {
|
when {
|
||||||
out.verified -> step = Step.Done
|
out.verified -> step = Step.Done
|
||||||
out.wgViaApp -> { step = Step.ImportProfile
|
out.wgViaApp -> { step = Step.ImportProfile
|
||||||
|
|
@ -100,12 +107,13 @@ fun OnboardApp() {
|
||||||
|
|
||||||
// Detect root once, off the main thread.
|
// Detect root once, off the main thread.
|
||||||
LaunchedEffect(Unit) { rootAvail = withContext(Dispatchers.IO) { RootShell.available() } }
|
LaunchedEffect(Unit) { rootAvail = withContext(Dispatchers.IO) { RootShell.available() } }
|
||||||
// Zero-tap (#551): on a rooted device, auto-run the silent onboarding
|
// Zero-tap (#558): on a rooted device, auto-run the silent onboarding
|
||||||
// once on launch — unless this host was already onboarded.
|
// on every launch — no gate. (Boot-time auto-run is handled by
|
||||||
|
// BootReceiver + OnboardService so it runs without opening the app.)
|
||||||
LaunchedEffect(rootAvail) {
|
LaunchedEffect(rootAvail) {
|
||||||
if (rootAvail && !autoTried && step == Step.Discover) {
|
if (rootAvail && !autoTried && step == Step.Discover) {
|
||||||
autoTried = true
|
autoTried = true
|
||||||
if (!prefs.getBoolean("onboarded:$host", false)) runRootAuto()
|
runRootAuto()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ to your cabine over the R3 tunnel — no third-party calls.
|
||||||
Published release `.xpi` (downloadable directly):
|
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:
|
The toolbox also serves it from the cabine:
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ async function wipe(host, token) {
|
||||||
return await resp.json();
|
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) {
|
function socialUrl(host, token) {
|
||||||
return `${baseUrl(host)}/social/${token}`;
|
return `${baseUrl(host)}/social/${token}`;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +112,7 @@ const SbxApi = {
|
||||||
r3Check,
|
r3Check,
|
||||||
graph,
|
graph,
|
||||||
wipe,
|
wipe,
|
||||||
|
faviconUrl,
|
||||||
socialUrl,
|
socialUrl,
|
||||||
reportUrl,
|
reportUrl,
|
||||||
tokenFromUrl,
|
tokenFromUrl,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DEFAULT_HOST="kbin.gk2.secubox.in"
|
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)"
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
say(){ printf '\033[1;36m▸\033[0m %s\n' "$*"; }
|
say(){ printf '\033[1;36m▸\033[0m %s\n' "$*"; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "SecuBox ToolBoX — Cartographie sociale",
|
"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.",
|
"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": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ button.danger { color: var(--cinnabar); border-color: var(--cinnabar); }
|
||||||
display: flex; align-items: center; gap: 6px; padding: 3px 2px;
|
display: flex; align-items: center; gap: 6px; padding: 3px 2px;
|
||||||
border-bottom: 1px solid #1a1a22; font-size: 11px;
|
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 .dom { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.row .hits { color: var(--muted); }
|
.row .hits { color: var(--muted); }
|
||||||
.tier { font-size: 9px; padding: 1px 4px; border-radius: 3px; }
|
.tier { font-size: 9px; padding: 1px 4px; border-radius: 3px; }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
// SyntaxError that aborts popup.js. Use api.ext instead.
|
// SyntaxError that aborts popup.js. Use api.ext instead.
|
||||||
const api = globalThis.SbxApi;
|
const api = globalThis.SbxApi;
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let curHost = api.DEFAULTS.host; // for favicon URLs (#555)
|
||||||
|
|
||||||
function show(which) {
|
function show(which) {
|
||||||
$("pair").hidden = which !== "pair";
|
$("pair").hidden = which !== "pair";
|
||||||
|
|
@ -24,6 +25,14 @@ function fillTopList(nodes) {
|
||||||
.forEach((n) => {
|
.forEach((n) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "row";
|
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");
|
const dom = document.createElement("span");
|
||||||
dom.className = "dom";
|
dom.className = "dom";
|
||||||
dom.textContent = n.domain || n.id;
|
dom.textContent = n.domain || n.id;
|
||||||
|
|
@ -57,6 +66,7 @@ function paint(data) {
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const cfg = await api.getConfig();
|
const cfg = await api.getConfig();
|
||||||
|
curHost = cfg.host || api.DEFAULTS.host;
|
||||||
$("ver").textContent = "v" + (api.ext.runtime.getManifest().version || "");
|
$("ver").textContent = "v" + (api.ext.runtime.getManifest().version || "");
|
||||||
|
|
||||||
// tunnel indicator
|
// tunnel indicator
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
||||||
var fp = document.getElementById('cert-fp-r3');
|
var fp = document.getElementById('cert-fp-r3');
|
||||||
if (!btn) return;
|
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 || '?';
|
fp.textContent = d.sha1 || d.sha256 || '?';
|
||||||
}).catch(function(){fp.textContent='?';});
|
}).catch(function(){fp.textContent='?';});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
secubox-toolbox (2.6.17-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* Social correlation: domain-rollup + history + target↔tracker (#549).
|
* Social correlation: domain-rollup + history + target↔tracker (#549).
|
||||||
|
|
|
||||||
141
packages/secubox-toolbox/mitmproxy_addons/protective_mode.py
Normal file
141
packages/secubox-toolbox/mitmproxy_addons/protective_mode.py
Normal 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()]
|
||||||
|
|
@ -340,28 +340,62 @@ _ANTIBOT_HEADER = (
|
||||||
("PerimeterX/HUMAN", ("x-px",)),
|
("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
|
def detect_antibot(flow) -> tuple:
|
||||||
headers, and cookie names.
|
"""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:
|
try:
|
||||||
url = (flow.request.pretty_url or "").lower()
|
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):
|
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
|
rh = flow.response.headers if flow.response else None
|
||||||
if rh is not None:
|
status = flow.response.status_code if flow.response else 0
|
||||||
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)
|
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 = []
|
blobs = []
|
||||||
try:
|
try:
|
||||||
blobs.extend(flow.request.headers.get_all("cookie") or [])
|
blobs.extend(flow.request.headers.get_all("cookie") or [])
|
||||||
|
|
@ -374,12 +408,37 @@ def detect_antibot(flow) -> Optional[str]:
|
||||||
pass
|
pass
|
||||||
joined = " ".join(blobs).lower()
|
joined = " ".join(blobs).lower()
|
||||||
if joined:
|
if joined:
|
||||||
for vendor, names in _ANTIBOT_COOKIE:
|
for v, names in _ANTIBOT_COOKIE:
|
||||||
if any(n in joined for n in names):
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return vendor, is_challenge
|
||||||
|
|
||||||
|
|
||||||
# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ──
|
# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ──
|
||||||
|
|
@ -485,7 +544,7 @@ class SocialGraph:
|
||||||
# per-client "challenged your humanity" alert is accurate.
|
# per-client "challenged your humanity" alert is accurate.
|
||||||
try:
|
try:
|
||||||
resp_host = _registrable_domain(flow.request.host)
|
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:
|
if resp_host and resp_host != src_site:
|
||||||
cdn_vendor, cache_status = detect_cdn(flow.response.headers)
|
cdn_vendor, cache_status = detect_cdn(flow.response.headers)
|
||||||
if cdn_vendor:
|
if cdn_vendor:
|
||||||
|
|
@ -495,9 +554,14 @@ class SocialGraph:
|
||||||
cache_status=cache_status,
|
cache_status=cache_status,
|
||||||
)
|
)
|
||||||
if antibot and resp_host:
|
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)
|
_social.record_host_antibot(domain=resp_host, antibot_vendor=antibot)
|
||||||
# Attribute the challenge to the 1st-party site the user
|
# CHALLENGE — only when one was actually ISSUED on this
|
||||||
# was on (for the per-client alert), keyed by mac_hash.
|
# 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(
|
_social.record_antibot_challenge(
|
||||||
client_mac_hash=mac_hash, src_site=src_site,
|
client_mac_hash=mac_hash, src_site=src_site,
|
||||||
antibot_vendor=antibot,
|
antibot_vendor=antibot,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ DEST_DIR="/var/lib/secubox/toolbox/webext"
|
||||||
DEST="${DEST_DIR}/secubox-toolbox-webext.xpi"
|
DEST="${DEST_DIR}/secubox-toolbox-webext.xpi"
|
||||||
# Tag-pinned (not /latest/): the webext release is make_latest:false so it
|
# 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*.
|
# 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; }
|
log() { logger -t "$MODULE" -- "$*" 2>/dev/null || echo "[$MODULE] $*" >&2; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,10 @@ fi
|
||||||
# must run BEFORE inject_banner so the banner cookies our addon
|
# must run BEFORE inject_banner so the banner cookies our addon
|
||||||
# emits don't pollute the graph
|
# emits don't pollute the graph
|
||||||
# - cert_pin_detect auto-learns pinned hosts (Phase 6.N)
|
# - 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")
|
ARGS+=(-s "$ADDON_DIR/${addon}.py")
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -944,31 +944,52 @@ async def admin_filter_regex() -> dict:
|
||||||
return {"regex": regex, "count": len(entries)}
|
return {"regex": regex, "count": len(entries)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ca/fingerprint")
|
def _ca_fp(ca_pem) -> dict:
|
||||||
async def ca_fingerprint() -> dict:
|
"""SHA1/SHA256/subject of a CA pem via openssl. '?' on any failure."""
|
||||||
"""Expose CA SHA1/SHA256 fingerprints so user can verify against their
|
|
||||||
iPhone Settings → Cert Trust UI. CSPN R2 transparency requirement."""
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
out = {"sha1": "?", "sha256": "?", "subject": "?"}
|
||||||
ca_pem = Path("/etc/secubox/toolbox/ca/ca.pem")
|
if not ca_pem.exists():
|
||||||
sha1 = sha256 = subject = "?"
|
return out
|
||||||
if ca_pem.exists():
|
|
||||||
try:
|
try:
|
||||||
sha1 = subprocess.run(
|
for key, flag in (("sha1", "-sha1"), ("sha256", "-sha256")):
|
||||||
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", "-sha1"],
|
out[key] = subprocess.run(
|
||||||
|
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", flag],
|
||||||
capture_output=True, text=True, timeout=2, check=False,
|
capture_output=True, text=True, timeout=2, check=False,
|
||||||
).stdout.split("=", 1)[-1].strip()
|
).stdout.split("=", 1)[-1].strip()
|
||||||
sha256 = subprocess.run(
|
out["subject"] = 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"],
|
["openssl", "x509", "-in", str(ca_pem), "-noout", "-subject"],
|
||||||
capture_output=True, text=True, timeout=2, check=False,
|
capture_output=True, text=True, timeout=2, check=False,
|
||||||
).stdout.split("=", 1)[-1].strip()
|
).stdout.split("=", 1)[-1].strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"sha1": sha1, "sha256": sha256, "subject": subject}
|
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
|
||||||
|
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
|
# 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 = Path("/var/lib/secubox/toolbox/webext/secubox-toolbox-webext.xpi")
|
||||||
_WEBEXT_XPI_RELEASE = (
|
_WEBEXT_XPI_RELEASE = (
|
||||||
"https://github.com/CyberMind-FR/secubox-deb/releases/download/"
|
"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
|
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")
|
@router.get("/social/report/{token}.pdf")
|
||||||
async def social_report_pdf(token: str) -> Response:
|
async def social_report_pdf(token: str) -> Response:
|
||||||
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.
|
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,58 @@ def _registrable_domain(host: str) -> str:
|
||||||
return last_two
|
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:
|
def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||||
"""Return the per-client graph JSON contract.
|
"""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.
|
# can colour/label by edge-network vendor.
|
||||||
for r in c.execute(
|
for r in c.execute(
|
||||||
"SELECT n.tracker_domain, n.hits, n.first_seen, n.last_seen, "
|
"SELECT n.tracker_domain, n.hits, n.first_seen, n.last_seen, "
|
||||||
"n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor, "
|
"n.sites_jsonl, n.country_iso, m.cdn_vendor, m.cache_status, "
|
||||||
"m.opgrade_vendor, m.opgrade_category "
|
"m.antibot_vendor, m.opgrade_vendor, m.opgrade_category "
|
||||||
"FROM social_nodes n "
|
"FROM social_nodes n "
|
||||||
"LEFT JOIN social_host_meta m ON m.tracker_domain = n.tracker_domain "
|
"LEFT JOIN social_host_meta m ON m.tracker_domain = n.tracker_domain "
|
||||||
"WHERE n.client_mac_hash = ? AND n.last_seen >= ? "
|
"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"])
|
sites = json.loads(r["sites_jsonl"])
|
||||||
except Exception:
|
except Exception:
|
||||||
sites = []
|
sites = []
|
||||||
|
cc = (r["country_iso"] or "").upper() or None
|
||||||
out["nodes"].append(
|
out["nodes"].append(
|
||||||
{
|
{
|
||||||
"id": r["tracker_domain"],
|
"id": r["tracker_domain"],
|
||||||
|
|
@ -748,6 +801,11 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||||
"sites": sites,
|
"sites": sites,
|
||||||
"first_seen": r["first_seen"],
|
"first_seen": r["first_seen"],
|
||||||
"last_seen": r["last_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"],
|
"cdn_vendor": r["cdn_vendor"],
|
||||||
"cache_status": r["cache_status"],
|
"cache_status": r["cache_status"],
|
||||||
"antibot_vendor": r["antibot_vendor"],
|
"antibot_vendor": r["antibot_vendor"],
|
||||||
|
|
@ -871,10 +929,52 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||||
})
|
})
|
||||||
out["history"] = history
|
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"] = {
|
out["stats"] = {
|
||||||
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
|
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
|
||||||
"total_sites": sites_count,
|
"total_sites": sites_count,
|
||||||
"total_domains": len(by_domain),
|
"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,
|
"first_seen": stats_row["first_seen"] if stats_row else None,
|
||||||
"last_seen": stats_row["last_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_sites": len({a["src_site"] for a in antibot}),
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ ExecStart=/usr/bin/mitmdump \
|
||||||
-s /usr/lib/secubox/toolbox/mitmproxy_addons/ja4.py \
|
-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/soc_relay.py \
|
||||||
-s /usr/lib/secubox/toolbox/mitmproxy_addons/inject_banner.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
|
-s /usr/lib/secubox/toolbox/mitmproxy_addons/local_store.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,54 @@
|
||||||
|
|
||||||
// ─── graph state ───
|
// ─── graph state ───
|
||||||
let simulation = null;
|
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() {
|
function svgSize() {
|
||||||
// Measure actual rendered size so the force center scales with the
|
// Measure actual rendered size so the force center scales with the
|
||||||
|
|
@ -130,6 +178,8 @@
|
||||||
sites: n.sites,
|
sites: n.sites,
|
||||||
first_seen: n.first_seen,
|
first_seen: n.first_seen,
|
||||||
last_seen: n.last_seen,
|
last_seen: n.last_seen,
|
||||||
|
country_iso: n.country_iso || null,
|
||||||
|
country_flag: n.country_flag || '',
|
||||||
cdn_vendor: n.cdn_vendor || null,
|
cdn_vendor: n.cdn_vendor || null,
|
||||||
cache_status: n.cache_status || null,
|
cache_status: n.cache_status || null,
|
||||||
antibot_vendor: n.antibot_vendor || 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', 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);
|
.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')
|
nodeG.filter(d => d.kind !== 'eye').append('text')
|
||||||
.attr('x', 12).attr('y', 4)
|
.attr('x', 12).attr('y', 4)
|
||||||
.text(d => (d.antibot_vendor ? '🤖 ' : '') + (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));
|
||||||
|
|
@ -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 ───
|
// ─── focus / detail panel ───
|
||||||
function focusNode(node, linkSel) {
|
function focusNode(node, linkSel) {
|
||||||
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }
|
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }
|
||||||
bind('nd_domain', node.label);
|
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_asn', '—');
|
||||||
bind('nd_cdn', node.cdn_vendor ? (node.cdn_vendor + (node.cache_status ? ' · ' + node.cache_status : '')) : '—');
|
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_antibot', node.antibot_vendor ? ('🤖 ' + node.antibot_vendor) : '—');
|
||||||
|
|
@ -413,7 +583,9 @@
|
||||||
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
|
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
|
||||||
if (!r.ok) throw new Error('http ' + r.status);
|
if (!r.ok) throw new Error('http ' + r.status);
|
||||||
const g = await r.json();
|
const g = await r.json();
|
||||||
render(g);
|
lastGraph = g;
|
||||||
|
ensureToggle();
|
||||||
|
draw(g);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[social] fetch failed', e);
|
console.error('[social] fetch failed', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ R3 tunnel — no third-party calls.
|
||||||
Published release `.xpi` (downloadable directly):
|
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:
|
The toolbox also serves it from the cabine:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user