diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 948ea4a..aac93f2 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -108,7 +108,7 @@ install_security-dashboard() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create system user $SVC_USER" echo "[DRY-RUN] Would write $APP_DIR/app.py" - echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl only)" + echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl/set-asn-exempt.sh only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the PSTN Trunk tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" @@ -129,6 +129,7 @@ install_security-dashboard() { log_info "Refreshing app code + sudoers rule + systemd unit (no Caddy/domain changes)..." _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" _secdash_write_app "$APP_DIR" + _secdash_write_asn_helper "$APP_DIR" _secdash_write_sudoers "$SVC_USER" _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" systemctl restart security-dashboard 2>/dev/null \ @@ -163,6 +164,7 @@ install_security-dashboard() { mkdir -p "$APP_DIR" _secdash_write_app "$APP_DIR" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" + _secdash_write_asn_helper "$APP_DIR" _secdash_write_sudoers "$SVC_USER" _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" @@ -230,13 +232,16 @@ sudo journalctl -u security-dashboard -f ## Security notes - Runs as a dedicated, unprivileged system user (\`secdash\`), not root. -- Sudo access is scoped to exactly five commands via +- Sudo access is scoped to exactly six commands via \`/etc/sudoers.d/security-dashboard\`: \`cscli decisions delete --id \`, \`cscli decisions list -o json\`, \`cscli alerts list -o json\` (read-only, used to label ASN exemptions with a carrier name from past alerts and to find known offending IPs for the "Ban" action), \`cscli decisions add --ip --duration --type ban --reason \` (used only by "Ban"), - and \`systemctl restart crowdsec\`. Nothing else. + \`systemctl restart crowdsec\`, and \`set-asn-exempt.sh\` (root:root, mode + 700, installed alongside \`app.py\` — the one thing that edits CrowdSec's + Asterisk-scenario YAMLs, since \`secdash\` has no write access to those + root-owned files directly and shouldn't). 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. @@ -344,6 +349,7 @@ $_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/cscli decisions add --ip * --duration * --type ban --reason * $_svc_user ALL=(root) NOPASSWD: /usr/bin/systemctl restart crowdsec +$_svc_user ALL=(root) NOPASSWD: /opt/security-dashboard/set-asn-exempt.sh * SUDOERS chmod 440 /etc/sudoers.d/security-dashboard visudo -c -f /etc/sudoers.d/security-dashboard >/dev/null 2>&1 \ @@ -530,6 +536,69 @@ _secdash_remove_caddy_block() { log_info "Removed the existing dashboard Caddy block (regenerating it fresh)." } +# Root-owned helper for editing CrowdSec's Asterisk-scenario YAMLs — the +# secdash service user (--shell /usr/sbin/nologin, no special file grants) +# cannot write /etc/crowdsec/scenarios/*.yaml directly (root:root, mode +# 644): confirmed live, a direct write from app.py failed with "[Errno 13] +# Permission denied". Rather than loosen those files' own permissions, +# route the edit through this one whitelisted root helper via sudo — same +# pattern every other CrowdSec-touching action here already uses (cscli via +# run_sudo), just for a plain file edit instead of a cscli subcommand. +# Mode 700 root:root: secdash can still invoke it (sudoers grants running +# it AS root regardless of the file's own permission bits), but nothing +# else on the box can execute it directly. +_secdash_write_asn_helper() { + local _app_dir="$1" + cat > "$_app_dir/set-asn-exempt.sh" << 'ASNHELPER' +#!/bin/bash +# Auto-generated by services/security-dashboard.sh — do not edit directly, +# re-run the installer instead. Invoked ONLY via sudo, by app.py's +# set_asn_exempt() (see /etc/sudoers.d/security-dashboard for the exact +# grant). Args are ASN numbers (already validated by the caller, but +# re-validated here too since this runs as root — never trust the caller +# alone for a root-executed script). +set -uo pipefail + +SCENARIO_FILES=( + /etc/crowdsec/scenarios/local-asterisk_bf.yaml + /etc/crowdsec/scenarios/local-asterisk_user_enum.yaml +) + +clean_asns=() +for a in "$@"; do + [[ "$a" =~ ^[0-9]+$ ]] && clean_asns+=("$a") +done + +expr="" sep="" +for a in "${clean_asns[@]}"; do + expr="${expr}${sep}'${a}'" + sep=", " +done + +found=0 +for f in "${SCENARIO_FILES[@]}"; do + if [[ -f "$f" ]]; then + found=1 + sed -i "s/ASNNumber in \[[^]]*\])/ASNNumber in [${expr}])/" "$f" || exit 1 + fi +done + +if [[ "$found" != "1" ]]; then + echo "No CrowdSec scenario files found to update" >&2 + exit 1 +fi + +if ! systemctl restart crowdsec; then + echo "Wrote ASN list but failed to restart CrowdSec" >&2 + exit 2 +fi + +echo "OK" +ASNHELPER + chown root:root "$_app_dir/set-asn-exempt.sh" + chmod 700 "$_app_dir/set-asn-exempt.sh" +} + # 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() { @@ -557,6 +626,12 @@ ASN_SCENARIO_FILES = [ "/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml", ] +# Root-owned helper for the one write (edit + crowdsec restart) — this +# service user (--shell /usr/sbin/nologin) has no write access to +# ASN_SCENARIO_FILES (root:root, mode 644) and shouldn't; see +# _secdash_write_asn_helper in services/security-dashboard.sh for why this +# goes through sudo instead of loosening those files' permissions. +ASN_HELPER_SCRIPT = "/opt/security-dashboard/set-asn-exempt.sh" TS_RE = re.compile(r"^\[([^\]]+)\]") KV_RE = re.compile(r'(\w+)="([^"]*)"') @@ -706,22 +781,15 @@ def set_asn_exempt(asn_list): # exempt ASN (the "unwhitelist" action) can actually reach zero instead # of being stuck refusing an empty save. clean = sorted(set(a.strip() for a in asn_list if ASN_RE.match(a.strip()))) - expr = ", ".join("'%s'" % a for a in clean) - for path in ASN_SCENARIO_FILES: - try: - with open(path) as f: - content = f.read() - except OSError: - continue - new_content = ASN_FILTER_RE.sub("ASNNumber in [%s])" % expr, content) - try: - with open(path, "w") as f: - f.write(new_content) - except OSError as e: - return False, "Failed writing %s: %s" % (path, e) - ok, out, err = run_sudo(["/usr/bin/systemctl", "restart", "crowdsec"]) + # Editing ASN_SCENARIO_FILES directly from this process used to fail + # with "[Errno 13] Permission denied" (root:root, mode 644, this + # service user has no write grant) — every ASN whitelist attempt was + # silently a no-op as far as CrowdSec was concerned. Routed through the + # sudoers-whitelisted root helper instead, same pattern every other + # CrowdSec-touching action here already uses. + ok, out, err = run_sudo([ASN_HELPER_SCRIPT] + clean) if not ok: - return False, "Wrote ASN list but failed to restart CrowdSec: %s" % (err or out) + return False, "Failed updating ASN exemption: %s" % (err or out or "unknown error") if not clean: return True, "Cleared — no ASNs exempted, all Asterisk traffic is evaluated normally again." return True, "Updated: %s" % ", ".join(clean)