Compare commits

..

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

6 changed files with 13 additions and 133 deletions

View File

@ -173,18 +173,12 @@ class ThreatAnalyzer:
f.write(json.dumps(alert.model_dump()) + "\n")
def get_recent_alerts(self, hours: int = 24, source: Optional[str] = None) -> List[ThreatAlert]:
"""Get recent alerts, deduplicated by id (last occurrence wins).
The collector appends on every poll, so the same CrowdSec alert id can
recur many times without dedup the headline counts and Top-N
leaderboards are massively inflated.
"""
"""Get recent alerts."""
cutoff = datetime.utcnow() - timedelta(hours=hours)
by_id: Dict[str, ThreatAlert] = {}
anon = 0
alerts = []
if not self.alerts_file.exists():
return []
return alerts
with open(self.alerts_file) as f:
for line in f:
@ -195,82 +189,35 @@ class ThreatAnalyzer:
continue
if source and data.get("source") != source:
continue
aid = data.get("id")
if not aid:
aid = f"_anon-{anon}"; anon += 1
by_id[aid] = ThreatAlert(**data)
alerts.append(ThreatAlert(**data))
except Exception:
continue
return list(by_id.values())
def compact_alerts(self, hours: int = 48):
"""Rewrite alerts.jsonl keeping only the last `hours`, deduped by id —
keeps the append-only log from growing unbounded."""
if not self.alerts_file.exists():
return
cutoff = datetime.utcnow() - timedelta(hours=hours)
recent: Dict[str, Dict[str, Any]] = {}
anon = 0
try:
with open(self.alerts_file) as f:
for line in f:
try:
data = json.loads(line)
ts = datetime.fromisoformat(data["timestamp"].rstrip("Z"))
if ts < cutoff:
continue
aid = data.get("id") or f"_anon-{anon}"
if not data.get("id"):
anon += 1
recent[aid] = data
except Exception:
continue
tmp = self.alerts_file.with_suffix(".jsonl.tmp")
with open(tmp, "w") as f:
for data in recent.values():
f.write(json.dumps(data) + "\n")
tmp.replace(self.alerts_file)
except Exception as e:
logger.warning("compact_alerts failed: %s", e)
return alerts
async def collect_crowdsec_alerts(self) -> List[ThreatAlert]:
"""Collect alerts from CrowdSec."""
alerts = []
try:
# The daemon runs as the unprivileged `secubox` user; `cscli` needs
# root (reads /etc/crowdsec/local_api_credentials.yaml). We go through
# the read-only sudo grant shipped in /etc/sudoers.d/secubox-threat-
# analyst (sudo lives here on the BACKEND only — the frontend just
# consumes the resulting values).
result = subprocess.run(
["sudo", "-n", "/usr/bin/cscli",
"alerts", "list", "-o", "json", "-l", "200"],
["cscli", "alerts", "list", "-o", "json"],
capture_output=True,
text=True,
timeout=15
timeout=10
)
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout) or []
for item in data[:200]:
src = item.get("source") or {}
# `remediation` is a bool, not a severity — map it.
severity = "high" if item.get("remediation") else "medium"
if result.returncode == 0:
data = json.loads(result.stdout)
for item in data[:50]: # Limit to 50
alert = ThreatAlert(
id=f"cs-{item.get('id', '')}",
source="crowdsec",
severity=severity,
severity=item.get("remediation", "medium"),
type=item.get("scenario", "unknown"),
ip=src.get("ip") or src.get("value"),
ip=item.get("source", {}).get("ip"),
details=item,
timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z")
)
alerts.append(alert)
else:
logger.warning(
"cscli alerts list failed (rc=%s): %s",
result.returncode, (result.stderr or "").strip()[:200]
)
except Exception as e:
logger.warning(f"CrowdSec collection failed: {e}")
@ -897,31 +844,9 @@ async def get_overview():
return await _build_overview()
_COLLECT_TTL = 300 # 5 min
async def _collect_refresh_loop():
"""Backend auto-collect: keep the alerts DB fed from CrowdSec + WAF even
when no operator has the page open. Compacts the log after each run so it
stays bounded (and deduped). subprocess work is brief and best-effort."""
while True:
try:
cs = await analyzer.collect_crowdsec_alerts()
waf = await analyzer.collect_waf_alerts()
for a in cs + waf:
analyzer.record_alert(a)
analyzer.compact_alerts()
if cs or waf:
logger.info("auto-collect: %d crowdsec + %d waf alerts", len(cs), len(waf))
except Exception as e:
logger.warning("auto-collect failed: %s", e)
await asyncio.sleep(_COLLECT_TTL)
@app.on_event("startup")
async def startup():
"""Initialize on startup."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
asyncio.create_task(_overview_refresh_loop())
asyncio.create_task(_collect_refresh_loop())
logger.info("Threat Analyst started")

View File

@ -1,21 +1,3 @@
secubox-threat-analyst (1.4.4-1~bookworm1) bookworm; urgency=medium
* feat(ingest): populate the headline stats + Top-N leaderboards with real
CrowdSec data (#599). The collector shelled out to bare `cscli`, which
fails for the unprivileged `secubox` user the daemon runs as → the alerts
DB stayed empty (Unique attackers / Active threats / Countries / Top-N all
0). Now goes through a read-only sudo grant (`/etc/sudoers.d/
secubox-threat-analyst`: `cscli alerts list *` + `decisions list *`,
visudo-validated in postinst) and fetches `-l 200`. sudo is BACKEND-only;
the frontend stays value-only.
* feat(collect): backend auto-collect loop (~5 min) so the DB fills without
the page open; severity mapped correctly (`remediation` is a bool).
* fix(dedup): `get_recent_alerts` now dedups by alert id (last wins) and a
bounded 48 h compaction rewrites `alerts.jsonl` — the append-on-every-poll
log was massively inflating counts and leaderboards.
-- Gerald KERMA <devel@cybermind.fr> Mon, 15 Jun 2026 12:00:00 +0200
secubox-threat-analyst (1.4.3-1~bookworm1) bookworm; urgency=medium
* feat(overview): global security overview — all metrics now dynamic,

View File

@ -1,19 +1,6 @@
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
# #599 — validate the read-only cscli sudo grant; drop it if malformed so a
# bad drop-in can never break sudo for the whole system.
SUDOERS=/etc/sudoers.d/secubox-threat-analyst
if [ -f "$SUDOERS" ]; then
chmod 0440 "$SUDOERS" || true
if command -v visudo >/dev/null 2>&1; then
if ! visudo -cf "$SUDOERS" >/dev/null 2>&1; then
echo "secubox-threat-analyst: invalid sudoers drop-in, removing" >&2
rm -f "$SUDOERS"
fi
fi
fi
# #595 — build-safe enable: a plain `systemctl enable` no-ops during an
# offline / image-build (chroot) install, leaving the unit DISABLED on
# the live board (caught on gk2: /threat-analyst/ backend down). Use

View File

@ -9,7 +9,3 @@ override_dh_auto_install:
cp -r www/threat-analyst/. debian/secubox-threat-analyst/usr/share/secubox/www/threat-analyst/
install -d debian/secubox-threat-analyst/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. debian/secubox-threat-analyst/usr/share/secubox/menu.d/ || true
# Read-only cscli sudo grant (#599) — backend ingestion of CrowdSec alerts.
install -d debian/secubox-threat-analyst/etc/sudoers.d
install -m 0440 debian/sudoers.d/secubox-threat-analyst \
debian/secubox-threat-analyst/etc/sudoers.d/secubox-threat-analyst

View File

@ -13,10 +13,7 @@ Restart=on-failure
RestartSec=5
UMask=0000
# #599 — allow the read-only `sudo cscli` ingestion grant (sibling pattern:
# secubox-crowdsec / secubox-waf also set this). The sudo scope is tightly
# limited to read-only `cscli alerts/decisions list` in /etc/sudoers.d.
NoNewPrivileges=no
NoNewPrivileges=true
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
RuntimeDirectoryMode=0775

View File

@ -1,7 +0,0 @@
# SecuBox threat-analyst — read-only CrowdSec ingestion.
# The FastAPI daemon runs as the unprivileged `secubox` user and needs to read
# CrowdSec alerts/decisions to populate the analyst dashboard. `cscli` needs
# root (it reads /etc/crowdsec/local_api_credentials.yaml). Only READ-ONLY
# subcommands are granted here — no add/delete/restart. CSPN least-privilege.
secubox ALL=(root) NOPASSWD: /usr/bin/cscli alerts list *
secubox ALL=(root) NOPASSWD: /usr/bin/cscli decisions list *