Compare commits

..

No commits in common. "65a1a8e494597b1ccb87af612e9c9f0230d0e69b" and "91cba0bbda66214bce004c57093265e0263a00d1" have entirely different histories.

4 changed files with 1 additions and 205 deletions

View File

@ -3,33 +3,6 @@
--- ---
## 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,7 +11,6 @@ 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
@ -728,125 +727,8 @@ 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,16 +1,3 @@
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,31 +262,6 @@
</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">
@ -580,29 +555,8 @@
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([loadOverview(), loadStats(), loadAlerts(), loadRules(), loadEmancipated()]); await Promise.all([loadStats(), loadAlerts(), loadRules(), loadEmancipated()]);
} }
// Diagnostic // Diagnostic