diff --git a/packages/secubox-cve-triage/README.md b/packages/secubox-cve-triage/README.md index 2457a286..5b1a3dba 100644 --- a/packages/secubox-cve-triage/README.md +++ b/packages/secubox-cve-triage/README.md @@ -34,6 +34,41 @@ Configuration file: `/etc/secubox/cve-triage.toml` - `GET /api/v1/cve-triage/status` - Module status - `GET /api/v1/cve-triage/health` - Health check +## Générateur de règles WAF « product-absent » + +`secubox-cvectl` orchestre un pipeline en quatre étapes sur un petit +sous-ensemble vendored de templates Nuclei (`nuclei-subset/`, MIT, +`nuclei-subset/LICENSE`) : + +1. **KEV** (`api/wafgen/extract.is_kev`) — ne garde que les CVE réellement + exploitées (tag `kev`). +2. **Extraction** (`api/wafgen/extract.extract`) — ne garde que les sondes à + chemin d'URL déterministe (`{{BaseURL}}/...` fixe, pas de `raw:`/`unsafe`, + pas de placeholder autre que `BaseURL`). +3. **Oracle de présence** (`api/wafgen/presence.should_generate`) — deux + barrières indépendantes : famille d'appliance connue-absente + (`f5`, `paloaltonetworks`, `ivanti`, `citrix`, `fortinet`, `cisco`, + `vmware`, `sonicwall`, `zyxel`, `dlink`, `netgear`, `juniper`), ET absence + de l'union de présence (dpkg + vhosts routés + modules secubox) — union + qui doit être **complète** (`api/wafgen/inventory.gather_present`) : dans + le doute, présent, on ne génère rien. +4. **Émission** (`api/wafgen/emit.write_category`) — écrit uniquement la + catégorie `product_absent_probes` dans `/etc/secubox/waf/waf-rules.json`, + toujours en mode `detect` (jamais escalate/block), additif et atomique. + +```bash +# Dry-run (défaut) : montre ce qui SERAIT généré, n'écrit rien. +secubox-cvectl waf-rules generate + +# Écrit la catégorie product_absent_probes (mode detect) ; refuse si +# l'inventaire de présence est incomplet (fail-safe, code retour 3). +secubox-cvectl waf-rules generate --apply +``` + +Le sous-ensemble vendored est rafraîchi hors-ligne par +`scripts/curate-nuclei-subset.sh` (mainteneur uniquement, jamais exécuté par +le service). + ## License LicenseRef-CMSD-1.0 (Source-Disclosed License) — CyberMind © 2024-2026. diff --git a/packages/secubox-cve-triage/api/main.py b/packages/secubox-cve-triage/api/main.py index 474fae4c..a5cfc37c 100644 --- a/packages/secubox-cve-triage/api/main.py +++ b/packages/secubox-cve-triage/api/main.py @@ -1090,6 +1090,64 @@ async def list_kev_cves(): return {"cves": kev_cves, "count": len(kev_cves), "source": "CISA KEV"} +# ============================================================================ +# WAF Product-Absent Probe Generator (wafgen) +# ============================================================================ +# Handlers are plain `def`, not `async def`: gather_present() shells out to +# dpkg, generate() does blocking file I/O over the vendored Nuclei subset, and +# write_category() is a blocking atomic write. This module is imported +# in-process by the secubox aggregator, which runs ONE shared event loop for +# ~110 modules — a blocking call inside an `async def` handler would freeze +# the whole board. Plain `def` handlers run in FastAPI's threadpool instead. + +@app.get("/waf-rules", dependencies=[Depends(require_jwt)]) +def waf_rules_preview(): + """Dry-run: what would be generated (kept + rejections). Writes nothing.""" + from .wafgen.generate import generate + from .wafgen.inventory import gather_present + + present, complete = gather_present() + kept, rejected = generate( + Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete + ) + return { + "present_count": len(present), + "inventory_complete": complete, + "kept": [ + {"cve": c.cve, "vendor": c.vendor, "product": c.product, "path": c.path} + for c in kept + ], + "rejected": [{"file": n, "reason": r} for n, r in rejected], + } + + +@app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)]) +def waf_rules_generate(): + """Apply: write product_absent_probes (detect mode). Refuses if the + presence inventory is incomplete (fail-safe).""" + from .wafgen.generate import generate + from .wafgen.inventory import gather_present + from .wafgen.emit import write_category + + present, complete = gather_present() + if not complete: + raise HTTPException( + status_code=409, + detail="presence inventory incomplete — refusing (fail-safe)", + ) + kept, rejected = generate( + Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete + ) + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now) + return { + "success": True, + "written": len(kept), + "mode": "detect", + "rejected": len(rejected), + } + + # ============================================================================ # Startup # ============================================================================ 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/cli.py b/packages/secubox-cve-triage/api/wafgen/cli.py new file mode 100644 index 00000000..dd61270a --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/cli.py @@ -0,0 +1,67 @@ +# 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 — CLI waf-rules generate +CyberMind — https://cybermind.fr + +Dry-run par défaut : montre ce qui SERAIT généré (retenus + rejets avec raison) +sans écrire. --apply écrit la catégorie product_absent_probes en mode detect. +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timezone +from pathlib import Path + +from .emit import write_category +from .generate import generate +from .inventory import gather_present + +SUBSET_DIR = Path("/usr/lib/secubox/cve-triage/nuclei-subset") +RULES_PATH = Path("/etc/secubox/waf/waf-rules.json") + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="secubox-cvectl") + sub = p.add_subparsers(dest="group", required=True) + wr = sub.add_parser("waf-rules") + wsub = wr.add_subparsers(dest="cmd", required=True) + gen = wsub.add_parser("generate", help="generate product-absent probe rules") + gen.add_argument("--apply", action="store_true", + help="write the category (default: dry-run, write nothing)") + gen.add_argument("--subset", default=str(SUBSET_DIR)) + gen.add_argument("--rules", default=str(RULES_PATH)) + args = p.parse_args(argv) + + present, complete = gather_present() + kept, rejected = generate(Path(args.subset), present, complete) + + print(f"presence union: {len(present)} product(s), complete={complete}") + print(f"kept: {len(kept)} probe(s), rejected: {len(rejected)}") + for c in kept: + print(f" ✓ {c.cve:<16} {c.vendor}/{c.product} {c.path}") + for name, why in rejected: + print(f" ✗ {name}: {why}") + + if not args.apply: + print("dry-run — nothing written (use --apply to write).") + return 0 + if not complete: + print("refusing to write: presence inventory incomplete (fail-safe).", + file=sys.stderr) + return 3 + write_category(Path(args.rules), kept, now=_now()) + print(f"wrote {len(kept)} probe(s) to product_absent_probes (mode=detect) in {args.rules}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/secubox-cve-triage/api/wafgen/emit.py b/packages/secubox-cve-triage/api/wafgen/emit.py new file mode 100644 index 00000000..cfee3bf9 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/emit.py @@ -0,0 +1,83 @@ +# 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 — émission de la catégorie product_absent_probes +CyberMind — https://cybermind.fr + +Écrit UNE seule catégorie dans waf-rules.json, en mode `detect` (jamais escalate +ni block — la promotion est humaine). Additif : les 17 autres catégories sont +préservées ; on remplace uniquement product_absent_probes (idempotent). Écriture +atomique (temp + os.replace) pour ne jamais corrompre le fichier du WAF frontal. +""" +from __future__ import annotations + +import json +import os +import re +import stat +import tempfile +from pathlib import Path + +from .extract import Candidate + +CATEGORY_KEY = "product_absent_probes" + + +def build_category(candidates: list[Candidate], *, now: str) -> dict: + """Construit la catégorie (toujours mode=detect). Le pattern est le chemin + échappé en regex (matche littéralement) — le but est de reconnaître la sonde, + pas de généraliser la vuln.""" + patterns = [] + for c in candidates: + patterns.append({ + "id": f"{c.vendor}-{c.cve}".lower(), + "pattern": re.escape(c.path), + "desc": f"{c.vendor} {c.product} probe (product absent)", + "cve": c.cve, + "vendor": c.vendor, + "source": "nuclei", + }) + return { + "name": "Product-absent scanner probes (generated)", + "severity": "high", + "mode": "detect", # JAMAIS escalate/block — promotion humaine + "generated": True, + "generated_at": now, + "patterns": patterns, + } + + +def write_category(rules_path: Path, candidates: list[Candidate], *, now: str) -> None: + """Remplace product_absent_probes dans waf-rules.json, préserve le reste, + écrit atomiquement. Un waf-rules.json absent/illisible lève — l'appelant + (CLI) décide quoi en faire ; on ne crée pas un fichier de règles vide.""" + rules_path = Path(rules_path) + data = json.loads(rules_path.read_text(encoding="utf-8")) + orig_mode = os.stat(rules_path).st_mode + cats = data.setdefault("categories", {}) + cats[CATEGORY_KEY] = build_category(candidates, now=now) + + # Unique temp file in the SAME directory as rules_path: concurrent writers + # (CLI cron regen + panel-triggered regen) must never collide on a single + # fixed name, and os.replace() only stays atomic on the same filesystem. + fd, tmp_name = tempfile.mkstemp( + dir=rules_path.parent, prefix=".waf-rules-", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") + # tempfile.mkstemp() always creates 0600 regardless of umask; os.replace() + # would make waf-rules.json inherit that, silently tightening it from its + # original mode (typically 0644) and breaking the WAF reader if it runs + # as a different user. Restore the original bits before the swap. + os.chmod(tmp_name, stat.S_IMODE(orig_mode)) + os.replace(tmp_name, rules_path) + except BaseException: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + raise 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..98c244d9 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/extract.py @@ -0,0 +1,107 @@ +# 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). + + 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"), + ) diff --git a/packages/secubox-cve-triage/api/wafgen/generate.py b/packages/secubox-cve-triage/api/wafgen/generate.py new file mode 100644 index 00000000..39846bc7 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/generate.py @@ -0,0 +1,58 @@ +# 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 — orchestration du générateur +CyberMind — https://cybermind.fr + +Parcourt le sous-ensemble Nuclei vendored, applique KEV + extraction + les deux +barrières de présence, retourne (retenus, rejets-avec-raison). N'écrit rien : +la CLI décide (dry-run par défaut). +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + +from .extract import Candidate, Rejection, extract, is_kev +from .presence import is_appliance, should_generate + + +def generate(subset_dir: Path, present: set[str], + present_complete: bool) -> tuple[list[Candidate], list[tuple[str, str]]]: + """(retenus, [(fichier, raison de rejet)]).""" + kept: list[Candidate] = [] + rejected: list[tuple[str, str]] = [] + + for p in sorted(Path(subset_dir).glob("*.yaml")): + try: + tmpl = yaml.safe_load(p.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + rejected.append((p.name, f"unparseable: {exc}")) + continue + if not isinstance(tmpl, dict): + rejected.append((p.name, "not a template mapping")) + continue + + if not is_kev(tmpl): + rejected.append((p.name, "not KEV (not known-exploited)")) + continue + + result = extract(tmpl) + if isinstance(result, Rejection): + rejected.append((p.name, result.reason)) + continue + + if not should_generate(result, present, present_complete): + why = ("inventory incomplete → present" if not present_complete + else "not an appliance family" if not is_appliance(result) + else "product is present on this box") + rejected.append((p.name, why)) + continue + + kept.append(result) + + return kept, rejected diff --git a/packages/secubox-cve-triage/api/wafgen/inventory.py b/packages/secubox-cve-triage/api/wafgen/inventory.py new file mode 100644 index 00000000..bf4f85c2 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/inventory.py @@ -0,0 +1,88 @@ +# 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 — collecte de l'union de présence +CyberMind — https://cybermind.fr + +Trois sources : les paquets dpkg de l'hôte, les vhosts routés par le WAF, et les +modules secubox. La source « vhosts » est CRUCIALE : nextcloud/gitea tournent en +LXC, absents du dpkg hôte mais routés — sans elle on générerait une règle qui +bannirait un vrai utilisateur. + +Retourne (present_set, complete). complete=False dès qu'UNE source échoue : +l'appelant (should_generate) refuse alors de générer — fail-safe présent. +`run`/`routes_path`/`menu_dir` sont injectés pour tester sans board. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +ROUTES_PATH = Path("/etc/secubox/waf/haproxy-routes.json") +MENU_DIR = Path("/usr/share/secubox/menu.d") + + +def _run_cmd(argv: list[str]) -> tuple[int, str]: + try: + p = subprocess.run(argv, capture_output=True, text=True, timeout=30) + return p.returncode, p.stdout + except (OSError, subprocess.SubprocessError): + return 1, "" + + +def gather_present(*, run=_run_cmd, routes_path: Path = ROUTES_PATH, + menu_dir: Path = MENU_DIR) -> tuple[set[str], bool]: + """Union { dpkg } ∪ { premiers labels des vhosts } ∪ { ids menu.d }. + + complete=False si une source obligatoire échoue → l'appelant traite tout + comme présent (ne génère rien). + """ + present: set[str] = set() + complete = True + + # 1. dpkg — obligatoire ; un échec rend l'inventaire incomplet. + rc, out = run(["dpkg-query", "-W", "-f=${Package}|${Version}|${Architecture}\n"]) + if rc != 0: + complete = False + else: + for line in out.splitlines(): + name = line.split("|", 1)[0].strip().lower() + if name: + present.add(name) + + # 2. vhosts routés — le premier label du domaine (nextcloud.gk2… → nextcloud). + # Fichier absent = pas de vhosts (légitime) ; présent mais illisible = + # source indéterminable → incomplet. + routes_path = Path(routes_path) + if routes_path.exists(): + try: + routes = json.loads(routes_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + complete = False + else: + if isinstance(routes, dict): + for domain in routes: + label = str(domain).split(".", 1)[0].strip().lower() + if label: + present.add(label) + else: + complete = False + + # 3. modules secubox — les ids de menu.d. Répertoire absent = pas de modules. + menu_dir = Path(menu_dir) + if menu_dir.exists(): + for p in sorted(menu_dir.glob("*.json")): + try: + d = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + complete = False + continue + mid = (d.get("id") or "").strip().lower() if isinstance(d, dict) else "" + if mid: + present.add(mid) + + return present, complete diff --git a/packages/secubox-cve-triage/api/wafgen/presence.py b/packages/secubox-cve-triage/api/wafgen/presence.py new file mode 100644 index 00000000..61748920 --- /dev/null +++ b/packages/secubox-cve-triage/api/wafgen/presence.py @@ -0,0 +1,53 @@ +# 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 — oracle de présence (deux barrières) +CyberMind — https://cybermind.fr + +Le mode d'échec à tuer : croire un produit absent alors qu'il est présent → règle +qui matche du trafic légitime → en escalate, ban d'un vrai utilisateur. D'où deux +garde-fous indépendants ; un candidat ne passe que s'il franchit les deux. + +Fonction pure : `present` (l'union des sources) et `present_complete` (toutes les +sources ont-elles été lues) sont injectés — la collecte réelle est dans +inventory.py. Règle de sûreté : dans le doute, PRÉSENT (ne rien générer). +""" +from __future__ import annotations + +from .extract import Candidate + +# Familles d'équipements réseau qu'une box Debian SecuBox n'est CATÉGORIQUEMENT +# jamais. Assertion humaine, versionnée — pas dérivée automatiquement. Un vendor +# hors de cette liste n'est jamais généré, même « prouvé absent » (le mapping +# serait trop faillible). +APPLIANCE_VENDORS = frozenset({ + "f5", "paloaltonetworks", "ivanti", "citrix", "fortinet", "cisco", + "vmware", "sonicwall", "zyxel", "dlink", "netgear", "juniper", +}) + + +def is_appliance(candidate: Candidate) -> bool: + """Barrière 1 : le vendor est-il une famille d'appliance connue-absente ?""" + return candidate.vendor.strip().lower() in APPLIANCE_VENDORS + + +def should_generate(candidate: Candidate, present: set[str], present_complete: bool) -> bool: + """Un candidat n'est généré que s'il franchit LES DEUX barrières. + + Barrière 1 : famille d'appliance (is_appliance). + Barrière 2 : absent de l'union de présence — ET l'union est complète. + Dans le doute (present_complete=False), on refuse : PRÉSENT par défaut. + """ + if not present_complete: + return False + if not is_appliance(candidate): + return False + lowered = {p.strip().lower() for p in present} + if candidate.vendor.strip().lower() in lowered: + return False + if candidate.product.strip().lower() in lowered: + return False + return True diff --git a/packages/secubox-cve-triage/debian/control b/packages/secubox-cve-triage/debian/control index 5114bf96..7102b013 100644 --- a/packages/secubox-cve-triage/debian/control +++ b/packages/secubox-cve-triage/debian/control @@ -7,7 +7,7 @@ Standards-Version: 4.6.2 Package: secubox-cve-triage Architecture: all -Depends: ${misc:Depends}, secubox-core (>= 1.0.0) +Depends: ${misc:Depends}, python3-yaml, secubox-core (>= 1.0.0) Description: SecuBox CVE Triage — vulnerability assessment workflow CVE triage dashboard: pull advisories, score against the installed package set, track remediation. Maintains its own data store under diff --git a/packages/secubox-cve-triage/debian/rules b/packages/secubox-cve-triage/debian/rules index 93cf06bc..8a0464bb 100755 --- a/packages/secubox-cve-triage/debian/rules +++ b/packages/secubox-cve-triage/debian/rules @@ -9,3 +9,7 @@ override_dh_auto_install: cp -r www/cve-triage/. debian/secubox-cve-triage/usr/share/secubox/www/cve-triage/ install -d debian/secubox-cve-triage/usr/share/secubox/menu.d [ -d menu.d ] && cp -r menu.d/. debian/secubox-cve-triage/usr/share/secubox/menu.d/ || true + install -d debian/secubox-cve-triage/usr/sbin + install -m 0755 sbin/secubox-cvectl debian/secubox-cve-triage/usr/sbin/ + install -d debian/secubox-cve-triage/usr/lib/secubox/cve-triage/nuclei-subset + cp -r nuclei-subset/. debian/secubox-cve-triage/usr/lib/secubox/cve-triage/nuclei-subset/ diff --git a/packages/secubox-cve-triage/nuclei-subset/CVE-2024-21887.yaml b/packages/secubox-cve-triage/nuclei-subset/CVE-2024-21887.yaml new file mode 100644 index 00000000..ed67afff --- /dev/null +++ b/packages/secubox-cve-triage/nuclei-subset/CVE-2024-21887.yaml @@ -0,0 +1,15 @@ +id: CVE-2024-21887 +info: + name: Ivanti Connect Secure - Command Injection + severity: critical + classification: + cve-id: CVE-2024-21887 + cpe: cpe:2.3:a:ivanti:connect_secure:*:*:*:*:*:*:*:* + metadata: + vendor: ivanti + product: connect_secure + tags: cve,cve2024,ivanti,rce,kev,vuln +http: + - method: GET + path: + - "{{BaseURL}}/api/v1/totp/user-backup" diff --git a/packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml b/packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml new file mode 100644 index 00000000..1e6af85a --- /dev/null +++ b/packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml @@ -0,0 +1,15 @@ +id: CVE-2024-3400 +info: + name: Palo Alto PAN-OS - Command Injection + 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}}/global-protect/portal/css/bootstrap.min.css" diff --git a/packages/secubox-cve-triage/nuclei-subset/LICENSE b/packages/secubox-cve-triage/nuclei-subset/LICENSE new file mode 100644 index 00000000..5c7e60d4 --- /dev/null +++ b/packages/secubox-cve-triage/nuclei-subset/LICENSE @@ -0,0 +1,16 @@ +The templates in this directory are vendored from projectdiscovery/nuclei-templates +(https://github.com/projectdiscovery/nuclei-templates), MIT License. + +Copyright (c) 2020-2026 ProjectDiscovery, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/packages/secubox-cve-triage/sbin/secubox-cvectl b/packages/secubox-cve-triage/sbin/secubox-cvectl new file mode 100755 index 00000000..a92caed1 --- /dev/null +++ b/packages/secubox-cve-triage/sbin/secubox-cvectl @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# 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 :: secubox-cvectl +set -euo pipefail +readonly MODULE="cve-triage" +readonly VERSION="1.1.0" +cd /usr/lib/secubox/cve-triage +exec /usr/bin/python3 -m api.wafgen.cli "$@" diff --git a/packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh b/packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh new file mode 100755 index 00000000..f244453f --- /dev/null +++ b/packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# 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 :: curate-nuclei-subset — MAINTAINER-ONLY, offline, NOT runtime. +# Clones nuclei-templates and copies KEV + appliance-family + URL-extractable +# templates into nuclei-subset/. Run before a release to refresh the vendored +# set; the .deb ships the copies, the service never clones anything. +set -euo pipefail +readonly OUT="$(dirname "$0")/../nuclei-subset" +readonly TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +git clone --depth 1 https://github.com/projectdiscovery/nuclei-templates "$TMP/nt" +echo "Review candidates by hand; copy the appliance/KEV/clean-path ones into $OUT." +echo "Vendors of interest: f5 paloaltonetworks ivanti citrix fortinet cisco vmware." +grep -rlE "kev" "$TMP/nt/http/cves" | head -50 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_cli.py b/packages/secubox-cve-triage/tests/test_cli.py new file mode 100644 index 00000000..7e25f695 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_cli.py @@ -0,0 +1,89 @@ +# 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 json +import textwrap + +import api.wafgen.cli as cli_mod +from api.wafgen.emit import CATEGORY_KEY + +# One appliance/KEV/clean template (real nested-tags format — tags under +# info:, like the vendored nuclei-subset fixtures after the extract.py fix). +_APPLIANCE_KEV_TEMPLATE = """ + 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"] +""" + + +def _subset_dir(tmp_path): + subset = tmp_path / "nuclei-subset" + subset.mkdir() + (subset / "keep.yaml").write_text(textwrap.dedent(_APPLIANCE_KEV_TEMPLATE)) + return subset + + +def _rules_file(tmp_path): + p = tmp_path / "waf-rules.json" + p.write_text(json.dumps({ + "_meta": {"version": "1.2.0"}, + "categories": { + "sqli": {"name": "SQLi", "severity": "critical", + "patterns": [{"id": "sqli-001", "pattern": "union select", "desc": "x"}]}, + }, + }, indent=2)) + return p + + +def test_default_run_returns_0_and_writes_nothing(tmp_path, monkeypatch): + monkeypatch.setattr(cli_mod, "gather_present", lambda: (set(), True)) + subset = _subset_dir(tmp_path) + rules = _rules_file(tmp_path) + before = rules.read_text() + + rc = cli_mod.main(["waf-rules", "generate", "--subset", str(subset), "--rules", str(rules)]) + + assert rc == 0 + assert rules.read_text() == before + + +def test_apply_returns_0_and_adds_only_product_absent_probes(tmp_path, monkeypatch): + monkeypatch.setattr(cli_mod, "gather_present", lambda: (set(), True)) + subset = _subset_dir(tmp_path) + rules = _rules_file(tmp_path) + + rc = cli_mod.main(["waf-rules", "generate", "--apply", + "--subset", str(subset), "--rules", str(rules)]) + + assert rc == 0 + data = json.loads(rules.read_text()) + # Other category preserved untouched. + assert data["categories"]["sqli"]["patterns"][0]["id"] == "sqli-001" + # Only the generated category was added. + assert set(data["categories"].keys()) == {"sqli", CATEGORY_KEY} + assert data["categories"][CATEGORY_KEY]["mode"] == "detect" + ids = [p["id"] for p in data["categories"][CATEGORY_KEY]["patterns"]] + assert any("2024-3400" in i for i in ids) + + +def test_apply_with_incomplete_inventory_returns_3_and_leaves_rules_untouched(tmp_path, monkeypatch): + # complete=False must be a hard guard: dropping it would let --apply + # write a category built under an unproven presence union (the exact + # fail-safe should_generate()/gather_present() are meant to enforce). + monkeypatch.setattr(cli_mod, "gather_present", lambda: (set(), False)) + subset = _subset_dir(tmp_path) + rules = _rules_file(tmp_path) + before = rules.read_text() + + rc = cli_mod.main(["waf-rules", "generate", "--apply", + "--subset", str(subset), "--rules", str(rules)]) + + assert rc == 3 + assert rules.read_text() == before diff --git a/packages/secubox-cve-triage/tests/test_emit.py b/packages/secubox-cve-triage/tests/test_emit.py new file mode 100644 index 00000000..825d7993 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_emit.py @@ -0,0 +1,145 @@ +# 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 json +import os +import stat + +import pytest + +from api.wafgen.emit import CATEGORY_KEY, build_category, write_category +from api.wafgen.extract import Candidate + + +def cand(cve="CVE-2024-3400", vendor="paloaltonetworks", product="pan-os", + path="/api/v1/totp/user-backup"): + return Candidate(cve=cve, vendor=vendor, product=product, + cpe=f"cpe:2.3:a:{vendor}:{product}:*", path=path, severity="critical") + + +def _rules_file(tmp_path): + p = tmp_path / "waf-rules.json" + p.write_text(json.dumps({ + "_meta": {"version": "1.2.0"}, + "categories": { + "sqli": {"name": "SQLi", "severity": "critical", + "patterns": [{"id": "sqli-001", "pattern": "union select", "desc": "x"}]}, + }, + }, indent=2)) + return p + + +def test_category_is_always_detect_mode(): + c = build_category([cand()], now="2026-07-18T00:00:00Z") + assert c["mode"] == "detect" + assert c["generated"] is True + + +def test_pattern_path_is_regex_escaped(): + # A path with regex-special chars must be escaped so it matches literally. + c = build_category([cand(path="/mgmt/tm/util/bash.php?x=1")], now="2026-07-18T00:00:00Z") + pat = c["patterns"][0]["pattern"] + assert "\\.php" in pat and "\\?" in pat + + +def test_write_preserves_the_other_categories(tmp_path): + p = _rules_file(tmp_path) + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + d = json.loads(p.read_text()) + # sqli untouched, product_absent_probes added. + assert "sqli" in d["categories"] + assert d["categories"]["sqli"]["patterns"][0]["id"] == "sqli-001" + assert CATEGORY_KEY in d["categories"] + assert d["categories"][CATEGORY_KEY]["mode"] == "detect" + assert d["_meta"]["version"] == "1.2.0" + + +def test_regenerate_is_idempotent(tmp_path): + p = _rules_file(tmp_path) + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + first = p.read_text() + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + assert p.read_text() == first + + +def test_regenerate_replaces_only_its_own_category(tmp_path): + p = _rules_file(tmp_path) + write_category(p, [cand(cve="CVE-2024-3400")], now="2026-07-18T00:00:00Z") + write_category(p, [cand(cve="CVE-2023-46747", vendor="f5", product="big-ip", + path="/mgmt/tm/util/bash")], now="2026-07-18T00:00:00Z") + d = json.loads(p.read_text()) + ids = [x["id"] for x in d["categories"][CATEGORY_KEY]["patterns"]] + assert any("2023-46747" in i for i in ids) + assert not any("2024-3400" in i for i in ids) # replaced, not appended + assert "sqli" in d["categories"] + + +def test_empty_candidates_writes_an_empty_category(tmp_path): + p = _rules_file(tmp_path) + write_category(p, [], now="2026-07-18T00:00:00Z") + d = json.loads(p.read_text()) + assert d["categories"][CATEGORY_KEY]["patterns"] == [] + + +def test_temp_file_path_is_unique_per_call(tmp_path, monkeypatch): + # Concurrent writers (CLI cron regen + panel regen) must not share a + # single fixed ".json.tmp" name: two writers racing on the same fixed + # name means one process's os.replace() can promote the OTHER + # process's in-flight content (a silent lost update). Spy on the + # actual src path passed to os.replace() across two calls — with a + # fixed name this would be identical every time; with mkstemp() it + # must differ. + import api.wafgen.emit as emit_mod + + p = _rules_file(tmp_path) + real_replace = os.replace + seen_src_paths = [] + + def spy_replace(src, dst): + seen_src_paths.append(str(src)) + return real_replace(src, dst) + + monkeypatch.setattr(emit_mod.os, "replace", spy_replace) + + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + + assert len(seen_src_paths) == 2 + assert seen_src_paths[0] != seen_src_paths[1] + + +def test_no_leftover_tmp_file_after_successful_write(tmp_path): + p = _rules_file(tmp_path) + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + leftovers = [f.name for f in tmp_path.iterdir() if f.name != p.name] + assert leftovers == [] + + +def test_write_category_raises_on_missing_rules_file(tmp_path): + missing = tmp_path / "waf-rules.json" + with pytest.raises(FileNotFoundError): + write_category(missing, [cand()], now="2026-07-18T00:00:00Z") + + +def test_write_preserves_original_file_permission_mode(tmp_path): + # tempfile.mkstemp() always creates its file 0600 regardless of umask; + # os.replace() would make waf-rules.json inherit that, silently + # tightening it from its live mode (typically 0644) and breaking the + # WAF reader if it runs as a different user (permission-drift outage). + p = _rules_file(tmp_path) + os.chmod(p, 0o644) + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + assert stat.S_IMODE(os.stat(p).st_mode) == 0o644 + + +def test_write_category_raises_on_corrupt_rules_file_and_leaves_it_unchanged(tmp_path): + p = tmp_path / "waf-rules.json" + corrupt = "{ not valid json" + p.write_text(corrupt) + with pytest.raises(json.JSONDecodeError): + write_category(p, [cand()], now="2026-07-18T00:00:00Z") + # A generator must never blank the WAF's live rules on failure. + assert p.read_text() == corrupt + leftovers = [f.name for f in tmp_path.iterdir() if f.name != p.name] + assert leftovers == [] 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..9ece8da2 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_extract.py @@ -0,0 +1,124 @@ +# 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_path_with_second_baseurl_after_stripping_is_rejected(): + # Only the LEADING {{BaseURL}} is stripped; a second occurrence later in + # the path (e.g. in a query string) must not survive into the emitted + # rule as a literal `{{BaseURL}}` fragment. + t = dict(CLEAN, http=[{"method": "GET", + "path": ["{{BaseURL}}/foo?cb={{BaseURL}}/bar"]}]) + r = extract(t) + assert isinstance(r, Rejection) + assert "placeholder" in r.reason.lower() + + +def test_path_without_leading_slash_is_rejected(): + # After stripping {{BaseURL}}, the remainder must be anchored at /. + t = dict(CLEAN, http=[{"method": "GET", "path": ["{{BaseURL}}foo"]}]) + assert isinstance(extract(t), Rejection) + + +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 + + +def test_is_kev_reads_info_nested_tags_real_upstream_format(): + # Real upstream projectdiscovery/nuclei-templates nests `tags` under + # `info:` (alongside classification/metadata, which extract() already + # reads from there). is_kev must read info.tags FIRST — a version that + # only reads the top-level `tags` key would miss every real template + # and silently generate nothing. + t = { + "id": "CVE-2024-3400", + "info": {"severity": "critical", "tags": "cve,cve2024,panos,rce,kev,vuln"}, + } + assert is_kev(t) is True + + +def test_is_kev_falls_back_to_top_level_tags_legacy_fixture_form(): + # Backward-compat: older-style fixtures with `tags` at the top level + # (no info.tags present) must still be detected. + t = {"id": "CVE-2024-1", "info": {"severity": "high"}, "tags": "cve,kev"} + assert is_kev(t) is True + + +def test_is_kev_false_when_neither_level_carries_the_kev_token(): + t = {"id": "CVE-2024-2", "info": {"severity": "high", "tags": "cve,rce"}, + "tags": "cve,rce"} + assert is_kev(t) is False diff --git a/packages/secubox-cve-triage/tests/test_generate.py b/packages/secubox-cve-triage/tests/test_generate.py new file mode 100644 index 00000000..a8dfba9f --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_generate.py @@ -0,0 +1,92 @@ +# 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 textwrap + +from api.wafgen.generate import generate + + +def _write(dir_, name, body): + (dir_ / name).write_text(textwrap.dedent(body)) + + +def test_generate_keeps_appliance_absent_kev_and_rejects_the_rest(tmp_path): + subset = tmp_path / "nuclei-subset" + subset.mkdir() + # kept: appliance (paloalto), absent, KEV, clean path + _write(subset, "keep.yaml", """ + 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,kev,rce + http: + - method: GET + path: ["{{BaseURL}}/api/v1/totp/user-backup"] + """) + # rejected: not KEV + _write(subset, "notkev.yaml", """ + id: CVE-2024-1 + info: + severity: high + metadata: {vendor: f5, product: big-ip} + tags: cve,rce + http: + - method: GET + path: ["{{BaseURL}}/x"] + """) + # rejected: raw + _write(subset, "raw.yaml", """ + id: CVE-2024-2 + info: {severity: high, metadata: {vendor: f5, product: big-ip}} + tags: cve,kev + http: + - raw: ["POST /x HTTP/1.1"] + unsafe: true + """) + + kept, rejected = generate(subset, present=set(), present_complete=True) + + assert [c.cve for c in kept] == ["CVE-2024-3400"] + rej_names = {name for name, _ in rejected} + assert "notkev.yaml" in rej_names and "raw.yaml" in rej_names + + +def test_present_product_is_rejected_by_the_oracle(tmp_path): + subset = tmp_path / "nuclei-subset" + subset.mkdir() + _write(subset, "f5.yaml", """ + id: CVE-2023-46747 + info: + severity: critical + classification: {cve-id: CVE-2023-46747, cpe: "cpe:2.3:a:f5:big-ip:*"} + metadata: {vendor: f5, product: big-ip} + tags: cve,kev + http: + - method: GET + path: ["{{BaseURL}}/mgmt/tm/util/bash"] + """) + # f5 is somehow present → not generated, and recorded as a rejection reason. + kept, rejected = generate(subset, present={"f5"}, present_complete=True) + assert kept == [] + assert any("present" in reason.lower() for _, reason in rejected) + + +def test_incomplete_inventory_generates_nothing(tmp_path): + subset = tmp_path / "nuclei-subset" + subset.mkdir() + _write(subset, "f5.yaml", """ + id: CVE-2023-46747 + info: + severity: critical + classification: {cve-id: CVE-2023-46747} + metadata: {vendor: f5, product: big-ip} + tags: cve,kev + http: + - method: GET + path: ["{{BaseURL}}/mgmt/tm/util/bash"] + """) + kept, _ = generate(subset, present=set(), present_complete=False) + assert kept == [] diff --git a/packages/secubox-cve-triage/tests/test_inventory.py b/packages/secubox-cve-triage/tests/test_inventory.py new file mode 100644 index 00000000..81560a13 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_inventory.py @@ -0,0 +1,111 @@ +# 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 json + +from api.wafgen.inventory import gather_present + + +def fake_run(rc, out): + def _run(argv): + return rc, out + return _run + + +def test_union_of_the_three_sources(tmp_path): + routes = tmp_path / "haproxy-routes.json" + routes.write_text(json.dumps({"nextcloud.gk2.secubox.in": ["127.0.0.1", 9080]})) + menu = tmp_path / "menu.d" + menu.mkdir() + (menu / "peertube.json").write_text(json.dumps({"id": "peertube"})) + run = fake_run(0, "nginx|1.24|arm64\nopenssl|3.0|arm64\n") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=menu) + + assert complete is True + assert "nginx" in present # dpkg + assert "nextcloud" in present # vhost first label + assert "peertube" in present # menu.d id + + +def test_dpkg_failure_marks_incomplete(tmp_path): + (tmp_path / "menu.d").mkdir() + routes = tmp_path / "haproxy-routes.json" + routes.write_text("{}") + # Non-empty output alongside rc!=0: a mutant that parses `out` regardless + # of `rc` would leak "junkpkg" into present and/or report complete=True. + run = fake_run(1, "junkpkg|1.0|arm64\n") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d") + assert complete is False # a source failed → caller must treat as present + assert "junkpkg" not in present # failed dpkg must contribute nothing + + +def test_unreadable_routes_marks_incomplete(tmp_path): + (tmp_path / "menu.d").mkdir() + run = fake_run(0, "nginx|1.24|arm64\n") + # routes file exists but is corrupt + routes = tmp_path / "haproxy-routes.json" + routes.write_text("{ not json") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d") + assert complete is False + + +def test_absent_routes_file_is_complete_and_empty(tmp_path): + # No routes file at all is a legitimate "no public vhosts", not a failure. + (tmp_path / "menu.d").mkdir() + run = fake_run(0, "nginx|1.24|arm64\n") + present, complete = gather_present(run=run, routes_path=tmp_path / "absent.json", + menu_dir=tmp_path / "menu.d") + assert complete is True + assert "nginx" in present + + +def test_routes_valid_json_but_not_a_dict_marks_incomplete(tmp_path): + # Valid JSON that isn't a mapping (null, a number, a string...) must not + # crash `for domain in ...` — it must degrade to complete=False. + (tmp_path / "menu.d").mkdir() + run = fake_run(0, "nginx|1.24|arm64\n") + routes = tmp_path / "haproxy-routes.json" + routes.write_text("null") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d") + assert complete is False + assert "nginx" in present # other sources still gathered + + +def test_routes_number_is_also_rejected(tmp_path): + (tmp_path / "menu.d").mkdir() + run = fake_run(0, "nginx|1.24|arm64\n") + routes = tmp_path / "haproxy-routes.json" + routes.write_text("42") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "menu.d") + assert complete is False + + +def test_unreadable_menu_entry_marks_incomplete(tmp_path): + routes = tmp_path / "haproxy-routes.json" + routes.write_text("{}") + menu = tmp_path / "menu.d" + menu.mkdir() + (menu / "good.json").write_text(json.dumps({"id": "peertube"})) + (menu / "bad.json").write_text("{ not json") + run = fake_run(0, "nginx|1.24|arm64\n") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=menu) + assert complete is False + assert "peertube" in present # good entries still gathered despite the bad one + + +def test_absent_menu_dir_is_complete(tmp_path): + # No menu.d directory at all is a legitimate "no modules", not a failure. + routes = tmp_path / "haproxy-routes.json" + routes.write_text("{}") + run = fake_run(0, "nginx|1.24|arm64\n") + + present, complete = gather_present(run=run, routes_path=routes, menu_dir=tmp_path / "absent-menu.d") + assert complete is True + assert "nginx" in present diff --git a/packages/secubox-cve-triage/tests/test_presence.py b/packages/secubox-cve-triage/tests/test_presence.py new file mode 100644 index 00000000..f62ac615 --- /dev/null +++ b/packages/secubox-cve-triage/tests/test_presence.py @@ -0,0 +1,47 @@ +# 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 +from api.wafgen.presence import APPLIANCE_VENDORS, is_appliance, should_generate + + +def cand(vendor="f5", product="big-ip", path="/mgmt/tm/util/bash"): + return Candidate(cve="CVE-2023-1", vendor=vendor, product=product, + cpe=f"cpe:2.3:a:{vendor}:{product}:*", path=path, severity="high") + + +def test_appliance_families_are_recognised(): + for v in ("f5", "paloaltonetworks", "ivanti", "citrix", "fortinet"): + assert is_appliance(cand(vendor=v)) is True + + +def test_non_appliance_vendor_is_not(): + for v in ("php", "wordpress", "nginx", "apache"): + assert is_appliance(cand(vendor=v)) is False + + +def test_generate_requires_BOTH_appliance_AND_absent(): + # appliance + absent from the union → generate. + assert should_generate(cand(), present=set(), present_complete=True) is True + + +def test_present_in_union_is_never_generated(): + # Even an appliance vendor: if the product is somehow present, do not generate. + assert should_generate(cand(), present={"big-ip"}, present_complete=True) is False + + +def test_non_appliance_is_never_generated_even_if_absent(): + assert should_generate(cand(vendor="php", product="php"), + present=set(), present_complete=True) is False + + +def test_incomplete_inventory_generates_nothing(): + # Fail-safe: if a presence source could not be read, treat as present → + # never generate (better to miss a probe than ban a real user). + assert should_generate(cand(), present=set(), present_complete=False) is False + + +def test_present_match_is_case_insensitive_and_on_vendor_or_product(): + assert should_generate(cand(), present={"F5"}, present_complete=True) is False + assert should_generate(cand(), present={"BIG-IP"}, present_complete=True) is False diff --git a/packages/secubox-cve-triage/www/cve-triage/index.html b/packages/secubox-cve-triage/www/cve-triage/index.html index ee3694cf..2025b3db 100644 --- a/packages/secubox-cve-triage/www/cve-triage/index.html +++ b/packages/secubox-cve-triage/www/cve-triage/index.html @@ -141,6 +141,7 @@ + @@ -229,6 +230,31 @@ Enter a search term above + + +
+

+ Product-absent probe candidates generated from the vendored Nuclei KEV subset. + This is a dry-run preview — nothing is written until you click Generate. + Generation refuses (409) if the presence inventory is incomplete (fail-safe). +

+
+
Present products: -
+
Inventory: -
+ + +
+

Kept 0

+ + + +
CVEVendorPath
+

Rejected 0

+ + + +
FileReason
+
@@ -258,6 +284,10 @@ const token = () => localStorage.getItem('sbx_token'); const headers = () => ({ 'Content-Type': 'application/json', ...(token() ? { 'Authorization': 'Bearer ' + token() } : {}) }); + function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + async function api(path, opts = {}) { try { const res = await fetch(API + path, { ...opts, headers: headers() }); @@ -280,6 +310,7 @@ if (name === 'trending') loadTrending(); if (name === 'recent') loadRecent(); if (name === 'triage') loadTriage(); + if (name === 'waf') loadWafRules(); } // Stats & Feed Summary @@ -577,6 +608,60 @@ refresh(); } + // WAF Probes (product-absent probe generator, dry-run preview + apply) + async function loadWafRules() { + const keptBody = document.getElementById('wafKeptTable'); + const rejBody = document.getElementById('wafRejectedTable'); + keptBody.innerHTML = ''; + rejBody.innerHTML = ''; + + const d = await api('/waf-rules'); + + document.getElementById('wafPresentCount').textContent = d.present_count ?? '-'; + document.getElementById('wafInventoryComplete').textContent = + d.inventory_complete === undefined ? '-' : (d.inventory_complete ? 'complete' : 'INCOMPLETE (fail-safe)'); + + const kept = d.kept || []; + document.getElementById('wafKeptCount').textContent = kept.length; + keptBody.innerHTML = kept.length ? kept.map(c => ` + + ${esc(c.cve)} + ${esc(c.vendor)} + ${esc(c.path)} + + `).join('') : 'No candidates kept'; + + const rejected = d.rejected || []; + document.getElementById('wafRejectedCount').textContent = rejected.length; + rejBody.innerHTML = rejected.length ? rejected.map(r => ` + + ${esc(r.file)} + ${esc(r.reason)} + + `).join('') : 'No rejections'; + } + + async function generateWafRules() { + const btn = document.getElementById('wafGenerateBtn'); + btn.disabled = true; + try { + const res = await fetch(API + '/waf-rules/generate', { method: 'POST', headers: headers() }); + if (res.status === 401) { window.location = '/login.html'; return; } + const text = await res.text(); + const body = text ? JSON.parse(text) : {}; + if (!res.ok) { + alert(`Generate failed: ${body.detail || res.status}`); + return; + } + alert(`Written ${body.written || 0} probe(s) (mode=detect), ${body.rejected || 0} rejected`); + loadWafRules(); + } catch (e) { + alert('Generate failed: request error'); + } finally { + btn.disabled = false; + } + } + // Refresh function refresh() { loadStats();