mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
Merge pull request #876 from CyberMind-FR/feat/waf-product-absent-generator
Some checks are pending
License Headers / check (push) Waiting to run
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:
commit
a64257ca58
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
0
packages/secubox-cve-triage/api/wafgen/__init__.py
Normal file
0
packages/secubox-cve-triage/api/wafgen/__init__.py
Normal file
67
packages/secubox-cve-triage/api/wafgen/cli.py
Normal file
67
packages/secubox-cve-triage/api/wafgen/cli.py
Normal 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())
|
||||
83
packages/secubox-cve-triage/api/wafgen/emit.py
Normal file
83
packages/secubox-cve-triage/api/wafgen/emit.py
Normal 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
|
||||
107
packages/secubox-cve-triage/api/wafgen/extract.py
Normal file
107
packages/secubox-cve-triage/api/wafgen/extract.py
Normal 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"),
|
||||
)
|
||||
58
packages/secubox-cve-triage/api/wafgen/generate.py
Normal file
58
packages/secubox-cve-triage/api/wafgen/generate.py
Normal 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
|
||||
88
packages/secubox-cve-triage/api/wafgen/inventory.py
Normal file
88
packages/secubox-cve-triage/api/wafgen/inventory.py
Normal 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
|
||||
53
packages/secubox-cve-triage/api/wafgen/presence.py
Normal file
53
packages/secubox-cve-triage/api/wafgen/presence.py
Normal 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'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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
15
packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml
Normal file
15
packages/secubox-cve-triage/nuclei-subset/CVE-2024-3400.yaml
Normal 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"
|
||||
16
packages/secubox-cve-triage/nuclei-subset/LICENSE
Normal file
16
packages/secubox-cve-triage/nuclei-subset/LICENSE
Normal 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.
|
||||
12
packages/secubox-cve-triage/sbin/secubox-cvectl
Executable file
12
packages/secubox-cve-triage/sbin/secubox-cvectl
Executable 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 "$@"
|
||||
19
packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh
Executable file
19
packages/secubox-cve-triage/scripts/curate-nuclei-subset.sh
Executable 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
|
||||
11
packages/secubox-cve-triage/tests/conftest.py
Normal file
11
packages/secubox-cve-triage/tests/conftest.py
Normal 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]))
|
||||
89
packages/secubox-cve-triage/tests/test_cli.py
Normal file
89
packages/secubox-cve-triage/tests/test_cli.py
Normal 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
|
||||
145
packages/secubox-cve-triage/tests/test_emit.py
Normal file
145
packages/secubox-cve-triage/tests/test_emit.py
Normal 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 == []
|
||||
124
packages/secubox-cve-triage/tests/test_extract.py
Normal file
124
packages/secubox-cve-triage/tests/test_extract.py
Normal 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
|
||||
92
packages/secubox-cve-triage/tests/test_generate.py
Normal file
92
packages/secubox-cve-triage/tests/test_generate.py
Normal 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 == []
|
||||
111
packages/secubox-cve-triage/tests/test_inventory.py
Normal file
111
packages/secubox-cve-triage/tests/test_inventory.py
Normal 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
|
||||
47
packages/secubox-cve-triage/tests/test_presence.py
Normal file
47
packages/secubox-cve-triage/tests/test_presence.py
Normal 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
|
||||
|
|
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user