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") f.write(json.dumps(alert.model_dump()) + "\n")
def get_recent_alerts(self, hours: int = 24, source: Optional[str] = None) -> List[ThreatAlert]: def get_recent_alerts(self, hours: int = 24, source: Optional[str] = None) -> List[ThreatAlert]:
"""Get recent alerts, deduplicated by id (last occurrence wins). """Get recent alerts."""
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.
"""
cutoff = datetime.utcnow() - timedelta(hours=hours) cutoff = datetime.utcnow() - timedelta(hours=hours)
by_id: Dict[str, ThreatAlert] = {} alerts = []
anon = 0
if not self.alerts_file.exists(): if not self.alerts_file.exists():
return [] return alerts
with open(self.alerts_file) as f: with open(self.alerts_file) as f:
for line in f: for line in f:
@ -195,82 +189,35 @@ class ThreatAnalyzer:
continue continue
if source and data.get("source") != source: if source and data.get("source") != source:
continue continue
aid = data.get("id") alerts.append(ThreatAlert(**data))
if not aid:
aid = f"_anon-{anon}"; anon += 1
by_id[aid] = ThreatAlert(**data)
except Exception: except Exception:
continue continue
return list(by_id.values()) return alerts
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)
async def collect_crowdsec_alerts(self) -> List[ThreatAlert]: async def collect_crowdsec_alerts(self) -> List[ThreatAlert]:
"""Collect alerts from CrowdSec.""" """Collect alerts from CrowdSec."""
alerts = [] alerts = []
try: 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( result = subprocess.run(
["sudo", "-n", "/usr/bin/cscli", ["cscli", "alerts", "list", "-o", "json"],
"alerts", "list", "-o", "json", "-l", "200"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=15 timeout=10
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0:
data = json.loads(result.stdout) or [] data = json.loads(result.stdout)
for item in data[:200]: for item in data[:50]: # Limit to 50
src = item.get("source") or {}
# `remediation` is a bool, not a severity — map it.
severity = "high" if item.get("remediation") else "medium"
alert = ThreatAlert( alert = ThreatAlert(
id=f"cs-{item.get('id', '')}", id=f"cs-{item.get('id', '')}",
source="crowdsec", source="crowdsec",
severity=severity, severity=item.get("remediation", "medium"),
type=item.get("scenario", "unknown"), type=item.get("scenario", "unknown"),
ip=src.get("ip") or src.get("value"), ip=item.get("source", {}).get("ip"),
details=item, details=item,
timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z") timestamp=item.get("created_at", datetime.utcnow().isoformat() + "Z")
) )
alerts.append(alert) alerts.append(alert)
else:
logger.warning(
"cscli alerts list failed (rc=%s): %s",
result.returncode, (result.stderr or "").strip()[:200]
)
except Exception as e: except Exception as e:
logger.warning(f"CrowdSec collection failed: {e}") logger.warning(f"CrowdSec collection failed: {e}")
@ -897,31 +844,9 @@ async def get_overview():
return await _build_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") @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()) asyncio.create_task(_overview_refresh_loop())
asyncio.create_task(_collect_refresh_loop())
logger.info("Threat Analyst started") 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 secubox-threat-analyst (1.4.3-1~bookworm1) bookworm; urgency=medium
* feat(overview): global security overview — all metrics now dynamic, * feat(overview): global security overview — all metrics now dynamic,

View File

@ -1,19 +1,6 @@
#!/bin/sh #!/bin/sh
set -e set -e
if [ "$1" = "configure" ]; then 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 # #595 — build-safe enable: a plain `systemctl enable` no-ops during an
# offline / image-build (chroot) install, leaving the unit DISABLED on # offline / image-build (chroot) install, leaving the unit DISABLED on
# the live board (caught on gk2: /threat-analyst/ backend down). Use # 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/ 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 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 [ -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 RestartSec=5
UMask=0000 UMask=0000
# #599 — allow the read-only `sudo cscli` ingestion grant (sibling pattern: NoNewPrivileges=true
# 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
RuntimeDirectory=secubox RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes RuntimeDirectoryPreserve=yes
RuntimeDirectoryMode=0775 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 *