mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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>
108 lines
3.8 KiB
Python
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"),
|
|
)
|