diff --git a/packages/secubox-cve-triage/api/main.py b/packages/secubox-cve-triage/api/main.py index 474fae4c..a5cfc37c 100644 --- a/packages/secubox-cve-triage/api/main.py +++ b/packages/secubox-cve-triage/api/main.py @@ -1090,6 +1090,64 @@ async def list_kev_cves(): return {"cves": kev_cves, "count": len(kev_cves), "source": "CISA KEV"} +# ============================================================================ +# WAF Product-Absent Probe Generator (wafgen) +# ============================================================================ +# Handlers are plain `def`, not `async def`: gather_present() shells out to +# dpkg, generate() does blocking file I/O over the vendored Nuclei subset, and +# write_category() is a blocking atomic write. This module is imported +# in-process by the secubox aggregator, which runs ONE shared event loop for +# ~110 modules — a blocking call inside an `async def` handler would freeze +# the whole board. Plain `def` handlers run in FastAPI's threadpool instead. + +@app.get("/waf-rules", dependencies=[Depends(require_jwt)]) +def waf_rules_preview(): + """Dry-run: what would be generated (kept + rejections). Writes nothing.""" + from .wafgen.generate import generate + from .wafgen.inventory import gather_present + + present, complete = gather_present() + kept, rejected = generate( + Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete + ) + return { + "present_count": len(present), + "inventory_complete": complete, + "kept": [ + {"cve": c.cve, "vendor": c.vendor, "product": c.product, "path": c.path} + for c in kept + ], + "rejected": [{"file": n, "reason": r} for n, r in rejected], + } + + +@app.post("/waf-rules/generate", dependencies=[Depends(require_jwt)]) +def waf_rules_generate(): + """Apply: write product_absent_probes (detect mode). Refuses if the + presence inventory is incomplete (fail-safe).""" + from .wafgen.generate import generate + from .wafgen.inventory import gather_present + from .wafgen.emit import write_category + + present, complete = gather_present() + if not complete: + raise HTTPException( + status_code=409, + detail="presence inventory incomplete — refusing (fail-safe)", + ) + kept, rejected = generate( + Path("/usr/lib/secubox/cve-triage/nuclei-subset"), present, complete + ) + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + write_category(Path("/etc/secubox/waf/waf-rules.json"), kept, now=now) + return { + "success": True, + "written": len(kept), + "mode": "detect", + "rejected": len(rejected), + } + + # ============================================================================ # Startup # ============================================================================ diff --git a/packages/secubox-cve-triage/www/cve-triage/index.html b/packages/secubox-cve-triage/www/cve-triage/index.html index ee3694cf..2025b3db 100644 --- a/packages/secubox-cve-triage/www/cve-triage/index.html +++ b/packages/secubox-cve-triage/www/cve-triage/index.html @@ -141,6 +141,7 @@ + @@ -229,6 +230,31 @@ Enter a search term above + + +
+

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

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

Kept 0

+ + + +
CVEVendorPath
+

Rejected 0

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