Merge pull request #884 from CyberMind-FR/fix/cve-triage-panel-apply-sudo
Some checks are pending
License Headers / check (push) Waiting to run

fix(cve-triage): WAF panel delegates to root secubox-cvectl + webui→ctl guideline (1.1.2)
This commit is contained in:
CyberMind 2026-07-19 07:14:23 +02:00 committed by GitHub
commit 753524c38a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 251 additions and 41 deletions

View File

@ -154,6 +154,72 @@ API MUST listen on Unix socket: `/run/secubox/<module>.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-<module>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-<module>` and install it `0440` to
`/etc/sudoers.d/secubox-<module>` 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: <absolute-path> <exact args...>
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-<module>` 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-<module>ctl", *args],
capture_output=True, text=True, timeout=120)
@app.post("/<action>", dependencies=[Depends(require_jwt)])
def do_action():
import json
p = _run_ctl("<verb>", "--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 Package Requirements
### debian/control ### debian/control

View File

@ -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. rows. Cache/stale-while-revalidate on the server for expensive endpoints.
- **Destructive actions confirm()** — and warn explicitly when an action can cut - **Destructive actions confirm()** — and warn explicitly when an action can cut
the operator's own access (see wireguard admin/mesh bring-down guard). 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-<module>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 ## 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 - [ ] `esc()` on all injected values, `data-*` delegation, no onclick interpolation
- [ ] responsive `@media (max-width:768px)`, body never scrolls sideways - [ ] responsive `@media (max-width:768px)`, body never scrolls sideways
- [ ] menu.d entry present (navbar auto-renders it) - [ ] menu.d entry present (navbar auto-renders it)
- [ ] privileged actions routed through `sudo secubox-<module>ctl` (scoped sudoers shipped), never done in-process
--- ---

View File

@ -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) + Read first: [`docs/grammar.md`](grammar.md) (canonical verbs table) +
[`HOWTO-grammar.md`](../HOWTO-grammar.md) (recipe for adding a verb). [`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 `<module>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-<module>`, installed `0440` to
`/etc/sudoers.d/secubox-<module>` — 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-<module> (0440, exact-command; validated with visudo -c)
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-<module>ctl <verb> --json
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-<module>ctl <verb> --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 ### Mandatory three-fold
Every `<module>ctl` exposes: Every `<module>ctl` exposes:

View File

@ -1100,54 +1100,70 @@ async def list_kev_cves():
# ~110 modules — a blocking call inside an `async def` handler would freeze # ~110 modules — a blocking call inside an `async def` handler would freeze
# the whole board. Plain `def` handlers run in FastAPI's threadpool instead. # 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)]) @app.get("/waf-rules", dependencies=[Depends(require_jwt)])
def waf_rules_preview(): def waf_rules_preview():
"""Dry-run: what would be generated (kept + rejections). Writes nothing.""" """Dry-run preview (kept + rejections), via the root CLI. Writes nothing."""
from .wafgen.generate import generate import json
from .wafgen.inventory import gather_present
from .wafgen.emit import existing_patterns
present, complete = gather_present() proc = _run_cvectl_generate("--json")
kept, rejected = generate( if proc.returncode != 0:
Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete, raise HTTPException(
existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")), 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 { return {
"present_count": len(present), "present_count": data.get("present_count", 0),
"inventory_complete": complete, "inventory_complete": data.get("complete", False),
"kept": [ "kept": data.get("kept", []),
{"cve": c.cve, "vendor": c.vendor, "product": c.product, "path": c.path} "rejected": data.get("rejected", []),
for c in kept
],
"rejected": [{"file": n, "reason": r} for n, r in rejected],
} }
@app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)]) @app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)])
def waf_rules_generate(): def waf_rules_generate():
"""Apply: write product_absent_probes (detect mode). Refuses if the """Apply the product_absent_probes detect category via the root CLI (sudo).
presence inventory is incomplete (fail-safe).""" Refuses (409) when the presence inventory is incomplete (CLI exit 3)."""
from .wafgen.generate import generate import json
from .wafgen.inventory import gather_present
from .wafgen.emit import existing_patterns, write_category
present, complete = gather_present() proc = _run_cvectl_generate("--apply", "--json")
if not complete: if proc.returncode == 3:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="presence inventory incomplete — refusing (fail-safe)", detail="presence inventory incomplete — refusing (fail-safe)",
) )
kept, rejected = generate( if proc.returncode != 0:
Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete, raise HTTPException(
existing=existing_patterns(Path("/etc/secubox/waf/waf-rules.json")), status_code=500,
) detail=f"waf-rules generate failed: {(proc.stderr or proc.stdout).strip()[:500]}",
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") )
write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now) try:
data = json.loads(proc.stdout)
except ValueError:
data = {}
return { return {
"success": True, "success": True,
"written": len(kept), "written": data.get("written", 0),
"mode": "detect", "mode": "detect",
"rejected": len(rejected), "rejected": len(data.get("rejected", [])),
} }

View File

@ -13,6 +13,7 @@ sans écrire. --apply écrit la catégorie product_absent_probes en mode detect.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 = wsub.add_parser("generate", help="generate product-absent probe rules")
gen.add_argument("--apply", action="store_true", gen.add_argument("--apply", action="store_true",
help="write the category (default: dry-run, write nothing)") 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("--subset", default=str(SUBSET_DIR))
gen.add_argument("--rules", default=str(RULES_PATH)) gen.add_argument("--rules", default=str(RULES_PATH))
args = p.parse_args(argv) 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, kept, rejected = generate(Path(args.subset), present, complete,
existing=existing_patterns(Path(args.rules))) existing=existing_patterns(Path(args.rules)))
print(f"presence union: {len(present)} product(s), complete={complete}") # Shared payload for --json (the panel routes parse this over sudo).
print(f"kept: {len(kept)} probe(s), rejected: {len(rejected)}") payload = {
for c in kept: "present_count": len(present),
print(f"{c.cve:<16} {c.vendor}/{c.product} {c.path}") "complete": complete,
for name, why in rejected: "kept": [{"cve": c.cve, "vendor": c.vendor, "product": c.product,
print(f"{name}: {why}") "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: 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 return 0
# --apply: refuse when the presence inventory is incomplete (fail-safe).
if not complete: if not complete:
print("refusing to write: presence inventory incomplete (fail-safe).", if args.json:
file=sys.stderr) 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 return 3
write_category(Path(args.rules), kept, now=_now()) 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 return 0

View File

@ -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 <devel@cybermind.fr> Sun, 19 Jul 2026 09:00:00 +0200
secubox-cve-triage (1.1.1-1~bookworm1) bookworm; urgency=medium secubox-cve-triage (1.1.1-1~bookworm1) bookworm; urgency=medium
* WAF generator: skip probes already covered by another WAF category * WAF generator: skip probes already covered by another WAF category

View File

@ -13,3 +13,7 @@ override_dh_auto_install:
install -m 0755 sbin/secubox-cvectl debian/secubox-cve-triage/usr/sbin/ 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 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/ 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

View File

@ -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

View File

@ -126,6 +126,33 @@ POST /api/v1/<module>/<method> # Action
Authentification JWT obligatoire sur tous les endpoints via `Depends(auth.require_jwt)`. 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-<module>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-<module>ctl <verbe> --json →`
le `ctl` (root) valide, agit, audite, renvoie un payload `--json` que le panel rend.
Le grant sudoers (`/etc/sudoers.d/secubox-<module>`, `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 ### Dual-vhost split — REQUIRED pour les modules avec UI applicative
Un module qui embarque une application avec sa propre interface web Un module qui embarque une application avec sa propre interface web