mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
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:
parent
525151ac92
commit
eb727669e2
62
packages/secubox-cve-triage/api/wafgen/emit.py
Normal file
62
packages/secubox-cve-triage/api/wafgen/emit.py
Normal 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)
|
||||
78
packages/secubox-cve-triage/tests/test_emit.py
Normal file
78
packages/secubox-cve-triage/tests/test_emit.py
Normal 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"] == []
|
||||
Loading…
Reference in New Issue
Block a user