From 03bf1885ae5fac390e516de7bbf38264f9af367f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 1 Oct 2025 20:56:04 +0200 Subject: [PATCH] feat: more hosts friction --- hosts/guard/psychological/unlock-hosts.sh | 19 +++- hosts/guard/setup_hosts_guard.sh | 129 +++++++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/hosts/guard/psychological/unlock-hosts.sh b/hosts/guard/psychological/unlock-hosts.sh index 9e8d431..4b82faf 100644 --- a/hosts/guard/psychological/unlock-hosts.sh +++ b/hosts/guard/psychological/unlock-hosts.sh @@ -5,6 +5,7 @@ set -euo pipefail TARGET=/etc/hosts CANON=/usr/local/share/locked-hosts LOG=/var/log/hosts-guard.log +SYSLOG_TAG=hosts-unlock EDITOR_CMD=${EDITOR:-nano} DELAY_SECONDS=45 @@ -13,7 +14,14 @@ log() { printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG" > require_root() { if [[ $EUID -ne 0 ]]; then exec sudo -E bash "$0" "$@"; fi } require_root "$@" -log "Requested intentional /etc/hosts modification session." +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 +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 @@ -42,9 +50,11 @@ 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." + 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." + 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 @@ -55,5 +65,6 @@ fi systemctl start hosts-guard.path || true systemctl start hosts-bind-mount.service || true -log "Unlock session complete." +log "Unlock session complete. Reason: $REASON" +logger -t "$SYSLOG_TAG" "session_end user=${SUDO_USER:-$USER} reason='$REASON'" echo "Done." >&2 diff --git a/hosts/guard/setup_hosts_guard.sh b/hosts/guard/setup_hosts_guard.sh index c73bbfa..7da6cdc 100644 --- a/hosts/guard/setup_hosts_guard.sh +++ b/hosts/guard/setup_hosts_guard.sh @@ -36,6 +36,9 @@ ENABLE_PATH=1 UNINSTALL=0 DELAY=45 DRY_RUN=0 +INSTALL_SHELL_HOOKS=1 +INSTALL_AUDIT_RULE=1 +ADD_ALIAS_STUB=1 ###################################################################### # Helpers @@ -61,6 +64,12 @@ while [[ $# -gt 0 ]]; do --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 ;; @@ -86,6 +95,10 @@ INSTALL_UNLOCK="/usr/local/sbin/unlock-hosts" CANON="/usr/local/share/locked-hosts" HOSTS="/etc/hosts" +# Shell hook destinations (user agnostic system-wide skeleton + etc profile.d) +ZSH_FILTER_SNIPPET="/etc/zsh/hosts_guard_history_filter.zsh" +BASH_FILTER_SNIPPET="/etc/profile.d/hosts_guard_history_filter.sh" + SYSTEMD_DIR="/etc/systemd/system" ###################################################################### @@ -103,7 +116,9 @@ if [[ $UNINSTALL -eq 1 ]]; then "$INSTALL_UNLOCK" \ "$SYSTEMD_DIR/hosts-guard.service" \ "$SYSTEMD_DIR/hosts-guard.path" \ - "$SYSTEMD_DIR/hosts-bind-mount.service"; do + "$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)." @@ -161,6 +176,114 @@ if [[ $DELAY -ne 45 ]]; then 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 + FILTER_PATTERN='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)' + + # 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() { + emulate -L zsh + setopt extendedglob + local line="$1" + local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)' + if [[ $line =~ ${_pattern} ]]; then + return 1 + fi + return 0 +} +if typeset -f add-zsh-hook >/dev/null 2>&1; then + add-zsh-hook zshaddhistory _hosts_guard_history_filter 2>/dev/null || true +else + zshaddhistory() { _hosts_guard_history_filter "$1"; } +fi +ZEOF + 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' +# Added by hosts guard setup – suppress unlock-hosts commands from Bash history +export HISTCONTROL=ignoredups:erasedups +_hosts_guard_hist_filter() { + local last_cmd + local _pattern='(^|;|&&|\|\|)\s*(sudo\s+)?(/usr/local/sbin/)?unlock-hosts(\s|;|$)' + last_cmd=$(history 1 2>/dev/null | sed -E 's/^ *[0-9]+ +//') + if [[ -n $last_cmd && $last_cmd =~ ${_pattern} ]]; then + local id + id=$(history 1 2>/dev/null | awk '{print $1}') + if [[ -n $id ]]; then history -d $id 2>/dev/null || true; fi + history -w 2>/dev/null || true + history -c || true + history -r 2>/dev/null || true + fi +} +case :${PROMPT_COMMAND-}: in + *:_hosts_guard_hist_filter:* ) ;; + * ) PROMPT_COMMAND="_hosts_guard_hist_filter${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; +esac +BEOF + chmod 644 "$BASH_FILTER_SNIPPET" + fi + fi +else + 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' +# 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 +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="-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 || 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" > /etc/audit/rules.d/hosts_unlock.rules + fi + fi + else + warn "auditctl not found; skipping audit rule (install auditd to enable)" + fi +fi + ###################################################################### # Install systemd units ###################################################################### @@ -206,6 +329,9 @@ 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 "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)" echo echo "Test flow:" echo " sudo sed -i '1s/.*/# tamper test/' /etc/hosts # Should revert automatically" @@ -213,5 +339,6 @@ echo " sudo $INSTALL_UNLOCK # Intentional edit workflow" echo echo "Uninstall:" echo " sudo $0 --uninstall" +echo "(Optional) Skip shell history hooks: --no-shell-hooks" echo exit 0