Compare commits

...

4 Commits

Author SHA1 Message Date
65a1a8e494 docs: HISTORY for threat-analyst global overview (#597, PR #598)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-15 11:07:19 +02:00
CyberMind
8fc5dba929
Merge pull request #598 from CyberMind-FR/feature/597-threat-analyst-global-security-overview
feat(threat-analyst): global security overview from WAF + CrowdSec + firewall
2026-06-15 11:06:29 +02:00
919a88c6be fix(threat-analyst): source CrowdSec+firewall from Prometheus :6060, not root cscli/nft (#597)
The daemon runs as the unprivileged 'secubox' user, so cscli (reads
/etc/crowdsec/local_api_credentials.yaml) and 'nft list' (needs root)
both failed silently → CrowdSec showed running=false and firewall=0.
Parse CrowdSec's privilege-free Prometheus endpoint instead: cs_alerts
(detection) + cs_active_decisions (enforcement, materialized in nft by
crowdsec-firewall-bouncer). No privilege escalation, no broken-dep
(secubox-blacklist-sync #521) coupling. WebUI relabelled detection vs
enforcement.
2026-06-15 11:02:06 +02:00
60eeb79185 feat(threat-analyst): global security overview from WAF + CrowdSec + firewall (closes #597)
Add cached /overview endpoint aggregating live WAF (/run/secubox/waf.sock
/stats), CrowdSec (cscli -o json decisions+alerts) and firewall (nft -j
list set blacklist_v4/v6 element counts). Double-buffer background refresh
(60s → overview.json). WebUI gains a 'Vue globale sécurité' card row wired
via loadOverview() in loadAll(); source health line shows WAF/CrowdSec
status + last refresh age.
2026-06-15 10:56:55 +02:00
4 changed files with 205 additions and 1 deletions

View File

@ -3,6 +3,33 @@
---
## 2026-06-15 — threat-analyst: global security overview (1.4.3, live on gk2)
`secubox-threat-analyst` 1.4.1 → 1.4.3, merged via **PR #598 (closes #597)**,
built + deployed live on gk2.
- **#597** — threat-analyst page becomes a **global security overview**: all
metrics dynamic, fed live from WAF + CrowdSec + firewall. New cached
`/overview` endpoint (double-buffer, 60 s background refresh →
`overview.json`) aggregating WAF (`/run/secubox/waf.sock /stats`: threats
today, blocked 24 h, rules loaded), CrowdSec (detection: alerts), firewall
(enforcement: IPs blocked in nft via crowdsec-firewall-bouncer). WebUI gains
a "Vue globale sécurité" card row + source health line (`loadOverview()` in
`loadAll()`).
- **Privilege-safe sourcing**: daemon runs as unprivileged `secubox` user →
`cscli`/`nft list` (both root-only) failed silently. Switched to CrowdSec's
privilege-free **Prometheus :6060** (`cs_alerts` + `cs_active_decisions`).
No privilege escalation, no coupling to broken `secubox-blacklist-sync`.
- Also carried the **1.4.2 build-safe postinst** fix (#595/#596) which had
not yet reached the board (was at 1.4.1; `deb-systemd-helper` enable).
- Live verified: CrowdSec 3712 alerts / 29312 active decisions, firewall
29312 blocked, WAF 140 rules; `/overview` 200 via socket **and** aggregator
proxy (aggregator restarted to re-discover the new route).
**Found, not fixed (separate):** `secubox-blacklist-sync.service` is **failed**
(#521, exit 2) → `secubox_blacklist` nft sets empty. Does not affect the
overview (firewall count comes from the bouncer via Prometheus).
## 2026-06-14 — ToolBoX privacy/perf sprint : 2.6.23 → 2.6.36, all live on gk2
Large feature sprint on `secubox-toolbox` (built + merged + deployed live,

View File

@ -11,6 +11,7 @@ Features:
import os
import json
import time
import asyncio
import logging
import subprocess
from datetime import datetime, timedelta
@ -727,8 +728,125 @@ async def rollback_rule(rule_id: str):
# Startup
# ============================================================================
# ============================================================================
# #597 — Global security overview : aggregate live metrics from WAF +
# CrowdSec + the nft firewall. Double-cache pattern (CLAUDE perf rule) :
# a background task refreshes every 60 s into _OVERVIEW so /overview is
# instant and never blocks on cscli/nft subprocesses. Each source is
# best-effort (partial dict on failure) — one dead source never breaks it.
# ============================================================================
_OVERVIEW: Dict[str, Any] = {}
_OVERVIEW_FILE = DATA_DIR / "overview.json"
_OVERVIEW_TTL = 60
async def _waf_overview() -> Dict[str, Any]:
"""WAF /stats over its unix socket."""
try:
transport = httpx.AsyncHTTPTransport(uds="/run/secubox/waf.sock")
async with httpx.AsyncClient(transport=transport, timeout=4) as c:
r = await c.get("http://waf/stats")
if r.status_code == 200:
s = r.json()
return {
"running": bool(s.get("running")),
"threats_today": s.get("threats_today", 0),
"threats_total": s.get("total_threats", 0),
"blocked_24h": s.get("blocked_24h", 0),
"rules_loaded": s.get("rules_loaded", 0),
"by_category": s.get("by_category", {}),
"by_severity": s.get("by_severity", {}),
"top_countries": s.get("top_countries", [])[:5],
"top_vhosts": s.get("top_vhosts", [])[:5],
}
except Exception as e:
logger.debug("waf overview failed: %s", e)
return {"running": False}
# CrowdSec exposes a privilege-free Prometheus endpoint on :6060. We parse it
# instead of shelling out to `cscli`/`nft` (both need root — this daemon runs as
# the unprivileged `secubox` user, CSPN least-privilege). This gives us both the
# detection layer (cs_alerts) and the enforcement layer (cs_active_decisions,
# which the crowdsec-firewall-bouncer materializes into nft) from one HTTP GET.
_PROM_URL = "http://127.0.0.1:6060/metrics"
def _prom_sum(text: str, prefix: str) -> int:
"""Sum the values of every Prometheus sample line starting with prefix."""
total = 0.0
for line in text.splitlines():
if not line.startswith(prefix) or line.startswith("#"):
continue
try:
total += float(line.rsplit(" ", 1)[1])
except (ValueError, IndexError):
continue
return int(total)
async def _crowdsec_firewall_overview():
"""One privilege-free fetch of CrowdSec Prometheus → (crowdsec, firewall).
crowdsec : detection layer alerts + active decisions
firewall : enforcement layer IPs blocked in nft via crowdsec-firewall-bouncer
"""
cs: Dict[str, Any] = {"running": False, "active_decisions": 0, "alerts": 0}
fw: Dict[str, Any] = {"running": False, "blocked": 0,
"source": "crowdsec-firewall-bouncer (nft)"}
try:
async with httpx.AsyncClient(timeout=4) as c:
r = await c.get(_PROM_URL)
if r.status_code == 200:
active = _prom_sum(r.text, "cs_active_decisions")
alerts = _prom_sum(r.text, "cs_alerts")
cs = {"running": True, "active_decisions": active, "alerts": alerts}
fw = {"running": True, "blocked": active,
"source": "crowdsec-firewall-bouncer (nft)"}
except Exception as e:
logger.debug("crowdsec prometheus overview failed: %s", e)
return cs, fw
async def _build_overview() -> Dict[str, Any]:
waf, (cs, fw) = await asyncio.gather(
_waf_overview(),
_crowdsec_firewall_overview(),
)
return {"waf": waf, "crowdsec": cs, "firewall": fw, "updated": int(time.time())}
async def _overview_refresh_loop():
while True:
try:
ov = await _build_overview()
_OVERVIEW.clear(); _OVERVIEW.update(ov)
try:
_OVERVIEW_FILE.write_text(json.dumps(ov))
except Exception:
pass
except Exception as e:
logger.warning("overview refresh failed: %s", e)
await asyncio.sleep(_OVERVIEW_TTL)
@app.get("/overview")
async def get_overview():
"""Global security overview (WAF + CrowdSec + firewall), 60 s cached."""
if _OVERVIEW:
return _OVERVIEW
if _OVERVIEW_FILE.exists():
try:
return json.loads(_OVERVIEW_FILE.read_text())
except Exception:
pass
return await _build_overview()
@app.on_event("startup")
async def startup():
"""Initialize on startup."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_overview_refresh_loop())
logger.info("Threat Analyst started")

View File

@ -1,3 +1,16 @@
secubox-threat-analyst (1.4.3-1~bookworm1) bookworm; urgency=medium
* feat(overview): global security overview — all metrics now dynamic,
fed live from WAF + CrowdSec + firewall (#597). New cached `/overview`
endpoint aggregates: WAF (threats_today/blocked_24h/rules_loaded via
/run/secubox/waf.sock), CrowdSec (active bans + alerts via cscli -o
json), firewall (blacklisted IP count v4/v6 via nft -j list set).
Double-buffer background refresh (60s, overview.json) + source
health line. WebUI gains a "Vue globale sécurité" card row wired to
the new endpoint via loadOverview() in loadAll().
-- Gerald KERMA <devel@cybermind.fr> Mon, 15 Jun 2026 10:00:00 +0200
secubox-threat-analyst (1.4.2-1~bookworm1) bookworm; urgency=medium
* fix(postinst): build-safe service enable (#595). A plain `systemctl

View File

@ -262,6 +262,31 @@
</div>
</div>
<!-- #597 Global security overview — live from WAF + CrowdSec + firewall -->
<h3 style="margin:1rem 0 .4rem">🛡 Vue globale sécurité <span id="ov-sources" style="font-size:.7rem;color:var(--text-muted)"></span></h3>
<div class="stats">
<div class="stat warn">
<div class="stat-label">WAF — menaces (24h)</div>
<div class="stat-value" id="o-waf-threats"></div>
<div class="stat-hint" id="o-waf-blocked">bloquées: —</div>
</div>
<div class="stat crit">
<div class="stat-label">CrowdSec — détections</div>
<div class="stat-value" id="o-cs-bans"></div>
<div class="stat-hint" id="o-cs-alerts">alertes: —</div>
</div>
<div class="stat">
<div class="stat-label">Firewall — IP bloquées (nft)</div>
<div class="stat-value" id="o-fw-blacklist"></div>
<div class="stat-hint" id="o-fw-split">via crowdsec-bouncer</div>
</div>
<div class="stat">
<div class="stat-label">WAF — règles chargées</div>
<div class="stat-value" id="o-waf-rules"></div>
<div class="stat-hint">moteur d'inspection</div>
</div>
</div>
<!-- Top-N leaderboards — the real insight surface -->
<div class="top-row">
<div class="top-box">
@ -555,8 +580,29 @@
setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`);
await loadAll();
}
// #597 — global overview from WAF + CrowdSec + firewall
async function loadOverview() {
const r = await get('/api/v1/threat-analyst/overview');
diagSet('/overview', r);
if (!r.ok) return;
const o = r.data || {}, w = o.waf || {}, c = o.crowdsec || {}, f = o.firewall || {};
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; };
set('o-waf-threats', w.threats_today ?? '—');
set('o-waf-blocked', 'bloquées: ' + (w.blocked_24h ?? 0) + ' (24h)');
set('o-cs-bans', c.alerts ?? '—');
set('o-cs-alerts', 'décisions actives: ' + (c.active_decisions ?? 0));
set('o-fw-blacklist', f.blocked ?? '—');
set('o-fw-split', f.source || 'via crowdsec-bouncer');
set('o-waf-rules', w.rules_loaded ?? '—');
const up = [];
up.push((w.running ? '🟢' : '🔴') + ' WAF');
up.push((c.running ? '🟢' : '🔴') + ' CrowdSec');
up.push('🟢 Firewall');
const ago = o.updated ? Math.max(0, Math.round(Date.now()/1000 - o.updated)) + 's' : '';
set('ov-sources', up.join(' · ') + (ago ? ' · maj ' + ago : ''));
}
async function loadAll() {
await Promise.all([loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
await Promise.all([loadOverview(), loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
}
// Diagnostic