secubox-deb/packages/secubox-cve-triage/api/wafgen/extract.py
CyberMind-FR dc190aa049 fix(cve-triage): is_kev reads info.tags (real nuclei format) + CLI main() tests
is_kev() read tags at the template's top level while extract() already read
classification/metadata from under info: — real upstream nuclei-templates
nest tags under info: too, so a real curation run silently produced zero
candidates. Read info.tags first with a top-level fallback for legacy
fixtures, and realign the two vendored KEV templates to the real nested
format. Also add direct coverage of cli.main() (dry-run no-op, --apply
writing only product_absent_probes, and the incomplete-inventory fail-safe
returning 3 without touching the rules file).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-18 08:06:20 +02:00

108 lines
3.8 KiB
Python

# 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).
Le nuclei-templates réel place `tags` sous `info:` (comme `classification`
et `metadata`, lus plus bas). On lit `info.tags` en priorité, avec un
repli sur `tags` au niveau racine pour les fixtures/anciens formats."""
info = template.get("info", {}) or {}
tags = info.get("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 /")
if "{{" in path:
return Rejection("path still contains a placeholder after stripping BaseURL")
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"),
)