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
* 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.
try:
out = _sp.run(
["journalctl",
"-u", "secubox-toolbox-mitm",
"-u", "secubox-toolbox-mitm-wg",
# #593 — glob matches the live R3 workers (…-mitm-wg-worker@N).
["journalctl", "-u", "secubox-toolbox-mitm*",
"--since", "-30min", "--no-pager"],
capture_output=True, text=True, timeout=5, check=False,
).stdout
except Exception:
out = ""
connections = out.count("client connect")
connections = out.count("server connect")
successful = out.count("<< 2") + out.count("<< 30")
tls_pinned = out.count("Client TLS handshake failed")
hosts: set[str] = set()
@ -3028,10 +3027,12 @@ async def admin_metrics() -> dict:
# Mitmproxy live stats (from journal)
try:
out = _sp.run(
["journalctl", "-u", "secubox-toolbox-mitm", "--since", "-30min", "--no-pager"],
capture_output=True, text=True, timeout=3, check=False,
# #593 — glob matches the LIVE R3 workers (…-mitm-wg-worker@N),
# 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
metrics["mitm"]["connections"] = out.count("client connect")
metrics["mitm"]["connections"] = out.count("server connect")
metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed")
hosts: set[str] = set()
for line in out.splitlines():

View File

@ -33,6 +33,7 @@ import concurrent.futures as _futures
import hashlib
import json
import logging
import re
import sqlite3
import time
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:
"""Cheap eTLD+1 : www.lemonde.fr → lemonde.fr ; a.b.example.co.uk →
example.co.uk. Raw IPs and single-label hosts pass through."""
@ -1040,16 +1050,27 @@ def aggregate(hours: int = 24) -> Dict:
"WHERE ts >= ?",
(since,),
).fetchone()[0]
out["by_tracker_domain"] = [
dict(r)
# #593 — fold to registrable domain + drop IP literals (the raw
# 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(
"SELECT tracker_domain, COUNT(*) AS hits, "
"COUNT(DISTINCT client_mac_hash) AS clients "
"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,),
).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"] = [
dict(r)
for r in c.execute(