Dotfiles/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh

511 lines
21 KiB
Bash
Executable File

#!/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:
# policy-block-binary-<name> Block execution of <name> via two layers:
# 1. PATH-priority wrapper in /usr/local/bin/ (catches $PATH calls)
# 2. AppArmor deny profile in /etc/apparmor.d/ (catches absolute paths)
# AppArmor layer is skipped silently if apparmor_parser is not present.
# policy-daemon-enable-<unit> Ensure <unit> is enabled and running (systemctl enable --now).
# Leaving the group reverts: service is disabled and stopped.
# policy-daemon-disable-<unit> Ensure <unit> is disabled and stopped (systemctl disable --now).
# Leaving the group reverts: service is re-enabled and started.
# <unit> 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
# 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.
#
# 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"
APPARMOR_DIR="/etc/apparmor.d"
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 policy groups ────────────────────────────────────────────────
ACTIVE_BLOCK_BINARIES=()
ACTIVE_DAEMON_ENABLE=()
ACTIVE_DAEMON_DISABLE=()
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-block-binary-*) ACTIVE_BLOCK_BINARIES+=("${g#policy-block-binary-}") ;;
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 ;;
policy-scan-notify) WANT_SCAN_NOTIFY=true ;;
no_local_users) WANT_NO_LOCAL_USERS=true ;;
esac
done
done <<< "$RAW_GROUPS"
fi
log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none} | daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP" \
"| security-scan: $WANT_SECURITY_SCAN | scan-notify: $WANT_SCAN_NOTIFY" \
"| no-local-users: $WANT_NO_LOCAL_USERS"
# ── Helpers ───────────────────────────────────────────────────────────────────
in_active_list() {
local needle="$1"
for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do
[[ "$b" == "$needle" ]] && return 0
done
return 1
}
# Find the real installed binary, skipping /usr/local/bin where our wrapper lives.
find_real_binary() {
local name="$1"
for dir in /usr/bin /usr/sbin /bin /sbin /usr/local/sbin /opt/bin; do
[[ -x "$dir/$name" ]] && echo "$dir/$name" && return 0
done
return 1
}
aa_profile_file() { echo "$APPARMOR_DIR/ansipa-block-${1}"; }
# Load an AppArmor deny profile for a binary path.
# An empty AppArmor profile denies all access: the binary cannot load shared
# libraries or open any files, so it exits immediately with a permission error.
apply_apparmor_block() {
local bin="$1"
command -v apparmor_parser &>/dev/null || return 0
local bin_path
bin_path=$(find_real_binary "$bin") || {
warn "AppArmor block: real binary '$bin' not found on disk — profile skipped until it is installed."
return 0
}
local profile_file
profile_file=$(aa_profile_file "$bin")
# Write the profile only if it doesn't exist or points to a different path.
if [[ ! -f "$profile_file" ]] || ! grep -qF "$bin_path" "$profile_file" 2>/dev/null; then
log "Writing AppArmor block profile: $profile_file ($bin_path)"
cat > "$profile_file" <<PROFILE
#include <tunables/global>
# ansipa-block-policy: managed by ansipa-enforce-policies — do not edit manually.
# Deny all access so the binary cannot load libraries or run.
# To unblock manually: apparmor_parser -R $profile_file && rm $profile_file
$bin_path {
}
PROFILE
fi
# Load (or reload) the profile in enforce mode.
if ! apparmor_parser -r "$profile_file" 2>/dev/null; then
warn "apparmor_parser failed to load $profile_file — AppArmor block not active"
fi
}
# Remove the AppArmor deny profile for a binary.
remove_apparmor_block() {
local bin="$1"
command -v apparmor_parser &>/dev/null || return 0
local profile_file
profile_file=$(aa_profile_file "$bin")
[[ -f "$profile_file" ]] || return 0
if grep -q "ansipa-block-policy" "$profile_file" 2>/dev/null; then
apparmor_parser -R "$profile_file" 2>/dev/null || true
rm -f "$profile_file"
log "Removed AppArmor block profile: $bin"
fi
}
# ── Binary blocking ───────────────────────────────────────────────────────────
# Layer 1: PATH-priority wrapper in /usr/local/bin/ — blocks $PATH-based calls.
# Layer 2: AppArmor deny profile — blocks absolute-path calls and direct exec().
# Both layers use the "ansipa policy" sentinel to identify managed files.
BLOCK_STATE="$STATE_DIR/blocked-binaries"
[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE"
for BIN in "${ACTIVE_BLOCK_BINARIES[@]}"; do
WRAPPER="$BLOCK_DIR/$BIN"
if [[ ! -f "$WRAPPER" ]] || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
log "Applying PATH wrapper block: $BIN"
cat > "$WRAPPER" <<WRAPPER
#!/bin/bash
# blocked by ansipa policy
echo "[$LOG_TAG] '$BIN' is blocked by system policy on this host." >&2
exit 1
WRAPPER
chmod 755 "$WRAPPER"
fi
apply_apparmor_block "$BIN"
done
# Remove blocks for binaries no longer in any active policy group.
while IFS= read -r OLD_BIN; do
[[ -z "$OLD_BIN" ]] && continue
if ! in_active_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 PATH wrapper block: $OLD_BIN"
fi
remove_apparmor_block "$OLD_BIN"
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:
# - Root timer (every 10 min): ansipa-fetch-alerts.sh downloads alerts from the
# server SMB share and places them in ~/administration/<hostname>/ 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.
# Managed by ansipa-enforce-policies — do not edit manually.
_NOTIFY_DAEMON=/usr/local/bin/ansipa-scan-notify.sh
if [[ -x "$_NOTIFY_DAEMON" ]] && \
! 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 (host left policy-scan-notify group)"
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-<unit>: ensure the unit is enabled and running.
# Leaving the group reverts: unit is disabled and stopped.
# policy-daemon-disable-<unit>: ensure the unit is disabled and stopped.
# Leaving the group reverts: unit is re-enabled and started.
# <unit> 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
# ── 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."