Compare commits

..

18 Commits

Author SHA1 Message Date
CyberMind
c043c5fca8
Merge pull request #584 from CyberMind-FR/fix/ad-ghost-collapse-placeholders
Some checks are pending
License Headers / check (push) Waiting to run
ad_ghost: remove ad placeholders entirely (collapse)
2026-06-14 15:02:02 +02:00
10d22f05b7 feat(ad_ghost): remove ad placeholders entirely — collapse, no black-hole
Reverses #576: ghosted ad slots collapse with display:none (space gone),
no void/black-hole. Host-blocking (204) still saves bandwidth.
secubox-toolbox 2.6.31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:01:35 +02:00
CyberMind
5ece3f1208
Merge pull request #583 from CyberMind-FR/feature/576-ad-ghost-replace-ghosted-ad-with-a-css-l
ad_ghost: black-hole placeholder for ghosted ads (#576)
2026-06-14 14:58:35 +02:00
5ca4ecf455 feat(ad_ghost): ghosted ads become a CSS black-hole placeholder (closes #576)
Replace display:none with a layered dark void — radial-gradient bg + inset
glow + glowing accretion-disc (::after), content hidden. Intentional
reclaimed space instead of a collapsed gap. R3+/R4, ad_ghost filter.
secubox-toolbox 2.6.30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:58:10 +02:00
CyberMind
ace77976fa
Merge pull request #582 from CyberMind-FR/fix/581-postinst-portal-stays-dead-after-dpkg-up
fix(postinst): portal reliably restarts after dpkg upgrade (#581)
2026-06-14 14:52:06 +02:00
4ffd66bb2d fix(toolbox): postinst reliably restarts portal after upgrade (closes #581)
The dpkg upgrade SIGTERMs the units before postinst runs, so the
try-restart loop was a no-op on the already-stopped portal -> kbin 503
(hit twice 2026-06-14). Now enabled units get a full restart (start even
if stopped); others keep try-restart. secubox-toolbox 2.6.29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:51:42 +02:00
CyberMind
9b8144073f
Merge pull request #580 from CyberMind-FR/fix/575-eye-graph-remove-hide-all-ip-nodes-count
Eye graph: hide IP nodes + clients list top-5 (#575)
2026-06-14 14:42:57 +02:00
2f04b0fa84 fix(toolbox): eye graph hides IP nodes + clients list capped to 5 (closes #575)
- social.js: drop IP-only tracker nodes (v4/v6) from the eye force-graph —
  no more domain+IP double bubbles; remaining nodes labelled country flag +
  domain name.
- www/toolbox index.html #clients: show top 5 only (+N more note).
secubox-toolbox 2.6.28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:42:07 +02:00
CyberMind
270f655d3a
Merge pull request #579 from CyberMind-FR/feature/574-webext-popup-protection-stats-quick-filt
webext popup: protection stats + live filter toggles (#574)
2026-06-14 12:10:41 +02:00
b366946855 feat(webext): popup protection stats + live filter toggles (closes #574)
New Protection panel in the popup: ghost savings (blocked/Mo/pages cleaned
via /admin/ghost) + live toggles for ad_ghost / ad_ghost_block / banner /
protective(off|alert|spoof) via /admin/filters. api.js: ghost/getAdminFilters/
setAdminFilters helpers. Top-tracker list stays top-5. webext 0.1.4,
tag-pin -> webext-v0.1.4, secubox-toolbox 2.6.27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:10:20 +02:00
CyberMind
433c5ca190
Merge pull request #573 from CyberMind-FR/feature/572-banner-colorful-emoji-chip-guirlande-fla
Banner: colourful emoji-chip guirlande (#572)
2026-06-14 11:02:14 +02:00
1a23c1f78a feat(toolbox): colourful emoji-chip guirlande banner (closes #572)
inject_banner right-side stats now render as vibrant rounded pills cycling
an 8-colour festive palette with neon box-shadow glow. Pure-ASCII inline
styling, works in CSP-strict + JS variants. secubox-toolbox 2.6.26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:01:37 +02:00
CyberMind
ff0503ea70
Merge pull request #571 from CyberMind-FR/feature/570-toolbox-mitm-dpi-media-type-statistifier
DPI media/content-type statistifier + donut (#570)
2026-06-14 10:58:08 +02:00
675f6ae458 feat(toolbox): DPI media/content-type statistifier + donut (closes #570)
mitmproxy_addons/media_stats.py buckets responses by content-type category
(emoji-iconified) + provider (eTLD+1), summing Content-Length (header only,
no body read). Rolling -> /run/secubox/media.json. api: /admin/media +
/admin/media/ui (SVG donut + emoji legend + top-5 providers w/ favicons).
Wired into the mitm-wg launcher. secubox-toolbox 2.6.25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:48 +02:00
CyberMind
b689c235f6
Merge pull request #569 from CyberMind-FR/feature/568-webext-popup-cap-top-tracker-list-to-5-e
webext: cap popup top-tracker list to 5 (#568)
2026-06-14 10:26:55 +02:00
d26992d905 feat(webext): cap popup top-tracker list to 5 (closes #568)
popup top-tracker list 12 -> 5. webext 0.1.3; /wg/toolbox.xpi tag-pin
-> webext-v0.1.3. secubox-toolbox 2.6.24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:26:34 +02:00
CyberMind
295f77601d
Merge pull request #567 from CyberMind-FR/feature/566-r3-r4-silent-ad-banner-ghoster-economisa
Modular mitm filters + R3+/R4 silent ad/banner ghoster (#566)
2026-06-14 09:29:15 +02:00
f176fa3173 feat(toolbox): modular mitm filters + R3+/R4 silent ad/banner ghoster (closes #566)
- filters.py: live filter config (/etc/secubox/toolbox/filters.json), toolbox
  WebUI toggles (banner / protective off|alert|spoof / ad_ghost / block /
  per-category cosmetics), 5s cache, no restart.
- ad_ghost.py: R3+/R4 (10.99.1.0/24) only — 204 known ad/tracker hosts
  (bandwidth save) + inject ad-hiding CSS (ads/consent-nag/newsletter/social);
  tallies blocked + bytes_saved_est + pages_cleaned → /run/secubox/ghost.json.
- inject_banner: filter-gated; shows ghost quick-stats; status inspected→
  protected on R3+/R4. protective_mode reads filter (env fallback).
- api: /admin/filters (GET/POST), /admin/ghost, /admin/filters/ui panel.
- launcher loads ad_ghost; postinst seeds filters.json. Unit-tested.
secubox-toolbox 2.6.23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:54 +02:00
20 changed files with 823 additions and 22 deletions

View File

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

View File

@ -89,6 +89,29 @@ async function wipe(host, token) {
return await resp.json();
}
// #574 — protection stats + modular filter toggles (cabine admin API).
async function ghost(host) {
try {
const r = await fetch(`${baseUrl(host)}/admin/ghost`, { credentials: "omit" });
return r.ok ? await r.json() : null;
} catch (_) { return null; }
}
async function getAdminFilters(host) {
try {
const r = await fetch(`${baseUrl(host)}/admin/filters`, { credentials: "omit" });
return r.ok ? await r.json() : null;
} catch (_) { return null; }
}
async function setAdminFilters(host, patch) {
const r = await fetch(`${baseUrl(host)}/admin/filters`, {
method: "POST", credentials: "omit",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.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) {
@ -112,6 +135,9 @@ const SbxApi = {
r3Check,
graph,
wipe,
ghost,
getAdminFilters,
setAdminFilters,
faviconUrl,
socialUrl,
reportUrl,

View File

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

View File

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

View File

@ -69,6 +69,14 @@ button.danger { color: var(--cinnabar); border-color: var(--cinnabar); }
.row .fav { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; background: #1a1a22; object-fit: contain; }
.row .dom { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .hits { color: var(--muted); }
/* #574 — protection panel */
#protect { margin: 8px 0; padding: 8px; background: #0e0e15; border: 1px solid #222; border-radius: 8px; }
.phead { color: var(--matrix); font-weight: 700; font-size: 12px; margin-bottom: 6px; }
.gstat { color: var(--muted); font-weight: 400; font-size: 10px; }
.tg { display: flex; align-items: center; gap: 6px; font-size: 11px; padding: 3px 0; }
.tg select { margin-left: auto; background: #14141c; color: var(--text); border: 1px solid #333; border-radius: 4px; }
#protect input { accent-color: var(--void); }
.tier { font-size: 9px; padding: 1px 4px; border-radius: 3px; }
.tier.cdn { background: #1d2a33; color: var(--cyan); }
.tier.ab { background: #2a1416; color: var(--cinnabar); }

View File

@ -36,6 +36,17 @@
<div class="toplist" id="topList"></div>
<section id="protect">
<div class="phead">🛡 Protection <span id="ghostStat" class="gstat"></span></div>
<label class="tg"><input type="checkbox" data-f="ad_ghost"> Masquer pubs/bannières (R3+)</label>
<label class="tg"><input type="checkbox" data-f="ad_ghost_block"> Bloquer hôtes pub (économie)</label>
<label class="tg"><input type="checkbox" data-f="banner"> Bannière transparence</label>
<label class="tg">Mode protecteur
<select data-f="protective"><option value="off">off</option><option value="alert">alert</option><option value="spoof">spoof</option></select>
</label>
<p id="protectMsg" class="muted"></p>
</section>
<div class="actions">
<button id="openFull">🗺️ Cartographie complète</button>
<button id="pdf">📄 Rapport PDF</button>

View File

@ -21,7 +21,7 @@ function fillTopList(nodes) {
(nodes || [])
.slice()
.sort((a, b) => (b.hits || 0) - (a.hits || 0))
.slice(0, 12)
.slice(0, 5)
.forEach((n) => {
const row = document.createElement("div");
row.className = "row";
@ -64,6 +64,41 @@ function paint(data) {
fillTopList(data.nodes);
}
// #574 — protection stats + live filter toggles in the popup.
async function loadProtection() {
const sec = $("protect");
if (!sec) return;
const g = await api.ghost(curHost);
if (g) {
$("ghostStat").textContent =
`${g.blocked_requests || 0} bloqués · ~${g.mb_saved_est || 0} Mo · ${g.pages_cleaned || 0} nettoyées`;
}
const f = await api.getAdminFilters(curHost);
if (!f) { sec.style.opacity = "0.5"; return; }
sec.style.opacity = "1";
sec.querySelectorAll("[data-f]").forEach((el) => {
const k = el.dataset.f;
if (el.type === "checkbox") el.checked = !!f[k];
else el.value = f[k];
});
if (!sec.dataset.wired) {
sec.dataset.wired = "1";
sec.querySelectorAll("[data-f]").forEach((el) => {
el.addEventListener("change", async () => {
const v = el.type === "checkbox" ? el.checked : el.value;
try {
await api.setAdminFilters(curHost, { [el.dataset.f]: v });
$("protectMsg").textContent = "✓ appliqué";
setTimeout(() => ($("protectMsg").textContent = ""), 1000);
loadProtection();
} catch (e) {
$("protectMsg").textContent = "erreur : " + e.message;
}
});
});
}
}
async function load() {
const cfg = await api.getConfig();
curHost = cfg.host || api.DEFAULTS.host;
@ -87,6 +122,7 @@ async function load() {
const data = await api.graph(cfg.host, cfg.token, cfg.since);
paint(data);
$("liveMsg").textContent = "";
loadProtection();
} catch (e) {
if (String(e.message) === "token-expired") {
// token died — drop it and go back to pairing

View File

@ -1,3 +1,106 @@
secubox-toolbox (2.6.31-1~bookworm1) bookworm; urgency=medium
* ad_ghost: remove ad placeholders entirely (#584, reverses #576). Ghosted
ad slots now collapse with display:none — no black-hole/void, the space
disappears. Host-blocking (204) still saves the bandwidth.
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 14:00:00 +0200
secubox-toolbox (2.6.30-1~bookworm1) bookworm; urgency=medium
* ad_ghost: ghosted ads become a CSS "black hole" placeholder (#576).
Instead of display:none (a collapsed blank gap), the ghosted ad slot is
a layered dark void — radial-gradient background + inset glow + a glowing
accretion-disc via ::after, real content hidden. Intentional reclaimed
space, R3+/R4, toggle via the ad_ghost filter.
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 13:30:00 +0200
secubox-toolbox (2.6.29-1~bookworm1) bookworm; urgency=medium
* fix(postinst): portal reliably restarts after dpkg upgrade (#581). The
upgrade SIGTERMs the units before postinst runs, so the old try-restart
loop was a no-op on the already-stopped portal → secubox-toolbox.service
stayed dead → kbin 503 (hit twice 2026-06-14). Now ENABLED units get a
full `restart` (starts even if stopped); others keep `try-restart`.
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 13:00:00 +0200
secubox-toolbox (2.6.28-1~bookworm1) bookworm; urgency=medium
* Eye graph: hide all IP nodes + cap clients list (#575).
- social.js: drop every IP-only tracker node (IPv4/IPv6) from the
eye force-graph — no more domain+IP double bubbles for one tracker.
Remaining tracker nodes are labelled country-flag + domain name.
- www/toolbox index.html #clients tab: show the top 5 clients only
("+ N autres" note when more).
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 12:30:00 +0200
secubox-toolbox (2.6.27-1~bookworm1) bookworm; urgency=medium
* webext popup: protection stats + live filter toggles (#574); webext
0.1.4. New 🛡 Protection panel — ghost savings (blocked / ~Mo / pages
cleaned via /admin/ghost) + live toggles for ad_ghost / ad_ghost_block /
banner / protective(off|alert|spoof) via /admin/filters. Top-tracker
list stays top-5. /wg/toolbox.xpi tag-pin → webext-v0.1.4.
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 12:00:00 +0200
secubox-toolbox (2.6.26-1~bookworm1) bookworm; urgency=medium
* Banner: colourful emoji-chip "guirlande" (#572). The right-side stat
row (status, flag, app, cookies, tracker-host, utiq, ghost, ASN) now
renders as vibrant rounded pills cycling an 8-colour festive palette
with a neon glow (box-shadow). Pure-ASCII inline styling — works in
both the CSP-strict and JS banner variants.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 19:30:00 +0200
secubox-toolbox (2.6.25-1~bookworm1) bookworm; urgency=medium
* DPI media/content-type statistifier + donut (#570).
- mitmproxy_addons/media_stats.py : buckets every response by content-
type CATEGORY (page/image/video/audio/script/style/font/api/text/
other, emoji-iconified) and by PROVIDER (eTLD+1), summing
Content-Length (header only — never reads the body, safe on video).
Rolling counters → /run/secubox/media.json. Wired into the launcher.
- api: GET /admin/media (categories %+emoji, top-5 providers, totals) ;
GET /admin/media/ui (SVG donut + emoji legend + top-5 providers with
favicons).
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 19:00:00 +0200
secubox-toolbox (2.6.24-1~bookworm1) bookworm; urgency=medium
* webext: cap the popup top-tracker list to 5 items (#568); webext 0.1.3.
/wg/toolbox.xpi tag-pin → webext-v0.1.4.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 18:30:00 +0200
secubox-toolbox (2.6.23-1~bookworm1) bookworm; urgency=medium
* Modular mitm filters + R3+/R4 silent ad/banner ghoster (#566).
- secubox_toolbox/filters.py : single filter config
(/etc/secubox/toolbox/filters.json), 5 s cached, toggled live from
the toolbox WebUI — banner / protective(off|alert|spoof) / ad_ghost /
ad_ghost_block / per-category cosmetics. No restart to flip.
- mitmproxy_addons/ad_ghost.py : for R3+/R4 (10.99.1.0/24) only —
204 known ad/tracker hosts (real bandwidth save) + inject ad-hiding
CSS (ads / consent-nag / newsletter / social-widget categories) into
HTML. Tallies blocked_requests + bytes_saved_est + pages_cleaned →
/run/secubox/ghost.json. Wired into the mitm-wg launcher.
- inject_banner : reads the filter (banner on/off), shows the ghost
quick-stats (shield N + ~X Ko), relabels status inspected→protected
on R3+/R4. protective_mode level now sourced from the filter config
(env fallback).
- api: GET/POST /admin/filters, GET /admin/ghost, GET /admin/filters/ui
(toggle panel). postinst seeds filters.json (preserved on upgrade).
Doctrine: opt-in via filter, logged, reversible ; cosmetic ghosting only
on the opted-in R3+/R4 tiers, 1st-party pages stay usable.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 18:00:00 +0200
secubox-toolbox (2.6.22-1~bookworm1) bookworm; urgency=medium
* detect_antibot: deployment vs challenge, response-level signals (#564,
@ -57,7 +160,7 @@ secubox-toolbox (2.6.19-1~bookworm1) bookworm; urgency=medium
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.
(clients/webext-toolbox 0.1.2). /wg/toolbox.xpi tag-pin → webext-v0.1.4.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 15:30:00 +0200

View File

@ -42,6 +42,23 @@ case "$1" in
chown root:secubox-toolbox /etc/secubox/toolbox/ca/ca.pem /etc/secubox/toolbox/ca/key.pem 2>/dev/null || true
fi
# #566 : modular filter config (toolbox WebUI toggles). Seed once;
# preserve operator edits on upgrade. Writable by the portal user.
install -d -m 0750 -o root -g secubox-toolbox /etc/secubox/toolbox 2>/dev/null || true
if [ ! -f /etc/secubox/toolbox/filters.json ]; then
cat > /etc/secubox/toolbox/filters.json <<'SBXFILTERS'
{
"banner": true,
"protective": "spoof",
"ad_ghost": true,
"ad_ghost_block": true,
"ad_ghost_categories": {"ads": true, "consent_nag": true, "newsletter": true, "social_widgets": true}
}
SBXFILTERS
chown root:secubox-toolbox /etc/secubox/toolbox/filters.json
chmod 0664 /etc/secubox/toolbox/filters.json
fi
# 4. Storage dir (SQLite + future PDF reports)
install -d -m 0750 -o secubox-toolbox -g secubox-toolbox /var/lib/secubox/toolbox
# #536 : Android APK serve dir + best-effort fetch of the latest
@ -214,14 +231,15 @@ fi
fi
fi
# Phase 10 (#501 perf) : on UPGRADE ($2 = previous version), try-restart
# the long-running daemons so the new code is live without operator
# intervention. dh_installsystemd ships --no-start --no-enable in
# debian/rules so without this loop secubox-toolbox.service stays dead
# post-upgrade until reboot (caught 2026-06-09 : kbin.gk2 503'd for 5 min
# after the 2.5.0 → 2.5.1 upgrade because the unit was SIGTERMed and
# never restarted). try-restart is a no-op when the unit is not
# running, so it's safe on fresh install / unconfigured boards.
# Phase 10 (#501 perf) : on UPGRADE ($2 = previous version), bring the
# long-running daemons back on the new code without operator action.
# dh_installsystemd ships --no-start --no-enable in debian/rules.
# #581 fix : the dpkg upgrade SIGTERMs the units BEFORE this postinst
# runs, so `try-restart` (a no-op on a stopped unit) left
# secubox-toolbox.service DEAD post-upgrade -> kbin 503 (caught twice on
# 2026-06-14, first on 2026-06-09). So : ENABLED units get a full
# `restart` (starts them even if currently stopped) ; everything else
# keeps `try-restart` (preserve an operator's deliberate stop).
if [ -n "${2:-}" ] && [ -d /run/systemd/system ]; then
for unit in secubox-toolbox.service \
secubox-toolbox-mitm.service \
@ -229,7 +247,11 @@ fi
secubox-toolbox-mitm-wg-worker@2.service \
secubox-toolbox-mitm-wg-worker@3.service \
secubox-toolbox-mitm-wg-worker@4.service ; do
if systemctl is-enabled --quiet "$unit" 2>/dev/null; then
systemctl restart "$unit" 2>/dev/null || true
else
systemctl try-restart "$unit" 2>/dev/null || true
fi
done
fi
;;

View File

@ -0,0 +1,161 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# #566 — R3+/R4 silent ad/banner/widget GHOSTER + economisable savings.
#
# For R3+/R4 tunnel clients (on 10.99.1.0/24) ONLY, and only when enabled
# in the modular filter config (toolbox WebUI → filters.json):
# - cosmetic ghost: inject a <style> that hides ad / consent-nag /
# newsletter-popup / social-widget containers (1st-party page stays
# usable) ;
# - block: 204 known ad/tracker hosts to save real bandwidth.
# Tallies ghosted requests + estimated bytes saved → /run/secubox/ghost.json,
# which inject_banner surfaces as quick stats. Doctrine: opt-in (filter
# toggle), logged, reversible.
from __future__ import annotations
import json
import os
import re
import sys
import time
from mitmproxy import http
# Shared modular filter config (best-effort import; safe defaults if absent).
try:
if "/usr/lib/secubox/toolbox" not in sys.path:
sys.path.insert(0, "/usr/lib/secubox/toolbox")
from secubox_toolbox.filters import get_filters
except Exception:
def get_filters(force: bool = False):
return {"ad_ghost": True, "ad_ghost_block": True,
"ad_ghost_categories": {"ads": True, "consent_nag": True,
"newsletter": True, "social_widgets": True}}
_STATS = "/run/secubox/ghost.json"
_EST_BYTES_PER_REQ = 45000 # honest estimate per blocked ad/tracker request
# Ad / tracker hosts to 204 (bandwidth save). Conservative: ad/tracker only.
_AD_HOST = re.compile(
r"(?:^|\.)(?:doubleclick|googlesyndication|googleadservices|"
r"googletagservices|adservice\.google|amazon-adsystem|adnxs|adsrvr|"
r"adform|criteo|rubiconproject|taboola|outbrain|smartadserver|moatads|"
r"scorecardresearch|2mdn|adroll|pubmatic|openx|casalemedia|"
r"yieldlove|sharethrough|teads|3lift|adsystem|adserver)",
re.IGNORECASE,
)
# Cosmetic hide selectors, grouped so the WebUI can toggle each category.
_COSMETIC = {
"ads": (
'[id^="google_ads"]', '[id^="div-gpt-ad"]', 'ins.adsbygoogle',
'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]',
'iframe[src*="amazon-adsystem"]', '[class*="ad-banner"]',
'[class*="advert"]', '[id*="banner-ad"]', '[id*="ad-container"]',
'[class*="-ads"]', '[class*="sponsored"]', 'aside[aria-label*="publicit"]',
),
"consent_nag": (
'#onetrust-banner-sdk', '#onetrust-consent-sdk', '#didomi-host',
'.qc-cmp2-container', '[id^="sp_message_container"]',
'[id*="cookie-consent"]', '[class*="cookie-banner"]',
'[class*="cookie-notice"]', '[aria-label*="cookie"]', '.cmpbox',
),
"newsletter": (
'[class*="newsletter-popup"]', '[class*="signup-modal"]',
'[id*="newsletter-modal"]', '[class*="subscribe-overlay"]',
),
"social_widgets": (
'.fb-like', '.twitter-share-button', '[class*="social-share"]',
'iframe[src*="facebook.com/plugins"]', 'iframe[src*="platform.twitter"]',
),
}
_RE_HEAD = re.compile(rb"</head>", re.IGNORECASE)
_MARK = b"sbx-ghost-style"
_counts = {"blocked_requests": 0, "bytes_saved_est": 0, "pages_cleaned": 0,
"since": int(time.time())}
_last_flush = 0.0
def _is_r3plus(flow) -> bool:
try:
ip = flow.client_conn.peername[0]
return bool(ip) and ip.startswith("10.99.1.")
except Exception:
return False
def _flush(force: bool = False) -> None:
global _last_flush
now = time.time()
if not force and (now - _last_flush) < 5:
return
_last_flush = now
try:
os.makedirs(os.path.dirname(_STATS), exist_ok=True)
with open(_STATS, "w", encoding="utf-8") as f:
json.dump({**_counts, "updated": int(now)}, f)
except Exception:
pass
def _style_for(cats: dict) -> bytes:
sels = []
for cat, on in cats.items():
if on and cat in _COSMETIC:
sels.extend(_COSMETIC[cat])
if not sels:
return b""
sel = ",".join(sels)
# #584 — NO placeholder : collapse the ghosted ad slot entirely so the
# space disappears (display:none), rather than leaving a void/black-hole.
# Host-blocking (204) still saves the bandwidth ; this just hides the box.
rule = sel + "{display:none!important;visibility:hidden!important;}"
return (b"<style id=\"sbx-ghost-style\">" + rule.encode("utf-8") + b"</style>")
class AdGhost:
def requestheaders(self, flow: http.HTTPFlow) -> None:
f = get_filters()
if not (f.get("ad_ghost") and f.get("ad_ghost_block")):
return
if not _is_r3plus(flow):
return
host = flow.request.pretty_host or ""
if _AD_HOST.search(host):
flow.response = http.Response.make(
204, b"", {"X-SecuBox-Ghost": "blocked"})
_counts["blocked_requests"] += 1
_counts["bytes_saved_est"] += _EST_BYTES_PER_REQ
_flush()
def response(self, flow: http.HTTPFlow) -> None:
f = get_filters()
if not f.get("ad_ghost") or not _is_r3plus(flow):
return
if not flow.response or flow.response.status_code != 200:
return
ct = (flow.response.headers.get("content-type", "") or "").lower()
if "text/html" not in ct:
return
try:
body = flow.response.content or b""
except Exception:
return
if not body or _MARK in body:
return
style = _style_for(f.get("ad_ghost_categories", {}))
if not style:
return
if _RE_HEAD.search(body):
new = _RE_HEAD.sub(style + b"</head>", body, count=1)
else:
new = style + body
flow.response.content = new
_counts["pages_cleaned"] += 1
_flush()
addons = [AdGhost()]

View File

@ -479,9 +479,28 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
if utiq_n > 0:
# 📡 N — operator-grade tracker active
right_parts.append(f"&#x1F4E1; utiq:{utiq_n}")
# #566 — ghost quick-stats: ads/widgets ghosted + bandwidth saved.
g_blocked = ctx.get("ghost_blocked", 0)
g_kb = ctx.get("ghost_kb", 0)
if g_blocked > 0:
# 🛡 N · ~X Ko économisés
right_parts.append(f"&#x1F6E1; {g_blocked}&#xA0;&#x2715;&#xA0;~{g_kb}&#x202F;Ko")
if ctx["asn"]:
right_parts.append(_ncr(ctx["asn"]))
right_text = " &#xB7; ".join(right_parts) # middle dot · = &#xB7;
# #572 — render the stats as a colourful "guirlande" of emoji chips :
# each metric is a vibrant rounded pill with a neon glow, cycling
# through a festive palette. Pure-ASCII styling (NCR emojis) so the
# ascii-encode of both the CSP-strict + JS paths stays happy.
_GUIRLANDE = ("#c9a84c", "#00d4ff", "#00ff41", "#e63946",
"#9e76ff", "#ff9900", "#ff5a9e", "#39ff14")
_chips = []
for _i, _p in enumerate(right_parts):
_c = _GUIRLANDE[_i % len(_GUIRLANDE)]
_chips.append(
f"<span style=\"background:{_c};color:#0a0a0f;padding:1px 7px;"
f"margin:0 2px;border-radius:9px;font-weight:bold;white-space:nowrap;"
f"box-shadow:0 0 6px {_c},0 0 2px {_c}\">{_p}</span>")
right_text = "".join(_chips)
grade = ctx["grade"]
grade_color = ctx["grade_color"]
# Static emojis used in the left-side text
@ -644,6 +663,17 @@ class InjectBanner:
# AND R3 (portable WG opt-in). R0/R1 stay banner-free.
if _client_level(flow) not in ("r2", "r3"):
return
# #566 — modular filter toggle (toolbox WebUI). Banner can be
# disabled without touching the addon list.
try:
import sys as _sys
if "/usr/lib/secubox/toolbox" not in _sys.path:
_sys.path.insert(0, "/usr/lib/secubox/toolbox")
from secubox_toolbox.filters import get_filters as _gf
if not _gf().get("banner", True):
return
except Exception:
pass
# Phase 10 perf : cheap pre-flight check on Content-Length to avoid
# reading multi-MB bodies into RAM just to discover we'd skip them.
# `flow.response.content` would buffer the whole body before returning.
@ -667,10 +697,25 @@ class InjectBanner:
# for R2) + level label ("R2" vs "R3")
try:
ctx = _compute_site_context(flow)
# #566 — ghost quick-stats (ads/widgets ghosted + KB saved).
try:
import json as _json
with open("/run/secubox/ghost.json", "r", encoding="utf-8") as _gf2:
_g = _json.load(_gf2)
ctx["ghost_blocked"] = int(_g.get("blocked_requests", 0))
ctx["ghost_kb"] = int(_g.get("bytes_saved_est", 0)) // 1024
except Exception:
ctx["ghost_blocked"] = 0
ctx["ghost_kb"] = 0
csp_strict = _detect_csp_strict(flow)
report_url = _report_url_for(flow)
level_label = _level_label(flow)
level = _client_level(flow)
# #566 — R3+/R4 is an active-protection tier (spoof + ghost),
# not mere inspection: relabel the banner status accordingly.
if level in ("r3", "r4") and ctx.get("status") == "inspected":
ctx["status"] = "protected"
ctx["status_icon"] = "\U0001F6E1" # 🛡
snippet = _banner_html_dynamic(_CA_SHA1, ctx, csp_strict,
report_url, level_label, level)
except Exception as e:

View File

@ -0,0 +1,113 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# #570 — DPI media/content-type statistifier. Buckets every mitm response
# by content-type CATEGORY and by PROVIDER (eTLD+1), summing Content-Length
# (header only — never reads the body, safe on video/large media). Rolling
# in-memory counters flushed to /run/secubox/media.json for the donut UI.
from __future__ import annotations
import json
import os
import re
import time
from mitmproxy import http
_STATS = "/run/secubox/media.json"
# content-type → (category, emoji). Order matters (first match wins).
_CATS = (
("page", "\U0001F4C4", ("text/html", "application/xhtml")), # 📄
("image", "\U0001F5BC", ("image/",)), # 🖼
("video", "\U0001F3AC", ("video/", "application/vnd.apple.mpegurl",
"application/x-mpegurl", "application/dash+xml")), # 🎬
("audio", "\U0001F3B5", ("audio/",)), # 🎵
("script", "\U0001F9E9", ("javascript", "ecmascript")), # 🧩
("style", "\U0001F3A8", ("text/css",)), # 🎨
("font", "\U0001F524", ("font/", "application/font", "application/vnd.ms-fontobject",
"application/x-font")), # 🔤
("api", "\U0001F4E6", ("application/json", "+json", "application/xml",
"text/xml", "application/grpc")), # 📦
("text", "\U0001F4DD", ("text/plain", "text/")), # 📝
)
_OTHER = ("other", "") # ❓
EMOJI = {c: e for c, e, _ in _CATS}
EMOJI[_OTHER[0]] = _OTHER[1]
_MAX_PROVIDERS = 250
_2L_TLD = ("co.uk", "com.au", "co.jp", "co.nz", "com.br", "co.za", "gouv.fr")
_cats: dict = {}
_providers: dict = {}
_total = {"bytes": 0, "count": 0, "since": int(time.time())}
_last_flush = 0.0
def _category(ct: str) -> tuple:
ct = (ct or "").split(";", 1)[0].strip().lower()
if not ct:
return _OTHER
for cat, emoji, frags in _CATS:
if any(f in ct for f in frags):
return (cat, emoji)
return _OTHER
def _registrable(host: str) -> str:
host = (host or "").split(":", 1)[0].lower().strip(".")
if not host or host.replace(".", "").isdigit():
return host or "?"
parts = host.split(".")
if len(parts) <= 2:
return host
last2 = ".".join(parts[-2:])
if last2 in _2L_TLD and len(parts) >= 3:
return ".".join(parts[-3:])
return last2
def _flush(force: bool = False) -> None:
global _last_flush
now = time.time()
if not force and (now - _last_flush) < 5:
return
_last_flush = now
# cap providers to the heaviest _MAX_PROVIDERS by bytes
global _providers
if len(_providers) > _MAX_PROVIDERS:
_providers = dict(sorted(_providers.items(),
key=lambda kv: -kv[1]["bytes"])[:_MAX_PROVIDERS])
try:
os.makedirs(os.path.dirname(_STATS), exist_ok=True)
with open(_STATS, "w", encoding="utf-8") as f:
json.dump({"categories": _cats, "providers": _providers,
"total": _total, "updated": int(now)}, f)
except Exception:
pass
class MediaStats:
def response(self, flow: http.HTTPFlow) -> None:
if not flow.response:
return
h = flow.response.headers
cat, _ = _category(h.get("content-type", ""))
try:
size = int(h.get("content-length", "0") or "0")
except (TypeError, ValueError):
size = 0
prov = _registrable(flow.request.pretty_host or "")
c = _cats.setdefault(cat, {"bytes": 0, "count": 0})
c["bytes"] += size
c["count"] += 1
p = _providers.setdefault(prov, {"bytes": 0, "count": 0})
p["bytes"] += size
p["count"] += 1
_total["bytes"] += size
_total["count"] += 1
_flush()
addons = [MediaStats()]

View File

@ -22,6 +22,7 @@ from __future__ import annotations
import logging
import os
import sys
import re
import time
@ -61,6 +62,14 @@ _last_flush = 0.0
def _level() -> str:
# Modular filter config (toolbox WebUI) is the source of truth (#566);
# fall back to the SECUBOX_PROTECTIVE_MODE env when the config is absent.
try:
if "/usr/lib/secubox/toolbox" not in sys.path:
sys.path.insert(0, "/usr/lib/secubox/toolbox")
from secubox_toolbox.filters import get_filters
v = (get_filters().get("protective") or "off").strip().lower()
except Exception:
v = (os.environ.get("SECUBOX_PROTECTIVE_MODE") or "off").strip().lower()
return v if v in ("off", "alert", "spoof") else "off"

View File

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

View File

@ -107,7 +107,10 @@ fi
# 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
# ad_ghost (#566) runs right after protective_mode: for R3+/R4 it 204s known
# ad/tracker hosts (bandwidth save) at request time and injects ad-hiding CSS
# on HTML responses. Gated by the modular filter config (toolbox WebUI).
for addon in inject_xff utiq_defense protective_mode ad_ghost local_store social_graph inject_banner dpi cookies avatar ja4 soc_relay cert_pin_detect media_stats; do
ARGS+=(-s "$ADDON_DIR/${addon}.py")
done

View File

@ -1395,7 +1395,7 @@ async def wg_toolbox_apk() -> Response:
_WEBEXT_XPI = Path("/var/lib/secubox/toolbox/webext/secubox-toolbox-webext.xpi")
_WEBEXT_XPI_RELEASE = (
"https://github.com/CyberMind-FR/secubox-deb/releases/download/"
"webext-v0.1.2/secubox-toolbox-webext.xpi"
"webext-v0.1.4/secubox-toolbox-webext.xpi"
)
@ -2422,9 +2422,176 @@ async def admin_protective() -> dict:
out.update(_json.loads(st.read_text()))
except Exception:
pass
try:
from .filters import get_filters as _gf
out["mode"] = _gf().get("protective", out["mode"])
except Exception:
pass
return out
@router.get("/admin/ghost")
async def admin_ghost() -> dict:
"""#566 — ad/banner ghoster savings (R3+/R4). Read-only counters."""
import json as _json
from pathlib import Path as _P
out: dict = {"blocked_requests": 0, "bytes_saved_est": 0,
"pages_cleaned": 0, "since": None, "updated": None}
try:
st = _P("/run/secubox/ghost.json")
if st.exists():
out.update(_json.loads(st.read_text()))
except Exception:
pass
out["mb_saved_est"] = round(out.get("bytes_saved_est", 0) / 1048576, 1)
return out
_MEDIA_EMOJI = {
"page": "\U0001F4C4", "image": "\U0001F5BC", "video": "\U0001F3AC",
"audio": "\U0001F3B5", "script": "\U0001F9E9", "style": "\U0001F3A8",
"font": "\U0001F524", "api": "\U0001F4E6", "text": "\U0001F4DD",
"other": "",
}
@router.get("/admin/media")
async def admin_media() -> dict:
"""#570 — DPI media/content-type statistics for the donut UI."""
import json as _json
from pathlib import Path as _P
raw: dict = {"categories": {}, "providers": {}, "total": {"bytes": 0, "count": 0}}
try:
st = _P("/run/secubox/media.json")
if st.exists():
raw.update(_json.loads(st.read_text()))
except Exception:
pass
tot_b = max(int(raw.get("total", {}).get("bytes", 0)), 1)
cats = []
for cat, v in (raw.get("categories") or {}).items():
b = int(v.get("bytes", 0))
cats.append({"cat": cat, "emoji": _MEDIA_EMOJI.get(cat, ""),
"bytes": b, "count": int(v.get("count", 0)),
"mb": round(b / 1048576, 1), "pct": round(100 * b / tot_b, 1)})
cats.sort(key=lambda x: -x["bytes"])
provs = sorted(
({"provider": p, "bytes": int(v.get("bytes", 0)),
"count": int(v.get("count", 0)), "mb": round(int(v.get("bytes", 0)) / 1048576, 1)}
for p, v in (raw.get("providers") or {}).items()),
key=lambda x: -x["bytes"])[:5]
return {"categories": cats, "top_providers": provs,
"total_mb": round(raw.get("total", {}).get("bytes", 0) / 1048576, 1),
"total_count": raw.get("total", {}).get("count", 0),
"updated": raw.get("updated")}
@router.get("/admin/media/ui", response_class=HTMLResponse)
async def admin_media_ui() -> HTMLResponse:
"""#570 — donut + emoji legend + top-5 providers."""
html = """<!doctype html><html lang=fr><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>DPI Médias ToolBoX</title>
<style>
body{background:#0a0a0f;color:#e8e6d9;font:14px system-ui,sans-serif;max-width:560px;margin:24px auto;padding:0 18px;text-align:center}
h1{color:#c9a84c;font-size:18px} .muted{color:#6b6b7a;font-size:12px}
#legend{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:10px 0}
.lg{background:#12121a;border:1px solid #222;border-radius:14px;padding:4px 9px;font-size:12px}
table{width:100%;border-collapse:collapse;margin-top:14px;font-size:13px}
td{padding:5px 4px;border-bottom:1px solid #1a1a22;text-align:left} td.r{text-align:right;color:#6b6b7a}
.fav{width:16px;height:16px;border-radius:3px;vertical-align:middle;margin-right:6px;background:#1a1a22}
h2{color:#6e40c9;font-size:13px;margin:18px 0 4px;text-align:left}
</style>
<h1>📊 DPI types de contenus</h1>
<p class=muted id=tot></p>
<svg id=donut viewBox="0 0 200 200" width=200 height=200></svg>
<div id=legend></div>
<h2>Top 5 fournisseurs</h2>
<table id=provs></table>
<script>
const PAL=['#c9a84c','#00d4ff','#e63946','#6e40c9','#00ff41','#ff9900','#9aa0a6','#ff5a9e','#4285f4','#888'];
const SVGNS='http://www.w3.org/2000/svg';
function arc(cx,cy,r,a0,a1){const p=(a,rr)=>[cx+rr*Math.cos(a),cy+rr*Math.sin(a)];const[x0,y0]=p(a0,r),[x1,y1]=p(a1,r),[xi1,yi1]=p(a1,r*0.58),[xi0,yi0]=p(a0,r*0.58);const big=a1-a0>Math.PI?1:0;return`M${x0} ${y0}A${r} ${r} 0 ${big} 1 ${x1} ${y1}L${xi1} ${yi1}A${r*0.58} ${r*0.58} 0 ${big} 0 ${xi0} ${yi0}Z`;}
fetch('/admin/media').then(r=>r.json()).then(d=>{
document.getElementById('tot').textContent=`${d.total_mb||0} Mo · ${d.total_count||0} flux`;
const svg=document.getElementById('donut');const cats=d.categories||[];
let a=-Math.PI/2;const tot=cats.reduce((s,c)=>s+c.bytes,0)||1;
cats.forEach((c,i)=>{const a1=a+2*Math.PI*c.bytes/tot;const path=document.createElementNS(SVGNS,'path');path.setAttribute('d',arc(100,100,92,a,a1));path.setAttribute('fill',PAL[i%PAL.length]);const t=document.createElementNS(SVGNS,'title');t.textContent=`${c.emoji} ${c.cat} ${c.pct}% (${c.mb} Mo)`;path.appendChild(t);svg.appendChild(path);a=a1;});
const lg=document.getElementById('legend');
cats.forEach((c,i)=>{const s=document.createElement('span');s.className='lg';s.innerHTML=`<b style="color:${PAL[i%PAL.length]}"></b> ${c.emoji} ${c.cat} ${c.pct}%`;lg.appendChild(s);});
const tb=document.getElementById('provs');
(d.top_providers||[]).forEach(p=>{const tr=document.createElement('tr');tr.innerHTML=`<td><img class=fav loading=lazy src="/social/favicon/${encodeURIComponent(p.provider)}" onerror="this.style.visibility='hidden'">${p.provider}</td><td class=r>${p.mb} Mo · ${p.count}</td>`;tb.appendChild(tr);});
});
</script></html>"""
return HTMLResponse(content=html)
@router.get("/admin/filters")
async def admin_filters_get() -> dict:
"""#566 — modular mitm filter config (read)."""
from .filters import get_filters
return get_filters(force=True)
@router.post("/admin/filters")
async def admin_filters_set(request: Request) -> dict:
"""#566 — toggle mitm filters from the toolbox WebUI (write)."""
from .filters import set_filters
try:
patch = await request.json()
except Exception:
patch = {}
if not isinstance(patch, dict):
raise HTTPException(status_code=400, detail="json object expected")
return set_filters(patch)
@router.get("/admin/filters/ui", response_class=HTMLResponse)
async def admin_filters_ui() -> HTMLResponse:
"""#566 — minimal filter toggle panel for the toolbox WebUI."""
html = """<!doctype html><html lang=fr><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Filtres ToolBoX</title>
<style>
body{background:#0a0a0f;color:#e8e6d9;font:14px system-ui,sans-serif;max-width:560px;margin:30px auto;padding:0 18px}
h1{color:#c9a84c;font-size:18px} h2{color:#6e40c9;font-size:13px;margin:18px 0 6px}
label{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #1a1a22}
select,input{accent-color:#6e40c9} .muted{color:#6b6b7a;font-size:12px}
.stat{display:inline-block;background:#12121a;border:1px solid #222;border-radius:6px;padding:6px 10px;margin:4px 4px 0 0}
#msg{color:#00ff41;min-height:18px;margin-top:10px}
</style>
<h1>🛡 Filtres ToolBoX</h1>
<p class=muted>Activation/désactivation à chaud des filtres mitm (R2/R3+). Prend effet en quelques secondes, sans redémarrage.</p>
<div id=stats></div>
<h2>Bannière & inspection</h2>
<label><input type=checkbox data-k=banner> Bannière de transparence (R2/R3)</label>
<label>Mode protecteur (spoof traceurs)
<select data-k=protective><option value=off>off</option><option value=alert>alert</option><option value=spoof>spoof</option></select></label>
<h2>Ghosting pub (R3+/R4)</h2>
<label><input type=checkbox data-k=ad_ghost> Masquer pubs/bannières/widgets (cosmétique)</label>
<label><input type=checkbox data-k=ad_ghost_block> Bloquer les hôtes pub/traceurs (économise la bande passante)</label>
<label><input type=checkbox data-c=ads> · catégorie : publicités</label>
<label><input type=checkbox data-c=consent_nag> · catégorie : bandeaux cookies/consentement</label>
<label><input type=checkbox data-c=newsletter> · catégorie : pop-ups newsletter</label>
<label><input type=checkbox data-c=social_widgets> · catégorie : widgets sociaux</label>
<p id=msg></p>
<script>
const $=s=>document.querySelector(s);
function post(p){fetch('/admin/filters',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(p)}).then(r=>r.json()).then(load).then(()=>{$('#msg').textContent='Enregistré ✓';setTimeout(()=>$('#msg').textContent='',1200)});}
function load(f){
document.querySelectorAll('[data-k]').forEach(el=>{const k=el.dataset.k;if(el.type==='checkbox')el.checked=!!f[k];else el.value=f[k];});
const c=f.ad_ghost_categories||{};document.querySelectorAll('[data-c]').forEach(el=>el.checked=!!c[el.dataset.c]);
}
function wire(){
document.querySelectorAll('[data-k]').forEach(el=>el.addEventListener('change',()=>{const v=el.type==='checkbox'?el.checked:el.value;post({[el.dataset.k]:v});}));
document.querySelectorAll('[data-c]').forEach(el=>el.addEventListener('change',()=>post({ad_ghost_categories:{[el.dataset.c]:el.checked}})));
}
function stats(){fetch('/admin/ghost').then(r=>r.json()).then(g=>{$('#stats').innerHTML=`<span class=stat>🛡 ${g.blocked_requests||0} bloqués</span><span class=stat>💾 ~${g.mb_saved_est||0} Mo économisés</span><span class=stat>🧹 ${g.pages_cleaned||0} pages nettoyées</span>`;});}
fetch('/admin/filters').then(r=>r.json()).then(f=>{load(f);wire();stats();});
</script></html>"""
return HTMLResponse(content=html)
@router.get("/social/report/{token}.pdf")
async def social_report_pdf(token: str) -> Response:
"""Phase 11.C (#508) — bilingual FR/EN evidence PDF for a peer.

View File

@ -0,0 +1,86 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# SecuBox-Deb :: toolbox :: modular filter config (#566)
#
# Single source of truth for which mitm filters are active, toggled from
# the toolbox WebUI (GET/POST /admin/filters). The mitm addons
# (inject_banner, protective_mode, ad_ghost) read this at flow time with a
# short cache so toggles take effect within seconds — no restart.
from __future__ import annotations
import json
import os
import time
from typing import Dict
FILTERS_PATH = "/etc/secubox/toolbox/filters.json"
DEFAULTS: Dict = {
"banner": True, # inject the R2/R3 transparency banner
"protective": "spoof", # off | alert | spoof (tracker spoofer)
"ad_ghost": True, # R3+/R4 silent ad/banner/widget ghosting
"ad_ghost_block": True, # 204 known ad/tracker hosts (save bandwidth)
"ad_ghost_categories": { # cosmetic ghost groups
"ads": True,
"consent_nag": True,
"newsletter": True,
"social_widgets": True,
},
}
_VALID_PROTECTIVE = ("off", "alert", "spoof")
_cache: Dict = {}
_cache_ts: float = 0.0
def get_filters(force: bool = False) -> Dict:
"""Merged filter config (defaults + on-disk overrides), 5 s cached."""
global _cache, _cache_ts
now = time.time()
if not force and _cache and (now - _cache_ts) < 5:
return _cache
out = json.loads(json.dumps(DEFAULTS)) # deep copy
try:
with open(FILTERS_PATH, "r", encoding="utf-8") as f:
disk = json.load(f)
if isinstance(disk, dict):
for k, v in disk.items():
if k == "ad_ghost_categories" and isinstance(v, dict):
out["ad_ghost_categories"].update(v)
elif k in out:
out[k] = v
except Exception:
pass
if out.get("protective") not in _VALID_PROTECTIVE:
out["protective"] = DEFAULTS["protective"]
_cache = out
_cache_ts = now
return out
def set_filters(patch: Dict) -> Dict:
"""Merge a partial patch into the on-disk config and return the result.
Only known keys are accepted (defensive)."""
global _cache_ts
cur = get_filters(force=True)
for k, v in (patch or {}).items():
if k == "ad_ghost_categories" and isinstance(v, dict):
cur["ad_ghost_categories"].update(
{ck: bool(cv) for ck, cv in v.items()
if ck in DEFAULTS["ad_ghost_categories"]})
elif k == "protective" and v in _VALID_PROTECTIVE:
cur["protective"] = v
elif k in ("banner", "ad_ghost", "ad_ghost_block"):
cur[k] = bool(v)
try:
os.makedirs(os.path.dirname(FILTERS_PATH), exist_ok=True)
tmp = FILTERS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cur, f, indent=1)
os.replace(tmp, FILTERS_PATH)
except Exception:
pass
_cache_ts = 0.0 # invalidate
return cur

View File

@ -243,8 +243,9 @@ async function loadClients() {
const rows = (d && d.clients) ? d.clients : (Array.isArray(d) ? d : null);
if (!rows) { el.innerHTML = `<div class="empty">${(d && d.__error) || 'no data'}</div>`; return; }
if (!rows.length) { el.innerHTML = '<div class="empty">no clients</div>'; return; }
const shown = rows.slice(0, 5); // #575 — cap the list to top 5
let html = '<table><thead><tr><th>MAC (hash)</th><th>IP</th><th>state</th><th>niveau</th><th>score</th><th>last</th><th>Actions</th></tr></thead><tbody>';
for (const c of rows) {
for (const c of shown) {
const ago = c.last_seen ? Math.round((Date.now()/1000 - c.last_seen) / 60) + 'm' : '—';
html += `<tr>
<td><code>${c.mac_hash}</code></td>
@ -265,6 +266,9 @@ async function loadClients() {
</tr>`;
}
html += '</tbody></table>';
if (rows.length > shown.length) {
html += `<div class="empty">+ ${rows.length - shown.length} autres clients (top 5 affichés)</div>`;
}
el.innerHTML = html;
}

View File

@ -155,6 +155,11 @@
// the densest tracker clusters become the visible "hot spots".
const EYE_ID = 'eye:device';
// #575 — IPs are noise in this view (and produce a 2nd bubble next to
// the domain for the same metric). Hide every IP-only tracker node ;
// keep domains, labelled by country flag + name (never the IP).
const isIp = (s) => /^\d{1,3}(\.\d{1,3}){3}$/.test(s) || (s || '').includes(':');
// Build d3 dataset: sites are union of all node.sites + tracker nodes themselves.
const siteSet = new Set();
for (const n of graph.nodes) for (const s of (n.sites || [])) siteSet.add(s);
@ -169,10 +174,11 @@
nodes.push({ id: 'site:' + s, label: s, kind: 'site' });
}
for (const n of graph.nodes) {
if (isIp(n.domain)) continue; // #575 — hide IP-only nodes
idx.set('tracker:' + n.domain, nodes.length);
nodes.push({
id: 'tracker:' + n.domain,
label: n.domain,
label: (n.country_flag ? n.country_flag + ' ' : '') + n.domain,
kind: 'tracker',
hits: n.hits,
sites: n.sites,
@ -194,6 +200,7 @@
links.push({ source: EYE_ID, target: 'site:' + s, reuse: 1, spoke: true });
}
for (const n of graph.nodes) {
if (isIp(n.domain)) continue; // #575 — no links to hidden IP nodes
const trackerKey = 'tracker:' + n.domain;
for (const s of (n.sites || [])) {
links.push({

View File

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