diff --git a/packages/secubox-cve-triage/api/wafgen/__init__.py b/packages/secubox-cve-triage/api/wafgen/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-cve-triage/api/wafgen/extract.py b/packages/secubox-cve-triage/api/wafgen/extract.py new file mode 100644 index 00000000..9abca939 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/extract.py @@ -0,0 +1,100 @@ +# 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 — 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"), + ) diff --git a/packages/secubox-cve-triage/tests/conftest.py b/packages/secubox-cve-triage/tests/conftest.py new file mode 100644 index 00000000..3bd501d8 --- /dev/null +++ b/packages/secubox-cve-triage/tests/conftest.py @@ -0,0 +1,11 @@ +# 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 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])) diff --git a/packages/secubox-cve-triage/tests/test_extract.py b/packages/secubox-cve-triage/tests/test_extract.py new file mode 100644 index 00000000..b071c85f --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_extract.py @@ -0,0 +1,81 @@ +# 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. +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