secubox-deb/packages/secubox-cve-triage/api/wafgen/cli.py
CyberMind-FR 213ff21c0d 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 <devel@cybermind.fr>
2026-07-19 06:54:33 +02:00

98 lines
3.5 KiB
Python

# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: cve-triage — CLI waf-rules generate
CyberMind — https://cybermind.fr
Dry-run par défaut : montre ce qui SERAIT généré (retenus + rejets avec raison)
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
from .emit import existing_patterns, write_category
from .generate import generate
from .inventory import gather_present
SUBSET_DIR = Path("/usr/lib/secubox/cve-triage/nuclei-subset")
RULES_PATH = Path("/etc/secubox/waf/waf-rules.json")
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="secubox-cvectl")
sub = p.add_subparsers(dest="group", required=True)
wr = sub.add_parser("waf-rules")
wsub = wr.add_subparsers(dest="cmd", required=True)
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)
present, complete = gather_present()
kept, rejected = generate(Path(args.subset), present, complete,
existing=existing_patterns(Path(args.rules)))
# 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:
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:
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())
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
if __name__ == "__main__":
raise SystemExit(main())