Merge pull request #567 from CyberMind-FR/feature/566-r3-r4-silent-ad-banner-ghoster-economisa
Some checks are pending
License Headers / check (push) Waiting to run

Modular mitm filters + R3+/R4 silent ad/banner ghoster (#566)
This commit is contained in:
CyberMind 2026-06-14 09:29:15 +02:00 committed by GitHub
commit 295f77601d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 417 additions and 2 deletions

View File

@ -1,3 +1,26 @@
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,

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

View File

@ -0,0 +1,157 @@
# 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""
rule = ",".join(sels) + "{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,6 +479,12 @@ 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;
@ -644,6 +650,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 +684,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

@ -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"

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; do
ARGS+=(-s "$ADDON_DIR/${addon}.py")
done

View File

@ -2422,9 +2422,97 @@ 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
@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