#!/bin/bash # CIS Tier-A hardening for a Debian 12 LXC. Idempotent, backs up everything, # gates the SSH restart on `sshd -t`, auto-rolls-back PAM on sanity failure. # Recovery path if anything breaks: `pct exec -- bash` from the PVE host. # # Rollout: copy into the target LXC and run as root, e.g. # pct push install/cis-tierA.sh /root/cis-tierA.sh # pct exec -- bash /root/cis-tierA.sh # First applied to authentik on 2026-07-24, then to 11 further containers. # # NOT hardened on purpose (break-glass foundation): the PVE hosts pve-gamer / # pve-i5 and the Wazuh manager VM. See the wiki for the break-glass chain. # # SSH: section 1 rewrites /etc/ssh/sshd_config.d/99-cis-hardening.conf WHOLESALE # on every run. Host-specific exceptions therefore do NOT belong in that file — # put them in a separate drop-in that sorts AFTER it, e.g. # 99-zz-local-forward.conf. Mind that a Match block extends until the next # Match, across Include file boundaries, so such a drop-in must stay last. # # Forwarding is denied per option (AllowTcpForwarding / AllowAgentForwarding / # AllowStreamLocalForwarding / X11Forwarding), not via DisableForwarding. Both # are equivalent in effect, but DisableForwarding overrides every other # forwarding option AND is invisible in `sshd -T` output — which makes a denied # port-forward practically undiagnosable. See the wiki entry on that. set -u TS=$(date +%Y%m%d-%H%M%S) BK=/root/cis-hardening-backup-$TS mkdir -p "$BK" export DEBIAN_FRONTEND=noninteractive say(){ echo "[cis] $*"; } ########## 1. SSH hardening (drop-in, validated) ########## SSHD=/etc/ssh/sshd_config.d/99-cis-hardening.conf grep -q "Include /etc/ssh/sshd_config.d" /etc/ssh/sshd_config || echo "Include /etc/ssh/sshd_config.d/*.conf" > /tmp/_noinc [ -f "$SSHD" ] && cp "$SSHD" "$BK/" 2>/dev/null printf '%s\n' "Warning: Authorized access only. All activity is monitored." > /etc/issue.net cat > "$SSHD" <<'EOF' PermitRootLogin prohibit-password MaxAuthTries 4 LoginGraceTime 60 ClientAliveInterval 15 ClientAliveCountMax 3 Banner /etc/issue.net MaxStartups 10:30:60 AllowTcpForwarding no AllowAgentForwarding no AllowStreamLocalForwarding no X11Forwarding no Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512 EOF if sshd -t 2>/tmp/sshderr; then systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null say "SSH: drop-in applied + restarted (sshd -t OK, active=$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd 2>/dev/null))" else rm -f "$SSHD"; say "SSH: sshd -t FAILED -> reverted ($(cat /tmp/sshderr))" fi ########## 2. login.defs (password aging + hashing) ########## cp /etc/login.defs "$BK/" setdef(){ if grep -qE "^\s*$1\b" /etc/login.defs; then sed -i -E "s|^\s*$1\b.*|$1 $2|" /etc/login.defs; else echo "$1 $2" >> /etc/login.defs; fi; } setdef PASS_MAX_DAYS 365 setdef PASS_MIN_DAYS 1 setdef PASS_WARN_AGE 7 setdef ENCRYPT_METHOD YESCRYPT useradd -D -f 30 2>/dev/null say "login.defs: aging + YESCRYPT set" ########## 3. pwquality ########## apt-get install -y libpam-pwquality >/dev/null 2>&1 [ -f /etc/security/pwquality.conf ] && cp /etc/security/pwquality.conf "$BK/" cat > /etc/security/pwquality.conf <<'EOF' minlen = 14 minclass = 4 maxrepeat = 3 dictcheck = 1 enforcing = 1 EOF [ -f /etc/security/faillock.conf ] && cp /etc/security/faillock.conf "$BK/" cat > /etc/security/faillock.conf <<'EOF' deny = 5 unlock_time = 900 fail_interval = 900 EOF say "pwquality.conf + faillock.conf written" ########## 4. PAM module enablement (backup + sanity + rollback) ########## CA=/etc/pam.d/common-auth CP=/etc/pam.d/common-password cp "$CA" "$BK/"; cp "$CP" "$BK/" # 5.3.3.4.1 remove nullok sed -i 's/[[:space:]]*nullok//g' "$CA" "$CP" # common-password: full, correctly-formed pwquality + pwhistory lines before pam_unix grep -q pam_pwquality.so "$CP" || sed -i '0,/^password[[:space:]].*pam_unix.so/s//password requisite pam_pwquality.so retry=3\n&/' "$CP" grep -q pam_pwhistory.so "$CP" || sed -i '0,/^password[[:space:]].*pam_unix.so/s//password required pam_pwhistory.so remember=5 use_authtok\n&/' "$CP" grep -qE 'pam_unix.so.*use_authtok' "$CP" || sed -i -E 's/(^password[[:space:]].*pam_unix.so.*)/\1 use_authtok/' "$CP" # common-auth: faillock preauth/authfail/authsucc (full lines, idempotent) if ! grep -q pam_faillock.so "$CA"; then sed -i '1i auth required pam_faillock.so preauth' "$CA" awk 'BEGIN{d=0}{print} (/pam_unix.so/ && d==0){print "auth [default=die] pam_faillock.so authfail"; print "auth sufficient pam_faillock.so authsucc"; d=1}' "$CA" > "$CA.tmp" && mv "$CA.tmp" "$CA" fi # sanity: every referenced module must exist; pam_unix + pam_deny must remain MODDIR=$(dirname "$(find /lib /usr/lib -name pam_unix.so 2>/dev/null | head -1)") ok=1 for m in $(grep -hoE 'pam_[a-z_]+\.so' "$CA" "$CP" | sort -u); do [ -f "$MODDIR/$m" ] || { say "PAM sanity: missing $m"; ok=0; } done grep -q pam_unix.so "$CA" && grep -q pam_unix.so "$CP" || ok=0 if [ "$ok" != "1" ]; then cp "$BK/common-auth" "$CA"; cp "$BK/common-password" "$CP" say "PAM: sanity FAILED -> rolled back common-auth/common-password" else say "PAM: pwquality/pwhistory/faillock enabled, nullok removed (sanity OK)" fi ########## 5. auditd (probe LXC support) ########## apt-get install -y auditd audispd-plugins >/dev/null 2>&1 AUOK=0 if auditctl -l >/dev/null 2>&1 && auditctl -a always,exit -F arch=b64 -S adjtimex -k _probe 2>/dev/null; then auditctl -d always,exit -F arch=b64 -S adjtimex -k _probe 2>/dev/null; AUOK=1 fi if [ "$AUOK" = "1" ]; then cp -n /etc/audit/rules.d/audit.rules "$BK/" 2>/dev/null cat > /etc/audit/rules.d/cis.rules <<'EOF' -w /etc/group -p wa -k identity -w /etc/passwd -p wa -k identity -w /etc/shadow -p wa -k identity -w /etc/gshadow -p wa -k identity -w /etc/security/opasswd -p wa -k identity -w /etc/sudoers -p wa -k scope -w /etc/sudoers.d/ -p wa -k scope -w /var/log/sudo.log -p wa -k sudo_log -a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change -a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale -w /etc/hosts -p wa -k system-locale -w /etc/network/ -p wa -k system-locale -w /var/log/wtmp -p wa -k session -w /var/log/btmp -p wa -k session -w /var/run/utmp -p wa -k session -w /var/log/lastlog -p wa -k logins -w /var/run/faillock/ -p wa -k logins -a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts -a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=1000 -F auid!=4294967295 -k delete -a always,exit -F arch=b64 -S chmod,fchmod,fchmodat,chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod -a always,exit -F arch=b64 -S init_module,delete_module,finit_module -k modules -w /usr/sbin/usermod -p x -k usermod EOF systemctl enable auditd >/dev/null 2>&1 augenrules --load >/dev/null 2>&1 systemctl restart auditd 2>/dev/null || service auditd restart 2>/dev/null say "auditd: FUNCTIONAL in this LXC -> CIS rules loaded ($(auditctl -l 2>/dev/null | wc -l) rules)" else systemctl disable --now auditd >/dev/null 2>&1 apt-get purge -y auditd audispd-plugins >/dev/null 2>&1 say "auditd: NOT supported in this LXC (host owns audit subsystem) -> N/A, removed" fi say "DONE. Backup: $BK"