#!/usr/bin/env bash # ansipa-enforce-policies.sh — enforce FreeIPA host-group-driven policies on this client. # # Policies are idempotent and reversible: joining a group applies the policy; # leaving the group removes it on the next run (every 30 min via systemd timer). # # Host-group naming conventions (device policies — applied to the whole machine): # policy-daemon-enable- Ensure is enabled and running (systemctl enable --now). # Leaving the group reverts: service is disabled and stopped. # policy-daemon-disable- Ensure is disabled and stopped (systemctl disable --now). # Leaving the group reverts: service is re-enabled and started. # may omit the .service suffix; all systemd unit types work. # If a unit appears in both enable and disable groups it is skipped. # policy-timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed) # policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans # policy-scan-notify (see User-group section below — treated as a user group) # no_local_users Lock passwords for root and all local users (UID >= 1000) so # that only FreeIPA domain accounts with centrally-managed sudo # rules can authenticate and gain elevated privileges. # Leaving the group reverts: every account locked by this policy # is unlocked on the next run. # local_sudo_ Grant full sudo on this specific device by adding # them to the local sudoers drop-in. Leaving the group removes # the drop-in on the next run. # # User-group naming conventions (per-user policies — follow the user across devices): # policy-block-binary- Prevent members of this FreeIPA user group from running # on any enrolled host. Use __ in place of . to support Flatpak # app IDs (e.g. policy-block-binary-org__gimp__Gimp blocks the # Flatpak org.gimp.Gimp). Enforced via a PATH-priority wrapper # that checks group membership at runtime via SSSD/id(1). # Removing the user group from FreeIPA reverts the wrapper. # policy-scan-notify Members receive scan alert notifications on every enrolled device # they log into. The fetch-alerts timer is installed fleet-wide # when the group exists; the notification daemon starts on login # only for group members (checked via id(1) / SSSD). # Deleting the IPA user group removes the timer and profile.d # snippet on the next enforcer run. # # Notes: # - Install scan tools first: add the host to ansipa-module-anti-malware. # - Configure Timeshift (type + target device) before enabling policy-timeshift-backup. set -euo pipefail LOG_TAG="ansipa-policies" STATE_DIR="/var/lib/ansipa-policies" BLOCK_DIR="/usr/local/bin" CRON_DIR="/etc/cron.d" log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; } warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; } HOST_FQDN=$(hostname -f 2>/dev/null || hostname) if ! command -v ipa &>/dev/null; then warn "ipa command not found — host not enrolled in FreeIPA. Exiting." exit 0 fi kinit -k "host/$HOST_FQDN" &>/dev/null || true mkdir -p "$STATE_DIR" # ── Fetch host group membership ─────────────────────────────────────────────── RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \ | grep -i "Member of host-groups:" | sed 's/.*: //' || true) # ── Parse active host-group (device) policies ───────────────────────────────── ACTIVE_DAEMON_ENABLE=() ACTIVE_DAEMON_DISABLE=() ACTIVE_LOCAL_SUDO_USERS=() WANT_TIMESHIFT_BACKUP=false WANT_SECURITY_SCAN=false WANT_SCAN_NOTIFY=false WANT_NO_LOCAL_USERS=false if [[ -n "$RAW_GROUPS" ]]; then while IFS=',' read -ra GRP_ARRAY; do for g in "${GRP_ARRAY[@]}"; do g="${g// /}" case "$g" in policy-daemon-enable-*) ACTIVE_DAEMON_ENABLE+=("${g#policy-daemon-enable-}") ;; policy-daemon-disable-*) ACTIVE_DAEMON_DISABLE+=("${g#policy-daemon-disable-}") ;; policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;; policy-security-scan) WANT_SECURITY_SCAN=true ;; no_local_users) WANT_NO_LOCAL_USERS=true ;; local_sudo_*) ACTIVE_LOCAL_SUDO_USERS+=("${g#local_sudo_}") ;; esac done done <<< "$RAW_GROUPS" fi # ── Fetch user-group-based binary block policies from FreeIPA ───────────────── # policy-block-binary- groups are FreeIPA *user* groups — membership follows # the user to every enrolled host rather than being tied to a device. ACTIVE_BLOCK_BINARIES=() ACTIVE_BLOCK_IPA_GROUPS=() _BLOCK_LIST=$(ipa group-find --pkey-only 2>/dev/null \ | awk '/Group name:/ {print $NF}' \ | grep "^policy-block-binary-" | sort -u || true) while IFS= read -r _grp; do [[ -z "$_grp" ]] && continue _raw="${_grp#policy-block-binary-}" ACTIVE_BLOCK_BINARIES+=("${_raw//__/.}") ACTIVE_BLOCK_IPA_GROUPS+=("$_grp") done <<< "$_BLOCK_LIST" unset _BLOCK_LIST _grp _raw # ── Fetch user-group-based scan-notify policy from FreeIPA ──────────────────── # policy-scan-notify is a FreeIPA *user* group — membership follows the user to # every enrolled host. Install the fetch-alerts timer on any device where the # group exists; the profile.d snippet gates daemon start on runtime membership. if ipa group-show policy-scan-notify >/dev/null 2>&1; then WANT_SCAN_NOTIFY=true fi log "Device policies — daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none}" \ "| daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \ "| timeshift-backup: $WANT_TIMESHIFT_BACKUP | security-scan: $WANT_SECURITY_SCAN" \ "| no-local-users: $WANT_NO_LOCAL_USERS | local-sudo: ${ACTIVE_LOCAL_SUDO_USERS[*]:-none}" log "User policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \ "| scan-notify: $WANT_SCAN_NOTIFY" # ── Helpers ─────────────────────────────────────────────────────────────────── _in_block_list() { local needle="$1" for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do [[ "$b" == "$needle" ]] && return 0 done return 1 } # ── Binary blocking (user-based) ────────────────────────────────────────────── # A PATH-priority wrapper is installed in /usr/local/bin/ for every binary named # by a policy-block-binary-* FreeIPA *user* group. The wrapper checks the # caller's group membership at runtime (via id + SSSD) and only blocks members; # non-members are transparently passed through to the real binary. # __ in the group suffix decodes to . so Flatpak app IDs are fully supported. # Removing the IPA user group causes the wrapper to be cleaned up on the next run. BLOCK_STATE="$STATE_DIR/blocked-binaries" [[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE" for _idx in "${!ACTIVE_BLOCK_BINARIES[@]}"; do BIN="${ACTIVE_BLOCK_BINARIES[$_idx]}" IPA_GRP="${ACTIVE_BLOCK_IPA_GROUPS[$_idx]}" WRAPPER="$BLOCK_DIR/$BIN" # Write (or refresh) the wrapper when it is absent, not ours, or the group name changed. if [[ ! -f "$WRAPPER" ]] \ || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null \ || ! grep -qF "$IPA_GRP" "$WRAPPER" 2>/dev/null; then log "Installing user-aware block wrapper: $BIN (group: $IPA_GRP)" cat > "$WRAPPER" </dev/null | tr ' ' '\n' | grep -qxF "${IPA_GRP}"; then echo "[ansipa-policies] '${BIN}' is blocked by system policy for your account." >&2 exit 1 fi _real=\$(PATH="/usr/bin:/usr/sbin:/bin:/sbin:/usr/local/sbin:/opt/bin:/var/lib/flatpak/exports/bin:/usr/share/flatpak/exports/bin" command -v "${BIN}" 2>/dev/null) [[ -n "\$_real" ]] && exec "\$_real" "\$@" command -v flatpak &>/dev/null && exec flatpak run "${BIN}" "\$@" 2>/dev/null echo "${BIN}: command not found" >&2 exit 127 WRAPPER chmod 755 "$WRAPPER" fi done unset _idx # Remove wrappers whose IPA user group no longer exists. while IFS= read -r OLD_BIN; do [[ -z "$OLD_BIN" ]] && continue if ! _in_block_list "$OLD_BIN"; then WRAPPER="$BLOCK_DIR/$OLD_BIN" if [[ -f "$WRAPPER" ]] && grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then rm -f "$WRAPPER" log "Removed binary block wrapper: $OLD_BIN" fi fi done < "$BLOCK_STATE" # Persist current blocked list. if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE" else > "$BLOCK_STATE" fi # ── Timeshift daily backup ───────────────────────────────────────────────────── TIMESHIFT_CRON="$CRON_DIR/ansipa-timeshift-backup" if [[ "$WANT_TIMESHIFT_BACKUP" == true ]]; then if [[ ! -f "$TIMESHIFT_CRON" ]]; then if ! command -v timeshift &>/dev/null; then warn "timeshift not found — add host to ansipa-module-timeshift first. Cron will be installed anyway." fi log "Enabling daily Timeshift backups" cat > "$TIMESHIFT_CRON" <<'CRON' # ansipa-policy-timeshift-backup: managed by ansipa-enforce-policies — do not edit manually. # Timeshift must be configured on this host (type + target device) before snapshots work. 0 3 * * * root /usr/bin/timeshift --create --comments "ansipa-daily" --tags D 2>&1 | logger -t timeshift-backup CRON chmod 644 "$TIMESHIFT_CRON" fi else if [[ -f "$TIMESHIFT_CRON" ]]; then rm -f "$TIMESHIFT_CRON" log "Removed Timeshift backup cron (host left policy-timeshift-backup group)" fi fi # ── Security scan ───────────────────────────────────────────────────────────── SCAN_CRON="$CRON_DIR/ansipa-security-scan" SCAN_SCRIPT="/usr/local/bin/ansipa-security-scan.sh" if [[ "$WANT_SECURITY_SCAN" == true ]]; then # (Re-)write the scan script so it stays current with this version of the enforcer. cat > "$SCAN_SCRIPT" <<'SCAN' #!/bin/bash # ansipa-security-scan — daily ClamAV / rkhunter / chkrootkit run + SMB upload. # Managed by ansipa-enforce-policies — do not edit manually. LOG=/var/log/ansipa-security-scan.log HOSTNAME=$(hostname -f 2>/dev/null || hostname) DATE=$(date +%Y-%m-%d) { echo "=== ansipa-security-scan: $DATE $HOSTNAME ===" if command -v freshclam &>/dev/null; then freshclam --quiet 2>/dev/null || true fi if command -v clamscan &>/dev/null; then clamscan -r --infected --quiet /home /etc /tmp /var/tmp 2>/dev/null || true fi if command -v rkhunter &>/dev/null; then rkhunter --update --quiet 2>/dev/null || true rkhunter --check --skip-keypress --quiet 2>/dev/null || true fi if command -v chkrootkit &>/dev/null; then chkrootkit 2>/dev/null || true fi echo "=== scan complete ===" } >> "$LOG" 2>&1 # ── Upload to server SMB share ──────────────────────────────────────────────── IPA_SERVER=$(awk '/^server[[:space:]]*=/{print $3}' /etc/ipa/default.conf 2>/dev/null || echo "") if [[ -n "$IPA_SERVER" ]] && [[ -f /etc/ansipa-smb.creds ]] && command -v smbclient &>/dev/null; then # Create host archive dir (mkdir is idempotent; errors suppressed). smbclient "//$IPA_SERVER/ansipa-scans" -A /etc/ansipa-smb.creds \ -c "mkdir archive; mkdir archive\\$HOSTNAME; put $LOG archive\\$HOSTNAME\\$DATE.log" \ >> "$LOG" 2>&1 \ && echo "[ansipa] Scan results uploaded to $IPA_SERVER/ansipa-scans/archive/$HOSTNAME/$DATE.log" >> "$LOG" \ || echo "[ansipa][WARN] SMB upload failed — results remain local at $LOG" >> "$LOG" else echo "[ansipa] SMB upload skipped (no credentials or smbclient not found)." >> "$LOG" fi SCAN chmod 755 "$SCAN_SCRIPT" if [[ ! -f "$SCAN_CRON" ]]; then log "Enabling daily security scans (ClamAV / rkhunter / chkrootkit)" cat > "$SCAN_CRON" <<'CRON' # ansipa-policy-security-scan: managed by ansipa-enforce-policies — do not edit manually. # Install scan tools by adding the host to the ansipa-module-anti-malware group. 0 2 * * * root /usr/local/bin/ansipa-security-scan.sh CRON chmod 644 "$SCAN_CRON" fi else if [[ -f "$SCAN_CRON" ]]; then rm -f "$SCAN_CRON" rm -f "$SCAN_SCRIPT" log "Removed security scan policy (host left policy-security-scan group)" fi fi # ── Scan notification daemon ────────────────────────────────────────────────── # policy-scan-notify is a FreeIPA *user* group (not a host group). The fetch- # alerts timer runs fleet-wide on any host where the group exists; the profile.d # snippet starts the notification daemon on login only for group members # (checked via id(1) / SSSD so no IPA query is needed at login time). # - Root timer (every 10 min): ansipa-fetch-alerts.sh downloads alerts from the # server SMB share and places them in ~/administration// per active user. # - profile.d snippet: starts ansipa-scan-notify.sh as a user daemon on login; # the daemon sends notify-send every 10 min while *.alert files remain. # Deleting a file from ~/administration/ counts as acknowledgment. # # Requires: ansipa-fetch-alerts.sh and ansipa-scan-notify.sh deployed by # deploy-ansipa-policies.yml (static scripts — not written inline here). FETCH_SVC="/etc/systemd/system/ansipa-fetch-alerts.service" FETCH_TIMER="/etc/systemd/system/ansipa-fetch-alerts.timer" NOTIFY_PROFILED="/etc/profile.d/ansipa-notify.sh" if [[ "$WANT_SCAN_NOTIFY" == true ]]; then if [[ ! -x /usr/local/bin/ansipa-fetch-alerts.sh ]]; then warn "ansipa-fetch-alerts.sh not found — run deploy-ansipa-policies.yml first." fi if [[ ! -f "$FETCH_SVC" ]]; then log "Installing ansipa-fetch-alerts systemd service + timer" cat > "$FETCH_SVC" <<'UNIT' [Unit] Description=Fetch Ansipa security alerts from the server SMB share After=network-online.target sssd.service Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/local/bin/ansipa-fetch-alerts.sh StandardOutput=journal StandardError=journal UNIT cat > "$FETCH_TIMER" <<'UNIT' [Unit] Description=Periodic ansipa security alert fetch [Timer] OnBootSec=2min OnUnitActiveSec=10min [Install] WantedBy=timers.target UNIT systemctl daemon-reload systemctl enable --now ansipa-fetch-alerts.timer log "ansipa-fetch-alerts.timer enabled" fi if [[ ! -f "$NOTIFY_PROFILED" ]]; then log "Installing /etc/profile.d/ansipa-notify.sh" cat > "$NOTIFY_PROFILED" <<'PROFILED' # ansipa-notify: launch the scan alert notification daemon on login for # members of the policy-scan-notify FreeIPA user group. # Managed by ansipa-enforce-policies — do not edit manually. _NOTIFY_DAEMON=/usr/local/bin/ansipa-scan-notify.sh if [[ -x "$_NOTIFY_DAEMON" ]] && \ id -nG 2>/dev/null | grep -qw "policy-scan-notify" && \ ! pgrep -u "$(id -u)" -f "ansipa-scan-notify" >/dev/null 2>&1; then "$_NOTIFY_DAEMON" & disown fi unset _NOTIFY_DAEMON PROFILED chmod 644 "$NOTIFY_PROFILED" fi else if [[ -f "$FETCH_TIMER" ]]; then systemctl disable --now ansipa-fetch-alerts.timer 2>/dev/null || true rm -f "$FETCH_SVC" "$FETCH_TIMER" systemctl daemon-reload log "Removed ansipa-fetch-alerts timer (policy-scan-notify user group no longer exists)" fi if [[ -f "$NOTIFY_PROFILED" ]]; then rm -f "$NOTIFY_PROFILED" log "Removed /etc/profile.d/ansipa-notify.sh" fi fi # ── Daemon enable / disable ─────────────────────────────────────────────────── # policy-daemon-enable-: ensure the unit is enabled and running. # Leaving the group reverts: unit is disabled and stopped. # policy-daemon-disable-: ensure the unit is disabled and stopped. # Leaving the group reverts: unit is re-enabled and started. # may omit the .service suffix; systemd accepts both forms. # Conflicts (unit in both lists): logged as a warning, unit is left untouched. DAEMON_ENABLE_STATE="$STATE_DIR/daemon-enabled" DAEMON_DISABLE_STATE="$STATE_DIR/daemon-disabled" [[ -f "$DAEMON_ENABLE_STATE" ]] || touch "$DAEMON_ENABLE_STATE" [[ -f "$DAEMON_DISABLE_STATE" ]] || touch "$DAEMON_DISABLE_STATE" # Append .service only when the name has no unit-type suffix already. _svc_unit() { [[ "$1" == *.* ]] && echo "$1" || echo "${1}.service"; } _in_enable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_ENABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; } _in_disable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_DISABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; } # Apply enable policies for _SVC in "${ACTIVE_DAEMON_ENABLE[@]}"; do if _in_disable_list "$_SVC"; then warn "Conflict: '$_SVC' is in both daemon-enable and daemon-disable groups — skipped" continue fi _UNIT=$(_svc_unit "$_SVC") _EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found") _AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive") if [[ "$_EN" != "enabled" || "$_AC" != "active" ]]; then log "Enabling service: $_UNIT (enabled=$_EN active=$_AC)" systemctl enable --now "$_UNIT" 2>/dev/null \ && log "Service enabled: $_UNIT" \ || warn "Failed to enable $_UNIT — unit may not exist on this host" fi done # Apply disable policies for _SVC in "${ACTIVE_DAEMON_DISABLE[@]}"; do if _in_enable_list "$_SVC"; then continue # conflict already warned above fi _UNIT=$(_svc_unit "$_SVC") _EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found") _AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive") if [[ "$_EN" == "enabled" || "$_AC" == "active" ]]; then log "Disabling service: $_UNIT (enabled=$_EN active=$_AC)" systemctl disable --now "$_UNIT" 2>/dev/null \ && log "Service disabled: $_UNIT" \ || warn "Failed to disable $_UNIT — unit may not exist on this host" fi done # Revert: host left a daemon-enable group → disable and stop the service while IFS= read -r _OLD; do [[ -z "$_OLD" ]] && continue if ! _in_enable_list "$_OLD"; then _UNIT=$(_svc_unit "$_OLD") log "Reverting enable policy: disabling $_UNIT (host left daemon-enable group)" systemctl disable --now "$_UNIT" 2>/dev/null \ || warn "Failed to disable (revert) $_UNIT" fi done < "$DAEMON_ENABLE_STATE" # Revert: host left a daemon-disable group → re-enable and start the service while IFS= read -r _OLD; do [[ -z "$_OLD" ]] && continue if ! _in_disable_list "$_OLD"; then _UNIT=$(_svc_unit "$_OLD") log "Reverting disable policy: enabling $_UNIT (host left daemon-disable group)" systemctl enable --now "$_UNIT" 2>/dev/null \ || warn "Failed to enable (revert) $_UNIT" fi done < "$DAEMON_DISABLE_STATE" # Persist current state if [[ ${#ACTIVE_DAEMON_ENABLE[@]} -gt 0 ]]; then printf '%s\n' "${ACTIVE_DAEMON_ENABLE[@]}" | sort -u > "$DAEMON_ENABLE_STATE" else > "$DAEMON_ENABLE_STATE" fi if [[ ${#ACTIVE_DAEMON_DISABLE[@]} -gt 0 ]]; then printf '%s\n' "${ACTIVE_DAEMON_DISABLE[@]}" | sort -u > "$DAEMON_DISABLE_STATE" else > "$DAEMON_DISABLE_STATE" fi # ── Per-device local sudo grants ────────────────────────────────────────────── # local_sudo_: write a sudoers drop-in granting full sudo on # this specific device. The drop-in is removed when the host leaves the group. LOCAL_SUDO_DIR="/etc/sudoers.d" LOCAL_SUDO_STATE="$STATE_DIR/local-sudo-users" [[ -f "$LOCAL_SUDO_STATE" ]] || touch "$LOCAL_SUDO_STATE" for _USER in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do _DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_USER}" if [[ ! -f "$_DROPIN" ]]; then log "Granting local sudo to $_USER on this device" echo "$_USER ALL=(ALL) ALL" > "$_DROPIN" chmod 440 "$_DROPIN" fi grep -qxF "$_USER" "$LOCAL_SUDO_STATE" 2>/dev/null || echo "$_USER" >> "$LOCAL_SUDO_STATE" done # Revoke sudo for users no longer in any active local_sudo_* group. while IFS= read -r _OLD_USER; do [[ -z "$_OLD_USER" ]] && continue _still_active=false for _U in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do [[ "$_U" == "$_OLD_USER" ]] && _still_active=true && break done if [[ "$_still_active" == false ]]; then _DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_OLD_USER}" if [[ -f "$_DROPIN" ]]; then rm -f "$_DROPIN" log "Revoked local sudo for $_OLD_USER (host left local_sudo_$_OLD_USER group)" fi fi done < "$LOCAL_SUDO_STATE" # Persist current local sudo users. if [[ ${#ACTIVE_LOCAL_SUDO_USERS[@]} -gt 0 ]]; then printf '%s\n' "${ACTIVE_LOCAL_SUDO_USERS[@]}" | sort -u > "$LOCAL_SUDO_STATE" else > "$LOCAL_SUDO_STATE" fi unset _USER _DROPIN _OLD_USER _still_active _U # ── No-local-users policy ────────────────────────────────────────────────────── # no_local_users: lock the passwords of root and all local users (UID >= 1000) # so that only FreeIPA domain accounts with centrally-managed sudo rules can # authenticate and gain elevated privileges. # Leaving the group reverts: every account locked by this policy is unlocked. NO_LOCAL_USERS_STATE="$STATE_DIR/no-local-users" _apply_no_local_users() { log "Applying no_local_users policy — locking local account passwords" [[ -f "$NO_LOCAL_USERS_STATE" ]] || touch "$NO_LOCAL_USERS_STATE" while IFS=: read -r uname _ uid _; do [[ "$uid" =~ ^[0-9]+$ ]] || continue { [[ "$uid" == "0" ]] || [[ "$uid" -ge 1000 ]]; } || continue # Skip accounts already tracked (locked on a previous run) grep -qxF "$uname" "$NO_LOCAL_USERS_STATE" 2>/dev/null && continue # Lock only accounts that currently have a real (unlocked) password hash local hash hash=$(getent shadow "$uname" 2>/dev/null | cut -d: -f2 || true) [[ -z "$hash" || "$hash" == '!'* || "$hash" == '*'* ]] && continue if passwd -l "$uname" &>/dev/null; then echo "$uname" >> "$NO_LOCAL_USERS_STATE" log "Locked local account: $uname" else warn "Failed to lock local account: $uname" fi done < /etc/passwd } _revert_no_local_users() { [[ -f "$NO_LOCAL_USERS_STATE" ]] || return 0 log "Reverting no_local_users policy — unlocking previously locked accounts" while IFS= read -r uname; do [[ -z "$uname" ]] && continue if passwd -u "$uname" &>/dev/null; then log "Unlocked local account: $uname" else warn "Failed to unlock local account: $uname (may have been removed)" fi done < "$NO_LOCAL_USERS_STATE" > "$NO_LOCAL_USERS_STATE" } if [[ "$WANT_NO_LOCAL_USERS" == true ]]; then _apply_no_local_users else if [[ -f "$NO_LOCAL_USERS_STATE" ]] && [[ -s "$NO_LOCAL_USERS_STATE" ]]; then _revert_no_local_users fi fi log "Policy enforcement complete."