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 ## 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, Large feature sprint on `secubox-toolbox` (built + merged + deployed live,

View File

@ -11,6 +11,7 @@ Features:
import os import os
import json import json
import time import time
import asyncio
import logging import logging
import subprocess import subprocess
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -727,8 +728,125 @@ async def rollback_rule(rule_id: str):
# Startup # 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") @app.on_event("startup")
async def startup(): async def startup():
"""Initialize on startup.""" """Initialize on startup."""
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_overview_refresh_loop())
logger.info("Threat Analyst started") 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 secubox-threat-analyst (1.4.2-1~bookworm1) bookworm; urgency=medium
* fix(postinst): build-safe service enable (#595). A plain `systemctl * fix(postinst): build-safe service enable (#595). A plain `systemctl

View File

@ -262,6 +262,31 @@
</div> </div>
</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 --> <!-- Top-N leaderboards — the real insight surface -->
<div class="top-row"> <div class="top-row">
<div class="top-box"> <div class="top-box">
@ -555,8 +580,29 @@
setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`); setAutoStatus('', `collected ${c.crowdsec ?? 0} cs · ${c.waf ?? 0} waf — ${new Date().toLocaleTimeString()}`);
await loadAll(); 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() { async function loadAll() {
await Promise.all([loadStats(), loadAlerts(), loadRules(), loadEmancipated()]); await Promise.all([loadOverview(), loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
} }
// Diagnostic // Diagnostic