feat(cve-triage): panel tab for generated WAF probes (dry-run + apply)

GET /waf-rules previews kept + rejections (writes nothing); POST /waf-rules/
generate applies (detect mode), refusing on an incomplete presence inventory.
Cyan hybrid-skin tab, esc()'d, sbx_token auth. Handlers are plain def (not
async def) since gather_present/generate/write_category do blocking work and
this module runs in-process inside the aggregator's shared event loop.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-18 07:57:07 +02:00
parent fedefd36a4
commit 5f459b1de1
2 changed files with 143 additions and 0 deletions

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

@ -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();