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/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.
- **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-<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
@ -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-<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) +
[`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
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
# 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", [])),
}

View File

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

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

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)`.
### 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
Un module qui embarque une application avec sa propre interface web