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>
This commit is contained in:
CyberMind-FR 2026-07-18 07:01:16 +02:00
parent 9325376034
commit 47085601ca
4 changed files with 192 additions and 0 deletions

View File

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

View File

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

View File

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