Fix ASN-exempt whitelist silently failing with Permission denied
set_asn_exempt() was writing /etc/crowdsec/scenarios/local-asterisk_*.yaml directly from the dashboard's own process, but those files are root:root mode 644 and the secdash service user (--shell /usr/sbin/nologin) has no write grant to them - confirmed by the reported "[Errno 13] Permission denied" error. Every whitelist/unban attempt through the dashboard was silently a no-op as far as CrowdSec was concerned, which is why a carrier's IP kept getting re-banned even after repeatedly trying to exempt its ASN. Fixed by routing the edit through a dedicated root-owned helper script (set-asn-exempt.sh, installed alongside app.py, root:root mode 700) that's the one thing added to the sudoers whitelist - same pattern every other CrowdSec-touching action here already uses (cscli via sudo), rather than loosening the scenario files' own permissions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
@@ -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 <digits>\`,
|
||||
\`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
|
||||
<ip> --duration <dur> --type ban --reason <text>\` (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)
|
||||
|
||||
Reference in New Issue
Block a user