mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 05:27:07 +00:00
Compare commits
19 Commits
4922aada7f
...
c64a666fa5
| Author | SHA1 | Date | |
|---|---|---|---|
| c64a666fa5 | |||
| 5325cddade | |||
|
|
1d7ca0cd22 | ||
| a610ffd276 | |||
| 5800ad713d | |||
| 4fd0d864ee | |||
|
|
f99f071642 | ||
| 77b6f2624d | |||
| d9fea2f9b4 | |||
| 65a1a8e494 | |||
|
|
8fc5dba929 | ||
| 919a88c6be | |||
| 60eeb79185 | |||
|
|
91cba0bbda | ||
| eb7fdb01e0 | |||
|
|
2c6e6f2b51 | ||
| ca1a2ede1d | |||
| e1f22b6dda | |||
| 7a56b8de35 |
|
|
@ -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
|
## 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,
|
Large feature sprint on `secubox-toolbox` (built + merged + deployed live,
|
||||||
|
|
|
||||||
107
docs/AI-HANDOVER-mistral.md
Normal file
107
docs/AI-HANDOVER-mistral.md
Normal 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… »
|
||||||
|
```
|
||||||
147
docs/cspn/CSPN-TEST-MATRIX.md
Normal file
147
docs/cspn/CSPN-TEST-MATRIX.md
Normal 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.
|
||||||
|
|
@ -11,6 +11,7 @@ Features:
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -172,12 +173,18 @@ class ThreatAnalyzer:
|
||||||
f.write(json.dumps(alert.model_dump()) + "\n")
|
f.write(json.dumps(alert.model_dump()) + "\n")
|
||||||
|
|
||||||
def get_recent_alerts(self, hours: int = 24, source: Optional[str] = None) -> List[ThreatAlert]:
|
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)
|
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||||
alerts = []
|
by_id: Dict[str, ThreatAlert] = {}
|
||||||
|
anon = 0
|
||||||
|
|
||||||
if not self.alerts_file.exists():
|
if not self.alerts_file.exists():
|
||||||
return alerts
|
return []
|
||||||
|
|
||||||
with open(self.alerts_file) as f:
|
with open(self.alerts_file) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
|
|
@ -188,35 +195,82 @@ class ThreatAnalyzer:
|
||||||
continue
|
continue
|
||||||
if source and data.get("source") != source:
|
if source and data.get("source") != source:
|
||||||
continue
|
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:
|
except Exception:
|
||||||
continue
|
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]:
|
async def collect_crowdsec_alerts(self) -> List[ThreatAlert]:
|
||||||
"""Collect alerts from CrowdSec."""
|
"""Collect alerts from CrowdSec."""
|
||||||
alerts = []
|
alerts = []
|
||||||
try:
|
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(
|
result = subprocess.run(
|
||||||
["cscli", "alerts", "list", "-o", "json"],
|
["sudo", "-n", "/usr/bin/cscli",
|
||||||
|
"alerts", "list", "-o", "json", "-l", "200"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=15
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout) or []
|
||||||
for item in data[:50]: # Limit to 50
|
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(
|
alert = ThreatAlert(
|
||||||
id=f"cs-{item.get('id', '')}",
|
id=f"cs-{item.get('id', '')}",
|
||||||
source="crowdsec",
|
source="crowdsec",
|
||||||
severity=item.get("remediation", "medium"),
|
severity=severity,
|
||||||
type=item.get("scenario", "unknown"),
|
type=item.get("scenario", "unknown"),
|
||||||
ip=item.get("source", {}).get("ip"),
|
ip=src.get("ip") or src.get("value"),
|
||||||
details=item,
|
details=item,
|
||||||
timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z")
|
timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z")
|
||||||
)
|
)
|
||||||
alerts.append(alert)
|
alerts.append(alert)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"cscli alerts list failed (rc=%s): %s",
|
||||||
|
result.returncode, (result.stderr or "").strip()[:200]
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"CrowdSec collection failed: {e}")
|
logger.warning(f"CrowdSec collection failed: {e}")
|
||||||
|
|
||||||
|
|
@ -727,8 +781,147 @@ async def rollback_rule(rule_id: str):
|
||||||
# Startup
|
# 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")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
"""Initialize on startup."""
|
"""Initialize on startup."""
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
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")
|
logger.info("Threat Analyst started")
|
||||||
|
|
|
||||||
|
|
@ -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
|
secubox-threat-analyst (1.4.1-1~bookworm1) bookworm; urgency=low
|
||||||
|
|
||||||
* Rewrite Description to identify this as the AI agent that
|
* Rewrite Description to identify this as the AI agent that
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,39 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
if [ "$1" = "configure" ]; then
|
if [ "$1" = "configure" ]; then
|
||||||
systemctl daemon-reload
|
# #599 — validate the read-only cscli sudo grant; drop it if malformed so a
|
||||||
systemctl enable secubox-threat-analyst.service || true
|
# bad drop-in can never break sudo for the whole system.
|
||||||
systemctl start secubox-threat-analyst.service || true
|
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
|
fi
|
||||||
#DEBHELPER#
|
#DEBHELPER#
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,7 @@ override_dh_auto_install:
|
||||||
cp -r www/threat-analyst/. debian/secubox-threat-analyst/usr/share/secubox/www/threat-analyst/
|
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
|
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
|
[ -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
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
UMask=0000
|
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
|
RuntimeDirectory=secubox
|
||||||
RuntimeDirectoryPreserve=yes
|
RuntimeDirectoryPreserve=yes
|
||||||
RuntimeDirectoryMode=0775
|
RuntimeDirectoryMode=0775
|
||||||
|
|
|
||||||
|
|
@ -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 *
|
||||||
|
|
@ -262,6 +262,31 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Top-N leaderboards — the real insight surface -->
|
||||||
<div class="top-row">
|
<div class="top-row">
|
||||||
<div class="top-box">
|
<div class="top-box">
|
||||||
|
|
@ -555,8 +580,29 @@
|
||||||
setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`);
|
setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`);
|
||||||
await loadAll();
|
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() {
|
async function loadAll() {
|
||||||
await Promise.all([loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
|
await Promise.all([loadOverview(), loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic
|
// Diagnostic
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
for r in c.execute(
|
# as the top "tracker"). Over-fetch, fold in Python, top 50.
|
||||||
"SELECT tracker_domain, COUNT(*) AS hits, "
|
_byd: dict = {}
|
||||||
"COUNT(DISTINCT client_mac_hash) AS clients "
|
for r in c.execute(
|
||||||
"FROM social_edges WHERE ts >= ? "
|
"SELECT tracker_domain, COUNT(*) AS hits, "
|
||||||
"GROUP BY tracker_domain ORDER BY hits DESC LIMIT 50",
|
"COUNT(DISTINCT client_mac_hash) AS clients "
|
||||||
(since,),
|
"FROM social_edges WHERE ts >= ? "
|
||||||
).fetchall()
|
"GROUP BY tracker_domain ORDER BY hits DESC LIMIT 400",
|
||||||
]
|
(since,),
|
||||||
|
).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(
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,17 @@ def run_cmd(cmd: list, timeout: int = 60) -> tuple:
|
||||||
return "", str(e), 1
|
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:
|
def is_libvirt_running() -> bool:
|
||||||
"""Check if libvirtd is running."""
|
"""Check if libvirtd is running."""
|
||||||
_, _, code = run_cmd(["systemctl", "is-active", "--quiet", "libvirtd"])
|
_, _, code = run_cmd(["systemctl", "is-active", "--quiet", "libvirtd"])
|
||||||
|
|
@ -81,7 +92,9 @@ def get_virsh_vms() -> list:
|
||||||
def get_lxc_containers() -> list:
|
def get_lxc_containers() -> list:
|
||||||
"""List all LXC containers."""
|
"""List all LXC containers."""
|
||||||
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:
|
if code != 0:
|
||||||
return containers
|
return containers
|
||||||
|
|
@ -122,7 +135,7 @@ def get_lxc_info(name: str) -> dict:
|
||||||
"""Get detailed LXC container info."""
|
"""Get detailed LXC container info."""
|
||||||
info = {"name": name, "type": "lxc"}
|
info = {"name": name, "type": "lxc"}
|
||||||
|
|
||||||
stdout, _, code = run_cmd(["lxc-info", "-n", name])
|
stdout, _, code = run_priv(["lxc-info", "-n", name])
|
||||||
if code == 0:
|
if code == 0:
|
||||||
for line in stdout.strip().split('\n'):
|
for line in stdout.strip().split('\n'):
|
||||||
if ':' in line:
|
if ':' in line:
|
||||||
|
|
@ -319,7 +332,7 @@ def start_vm(name: str):
|
||||||
if is_lxc_available():
|
if is_lxc_available():
|
||||||
for c in get_lxc_containers():
|
for c in get_lxc_containers():
|
||||||
if c["name"] == name:
|
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:
|
if code != 0:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start: {stderr}")
|
raise HTTPException(status_code=500, detail=f"Failed to start: {stderr}")
|
||||||
return {"status": "started", "name": name}
|
return {"status": "started", "name": name}
|
||||||
|
|
@ -347,7 +360,7 @@ def stop_vm(name: str, force: bool = False):
|
||||||
cmd = ["lxc-stop", "-n", name]
|
cmd = ["lxc-stop", "-n", name]
|
||||||
if force:
|
if force:
|
||||||
cmd.append("-k")
|
cmd.append("-k")
|
||||||
stdout, stderr, code = run_cmd(cmd)
|
stdout, stderr, code = run_priv(cmd)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to stop: {stderr}")
|
raise HTTPException(status_code=500, detail=f"Failed to stop: {stderr}")
|
||||||
return {"status": "stopped", "name": name}
|
return {"status": "stopped", "name": name}
|
||||||
|
|
@ -371,8 +384,8 @@ def restart_vm(name: str):
|
||||||
if is_lxc_available():
|
if is_lxc_available():
|
||||||
for c in get_lxc_containers():
|
for c in get_lxc_containers():
|
||||||
if c["name"] == name:
|
if c["name"] == name:
|
||||||
run_cmd(["lxc-stop", "-n", name])
|
run_priv(["lxc-stop", "-n", name])
|
||||||
stdout, stderr, code = run_cmd(["lxc-start", "-n", name])
|
stdout, stderr, code = run_priv(["lxc-start", "-n", name])
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to restart: {stderr}")
|
raise HTTPException(status_code=500, detail=f"Failed to restart: {stderr}")
|
||||||
return {"status": "restarted", "name": name}
|
return {"status": "restarted", "name": name}
|
||||||
|
|
@ -404,9 +417,11 @@ def delete_vm(name: str):
|
||||||
for c in get_lxc_containers():
|
for c in get_lxc_containers():
|
||||||
if c["name"] == name:
|
if c["name"] == name:
|
||||||
if c.get('state') == 'running':
|
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:
|
if code != 0:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete: {stderr}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete: {stderr}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
secubox-vm (1.0.0-1) stable; urgency=low
|
||||||
|
|
||||||
* Initial release
|
* Initial release
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
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/iso
|
||||||
install -d -o root -g root -m 755 /var/lib/secubox/vm/disks
|
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 daemon-reload
|
||||||
systemctl enable secubox-vm.service
|
systemctl enable secubox-vm.service
|
||||||
systemctl start secubox-vm.service || true
|
systemctl start secubox-vm.service || true
|
||||||
|
|
|
||||||
|
|
@ -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 -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 -d $(CURDIR)/debian/secubox-vm/usr/lib/systemd/system
|
||||||
install -m 644 debian/secubox-vm.service $(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
|
||||||
|
|
|
||||||
9
packages/secubox-vm/debian/sudoers.d/secubox-vm
Normal file
9
packages/secubox-vm/debian/sudoers.d/secubox-vm
Normal 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 *
|
||||||
|
|
@ -140,13 +140,18 @@ for PKG in "${PACKAGES[@]}"; do
|
||||||
# Use timeout to prevent infinite hangs (5 minutes per package)
|
# Use timeout to prevent infinite hangs (5 minutes per package)
|
||||||
TIMEOUT_CMD="timeout --kill-after=30s 300s"
|
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
|
if [[ "$ARCH" == "arm64" ]] && [[ "$(uname -m)" != "aarch64" ]]; then
|
||||||
# Cross-compile pour arm64
|
# 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
|
BUILD_OK=1
|
||||||
fi
|
fi
|
||||||
else
|
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
|
BUILD_OK=1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user