security-dashboard: label ASN exemptions from alert history too

Currently-exempt ASNs with no active ban (e.g. T-Mobile once its bans
stop firing) had no carrier name to show, since the name lookup only
looked at cscli decisions list (active bans only). Add a
cscli alerts list-based fallback (includes expired/resolved alerts)
and merge it into the name lookup used by /api/asn-exempt.
This commit is contained in:
Claude
2026-07-21 21:19:45 +00:00
parent 9e98c90a57
commit 9770232227
+57 -18
View File
@@ -112,8 +112,9 @@ install_security-dashboard() {
}
case "$MODE" in
update)
log_info "Refreshing app code only (no config/domain changes)..."
log_info "Refreshing app code + sudoers rule (no config/domain changes)..."
_secdash_write_app "$APP_DIR"
_secdash_write_sudoers "$SVC_USER"
systemctl restart security-dashboard 2>/dev/null \
&& log_success "security-dashboard restarted" \
|| log_warning "Restart failed — check: systemctl status security-dashboard"
@@ -146,21 +147,7 @@ install_security-dashboard() {
_secdash_write_app "$APP_DIR"
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
# ── Scoped sudo — only the exact commands the app needs, nothing else ───
# Numeric-only glob on the decision ID; Python subprocess calls always pass
# args as a list (no shell=True anywhere), so there's no shell-metachar
# injection surface even before sudoers' own pattern match kicks in — the
# server-side ID validation (must be all-digits) happens before this is
# ever reached, this is defense in depth, not the only check.
cat > /etc/sudoers.d/security-dashboard << SUDOERS
$SVC_USER ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete --id [0-9]*
$SVC_USER ALL=(root) NOPASSWD: /usr/bin/cscli decisions list -o json
$SVC_USER ALL=(root) NOPASSWD: /usr/bin/systemctl restart crowdsec
SUDOERS
chmod 440 /etc/sudoers.d/security-dashboard
visudo -c -f /etc/sudoers.d/security-dashboard >/dev/null 2>&1 \
&& log_success "Sudoers rule installed and validated" \
|| { log_error "Sudoers rule failed validation — removing it (dashboard's CrowdSec tab won't work until fixed)"; rm -f /etc/sudoers.d/security-dashboard; }
_secdash_write_sudoers "$SVC_USER"
# ── systemd unit ──────────────────────────────────────────────────────────
cat > /etc/systemd/system/security-dashboard.service << SDSVC
@@ -323,9 +310,11 @@ sudo journalctl -u security-dashboard -f
## Security notes
- Runs as a dedicated, unprivileged system user (\`secdash\`), not root.
- Sudo access is scoped to exactly three commands via
- Sudo access is scoped to exactly four commands via
\`/etc/sudoers.d/security-dashboard\`: \`cscli decisions delete --id <digits>\`,
\`cscli decisions list -o json\`, and \`systemctl restart crowdsec\`. Nothing else.
\`cscli decisions list -o json\`, \`cscli alerts list -o json\` (read-only,
used only to label ASN exemptions with a carrier name from past alerts),
and \`systemctl restart crowdsec\`. Nothing else.
- Listens on all interfaces (Caddy reaches it via \`host.docker.internal\`, a
Docker bridge IP — a loopback-only bind refuses that). Access is scoped by
UFW instead, allowed only from Caddy's internal network, not the internet.
@@ -339,6 +328,30 @@ README_MD
echo ""
}
# Scoped sudo — only the exact commands the app needs, nothing else. Numeric-
# only glob on the decision ID; Python subprocess calls always pass args as a
# list (no shell=True anywhere), so there's no shell-metachar injection
# surface even before sudoers' own pattern match kicks in — the server-side
# ID validation (must be all-digits) happens before this is ever reached,
# this is defense in depth, not the only check. Separate function, called
# from both "update" and fresh-install, so adding a new permission later
# (like alerts list, added after ASN-exempt entries with no currently-active
# ban had no carrier name to show) reaches existing installs on their next
# update instead of silently only applying to new ones.
_secdash_write_sudoers() {
local _svc_user="$1"
cat > /etc/sudoers.d/security-dashboard << SUDOERS
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete --id [0-9]*
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions list -o json
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli alerts list -o json
$_svc_user ALL=(root) NOPASSWD: /usr/bin/systemctl restart crowdsec
SUDOERS
chmod 440 /etc/sudoers.d/security-dashboard
visudo -c -f /etc/sudoers.d/security-dashboard >/dev/null 2>&1 \
&& log_success "Sudoers rule installed and validated" \
|| { log_error "Sudoers rule failed validation — removing it (dashboard's CrowdSec tab won't work until fixed)"; rm -f /etc/sudoers.d/security-dashboard; }
}
# Writes the Python app. Separate function so "update" mode (refresh code,
# keep config) and fresh installs share one copy instead of drifting apart.
_secdash_write_app() {
@@ -453,6 +466,30 @@ def delete_decision(decision_id):
return ok, (err or out or ("deleted" if ok else "failed"))
def get_alert_history_names():
"""ASN -> as_name map built from historical alerts (cscli alerts list,
unlike decisions list, includes expired/resolved ones). A successfully
exempted ASN (e.g. T-Mobile once its bans stop firing) has no *active*
decision left to source a name from — this is the fallback that still
finds one, from the alert that was raised before the exemption took
effect."""
ok, out, err = run_sudo(["/usr/bin/cscli", "alerts", "list", "-o", "json"])
if not ok or not out.strip():
return {}
try:
data = json.loads(out)
except json.JSONDecodeError:
return {}
names = {}
for alert in data or []:
source = alert.get("source") or {}
asn = source.get("as_number")
name = source.get("as_name")
if asn and name:
names.setdefault(str(asn), name)
return names
def get_asn_exempt(known_names=None):
"""known_names: optional {asn: as_name} lookup, built from current
decisions, to label already-exempt ASNs that aren't actively generating
@@ -683,6 +720,8 @@ class Handler(BaseHTTPRequestHandler):
elif self.path == "/api/asn-exempt":
decisions = get_decisions()
known_names = {d["as_number"]: d["as_name"] for d in decisions if d.get("as_number")}
for asn, name in get_alert_history_names().items():
known_names.setdefault(asn, name)
self._json({"asns": get_asn_exempt(known_names)})
else:
self._json({"error": "not found"}, 404)