mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-02 01:52:06 +00:00
Compare commits
18 Commits
18f727b7d7
...
c043c5fca8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c043c5fca8 | ||
| 10d22f05b7 | |||
|
|
5ece3f1208 | ||
| 5ca4ecf455 | |||
|
|
ace77976fa | ||
| 4ffd66bb2d | |||
|
|
9b8144073f | ||
| 2f04b0fa84 | |||
|
|
270f655d3a | ||
| b366946855 | |||
|
|
433c5ca190 | ||
| 1a23c1f78a | |||
|
|
ff0503ea70 | ||
| 675f6ae458 | |||
|
|
b689c235f6 | ||
| d26992d905 | |||
|
|
295f77601d | ||
| f176fa3173 |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' "$*"; }
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
systemctl try-restart "$unit" 2>/dev/null || true
|
||||
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
|
||||
;;
|
||||
|
|
|
|||
161
packages/secubox-toolbox/mitmproxy_addons/ad_ghost.py
Normal file
161
packages/secubox-toolbox/mitmproxy_addons/ad_ghost.py
Normal 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()]
|
||||
|
|
@ -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"📡 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"🛡 {g_blocked} ✕ ~{g_kb} Ko")
|
||||
if ctx["asn"]:
|
||||
right_parts.append(_ncr(ctx["asn"]))
|
||||
right_text = " · ".join(right_parts) # middle dot · = ·
|
||||
# #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:
|
||||
|
|
|
|||
113
packages/secubox-toolbox/mitmproxy_addons/media_stats.py
Normal file
113
packages/secubox-toolbox/mitmproxy_addons/media_stats.py
Normal 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()]
|
||||
|
|
@ -22,6 +22,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
|
||||
|
|
@ -61,7 +62,15 @@ _last_flush = 0.0
|
|||
|
||||
|
||||
def _level() -> str:
|
||||
v = (os.environ.get("SECUBOX_PROTECTIVE_MODE") or "off").strip().lower()
|
||||
# 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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
86
packages/secubox-toolbox/secubox_toolbox/filters.py
Normal file
86
packages/secubox-toolbox/secubox_toolbox/filters.py
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user