mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 23:09:21 +00:00
Compare commits
No commits in common. "18f727b7d79b2badd7d953883a1c1be3a9eea9b5" and "dc6505a2f2c5123bce3903af715280ac4ce8a2f3" have entirely different histories.
18f727b7d7
...
dc6505a2f2
|
|
@ -12,20 +12,13 @@ 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 — real zero-tap, fully automated (#538, #551, #558)
|
## Root path — fully-automated silent onboarding (#538, #551)
|
||||||
On a **rooted** device the app onboards with **zero taps**, two ways:
|
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
|
||||||
- **On launch** — auto-detects root and runs the silent sequence immediately
|
onboarded yet, starts the silent sequence automatically (`RootAuto` step,
|
||||||
every launch (no gate), retrying reachability while WiFi/tunnel settle.
|
streaming log). The **⚡ Installation automatique (root)** button stays for
|
||||||
- **On boot** — a `BOOT_COMPLETED` receiver starts a short foreground service
|
re-runs. The "already onboarded" flag is persisted per host (SharedPreferences)
|
||||||
(`OnboardService`) that runs the same silent sequence **without opening the
|
so reopening the app doesn't redo it. Steps:
|
||||||
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 = 3
|
versionCode = 2
|
||||||
versionName = "0.3.0"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@
|
||||||
|
|
||||||
<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" />
|
||||||
|
|
@ -31,26 +26,6 @@
|
||||||
</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
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// 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,20 +70,12 @@ 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/#558). NO onboarded gate — it auto-runs
|
// zero-tap auto-launch (#551). Persists an onboarded flag per host on
|
||||||
// every launch (idempotent: re-asserts CA + WG). Reachability is
|
// success so reopening the app doesn't redo it.
|
||||||
// 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 {
|
||||||
// poll reachability up to ~9 s (network may still be settling)
|
val ok = withContext(Dispatchers.IO) { api.reachable() }
|
||||||
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 {
|
||||||
|
|
@ -94,6 +86,7 @@ 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
|
||||||
|
|
@ -107,13 +100,12 @@ 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 (#558): on a rooted device, auto-run the silent onboarding
|
// Zero-tap (#551): on a rooted device, auto-run the silent onboarding
|
||||||
// on every launch — no gate. (Boot-time auto-run is handled by
|
// once on launch — unless this host was already onboarded.
|
||||||
// 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
|
||||||
runRootAuto()
|
if (!prefs.getBoolean("onboarded:$host", false)) runRootAuto()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
// 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.2/secubox-toolbox-webext.xpi
|
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/secubox-toolbox-webext.xpi
|
||||||
```
|
```
|
||||||
|
|
||||||
The toolbox also serves it from the cabine:
|
The toolbox also serves it from the cabine:
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,6 @@ 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}`;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +106,6 @@ 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.2/secubox-toolbox-webext.xpi"
|
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/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.2",
|
"version": "0.1.1",
|
||||||
"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,7 +66,6 @@ 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,7 +8,6 @@
|
||||||
// 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";
|
||||||
|
|
@ -25,14 +24,6 @@ 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;
|
||||||
|
|
@ -66,7 +57,6 @@ 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?ca=wg').then(function(r){return r.json();}).then(function(d){
|
fetch('/ca/fingerprint').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,84 +1,3 @@
|
||||||
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).
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
# 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,62 +340,28 @@ _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.
|
||||||
|
|
||||||
def detect_antibot(flow) -> tuple:
|
Best-effort, passive. Scans the request URL, the response + request
|
||||||
"""Return ``(vendor, is_challenge)``.
|
headers, and cookie names.
|
||||||
|
|
||||||
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 v, frags in _ANTIBOT_URL:
|
for vendor, frags in _ANTIBOT_URL:
|
||||||
if any(f in url for f in frags):
|
if any(f in url for f in frags):
|
||||||
vendor = v
|
return vendor
|
||||||
break
|
|
||||||
|
|
||||||
|
# Response headers
|
||||||
rh = flow.response.headers if flow.response else None
|
rh = flow.response.headers if flow.response else None
|
||||||
status = flow.response.status_code if flow.response else 0
|
|
||||||
|
|
||||||
if rh is not None:
|
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())
|
keys = " ".join(k.lower() for k in rh.keys())
|
||||||
for v, hdrs in _ANTIBOT_HEADER:
|
for vendor, hdrs in _ANTIBOT_HEADER:
|
||||||
if any(h in keys for h in hdrs):
|
if any(h in keys for h in hdrs):
|
||||||
vendor = vendor or v
|
return vendor
|
||||||
break
|
|
||||||
|
|
||||||
# Cookie names (both directions) — DEPLOYMENT signal.
|
# Cookie names (both directions)
|
||||||
blobs = []
|
blobs = []
|
||||||
try:
|
try:
|
||||||
blobs.extend(flow.request.headers.get_all("cookie") or [])
|
blobs.extend(flow.request.headers.get_all("cookie") or [])
|
||||||
|
|
@ -408,37 +374,12 @@ def detect_antibot(flow) -> tuple:
|
||||||
pass
|
pass
|
||||||
joined = " ".join(blobs).lower()
|
joined = " ".join(blobs).lower()
|
||||||
if joined:
|
if joined:
|
||||||
for v, names in _ANTIBOT_COOKIE:
|
for vendor, names in _ANTIBOT_COOKIE:
|
||||||
if any(n in joined for n in names):
|
if any(n in joined for n in names):
|
||||||
vendor = vendor or v
|
return vendor
|
||||||
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 vendor, is_challenge
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ──
|
# ─── operator-grade / state-adjacent identity detection (Phase 12.C #518) ──
|
||||||
|
|
@ -544,7 +485,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, antibot_challenge = detect_antibot(flow)
|
antibot = 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:
|
||||||
|
|
@ -554,18 +495,13 @@ 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)
|
||||||
# CHALLENGE — only when one was actually ISSUED on this
|
# Attribute the challenge to the 1st-party site the user
|
||||||
# response. Presence on a 200 is deployment, not a challenge,
|
# was on (for the per-client alert), keyed by mac_hash.
|
||||||
# so it must NOT inflate the per-client "challenged your
|
_social.record_antibot_challenge(
|
||||||
# humanity" alert / severity.
|
client_mac_hash=mac_hash, src_site=src_site,
|
||||||
if antibot_challenge:
|
antibot_vendor=antibot,
|
||||||
_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.
|
# Phase 12.C (#518) — operator-grade / state-adjacent identity.
|
||||||
op_vendor, op_category = detect_operator_grade(flow)
|
op_vendor, op_category = detect_operator_grade(flow)
|
||||||
if op_vendor:
|
if op_vendor:
|
||||||
|
|
|
||||||
|
|
@ -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.2/secubox-toolbox-webext.xpi"
|
RELEASE_URL="https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/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,10 +104,7 @@ 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)
|
||||||
# protective_mode (#560) runs right after utiq_defense — early, so spoof-level
|
for addon in inject_xff utiq_defense local_store social_graph inject_banner dpi cookies avatar ja4 soc_relay cert_pin_detect; do
|
||||||
# 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,52 +944,31 @@ async def admin_filter_regex() -> dict:
|
||||||
return {"regex": regex, "count": len(entries)}
|
return {"regex": regex, "count": len(entries)}
|
||||||
|
|
||||||
|
|
||||||
def _ca_fp(ca_pem) -> dict:
|
@router.get("/ca/fingerprint")
|
||||||
"""SHA1/SHA256/subject of a CA pem via openssl. '?' on any failure."""
|
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."""
|
||||||
import subprocess
|
import subprocess
|
||||||
out = {"sha1": "?", "sha256": "?", "subject": "?"}
|
from pathlib import Path
|
||||||
if not ca_pem.exists():
|
ca_pem = Path("/etc/secubox/toolbox/ca/ca.pem")
|
||||||
return out
|
sha1 = sha256 = subject = "?"
|
||||||
try:
|
if ca_pem.exists():
|
||||||
for key, flag in (("sha1", "-sha1"), ("sha256", "-sha256")):
|
try:
|
||||||
out[key] = subprocess.run(
|
sha1 = subprocess.run(
|
||||||
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", flag],
|
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", "-sha1"],
|
||||||
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()
|
||||||
out["subject"] = subprocess.run(
|
sha256 = subprocess.run(
|
||||||
["openssl", "x509", "-in", str(ca_pem), "-noout", "-subject"],
|
["openssl", "x509", "-in", str(ca_pem), "-noout", "-fingerprint", "-sha256"],
|
||||||
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:
|
subject = subprocess.run(
|
||||||
pass
|
["openssl", "x509", "-in", str(ca_pem), "-noout", "-subject"],
|
||||||
return out
|
capture_output=True, text=True, timeout=2, check=False,
|
||||||
|
).stdout.split("=", 1)[-1].strip()
|
||||||
|
except Exception:
|
||||||
@router.get("/ca/fingerprint")
|
pass
|
||||||
async def ca_fingerprint(request: Request, ca: str = "auto") -> dict:
|
return {"sha1": sha1, "sha256": sha256, "subject": subject}
|
||||||
"""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
|
||||||
|
|
@ -1395,7 +1374,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.2/secubox-toolbox-webext.xpi"
|
"webext-v0.1.1/secubox-toolbox-webext.xpi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2403,28 +2382,6 @@ 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,58 +708,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -779,8 +727,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, n.country_iso, m.cdn_vendor, m.cache_status, "
|
"n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor, "
|
||||||
"m.antibot_vendor, m.opgrade_vendor, m.opgrade_category "
|
"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 >= ? "
|
||||||
|
|
@ -791,7 +739,6 @@ 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"],
|
||||||
|
|
@ -801,11 +748,6 @@ 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"],
|
||||||
|
|
@ -929,52 +871,10 @@ 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,7 +25,6 @@ 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,54 +70,6 @@
|
||||||
|
|
||||||
// ─── 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
|
||||||
|
|
@ -178,8 +130,6 @@
|
||||||
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,
|
||||||
|
|
@ -308,19 +258,6 @@
|
||||||
.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));
|
||||||
|
|
@ -402,118 +339,11 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── #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', node.country_flag ? (node.country_flag + ' ' + (node.country_iso || '')) : '—');
|
bind('nd_country', '—'); // Phase C dependency (GeoIP)
|
||||||
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) : '—');
|
||||||
|
|
@ -583,9 +413,7 @@
|
||||||
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();
|
||||||
lastGraph = g;
|
render(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.2/secubox-toolbox-webext.xpi
|
https://github.com/CyberMind-FR/secubox-deb/releases/download/webext-v0.1.1/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