From 213ff21c0df9c8824770ffd2b904e60a13833536 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 19 Jul 2026 06:54:33 +0200 Subject: [PATCH 1/2] fix(cve-triage): panel delegates WAF generate to root secubox-cvectl via sudo Aggregator-served panel (User=secubox) cannot read/write 0750 root:root /etc/secubox/waf. Both routes now shell out to the root CLI (--json preview, --apply --json write) via scoped exact-command sudoers grants. Adds CLI --json. Co-Authored-By: Gerald KERMA --- packages/secubox-cve-triage/api/main.py | 78 +++++++++++-------- packages/secubox-cve-triage/api/wafgen/cli.py | 49 +++++++++--- packages/secubox-cve-triage/debian/changelog | 13 ++++ packages/secubox-cve-triage/debian/rules | 4 + .../sudoers.d/secubox-cve-triage | 17 ++++ 5 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 packages/secubox-cve-triage/sudoers.d/secubox-cve-triage diff --git a/packages/secubox-cve-triage/api/main.py b/packages/secubox-cve-triage/api/main.py index 9ad6df32..b154923b 100644 --- a/packages/secubox-cve-triage/api/main.py +++ b/packages/secubox-cve-triage/api/main.py @@ -1100,54 +1100,70 @@ async def list_kev_cves(): # ~110 modules — a blocking call inside an `async def` handler would freeze # the whole board. Plain `def` handlers run in FastAPI's threadpool instead. +def _run_cvectl_generate(*extra: str): + """Run the root generator CLI over sudo (exact-command sudoers grant — see + /etc/sudoers.d/secubox-cve-triage). The panel runs as `secubox` in the + aggregator, but /etc/secubox/waf is 0750 root:root, so the whole generate + flow (reading the WAF routes for the presence inventory, reading/writing + waf-rules.json) is root-only. Handlers are plain `def` so this blocking + subprocess runs in FastAPI's threadpool, off the shared aggregator loop.""" + import subprocess + + return subprocess.run( + ["sudo", "-n", "/usr/sbin/secubox-cvectl", "waf-rules", "generate", *extra], + capture_output=True, text=True, timeout=120, + ) + + @app.get("/waf-rules", dependencies=[Depends(require_jwt)]) def waf_rules_preview(): - """Dry-run: what would be generated (kept + rejections). Writes nothing.""" - from .wafgen.generate import generate - from .wafgen.inventory import gather_present - from .wafgen.emit import existing_patterns + """Dry-run preview (kept + rejections), via the root CLI. Writes nothing.""" + import json - present, complete = gather_present() - kept, rejected = generate( - Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete, - existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")), - ) + proc = _run_cvectl_generate("--json") + if proc.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"waf-rules preview failed: {(proc.stderr or proc.stdout).strip()[:500]}", + ) + try: + data = json.loads(proc.stdout) + except ValueError: + raise HTTPException(status_code=500, detail="waf-rules preview: malformed CLI output") return { - "present_count": len(present), - "inventory_complete": complete, - "kept": [ - {"cve": c.cve, "vendor": c.vendor, "product": c.product, "path": c.path} - for c in kept - ], - "rejected": [{"file": n, "reason": r} for n, r in rejected], + "present_count": data.get("present_count", 0), + "inventory_complete": data.get("complete", False), + "kept": data.get("kept", []), + "rejected": data.get("rejected", []), } @app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)]) def waf_rules_generate(): - """Apply: write product_absent_probes (detect mode). Refuses if the - presence inventory is incomplete (fail-safe).""" - from .wafgen.generate import generate - from .wafgen.inventory import gather_present - from .wafgen.emit import existing_patterns, write_category + """Apply the product_absent_probes detect category via the root CLI (sudo). + Refuses (409) when the presence inventory is incomplete (CLI exit 3).""" + import json - present, complete = gather_present() - if not complete: + proc = _run_cvectl_generate("--apply", "--json") + if proc.returncode == 3: raise HTTPException( status_code=409, detail="presence inventory incomplete — refusing (fail-safe)", ) - kept, rejected = generate( - Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete, - existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")), - ) - now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") - write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now) + if proc.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"waf-rules generate failed: {(proc.stderr or proc.stdout).strip()[:500]}", + ) + try: + data = json.loads(proc.stdout) + except ValueError: + data = {} return { "success": True, - "written": len(kept), + "written": data.get("written", 0), "mode": "detect", - "rejected": len(rejected), + "rejected": len(data.get("rejected", [])), } diff --git a/packages/secubox-cve-triage/api/wafgen/cli.py b/packages/secubox-cve-triage/api/wafgen/cli.py index 994b4512..fc12c1da 100644 --- a/packages/secubox-cve-triage/api/wafgen/cli.py +++ b/packages/secubox-cve-triage/api/wafgen/cli.py @@ -13,6 +13,7 @@ sans écrire. --apply écrit la catégorie product_absent_probes en mode detect. from __future__ import annotations import argparse +import json import sys from datetime import datetime, timezone from pathlib import Path @@ -37,6 +38,8 @@ def main(argv: list[str] | None = None) -> int: gen = wsub.add_parser("generate", help="generate product-absent probe rules") gen.add_argument("--apply", action="store_true", help="write the category (default: dry-run, write nothing)") + gen.add_argument("--json", action="store_true", + help="emit a JSON payload instead of the human text (used by the panel)") gen.add_argument("--subset", default=str(SUBSET_DIR)) gen.add_argument("--rules", default=str(RULES_PATH)) args = p.parse_args(argv) @@ -45,22 +48,48 @@ def main(argv: list[str] | None = None) -> int: kept, rejected = generate(Path(args.subset), present, complete, existing=existing_patterns(Path(args.rules))) - print(f"presence union: {len(present)} product(s), complete={complete}") - print(f"kept: {len(kept)} probe(s), rejected: {len(rejected)}") - for c in kept: - print(f" ✓ {c.cve:<16} {c.vendor}/{c.product} {c.path}") - for name, why in rejected: - print(f" ✗ {name}: {why}") + # Shared payload for --json (the panel routes parse this over sudo). + payload = { + "present_count": len(present), + "complete": complete, + "kept": [{"cve": c.cve, "vendor": c.vendor, "product": c.product, + "path": c.path} for c in kept], + "rejected": [{"file": n, "reason": r} for n, r in rejected], + "applied": False, + } + + if not args.json: + print(f"presence union: {len(present)} product(s), complete={complete}") + print(f"kept: {len(kept)} probe(s), rejected: {len(rejected)}") + for c in kept: + print(f" ✓ {c.cve:<16} {c.vendor}/{c.product} {c.path}") + for name, why in rejected: + print(f" ✗ {name}: {why}") if not args.apply: - print("dry-run — nothing written (use --apply to write).") + if args.json: + print(json.dumps(payload)) + else: + print("dry-run — nothing written (use --apply to write).") return 0 + + # --apply: refuse when the presence inventory is incomplete (fail-safe). if not complete: - print("refusing to write: presence inventory incomplete (fail-safe).", - file=sys.stderr) + if args.json: + print(json.dumps({**payload, + "error": "presence inventory incomplete — refusing (fail-safe)"})) + else: + print("refusing to write: presence inventory incomplete (fail-safe).", + file=sys.stderr) return 3 + write_category(Path(args.rules), kept, now=_now()) - print(f"wrote {len(kept)} probe(s) to product_absent_probes (mode=detect) in {args.rules}") + payload["applied"] = True + payload["written"] = len(kept) + if args.json: + print(json.dumps(payload)) + else: + print(f"wrote {len(kept)} probe(s) to product_absent_probes (mode=detect) in {args.rules}") return 0 diff --git a/packages/secubox-cve-triage/debian/changelog b/packages/secubox-cve-triage/debian/changelog index 1676bd09..50bd4e3c 100644 --- a/packages/secubox-cve-triage/debian/changelog +++ b/packages/secubox-cve-triage/debian/changelog @@ -1,3 +1,16 @@ +secubox-cve-triage (1.1.2-1~bookworm1) bookworm; urgency=medium + + * Fix the WAF-probes panel: the aggregator-served routes (User=secubox) could + not read/write the 0750 root:root /etc/secubox/waf tree — the preview 500'd + ("waf empty") and Generate returned a request error (PermissionError). Both + routes now delegate to the root helper `secubox-cvectl waf-rules generate` + (`--json` preview, `--apply --json` write) via scoped, exact-command sudoers + grants (/etc/sudoers.d/secubox-cve-triage). Adds `--json` output to the CLI. + The webui-delegates-to-a-confined-audited-ctl pattern is now a module + guideline (see .claude/MODULE-COMPLIANCE.md + WEBUI-PANEL-GUIDELINES.md). + + -- Gerald KERMA Sun, 19 Jul 2026 09:00:00 +0200 + secubox-cve-triage (1.1.1-1~bookworm1) bookworm; urgency=medium * WAF generator: skip probes already covered by another WAF category diff --git a/packages/secubox-cve-triage/debian/rules b/packages/secubox-cve-triage/debian/rules index 8a0464bb..0cdd788d 100755 --- a/packages/secubox-cve-triage/debian/rules +++ b/packages/secubox-cve-triage/debian/rules @@ -13,3 +13,7 @@ override_dh_auto_install: install -m 0755 sbin/secubox-cvectl debian/secubox-cve-triage/usr/sbin/ install -d debian/secubox-cve-triage/usr/lib/secubox/cve-triage/nuclei-subset cp -r nuclei-subset/. debian/secubox-cve-triage/usr/lib/secubox/cve-triage/nuclei-subset/ + # sudoers: the panel (User=secubox in the aggregator) delegates the + # root-only generate flow to secubox-cvectl via exact-command sudo grants. + install -d debian/secubox-cve-triage/etc/sudoers.d + install -m 0440 sudoers.d/secubox-cve-triage debian/secubox-cve-triage/etc/sudoers.d/secubox-cve-triage diff --git a/packages/secubox-cve-triage/sudoers.d/secubox-cve-triage b/packages/secubox-cve-triage/sudoers.d/secubox-cve-triage new file mode 100644 index 00000000..54778dcd --- /dev/null +++ b/packages/secubox-cve-triage/sudoers.d/secubox-cve-triage @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox CVE-Triage — the WAF product-absent generator panel runs in-process +# under the aggregator (User=secubox), but /etc/secubox/waf is 0750 root:root: +# secubox can neither traverse it (to read haproxy-routes.json for the presence +# inventory, nor waf-rules.json) nor write waf-rules.json. The whole generate +# flow is therefore root-only. Both panel operations are routed through the root +# helper `secubox-cvectl waf-rules generate` via these scoped, exact-command +# grants (no wildcards, no flag escapes) — same pattern as secubox-crowdsec / +# secubox-threats. +# +# GET /api/v1/cve-triage/waf-rules → generate --json (dry-run preview) +# POST /api/v1/cve-triage/waf-rules/generate → generate --apply --json (write detect category) +# +# Operator UI symptom if these entries are missing: the panel's WAF tab shows +# empty (preview 500 / PermissionError) and "Generate" returns a request error. +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-cvectl waf-rules generate --json +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-cvectl waf-rules generate --apply --json From 30931bc53450be2092d09b22aec5a54155b1e8d5 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 19 Jul 2026 07:01:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20guideline=20=E2=80=94=20webui=20del?= =?UTF-8?q?egates=20privileged=20ops=20to=20a=20confined,=20audited=20ctl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webui/API runs unprivileged (secubox); operations touching root-owned config or driving systemd/LXC/apps MUST delegate to the root helper secubox-ctl via scoped exact-command sudoers (shipped by the package), which acts as root and audits each change. Documented in MODULE-COMPLIANCE (authoritative), MODULE-GUIDELINES §7 (CTL), WEBUI-PANEL-GUIDELINES, and wiki/Architecture. Ref: secubox-cvectl fix. Co-Authored-By: Gerald KERMA --- .claude/MODULE-COMPLIANCE.md | 66 +++++++++++++++++++++++++++++++ .claude/WEBUI-PANEL-GUIDELINES.md | 10 +++++ docs/MODULE-GUIDELINES.md | 28 +++++++++++++ wiki/Architecture.md | 27 +++++++++++++ 4 files changed, 131 insertions(+) diff --git a/.claude/MODULE-COMPLIANCE.md b/.claude/MODULE-COMPLIANCE.md index c2d795dc..eaf9b9ee 100644 --- a/.claude/MODULE-COMPLIANCE.md +++ b/.claude/MODULE-COMPLIANCE.md @@ -154,6 +154,72 @@ API MUST listen on Unix socket: `/run/secubox/.sock` --- +## Privileged Operations — webui delegates to a confined, audited `ctl` (Required) + +**Principle.** The webui/API runs **unprivileged** — `User=secubox`, and when +aggregator-served it shares the aggregator's `secubox` context. It therefore +**cannot** read/write root-owned config (e.g. `/etc/secubox/waf` is `0750 +root:root`), and **must not** drive systemd / LXC / applications in-process. + +Every operation that (a) touches root-owned files, or (b) pilots the system or +another app (start/stop/reload a unit, edit a live config, run a privileged +CLI) **MUST be delegated to the module's root helper** `secubox-ctl`. +The webui becomes a thin JWT client; the **`ctl` is the single privileged +surface** — confined (scoped sudoers), auditable (it logs each action to +`/var/log/secubox/audit.log` for security-relevant changes), and it is what +actually causes the system/apps to change. Doing the privileged work in-process +raises `PermissionError` → HTTP 500 ("request error" / empty panel) and +bypasses the audit trail. + +**Sudoers grant (ship it — a missing grant is a compliance failure).** +Ship `sudoers.d/secubox-` and install it `0440` to +`/etc/sudoers.d/secubox-` from `debian/rules`. Every grant is an +**exact-command** match — no wildcards, no shell, no flag escapes — and each is +**documented** (which route uses it, why root): + +``` +# secubox ALL=(root) NOPASSWD: +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-cvectl waf-rules generate --json +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-cvectl waf-rules generate --apply --json +``` + +Validate with `visudo -c -f sudoers.d/secubox-` in CI/before deploy. + +**Panel side.** Call the helper over `sudo -n` from a **plain `def`** handler +(so the blocking subprocess runs in FastAPI's threadpool, off the shared +aggregator loop), and map the `ctl` exit code to an HTTP status: + +```python +def _run_ctl(*args): + import subprocess + return subprocess.run(["sudo", "-n", "/usr/sbin/secubox-ctl", *args], + capture_output=True, text=True, timeout=120) + +@app.post("/", dependencies=[Depends(require_jwt)]) +def do_action(): + import json + p = _run_ctl("", "--apply", "--json") + if p.returncode == 3: # module-defined fail-safe refusal + raise HTTPException(status_code=409, detail="…") + if p.returncode != 0: + raise HTTPException(status_code=500, detail=(p.stderr or p.stdout).strip()[:500]) + return json.loads(p.stdout) # ctl emits a --json payload the panel renders +``` + +**The `ctl` contract.** Runs as root; validates its own inputs; performs the +privileged action; supports a **dry-run default** and an explicit `--apply` for +any state change; offers a machine-readable `--json` output for the panel; +appends an audit line for each security-relevant decision. Reference +implementations: `secubox-cvectl` (WAF rule generation), `secubox-profilectl` +(module on/off actuation). + +**Aggregator note.** An aggregator-served module's route code is imported at +aggregator startup — after deploying new/changed routes you MUST +`systemctl restart secubox-aggregator` for them to appear (a stale aggregator +returns 404 on new routes). Modules on their own socket restart independently. + +--- + ## Debian Package Requirements ### debian/control diff --git a/.claude/WEBUI-PANEL-GUIDELINES.md b/.claude/WEBUI-PANEL-GUIDELINES.md index 1b519153..64d344bb 100644 --- a/.claude/WEBUI-PANEL-GUIDELINES.md +++ b/.claude/WEBUI-PANEL-GUIDELINES.md @@ -164,6 +164,15 @@ Emoji are functional glyphs, not decoration — one per concept, consistent: rows. Cache/stale-while-revalidate on the server for expensive endpoints. - **Destructive actions confirm()** — and warn explicitly when an action can cut the operator's own access (see wireguard admin/mesh bring-down guard). +- **Privileged actions delegate to the module `ctl` — never do them in-process.** + The panel runs unprivileged (`secubox`); anything that writes root-owned config + or drives systemd/LXC/an app must POST to a route that shells out to + `sudo -n /usr/sbin/secubox-ctl …` (scoped exact-command sudoers), which + performs and **audits** the change as root and returns a `--json` payload the + panel renders. In-process privileged work raises `PermissionError` → a 500 the + user sees as "request error"/empty panel. Full contract (sudoers, `ctl` + responsibilities, aggregator-restart caveat): **`.claude/MODULE-COMPLIANCE.md` + → Privileged Operations**. Reference: `cve-triage` / `secubox-cvectl`. ## 7. Checklist for a new/reskinned panel @@ -176,6 +185,7 @@ Emoji are functional glyphs, not decoration — one per concept, consistent: - [ ] `esc()` on all injected values, `data-*` delegation, no onclick interpolation - [ ] responsive `@media (max-width:768px)`, body never scrolls sideways - [ ] menu.d entry present (navbar auto-renders it) +- [ ] privileged actions routed through `sudo secubox-ctl` (scoped sudoers shipped), never done in-process --- diff --git a/docs/MODULE-GUIDELINES.md b/docs/MODULE-GUIDELINES.md index 873ec3ec..f0d0dd0c 100644 --- a/docs/MODULE-GUIDELINES.md +++ b/docs/MODULE-GUIDELINES.md @@ -509,6 +509,34 @@ The `build-image.sh` slipstream loop (`cp /tmp/secubox-debs/secubox-*.deb`) pick Read first: [`docs/grammar.md`](grammar.md) (canonical verbs table) + [`HOWTO-grammar.md`](../HOWTO-grammar.md) (recipe for adding a verb). +### Privilege & delegation — the `ctl` is the single privileged, audited surface + +The WebUI/API runs **unprivileged** (`User=secubox`; aggregator-served modules +share that context). It cannot touch root-owned config (e.g. `/etc/secubox/waf` +is `0750 root:root`) and must not drive systemd/LXC/apps in-process. Every +privileged operation — anything that **causes the system or an app to change**, +or reads/writes root-owned state — is **delegated to `ctl`**, which runs +as root, validates its inputs, performs the change, and **audits** each +security-relevant decision (`/var/log/secubox/audit.log`). + +The panel reaches it over `sudo -n`, gated by a **scoped, exact-command** grant +the package ships (`sudoers.d/secubox-`, installed `0440` to +`/etc/sudoers.d/secubox-` — no wildcards, no flag escapes, one line per +allowed invocation, each documented). The `ctl` offers a `--json` output for the +panel and a **dry-run default / explicit `--apply`** for any state change. + +```text +# sudoers.d/secubox- (0440, exact-command; validated with visudo -c) +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-ctl --json +secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-ctl --apply --json +``` + +A missing grant is a compliance failure: the panel then does the work +in-process, hits `PermissionError` → HTTP 500 (operator sees "request error" / +an empty panel) and bypasses the audit trail. Full contract: +[`.claude/MODULE-COMPLIANCE.md`](../.claude/MODULE-COMPLIANCE.md) → *Privileged +Operations*. Reference: `secubox-cvectl`, `secubox-profilectl`. + ### Mandatory three-fold Every `ctl` exposes: diff --git a/wiki/Architecture.md b/wiki/Architecture.md index b657d2e0..0dd4b5de 100644 --- a/wiki/Architecture.md +++ b/wiki/Architecture.md @@ -126,6 +126,33 @@ POST /api/v1// # Action Authentification JWT obligatoire sur tous les endpoints via `Depends(auth.require_jwt)`. +### Opérations privilégiées — le webui sous-traite au `ctl` confiné et audité (REQUIRED) + +L'API/webui tourne **sans privilège** (`User=secubox`, et servie in-process par +l'aggregator elle partage son contexte `secubox`). Elle ne peut donc ni lire/écrire +la config root (ex. `/etc/secubox/waf` est `0750 root:root`), ni piloter +systemd/LXC/une app en direct. + +> **Principe.** Toute opération qui touche un fichier root ou qui **cause au +> système / à une app** (start/stop/reload d'une unit, écriture d'une config live, +> exécution d'un binaire privilégié) est **déléguée au helper root +> `secubox-ctl`**. Le webui devient un client JWT léger ; le **`ctl` est la +> surface privilégiée unique** — confinée (sudoers scopé, commande exacte), +> **auditée** (`/var/log/secubox/audit.log`), et c'est elle qui pilote réellement. + +Chaîne : `panel (JWT) → route def → sudo -n secubox-ctl --json →` +le `ctl` (root) valide, agit, audite, renvoie un payload `--json` que le panel rend. +Le grant sudoers (`/etc/sudoers.d/secubox-`, `0440`, commande exacte, sans +wildcard) est **livré par le paquet** et documenté. Symptôme si absent : +`PermissionError` → 500 (« request error » / panneau vide). + +Note aggregator : une route servie in-process par l'aggregator n'apparaît qu'après +`systemctl restart secubox-aggregator` (le code module est importé au démarrage). + +Réf. d'implémentation : `secubox-cvectl` (génération de règles WAF), +`secubox-profilectl` (bascule on/off des modules). Contrat complet : +[`.claude/MODULE-COMPLIANCE.md`](../.claude/MODULE-COMPLIANCE.md) → *Privileged Operations*. + ### Dual-vhost split — REQUIRED pour les modules avec UI applicative Un module qui embarque une application avec sa propre interface web