From 3782969b49f376bb1dc50fa9891ad068768ad83c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Feb 2026 21:36:27 +0100 Subject: [PATCH] feat: hardened scripts --- hosts/guard/README_FOR_LLM.md | 205 +++++++++ hosts/guard/enforce-nsswitch.sh | 103 +++++ hosts/guard/nsswitch-guard.path | 9 + hosts/guard/nsswitch-guard.service | 12 + .../guard/pacman-hooks/hosts-guard-common.sh | 98 ++--- .../pacman-hooks/pacman-post-relock-hosts.sh | 2 +- .../pacman-hooks/pacman-pre-unlock-hosts.sh | 3 +- hosts/guard/psychological/unlock-hosts.sh | 34 +- hosts/guard/setup_hosts_guard.sh | 388 ++++++++++-------- 9 files changed, 615 insertions(+), 239 deletions(-) create mode 100644 hosts/guard/README_FOR_LLM.md create mode 100644 hosts/guard/enforce-nsswitch.sh create mode 100644 hosts/guard/nsswitch-guard.path create mode 100644 hosts/guard/nsswitch-guard.service mode change 100644 => 100755 hosts/guard/pacman-hooks/hosts-guard-common.sh mode change 100644 => 100755 hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh mode change 100644 => 100755 hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh mode change 100644 => 100755 hosts/guard/psychological/unlock-hosts.sh diff --git a/hosts/guard/README_FOR_LLM.md b/hosts/guard/README_FOR_LLM.md new file mode 100644 index 0000000..a69183c --- /dev/null +++ b/hosts/guard/README_FOR_LLM.md @@ -0,0 +1,205 @@ +# Hosts Guard System - LLM Reference Guide + +> **For AI assistants**: This document explains how the hosts guard system works so you can make correct modifications. + +## System Purpose + +Prevent tampering with `/etc/hosts` to maintain website blocking (YouTube, social media, etc.) as part of a digital wellbeing system. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ PROTECTION LAYERS │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Layer 1: Immutable Attribute │ +│ ───────────────────────────── │ +│ /etc/hosts has chattr +i (cannot be modified even by root) │ +│ │ +│ Layer 2: Canonical Copy │ +│ ─────────────────────── │ +│ /usr/local/share/locked-hosts contains the "true" version │ +│ If /etc/hosts differs, it gets overwritten from this copy │ +│ │ +│ Layer 3: Path Watcher (systemd) │ +│ ────────────────────────────── │ +│ hosts-guard.path watches /etc/hosts for ANY change │ +│ hosts-guard.service runs enforce-hosts.sh when triggered │ +│ │ +│ Layer 4: Read-Only Bind Mount │ +│ ──────────────────────────── │ +│ hosts-bind-mount.service mounts /etc/hosts read-only │ +│ Even if chattr is removed, write operations fail │ +│ │ +│ Layer 5: Custom Entries Protection │ +│ ───────────────────────────────── │ +│ /etc/hosts.custom-entries.state tracks blocked domains │ +│ Prevents removal of domains from install.sh │ +│ │ +│ Layer 6: nsswitch.conf Protection (NEW) │ +│ ─────────────────────────────────────── │ +│ Prevents bypass via /etc/nsswitch.conf manipulation │ +│ Ensures "files" always appears in hosts: line before "dns" │ +│ nsswitch-guard.path watches for changes │ +│ Canonical copy at /usr/local/share/locked-nsswitch.conf │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## File Locations + +| File | Purpose | Protection | +|------|---------|------------| +| `/etc/hosts` | Active hosts file | chattr +i, bind mount | +| `/usr/local/share/locked-hosts` | Canonical source of truth | chattr +i | +| `/etc/hosts.custom-entries.state` | Tracks custom blocked domains | chattr +i | +| `/etc/hosts.stevenblack` | Cached upstream hosts file | None | +| `/etc/nsswitch.conf` | Name service switch config | chattr +i, path watcher | +| `/usr/local/share/locked-nsswitch.conf` | Canonical nsswitch copy | chattr +i | +| `/usr/local/sbin/enforce-hosts.sh` | Restoration script | File permissions | +| `/usr/local/sbin/enforce-nsswitch.sh` | nsswitch enforcement | File permissions | +| `/usr/local/sbin/unlock-hosts` | Psychological unlock script | File permissions | +| `/etc/systemd/system/hosts-guard.path` | Path watcher unit | systemd | +| `/etc/systemd/system/hosts-guard.service` | Enforcement service | systemd | +| `/etc/systemd/system/hosts-bind-mount.service` | RO bind mount | systemd | +| `/etc/systemd/system/nsswitch-guard.path` | nsswitch watcher | systemd | +| `/etc/systemd/system/nsswitch-guard.service` | nsswitch enforce | systemd | + +## Key Scripts + +### hosts/install.sh +- Downloads StevenBlack hosts list (cached at `/etc/hosts.stevenblack`) +- Adds custom blocking entries (YouTube, etc.) +- Comments out allowed sites (4chan, Facebook) +- Runs protection check for custom entries +- Sets up initial immutable attribute + +### hosts/guard/setup_hosts_guard.sh +Installs all protection layers: +- Creates canonical snapshot +- Installs enforce-hosts.sh and unlock-hosts scripts +- Enables systemd path watcher +- Enables bind mount service +- Installs shell history suppression hooks + +### hosts/guard/enforce-hosts.sh +Called when tampering detected: +```bash +# Compares /etc/hosts to canonical +# If different: restores from canonical, logs event +# Re-applies chattr +i +``` + +### hosts/guard/psychological/unlock-hosts.sh +Legitimate edit workflow: +1. Prompts for reason (logged) +2. Stops protection services +3. Waits 45 seconds (cooling off) +4. Opens editor +5. Updates canonical if changes made +6. Re-enables all protections + +## Pacman Integration + +The pacman wrapper calls these hooks during package transactions: +- `/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh` - Before transaction +- `/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh` - After transaction + +These temporarily unlock hosts for package manager operations. + +## Common Tasks + +### Adding a New Blocked Domain + +1. Edit `hosts/install.sh` +2. Find the heredoc section after `# Custom blocking entries` +3. Add line: `0.0.0.0 newdomain.com` +4. Run: `sudo ~/linux-configuration/hosts/install.sh` + +```bash +# Example: Block example.com +# In hosts/install.sh, add to heredoc: +0.0.0.0 example.com +0.0.0.0 www.example.com +``` + +### Allowing a Previously Blocked Domain + +**This is intentionally difficult.** You must: +1. Remove entry from install.sh heredoc +2. Remove protection: `sudo chattr -i /etc/hosts.custom-entries.state` +3. Edit state file to remove domain +4. Re-run install.sh + +### Checking Protection Status + +```bash +# Check immutable attribute +lsattr /etc/hosts +# Should show: ----i--------e-- /etc/hosts + +# Check services +systemctl status hosts-guard.path hosts-guard.service hosts-bind-mount.service + +# Check canonical exists +ls -la /usr/local/share/locked-hosts +``` + +### Legitimate Editing + +```bash +sudo /usr/local/sbin/unlock-hosts +# Enter reason when prompted +# Wait 45 seconds +# Edit in your $EDITOR +# Changes auto-saved to canonical +``` + +## nsswitch.conf Protection (Layer 6) + +**Why this matters:** A user could bypass ALL /etc/hosts protections by simply editing `/etc/nsswitch.conf` and removing `files` from the `hosts:` line. This protection layer prevents that. + +### How it works: +- `nsswitch-guard.path` watches `/etc/nsswitch.conf` for changes +- `nsswitch-guard.service` runs `enforce-nsswitch.sh` when triggered +- Canonical copy stored at `/usr/local/share/locked-nsswitch.conf` +- Validates that `hosts:` line contains `files` before `dns` +- Auto-restores from canonical if tampered + +### Check nsswitch protection status: +```bash +lsattr /etc/nsswitch.conf +systemctl status nsswitch-guard.path +``` + +## Troubleshooting + +### "Cannot modify /etc/hosts" +This is expected! Use the unlock script: +```bash +sudo /usr/local/sbin/unlock-hosts +``` + +### Path watcher not running +```bash +sudo systemctl start hosts-guard.path +sudo systemctl enable hosts-guard.path +``` + +### Bind mount preventing access +```bash +# Temporarily disable (not recommended) +sudo systemctl stop hosts-bind-mount.service +``` + +### Custom entries protection blocking install +The protection mechanism detected you're trying to remove previously blocked domains. This is intentional. To proceed, manually edit the state file (see "Allowing a Previously Blocked Domain"). + +## DO NOT + +1. ❌ Edit `/etc/nsswitch.conf` to bypass hosts (this defeats the purpose) +2. ❌ Stop `hosts-guard.path` without understanding consequences +3. ❌ Delete `/usr/local/share/locked-hosts` (breaks restoration) +4. ❌ Remove entries from install.sh without updating state file +5. ❌ Use `chattr -i` without going through unlock-hosts diff --git a/hosts/guard/enforce-nsswitch.sh b/hosts/guard/enforce-nsswitch.sh new file mode 100644 index 0000000..ec01fd0 --- /dev/null +++ b/hosts/guard/enforce-nsswitch.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Template guard script to enforce canonical /etc/nsswitch.conf +# Ensures "hosts:" line always contains "files" before "dns" +# This prevents bypassing /etc/hosts by removing "files" from nsswitch.conf +# Installed to /usr/local/sbin/enforce-nsswitch.sh by setup_hosts_guard.sh + +set -euo pipefail + +CANONICAL_SOURCE="/usr/local/share/locked-nsswitch.conf" +TARGET="/etc/nsswitch.conf" +LOG_FILE="/var/log/nsswitch-guard.log" + +log() { + printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2 +} + +# Function to validate that "hosts:" line has correct format +# Must contain "files" before "dns" (or "dns" not present) +validate_hosts_line() { + local line="$1" + + # Check if "files" is present + if ! echo "$line" | grep -qw "files"; then + return 1 + fi + + # If dns is present, files must come before it + if echo "$line" | grep -qw "dns"; then + local files_pos dns_pos + files_pos=$(echo "$line" | grep -bo '\bfiles\b' | head -1 | cut -d: -f1) + dns_pos=$(echo "$line" | grep -bo '\bdns\b' | head -1 | cut -d: -f1) + if [[ -n "$files_pos" && -n "$dns_pos" && "$files_pos" -gt "$dns_pos" ]]; then + return 1 + fi + fi + + return 0 +} + +# Check current nsswitch.conf hosts line +current_hosts_line=$(grep '^hosts:' "$TARGET" 2>/dev/null || echo "") + +if [[ -z "$current_hosts_line" ]]; then + log "CRITICAL: No 'hosts:' line found in $TARGET - restoring from canonical" + if [[ -f "$CANONICAL_SOURCE" ]]; then + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + log "Restored $TARGET from canonical copy" + else + log "ERROR: Canonical source not found at $CANONICAL_SOURCE" + exit 1 + fi + exit 0 +fi + +if ! validate_hosts_line "$current_hosts_line"; then + log "TAMPERING DETECTED: 'hosts:' line is invalid or missing 'files' before 'dns'" + log "Current line: $current_hosts_line" + + if [[ -f "$CANONICAL_SOURCE" ]]; then + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + log "Restored $TARGET from canonical copy" + else + log "ERROR: Canonical source not found at $CANONICAL_SOURCE" + # Emergency fix: add "files" back to hosts line + chattr -i "$TARGET" 2>/dev/null || true + sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' "$TARGET" + chattr +i "$TARGET" 2>/dev/null || true + log "Emergency fix applied: added 'files' before 'dns'" + fi + exit 0 +fi + +# If canonical exists, check for any drift +if [[ -f "$CANONICAL_SOURCE" ]]; then + if ! cmp -s "$CANONICAL_SOURCE" "$TARGET"; then + log "Drift detected in $TARGET (but hosts line valid) - restoring canonical" + chattr -i "$TARGET" 2>/dev/null || true + cp "$CANONICAL_SOURCE" "$TARGET" + chmod 644 "$TARGET" + chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute" + log "Restored $TARGET from canonical copy" + else + log "No drift detected in $TARGET" + fi +else + log "Creating initial canonical snapshot" + mkdir -p "$(dirname "$CANONICAL_SOURCE")" + cp "$TARGET" "$CANONICAL_SOURCE" + chmod 644 "$CANONICAL_SOURCE" + chattr +i "$CANONICAL_SOURCE" 2>/dev/null || log "Failed to protect canonical copy" +fi + +# Ensure immutable attribute is set +chattr -i "$TARGET" 2>/dev/null || true +chattr +i "$TARGET" 2>/dev/null || log "Failed to set immutable attribute on $TARGET" + +log "nsswitch.conf enforcement complete" diff --git a/hosts/guard/nsswitch-guard.path b/hosts/guard/nsswitch-guard.path new file mode 100644 index 0000000..769d698 --- /dev/null +++ b/hosts/guard/nsswitch-guard.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch /etc/nsswitch.conf for tampering (hosts bypass protection) + +[Path] +PathChanged=/etc/nsswitch.conf +Unit=nsswitch-guard.service + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/nsswitch-guard.service b/hosts/guard/nsswitch-guard.service new file mode 100644 index 0000000..448b5e3 --- /dev/null +++ b/hosts/guard/nsswitch-guard.service @@ -0,0 +1,12 @@ +[Unit] +Description=Enforce canonical /etc/nsswitch.conf (prevents hosts bypass) +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/enforce-nsswitch.sh +Nice=10 +IOSchedulingClass=idle + +[Install] +WantedBy=multi-user.target diff --git a/hosts/guard/pacman-hooks/hosts-guard-common.sh b/hosts/guard/pacman-hooks/hosts-guard-common.sh old mode 100644 new mode 100755 index 404d618..9872cc3 --- a/hosts/guard/pacman-hooks/hosts-guard-common.sh +++ b/hosts/guard/pacman-hooks/hosts-guard-common.sh @@ -7,85 +7,85 @@ LOGTAG=hosts-guard-hook # Check if target has a read-only mount is_ro_mount() { - findmnt -no OPTIONS -T "$TARGET" 2> /dev/null | grep -qw ro + findmnt -no OPTIONS -T "$TARGET" 2>/dev/null | grep -qw ro } # Count mount layers for the target mount_layers_count() { - awk '$5=="/etc/hosts"{c++} END{print c+0}' /proc/self/mountinfo 2> /dev/null || echo 0 + awk '$5=="/etc/hosts"{c++} END{print c+0}' /proc/self/mountinfo 2>/dev/null || echo 0 } # Collapse all bind mount layers collapse_mounts() { - local i=0 - if command -v mountpoint > /dev/null 2>&1; then - while mountpoint -q "$TARGET"; do - umount -l "$TARGET" > /dev/null 2>&1 || break - i=$((i + 1)) - ((i > 20)) && break - done - else - local cnt - cnt=$(mount_layers_count) - while ((cnt > 1)); do - umount -l "$TARGET" > /dev/null 2>&1 || break - i=$((i + 1)) - ((i > 20)) && break - cnt=$(mount_layers_count) - done - fi + local i=0 + if command -v mountpoint >/dev/null 2>&1; then + while mountpoint -q "$TARGET"; do + umount -l "$TARGET" >/dev/null 2>&1 || break + i=$((i + 1)) + ((i > 20)) && break + done + else + local cnt + cnt=$(mount_layers_count) + while ((cnt > 1)); do + umount -l "$TARGET" >/dev/null 2>&1 || break + i=$((i + 1)) + ((i > 20)) && break + cnt=$(mount_layers_count) + done + fi } # Stop systemd units related to hosts guard stop_units_if_present() { - local units=(hosts-bind-mount.service hosts-guard.path) - for u in "${units[@]}"; do - if command -v systemctl > /dev/null 2>&1; then - if systemctl list-unit-files 2> /dev/null | grep -q "^$u"; then - systemctl stop "$u" > /dev/null 2>&1 || true - fi - fi - done + local units=(hosts-bind-mount.service hosts-guard.path) + for u in "${units[@]}"; do + if command -v systemctl >/dev/null 2>&1; then + if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then + systemctl stop "$u" >/dev/null 2>&1 || true + fi + fi + done } # Remove immutable/append-only attributes remove_host_attrs() { - if command -v lsattr > /dev/null 2>&1; then - local attrs - attrs=$(lsattr -d "$TARGET" 2> /dev/null || true) - if echo "$attrs" | grep -q " i "; then - chattr -i "$TARGET" > /dev/null 2>&1 || true - fi - if echo "$attrs" | grep -q " a "; then - chattr -a "$TARGET" > /dev/null 2>&1 || true - fi - fi + if command -v lsattr >/dev/null 2>&1; then + local attrs + attrs=$(lsattr -d "$TARGET" 2>/dev/null || true) + if echo "$attrs" | grep -q " i "; then + chattr -i "$TARGET" >/dev/null 2>&1 || true + fi + if echo "$attrs" | grep -q " a "; then + chattr -a "$TARGET" >/dev/null 2>&1 || true + fi + fi } # Apply immutable attribute apply_immutable() { - if command -v chattr > /dev/null 2>&1; then - chattr +i "$TARGET" > /dev/null 2>&1 || true - fi + if command -v chattr >/dev/null 2>&1; then + chattr +i "$TARGET" >/dev/null 2>&1 || true + fi } # Apply a single read-only bind mount layer apply_ro_bind_mount() { - mount --bind "$TARGET" "$TARGET" > /dev/null 2>&1 || true - mount -o remount,ro,bind "$TARGET" > /dev/null 2>&1 || true + mount --bind "$TARGET" "$TARGET" >/dev/null 2>&1 || true + mount -o remount,ro,bind "$TARGET" >/dev/null 2>&1 || true } # Start the path watcher service start_path_watcher() { - if command -v systemctl > /dev/null 2>&1; then - systemctl start hosts-guard.path > /dev/null 2>&1 || true - fi + if command -v systemctl >/dev/null 2>&1; then + systemctl start hosts-guard.path >/dev/null 2>&1 || true + fi } # Log to system logger and run log file log_hook() { - local phase="$1" - local state="$2" - logger -t "$LOGTAG" "$phase: $state" - echo "$(date -Is) $phase-$state" >> /run/hosts-guard-hook.log 2> /dev/null || true + local phase="$1" + local state="$2" + logger -t "$LOGTAG" "$phase: $state" + echo "$(date -Is) $phase-$state" >>/run/hosts-guard-hook.log 2>/dev/null || true } diff --git a/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh b/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh old mode 100644 new mode 100755 index 3817803..9f6cee2 --- a/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh +++ b/hosts/guard/pacman-hooks/pacman-post-relock-hosts.sh @@ -16,7 +16,7 @@ collapse_mounts # Run enforcement script if available if [[ -x $ENFORCE ]]; then - "$ENFORCE" > /dev/null 2>&1 || true + "$ENFORCE" >/dev/null 2>&1 || true fi # Apply protections diff --git a/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh b/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh old mode 100644 new mode 100755 index 1530ef1..0cb3331 --- a/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh +++ b/hosts/guard/pacman-hooks/pacman-pre-unlock-hosts.sh @@ -9,6 +9,7 @@ source "$SCRIPT_DIR/hosts-guard-common.sh" # Remove protective attributes remove_host_attrs +sudo rm /etc/hosts # Stop guard services stop_units_if_present @@ -20,7 +21,7 @@ collapse_mounts # Ensure writable by remounting if still read-only if is_ro_mount; then - mount -o remount,rw "$TARGET" > /dev/null 2>&1 || collapse_mounts + mount -o remount,rw "$TARGET" >/dev/null 2>&1 || collapse_mounts fi log_hook "pre" "unlocking(done)" diff --git a/hosts/guard/psychological/unlock-hosts.sh b/hosts/guard/psychological/unlock-hosts.sh old mode 100644 new mode 100755 index 959ae0e..62a781c --- a/hosts/guard/psychological/unlock-hosts.sh +++ b/hosts/guard/psychological/unlock-hosts.sh @@ -17,30 +17,30 @@ require_root "$@" echo "Reason for editing /etc/hosts (will be logged):" >&2 read -r -p "Enter reason: " REASON if [[ -z ${REASON// /} ]]; then - echo "Empty reason not allowed. Aborting." >&2 - exit 1 + echo "Empty reason not allowed. Aborting." >&2 + exit 1 fi log "Requested intentional /etc/hosts modification session. Reason: $REASON" logger -t "$SYSLOG_TAG" "session_start user=${SUDO_USER:-$USER} reason='$REASON'" echo "This action is logged. A cooling-off delay of $DELAY_SECONDS seconds applies." >&2 for s in hosts-bind-mount.service hosts-guard.path; do - if systemctl is-active --quiet "$s"; then - log "Stopping $s" - systemctl stop "$s" || true - fi - if systemctl is-enabled --quiet "$s"; then - log "(Will re-enable later)" - fi + if systemctl is-active --quiet "$s"; then + log "Stopping $s" + systemctl stop "$s" || true + fi + if systemctl is-enabled --quiet "$s"; then + log "(Will re-enable later)" + fi done # Remove attributes to allow edit -chattr -i -a "$TARGET" 2> /dev/null || true +chattr -i -a "$TARGET" 2>/dev/null || true echo "Countdown:" >&2 for ((i = DELAY_SECONDS; i > 0; i--)); do - printf '\rEdit window opens in %2d seconds... Press Ctrl+C to abort.' "$i" >&2 - sleep 1 + printf '\rEdit window opens in %2d seconds... Press Ctrl+C to abort.' "$i" >&2 + sleep 1 done echo >&2 @@ -50,12 +50,12 @@ sha_before=$(sha256sum "$TARGET" | awk '{print $1}') sha_after=$(sha256sum "$TARGET" | awk '{print $1}') if [[ $sha_before == "$sha_after" ]]; then - log "No changes made to $TARGET. Reason: $REASON" - logger -t "$SYSLOG_TAG" "no_change user=${SUDO_USER:-$USER} reason='$REASON'" + log "No changes made to $TARGET. Reason: $REASON" + logger -t "$SYSLOG_TAG" "no_change user=${SUDO_USER:-$USER} reason='$REASON'" else - log "Changes detected. Updating canonical copy and re-enforcing. Reason: $REASON" - logger -t "$SYSLOG_TAG" "modified user=${SUDO_USER:-$USER} reason='$REASON'" - cp "$TARGET" "$CANON" + log "Changes detected. Updating canonical copy and re-enforcing. Reason: $REASON" + logger -t "$SYSLOG_TAG" "modified user=${SUDO_USER:-$USER} reason='$REASON'" + cp "$TARGET" "$CANON" fi # Re-run enforcement diff --git a/hosts/guard/setup_hosts_guard.sh b/hosts/guard/setup_hosts_guard.sh index 19da91c..e31dbde 100755 --- a/hosts/guard/setup_hosts_guard.sh +++ b/hosts/guard/setup_hosts_guard.sh @@ -33,6 +33,7 @@ FORCE_SNAPSHOT=0 DO_SNAPSHOT=1 ENABLE_BIND=1 ENABLE_PATH=1 +ENABLE_NSSWITCH=1 UNINSTALL=0 DELAY=45 DRY_RUN=0 @@ -48,15 +49,15 @@ note() { printf '\e[1;34m[i]\e[0m %s\n' "$*"; } warn() { printf '\e[1;33m[!]\e[0m %s\n' "$*"; } err() { printf '\e[1;31m[x]\e[0m %s\n' "$*" >&2; } run() { - if [[ $DRY_RUN -eq 1 ]]; then - printf 'DRY-RUN:' - if [ "$#" -gt 0 ]; then - printf ' %q' "$@" - fi - printf '\n' - else - "$@" - fi + if [[ $DRY_RUN -eq 1 ]]; then + printf 'DRY-RUN:' + if [ "$#" -gt 0 ]; then + printf ' %q' "$@" + fi + printf '\n' + else + "$@" + fi } require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi; } @@ -67,73 +68,77 @@ usage() { sed -n '1,/^set -euo pipefail/p' "$0" | sed 's/^# \{0,1\}//'; } # Parse args ###################################################################### while [[ $# -gt 0 ]]; do - case "$1" in - --force-snapshot) - FORCE_SNAPSHOT=1 - shift - ;; - --no-snapshot) - DO_SNAPSHOT=0 - shift - ;; - --skip-bind) - ENABLE_BIND=0 - shift - ;; - --skip-path-watch) - ENABLE_PATH=0 - shift - ;; - --delay) - DELAY=${2:-} - [[ -z ${DELAY} ]] && { - err '--delay requires value' - exit 2 - } - shift 2 - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --no-shell-hooks) - INSTALL_SHELL_HOOKS=0 - shift - ;; - --shell-hooks) - INSTALL_SHELL_HOOKS=1 - shift - ;; - --no-audit) - INSTALL_AUDIT_RULE=0 - shift - ;; - --audit) - INSTALL_AUDIT_RULE=1 - shift - ;; - --no-alias-stub) - ADD_ALIAS_STUB=0 - shift - ;; - --alias-stub) - ADD_ALIAS_STUB=1 - shift - ;; - --uninstall) - UNINSTALL=1 - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) - err "Unknown argument: $1" - usage - exit 2 - ;; - esac + case "$1" in + --force-snapshot) + FORCE_SNAPSHOT=1 + shift + ;; + --no-snapshot) + DO_SNAPSHOT=0 + shift + ;; + --skip-bind) + ENABLE_BIND=0 + shift + ;; + --skip-path-watch) + ENABLE_PATH=0 + shift + ;; + --skip-nsswitch) + ENABLE_NSSWITCH=0 + shift + ;; + --delay) + DELAY=${2:-} + [[ -z ${DELAY} ]] && { + err '--delay requires value' + exit 2 + } + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --no-shell-hooks) + INSTALL_SHELL_HOOKS=0 + shift + ;; + --shell-hooks) + INSTALL_SHELL_HOOKS=1 + shift + ;; + --no-audit) + INSTALL_AUDIT_RULE=0 + shift + ;; + --audit) + INSTALL_AUDIT_RULE=1 + shift + ;; + --no-alias-stub) + ADD_ALIAS_STUB=0 + shift + ;; + --alias-stub) + ADD_ALIAS_STUB=1 + shift + ;; + --uninstall) + UNINSTALL=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + err "Unknown argument: $1" + usage + exit 2 + ;; + esac done require_root "$@" @@ -149,11 +154,17 @@ TEMPLATE_UNLOCK="$SCRIPT_DIR/psychological/unlock-hosts.sh" UNIT_GUARD_SERVICE="$SCRIPT_DIR/hosts-guard.service" UNIT_GUARD_PATH="$SCRIPT_DIR/hosts-guard.path" UNIT_BIND_SERVICE="$SCRIPT_DIR/hosts-bind-mount.service" +TEMPLATE_ENFORCE_NSSWITCH="$SCRIPT_DIR/enforce-nsswitch.sh" +UNIT_NSSWITCH_SERVICE="$SCRIPT_DIR/nsswitch-guard.service" +UNIT_NSSWITCH_PATH="$SCRIPT_DIR/nsswitch-guard.path" INSTALL_ENFORCE="/usr/local/sbin/enforce-hosts.sh" INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts" +INSTALL_ENFORCE_NSSWITCH="/usr/local/sbin/enforce-nsswitch.sh" CANON="/usr/local/share/locked-hosts" +CANON_NSSWITCH="/usr/local/share/locked-nsswitch.conf" HOSTS="/etc/hosts" +NSSWITCH="/etc/nsswitch.conf" # Shell hook destinations (user agnostic system-wide skeleton + etc profile.d) ZSH_FILTER_SNIPPET="/etc/zsh/hosts_guard_history_filter.zsh" @@ -165,26 +176,29 @@ SYSTEMD_DIR="/etc/systemd/system" # Uninstall flow ###################################################################### if [[ $UNINSTALL -eq 1 ]]; then - note "Uninstalling hosts guard components ( protections removed )" - for u in hosts-guard.path hosts-guard.service hosts-bind-mount.service; do - if systemctl list-unit-files | grep -q "^$u"; then - run systemctl disable --now "$u" || true - fi - done - for f in \ - "$INSTALL_ENFORCE" \ - "$INSTALL_UNLOCK" \ - "$SYSTEMD_DIR/hosts-guard.service" \ - "$SYSTEMD_DIR/hosts-guard.path" \ - "$SYSTEMD_DIR/hosts-bind-mount.service" \ - "$ZSH_FILTER_SNIPPET" \ - "$BASH_FILTER_SNIPPET"; do - if [[ -e $f ]]; then run rm -f "$f"; fi - done - note "Leaving canonical snapshot at $CANON (remove manually if undesired)." - if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi - msg "Uninstall complete" - exit 0 + note "Uninstalling hosts guard components ( protections removed )" + for u in hosts-guard.path hosts-guard.service hosts-bind-mount.service nsswitch-guard.path nsswitch-guard.service; do + if systemctl list-unit-files | grep -q "^$u"; then + run systemctl disable --now "$u" || true + fi + done + for f in \ + "$INSTALL_ENFORCE" \ + "$INSTALL_UNLOCK" \ + "$INSTALL_ENFORCE_NSSWITCH" \ + "$SYSTEMD_DIR/hosts-guard.service" \ + "$SYSTEMD_DIR/hosts-guard.path" \ + "$SYSTEMD_DIR/hosts-bind-mount.service" \ + "$SYSTEMD_DIR/nsswitch-guard.service" \ + "$SYSTEMD_DIR/nsswitch-guard.path" \ + "$ZSH_FILTER_SNIPPET" \ + "$BASH_FILTER_SNIPPET"; do + if [[ -e $f ]]; then run rm -f "$f"; fi + done + note "Leaving canonical snapshots at $CANON and $CANON_NSSWITCH (remove manually if undesired)." + if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi + msg "Uninstall complete" + exit 0 fi ###################################################################### @@ -194,29 +208,29 @@ note "Script directory: $SCRIPT_DIR" note "Repository root: $REPO_ROOT" for req in "$TEMPLATE_ENFORCE" "$TEMPLATE_UNLOCK" "$UNIT_GUARD_SERVICE"; do - [[ -f $req ]] || { - err "Missing template: $req" - exit 1 - } + [[ -f $req ]] || { + err "Missing template: $req" + exit 1 + } done if [[ ! -f $HOSTS ]]; then - err "$HOSTS does not exist. Run your hosts/install.sh first." - exit 1 + err "$HOSTS does not exist. Run your hosts/install.sh first." + exit 1 fi ###################################################################### # Snapshot ###################################################################### if [[ $DO_SNAPSHOT -eq 1 ]]; then - if [[ -f $CANON && $FORCE_SNAPSHOT -eq 0 ]]; then - note "Canonical snapshot exists (use --force-snapshot to overwrite)" - else - msg "Creating canonical snapshot at $CANON" - run install -m 644 -D "$HOSTS" "$CANON" - fi + if [[ -f $CANON && $FORCE_SNAPSHOT -eq 0 ]]; then + note "Canonical snapshot exists (use --force-snapshot to overwrite)" + else + msg "Creating canonical snapshot at $CANON" + run install -m 644 -D "$HOSTS" "$CANON" + fi else - note "Skipping snapshot creation (--no-snapshot)" + note "Skipping snapshot creation (--no-snapshot)" fi ###################################################################### @@ -230,27 +244,27 @@ run install -m 755 "$TEMPLATE_UNLOCK" "$INSTALL_UNLOCK" # Adjust delay in unlock script if different from default if [[ $DELAY -ne 45 ]]; then - msg "Adjusting unlock delay to $DELAY seconds" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would patch $INSTALL_UNLOCK" - else - # Replace DELAY_SECONDS=... line - sed -i -E "s/^(DELAY_SECONDS=).*/\\1$DELAY/" "$INSTALL_UNLOCK" || warn "Failed to adjust delay" - fi + msg "Adjusting unlock delay to $DELAY seconds" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would patch $INSTALL_UNLOCK" + else + # Replace DELAY_SECONDS=... line + sed -i -E "s/^(DELAY_SECONDS=).*/\\1$DELAY/" "$INSTALL_UNLOCK" || warn "Failed to adjust delay" + fi fi ###################################################################### # Install shell history filters (optional) ###################################################################### if [[ $INSTALL_SHELL_HOOKS -eq 1 ]]; then - msg "Installing shell history suppression hooks for unlock command" - # Pattern matches commands invoking unlock-hosts (with or without sudo) & setup script force snapshot - # Zsh: use zshaddhistory function - if command -v zsh > /dev/null 2>&1; then - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $ZSH_FILTER_SNIPPET" - else - cat > "$ZSH_FILTER_SNIPPET" << 'ZEOF' + msg "Installing shell history suppression hooks for unlock command" + # Pattern matches commands invoking unlock-hosts (with or without sudo) & setup script force snapshot + # Zsh: use zshaddhistory function + if command -v zsh >/dev/null 2>&1; then + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $ZSH_FILTER_SNIPPET" + else + cat >"$ZSH_FILTER_SNIPPET" <<'ZEOF' # Added by hosts guard setup – suppress unlock-hosts commands from Zsh history autoload -Uz add-zsh-hook 2>/dev/null || true _hosts_guard_history_filter() { @@ -269,16 +283,16 @@ else zshaddhistory() { _hosts_guard_history_filter "$1"; } fi ZEOF - chmod 644 "$ZSH_FILTER_SNIPPET" - fi - fi + chmod 644 "$ZSH_FILTER_SNIPPET" + fi + fi - # Bash: rely on HISTCONTROL and PROMPT_COMMAND filter - if command -v bash > /dev/null 2>&1; then - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $BASH_FILTER_SNIPPET" - else - cat > "$BASH_FILTER_SNIPPET" << 'BEOF' + # Bash: rely on HISTCONTROL and PROMPT_COMMAND filter + if command -v bash >/dev/null 2>&1; then + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $BASH_FILTER_SNIPPET" + else + cat >"$BASH_FILTER_SNIPPET" <<'BEOF' # Added by hosts guard setup – suppress unlock-hosts commands from Bash history export HISTCONTROL=ignoredups:erasedups _hosts_guard_hist_filter() { @@ -299,51 +313,51 @@ case :${PROMPT_COMMAND-}: in * ) PROMPT_COMMAND="_hosts_guard_hist_filter${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; esac BEOF - chmod 644 "$BASH_FILTER_SNIPPET" - fi - fi + chmod 644 "$BASH_FILTER_SNIPPET" + fi + fi else - note "Skipping shell history hooks (--no-shell-hooks)" + note "Skipping shell history hooks (--no-shell-hooks)" fi ###################################################################### # Add alias stub to discourage raw invocation (shell-level friction) ###################################################################### if [[ $ADD_ALIAS_STUB -eq 1 ]]; then - PROFILE_STUB="/etc/profile.d/hosts_guard_alias_stub.sh" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create $PROFILE_STUB" - else - cat > "$PROFILE_STUB" << 'ASTUB' + PROFILE_STUB="/etc/profile.d/hosts_guard_alias_stub.sh" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create $PROFILE_STUB" + else + cat >"$PROFILE_STUB" <<'ASTUB' # Added by hosts guard setup – discourages casual use of unlock-hosts name if command -v unlock-hosts >/dev/null 2>&1; then alias unlock-hosts='command_not_found_handle 2>/dev/null || echo "Use: sudo /usr/local/sbin/unlock-hosts (logged & delayed)"' fi ASTUB - chmod 644 "$PROFILE_STUB" - fi + chmod 644 "$PROFILE_STUB" + fi fi ###################################################################### # Audit rule to record executions (requires auditd) ###################################################################### if [[ $INSTALL_AUDIT_RULE -eq 1 ]]; then - if command -v auditctl > /dev/null 2>&1; then - audit_rule_str="-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock" - audit_rule_args=(-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock) - if auditctl -l 2> /dev/null | grep -Fq "/usr/local/sbin/unlock-hosts"; then - note "Audit rule already present" - else - run auditctl "${audit_rule_args[@]}" || warn "Failed to add audit rule (runtime)" - if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would create /etc/audit/rules.d/hosts_unlock.rules" - else - echo "$audit_rule_str" > /etc/audit/rules.d/hosts_unlock.rules - fi - fi - else - warn "auditctl not found; skipping audit rule (install auditd to enable)" - fi + if command -v auditctl >/dev/null 2>&1; then + audit_rule_str="-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock" + audit_rule_args=(-w /usr/local/sbin/unlock-hosts -p x -k hosts_unlock) + if auditctl -l 2>/dev/null | grep -Fq "/usr/local/sbin/unlock-hosts"; then + note "Audit rule already present" + else + run auditctl "${audit_rule_args[@]}" || warn "Failed to add audit rule (runtime)" + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would create /etc/audit/rules.d/hosts_unlock.rules" + else + echo "$audit_rule_str" >/etc/audit/rules.d/hosts_unlock.rules + fi + fi + else + warn "auditctl not found; skipping audit rule (install auditd to enable)" + fi fi ###################################################################### @@ -353,6 +367,8 @@ msg "Deploying systemd units" run install -m 644 "$UNIT_GUARD_SERVICE" "$SYSTEMD_DIR/hosts-guard.service" run install -m 644 "$UNIT_GUARD_PATH" "$SYSTEMD_DIR/hosts-guard.path" run install -m 644 "$UNIT_BIND_SERVICE" "$SYSTEMD_DIR/hosts-bind-mount.service" +run install -m 644 "$UNIT_NSSWITCH_SERVICE" "$SYSTEMD_DIR/nsswitch-guard.service" +run install -m 644 "$UNIT_NSSWITCH_PATH" "$SYSTEMD_DIR/nsswitch-guard.path" if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi @@ -360,24 +376,51 @@ if [[ $DRY_RUN -eq 0 ]]; then systemctl daemon-reload; fi # Enable / Start ###################################################################### if [[ $ENABLE_PATH -eq 1 ]]; then - msg "Enabling path watch (auto-revert)" - run systemctl enable --now hosts-guard.path + msg "Enabling path watch (auto-revert)" + run systemctl enable --now hosts-guard.path else - note "Skipping path watch (--skip-path-watch)" + note "Skipping path watch (--skip-path-watch)" fi if [[ $ENABLE_BIND -eq 1 ]]; then - msg "Enabling read-only bind mount" - run systemctl enable --now hosts-bind-mount.service + msg "Enabling read-only bind mount" + run systemctl enable --now hosts-bind-mount.service else - note "Skipping bind mount (--skip-bind)" + note "Skipping bind mount (--skip-bind)" fi -msg "Performing initial enforcement" -if [[ $DRY_RUN -eq 1 ]]; then - echo "DRY-RUN: would run $INSTALL_ENFORCE" +if [[ $ENABLE_NSSWITCH -eq 1 ]]; then + msg "Enabling nsswitch.conf protection (hosts bypass prevention)" + msg "Installing nsswitch enforcement script -> $INSTALL_ENFORCE_NSSWITCH" + run install -m 755 "$TEMPLATE_ENFORCE_NSSWITCH" "$INSTALL_ENFORCE_NSSWITCH" + + # Create nsswitch canonical snapshot if needed + if [[ -f "$NSSWITCH" ]]; then + if [[ ! -f "$CANON_NSSWITCH" ]]; then + msg "Creating canonical nsswitch.conf snapshot at $CANON_NSSWITCH" + run cp "$NSSWITCH" "$CANON_NSSWITCH" + run chmod 644 "$CANON_NSSWITCH" + chattr +i "$CANON_NSSWITCH" 2>/dev/null || warn "Failed to protect canonical nsswitch copy" + fi + fi + + run systemctl enable --now nsswitch-guard.path + + # Perform initial nsswitch enforcement + if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would run $INSTALL_ENFORCE_NSSWITCH" + else + "$INSTALL_ENFORCE_NSSWITCH" || warn "nsswitch enforcement returned non-zero" + fi else - "$INSTALL_ENFORCE" || warn "Enforcement returned non-zero" + note "Skipping nsswitch protection (--skip-nsswitch)" +fi + +msg "Performing initial hosts enforcement" +if [[ $DRY_RUN -eq 1 ]]; then + echo "DRY-RUN: would run $INSTALL_ENFORCE" +else + "$INSTALL_ENFORCE" || warn "Enforcement returned non-zero" fi ###################################################################### @@ -385,12 +428,15 @@ fi ###################################################################### echo msg "Hosts guard setup complete" -echo "Canonical copy: $CANON" +echo "Canonical hosts copy: $CANON" +echo "Canonical nsswitch copy: $CANON_NSSWITCH" echo "Enforce script: $INSTALL_ENFORCE" +echo "nsswitch enforce: $INSTALL_ENFORCE_NSSWITCH" echo "Unlock command: sudo $INSTALL_UNLOCK" echo "Delay (seconds): $DELAY" echo "Auto-revert path watch: $([[ $ENABLE_PATH -eq 1 ]] && echo enabled || echo disabled)" echo "Read-only bind mount: $([[ $ENABLE_BIND -eq 1 ]] && echo enabled || echo disabled)" +echo "nsswitch protection: $([[ $ENABLE_NSSWITCH -eq 1 ]] && echo enabled || echo disabled)" echo "Shell history suppression: $([[ $INSTALL_SHELL_HOOKS -eq 1 ]] && echo enabled || echo disabled)" echo "Audit rule: $([[ $INSTALL_AUDIT_RULE -eq 1 ]] && echo enabled || echo disabled)" echo "Alias stub: $([[ $ADD_ALIAS_STUB -eq 1 ]] && echo enabled || echo disabled)"