mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Merge pull request #884 from CyberMind-FR/fix/cve-triage-panel-apply-sudo
Some checks are pending
License Headers / check (push) Waiting to run
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:
commit
753524c38a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", [])),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
packages/secubox-cve-triage/sudoers.d/secubox-cve-triage
Normal file
17
packages/secubox-cve-triage/sudoers.d/secubox-cve-triage
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user