3 modules purs (extract, presence, inventory) + emit additif/atomique + CLI dry-run + panel. Les 2 barrières (allowlist appliance ET union dpkg∪vhosts∪ modules) avec fail-safe présent. Sortie toujours detect (jamais armée), preuve par mutation à chaque tâche. Ajoute python3-yaml. 28 tests. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
54 KiB
Générateur de règles WAF « produit absent » — Plan d'implémentation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Générer, dans secubox-cve-triage, des règles WAF en mode detect qui fichent les scanners sondant des produits absents de la box — à partir d'un sous-ensemble Nuclei vendored, filtré par deux barrières de présence.
Architecture: Pipeline de fonctions pures (extraction Nuclei → candidat ; oracle de présence à deux barrières) plus un adaptateur d'inventaire mince (dpkg ∪ vhosts ∪ modules) et un émetteur additif/atomique qui écrit une seule catégorie product_absent_probes dans waf-rules.json. Sortie toujours en detect, jamais armée.
Tech Stack: Python 3.11, PyYAML (à ajouter aux deps), stdlib. Aucune autre dépendance.
Spec: docs/superpowers/specs/2026-07-18-waf-product-absent-generator-design.md
Global Constraints
- Zéro faux positif par construction. Un candidat n'est généré que s'il franchit les deux barrières : (1) famille d'appliance connue-absente ET (2) absent de
dpkg ∪ vhosts routés ∪ modules. - Dans le doute, PRÉSENT. Toute source de présence illisible/indéterminable ⟹ on ne génère rien (mieux vaut rater une sonde que bannir un vrai utilisateur).
- Sortie toujours en
mode: "detect". Aucun chemin de code n'émetescalateniblock. La promotion enescalateest une décision humaine, hors de ce générateur. - Additif et idempotent. Régénérer ne modifie QUE la catégorie
product_absent_probes; les 17 autres catégories dewaf-rules.jsonsont préservées. Écriture atomique (temp +os.replace). - Modules purs sans import de
api.main.api/main.pylit la config à l'import (comme secubox-waf/users) ; les moduleswafgen/*ne doivent PAS l'importer, pour rester testables sans board — même leçon queredact.py. - Vendored, pas de réseau au runtime. Le service ne clone rien ; il lit le sous-ensemble embarqué. La curation est un script mainteneur hors-ligne.
- Python 3.11,
PyYAML(python3-yaml, à ajouter àdebian/control). SPDX 4-line header sur chaque.py. Commits terminés parCo-Authored-By: Gerald KERMA <devel@cybermind.fr>. Aucune référence à Claude. - Tests par répertoire :
.venv/bin/python -m pytest packages/secubox-cve-triage/tests -q. Jamaispytest common/ packages/(collisionapi, cf.pytest.ini).
En-tête SPDX (copier tel quel) :
# 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.
Structure des fichiers
packages/secubox-cve-triage/
├── api/
│ └── wafgen/
│ ├── __init__.py # vide
│ ├── extract.py # PUR : template Nuclei (dict) → Candidate | Rejection
│ ├── presence.py # PUR : les 2 barrières (appliance allowlist + union)
│ ├── inventory.py # MINCE : dpkg ∪ routes ∪ menu.d → (present_set, complete)
│ └── emit.py # ADDITIF/ATOMIQUE : écrit product_absent_probes
├── sbin/
│ └── secubox-cvectl # CLI : waf-rules generate [--dry-run|--apply]
├── nuclei-subset/ # sous-ensemble vendored (data, shippé) + LICENSE
│ ├── LICENSE # attribution MIT ProjectDiscovery
│ └── *.yaml # templates KEV appliance à sonde URL-extractible
├── scripts/
│ └── curate-nuclei-subset.sh # mainteneur hors-ligne (non runtime)
├── debian/control # MODIFIER : + python3-yaml
├── debian/install # MODIFIER : shipper wafgen/, sbin, nuclei-subset/
└── tests/
├── conftest.py
├── test_extract.py
├── test_presence.py
├── test_inventory.py
└── test_emit.py
Chaque fichier a une responsabilité : extract = ce qu'est une sonde ; presence = a-t-on ce produit ; inventory = collecte des sources ; emit = écrit la catégorie. inventory est le seul à toucher le système ; tout le reste est pur.
Task 1 : Extraction Nuclei → candidat (pur)
Files:
- Create:
packages/secubox-cve-triage/api/__init__.py(s'il n'existe pas — vérifier ; ne pas écraser) - Create:
packages/secubox-cve-triage/api/wafgen/__init__.py(vide) - Create:
packages/secubox-cve-triage/api/wafgen/extract.py - Create:
packages/secubox-cve-triage/tests/conftest.py - Test:
packages/secubox-cve-triage/tests/test_extract.py
Interfaces:
-
Consumes: rien (prend un dict déjà parsé).
-
Produces:
Candidate(dataclass gelée :cve, vendor, product, cpe, path, severity),Rejection(dataclass gelée :reason: str),extract(template: dict) -> Candidate | Rejection,is_kev(template: dict) -> bool. -
Step 1 : conftest
Créer packages/secubox-cve-triage/tests/conftest.py :
# 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.
import sys
from pathlib import Path
# Rend `api` importable en top-level (miroir du layout runtime sous
# /usr/lib/secubox/cve-triage). Les modules wafgen n'importent pas api.main,
# donc pas de lecture de config à l'import.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- Step 2 : tests qui échouent
Créer packages/secubox-cve-triage/tests/test_extract.py :
# 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.
from api.wafgen.extract import Candidate, Rejection, extract, is_kev
# A clean GET-path template — the kind that becomes a rule.
CLEAN = {
"id": "CVE-2024-3400",
"info": {"severity": "critical",
"classification": {"cve-id": "CVE-2024-3400",
"cpe": "cpe:2.3:a:paloaltonetworks:pan-os:*"},
"metadata": {"vendor": "paloaltonetworks", "product": "pan-os"}},
"tags": "cve,cve2024,panos,rce,kev,vuln",
"http": [{"method": "GET",
"path": ["{{BaseURL}}/api/v1/totp/user-backup"]}],
}
# A raw/unsafe smuggling template — NOT URL-extractable (real F5 CVE-2023-46747 shape).
RAW = {
"id": "CVE-2023-46747",
"info": {"severity": "critical", "metadata": {"vendor": "f5", "product": "big-ip"}},
"tags": "cve,f5,kev",
"http": [{"raw": ["POST /tmui/login.jsp HTTP/1.1\n..."], "unsafe": True}],
}
def test_clean_template_extracts_a_candidate():
c = extract(CLEAN)
assert isinstance(c, Candidate)
assert c.cve == "CVE-2024-3400"
assert c.vendor == "paloaltonetworks"
assert c.product == "pan-os"
assert c.path == "/api/v1/totp/user-backup"
assert c.severity == "critical"
def test_raw_template_is_rejected():
r = extract(RAW)
assert isinstance(r, Rejection)
assert "raw" in r.reason.lower()
def test_multistep_template_is_rejected():
# More than one http block, or a path list with >1 entry → not a single
# deterministic probe.
t = dict(CLEAN, http=[{"method": "GET", "path": ["{{BaseURL}}/a", "{{BaseURL}}/b"]}])
assert isinstance(extract(t), Rejection)
def test_path_with_non_baseurl_placeholder_is_rejected():
# {{BaseURL}} is fine; any other {{...}} means the probe isn't a fixed URL.
t = dict(CLEAN, http=[{"method": "GET", "path": ["{{BaseURL}}/x?u={{username}}"]}])
r = extract(t)
assert isinstance(r, Rejection)
assert "placeholder" in r.reason.lower()
def test_body_matcher_only_template_is_rejected():
# No http path at all (e.g. a network/dns template) → nothing to extract.
t = {"id": "CVE-2024-9999", "info": {"severity": "high",
"metadata": {"vendor": "f5", "product": "big-ip"}}, "tags": "cve,kev"}
assert isinstance(extract(t), Rejection)
def test_missing_vendor_or_product_is_rejected():
# Without vendor/product the presence oracle can't reason — reject.
t = dict(CLEAN, info={"severity": "high", "classification": {"cve-id": "CVE-2024-3400"}})
assert isinstance(extract(t), Rejection)
def test_path_is_regex_escaped_ready():
# A path with regex-special chars must round-trip literally (the emitter
# will escape; extract returns the raw path unchanged).
t = dict(CLEAN, http=[{"method": "GET", "path": ["{{BaseURL}}/mgmt/tm/util/bash"]}])
assert extract(t).path == "/mgmt/tm/util/bash"
def test_is_kev_reads_the_tag():
assert is_kev(CLEAN) is True
assert is_kev(dict(CLEAN, tags="cve,cve2024,rce")) is False
- Step 3 : lancer — doit échouer
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_extract.py -q
Attendu : ModuleNotFoundError: No module named 'api.wafgen'.
- Step 4 : implémenter
Créer packages/secubox-cve-triage/api/wafgen/__init__.py vide, puis packages/secubox-cve-triage/api/wafgen/extract.py :
# 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 — extraction d'une sonde URL depuis un template Nuclei
CyberMind — https://cybermind.fr
Un template Nuclei ne devient une règle QUE si sa sonde est une requête à chemin
d'URL déterministe. sbxwaf matche une regex sur path+query+body+UA ; on ne peut
donc pas transformer un exploit `raw:`/multi-étapes/body-matcher en règle. Chaque
rejet est explicite (avec sa raison) — le silence surestimerait la couverture.
Fonction pure : prend un dict déjà parsé (le YAML est chargé ailleurs), pour
rester testable sans fichier ni réseau.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# `{{BaseURL}}` est le seul placeholder autorisé : il est remplacé par l'hôte à
# la volée. Tout autre `{{...}}` (username, rand, hex_encode…) signifie que la
# sonde n'est pas une URL fixe → on ne peut pas la matcher déterministement.
_PLACEHOLDER_RE = re.compile(r"\{\{(?!BaseURL\}\})[^}]*\}\}")
_BASEURL_RE = re.compile(r"^\{\{BaseURL\}\}")
@dataclass(frozen=True)
class Candidate:
cve: str
vendor: str
product: str
cpe: str
path: str
severity: str
@dataclass(frozen=True)
class Rejection:
reason: str
def is_kev(template: dict) -> bool:
"""True si le template porte le tag KEV (exploité pour de vrai)."""
tags = template.get("tags", "")
if isinstance(tags, list):
tags = ",".join(tags)
return "kev" in [t.strip().lower() for t in str(tags).split(",")]
def _meta(template: dict) -> dict:
info = template.get("info", {}) or {}
return info.get("metadata", {}) or {}
def _classification(template: dict) -> dict:
info = template.get("info", {}) or {}
return info.get("classification", {}) or {}
def extract(template: dict) -> Candidate | Rejection:
"""template Nuclei parsé → Candidate (sonde URL extractible) ou Rejection."""
meta = _meta(template)
vendor = (meta.get("vendor") or "").strip().lower()
product = (meta.get("product") or "").strip().lower()
if not vendor or not product:
return Rejection("missing vendor/product metadata")
http = template.get("http") or template.get("requests") or []
if not isinstance(http, list) or len(http) != 1:
return Rejection("not a single http block (multi-step or none)")
block = http[0]
if block.get("raw") is not None or block.get("unsafe"):
return Rejection("raw/unsafe request — not a URL path")
paths = block.get("path")
if not isinstance(paths, list) or len(paths) != 1:
return Rejection("not a single path (multi-request or body-only)")
raw_path = paths[0]
if _PLACEHOLDER_RE.search(raw_path):
return Rejection("path contains a non-BaseURL placeholder")
path = _BASEURL_RE.sub("", raw_path)
if not path.startswith("/"):
return Rejection("path is not anchored at /")
cls = _classification(template)
cve = cls.get("cve-id") or template.get("id") or ""
info = template.get("info", {}) or {}
return Candidate(
cve=cve,
vendor=vendor,
product=product,
cpe=cls.get("cpe", ""),
path=path,
severity=info.get("severity", "high"),
)
- Step 5 : lancer — doit passer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_extract.py -q
Attendu : 8 passed.
- Step 6 : preuve par mutation
Remplacer temporairement if block.get("raw") is not None or block.get("unsafe"): par if False:, relancer → test_raw_template_is_rejected FAIL. Restaurer, reconfirmer. Consigner.
- Step 7 : commit
git add packages/secubox-cve-triage/api/__init__.py packages/secubox-cve-triage/api/wafgen/ \
packages/secubox-cve-triage/tests/conftest.py packages/secubox-cve-triage/tests/test_extract.py
git commit -m "feat(cve-triage): extract a URL probe from a Nuclei template
Pure function: only a single-http, single-path, BaseURL-only GET/POST becomes a
candidate. raw/unsafe/multi-step/body-matcher templates are rejected with a
reason — sbxwaf matches URL regex, so a non-URL exploit can't become a rule.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 2 : L'oracle de présence — les deux barrières (pur)
Files:
- Create:
packages/secubox-cve-triage/api/wafgen/presence.py - Test:
packages/secubox-cve-triage/tests/test_presence.py
Interfaces:
-
Consumes:
Candidatedeapi.wafgen.extract. -
Produces:
APPLIANCE_VENDORS(frozenset),is_appliance(candidate) -> bool,should_generate(candidate, present: set[str], present_complete: bool) -> bool. -
Step 1 : tests qui échouent
Créer packages/secubox-cve-triage/tests/test_presence.py :
# 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.
from api.wafgen.extract import Candidate
from api.wafgen.presence import APPLIANCE_VENDORS, is_appliance, should_generate
def cand(vendor="f5", product="big-ip", path="/mgmt/tm/util/bash"):
return Candidate(cve="CVE-2023-1", vendor=vendor, product=product,
cpe=f"cpe:2.3:a:{vendor}:{product}:*", path=path, severity="high")
def test_appliance_families_are_recognised():
for v in ("f5", "paloaltonetworks", "ivanti", "citrix", "fortinet"):
assert is_appliance(cand(vendor=v)) is True
def test_non_appliance_vendor_is_not():
for v in ("php", "wordpress", "nginx", "apache"):
assert is_appliance(cand(vendor=v)) is False
def test_generate_requires_BOTH_appliance_AND_absent():
# appliance + absent from the union → generate.
assert should_generate(cand(), present=set(), present_complete=True) is True
def test_present_in_union_is_never_generated():
# Even an appliance vendor: if the product is somehow present, do not generate.
assert should_generate(cand(), present={"big-ip"}, present_complete=True) is False
def test_non_appliance_is_never_generated_even_if_absent():
assert should_generate(cand(vendor="php", product="php"),
present=set(), present_complete=True) is False
def test_incomplete_inventory_generates_nothing():
# Fail-safe: if a presence source could not be read, treat as present →
# never generate (better to miss a probe than ban a real user).
assert should_generate(cand(), present=set(), present_complete=False) is False
def test_present_match_is_case_insensitive_and_on_vendor_or_product():
assert should_generate(cand(), present={"F5"}, present_complete=True) is False
assert should_generate(cand(), present={"BIG-IP"}, present_complete=True) is False
- Step 2 : lancer — doit échouer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_presence.py -q
Attendu : ModuleNotFoundError: No module named 'api.wafgen.presence'.
- Step 3 : implémenter
Créer packages/secubox-cve-triage/api/wafgen/presence.py :
# 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 — oracle de présence (deux barrières)
CyberMind — https://cybermind.fr
Le mode d'échec à tuer : croire un produit absent alors qu'il est présent → règle
qui matche du trafic légitime → en escalate, ban d'un vrai utilisateur. D'où deux
garde-fous indépendants ; un candidat ne passe que s'il franchit les deux.
Fonction pure : `present` (l'union des sources) et `present_complete` (toutes les
sources ont-elles été lues) sont injectés — la collecte réelle est dans
inventory.py. Règle de sûreté : dans le doute, PRÉSENT (ne rien générer).
"""
from __future__ import annotations
from .extract import Candidate
# Familles d'équipements réseau qu'une box Debian SecuBox n'est CATÉGORIQUEMENT
# jamais. Assertion humaine, versionnée — pas dérivée automatiquement. Un vendor
# hors de cette liste n'est jamais généré, même « prouvé absent » (le mapping
# serait trop faillible).
APPLIANCE_VENDORS = frozenset({
"f5", "paloaltonetworks", "ivanti", "citrix", "fortinet", "cisco",
"vmware", "sonicwall", "zyxel", "dlink", "netgear", "juniper",
})
def is_appliance(candidate: Candidate) -> bool:
"""Barrière 1 : le vendor est-il une famille d'appliance connue-absente ?"""
return candidate.vendor.strip().lower() in APPLIANCE_VENDORS
def should_generate(candidate: Candidate, present: set[str], present_complete: bool) -> bool:
"""Un candidat n'est généré que s'il franchit LES DEUX barrières.
Barrière 1 : famille d'appliance (is_appliance).
Barrière 2 : absent de l'union de présence — ET l'union est complète.
Dans le doute (present_complete=False), on refuse : PRÉSENT par défaut.
"""
if not present_complete:
return False
if not is_appliance(candidate):
return False
lowered = {p.strip().lower() for p in present}
if candidate.vendor.strip().lower() in lowered:
return False
if candidate.product.strip().lower() in lowered:
return False
return True
- Step 4 : lancer — doit passer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_presence.py -q
Attendu : 7 passed.
- Step 5 : preuve par mutation
Remplacer if not present_complete: return False par if not present_complete: pass, relancer → test_incomplete_inventory_generates_nothing FAIL. Restaurer, reconfirmer. Puis remplacer le and implicite en retirant la Barrière 1 (if not is_appliance(...) → if False), relancer → test_non_appliance_is_never_generated_even_if_absent FAIL. Restaurer. Consigner les deux.
- Step 6 : commit
git add packages/secubox-cve-triage/api/wafgen/presence.py packages/secubox-cve-triage/tests/test_presence.py
git commit -m "feat(cve-triage): presence oracle — appliance allowlist AND absence
Two barriers, both required: (1) the vendor is a known-absent appliance family;
(2) the product is absent from the presence union AND the union was fully read.
In doubt (incomplete inventory) → present → generate nothing. Better to miss a
probe than ban a real user.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 3 : L'adaptateur d'inventaire (dpkg ∪ vhosts ∪ modules)
Files:
- Create:
packages/secubox-cve-triage/api/wafgen/inventory.py - Test:
packages/secubox-cve-triage/tests/test_inventory.py
Interfaces:
-
Consumes: rien (sources injectées).
-
Produces:
gather_present(*, run=..., routes_path=..., menu_dir=...) -> tuple[set[str], bool]— retourne(present_set, complete).complete=Falsesi une source a échoué. -
Step 1 : tests qui échouent
Créer packages/secubox-cve-triage/tests/test_inventory.py :
# 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.
import json
from api.wafgen.inventory import gather_present
def fake_run(rc, out):
def _run(argv):
return rc, out
return _run
def test_union_of_the_three_sources(tmp_path):
routes = tmp_path / "haproxy-routes.json"
routes.write_text(json.dumps({"nextcloud.gk2.secubox.in": ["127.0.0.1", 9080]}))
menu = tmp_path / "menu.d"
menu.mkdir()
(menu / "peertube.json").write_text(json.dumps({"id": "peertube"}))
run = fake_run(0, "nginx|1.24|arm64\nopenssl|3.0|arm64\n")
present, complete = gather_present(run=run, routes_path=routes, menu_dir=menu)
assert complete is True
assert "nginx" in present # dpkg
assert "nextcloud" in present # vhost first label
assert "peertube" in present # menu.d id
def test_dpkg_failure_marks_incomplete(tmp_path):
(tmp_path / "menu.d").mkdir()
routes = tmp_path / "haproxy-routes.json"
routes.write_text("{}")
run = fake_run(1, "") # dpkg-query failed
present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d")
assert complete is False # a source failed → caller must treat as present
def test_unreadable_routes_marks_incomplete(tmp_path):
(tmp_path / "menu.d").mkdir()
run = fake_run(0, "nginx|1.24|arm64\n")
# routes file exists but is corrupt
routes = tmp_path / "haproxy-routes.json"
routes.write_text("{ not json")
present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d")
assert complete is False
def test_absent_routes_file_is_complete_and_empty(tmp_path):
# No routes file at all is a legitimate "no public vhosts", not a failure.
(tmp_path / "menu.d").mkdir()
run = fake_run(0, "nginx|1.24|arm64\n")
present, complete = gather_present(run=run, routes_path=tmp_path / "absent.json",
menu_dir=tmp_path / "menu.d")
assert complete is True
assert "nginx" in present
- Step 2 : lancer — doit échouer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_inventory.py -q
Attendu : ModuleNotFoundError: No module named 'api.wafgen.inventory'.
- Step 3 : implémenter
Créer packages/secubox-cve-triage/api/wafgen/inventory.py :
# 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 — collecte de l'union de présence
CyberMind — https://cybermind.fr
Trois sources : les paquets dpkg de l'hôte, les vhosts routés par le WAF, et les
modules secubox. La source « vhosts » est CRUCIALE : nextcloud/gitea tournent en
LXC, absents du dpkg hôte mais routés — sans elle on générerait une règle qui
bannirait un vrai utilisateur.
Retourne (present_set, complete). complete=False dès qu'UNE source échoue :
l'appelant (should_generate) refuse alors de générer — fail-safe présent.
`run`/`routes_path`/`menu_dir` sont injectés pour tester sans board.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROUTES_PATH = Path("/etc/secubox/waf/haproxy-routes.json")
MENU_DIR = Path("/usr/share/secubox/menu.d")
def _run_cmd(argv: list[str]) -> tuple[int, str]:
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=30)
return p.returncode, p.stdout
except (OSError, subprocess.SubprocessError):
return 1, ""
def gather_present(*, run=_run_cmd, routes_path: Path = ROUTES_PATH,
menu_dir: Path = MENU_DIR) -> tuple[set[str], bool]:
"""Union { dpkg } ∪ { premiers labels des vhosts } ∪ { ids menu.d }.
complete=False si une source obligatoire échoue → l'appelant traite tout
comme présent (ne génère rien).
"""
present: set[str] = set()
complete = True
# 1. dpkg — obligatoire ; un échec rend l'inventaire incomplet.
rc, out = run(["dpkg-query", "-W", "-f=${Package}|${Version}|${Architecture}\n"])
if rc != 0:
complete = False
else:
for line in out.splitlines():
name = line.split("|", 1)[0].strip().lower()
if name:
present.add(name)
# 2. vhosts routés — le premier label du domaine (nextcloud.gk2… → nextcloud).
# Fichier absent = pas de vhosts (légitime) ; présent mais illisible =
# source indéterminable → incomplet.
routes_path = Path(routes_path)
if routes_path.exists():
try:
for domain in json.loads(routes_path.read_text(encoding="utf-8")):
label = str(domain).split(".", 1)[0].strip().lower()
if label:
present.add(label)
except (OSError, json.JSONDecodeError):
complete = False
# 3. modules secubox — les ids de menu.d. Répertoire absent = pas de modules.
menu_dir = Path(menu_dir)
if menu_dir.exists():
for p in sorted(menu_dir.glob("*.json")):
try:
d = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
complete = False
continue
mid = (d.get("id") or "").strip().lower() if isinstance(d, dict) else ""
if mid:
present.add(mid)
return present, complete
- Step 4 : lancer — doit passer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_inventory.py -q
Attendu : 4 passed.
- Step 5 : preuve par mutation
Remplacer if rc != 0: complete = False par if rc != 0: pass, relancer → test_dpkg_failure_marks_incomplete FAIL. Restaurer. Consigner.
- Step 6 : commit
git add packages/secubox-cve-triage/api/wafgen/inventory.py packages/secubox-cve-triage/tests/test_inventory.py
git commit -m "feat(cve-triage): gather the presence union (dpkg + vhosts + modules)
Returns (present_set, complete). The vhosts source is what catches nextcloud/
gitea running in LXC — absent from host dpkg but routed. complete=False on any
source failure so the oracle refuses to generate (fail-safe present).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 4 : L'émetteur — catégorie product_absent_probes (additif, atomique)
Files:
- Create:
packages/secubox-cve-triage/api/wafgen/emit.py - Test:
packages/secubox-cve-triage/tests/test_emit.py
Interfaces:
-
Consumes:
Candidatedeapi.wafgen.extract. -
Produces:
CATEGORY_KEY = "product_absent_probes",build_category(candidates: list[Candidate], *, now: str) -> dict,write_category(rules_path: Path, candidates: list[Candidate], *, now: str) -> None. -
Step 1 : tests qui échouent
Créer packages/secubox-cve-triage/tests/test_emit.py :
# 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.
import json
from api.wafgen.emit import CATEGORY_KEY, build_category, write_category
from api.wafgen.extract import Candidate
def cand(cve="CVE-2024-3400", vendor="paloaltonetworks", product="pan-os",
path="/api/v1/totp/user-backup"):
return Candidate(cve=cve, vendor=vendor, product=product,
cpe=f"cpe:2.3:a:{vendor}:{product}:*", path=path, severity="critical")
def _rules_file(tmp_path):
p = tmp_path / "waf-rules.json"
p.write_text(json.dumps({
"_meta": {"version": "1.2.0"},
"categories": {
"sqli": {"name": "SQLi", "severity": "critical",
"patterns": [{"id": "sqli-001", "pattern": "union select", "desc": "x"}]},
},
}, indent=2))
return p
def test_category_is_always_detect_mode():
c = build_category([cand()], now="2026-07-18T00:00:00Z")
assert c["mode"] == "detect"
assert c["generated"] is True
def test_pattern_path_is_regex_escaped():
# A path with regex-special chars must be escaped so it matches literally.
c = build_category([cand(path="/mgmt/tm/util/bash.php?x=1")], now="2026-07-18T00:00:00Z")
pat = c["patterns"][0]["pattern"]
assert "\\.php" in pat and "\\?" in pat
def test_write_preserves_the_other_categories(tmp_path):
p = _rules_file(tmp_path)
write_category(p, [cand()], now="2026-07-18T00:00:00Z")
d = json.loads(p.read_text())
# sqli untouched, product_absent_probes added.
assert "sqli" in d["categories"]
assert d["categories"]["sqli"]["patterns"][0]["id"] == "sqli-001"
assert CATEGORY_KEY in d["categories"]
assert d["categories"][CATEGORY_KEY]["mode"] == "detect"
assert d["_meta"]["version"] == "1.2.0"
def test_regenerate_is_idempotent(tmp_path):
p = _rules_file(tmp_path)
write_category(p, [cand()], now="2026-07-18T00:00:00Z")
first = p.read_text()
write_category(p, [cand()], now="2026-07-18T00:00:00Z")
assert p.read_text() == first
def test_regenerate_replaces_only_its_own_category(tmp_path):
p = _rules_file(tmp_path)
write_category(p, [cand(cve="CVE-2024-3400")], now="2026-07-18T00:00:00Z")
write_category(p, [cand(cve="CVE-2023-46747", vendor="f5", product="big-ip",
path="/mgmt/tm/util/bash")], now="2026-07-18T00:00:00Z")
d = json.loads(p.read_text())
ids = [x["id"] for x in d["categories"][CATEGORY_KEY]["patterns"]]
assert any("2023-46747" in i for i in ids)
assert not any("2024-3400" in i for i in ids) # replaced, not appended
assert "sqli" in d["categories"]
def test_empty_candidates_writes_an_empty_category(tmp_path):
p = _rules_file(tmp_path)
write_category(p, [], now="2026-07-18T00:00:00Z")
d = json.loads(p.read_text())
assert d["categories"][CATEGORY_KEY]["patterns"] == []
- Step 2 : lancer — doit échouer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_emit.py -q
Attendu : ModuleNotFoundError: No module named 'api.wafgen.emit'.
- Step 3 : implémenter
Créer packages/secubox-cve-triage/api/wafgen/emit.py :
# 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 — émission de la catégorie product_absent_probes
CyberMind — https://cybermind.fr
Écrit UNE seule catégorie dans waf-rules.json, en mode `detect` (jamais escalate
ni block — la promotion est humaine). Additif : les 17 autres catégories sont
préservées ; on remplace uniquement product_absent_probes (idempotent). Écriture
atomique (temp + os.replace) pour ne jamais corrompre le fichier du WAF frontal.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from .extract import Candidate
CATEGORY_KEY = "product_absent_probes"
def build_category(candidates: list[Candidate], *, now: str) -> dict:
"""Construit la catégorie (toujours mode=detect). Le pattern est le chemin
échappé en regex (matche littéralement) — le but est de reconnaître la sonde,
pas de généraliser la vuln."""
patterns = []
for c in candidates:
patterns.append({
"id": f"{c.vendor}-{c.cve}".lower(),
"pattern": re.escape(c.path),
"desc": f"{c.vendor} {c.product} probe (product absent)",
"cve": c.cve,
"vendor": c.vendor,
"source": "nuclei",
})
return {
"name": "Product-absent scanner probes (generated)",
"severity": "high",
"mode": "detect", # JAMAIS escalate/block — promotion humaine
"generated": True,
"generated_at": now,
"patterns": patterns,
}
def write_category(rules_path: Path, candidates: list[Candidate], *, now: str) -> None:
"""Remplace product_absent_probes dans waf-rules.json, préserve le reste,
écrit atomiquement. Un waf-rules.json absent/illisible lève — l'appelant
(CLI) décide quoi en faire ; on ne crée pas un fichier de règles vide."""
rules_path = Path(rules_path)
data = json.loads(rules_path.read_text(encoding="utf-8"))
cats = data.setdefault("categories", {})
cats[CATEGORY_KEY] = build_category(candidates, now=now)
tmp = rules_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
os.replace(tmp, rules_path)
- Step 4 : lancer — doit passer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_emit.py -q
Attendu : 6 passed.
- Step 5 : preuve par mutation
Remplacer "mode": "detect", par "mode": "escalate",, relancer → test_category_is_always_detect_mode FAIL (garde-fou : jamais armé). Restaurer. Puis remplacer re.escape(c.path) par c.path, relancer → test_pattern_path_is_regex_escaped FAIL. Restaurer. Consigner les deux.
- Step 6 : commit
git add packages/secubox-cve-triage/api/wafgen/emit.py packages/secubox-cve-triage/tests/test_emit.py
git commit -m "feat(cve-triage): emit product_absent_probes — detect-only, additive, atomic
Writes one category in detect mode (never escalate/block; promotion is human),
replacing only its own key so the 17 shipped categories are preserved, idempotent,
atomic temp+os.replace. The pattern is the regex-escaped path — recognise the
probe, don't generalise the vuln.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 5 : La CLI + le sous-ensemble vendored + le packaging
Files:
- Create:
packages/secubox-cve-triage/api/wafgen/generate.py(orchestration, testable) - Create:
packages/secubox-cve-triage/sbin/secubox-cvectl - Create:
packages/secubox-cve-triage/nuclei-subset/LICENSE - Create:
packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml - Create:
packages/secubox-cve-triage/nuclei-subset/CVE-2024-21887.yaml - Create:
packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh - Modify:
packages/secubox-cve-triage/debian/control - Modify:
packages/secubox-cve-triage/debian/install - Create:
packages/secubox-cve-triage/README.md(section générateur) - Test:
packages/secubox-cve-triage/tests/test_generate.py
Interfaces:
-
Consumes:
extract,is_kev(extract) ;should_generate(presence) ;gather_present(inventory) ;write_category,build_category(emit). -
Produces:
generate(subset_dir, present, present_complete) -> tuple[list[Candidate], list[tuple[str,str]]]— retourne(retenus, rejets)où chaque rejet est(fichier, raison).main(argv) -> int. -
Step 1 : tests qui échouent
Créer packages/secubox-cve-triage/tests/test_generate.py :
# 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.
import textwrap
from api.wafgen.generate import generate
def _write(dir_, name, body):
(dir_ / name).write_text(textwrap.dedent(body))
def test_generate_keeps_appliance_absent_kev_and_rejects_the_rest(tmp_path):
subset = tmp_path / "nuclei-subset"
subset.mkdir()
# kept: appliance (paloalto), absent, KEV, clean path
_write(subset, "keep.yaml", """
id: CVE-2024-3400
info:
severity: critical
classification: {cve-id: CVE-2024-3400, cpe: "cpe:2.3:a:paloaltonetworks:pan-os:*"}
metadata: {vendor: paloaltonetworks, product: pan-os}
tags: cve,kev,rce
http:
- method: GET
path: ["{{BaseURL}}/api/v1/totp/user-backup"]
""")
# rejected: not KEV
_write(subset, "notkev.yaml", """
id: CVE-2024-1
info:
severity: high
metadata: {vendor: f5, product: big-ip}
tags: cve,rce
http:
- method: GET
path: ["{{BaseURL}}/x"]
""")
# rejected: raw
_write(subset, "raw.yaml", """
id: CVE-2024-2
info: {severity: high, metadata: {vendor: f5, product: big-ip}}
tags: cve,kev
http:
- raw: ["POST /x HTTP/1.1"]
unsafe: true
""")
kept, rejected = generate(subset, present=set(), present_complete=True)
assert [c.cve for c in kept] == ["CVE-2024-3400"]
rej_names = {name for name, _ in rejected}
assert "notkev.yaml" in rej_names and "raw.yaml" in rej_names
def test_present_product_is_rejected_by_the_oracle(tmp_path):
subset = tmp_path / "nuclei-subset"
subset.mkdir()
_write(subset, "f5.yaml", """
id: CVE-2023-46747
info:
severity: critical
classification: {cve-id: CVE-2023-46747, cpe: "cpe:2.3:a:f5:big-ip:*"}
metadata: {vendor: f5, product: big-ip}
tags: cve,kev
http:
- method: GET
path: ["{{BaseURL}}/mgmt/tm/util/bash"]
""")
# f5 is somehow present → not generated, and recorded as a rejection reason.
kept, rejected = generate(subset, present={"f5"}, present_complete=True)
assert kept == []
assert any("present" in reason.lower() for _, reason in rejected)
def test_incomplete_inventory_generates_nothing(tmp_path):
subset = tmp_path / "nuclei-subset"
subset.mkdir()
_write(subset, "f5.yaml", """
id: CVE-2023-46747
info:
severity: critical
classification: {cve-id: CVE-2023-46747}
metadata: {vendor: f5, product: big-ip}
tags: cve,kev
http:
- method: GET
path: ["{{BaseURL}}/mgmt/tm/util/bash"]
""")
kept, _ = generate(subset, present=set(), present_complete=False)
assert kept == []
- Step 2 : lancer — doit échouer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_generate.py -q
Attendu : ModuleNotFoundError: No module named 'api.wafgen.generate'.
- Step 3 : implémenter l'orchestration
Créer packages/secubox-cve-triage/api/wafgen/generate.py :
# 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 — orchestration du générateur
CyberMind — https://cybermind.fr
Parcourt le sous-ensemble Nuclei vendored, applique KEV + extraction + les deux
barrières de présence, retourne (retenus, rejets-avec-raison). N'écrit rien :
la CLI décide (dry-run par défaut).
"""
from __future__ import annotations
from pathlib import Path
import yaml
from .extract import Candidate, Rejection, extract, is_kev
from .presence import is_appliance, should_generate
def generate(subset_dir: Path, present: set[str],
present_complete: bool) -> tuple[list[Candidate], list[tuple[str, str]]]:
"""(retenus, [(fichier, raison de rejet)])."""
kept: list[Candidate] = []
rejected: list[tuple[str, str]] = []
for p in sorted(Path(subset_dir).glob("*.yaml")):
try:
tmpl = yaml.safe_load(p.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
rejected.append((p.name, f"unparseable: {exc}"))
continue
if not isinstance(tmpl, dict):
rejected.append((p.name, "not a template mapping"))
continue
if not is_kev(tmpl):
rejected.append((p.name, "not KEV (not known-exploited)"))
continue
result = extract(tmpl)
if isinstance(result, Rejection):
rejected.append((p.name, result.reason))
continue
if not should_generate(result, present, present_complete):
why = ("inventory incomplete → present" if not present_complete
else "not an appliance family" if not is_appliance(result)
else "product is present on this box")
rejected.append((p.name, why))
continue
kept.append(result)
return kept, rejected
- Step 4 : lancer — doit passer
.venv/bin/python -m pytest packages/secubox-cve-triage/tests/test_generate.py -q
Attendu : 3 passed.
- Step 5 : la CLI
Créer packages/secubox-cve-triage/sbin/secubox-cvectl (chmod 755) :
#!/usr/bin/env bash
# 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 :: secubox-cvectl
set -euo pipefail
readonly MODULE="cve-triage"
readonly VERSION="1.1.0"
cd /usr/lib/secubox/cve-triage
exec /usr/bin/python3 -m api.wafgen.cli "$@"
Créer packages/secubox-cve-triage/api/wafgen/cli.py :
# 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 sys
from datetime import datetime, timezone
from pathlib import Path
from .emit import 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("--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)
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).")
return 0
if not complete:
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}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 6 : le sous-ensemble vendored initial + licence + script de curation
Créer packages/secubox-cve-triage/nuclei-subset/LICENSE :
The templates in this directory are vendored from projectdiscovery/nuclei-templates
(https://github.com/projectdiscovery/nuclei-templates), MIT License.
Copyright (c) 2020-2026 ProjectDiscovery, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
Créer packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml (PAN-OS, KEV, chemin GET propre — transcrit de la forme réelle) :
id: CVE-2024-3400
info:
name: Palo Alto PAN-OS - Command Injection
severity: critical
classification:
cve-id: CVE-2024-3400
cpe: cpe:2.3:a:paloaltonetworks:pan-os:*:*:*:*:*:*:*:*
metadata:
vendor: paloaltonetworks
product: pan-os
tags: cve,cve2024,panos,rce,kev,vuln
http:
- method: GET
path:
- "{{BaseURL}}/global-protect/portal/css/bootstrap.min.css"
Créer packages/secubox-cve-triage/nuclei-subset/CVE-2024-21887.yaml (Ivanti, KEV, chemin GET propre) :
id: CVE-2024-21887
info:
name: Ivanti Connect Secure - Command Injection
severity: critical
classification:
cve-id: CVE-2024-21887
cpe: cpe:2.3:a:ivanti:connect_secure:*:*:*:*:*:*:*:*
metadata:
vendor: ivanti
product: connect_secure
tags: cve,cve2024,ivanti,rce,kev,vuln
http:
- method: GET
path:
- "{{BaseURL}}/api/v1/totp/user-backup"
Créer packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh (chmod 755) — mainteneur hors-ligne :
#!/usr/bin/env bash
# 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 :: curate-nuclei-subset — MAINTAINER-ONLY, offline, NOT runtime.
# Clones nuclei-templates and copies KEV + appliance-family + URL-extractable
# templates into nuclei-subset/. Run before a release to refresh the vendored
# set; the .deb ships the copies, the service never clones anything.
set -euo pipefail
readonly OUT="$(dirname "$0")/../nuclei-subset"
readonly TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 https://github.com/projectdiscovery/nuclei-templates "$TMP/nt"
echo "Review candidates by hand; copy the appliance/KEV/clean-path ones into $OUT."
echo "Vendors of interest: f5 paloaltonetworks ivanti citrix fortinet cisco vmware."
grep -rlE "kev" "$TMP/nt/http/cves" | head -50
- Step 7 : packaging
Ajouter python3-yaml aux Depends de packages/secubox-cve-triage/debian/control (après python3). Vérifier la ligne Depends: existante et y insérer python3-yaml,.
Dans packages/secubox-cve-triage/debian/install, ajouter (adapter au style existant — lire d'abord) :
api/wafgen/*.py usr/lib/secubox/cve-triage/api/wafgen/
sbin/secubox-cvectl usr/sbin/
nuclei-subset/* usr/lib/secubox/cve-triage/nuclei-subset/
- Step 8 : suite complète + vérif YAML réelle
.venv/bin/python -m pytest packages/secubox-cve-triage/tests -q
Attendu : 28 passed (8+7+4+6+3).
Vérifier que les deux templates vendored passent le pipeline réel :
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/packages/secubox-cve-triage
.venv/bin/python -c "
import sys; sys.path.insert(0,'.')
from pathlib import Path
from api.wafgen.generate import generate
kept, rej = generate(Path('nuclei-subset'), present=set(), present_complete=True)
print('retenus:', [c.cve for c in kept])
print('rejets :', rej)
assert len(kept) == 2, kept
print('les 2 templates vendored produisent bien un candidat ✓')
"
Attendu : les 2 CVE retenus.
- Step 9 : commit
git add packages/secubox-cve-triage/
git commit -m "feat(cve-triage): secubox-cvectl waf-rules generate + vendored Nuclei subset
CLI (dry-run by default, --apply to write) orchestrates extract + presence +
emit. Ships a small vendored Nuclei subset (MIT, with LICENSE) of appliance/KEV/
URL-extractable templates + an offline maintainer curation script. Adds
python3-yaml to Depends. --apply refuses to write when the presence inventory is
incomplete (fail-safe).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Task 6 : Panel — onglet règles générées
Files:
- Modify:
packages/secubox-cve-triage/api/main.py(ajouter les routes GET/waf-rules+ POST/waf-rules/generate) - Modify:
packages/secubox-cve-triage/www/cve-triage/index.html(onglet)
Interfaces:
-
Consumes:
generate,gather_present,write_category(wafgen) ;require_jwt(secubox_core). -
Produces: routes
/waf-rules(aperçu : retenus + rejets, dry-run) et/waf-rules/generate(POST--apply). -
Step 1 : implémenter les routes
Dans packages/secubox-cve-triage/api/main.py, à côté des routes existantes (après les @router.get(...) du domaine feeds), ajouter :
@router.get("/waf-rules", dependencies=[Depends(require_jwt)])
async 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 pathlib import Path
present, complete = gather_present()
kept, rejected = generate(Path("/usr/lib/secubox/cve-triage/nuclei-subset"),
present, complete)
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],
}
@router.post("/waf-rules/generate", dependencies=[Depends(require_jwt)])
async def waf_rules_generate():
"""Apply: write product_absent_probes (detect mode). Refuses if inventory
is incomplete (fail-safe)."""
from .wafgen.generate import generate
from .wafgen.inventory import gather_present
from .wafgen.emit import write_category
from datetime import datetime, timezone
from pathlib import Path
present, complete = gather_present()
if not complete:
raise HTTPException(409, "presence inventory incomplete — refusing (fail-safe)")
kept, rejected = generate(Path("/usr/lib/secubox/cve-triage/nuclei-subset"),
present, complete)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now)
return {"success": True, "written": len(kept), "mode": "detect",
"rejected": len(rejected)}
Note à l'implémenteur : require_jwt, HTTPException, router/app sont déjà importés dans main.py — vérifier leurs noms exacts et réutiliser. Ne pas ré-importer différemment.
- Step 2 : l'onglet webui
Dans packages/secubox-cve-triage/www/cve-triage/index.html, ajouter un onglet « WAF probes » qui appelle GET /api/v1/cve-triage/waf-rules, affiche kept (cve/vendor/path) et rejected (file/reason), plus un bouton « Generate » qui POST /waf-rules/generate puis rafraîchit. Suivre le style cyan hybrid-skin déjà présent dans le fichier (réutiliser ses classes .card/.btn/.tab ; lire l'existant). esc() sur toute valeur d'API ; lire le token depuis localStorage.getItem('sbx_token') ; 401 → toast.
node --check le JS extrait.
- Step 3 : vérifier que le module se charge encore
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/packages/secubox-cve-triage
python3 -c "import ast; ast.parse(open('api/main.py').read()); print('main.py parse ✓')"
(La suite tests/ reste verte — les routes ajoutées n'importent wafgen que dans le corps des handlers, pas au niveau module, donc elles n'introduisent pas de lecture de config supplémentaire.)
- Step 4 : commit
git add packages/secubox-cve-triage/api/main.py packages/secubox-cve-triage/www/cve-triage/index.html
git commit -m "feat(cve-triage): panel tab for generated WAF probes (dry-run + apply)
GET /waf-rules previews kept + rejections (writes nothing); POST /waf-rules/
generate applies (detect mode), refusing on an incomplete presence inventory.
Cyan hybrid-skin tab, esc()'d, sbx_token auth.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
Auto-revue du plan
Couverture du spec :
| Exigence du spec | Tâche |
|---|---|
| Source vendored Nuclei (MIT, pas de git-pull runtime) | 5 (subset + LICENSE + curation script) |
| Extraction : sonde URL, rejets journalisés | 1 |
| Barrière 1 : allowlist appliance | 2 |
| Barrière 2 : union dpkg ∪ vhosts ∪ modules | 3 |
| Fail-safe « dans le doute présent » | 2 (present_complete), 3 (complete), 5 (CLI refuse), 6 (409) |
| Trou LXC/vhost (nextcloud absent dpkg mais routé) | 3 (source vhosts + test) |
Sortie catégorie detect, additive, idempotente, atomique |
4 |
| Jamais escalate/block (jamais armé) | 4 (test + mutation) |
| CLI dry-run par défaut | 5 |
| Panel onglet | 6 |
| Tests capables d'échouer (mutation) | 1,2,3,4 |
Hors périmètre, donc absent (voulu) : git-pull runtime, promotion auto detect→escalate, génération hors appliance, généralisation des patterns, corrélation ASN/JA4.
Placeholders : aucun — chaque étape porte son code et sa commande. Les « notes à l'implémenteur » (routes de main.py, style du panel) demandent de lire l'existant plutôt que d'inventer — instruction, pas trou.
Cohérence des types : Candidate (Task 1) consommé par 2/4/5 ; should_generate(candidate, present, present_complete) (Task 2) appelé identiquement en 5 ; gather_present() -> (set, bool) (Task 3) consommé en 5/6 ; write_category(rules_path, candidates, *, now) / CATEGORY_KEY (Task 4) consommés en 5/6 ; generate(subset_dir, present, present_complete) -> (kept, rejected) (Task 5) consommé en 6. Noms stables partout.