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>
This commit is contained in:
CyberMind-FR 2026-07-18 07:27:34 +02:00
parent 525151ac92
commit eb727669e2
2 changed files with 140 additions and 0 deletions

View File

@ -0,0 +1,62 @@
# 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)

View File

@ -0,0 +1,78 @@
# 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"] == []