Compare commits

..

3 Commits

Author SHA1 Message Date
CyberMind
2c6e6f2b51
Merge pull request #594 from CyberMind-FR/fix/593-webui-fix-metrics-mitm-0-wrong-unit-thre
Some checks are pending
License Headers / check (push) Waiting to run
fix(webui): metrics mitm=0 + threats shows IPs (#593)
2026-06-15 10:23:27 +02:00
ca1a2ede1d fix(toolbox): WebUI metrics mitm=0 (wrong unit) + threats IPs (closes #593)
- admin metrics journalctl glob secubox-toolbox-mitm* (matches live R3
  workers, not the dead R2 unit); connections from 'server connect'.
- social-aggregate by_tracker_domain folds to registrable domain + drops
  IP literals (cabine's own WAN IP was the top 'tracker'). secubox-toolbox 2.6.37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:22:58 +02:00
e1f22b6dda docs(cspn): draft CSPN test matrix (criteria → runnable tests)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:14:40 +02:00
4 changed files with 199 additions and 17 deletions

View File

@ -0,0 +1,147 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# SecuBox-Deb — CSPN Test Matrix (draft)
Maps the ANSSI **CSPN** evaluation themes + the project's stated security
functions (CLAUDE.md §"Contraintes ANSSI CSPN") to **concrete, mostly
automatable tests**. Target home for the automated rows: `tests/cspn/`
(pytest, gated in CI). Each row is an *acceptance check* with a command/
assertion and the evidence artifact an evaluator would expect.
**Legend** — Type: `A`=automated (pytest/CI), `M`=manual/pentest, `D`=doc/spec.
Status: ⬜ todo · 🔄 partial · ✅ covered.
> Scope note: the **cible de sécurité** (security target) must be written
> first (TOE boundary, assumptions, threats, security functions). This
> matrix is the *robustness + conformity* test plan that hangs off it.
---
## 0. Security target & conformity (D)
| ID | Requirement | Type | Method / artifact | Pass | St |
|----|-------------|------|-------------------|------|----|
| ST-01 | Cible de sécurité rédigée (TOE, hypothèses, menaces, FS) | D | `docs/cspn/cible-securite.md` reviewed | doc complete + signed | ⬜ |
| ST-02 | TOE boundary & versions pinned | D | version manifest (pkg list + hashes) per release | matches APT repo | ⬜ |
| ST-03 | Conformity: spec ↔ impl traceability | D | each FS → code path + test ID | 100% FS mapped | ⬜ |
## 1. Cryptography — TLS / keys / RNG
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| CRY-01 | TLS 1.3 min; TLS ≤1.1 refused (HAProxy frontends) | A | `openssl s_client -tls1_1 -connect <vhost>:443` → handshake fail; `-tls1_3` → ok | 1.0/1.1/1.2-weak refused | ⬜ |
| CRY-02 | Strong cipher suites only (no RC4/3DES/CBC-legacy) | A | `nmap --script ssl-enum-ciphers` / testssl.sh grade ≥ A | A grade, no weak | ⬜ |
| CRY-03 | HSTS + secure headers on exposed vhosts | A | `curl -sI``Strict-Transport-Security`, `X-Content-Type-Options` | present | ⬜ |
| CRY-04 | Private keys 0600, owner-restricted, not world-readable | A | `stat -c %a` on `/etc/secubox/**/key.pem`, ACME keys | 600, non-root svc owner | 🔄 |
| CRY-05 | CA / mitm keys never in VCS or logs | A | `git grep -nE 'BEGIN (RSA |EC )?PRIVATE KEY'` == empty; journald scrub | no hits | ⬜ |
| CRY-06 | RNG source = kernel CSPRNG for tokens/keys | A | code audit: `secrets`/`os.urandom`, no `random` for security | no `random.` in sec paths | 🔄 |
| CRY-07 | mitm R3 CA fingerprint published & verifiable | A | `/ca/fingerprint?ca=wg` == cert on disk (sha256) | match (D5:E4:3A…) | ✅ |
## 2. Authentication & session
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| AUT-01 | All API endpoints require JWT (`Depends(require_jwt)`) | A | enumerate FastAPI routes; assert auth dep except allowlist | 100% gated | 🔄 |
| AUT-02 | Unauthenticated request → 401, no data leak | A | `curl` each `/api/v1/*` sans token | 401, empty body | ⬜ |
| AUT-03 | JWT signature verified; tampered/expired rejected | A | forge/expire token → 401 | rejected | ⬜ |
| AUT-04 | Social/report tokens = HMAC, TTL-bound, salt-rotated | A | expired/forged `/social/{token}` → 403; salt rotates daily | rejected + rotation | 🔄 |
| AUT-05 | No default/hardcoded credentials | A | grep configs + first-boot generates per-device secrets | none | 🔄 |
| AUT-06 | Brute-force handled at the WAF layer (per project doctrine) | M | rate-limit probe via HAProxy/CrowdSec | throttled/banned | 🔄 |
| AUT-07 | ZKP auth (GK-HAM-2025) NIZK soundness, G rotation 24h PFS | M+A | protocol test vectors + rotation timer check | proofs verify, rotates | ⬜ |
## 3. Access control / privilege separation
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| ACL-01 | Each daemon runs as `secubox-<module>` (not root) | A | `systemctl show -p User` over all `secubox-*` units | non-root each | 🔄 |
| ACL-02 | AppArmor profile present + **enforce** per service | A | `aa-status` lists each profile in enforce | all enforce | ⬜ |
| ACL-03 | systemd hardening (ProtectSystem, NoNewPrivileges, etc.) | A | `systemd-analyze security secubox-*` score | exposure ≤ medium | ⬜ |
| ACL-04 | Filesystem perms: `/etc/secubox/secrets` 0600, parents traversable but not writable | A | `stat` perms + traversal test as svc user | 0600 secrets, 0755 parents | 🔄 |
| ACL-05 | No unintended setuid/world-writable shipped | A | `find / -perm -4000 / -perm -0002` in image | known allowlist only | ⬜ |
## 4. Network filtering / attack surface
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| NET-01 | nftables policy DEFAULT DROP (input/forward) | A | `nft list chain inet filter input``policy drop` | drop | ✅ (verify) |
| NET-02 | Only declared ports open; no stray listeners | A | `ss -tlnp` ∩ documented port map | exact match | 🔄 |
| NET-03 | WAN-side SSH closed (key-only + source-restricted) | A | sshd `PasswordAuthentication no`; nft SSH-guard drops non-LAN/tunnel | enforced | ✅ |
| NET-04 | No IPv6 leak past the v4 firewall guards | A | nft inet covers v6; `ss` v6 listeners reviewed | covered | ⬜ |
| NET-05 | nft rules persist across reboot + survive pkg upgrade | A | reboot/upgrade → drop-ins reload; ruleset intact | persists | 🔄 |
| NET-06 | DNS = Unbound only; DoH/DoT exfil detected/blocked (opt-in) | A | resolve via Unbound; DoH probe flagged | controlled | 🔄 |
## 5. WAF / traffic inspection integrity (no bypass)
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| WAF-01 | No `waf_bypass` anywhere; all vhosts → mitm inspector | A | grep HAProxy cfg; each backend = mitmproxy_inspector (or documented exception) | no bypass | 🔄 |
| WAF-02 | mitm CA only trusted on consenting (R2/R3) clients | A | non-consenting client not MITM'd | scoped | ✅ |
| WAF-03 | Banner/transparency shown to inspected clients (CSPN R2 req) | A | inspected HTML carries the banner guard | present | ✅ |
| WAF-04 | Active interference (spoof/ghost) is opt-in + logged + reversible | A | filters default-safe; every action → audit.log; toggle off restores | conforms | ✅ |
| WAF-05 | mitm fail-open never serves attacker-controlled content | M | malformed upstream / addon exception → flow unbroken, no inject error | safe | 🔄 |
## 6. Logging & audit (immutability)
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| LOG-01 | Security decisions (ban/unban/spoof/escalate/rule-change) logged to `/var/log/secubox/audit.log` | A | trigger each → grep audit line | logged | 🔄 |
| LOG-02 | Timestamps RFC 3339 / ISO-8601 with TZ | A | regex each audit line | conforms | 🔄 |
| LOG-03 | Append-only / rotation without truncate (immutability) | A | `chattr +a` or rotate-copy-truncate disabled; tamper test | no in-place edit | ⬜ |
| LOG-04 | Logs free of secrets/PII (mac→hash, no tokens) | A | grep audit/journal for token/cookie/key patterns | none | 🔄 |
| LOG-05 | Audit survives service crash + reboot | A | crash mid-write → log consistent | intact | ⬜ |
## 7. Configuration management & rollback (4R / double-buffer)
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| CFG-01 | Sensitive config change = shadow→validate→atomic swap | A | `secubox-params swap` flow; partial write never live | atomic | ⬜ |
| CFG-02 | 4R rollback restores prior state (R1..R4 snapshots) | A | mutate → `rollback --target R1` → state == pre | restored | ⬜ |
| CFG-03 | Validation rejects bad config before swap (4R: Read→Write→Validate→Rollback/Commit) | A | inject invalid → swap refused, live unchanged | refused | ⬜ |
| CFG-04 | Config swap audit-logged + (ZKP-gated where required) | A | swap → audit line | logged | ⬜ |
## 8. Update mechanism
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| UPD-01 | APT repo GPG-signed; unsigned/altered pkg refused | A | tamper a .deb → `apt` refuses | refused | 🔄 |
| UPD-02 | Upgrade preserves runtime state + restarts services (no outage) | A | upgrade → portal up, kbin 200, nft intact (regression of #581) | no downtime | ✅ |
| UPD-03 | Downgrade / rollback path defined | D+A | pinned prior version installs cleanly | works | ⬜ |
| UPD-04 | Reproducible build / provenance | A | CI build hashes recorded per release | recorded | 🔄 |
## 9. Data protection at rest
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| DAT-01 | Secrets only under `/etc/secubox/secrets` 0600, svc-owned | A | inventory + `stat` | conforms | 🔄 |
| DAT-02 | No secrets in code / TOML / git history | A | `git log -p` + `git grep` secret patterns | none | 🔄 |
| DAT-03 | SQLite stores hashed identifiers (mac_hash, cookie_id_hash), not raw PII | A | schema + sample-row audit | hashed | 🔄 |
| DAT-04 | Data retention enforced (social 7d, logs rotation) | A | retention timers prune | enforced | 🔄 |
## 10. Resilience / fail-safe
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| RES-01 | Service crash → auto-recovery (watchdog), portal probe | A | kill portal → restored + kbin 200 | recovers | ⬜ |
| RES-02 | RAM-pressure: no OOM cascade under load (Armada budget) | M+A | load test; per-service MemoryMax; no thundering-herd | stable | 🔄 |
| RES-03 | Fail-secure: filter/addon error must not open the WAF or break pages | A | inject addon exception → default-drop / fail-open page-safe | secure | 🔄 |
## 11. Hardening / vulnerability management
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| HRD-01 | No known-vuln Python deps | A | `pip-audit` / safety in CI | 0 high/critical | ⬜ |
| HRD-02 | No known-vuln OS packages in the image | A | `debsecan`/trivy on the image | 0 high/critical | ⬜ |
| HRD-03 | Attack-surface minimal: unused services disabled | A | enabled-units ∩ required set | minimal | 🔄 |
| HRD-04 | SAST clean on the codebase | A | `bandit` (py) in CI | no high | ⬜ |
| HRD-05 | Pentest of the exposed surface (kbin, HAProxy, R3) | M | grey-box assessment report | no critical | ⬜ |
## 12. Conformity glue (CI)
| ID | Requirement | Type | Method / assertion | Pass | St |
|----|-------------|------|-------------------|------|----|
| CI-01 | `tests/cspn/` runs in CI, gates merge | A | workflow job red on fail | gating | ⬜ |
| CI-02 | Coverage ≥80% on security-critical modules | A | coverage report | ≥80% | ⬜ |
| CI-03 | `compliance-lint` (AppArmor/user/secrets/no-bypass/SPDX) per PR | A | linter job | clean | 🔄 (SPDX only) |
---
## How to operationalise
1. Write the **cible de sécurité** (ST-01) — everything else traces to it.
2. Scaffold `tests/cspn/` (pytest), one module per theme above
(`test_crypto.py`, `test_authz.py`, `test_firewall.py`, `test_audit.py`,
`test_rollback.py`, …). Each `XXX-NN` ID = one test function id.
3. Add a CI job (CI-01) running it against a built image / a staging board.
4. Add `compliance-lint` (CI-03) for the static rows (perms, AppArmor,
no-bypass, SPDX, no-secrets).
5. Burn down ⬜→✅; the ✅ rows above are already verifiable today.
Priority order (highest CSPN risk first): **§6 audit immutability**, **§7
4R rollback**, **§3 AppArmor enforce + privilege**, **§1 TLS**, **§12 CI
gate/coverage** — these are the criteria most likely to fail an assessment
today given the current ~9% test coverage.

View File

@ -1,3 +1,16 @@
secubox-toolbox (2.6.37-1~bookworm1) bookworm; urgency=medium
* fix(webui): metrics mitm=0 + threats list full of IPs (#593).
- /admin/metrics + the report metrics read journalctl `-u
secubox-toolbox-mitm` (the dead R2 unit) → always 0. Now a glob
`secubox-toolbox-mitm*` matches the live R3 workers
(…-mitm-wg-worker@N) ; connections counted from `server connect`.
- /admin/social-aggregate `by_tracker_domain` listed raw IPs (incl.
the cabine's own WAN IP as the top "tracker"). Now folds to the
registrable domain (eTLD+1) and drops IP literals (`_is_ip`).
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 17:30:00 +0200
secubox-toolbox (2.6.36-1~bookworm1) bookworm; urgency=medium secubox-toolbox (2.6.36-1~bookworm1) bookworm; urgency=medium
* fix(autolearn): exclude anti-bot vendors from the auto-block list (#589 * fix(autolearn): exclude anti-bot vendors from the auto-block list (#589

View File

@ -1543,15 +1543,14 @@ def _aggregate_session(mac_hash: str) -> dict:
# Without -u for both, R3 clients always saw 0 connections in metrics. # Without -u for both, R3 clients always saw 0 connections in metrics.
try: try:
out = _sp.run( out = _sp.run(
["journalctl", # #593 — glob matches the live R3 workers (…-mitm-wg-worker@N).
"-u", "secubox-toolbox-mitm", ["journalctl", "-u", "secubox-toolbox-mitm*",
"-u", "secubox-toolbox-mitm-wg",
"--since", "-30min", "--no-pager"], "--since", "-30min", "--no-pager"],
capture_output=True, text=True, timeout=5, check=False, capture_output=True, text=True, timeout=5, check=False,
).stdout ).stdout
except Exception: except Exception:
out = "" out = ""
connections = out.count("client connect") connections = out.count("server connect")
successful = out.count("<< 2") + out.count("<< 30") successful = out.count("<< 2") + out.count("<< 30")
tls_pinned = out.count("Client TLS handshake failed") tls_pinned = out.count("Client TLS handshake failed")
hosts: set[str] = set() hosts: set[str] = set()
@ -3028,10 +3027,12 @@ async def admin_metrics() -> dict:
# Mitmproxy live stats (from journal) # Mitmproxy live stats (from journal)
try: try:
out = _sp.run( out = _sp.run(
["journalctl", "-u", "secubox-toolbox-mitm", "--since", "-30min", "--no-pager"], # #593 — glob matches the LIVE R3 workers (…-mitm-wg-worker@N),
capture_output=True, text=True, timeout=3, check=False, # not just the (dead) R2 …-mitm unit → real numbers.
["journalctl", "-u", "secubox-toolbox-mitm*", "--since", "-30min", "--no-pager"],
capture_output=True, text=True, timeout=4, check=False,
).stdout ).stdout
metrics["mitm"]["connections"] = out.count("client connect") metrics["mitm"]["connections"] = out.count("server connect")
metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed") metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed")
hosts: set[str] = set() hosts: set[str] = set()
for line in out.splitlines(): for line in out.splitlines():

View File

@ -33,6 +33,7 @@ import concurrent.futures as _futures
import hashlib import hashlib
import json import json
import logging import logging
import re
import sqlite3 import sqlite3
import time import time
from pathlib import Path from pathlib import Path
@ -693,6 +694,15 @@ _MULTI_LABEL_TLDS = {
} }
_IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
def _is_ip(host: str) -> bool:
"""True for an IPv4/IPv6 literal (to keep IPs out of domain views)."""
h = host or ""
return bool(_IP_RE.match(h)) or ":" in h
def _registrable_domain(host: str) -> str: def _registrable_domain(host: str) -> str:
"""Cheap eTLD+1 : www.lemonde.fr → lemonde.fr ; a.b.example.co.uk → """Cheap eTLD+1 : www.lemonde.fr → lemonde.fr ; a.b.example.co.uk →
example.co.uk. Raw IPs and single-label hosts pass through.""" example.co.uk. Raw IPs and single-label hosts pass through."""
@ -1040,16 +1050,27 @@ def aggregate(hours: int = 24) -> Dict:
"WHERE ts >= ?", "WHERE ts >= ?",
(since,), (since,),
).fetchone()[0] ).fetchone()[0]
out["by_tracker_domain"] = [ # #593 — fold to registrable domain + drop IP literals (the raw
dict(r) # column otherwise surfaces IPs, incl. the cabine's own WAN IP,
# as the top "tracker"). Over-fetch, fold in Python, top 50.
_byd: dict = {}
for r in c.execute( for r in c.execute(
"SELECT tracker_domain, COUNT(*) AS hits, " "SELECT tracker_domain, COUNT(*) AS hits, "
"COUNT(DISTINCT client_mac_hash) AS clients " "COUNT(DISTINCT client_mac_hash) AS clients "
"FROM social_edges WHERE ts >= ? " "FROM social_edges WHERE ts >= ? "
"GROUP BY tracker_domain ORDER BY hits DESC LIMIT 50", "GROUP BY tracker_domain ORDER BY hits DESC LIMIT 400",
(since,), (since,),
).fetchall() ).fetchall():
] td = r["tracker_domain"] or ""
if not td or _is_ip(td):
continue
dom = _registrable_domain(td)
e = _byd.setdefault(dom, {"tracker_domain": dom, "hits": 0,
"clients": 0})
e["hits"] += r["hits"]
e["clients"] = max(e["clients"], r["clients"])
out["by_tracker_domain"] = sorted(
_byd.values(), key=lambda x: -x["hits"])[:50]
out["by_client"] = [ out["by_client"] = [
dict(r) dict(r)
for r in c.execute( for r in c.execute(