Commit Graph

2811 Commits

Author SHA1 Message Date
9581e0edd6 docs(plan): profiles tiers + installer export — 3 tasks TDD
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 11:07:29 +02:00
7803235ec7 docs(profiles): spec for functional tiers + installer export
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 11:04:08 +02:00
CyberMind
a64257ca58
Merge pull request #876 from CyberMind-FR/feat/waf-product-absent-generator
Some checks are pending
License Headers / check (push) Waiting to run
feat(cve-triage): WAF product-absent rule generator (detect-mode)
2026-07-18 08:09:44 +02:00
dc190aa049 fix(cve-triage): is_kev reads info.tags (real nuclei format) + CLI main() tests
is_kev() read tags at the template's top level while extract() already read
classification/metadata from under info: — real upstream nuclei-templates
nest tags under info: too, so a real curation run silently produced zero
candidates. Read info.tags first with a top-level fallback for legacy
fixtures, and realign the two vendored KEV templates to the real nested
format. Also add direct coverage of cli.main() (dry-run no-op, --apply
writing only product_absent_probes, and the incomplete-inventory fail-safe
returning 3 without touching the rules file).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 08:06:20 +02:00
5f459b1de1 feat(cve-triage): panel tab for generated WAF probes (dry-run + apply)
GET /waf-rules previews kept + rejections (writes nothing); POST /waf-rules/
generate applies (detect mode), refusing on an incomplete presence inventory.
Cyan hybrid-skin tab, esc()'d, sbx_token auth. Handlers are plain def (not
async def) since gather_present/generate/write_category do blocking work and
this module runs in-process inside the aggregator's shared event loop.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:57:07 +02:00
fedefd36a4 feat(cve-triage): secubox-cvectl waf-rules generate + vendored Nuclei subset
CLI (dry-run by default, --apply to write) orchestrates extract + presence +
emit. Ships a small vendored Nuclei subset (MIT, with LICENSE) of appliance/KEV/
URL-extractable templates + an offline maintainer curation script. Adds
python3-yaml to Depends. --apply refuses to write when the presence inventory is
incomplete (fail-safe).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:46:16 +02:00
f16c23e043 fix(cve-triage): preserve waf-rules.json mode on wafgen rewrite
tempfile.mkstemp() always creates its temp file 0600 regardless of
umask; os.replace() would make waf-rules.json inherit that, silently
tightening it from its live mode (typically 0644) on every regen and
breaking hot-reload if the WAF reader runs as a different user.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:41:00 +02:00
8becec8480 fix(cve-triage): unique tmp filename for wafgen write_category
Two concurrent regenerators (CLI cron + panel) writing waf-rules.json
both used a single fixed .json.tmp name, letting one process's
os.replace() promote the other's in-flight content — a silent lost
update. Use tempfile.mkstemp() in the same directory to get a unique
temp path per call, and clean it up on any failure before the swap.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:35:22 +02:00
eb727669e2 feat(cve-triage): emit product_absent_probes — detect-only, additive, atomic
Writes one category in detect mode (never escalate/block; promotion is human),
replacing only its own key so the 17 shipped categories are preserved, idempotent,
atomic temp+os.replace. The pattern is the regex-escaped path — recognise the
probe, don't generalise the vuln.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:27:34 +02:00
525151ac92 fix(cve-triage): guard routes JSON against non-dict payloads
gather_present() crashed with an uncaught TypeError when the WAF routes
file contained valid-but-non-dict JSON (null, a number, a string), since
`for domain in <parsed>` is not covered by the (OSError, JSONDecodeError)
except clause. Mirror the existing menu.d isinstance(dict) guard: parse
into a local, then require a dict before iterating keys, else
complete=False without iterating.

Also cover the previously-untested menu.d fail-safe paths (corrupt entry
-> incomplete, absent menu_dir -> stays complete) and strengthen the
dpkg-failure test with non-empty output to prove a failed dpkg run
contributes nothing to the present set.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:23:38 +02:00
ac60e14539 feat(cve-triage): gather the presence union (dpkg + vhosts + modules)
Returns (present_set, complete). The vhosts source is what catches nextcloud/
gitea running in LXC — absent from host dpkg but routed. complete=False on any
source failure so the oracle refuses to generate (fail-safe present).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:17:40 +02:00
ad23a825e7 feat(cve-triage): presence oracle — appliance allowlist AND absence
Two barriers, both required: (1) the vendor is a known-absent appliance family;
(2) the product is absent from the presence union AND the union was fully read.
In doubt (incomplete inventory) → present → generate nothing. Better to miss a
probe than ban a real user.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:11:57 +02:00
219a481ee6 fix(cve-triage): reject residual {{...}} placeholders after BaseURL strip
extract() only stripped the leading {{BaseURL}}, so a second occurrence
elsewhere in the path (e.g. a query string) survived into the emitted
Candidate.path as a literal fragment. Add a belt-and-suspenders check
that rejects any path still containing "{{" after the strip, and cover
both this gap and the previously untested not-anchored-at-/ rejection
with new tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:09:09 +02:00
47085601ca feat(cve-triage): extract a URL probe from a Nuclei template
Pure function: only a single-http, single-path, BaseURL-only GET/POST becomes a
candidate. raw/unsafe/multi-step/body-matcher templates are rejected with a
reason — sbxwaf matches URL regex, so a non-URL exploit can't become a rule.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 07:01:16 +02:00
9325376034 docs(plan): générateur WAF product-absent — 6 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
3 modules purs (extract, presence, inventory) + emit additif/atomique + CLI
dry-run + panel. Les 2 barrières (allowlist appliance ET union dpkg∪vhosts∪
modules) avec fail-safe présent. Sortie toujours detect (jamais armée), preuve
par mutation à chaque tâche. Ajoute python3-yaml. 28 tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 05:30:01 +02:00
ed9382b30b docs(spec): générateur de règles WAF "produit absent"
Fiche les scanners sondant des exploits de produits que la box ne fait PAS
tourner (F5/PAN-OS/Ivanti): 0 faux positif car le produit est absent. Principe:
honeypot=DPI, rendre le bruit pertinent. Vit dans cve-triage (a déjà l'inventaire
dpkg + KEV). Source = sous-ensemble Nuclei vendored (MIT, pas de git-pull
runtime). Sortie = catégorie product_absent_probes en mode DETECT, additive/
idempotente, jamais les 17 catégories existantes, jamais armée (promotion
escalate = humain).

Oracle de présence = DEUX barrières: (1) allowlist de familles d'appliances
qu'une box Debian n'est jamais; (2) absent de dpkg ∪ vhosts routés ∪ modules —
crucial car nextcloud/gitea sont en LXC, absents du dpkg hôte mais routés
(vérifié gk2). Règle de sûreté: dans le doute, PRÉSENT (rater une sonde est
sans conséquence; bannir un vrai user ne l'est pas). S'appuie sur detect
(PR#872) + escalate (PR#873) livrés.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 05:13:23 +02:00
192f059df6 Merge fix/podcaster-import-staging-ssd: stage YouTube imports on the SSD (#874)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 19:34:46 +02:00
a9e0e97ed3 fix(podcaster): stage YouTube imports on the SSD, not the eMMC root
run_import computed its staging workdir as Path(media_dir).parent /
youtube-import. media_dir is the symlink …/podcaster/media -> the SSD; taking
.parent BEFORE resolving lands staging back on the eMMC root. A large playlist
import then downloaded ~1.4G into eMMC staging, filled / to 99%, and stalled
before moving anything to the SSD store — a recurring failure a symlink alone
never fixed because the path is computed in code.

.resolve() follows the symlink so staging and store share the SSD, and it
derives from the real path so a package reinstall that re-lays the symlink
cannot revert it. Verified: with media -> SSD, the workdir resolves onto the SSD.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 19:30:35 +02:00
fd8fe87a4c Merge feat/sbxwaf-mode-escalate: escalate mode — observe then ban a persistent scanner (#873)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:36:19 +02:00
772590b0d1 fix(sbxwaf): log an escalate ban to journald, like the block path
The final review found escalate bans were visible in the dashboard JSON
(action=banned) but silent in journald — the block path emits a THREAT line on
ban, the escalate branch did not. An operator tailing journalctl -u
secubox-waf-ng for THREAT would miss the very scanners escalate exists to catch.
One line, mirroring the block path. No behaviour change; tests + vet clean.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:35:26 +02:00
6c85979070 feat(sbxwaf): escalate path — observe on a separate counter, then ban
A separate escalateBan (default 24h/3, --escalate-window/--escalate-threshold)
counts hits on escalate-mode categories. Below the threshold the request passes
and logs action=detect (like detect); at the threshold it bans exactly as the
block path does (crowdsec.Report + writeBan) — but gated on escalateBan, never
s.ban, so the slow-scanner signal never mixes with the block ban. A nil
escalateBan degrades to detect (observe, never ban), never to block.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:16:58 +02:00
af832de5a1 feat(sbxwaf): recognise the escalate mode value
escalate is a first-class known mode alongside block/detect. Absent/unknown
still fail closed to block; escalate itself must not be treated as unknown.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:09:54 +02:00
3549e2f743 docs(plan): sbxwaf mode escalate — 3 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
Task 1: reconnaître modeEscalate (fail-closed préservé). Task 2: champ
escalateBan (compteur SÉPARÉ, défauts 24h/3), branche self-contained qui observe
(action=detect, passe) sous le seuil et banne au seuil via crowdsec+writeBan —
gatée sur escalateBan, JAMAIS s.ban, sinon les signaux se mélangent. nil →
comme detect. Task 3: déploiement + preuve live (observation; le ban au seuil
est prouvé déterministe­ment par les tests unitaires).

Un seul site d'appel de Match. Mutation obligatoire par tâche.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:07:05 +02:00
6d0e18eab9 docs(spec): sbxwaf mode escalate — observer puis bannir un scanner récidiviste
Moitié "mécanisme" du besoin (l'autre moitié = générateur Nuclei, spec B).
escalate = detect jusqu'à N sondes, puis block+cscli. Réutilise entièrement
Ban.Record + CscliReporter + les chemins detect/block livrés (PR #872) — la
seule pièce neuve est une 2e instance Ban avec fenêtre LONGUE (défaut 24h/3),
car un scanner lent échappe à la fenêtre 5min du mode block.

Clarification (retour user): escalate n'affaiblit PAS l'autoban. block reste le
défaut et banne les agressions immédiatement; les 17 catégories restent en
block. escalate AJOUTE l'autoban de l'adversaire patient que le ban rapide rate.

Deux limites posées franchement: scanner distribué (1 IP/sonde) n'atteint jamais
N; le ban escalate = drop nft de tout le trafic → les règles générées (spec B)
naissent en detect pur et sont promues à la main après vérif 0 FP.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 18:02:34 +02:00
3de66fb05c docs(spec): CVE — distinguer les deux buts aux filtres opposés
Correction de l'utilisateur: un CVE sert à deux choses aux filtres INVERSES.
PROTÉGER (ce spec): KEV ∧ paquet installé ∧ exposé — suis-je vulnérable.
FICHER (générateur WAF): le motif vise un produit ABSENT ⟹ 0 faux positif —
qui me sonde. Personne n'a de raison légitime de demander /mgmt/tm/util/bash
sur une box sans F5; c'est un signal pur qu'on ne bloque pas (mode detect) mais
qu'on fiche. Ce spec ne traite QUE PROTÉGER; le filtre fingerprinting appartient
au générateur, qui s'appuie sur le mode detect livré (PR #872).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:37:03 +02:00
8f9cdeaaec Merge feat/sbxwaf-mode-detect: per-category detect mode + hot-reload + honest counters (#872)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:34:56 +02:00
81a50f6307 fix(waf): keep detect-mode matches out of the block counters
The final review found the producer/consumer halves disagreed: sbxwaf writes
action="detect" for a matched-but-passed request, but the dashboard's
_get_threat_stats counted every log line as a threat and set
blocked_24h = total_threats. The instant an operator armed a detect category,
every observed-but-passed request would inflate the "blocked" count and the
category/severity donuts — the exact conflation action="detect" exists to
prevent.

Detect records now feed a separate observed_threats/observed_today counter
(persisted like the rest, since the reader is incremental) and are excluded
from total_threats and the block breakdown. CrowdSec's own alert overlay is a
distinct source (real bans) and is untouched.

Two tests, mutation-verified (removing the exclusion makes both fail). They
live in a new file because the pre-existing test_stats_cache.py fails to
collect on master (stale `import StatsCache`, since renamed to a no-op shim) —
not touched here.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:30:46 +02:00
f3bb74e0a0 fix(sbxwaf): wire rules hot-reload into the request path
The design spec claimed a rules change needs no restart. It was false:
Rules.Maybe() was defined but never called — only s.routes.Maybe() was. So an
operator flipping a category to detect saw nothing until the WAF fronting every
vhost was restarted.

One line beside the existing routes reload check. Match takes an RLock, the
reload Apply takes the Lock — race-safe (go test -race clean), same one-stat
cost routes already pays. Server-level test proves a live block->detect edit
takes effect across two requests without a restart; mutation-verified (removing
the line makes it fail).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 17:30:46 +02:00
2f44ee7d55 feat(sbxwaf): honour detect mode — match, log, pass, never ban
Match now reports the category mode; main.go's single call site treats a detect
hit as observation: the threat record is written with action=detect and the
request continues to the upstream. No ban, no CrowdSec report, no nft decision —
a detect category is as harmless as enabled:false, minus the log line.

action=detect keeps stats honest: without it, 'blocked' and 'would have blocked'
would be indistinguishable.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:59:25 +02:00
2dc2654bfe feat(sbxwaf): parse a per-category mode (default block, fail closed)
Absent, empty or null mode means block: the 17 shipped categories carry no
mode and must keep blocking. An unknown value also means block, loudly — a
typo like 'monitor' must never silently drop a protection nor downgrade it to
observation.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:48:19 +02:00
a1b9add575 docs(plan): sbxwaf mode detect — 3 tâches TDD
Some checks are pending
License Headers / check (push) Waiting to run
Task 1: parser `mode` par catégorie (défaut block, fail-closed sur valeur
inconnue). Task 2: Match remonte le mode; le chemin detect journalise
action=detect et laisse passer, sans jamais bannir. Task 3: déploiement gk2 +
preuve sur trafic réel (bascule cve_2024 en detect à chaud, puis restauration).

Un seul site d'appel de Match (main.go:376) — rayon de souffle contenu.
Chaque tâche exige une preuve par mutation: casser le comportement, constater
l'échec du test, restaurer.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:42:20 +02:00
572d6eece9 docs(spec): sbxwaf mode detect — essayer une règle avant de l'armer
Né de la question "peut-on dériver des règles WAF depuis les CVE ?", dont
l'exploration a montré que ce mode en est le prérequis bloquant. Mais il vaut
seul, et il est livré seul.

Aujourd'hui rules.go n'a que deux états: catégorie sautée, ou évaluée ET
bloquante. Toute nouvelle règle est donc un pari armé en production devant
Nextcloud/PeerTube/Gitea — et une régression WAF a déjà servi du JS vide en
silence (YaCy blanc).

Champ `mode: detect|block` par catégorie. Absent ⇒ block: les 17 catégories
existantes ne changent pas. Un défaut detect transformerait le WAF en
observateur muet — une panne de sécurité silencieuse.

Un match en detect est compté et journalisé (action: "detect") mais laisse
passer et ne déclenche AUCUN ban. Invariant: une catégorie en detect doit être
aussi inoffensive qu'un enabled:false, à la journalisation près.

Donne enfin l'instrument pour prouver le poids mort déjà diagnostiqué: 39/149
patterns voip/xmpp inutiles en HTTP, et une catégorie cve_2024 visant PAN-OS,
Ivanti et F5 — trois produits absents de cette box Debian.

Générateur CVE→règles explicitement hors périmètre (chantier suivant): source
Nuclei (MIT vérifié; les CVE ne contiennent aucun exploit — vérifié sur le KEV),
filtre KEV ∧ installé ∧ exposé, expiration à la correction.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:36:02 +02:00
a33e3c0092 docs(spec): alertes CVE récurrentes et réelles
Le module secubox-cve-triage existe déjà et fait plus que prévu (dpkg, NVD,
CVSS, EPSS réel, flux KEV CISA, triage, panel). Il lui manque une gâchette
(aucun .timer), une voix (zéro notify) et un filtre anti-bruit. Ce spec livre
l'appelant, pas un module.

Filtre: KEV ∧ installé ∧ (exposé public ∨ rançongiciel) — un ET, pas un OU.
L'exposition vient de l'inventaire secubox-profiles livré ce jour (16 modules
publics sur 187). Anti-bruit: alerte sur le delta, ré-alerte sur escalade,
acquittement, digest, plancher anti-tempête.

CrowdSec CTI écarté: clé obligatoire, 40 requêtes/mois en gratuit, dépendance
cloud pour une donnée que le KEV CISA fournit librement (1647 CVE, sans clé).

XMPP chiffré honnêtement: le module jabber ne sait pas envoyer: c'est un
gestionnaire Prosody. Le sink exige slixmpp (présent dans bookworm), un compte
bot, un secret et un client — une tâche isolée, pas un branchement.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:17:50 +02:00
ea25a23831 Merge feat/profiles-webui: profiles panel + own-socket API + 46s->0.03s status (#871)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:16:08 +02:00
b7bd56b23a perf(profiles): batch the probes and cache the status (46s -> 0.03s)
GET /status took 46 SECONDS on the board and the panel was unusable.

Cause: observe() ran 3 subprocesses per module (is-enabled, is-active, show)
plus lxc-info, and load_routes() re-read the routes file once per module. With
187 modules that is ~560 spawns at ~29ms each, on a box running 118 services at
load 5.4 on 4 cores.

observe_all() now issues ONE systemctl-show for every unit (measured: 187 units
in 0.28s), ONE LXC enumeration, and reads routes once. RSS comes from
/proc/<MainPID>/status - a file read, not a spawn. web.py serves /status from a
60s cache refreshed off the event loop (asyncio.to_thread), per the project's
own "double caching" pattern; the CLI still computes live, since a one-shot
command gains nothing from staleness. /status reports cached_at + age_s rather
than pretending to be live.

Two traps, both covered by tests:
- A bare template unit (name@.service) makes systemctl-show fail for the WHOLE
  batch - it returned 40 blocks of 188. Templates are excluded.
- A unit missing from the batch output must become enabled=None/active=None
  (unknown), never False. This branch has fixed five defects of that class; a
  batch is a fresh way to commit the same one.

Measured live on gk2 after deploy: 46s -> 2.57s cold, 0.025s warm; 187 modules;
sockets 108 before and after; panel 1.6ms and API 94ms through nginx.

The honest-unknown rule is visible in the real data: totals went from
off:66/unknown:0 to off:62/unknown:4 - the old code was asserting "off" for the
4 template units it could not actually resolve.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 15:10:17 +02:00
dfb01df1bb fix(profiles): ship the nginx route to the directory nginx actually includes
Two bugs that cancelled into a 404, both found by deploying rather than by tests:

1. The drop-in was installed to /etc/nginx/secubox.d/, which nothing includes.
   The admin vhost includes /etc/nginx/secubox-routes.d/*.conf. secubox.d holds
   138 files and is dead — a route dropped there is inert.

2. proxy_pass ended in ':/', which strips the prefix. The app registers the FULL
   path (@app.get('/api/v1/profiles/status')), so stripping handed it /status —
   a route it does not know. Prefix must be preserved for a dedicated socket
   whose app owns its full path.

Verified live through nginx and public HTTPS: panel and all three API routes 200.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:45:21 +02:00
cedc012051 fix(profiles): load_routes must not crash on an untraversable parent
Found by deploying to the real board, not by tests: GET /status returned 500.

/etc/secubox/waf is 0750 root:root while haproxy-routes.json inside it is 0644
— the file is world-readable, the directory is not traversable by the secubox
user the service runs as. Path.exists() raises PermissionError in that case, and
it sat OUTSIDE the try that already guarded read_text.

This is the fifth instance of the same class on this branch. The previous four
were 'unknown must not become a definite answer'; this one is its twin:
unknown must not become a crash either. An unreadable routes file now yields
None (undeterminable) as intended, so /status degrades to 'exposure unknown'
instead of 500ing the whole panel.

The directory permissions are the WAF module's business and are left alone —
this module's job is to survive them.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:40:21 +02:00
78f5e80747 feat(profiles): webui surface — own-socket API + hybrid-dark panel
Phase 1 stays read-only for actuation: no apply endpoint anywhere. Adds
api/web.py (FastAPI, JWT-gated) exposing status/profiles/pins/diff plus the
two writes Phase 1 allows — profile membership and pins — both atomic
(temp file + os.replace) and limited to /etc/secubox/profiles/*.toml.

A pin that would switch a protected module (auth/aggregator/core/nginx/
firewall/profiles) off is refused with HTTP 409 independently of
diff.plan_changes, before anything touches disk — proven load-bearing by
temporarily removing the guard and watching the test fail, then restoring
it and watching it pass.

Runs on its own service + socket (/run/secubox/profiles.sock), never via
the aggregator. Probe results that couldn't be determined surface as
"unknown", never silently downgraded to "off" (observe.is_on() collapses
None to False by design for diff's actionable decision, which would
misrepresent an indeterminate probe here).

www/profiles/index.html follows WEBUI-PANEL-GUIDELINES.md (hybrid-dark,
shared sidebar, data-* delegation, esc() on all injected values) with
stat grid, per-category/runtime/exposure aggregates, per-module pin/
membership controls, and a diff view explicitly labelled not-applied.

94/94 tests pass (73 pre-existing + 21 new, packages/secubox-profiles/tests).
2026-07-17 13:35:44 +02:00
2dff4868fe Merge feat/profiles-phase1: module inventory, profiler and diff (read-only)
Some checks are pending
License Headers / check (push) Waiting to run
Phase 1 of the profile system: flat-file manifests, a scan profiler that derives
them from reality and measures cost, pure state resolution, an ordered diff, and
the secubox-profilectl CLI. apply deliberately does not exist — it arrives in
Phase 3 with 4R snapshots, sequential application and audit; a test locks its
absence.

73 tests. Six review rounds caught five defects the tests could not: vacuous
negative tests short-circuited by an earlier guard, a failed probe fabricating a
definite False (three separate instances), a taxonomy guard defeated by a string
collision, a bypassable protected core, and an unbuildable package.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:19:01 +02:00
fc28ab7678 fix(profiles): unbuildable package, rc collision on scan, data-driven protection
C1: drop debian/compat (kept debhelper-compat (= 13) Build-Depends) — the
package was the only one in the repo carrying both, which debhelper refuses
to build.

C2: cli._run() now returns rc=None for "did not execute" (OSError/timeout),
mirroring observe._run_cmd's contract. _cmd_scan checks rc for both
systemctl list-unit-files and lxc-ls and aborts rather than silently
deriving LXC modules as runtime="native" from empty output; a non-root
invocation (rc=0, empty output) is refused explicitly since it is
indistinguishable from "no containers" by rc alone.

C3: PROTECTED_IDS moves from scan.py to manifest.py (a schema property, not
a profiler detail) and load_manifest() now forces protected=True for those
ids regardless of what a shipped manifest declares, closing the gap where a
sloppy module package could silently unprotect auth/aggregator/core/etc.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 13:14:02 +02:00
310f4713f5 Merge fix/users-secret-leak: stop leaking password hashes + TOTP secrets (#870)
Some checks are pending
License Headers / check (push) Waiting to run
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:59:25 +02:00
6f8a04fcad fix(users): stop leaking password hashes and TOTP secrets to any client
GET /api/v1/users/users, /user/{username} and /export returned raw records
from users.json, which holds the argon2id password hash, the TOTP *secret*,
and the argon2id hashes of the TOTP backup codes.

All three routes are gated by `require_jwt` — ANY authenticated account, not
just an admin — so the lowest-privilege user could harvest every other
account's credentials. The TOTP secret alone lets an attacker mint valid
6-digit codes forever: a complete MFA bypass. This is a privilege-escalation
vector on a product whose own charter forbids secrets in clear.

/export was the worst: its comment said "Remove sensitive data" while dropping
only `provision_results`. A comment asserting a guarantee the code does not
provide is worse than no comment — it stops reviewers from looking.

Add redact_user() as the single place that decides what a client may see, and
route all three through it. `totp` is kept but reduced to its non-secret
metadata, and its PRESENCE is preserved: the webui shows the MFA badge on a
truthiness test (`u.totp_enabled || u.totp || u.mfa_enabled`), so dropping the
key would blank the badge and always emitting one would light it up for users
who never enrolled.

It lives in its own module rather than main.py because main.py reads the config
at import, so importing it requires read access to /etc/secubox/secubox.conf —
a security rule must stay testable without privileges.

Verified live on gk2 across all three routes: no argon2 hash, no TOTP secret,
no backup codes; totp_enabled still correct per account (admin true, gk2 and
operator false). Mutation-checked: restoring the passthrough fails 5 of the 9
tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:58:38 +02:00
041b2d63ed fix(profiles): stop scan from silently downgrading exposure on unreadable routes
_cmd_scan collapsed load_routes()'s None (routes file present but
unreadable/corrupt, indeterminate) into the same set() as a genuinely
absent routes file, so a corrupt WAF routes file silently derived
every routed module's exposure down from public to lan/internal with
no operator feedback — and since a written manifest is authoritative,
the degraded value would stick until a manual --force rescan. scan now
warns on stderr in the indeterminate case only, naming the consequence
and the remedy, and stays silent when routes are genuinely absent.
_cmd_status/_cmd_diff were already correct and are untouched. Also
drops the unused pins_file binding in _cmd_status.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:49:38 +02:00
536eac0977 feat(profiles): CLI secubox-profilectl (scan/status/diff) + paquet
Phase 1 en lecture seule : apply n'existe pas, un test le verrouille.
Vérifié sur gk2 : scan découvre les modules et status affiche taxonomie +
coût RSS, sans qu'aucun changement de service ne soit attribuable à ces
commandes (fluctuation ±1 confirmée comme bruit préexistant des timers
oneshot du board, pas causée par cet outil).

Fix additif : load_routes() peut renvoyer None (fichier de routes corrompu),
scan._cmd_scan retombe sur set() plutôt que de planter dans discover().

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:43:20 +02:00
775175f0dc fix(profiles): stop menu.d mesh from colliding with deployment mesh
_category() let menu.d's UI catch-all "mesh" (declared by media modules
like peertube/lyrion) pass through as deployment category "mesh" simply
because the token matched — corrupting per-category stats. Replace the
membership check with an explicit MENU_CATEGORY_MAP, defaulting unknown
tokens to the neutral "infra" instead of a confidently wrong category.

Also make _toml_str escape control characters (\n \t \r) per TOML basic-
string rules — an unescaped literal newline in a field value previously
emitted a file tomllib itself could not reparse. Add roundtrip tests for
quote/backslash and control-character values, and replace a vacuous
"category in the whole enum" assertion with the exact expected value.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:29:33 +02:00
ec589adfca feat(profiles): profiler scan — dérive les manifestes du réel
134 manifestes ne s'écrivent pas à la main : on les dérive des units, LXC,
routes WAF et menu.d, puis l'opérateur corrige — et scan n'écrase plus.
Émetteur TOML écrit à la main (tomllib est en lecture seule), validé par
aller-retour avec le loader.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:19:16 +02:00
cb691e3577 feat(profiles): diff ordonné désiré vs réel
Ordre = décision de sûreté : stops avant starts (la box a ~2 Go libres, un pic
d'allumage la tuerait), stops par priorité croissante, starts par décroissante.
Un pin 'off' sur un module protégé est refusé, pas averti.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:11:45 +02:00
aa430739e6 fix(profiles): stop fabricating False in observe() systemd/routes branches
_run_cmd returned rc=1 both when systemctl genuinely answered "disabled"
and when the command failed to execute (OSError/timeout) — the latter
then read as a fabricated `enabled=False`/`active=False`, which a later
phase would treat as "off" and wrongly start. _run_cmd now returns
rc=None as the not-executed sentinel, and observe() maps it to None
instead of False for both enabled and active.

Likewise load_routes() folded an unreadable/corrupt routes file into the
same "no routes" set() as a genuinely absent file, producing a fabricated
portal_routed=False. It now returns None when the file exists but can't
be read/parsed, and observe() propagates that as portal_routed=None.

Also restores the `m: Manifest` type annotation on observe() that had
been dropped.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 12:04:55 +02:00
e0106674be feat(profiles): sondes d'état réel en lecture seule
L'inconnu reste None, jamais False : lxc-info échoue depuis le contexte non
privilégié de l'API (lxc_state='absent' alors que le service répond), et un
False inventé produirait une fausse décision d'allumage.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:56:13 +02:00
ea2f622503 feat(profiles): résolution de l'état désiré (protected > pin > profil > off)
Profils exhaustifs + pins persistants. resolve() est pure : c'est la règle la
plus critique du système, elle doit être testable sans board.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-17 11:51:20 +02:00