#!/bin/bash # Read-only Stage 1 base-hardening audit. Checks the current state of a PVE # host against the checklist in docs/04-security-hardening.md and prints # PASS/FAIL per item. Exits non-zero if anything fails, so it can gate CI or # be run periodically as a compliance check. Makes no changes. # # Usage: ./audit.sh (run as root on the PVE host) set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" require_root AUDIT_FAIL=0 # --- apt repos: no enabled enterprise source --- ENTERPRISE_ENABLED=0 for f in /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do [ -f "$f" ] || continue grep -qi 'enterprise.proxmox.com' "$f" 2>/dev/null && ENTERPRISE_ENABLED=1 done if [ "$ENTERPRISE_ENABLED" -eq 0 ]; then audit_pass "no enabled enterprise apt repo" else audit_fail "an enterprise apt repo is still enabled (needs a subscription to update)" fi # --- SSH --- SSHD_T="$(sshd -T 2>/dev/null)" if echo "$SSHD_T" | grep -qiE '^permitrootlogin (prohibit-password|without-password)'; then audit_pass "sshd: PermitRootLogin prohibit-password (key-only)" else audit_fail "sshd: PermitRootLogin is not key-only (prohibit-password/without-password)" fi if echo "$SSHD_T" | grep -qi '^passwordauthentication no'; then audit_pass "sshd: PasswordAuthentication no" else audit_fail "sshd: PasswordAuthentication is not disabled" fi # --- fail2ban --- if systemctl is-active --quiet fail2ban 2>/dev/null; then audit_pass "fail2ban is active" else audit_fail "fail2ban is not active" fi # --- PVE firewall --- FW_STATUS="$(pve-firewall status 2>/dev/null || true)" if echo "$FW_STATUS" | grep -qi '^Status: enabled'; then audit_pass "pve-firewall is enabled" else audit_fail "pve-firewall is not enabled (status: ${FW_STATUS:-unknown})" fi if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi '^policy_in:\s*DROP' /etc/pve/firewall/cluster.fw 2>/dev/null; then audit_pass "cluster.fw has default-deny inbound policy" else audit_fail "cluster.fw missing or does not default-deny inbound" fi # --- unattended-upgrades --- if dpkg -s unattended-upgrades >/dev/null 2>&1 && systemctl is-enabled --quiet unattended-upgrades 2>/dev/null; then audit_pass "unattended-upgrades installed and enabled" else audit_fail "unattended-upgrades not installed/enabled" fi if [ -f /var/run/reboot-required ]; then audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one" fi # --- named admin user (not just root@pam) --- if pveum user list --output-format json 2>/dev/null | grep -q '"userid":"[^"]*@pve"'; then audit_pass "a named @pve admin user exists (root@pam is not the only account)" else audit_fail "no named @pve user found - root@pam is the only account" fi # --- time sync --- if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then audit_pass "clock is NTP-synchronized" else audit_fail "clock is not NTP-synchronized" fi # --- subscription nag (cosmetic - warn only, never fails the audit) --- JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" if [ -f "$JS_FILE" ] && ! grep -qF "data.status.toLowerCase() !== 'active'" "$JS_FILE"; then audit_pass "subscription nag patch applied" else audit_warn "subscription nag patch not applied (cosmetic only, see scripts/disable-subscription-nag.sh)" fi echo if [ "$AUDIT_FAIL" -eq 0 ]; then echo "All Stage 1 base-hardening checks passed." else echo "One or more checks failed - see FAIL lines above." fi exit "$AUDIT_FAIL"