feat(secubox-core+toolbox): generative rule engine + sensitivity profiles + whitelist expansion + iOS captive fix (ref #492)

## 4 changes bundled

### 1. URGENT iOS 'commencer a surfer' fix

The button linked to https://duckduckgo.com directly. iOS captive sheet
intercepted the navigation and did nothing visible. User stuck on the
success page.

Fix : href -> http://captive.apple.com/hotspot-detect.html + target=_blank
+ JS fallback to DDG after 500ms. iOS sees the captive probe succeed,
closes the captive sheet, user lands in Safari with full connectivity.

### 2. Generative rule engine (common/secubox_core/rule_engine.py)

Replaces the static whitelist.py with a 3-layer decision engine :

  Layer 1 : STATIC      whitelist-baseline.yaml + operator override
  Layer 2 : GENERATIVE  Python predicates that auto-trust by criteria :
                          - *.gouv.fr / service-public.fr -> trust
                          - *.ameli.fr / cnam.fr / cpam.fr / etc. -> trust
                          - hosts with ≥3 cert-pinning failures observed
                            -> trust-warn (auto-learned)
                          - high-reputation ASN (Cloudflare/Akamai/Google
                            /Amazon/Apple/OVH) -> trust-warn
  Layer 3 : DEFAULT     inspect (fall through)

  record_pinning_failure(host) called by mitm addons on TLS handshake
  fail -> persisted to /var/lib/secubox/toolbox/learned-rules.json ->
  next evaluate() returns trust-warn for that host.

### 3. Dynamic sensitivity profiles

  🟢 low       : block only confirmed malware (threat-intel match).
                 DGA ≥ 90, beacon never. No false positives.
  🟡 medium    : balanced default. DGA ≥ 70, beacon ≥ 75.
  🟠 high      : strict. DGA ≥ 50, beacon ≥ 50, low-rep ASN block.
  🔴 paranoid  : default-deny — block anything not whitelisted.

  should_block(threat_intel_matches, dga_score, beaconing_score, asn_rep,
               is_whitelisted) returns (decision, reason). Used by
  scoring + (Phase 4) active blocking.

  Profile selectable via /etc/secubox/toolbox/rule-engine.yaml :
    sensitivity: low | medium | high | paranoid
    static_rules: [...]  # operator extends baseline

### 4. Whitelist baseline expansion (47 -> 108 patterns)

  + Banking FR neobanks : Boursorama Banque, Hello Bank, BforBank, N26,
    Wise, Monzo, ING, Trade Republic + crypto (Binance, Kraken, Coinbase)
  + Streaming : Netflix, Disney+, Spotify, Deezer, Canal+, France.tv,
    Arte, YouTube + CDN, Twitch, Amazon Video
  + Education FR : Pronote, Ecole Directe, ENT universitaire, CNED
  + Health FR : Maiia, Livi
  + Workplace SaaS : Office365, SharePoint, Slack, Zoom, Teams,
    Salesforce, Atlassian, Notion, Dropbox, Box, Adobe
  + Gaming : Steam, Epic, EA, Battle.net, PlayStation, Xbox Live,
    Nintendo, Discord
  + DoH resolvers : Cloudflare, Google, NextDNS

## Backward compat

whitelist.py kept as a thin shim — match() / is_whitelisted() delegate
to rule_engine. local_store.py + inject_banner.py continue to work.

## Verified gk2 (2026-06-05)

  stats : static_rules=108, generative_rules=4, sensitivity=medium
  evaluate('impots.gouv.fr') -> trust (gov-fr generative)
  should_block(threat_intel_matches=1) -> True
  should_block(dga_score=80) -> True (≥ 70)
  should_block(is_whitelisted=True) -> False
This commit is contained in:
CyberMind-FR 2026-06-05 09:45:04 +02:00
parent 9690837177
commit 6399a6c3ee
4 changed files with 655 additions and 1 deletions

View File

@ -0,0 +1,385 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""ToolBoX rule engine (#492) : generative whitelist + dynamic filtering sensitivity.
Replaces the static whitelist.py with a 3-layer system :
1. STATIC rules : operator-edited YAML patterns (whitelist-baseline.yaml
+ /etc/secubox/toolbox/whitelist.yaml)
2. GENERATIVE : auto-learned rules at runtime :
- persistent cert-pinning failures -> auto-whitelist
- HSTS preload list match -> trust
- high-reputation ASN + valid CT log -> soft trust
3. SENSITIVITY : filtering profile that gates active blocking decisions :
low / medium / high / paranoid
Stored in :
/etc/secubox/toolbox/rule-engine.yaml : sensitivity profile + custom rules
/var/lib/secubox/toolbox/learned-rules.json : runtime-learned trust list
The engine exposes :
evaluate(host, context) -> {action, reason, source}
action = "trust" | "trust-warn" | "inspect" | "block"
source = "static" | "generative" | "default"
Used by :
- mitmproxy addons before deciding to MITM
- local_store.py to tag analysis_status
- aggregator to produce the inspection breakdown
"""
from __future__ import annotations
import fnmatch
import json
import logging
import re
import time
from pathlib import Path
from typing import Optional
log = logging.getLogger("secubox.rule_engine")
# Configurable paths
BASELINE_PATH = Path("/usr/share/secubox/toolbox/whitelist-baseline.yaml")
OVERRIDE_PATH = Path("/etc/secubox/toolbox/whitelist.yaml")
RULES_PATH = Path("/etc/secubox/toolbox/rule-engine.yaml")
LEARNED_PATH = Path("/var/lib/secubox/toolbox/learned-rules.json")
# Cache
_CACHE: dict | None = None
_CACHE_MTIME: float = 0.0
_LEARNED_CACHE: dict | None = None
_LEARNED_CACHE_MTIME: float = 0.0
# ────────── Sensitivity profiles ──────────
SENSITIVITY_PROFILES = {
"low": {
"label": "🟢 Permissif",
"description": "Bloque seulement le malware confirmé. Aucun faux positif.",
"threat_intel_action": "block", # block iff match in feeds
"dga_score_min": 90, # block iff DGA confidence ≥ 90
"beaconing_score_min": 100, # never block on beacon alone
"block_unknown": False,
},
"medium": {
"label": "🟡 Équilibré (défaut)",
"description": "Bloque malware + DGA fort + beaconing évident.",
"threat_intel_action": "block",
"dga_score_min": 70,
"beaconing_score_min": 75,
"block_unknown": False,
},
"high": {
"label": "🟠 Strict",
"description": "Bloque malware + DGA + beaconing + ASN faible réputation.",
"threat_intel_action": "block",
"dga_score_min": 50,
"beaconing_score_min": 50,
"block_unknown": False,
"block_low_reputation_asn": True,
},
"paranoid": {
"label": "🔴 Paranoïaque",
"description": "Bloque TOUT sauf si whitelisté (static OU généré).",
"threat_intel_action": "block",
"dga_score_min": 30,
"beaconing_score_min": 30,
"block_unknown": True, # default-deny
},
}
# ────────── Generative rules ──────────
# Predicate functions for generative rules. Each takes (host, context) and
# returns True if the rule matches.
def _is_french_gov(host: str, ctx: dict) -> bool:
"""*.gouv.fr or *.service-public.fr : French gov, never MITM."""
return host.endswith(".gouv.fr") or host == "service-public.fr"
def _is_french_health(host: str, ctx: dict) -> bool:
"""*.fr health domains (CNAM/CPAM/ANS)."""
return any(host.endswith(s) for s in (
".ameli.fr", ".cnam.fr", ".cpam.fr", ".ars.sante.fr",
".dossier-medical.fr", ".sesam-vitale.fr",
))
def _is_persistent_pinning(host: str, ctx: dict) -> bool:
"""Auto-learn : if we observed ≥3 cert-pinning failures, assume legit."""
pinning_count = ctx.get("pinning_history", {}).get(host, 0)
return pinning_count >= 3
def _is_high_reputation_asn(host: str, ctx: dict) -> bool:
"""ASN is in our high-reputation set (Cloudflare, Akamai, Google, etc.)."""
high_rep_asns = {
13335, # Cloudflare
16509, # Amazon
15169, # Google
20940, # Akamai
32934, # Facebook
2906, # Netflix
46489, # Twitch
13414, # Twitter
714, # Apple
16276, # OVH
}
asn = ctx.get("asn")
return asn in high_rep_asns if asn else False
GENERATIVE_RULES = [
{
"name": "gov-fr",
"predicate": _is_french_gov,
"action": "trust",
"reason": "French gov ccTLD (*.gouv.fr / service-public.fr)",
"category": "government-auto",
},
{
"name": "health-fr",
"predicate": _is_french_health,
"action": "trust",
"reason": "French healthcare infrastructure",
"category": "health-auto",
},
{
"name": "persistent-pinning",
"predicate": _is_persistent_pinning,
"action": "trust-warn",
"reason": "Cert-pinning observed ≥3 times — auto-learned trust",
"category": "learned-cert-pinned",
},
{
"name": "high-rep-asn",
"predicate": _is_high_reputation_asn,
"action": "trust-warn",
"reason": "Hosted by high-reputation infrastructure provider",
"category": "infrastructure-auto",
},
]
# ────────── Static rules (YAML) ──────────
def _load_yaml(path: Path) -> list[dict]:
if not path.exists():
return []
try:
import yaml as _yaml
except ImportError:
log.warning("PyYAML unavailable")
return []
try:
data = _yaml.safe_load(path.read_text()) or {}
return data.get("whitelist", []) or []
except Exception as e:
log.warning("yaml load failed (%s): %s", path, e)
return []
def _load_engine_config() -> dict:
"""Load /etc/secubox/toolbox/rule-engine.yaml — sensitivity + custom rules."""
if not RULES_PATH.exists():
return {"sensitivity": "medium"}
try:
import yaml as _yaml
return _yaml.safe_load(RULES_PATH.read_text()) or {"sensitivity": "medium"}
except Exception as e:
log.warning("rule-engine config load failed: %s", e)
return {"sensitivity": "medium"}
def _load_learned() -> dict:
"""Load runtime-learned rules from disk."""
global _LEARNED_CACHE, _LEARNED_CACHE_MTIME
if not LEARNED_PATH.exists():
return {"hosts": {}, "pinning_history": {}}
try:
m = LEARNED_PATH.stat().st_mtime
if _LEARNED_CACHE is not None and m <= _LEARNED_CACHE_MTIME:
return _LEARNED_CACHE
_LEARNED_CACHE = json.loads(LEARNED_PATH.read_text())
_LEARNED_CACHE_MTIME = m
return _LEARNED_CACHE
except Exception as e:
log.warning("learned rules load failed: %s", e)
return {"hosts": {}, "pinning_history": {}}
def record_pinning_failure(host: str) -> int:
"""Auto-learn : called by mitm addons when a cert-pinning failure is detected.
Increments the host's pinning counter. Once ≥3, the host gets generatively
whitelisted on the next evaluate() call.
Returns the new count.
"""
learned = _load_learned()
learned.setdefault("pinning_history", {})
learned["pinning_history"][host] = learned["pinning_history"].get(host, 0) + 1
learned.setdefault("hosts", {})[host] = {
"last_seen": int(time.time()),
"source": "auto-pinning-detected",
}
try:
LEARNED_PATH.parent.mkdir(parents=True, exist_ok=True)
LEARNED_PATH.write_text(json.dumps(learned, indent=2)[:200000])
# Bust the cache
global _LEARNED_CACHE_MTIME
_LEARNED_CACHE_MTIME = 0.0
except Exception as e:
log.warning("learned rules persist failed: %s", e)
return learned["pinning_history"][host]
def _refresh_static_cache() -> dict:
"""Reload static whitelist YAML if mtime changed."""
global _CACHE, _CACHE_MTIME
mtime = 0.0
for p in (BASELINE_PATH, OVERRIDE_PATH, RULES_PATH):
if p.exists():
mtime = max(mtime, p.stat().st_mtime)
if _CACHE is not None and mtime <= _CACHE_MTIME:
return _CACHE
entries = _load_yaml(BASELINE_PATH) + _load_yaml(OVERRIDE_PATH)
# Also load custom rules from engine config
cfg = _load_engine_config()
for e in cfg.get("static_rules") or []:
entries.append(e)
compiled: dict[str, dict] = {}
for e in entries:
p = e.get("pattern", "").lower().strip()
if p:
compiled[p] = e
_CACHE = {
"entries": compiled,
"count": len(compiled),
"sensitivity": (cfg.get("sensitivity") or "medium").lower(),
}
_CACHE_MTIME = mtime
log.info("rule-engine reloaded : %d static rules, sensitivity=%s",
len(compiled), _CACHE["sensitivity"])
return _CACHE
# ────────── Public API ──────────
def evaluate(host: str, context: Optional[dict] = None) -> dict:
"""Decide what to do with a host. Order :
1. Static whitelist match -> trust
2. Generative rule match -> trust / trust-warn
3. Default -> inspect
Returns {action, reason, source, category}.
"""
if not host:
return {"action": "inspect", "reason": "no host", "source": "default", "category": None}
h = host.lower().strip()
ctx = context or {}
# 1. Static rules
cache = _refresh_static_cache()
for pattern, entry in cache["entries"].items():
if fnmatch.fnmatch(h, pattern):
return {
"action": "trust",
"reason": entry.get("reason", "whitelisted"),
"source": "static",
"category": entry.get("category"),
"quality_expected": entry.get("quality_expected", "?"),
}
# 2. Generative rules — inject pinning history from learned store
learned = _load_learned()
enriched_ctx = {**ctx, "pinning_history": learned.get("pinning_history", {})}
for rule in GENERATIVE_RULES:
try:
if rule["predicate"](h, enriched_ctx):
return {
"action": rule["action"],
"reason": rule["reason"],
"source": "generative",
"category": rule["category"],
"rule_name": rule["name"],
}
except Exception as e:
log.debug("generative rule %s eval failed: %s", rule["name"], e)
# 3. Default : inspect
return {"action": "inspect", "reason": "no rule matched", "source": "default", "category": None}
def get_sensitivity() -> dict:
"""Returns the active sensitivity profile dict (label/description/thresholds)."""
cache = _refresh_static_cache()
profile_name = cache.get("sensitivity", "medium")
return {
"name": profile_name,
**SENSITIVITY_PROFILES.get(profile_name, SENSITIVITY_PROFILES["medium"]),
}
def should_block(*, threat_intel_matches: int = 0, dga_score: int = 0,
beaconing_score: int = 0, asn_reputation: str | None = None,
is_whitelisted: bool = False) -> tuple[bool, str]:
"""Given signal values + active sensitivity, decide whether to block.
Returns (block_decision, reason).
"""
profile = get_sensitivity()
if is_whitelisted:
return False, "whitelisted (rule_engine)"
if profile.get("threat_intel_action") == "block" and threat_intel_matches > 0:
return True, f"threat-intel match (sensitivity={profile['name']})"
if dga_score >= profile.get("dga_score_min", 999):
return True, f"DGA score {dga_score} ≥ threshold {profile['dga_score_min']}"
if beaconing_score >= profile.get("beaconing_score_min", 999):
return True, f"beaconing score {beaconing_score} ≥ threshold {profile['beaconing_score_min']}"
if profile.get("block_low_reputation_asn") and asn_reputation in ("D", "F"):
return True, f"low-reputation ASN ({asn_reputation})"
if profile.get("block_unknown"):
return True, "paranoid mode : default-deny"
return False, "no rule matched"
def stats() -> dict:
"""Returns engine stats : static rule count + sensitivity + learned count."""
cache = _refresh_static_cache()
learned = _load_learned()
return {
"static_rules": cache["count"],
"sensitivity": cache.get("sensitivity", "medium"),
"sensitivity_profile": get_sensitivity(),
"generative_rules": len(GENERATIVE_RULES),
"learned_hosts": len(learned.get("hosts", {})),
"learned_pinning_count": sum(learned.get("pinning_history", {}).values()),
"baseline_loaded": BASELINE_PATH.exists(),
"override_loaded": OVERRIDE_PATH.exists(),
"engine_config_loaded": RULES_PATH.exists(),
}
# Backward-compatibility shim for callers still using whitelist.match/is_whitelisted
def match(host: str) -> dict | None:
"""Returns the matching rule dict for compat with whitelist.py. None if no
trust decision."""
result = evaluate(host)
if result["action"] in ("trust", "trust-warn"):
return {
"pattern": host,
"reason": result["reason"],
"category": result.get("category"),
"quality_expected": result.get("quality_expected", "?"),
"source": result["source"],
}
return None
def is_whitelisted(host: str) -> bool:
return match(host) is not None

View File

@ -40,7 +40,19 @@ p{color:#a8b6a8;margin:0.5rem 0;text-align:center;font-size:0.9rem}
{% endif %}
</div>
<a href="https://duckduckgo.com" class="go-surf">🚀 COMMENCE À SURFER</a>
{# Phase 3 (#492) : iOS captive-sheet aware. Linking directly to
https://duckduckgo.com gets intercepted by iOS captive UI which doesn't
navigate. Linking to the Apple captive probe lets iOS verify connectivity
and CLOSE the captive sheet automatically — user lands in Safari. #}
<a href="http://captive.apple.com/hotspot-detect.html"
target="_blank" rel="noopener"
class="go-surf"
onclick="setTimeout(function(){window.location.href='https://duckduckgo.com';}, 500);">
🚀 COMMENCE À SURFER
</a>
<p style="font-size:0.7rem;color:#444;text-align:center;margin-top:-0.5rem">
↑ ferme la page captive et ouvre Safari · ou utilise ton navigateur directement
</p>
<p class="subtle">// Hash session : <span class="mac">{{ mac_hash }}</span></p>

View File

@ -218,6 +218,262 @@ whitelist:
category: vendor-os
quality_expected: A
# ───────────── Banking FR additional (neobanks + extras) ─────────────
- pattern: "*.boursorama-banque.com"
reason: "Banking neobank FR"
category: financial
quality_expected: A
- pattern: "*.hellobank.fr"
reason: "Banking FR"
category: financial
quality_expected: A
- pattern: "*.bforbank.com"
reason: "Banking neobank FR"
category: financial
quality_expected: A
- pattern: "*.n26.com"
reason: "Banking neobank EU"
category: financial
quality_expected: A+
- pattern: "*.wise.com"
reason: "Money transfer service (Wise/Transferwise)"
category: financial
quality_expected: A+
- pattern: "*.monzo.com"
reason: "Banking neobank"
category: financial
quality_expected: A+
- pattern: "*.ing.fr"
reason: "Banking FR (ING)"
category: financial
quality_expected: A
- pattern: "*.traderepublic.com"
reason: "Brokerage EU"
category: financial
quality_expected: A+
- pattern: "*.binance.com"
reason: "Crypto exchange (cert-pinned)"
category: financial
quality_expected: A
- pattern: "*.kraken.com"
reason: "Crypto exchange (cert-pinned)"
category: financial
quality_expected: A+
- pattern: "*.coinbase.com"
reason: "Crypto exchange (cert-pinned)"
category: financial
quality_expected: A+
# ───────────── Streaming media (cert-pinned, MITM would break playback) ─────────────
- pattern: "*.netflix.com"
reason: "Netflix streaming, cert-pinned + DRM"
category: streaming-media
quality_expected: A
- pattern: "*.nflximg.net"
reason: "Netflix CDN"
category: streaming-media
quality_expected: A
- pattern: "*.nflxvideo.net"
reason: "Netflix video CDN"
category: streaming-media
quality_expected: A
- pattern: "*.disneyplus.com"
reason: "Disney+ streaming"
category: streaming-media
quality_expected: A
- pattern: "*.spotify.com"
reason: "Spotify cert-pinned"
category: streaming-media
quality_expected: A
- pattern: "*.scdn.co"
reason: "Spotify CDN"
category: streaming-media
quality_expected: A
- pattern: "*.deezer.com"
reason: "Deezer streaming FR"
category: streaming-media
quality_expected: A
- pattern: "*.canalplus.com"
reason: "Canal+ streaming FR"
category: streaming-media
quality_expected: A
- pattern: "*.mycanal.fr"
reason: "MyCanal streaming FR"
category: streaming-media
quality_expected: A
- pattern: "*.france.tv"
reason: "France Télévisions (public broadcasting)"
category: streaming-media
quality_expected: A
- pattern: "*.arte.tv"
reason: "Arte (public broadcasting FR/DE)"
category: streaming-media
quality_expected: A+
- pattern: "*.youtube.com"
reason: "YouTube cert-pinned, DRM video"
category: streaming-media
quality_expected: A
- pattern: "*.googlevideo.com"
reason: "YouTube video CDN"
category: streaming-media
quality_expected: A
- pattern: "*.ytimg.com"
reason: "YouTube static CDN"
category: streaming-media
quality_expected: A
- pattern: "*.twitch.tv"
reason: "Twitch live streaming, cert-pinned"
category: streaming-media
quality_expected: A
- pattern: "*.amazonvideo.com"
reason: "Amazon Prime Video"
category: streaming-media
quality_expected: A
# ───────────── Productivity / Education FR ─────────────
- pattern: "*.pronote.net"
reason: "Pronote ENT scolaire FR"
category: education
quality_expected: A
- pattern: "*.index-education.net"
reason: "Pronote operator"
category: education
quality_expected: A
- pattern: "*.ecoledirecte.com"
reason: "Ecole Directe ENT scolaire FR"
category: education
quality_expected: A
- pattern: "*.ent.unice.fr"
reason: "ENT universitaire FR"
category: education
quality_expected: A
- pattern: "*.cned.fr"
reason: "CNED enseignement à distance FR"
category: education
quality_expected: A
- pattern: "*.maiia.com"
reason: "Telemedicine FR (Cegedim)"
category: health
quality_expected: A
- pattern: "*.livi.fr"
reason: "Telemedicine FR (Livi)"
category: health
quality_expected: A
# ───────────── Workplace SaaS (cert-pinned) ─────────────
- pattern: "*.office.com"
reason: "Microsoft Office365"
category: workplace
quality_expected: A
- pattern: "*.live.com"
reason: "Microsoft accounts"
category: workplace
quality_expected: A
- pattern: "*.office365.com"
reason: "Microsoft Office365"
category: workplace
quality_expected: A
- pattern: "*.sharepoint.com"
reason: "Microsoft SharePoint"
category: workplace
quality_expected: A
- pattern: "*.slack.com"
reason: "Slack messaging"
category: workplace
quality_expected: A
- pattern: "*.zoom.us"
reason: "Zoom videoconference"
category: workplace
quality_expected: A
- pattern: "*.teams.microsoft.com"
reason: "Microsoft Teams"
category: workplace
quality_expected: A
- pattern: "*.salesforce.com"
reason: "Salesforce CRM"
category: workplace
quality_expected: A
- pattern: "*.atlassian.com"
reason: "Atlassian (Jira, Confluence)"
category: workplace
quality_expected: A
- pattern: "*.notion.so"
reason: "Notion workspace"
category: workplace
quality_expected: A
- pattern: "*.dropbox.com"
reason: "Dropbox cloud sync"
category: workplace
quality_expected: A
- pattern: "*.box.com"
reason: "Box.com cloud"
category: workplace
quality_expected: A
- pattern: "*.adobe.com"
reason: "Adobe Creative Cloud"
category: workplace
quality_expected: A
# ───────────── Gaming (cert-pinned, DRM, online auth) ─────────────
- pattern: "*.steamcommunity.com"
reason: "Steam community + downloads"
category: gaming
quality_expected: A
- pattern: "*.steampowered.com"
reason: "Steam infrastructure"
category: gaming
quality_expected: A
- pattern: "*.epicgames.com"
reason: "Epic Games"
category: gaming
quality_expected: A
- pattern: "*.ea.com"
reason: "Electronic Arts"
category: gaming
quality_expected: A
- pattern: "*.battle.net"
reason: "Blizzard Battle.net"
category: gaming
quality_expected: A
- pattern: "*.playstation.com"
reason: "PlayStation Network"
category: gaming
quality_expected: A
- pattern: "*.xbox.com"
reason: "Xbox Live"
category: gaming
quality_expected: A
- pattern: "*.xboxlive.com"
reason: "Xbox Live infrastructure"
category: gaming
quality_expected: A
- pattern: "*.nintendo.net"
reason: "Nintendo online services"
category: gaming
quality_expected: A
- pattern: "*.discord.com"
reason: "Discord (gaming chat, cert-pinned)"
category: gaming
quality_expected: A
- pattern: "*.discord.gg"
reason: "Discord invite domain"
category: gaming
quality_expected: A
# ───────────── DNS-over-HTTPS resolvers ─────────────
- pattern: "*.cloudflare-dns.com"
reason: "Cloudflare DoH/DoT resolver"
category: privacy-tools
quality_expected: A+
- pattern: "*.dns.google"
reason: "Google DoH/DoT resolver"
category: privacy-tools
quality_expected: A+
- pattern: "*.nextdns.io"
reason: "NextDNS DoH/DoT resolver"
category: privacy-tools
quality_expected: A+
# ───────────── Captive portal probes (must reach the portal itself, not internet) ─────────────
# NOTE : these probes are HANDLED locally by the captive splash, never reach
# this whitelist. Listed here for documentation.

View File

@ -28,6 +28,7 @@ Depends: ${misc:Depends}, ${python3:Depends},
adduser,
fonts-dejavu-core,
fonts-symbola,
fonts-noto-color-emoji,
python3-yaml,
python3-geoip2 | geoipupdate
Description: SecuBox-DEB ToolBoX — Gondwana Cabine Numérique (captive AP + MITM analyzer)