Compare commits

..

19 Commits

Author SHA1 Message Date
c64a666fa5 fix(build): build-packages.sh passes -d; docs: apt repo published + signed
Some checks are pending
License Headers / check (push) Waiting to run
build-packages.sh omitted -d, so dpkg-checkbuilddeps silently dropped
arch:all packages (incl. secubox-core) from the repo. All 144 packages now
build + are published to apt.secubox.in, signed with apt@secubox.in.
2026-06-15 15:14:55 +02:00
5325cddade docs: HISTORY for vm /vm/ container-listing fix (#601, PR #602) 2026-06-15 14:51:30 +02:00
CyberMind
1d7ca0cd22
Merge pull request #602 from CyberMind-FR/feature/601-vm-vm-shows-0-containers-lxc-enumeration
fix(vm): /vm/ shows 0 containers — sudo LXC enumeration + RAM column key
2026-06-15 14:50:46 +02:00
a610ffd276 fix(vm): lxc-ls column key RAM not MEMORY (#601)
lxc-ls -F MEMORY is rejected ('Invalid key') and emits no container rows,
so even with the sudo fix the list was empty. Use RAM (the valid key).
2026-06-15 14:49:19 +02:00
5800ad713d fix(vm): LXC enumeration via sudo so /vm/ shows real containers (closes #601)
The aggregator mounts the vm module in-process as the unprivileged secubox
user, so bare lxc-ls couldn't see root's /var/lib/lxc → 0 containers. Route
LXC read+lifecycle through sudo -n with a new read-only-ish grant
(/etc/sudoers.d/secubox-vm: lxc-ls/info/start/stop, visudo-validated);
lxc-create/destroy stay root-only. postinst reloads secubox-aggregator so
the in-process module refreshes. KVM/libvirt readings were already correct.
2026-06-15 14:46:00 +02:00
4fd0d864ee docs: HISTORY for threat-analyst CrowdSec ingestion (#599, PR #600) 2026-06-15 13:06:15 +02:00
CyberMind
f99f071642
Merge pull request #600 from CyberMind-FR/feature/599-threat-analyst-ingest-real-crowdsec-aler
feat(threat-analyst): ingest real CrowdSec alerts (read-only sudo) + auto-collect + dedup
2026-06-15 11:17:25 +02:00
77b6f2624d fix(threat-analyst): NoNewPrivileges=no so read-only sudo cscli ingestion works (#599)
NoNewPrivileges=yes blocked sudo escalation ('no new privileges flag is
set'). Match the sibling secubox-crowdsec/secubox-waf units which set
NoNewPrivileges=no for the same read-only cscli access.
2026-06-15 11:15:57 +02:00
d9fea2f9b4 feat(threat-analyst): ingest real CrowdSec alerts via read-only sudo + auto-collect + dedup (closes #599)
collect_crowdsec_alerts() shelled out to bare cscli, which fails for the
unprivileged secubox user → alerts DB empty → headline stats and Top-N
leaderboards all 0. Now goes through a backend-only read-only sudo grant
(/etc/sudoers.d/secubox-threat-analyst: cscli alerts/decisions list,
visudo-validated in postinst) fetching -l 200. Adds a 5-min backend
auto-collect loop, correct severity mapping, dedup-by-id in
get_recent_alerts, and bounded 48h compaction of alerts.jsonl (the
append-on-every-poll log was inflating counts). Frontend stays value-only.
2026-06-15 11:14:53 +02:00
65a1a8e494 docs: HISTORY for threat-analyst global overview (#597, PR #598) 2026-06-15 11:07:19 +02:00
CyberMind
8fc5dba929
Merge pull request #598 from CyberMind-FR/feature/597-threat-analyst-global-security-overview
feat(threat-analyst): global security overview from WAF + CrowdSec + firewall
2026-06-15 11:06:29 +02:00
919a88c6be fix(threat-analyst): source CrowdSec+firewall from Prometheus :6060, not root cscli/nft (#597)
The daemon runs as the unprivileged 'secubox' user, so cscli (reads
/etc/crowdsec/local_api_credentials.yaml) and 'nft list' (needs root)
both failed silently → CrowdSec showed running=false and firewall=0.
Parse CrowdSec's privilege-free Prometheus endpoint instead: cs_alerts
(detection) + cs_active_decisions (enforcement, materialized in nft by
crowdsec-firewall-bouncer). No privilege escalation, no broken-dep
(secubox-blacklist-sync #521) coupling. WebUI relabelled detection vs
enforcement.
2026-06-15 11:02:06 +02:00
60eeb79185 feat(threat-analyst): global security overview from WAF + CrowdSec + firewall (closes #597)
Add cached /overview endpoint aggregating live WAF (/run/secubox/waf.sock
/stats), CrowdSec (cscli -o json decisions+alerts) and firewall (nft -j
list set blacklist_v4/v6 element counts). Double-buffer background refresh
(60s → overview.json). WebUI gains a 'Vue globale sécurité' card row wired
via loadOverview() in loadAll(); source health line shows WAF/CrowdSec
status + last refresh age.
2026-06-15 10:56:55 +02:00
CyberMind
91cba0bbda
Merge pull request #596 from CyberMind-FR/fix/595-threat-analyst-service-ends-up-disabled
fix(threat-analyst): build-safe service enable (#595)
2026-06-15 10:50:08 +02:00
eb7fdb01e0 fix(threat-analyst): build-safe service enable in postinst (closes #595)
A plain systemctl enable no-ops during offline/image-build installs, so the
unit ended up disabled on gk2 → /threat-analyst/ page up but backend down.
Now deb-systemd-helper enable (persists to first boot) + guarded start.
secubox-threat-analyst 1.4.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:49:19 +02:00
CyberMind
2c6e6f2b51
Merge pull request #594 from CyberMind-FR/fix/593-webui-fix-metrics-mitm-0-wrong-unit-thre
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
7a56b8de35 docs: Mistral.ai handover prompt (reprise code + analyse projet)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:37:48 +02:00
19 changed files with 812 additions and 44 deletions

View File

@ -3,6 +3,102 @@
---
## 2026-06-15 — APT repo: all packages published + signed (apt.secubox.in)
Made the apt repo at `https://admin.gk2.secubox.in/repo/` (served from
`/var/www/apt.secubox.in`, manager `repoctl`/reprepro) carry **all** packages.
- **Was broken**: pool had 15 orphan debs with an **empty reprepro DB** and no
working signature — the published signing key `packages@secubox.in`
(fp 31848880…) has **no private key on the board**.
- **Signing** (user chose on-board `apt@secubox.in`, fp 219BA872…): imported its
secret into the repo GPG home (`/var/lib/secubox-repo/gpg`), wrote
`conf/distributions` (`SignWith: 219BA872…`) + `conf/options`, re-published
`secubox-keyring.gpg` + `FINGERPRINT.txt`. `InRelease`/`Release.gpg` now
**Good signature**. (install.sh doesn't pin the fp — transparent.)
- **Built all 144 packages** (`-d`, arch:all) + `reprepro includedeb bookworm`
→ 288 entries (×2 arch), 145 debs in pool, current versions
(core 1.1.6, threat-analyst 1.4.4, vm 1.0.1, toolbox 2.6.37, hub 1.4.3).
WebUI `/api/v1/repo/packages` lists 288. Served + signed via nginx :9080.
- **Tooling fix**: `scripts/build-packages.sh` now passes `-d` to
dpkg-buildpackage (it omitted it → dpkg-checkbuilddeps silently dropped
secubox-core and others from every build). 1 pkg failed (sentinelle-gsm,
buildinfo artifact race — deb still produced).
**Blocker for public HTTPS (separate, pre-existing):** `apt.secubox.in` via
HAProxy returns 503 because the **WAF mitmproxy LXC is crash-looping**
(restart #45552, `PermissionError: /home/mitmproxy/.mitmproxy/config.yaml`),
which downs the `mitmproxy_inspector` backend → ALL WAF-inspected vhosts 503
(analyse.gk2 etc., not just apt). Repo is reachable internally (nginx :9080)
and via the `/repo/` WebUI; public apt URL needs the WAF restored.
## 2026-06-15 — threat-analyst: global security overview (1.4.3, live on gk2)
`secubox-threat-analyst` 1.4.1 → 1.4.3, merged via **PR #598 (closes #597)**,
built + deployed live on gk2.
- **#597** — threat-analyst page becomes a **global security overview**: all
metrics dynamic, fed live from WAF + CrowdSec + firewall. New cached
`/overview` endpoint (double-buffer, 60 s background refresh →
`overview.json`) aggregating WAF (`/run/secubox/waf.sock /stats`: threats
today, blocked 24 h, rules loaded), CrowdSec (detection: alerts), firewall
(enforcement: IPs blocked in nft via crowdsec-firewall-bouncer). WebUI gains
a "Vue globale sécurité" card row + source health line (`loadOverview()` in
`loadAll()`).
- **Privilege-safe sourcing**: daemon runs as unprivileged `secubox` user →
`cscli`/`nft list` (both root-only) failed silently. Switched to CrowdSec's
privilege-free **Prometheus :6060** (`cs_alerts` + `cs_active_decisions`).
No privilege escalation, no coupling to broken `secubox-blacklist-sync`.
- Also carried the **1.4.2 build-safe postinst** fix (#595/#596) which had
not yet reached the board (was at 1.4.1; `deb-systemd-helper` enable).
- Live verified: CrowdSec 3712 alerts / 29312 active decisions, firewall
29312 blocked, WAF 140 rules; `/overview` 200 via socket **and** aggregator
proxy (aggregator restarted to re-discover the new route).
**Found, not fixed (separate):** `secubox-blacklist-sync.service` is **failed**
(#521, exit 2) → `secubox_blacklist` nft sets empty. Does not affect the
overview (firewall count comes from the bouncer via Prometheus).
### 1.4.4 — real CrowdSec ingestion (#599, PR #600)
The overview cards populated, but the **headline stats + Top-N leaderboards
stayed 0**: `collect_crowdsec_alerts()` shelled out to bare `cscli`, which
fails for the unprivileged `secubox` user → `alerts.jsonl` empty.
- **Read-only sudo ingestion** (backend only; frontend stays value-only):
collector now runs `sudo -n /usr/bin/cscli alerts list -o json -l 200`.
Ships `/etc/sudoers.d/secubox-threat-analyst` (only `cscli alerts/decisions
list *`, read-only), `visudo`-validated in postinst (self-removes if bad).
- **`NoNewPrivileges=no`** on the unit so sudo can escalate — matches the
sibling `secubox-crowdsec` / `secubox-waf` units (`NoNewPrivileges=yes`
had blocked sudo: "no new privileges flag is set").
- **Auto-collect loop** (~5 min) fills the DB without the page open; severity
mapped correctly (`remediation` is a bool).
- **Dedup + 48 h compaction**: `get_recent_alerts` dedups by id, `compact_
alerts()` bounds the append-only log (was inflating counts/leaderboards).
- Live verified (1.4.4): `alerts_24h=12`, **13 unique IPs, 10 countries**
(BG/BR/DE/FR/ID/IE/JP/NL/SG/US), 6+ scenarios → stats + leaderboards real.
### secubox-vm 1.0.1 — /vm/ showed 0 containers (#601, PR #602)
`https://admin.gk2.secubox.in/vm/` reported 0 containers though gk2 runs 20
LXC (16 running). Two compounding bugs:
- **Privilege**: the **aggregator mounts each module in-process** as the
unprivileged `secubox` user (serving model confirmed:
`/usr/lib/python3/dist-packages/aggregator/main.py` imports
`/usr/lib/secubox/<name>/api/main.py`). Bare `lxc-ls` can't see root's
`/var/lib/lxc` → empty.
- **Wrong `-F` key**: `lxc-ls -F MEMORY` is rejected (`Invalid key`) and emits
no rows — valid key is `RAM`.
Fix (backend-only): LXC read+lifecycle via `sudo -n` (`run_priv`); ships
`/etc/sudoers.d/secubox-vm` (`lxc-ls/info/start/stop`, visudo-validated);
`lxc-create`/`destroy` stay root-only (endpoints carry no JWT); `lxc-ls -F
…,RAM`; postinst reloads `secubox-aggregator`. KVM/libvirt readings were
already correct (`/dev/kvm` absent, libvirtd off). Live: `containers
{total: 20, running: 16}`, `/vms` lists all 20.
## 2026-06-14 — ToolBoX privacy/perf sprint : 2.6.23 → 2.6.36, all live on gk2
Large feature sprint on `secubox-toolbox` (built + merged + deployed live,

107
docs/AI-HANDOVER-mistral.md Normal file
View File

@ -0,0 +1,107 @@
<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 -->
# AI Handover — prompt Mistral.ai (reprise du code + analyse projet)
Prompt prêt à coller dans **Mistral Le Chat** (ou via l'API) pour qu'un agent
reprenne le code SecuBox-Deb et analyse le projet.
**Usage :** Le Chat n'a pas accès au dépôt ni au board `gk2` par défaut. Pour une
vraie reprise, lance l'agent dans un IDE/agent ayant accès au filesystem + SSH,
ou colle-lui `CLAUDE.md` + `.claude/*` en contexte. Mets à jour la section
« ÉTAT ACTUEL » depuis `.claude/HISTORY.md` avant chaque réutilisation.
---
```
# RÔLE
Tu es un ingénieur senior Debian / Python / sécurité réseau qui REPREND le projet
SecuBox-Deb. Tu travailles méthodiquement : tu LIS avant d'écrire, tu vérifies
avant d'affirmer, tu respectes à la lettre les conventions ci-dessous, et tu
n'inventes pas de fichiers/commandes — tu les vérifies dans le dépôt. Langue : français.
# CONTEXTE PROJET
SecuBox-Deb = plateforme cybersécurité CyberMind, portage Debian 12 (Bookworm)
ARM64 depuis OpenWrt, cible ANSSI CSPN. Matériel : MOCHAbin / ESPRESSObin
(Marvell Armada, aarch64). Dev : Gérald Kerma (Gandalf). Dépôt :
github.com/CyberMind-FR/secubox-deb.
Stack : Debian bookworm, kernel 6.x, nftables (PAS iptables), Unbound (Vortex DNS),
HAProxy + mitmproxy (WAF), Suricata + CrowdSec, FastAPI/Uvicorn (sockets unix par
module), LXC (pas Docker pour les apps), WireGuard, SQLite par défaut.
Palette cyberpunk/hermétique : cosmos #0a0a0f, gold #c9a84c, cinnabar #e63946,
matrix #00ff41, void #6e40c9, cyan #00d4ff. Polices Cinzel / IM Fell / JetBrains Mono.
# À LIRE EN PREMIER (sources de vérité)
1. CLAUDE.md + .claude/CLAUDE.md — règles impératives.
2. .claude/WIP.md — travail en cours + « Next Up ».
3. .claude/HISTORY.md — historique daté (commence par l'entrée la plus récente).
4. .claude/PATTERNS.md, .claude/MODULE-COMPLIANCE.md, .claude/MIGRATION-MAP.md.
5. docs/TOOLS.md, scripts/README.md.
# RÈGLES IMPÉRATIVES (non négociables)
- nftables DEFAULT DROP ; jamais iptables ni uci/LuCI.
- JAMAIS de waf_bypass : tout le trafic passe par mitmproxy.
- Secrets hors code : /etc/secubox/secrets/ chmod 600 ; jamais en clair / en TOML versionné.
- En-tête SPDX LicenseRef-CMSD-1.0 sur chaque fichier (vérifié par scripts/license-headers.py --check).
- SQLite par défaut (pas MySQL/Postgres sauf exception documentée).
- AppArmor enforce + user dédié secubox-<module> par service.
- Packaging Architecture:all pour le Python ; debian/compat=13, Standards-Version 4.6.2.
override_dh_strip est MORT pour Architecture:all → installer via execute_after_dh_auto_install.
- Pas de référence « Claude Code » / outil IA dans les commits/PR.
# WORKFLOW (multi-agent worktree)
- Tout travail non trivial = worktree dédié : bash scripts/agent-worktree.sh start --issue <#>
(branche feature/<#>-… ou fix/<#>-… selon le label ; master réservé au housekeeping).
- Cycle : issue GitHub → worktree → commits « (ref #<#>) » → PR « Closes #<#> » →
merge → agent-worktree.sh clean <#>. Ne jamais fermer une issue automatiquement.
- Build .deb : cd packages/<pkg> && dpkg-buildpackage -us -uc -b -d (le -d ok pour arch:all).
# DÉPLOIEMENT LIVE (board « gk2 »)
- SSH : root@192.168.1.200 (LAN) ou root@10.98.0.1 (tunnel wg-admin) ; clé en place.
- Portail toolbox = secubox-toolbox.service (host, uvicorn secubox_toolbox.app:app
sur 0.0.0.0:8088). HAProxy : kbin.gk2.secubox.in → backend toolbox_landing → 10.99.0.1:8088.
- R3 = 4 workers host-native secubox-toolbox-mitm-wg-worker@{1..4}.service
(mitmdump 10.99.1.1:8081-8084) chargeant les addons depuis
/usr/lib/secubox/toolbox/mitmproxy_addons/ (liste dans sbin/secubox-toolbox-mitm-wg-launch).
- Recette deploy : build → scp .deb → dpkg -i --force-confold --force-confdef →
TOUJOURS vérifier portail actif ET curl -sk https://kbin.gk2.secubox.in/ == 200
(un upgrade SIGTERM le portail ; le postinst le relance depuis 2.6.29, mais vérifie).
Changement d'addon → redémarrer les 4 workers SÉQUENTIELLEMENT (RAM limitée).
Ne PAS faire de restart de masse secubox-* (~100+ daemons).
# ARCHITECTURE TOOLBOX (module le plus actif)
packages/secubox-toolbox/ : FastAPI (secubox_toolbox/api.py, app.py), addons
mitmproxy (mitmproxy_addons/), filtres modulaires (secubox_toolbox/filters.py →
/etc/secubox/toolbox/filters.json, togglés via /admin/filters/ui). Store social :
SQLite /var/lib/secubox/toolbox/toolbox.db (social_edges/nodes/links/host_meta/
antibot/opgrade + threat_intel). Cartographie : www/toolbox/social.js (vues donut /
domaines-nuggets / œil), index.html (WebUI 5 onglets). Addons : inject_banner,
protective_mode, ad_ghost, media_cache, media_stats, social_graph, dpi, cookies,
avatar, ja4, utiq_defense, cert_pin_detect. Niveaux clients : R0/R1 (sans
bannière), R2 (captif), R3 (tunnel WG 10.99.1.0/24), R4 (prévu).
# ÉTAT ACTUEL (2026-06-14 — RAFRAÎCHIR depuis HISTORY avant réutilisation)
secubox-toolbox 2.6.36 déployé live, kbin sain. Live : protective spoofer,
filtres modulaires + ad-ghoster (collapse), media cache (opt-in), autolearn
trackers, DPI media donut, cartographie donut + nuggets domaine (IPs cachées) +
favicons, bannière guirlande + pin partagé, panneau protection webext,
/ca/fingerprint R3, fix postinst (kbin 503), detect_antibot deployment-vs-challenge.
Clients : APK Android v0.3.0 (zero-tap), webext v0.1.4. Fix : sync photos
iPhone↔Nextcloud (files_antivirus off + limites PHP).
# TRAVAIL OUVERT
#592 secubox-webmail-hub : inbox unifié Gmail (OAuth2) + Gandi + OVH ssl0, toutes
les sous-boîtes/alias en une page. Design filé, BLOQUÉ : besoin d'un client OAuth
Google (client_id/secret/redirect) + nom de vhost + décision read-only. Phase 1
IMAP (Gandi/OVH) peut démarrer sans OAuth.
# TES PREMIÈRES TÂCHES
1. ANALYSE (sans rien modifier) : lis .claude/* + CLAUDE.md, puis produis une
synthèse structurée — architecture, état des modules (✅/🔄/⬜ via
MIGRATION-MAP.md), dette technique, risques sécurité, écarts CSPN, backlog
priorisé. Cite chemin:ligne.
2. Propose un plan pour l'item « Next Up » (ou #592), conforme au workflow worktree
+ aux règles, AVANT d'écrire du code.
3. Toute action sur le board live : décris-la et demande confirmation si difficile
à annuler ou exposée.
Commence par : « J'ai lu CLAUDE.md, .claude/WIP.md et HISTORY.md. Voici ma synthèse… »
```

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

@ -11,6 +11,7 @@ Features:
import os
import json
import time
import asyncio
import logging
import subprocess
from datetime import datetime, timedelta
@ -172,12 +173,18 @@ class ThreatAnalyzer:
f.write(json.dumps(alert.model_dump()) + "\n")
def get_recent_alerts(self, hours: int = 24, source: Optional[str] = None) -> List[ThreatAlert]:
"""Get recent alerts."""
"""Get recent alerts, deduplicated by id (last occurrence wins).
The collector appends on every poll, so the same CrowdSec alert id can
recur many times without dedup the headline counts and Top-N
leaderboards are massively inflated.
"""
cutoff = datetime.utcnow() - timedelta(hours=hours)
alerts = []
by_id: Dict[str, ThreatAlert] = {}
anon = 0
if not self.alerts_file.exists():
return alerts
return []
with open(self.alerts_file) as f:
for line in f:
@ -188,35 +195,82 @@ class ThreatAnalyzer:
continue
if source and data.get("source") != source:
continue
alerts.append(ThreatAlert(**data))
aid = data.get("id")
if not aid:
aid = f"_anon-{anon}"; anon += 1
by_id[aid] = ThreatAlert(**data)
except Exception:
continue
return alerts
return list(by_id.values())
def compact_alerts(self, hours: int = 48):
"""Rewrite alerts.jsonl keeping only the last `hours`, deduped by id —
keeps the append-only log from growing unbounded."""
if not self.alerts_file.exists():
return
cutoff = datetime.utcnow() - timedelta(hours=hours)
recent: Dict[str, Dict[str, Any]] = {}
anon = 0
try:
with open(self.alerts_file) as f:
for line in f:
try:
data = json.loads(line)
ts = datetime.fromisoformat(data["timestamp"].rstrip("Z"))
if ts < cutoff:
continue
aid = data.get("id") or f"_anon-{anon}"
if not data.get("id"):
anon += 1
recent[aid] = data
except Exception:
continue
tmp = self.alerts_file.with_suffix(".jsonl.tmp")
with open(tmp, "w") as f:
for data in recent.values():
f.write(json.dumps(data) + "\n")
tmp.replace(self.alerts_file)
except Exception as e:
logger.warning("compact_alerts failed: %s", e)
async def collect_crowdsec_alerts(self) -> List[ThreatAlert]:
"""Collect alerts from CrowdSec."""
alerts = []
try:
# The daemon runs as the unprivileged `secubox` user; `cscli` needs
# root (reads /etc/crowdsec/local_api_credentials.yaml). We go through
# the read-only sudo grant shipped in /etc/sudoers.d/secubox-threat-
# analyst (sudo lives here on the BACKEND only — the frontend just
# consumes the resulting values).
result = subprocess.run(
["cscli", "alerts", "list", "-o", "json"],
["sudo", "-n", "/usr/bin/cscli",
"alerts", "list", "-o", "json", "-l", "200"],
capture_output=True,
text=True,
timeout=10
timeout=15
)
if result.returncode == 0:
data = json.loads(result.stdout)
for item in data[:50]: # Limit to 50
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout) or []
for item in data[:200]:
src = item.get("source") or {}
# `remediation` is a bool, not a severity — map it.
severity = "high" if item.get("remediation") else "medium"
alert = ThreatAlert(
id=f"cs-{item.get('id', '')}",
source="crowdsec",
severity=item.get("remediation", "medium"),
severity=severity,
type=item.get("scenario", "unknown"),
ip=item.get("source", {}).get("ip"),
ip=src.get("ip") or src.get("value"),
details=item,
timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z")
)
alerts.append(alert)
else:
logger.warning(
"cscli alerts list failed (rc=%s): %s",
result.returncode, (result.stderr or "").strip()[:200]
)
except Exception as e:
logger.warning(f"CrowdSec collection failed: {e}")
@ -727,8 +781,147 @@ async def rollback_rule(rule_id: str):
# Startup
# ============================================================================
# ============================================================================
# #597 — Global security overview : aggregate live metrics from WAF +
# CrowdSec + the nft firewall. Double-cache pattern (CLAUDE perf rule) :
# a background task refreshes every 60 s into _OVERVIEW so /overview is
# instant and never blocks on cscli/nft subprocesses. Each source is
# best-effort (partial dict on failure) — one dead source never breaks it.
# ============================================================================
_OVERVIEW: Dict[str, Any] = {}
_OVERVIEW_FILE = DATA_DIR / "overview.json"
_OVERVIEW_TTL = 60
async def _waf_overview() -> Dict[str, Any]:
"""WAF /stats over its unix socket."""
try:
transport = httpx.AsyncHTTPTransport(uds="/run/secubox/waf.sock")
async with httpx.AsyncClient(transport=transport, timeout=4) as c:
r = await c.get("http://waf/stats")
if r.status_code == 200:
s = r.json()
return {
"running": bool(s.get("running")),
"threats_today": s.get("threats_today", 0),
"threats_total": s.get("total_threats", 0),
"blocked_24h": s.get("blocked_24h", 0),
"rules_loaded": s.get("rules_loaded", 0),
"by_category": s.get("by_category", {}),
"by_severity": s.get("by_severity", {}),
"top_countries": s.get("top_countries", [])[:5],
"top_vhosts": s.get("top_vhosts", [])[:5],
}
except Exception as e:
logger.debug("waf overview failed: %s", e)
return {"running": False}
# CrowdSec exposes a privilege-free Prometheus endpoint on :6060. We parse it
# instead of shelling out to `cscli`/`nft` (both need root — this daemon runs as
# the unprivileged `secubox` user, CSPN least-privilege). This gives us both the
# detection layer (cs_alerts) and the enforcement layer (cs_active_decisions,
# which the crowdsec-firewall-bouncer materializes into nft) from one HTTP GET.
_PROM_URL = "http://127.0.0.1:6060/metrics"
def _prom_sum(text: str, prefix: str) -> int:
"""Sum the values of every Prometheus sample line starting with prefix."""
total = 0.0
for line in text.splitlines():
if not line.startswith(prefix) or line.startswith("#"):
continue
try:
total += float(line.rsplit(" ", 1)[1])
except (ValueError, IndexError):
continue
return int(total)
async def _crowdsec_firewall_overview():
"""One privilege-free fetch of CrowdSec Prometheus → (crowdsec, firewall).
crowdsec : detection layer alerts + active decisions
firewall : enforcement layer IPs blocked in nft via crowdsec-firewall-bouncer
"""
cs: Dict[str, Any] = {"running": False, "active_decisions": 0, "alerts": 0}
fw: Dict[str, Any] = {"running": False, "blocked": 0,
"source": "crowdsec-firewall-bouncer (nft)"}
try:
async with httpx.AsyncClient(timeout=4) as c:
r = await c.get(_PROM_URL)
if r.status_code == 200:
active = _prom_sum(r.text, "cs_active_decisions")
alerts = _prom_sum(r.text, "cs_alerts")
cs = {"running": True, "active_decisions": active, "alerts": alerts}
fw = {"running": True, "blocked": active,
"source": "crowdsec-firewall-bouncer (nft)"}
except Exception as e:
logger.debug("crowdsec prometheus overview failed: %s", e)
return cs, fw
async def _build_overview() -> Dict[str, Any]:
waf, (cs, fw) = await asyncio.gather(
_waf_overview(),
_crowdsec_firewall_overview(),
)
return {"waf": waf, "crowdsec": cs, "firewall": fw, "updated": int(time.time())}
async def _overview_refresh_loop():
while True:
try:
ov = await _build_overview()
_OVERVIEW.clear(); _OVERVIEW.update(ov)
try:
_OVERVIEW_FILE.write_text(json.dumps(ov))
except Exception:
pass
except Exception as e:
logger.warning("overview refresh failed: %s", e)
await asyncio.sleep(_OVERVIEW_TTL)
@app.get("/overview")
async def get_overview():
"""Global security overview (WAF + CrowdSec + firewall), 60 s cached."""
if _OVERVIEW:
return _OVERVIEW
if _OVERVIEW_FILE.exists():
try:
return json.loads(_OVERVIEW_FILE.read_text())
except Exception:
pass
return await _build_overview()
_COLLECT_TTL = 300 # 5 min
async def _collect_refresh_loop():
"""Backend auto-collect: keep the alerts DB fed from CrowdSec + WAF even
when no operator has the page open. Compacts the log after each run so it
stays bounded (and deduped). subprocess work is brief and best-effort."""
while True:
try:
cs = await analyzer.collect_crowdsec_alerts()
waf = await analyzer.collect_waf_alerts()
for a in cs + waf:
analyzer.record_alert(a)
analyzer.compact_alerts()
if cs or waf:
logger.info("auto-collect: %d crowdsec + %d waf alerts", len(cs), len(waf))
except Exception as e:
logger.warning("auto-collect failed: %s", e)
await asyncio.sleep(_COLLECT_TTL)
@app.on_event("startup")
async def startup():
"""Initialize on startup."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_overview_refresh_loop())
asyncio.create_task(_collect_refresh_loop())
logger.info("Threat Analyst started")

View File

@ -1,3 +1,44 @@
secubox-threat-analyst (1.4.4-1~bookworm1) bookworm; urgency=medium
* feat(ingest): populate the headline stats + Top-N leaderboards with real
CrowdSec data (#599). The collector shelled out to bare `cscli`, which
fails for the unprivileged `secubox` user the daemon runs as → the alerts
DB stayed empty (Unique attackers / Active threats / Countries / Top-N all
0). Now goes through a read-only sudo grant (`/etc/sudoers.d/
secubox-threat-analyst`: `cscli alerts list *` + `decisions list *`,
visudo-validated in postinst) and fetches `-l 200`. sudo is BACKEND-only;
the frontend stays value-only.
* feat(collect): backend auto-collect loop (~5 min) so the DB fills without
the page open; severity mapped correctly (`remediation` is a bool).
* fix(dedup): `get_recent_alerts` now dedups by alert id (last wins) and a
bounded 48 h compaction rewrites `alerts.jsonl` — the append-on-every-poll
log was massively inflating counts and leaderboards.
-- Gerald KERMA <devel@cybermind.fr> Mon, 15 Jun 2026 12:00:00 +0200
secubox-threat-analyst (1.4.3-1~bookworm1) bookworm; urgency=medium
* feat(overview): global security overview — all metrics now dynamic,
fed live from WAF + CrowdSec + firewall (#597). New cached `/overview`
endpoint aggregates: WAF (threats_today/blocked_24h/rules_loaded via
/run/secubox/waf.sock), CrowdSec (active bans + alerts via cscli -o
json), firewall (blacklisted IP count v4/v6 via nft -j list set).
Double-buffer background refresh (60s, overview.json) + source
health line. WebUI gains a "Vue globale sécurité" card row wired to
the new endpoint via loadOverview() in loadAll().
-- Gerald KERMA <devel@cybermind.fr> Mon, 15 Jun 2026 10:00:00 +0200
secubox-threat-analyst (1.4.2-1~bookworm1) bookworm; urgency=medium
* fix(postinst): build-safe service enable (#595). A plain `systemctl
enable` no-ops during offline/image-build installs, so the unit ended
up DISABLED on the live board → /threat-analyst/ page loaded but its
backend (stats/alerts/rules) was down. Now uses deb-systemd-helper
(persists the enable to first boot) + guarded daemon-reload/start.
-- Gerald KERMA <devel@cybermind.fr> Sun, 15 Jun 2026 11:00:00 +0200
secubox-threat-analyst (1.4.1-1~bookworm1) bookworm; urgency=low
* Rewrite Description to identify this as the AI agent that

View File

@ -1,9 +1,39 @@
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
systemctl daemon-reload
# #599 — validate the read-only cscli sudo grant; drop it if malformed so a
# bad drop-in can never break sudo for the whole system.
SUDOERS=/etc/sudoers.d/secubox-threat-analyst
if [ -f "$SUDOERS" ]; then
chmod 0440 "$SUDOERS" || true
if command -v visudo >/dev/null 2>&1; then
if ! visudo -cf "$SUDOERS" >/dev/null 2>&1; then
echo "secubox-threat-analyst: invalid sudoers drop-in, removing" >&2
rm -f "$SUDOERS"
fi
fi
fi
# #595 — build-safe enable: a plain `systemctl enable` no-ops during an
# offline / image-build (chroot) install, leaving the unit DISABLED on
# the live board (caught on gk2: /threat-analyst/ backend down). Use
# deb-systemd-helper (records the enable, applies on first boot) and only
# touch the running system when systemd is actually up.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
fi
if command -v deb-systemd-helper >/dev/null 2>&1; then
deb-systemd-helper enable secubox-threat-analyst.service >/dev/null 2>&1 || true
else
systemctl enable secubox-threat-analyst.service || true
fi
if [ -d /run/systemd/system ]; then
if command -v deb-systemd-invoke >/dev/null 2>&1; then
deb-systemd-invoke start secubox-threat-analyst.service >/dev/null 2>&1 || true
else
systemctl start secubox-threat-analyst.service || true
fi
fi
fi
#DEBHELPER#
exit 0

View File

@ -9,3 +9,7 @@ override_dh_auto_install:
cp -r www/threat-analyst/. debian/secubox-threat-analyst/usr/share/secubox/www/threat-analyst/
install -d debian/secubox-threat-analyst/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. debian/secubox-threat-analyst/usr/share/secubox/menu.d/ || true
# Read-only cscli sudo grant (#599) — backend ingestion of CrowdSec alerts.
install -d debian/secubox-threat-analyst/etc/sudoers.d
install -m 0440 debian/sudoers.d/secubox-threat-analyst \
debian/secubox-threat-analyst/etc/sudoers.d/secubox-threat-analyst

View File

@ -13,7 +13,10 @@ Restart=on-failure
RestartSec=5
UMask=0000
NoNewPrivileges=true
# #599 — allow the read-only `sudo cscli` ingestion grant (sibling pattern:
# secubox-crowdsec / secubox-waf also set this). The sudo scope is tightly
# limited to read-only `cscli alerts/decisions list` in /etc/sudoers.d.
NoNewPrivileges=no
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
RuntimeDirectoryMode=0775

View File

@ -0,0 +1,7 @@
# SecuBox threat-analyst — read-only CrowdSec ingestion.
# The FastAPI daemon runs as the unprivileged `secubox` user and needs to read
# CrowdSec alerts/decisions to populate the analyst dashboard. `cscli` needs
# root (it reads /etc/crowdsec/local_api_credentials.yaml). Only READ-ONLY
# subcommands are granted here — no add/delete/restart. CSPN least-privilege.
secubox ALL=(root) NOPASSWD: /usr/bin/cscli alerts list *
secubox ALL=(root) NOPASSWD: /usr/bin/cscli decisions list *

View File

@ -262,6 +262,31 @@
</div>
</div>
<!-- #597 Global security overview — live from WAF + CrowdSec + firewall -->
<h3 style="margin:1rem 0 .4rem">🛡 Vue globale sécurité <span id="ov-sources" style="font-size:.7rem;color:var(--text-muted)"></span></h3>
<div class="stats">
<div class="stat warn">
<div class="stat-label">WAF — menaces (24h)</div>
<div class="stat-value" id="o-waf-threats"></div>
<div class="stat-hint" id="o-waf-blocked">bloquées: —</div>
</div>
<div class="stat crit">
<div class="stat-label">CrowdSec — détections</div>
<div class="stat-value" id="o-cs-bans"></div>
<div class="stat-hint" id="o-cs-alerts">alertes: —</div>
</div>
<div class="stat">
<div class="stat-label">Firewall — IP bloquées (nft)</div>
<div class="stat-value" id="o-fw-blacklist"></div>
<div class="stat-hint" id="o-fw-split">via crowdsec-bouncer</div>
</div>
<div class="stat">
<div class="stat-label">WAF — règles chargées</div>
<div class="stat-value" id="o-waf-rules"></div>
<div class="stat-hint">moteur d'inspection</div>
</div>
</div>
<!-- Top-N leaderboards — the real insight surface -->
<div class="top-row">
<div class="top-box">
@ -555,8 +580,29 @@
setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`);
await loadAll();
}
// #597 — global overview from WAF + CrowdSec + firewall
async function loadOverview() {
const r = await get('/api/v1/threat-analyst/overview');
diagSet('/overview', r);
if (!r.ok) return;
const o = r.data || {}, w = o.waf || {}, c = o.crowdsec || {}, f = o.firewall || {};
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
set('o-waf-threats', w.threats_today ?? '—');
set('o-waf-blocked', 'bloquées: ' + (w.blocked_24h ?? 0) + ' (24h)');
set('o-cs-bans', c.alerts ?? '—');
set('o-cs-alerts', 'décisions actives: ' + (c.active_decisions ?? 0));
set('o-fw-blacklist', f.blocked ?? '—');
set('o-fw-split', f.source || 'via crowdsec-bouncer');
set('o-waf-rules', w.rules_loaded ?? '—');
const up = [];
up.push((w.running ? '🟢' : '🔴') + ' WAF');
up.push((c.running ? '🟢' : '🔴') + ' CrowdSec');
up.push('🟢 Firewall');
const ago = o.updated ? Math.max(0, Math.round(Date.now()/1000 - o.updated)) + 's' : '';
set('ov-sources', up.join(' · ') + (ago ? ' · maj ' + ago : ''));
}
async function loadAll() {
await Promise.all([loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
await Promise.all([loadOverview(), loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
}
// Diagnostic

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(

View File

@ -41,6 +41,17 @@ def run_cmd(cmd: list, timeout: int = 60) -> tuple:
return "", str(e), 1
def run_priv(cmd: list, timeout: int = 60) -> tuple:
"""Run a host privileged command via the read/lifecycle sudo grant.
The aggregator mounts this module in-process as the unprivileged `secubox`
user, which cannot see root's /var/lib/lxc containers — bare `lxc-ls`
returns nothing, so the dashboard reported 0 containers (#601). The grant
in /etc/sudoers.d/secubox-vm covers read + lifecycle only.
"""
return run_cmd(["sudo", "-n"] + cmd, timeout=timeout)
def is_libvirt_running() -> bool:
"""Check if libvirtd is running."""
_, _, code = run_cmd(["systemctl", "is-active", "--quiet", "libvirtd"])
@ -81,7 +92,9 @@ def get_virsh_vms() -> list:
def get_lxc_containers() -> list:
"""List all LXC containers."""
containers = []
stdout, _, code = run_cmd(["lxc-ls", "-f", "-F", "NAME,STATE,IPV4,MEMORY"])
# NB: the memory column key is `RAM` — `MEMORY` is rejected by lxc-ls
# ("Invalid key") and yields zero output (#601).
stdout, _, code = run_priv(["lxc-ls", "-f", "-F", "NAME,STATE,IPV4,RAM"])
if code != 0:
return containers
@ -122,7 +135,7 @@ def get_lxc_info(name: str) -> dict:
"""Get detailed LXC container info."""
info = {"name": name, "type": "lxc"}
stdout, _, code = run_cmd(["lxc-info", "-n", name])
stdout, _, code = run_priv(["lxc-info", "-n", name])
if code == 0:
for line in stdout.strip().split('\n'):
if ':' in line:
@ -319,7 +332,7 @@ def start_vm(name: str):
if is_lxc_available():
for c in get_lxc_containers():
if c["name"] == name:
stdout, stderr, code = run_cmd(["lxc-start", "-n", name])
stdout, stderr, code = run_priv(["lxc-start", "-n", name])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to start: {stderr}")
return {"status": "started", "name": name}
@ -347,7 +360,7 @@ def stop_vm(name: str, force: bool = False):
cmd = ["lxc-stop", "-n", name]
if force:
cmd.append("-k")
stdout, stderr, code = run_cmd(cmd)
stdout, stderr, code = run_priv(cmd)
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to stop: {stderr}")
return {"status": "stopped", "name": name}
@ -371,8 +384,8 @@ def restart_vm(name: str):
if is_lxc_available():
for c in get_lxc_containers():
if c["name"] == name:
run_cmd(["lxc-stop", "-n", name])
stdout, stderr, code = run_cmd(["lxc-start", "-n", name])
run_priv(["lxc-stop", "-n", name])
stdout, stderr, code = run_priv(["lxc-start", "-n", name])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to restart: {stderr}")
return {"status": "restarted", "name": name}
@ -404,9 +417,11 @@ def delete_vm(name: str):
for c in get_lxc_containers():
if c["name"] == name:
if c.get('state') == 'running':
run_cmd(["lxc-stop", "-n", name, "-k"])
run_priv(["lxc-stop", "-n", name, "-k"])
stdout, stderr, code = run_cmd(["lxc-destroy", "-n", name])
# lxc-destroy is intentionally NOT in the sudo grant — deleting
# containers from an unauthenticated endpoint stays root-only.
stdout, stderr, code = run_priv(["lxc-destroy", "-n", name])
if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to delete: {stderr}")

View File

@ -1,3 +1,16 @@
secubox-vm (1.0.1-1) stable; urgency=medium
* fix(lxc): /vm/ reported 0 containers though the host runs 20 LXC
containers (#601). The aggregator mounts this module in-process as the
unprivileged `secubox` user, so bare `lxc-ls` could not see root's
/var/lib/lxc → empty list. LXC read + lifecycle now go through `sudo -n`
via a new read-only-ish grant (/etc/sudoers.d/secubox-vm: lxc-ls/info/
start/stop, visudo-validated). lxc-create/lxc-destroy stay root-only.
postinst reloads secubox-aggregator so the in-process module refreshes.
(KVM/libvirt readings were already correct: no /dev/kvm, libvirtd off.)
-- Gerald KERMA <devel@cybermind.fr> Mon, 15 Jun 2026 13:00:00 +0200
secubox-vm (1.0.0-1) stable; urgency=low
* Initial release

View File

@ -5,6 +5,19 @@ case "$1" in
install -d -o root -g root -m 755 /var/lib/secubox/vm
install -d -o root -g root -m 755 /var/lib/secubox/vm/iso
install -d -o root -g root -m 755 /var/lib/secubox/vm/disks
# #601 — validate the read+lifecycle cscli/lxc sudo grant; drop it if
# malformed so a bad drop-in can never break sudo for the whole system.
SUDOERS=/etc/sudoers.d/secubox-vm
if [ -f "$SUDOERS" ]; then
chmod 0440 "$SUDOERS" || true
if command -v visudo >/dev/null 2>&1 && ! visudo -cf "$SUDOERS" >/dev/null 2>&1; then
echo "secubox-vm: invalid sudoers drop-in, removing" >&2
rm -f "$SUDOERS"
fi
fi
# The aggregator mounts this module in-process; reload it so the new code
# + LXC listing take effect (the per-module service is not the live path).
systemctl try-reload-or-restart secubox-aggregator.service 2>/dev/null || true
systemctl daemon-reload
systemctl enable secubox-vm.service
systemctl start secubox-vm.service || true

View File

@ -14,3 +14,7 @@ override_dh_auto_install:
install -m 644 menu.d/903-vm.json $(CURDIR)/debian/secubox-vm/usr/share/secubox/menu.d/
install -d $(CURDIR)/debian/secubox-vm/usr/lib/systemd/system
install -m 644 debian/secubox-vm.service $(CURDIR)/debian/secubox-vm/usr/lib/systemd/system/
# #601 — read+lifecycle sudo grant so LXC enumeration works under the
# unprivileged `secubox` user the aggregator mounts this module as.
install -d $(CURDIR)/debian/secubox-vm/etc/sudoers.d
install -m 0440 debian/sudoers.d/secubox-vm $(CURDIR)/debian/secubox-vm/etc/sudoers.d/secubox-vm

View File

@ -0,0 +1,9 @@
# SecuBox VM module — host virtualization management as the `secubox` user.
# The aggregator mounts this module in-process as `secubox`, which cannot see
# root's /var/lib/lxc containers without sudo. READ + container LIFECYCLE only.
# lxc-create / lxc-destroy are deliberately NOT granted — creating/deleting
# containers stays root-only (these endpoints carry no JWT). CSPN least-priv.
secubox ALL=(root) NOPASSWD: /usr/bin/lxc-ls *
secubox ALL=(root) NOPASSWD: /usr/bin/lxc-info *
secubox ALL=(root) NOPASSWD: /usr/bin/lxc-start *
secubox ALL=(root) NOPASSWD: /usr/bin/lxc-stop *

View File

@ -140,13 +140,18 @@ for PKG in "${PACKAGES[@]}"; do
# Use timeout to prevent infinite hangs (5 minutes per package)
TIMEOUT_CMD="timeout --kill-after=30s 300s"
# -d skips the build-dependency check: every SecuBox package is
# Architecture: all (dh just copies files), so the Build-Depends need not be
# installed on the build host. Without -d, packages declaring deps absent
# here (e.g. python3-all version mismatch) fail dpkg-checkbuilddeps and were
# silently dropped from the repo — incl. secubox-core. (CLAUDE.md mandates -d.)
if [[ "$ARCH" == "arm64" ]] && [[ "$(uname -m)" != "aarch64" ]]; then
# Cross-compile pour arm64
if $TIMEOUT_CMD dpkg-buildpackage -a arm64 --host-arch arm64 -us -uc -b > "$BUILD_LOG" 2>&1; then
if $TIMEOUT_CMD dpkg-buildpackage -a arm64 --host-arch arm64 -us -uc -b -d > "$BUILD_LOG" 2>&1; then
BUILD_OK=1
fi
else
if $TIMEOUT_CMD dpkg-buildpackage -us -uc -b > "$BUILD_LOG" 2>&1; then
if $TIMEOUT_CMD dpkg-buildpackage -us -uc -b -d > "$BUILD_LOG" 2>&1; then
BUILD_OK=1
fi
fi