From eb727669e29a0774276535886b01ed70bf560ef8 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 18 Jul 2026 07:27:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(cve-triage):=20emit=20product=5Fabsent=5Fp?= =?UTF-8?q?robes=20=E2=80=94=20detect-only,=20additive,=20atomic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../secubox-cve-triage/api/wafgen/emit.py | 62 +++++++++++++++ .../secubox-cve-triage/tests/test_emit.py | 78 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/secubox-cve-triage/api/wafgen/emit.py create mode 100644 packages/secubox-cve-triage/tests/test_emit.py diff --git a/packages/secubox-cve-triage/api/wafgen/emit.py b/packages/secubox-cve-triage/api/wafgen/emit.py new file mode 100644 index 00000000..72cd96e8 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/emit.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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) diff --git a/packages/secubox-cve-triage/tests/test_emit.py b/packages/secubox-cve-triage/tests/test_emit.py new file mode 100644 index 00000000..310750b7 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_emit.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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"] == []