Merge pull request #876 from CyberMind-FR/feat/waf-product-absent-generator
Some checks are pending
License Headers / check (push) Waiting to run

feat(cve-triage): WAF product-absent rule generator (detect-mode)
This commit is contained in:
CyberMind 2026-07-18 08:09:44 +02:00 committed by GitHub
commit a64257ca58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1335 additions and 1 deletions

View File

@ -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.

View File

@ -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
# ============================================================================

View File

@ -0,0 +1,67 @@
# 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 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())

View File

@ -0,0 +1,83 @@
# 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 é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

View File

@ -0,0 +1,107 @@
# 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"),
)

View File

@ -0,0 +1,58 @@
# 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 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

View File

@ -0,0 +1,88 @@
# 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 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

View File

@ -0,0 +1,53 @@
# 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 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' 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

View File

@ -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

View File

@ -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/

View File

@ -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"

View File

@ -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"

View File

@ -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.

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 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 :: 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 "$@"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# 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 :: 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

View 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]))

View File

@ -0,0 +1,89 @@
# 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 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

View File

@ -0,0 +1,145 @@
# 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 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 == []

View File

@ -0,0 +1,124 @@
# 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_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

View File

@ -0,0 +1,92 @@
# 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 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 == []

View File

@ -0,0 +1,111 @@
# 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 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

View File

@ -0,0 +1,47 @@
# 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
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

View File

@ -141,6 +141,7 @@
<button class="tab" onclick="showTab('recent')">Recent (7d)</button>
<button class="tab" onclick="showTab('triage')">My Triage</button>
<button class="tab" onclick="showTab('search')">Search</button>
<button class="tab" onclick="showTab('waf')">WAF Probes</button>
</div>
<!-- CISA KEV Tab -->
@ -229,6 +230,31 @@
<tbody id="searchTable"><tr><td colspan="5" style="text-align:center;color:var(--p31-dim);padding:2rem;">Enter a search term above</td></tr></tbody>
</table>
</div>
<!-- WAF Probes Tab -->
<div class="tab-content" id="tab-waf">
<p style="color:var(--p31-dim);font-size:0.75rem;margin-bottom:0.75rem;">
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).
</p>
<div class="form-row" style="align-items:center;">
<div style="font-size:0.75rem;">Present products: <strong id="wafPresentCount">-</strong></div>
<div style="font-size:0.75rem;">Inventory: <strong id="wafInventoryComplete">-</strong></div>
<button class="btn primary" id="wafGenerateBtn" onclick="generateWafRules()">Generate</button>
<button class="btn" onclick="loadWafRules()">Refresh</button>
</div>
<h2 style="font-size:0.8rem;margin-top:0.75rem;">Kept <span class="count" id="wafKeptCount">0</span></h2>
<table>
<thead><tr><th>CVE</th><th>Vendor</th><th>Path</th></tr></thead>
<tbody id="wafKeptTable"><tr><td colspan="3" class="loading"><span class="spinner"></span></td></tr></tbody>
</table>
<h2 style="font-size:0.8rem;margin-top:0.75rem;">Rejected <span class="count" id="wafRejectedCount">0</span></h2>
<table>
<thead><tr><th>File</th><th>Reason</th></tr></thead>
<tbody id="wafRejectedTable"><tr><td colspan="2" class="loading"><span class="spinner"></span></td></tr></tbody>
</table>
</div>
</div>
<!-- Package Scan Card -->
@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<tr><td colspan="3" class="loading"><span class="spinner"></span></td></tr>';
rejBody.innerHTML = '<tr><td colspan="2" class="loading"><span class="spinner"></span></td></tr>';
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 => `
<tr>
<td><code>${esc(c.cve)}</code></td>
<td>${esc(c.vendor)}</td>
<td><code>${esc(c.path)}</code></td>
</tr>
`).join('') : '<tr><td colspan="3" style="text-align:center;color:var(--p31-dim);padding:2rem;">No candidates kept</td></tr>';
const rejected = d.rejected || [];
document.getElementById('wafRejectedCount').textContent = rejected.length;
rejBody.innerHTML = rejected.length ? rejected.map(r => `
<tr>
<td><code>${esc(r.file)}</code></td>
<td>${esc(r.reason)}</td>
</tr>
`).join('') : '<tr><td colspan="2" style="text-align:center;color:var(--p31-dim);padding:2rem;">No rejections</td></tr>';
}
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();