mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
9325376034
commit
47085601ca
0
packages/secubox-cve-triage/api/wafgen/__init__.py
Normal file
0
packages/secubox-cve-triage/api/wafgen/__init__.py
Normal file
100
packages/secubox-cve-triage/api/wafgen/extract.py
Normal file
100
packages/secubox-cve-triage/api/wafgen/extract.py
Normal 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"),
|
||||
)
|
||||
11
packages/secubox-cve-triage/tests/conftest.py
Normal file
11
packages/secubox-cve-triage/tests/conftest.py
Normal 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]))
|
||||
81
packages/secubox-cve-triage/tests/test_extract.py
Normal file
81
packages/secubox-cve-triage/tests/test_extract.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user