#!/usr/bin/env bash # ============================================================================= # copyfail-setup.sh — Copy Fail mitigation + server setup & hardening prep # ============================================================================= # # A single, idempotent, multi-distro script to run on a server while you are # connected to it. It performs five things (each can be skipped with a flag): # # 1. Mitigate "Copy Fail" (CVE-2026-31431). Optionally (--update) also apply # pending package updates. # 2. Install a per-user login welcome banner (~/.bashrc). # 3. Install useful per-user shell aliases (~/.bashrc). # 4. Install tmux, ripgrep, and fzf (fuzzy Ctrl+R history search). # 5. Write a read-only security baseline report for later hardening. # # Design goals: # * Multi-distro: apt (Debian/Ubuntu), dnf/yum (RHEL/CentOS/Alma/Rocky/Oracle/ # Fedora), zypper (openSUSE/SLES), pacman (Arch/Manjaro/CachyOS/EndeavourOS/ # Artix), and apk (Alpine). Tolerates EOL boxes with dead repos. # * Non-fatal: one failed step never aborts the rest; everything is summarized. # * Idempotent: safe to re-run; the ~/.bashrc block is replaced, not appended. # * Run as your normal user; privileged steps use sudo automatically. Banner # and aliases are written to the *invoking* user's ~/.bashrc only. # # Usage: # ./copyfail-setup.sh [options] # # Options: # --no-mitigation Skip the Copy Fail module blacklist / unload # --update Apply pending OS package updates. OFF by default: the # module blacklist already closes the Copy Fail surface, so # an unattended full upgrade (which can break a neglected # box) is left for a maintenance window. apt upgrades # without removals; dnf/yum/zypper/apk run their normal # update; pacman still needs --upgrade-rolling on top. # --no-update Skip the OS package upgrade (already the default). # --no-banner Skip installing the login banner # --no-aliases Skip installing shell aliases # --no-tools Skip installing tmux / ripgrep / fzf # --no-report Skip the security report # --block-af-alg Also blacklist the whole AF_ALG socket family, not just # algif_aead. Broader hammer; can break IPsec af_alg / # OpenSSL afalg / libkcapi. Default blocks only algif_aead. # --upgrade-rolling Allow the unattended full upgrade on rolling-release # distros (pacman). OFF by default: an unattended # 'pacman -Syu' on a neglected box can break it (stale # keyring, partial upgrade, file-conflict abort), so # rolling upgrades should be run by hand. The CVE # mitigation is still applied either way. # --report-dir DIR Where to write the security report (default: CWD) # --deep Add slow SUID/SGID + world-writable filesystem scans # to the security report # --yes Non-interactive: never prompt, never reboot # -h, --help Show this help # # Environment: # COPYFAIL_MIN_BOOT_MB Minimum free space (MB) required on /boot (or / when # /boot is not a separate mount) before the package # upgrade runs. Below this the upgrade is SKIPPED, so a # new-kernel postinst can't fill /boot and wedge dpkg/rpm # or leave the box unbootable. Default: 150. # # Exit status: # 0 Clean run, or every security-critical step succeeded. Best-effort steps # (package upgrade, tool install) may still have warned — see the summary. # 1 A security-critical step failed: the Copy Fail mitigation could not be # applied (no privilege, or the modprobe.d blacklist could not be written). # Lets fleet orchestration flag the host with `if ! copyfail-setup.sh ...`. # ============================================================================= set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CHECKER="$SCRIPT_DIR/check-copy-fail.sh" # ----- Defaults -------------------------------------------------------------- DO_MITIGATION=1 DO_UPDATE=0 DO_BANNER=1 DO_ALIASES=1 DO_TOOLS=1 DO_REPORT=1 DEEP_REPORT=0 ASSUME_YES=0 BLOCK_AF_ALG=0 UPGRADE_ROLLING=0 REPORT_DIR="$(pwd)" # ----- Argument parsing ------------------------------------------------------ while [ "$#" -gt 0 ]; do case "$1" in --no-mitigation) DO_MITIGATION=0 ;; --update) DO_UPDATE=1 ;; --no-update) DO_UPDATE=0 ;; --no-banner) DO_BANNER=0 ;; --no-aliases) DO_ALIASES=0 ;; --no-tools) DO_TOOLS=0 ;; --no-report) DO_REPORT=0 ;; --block-af-alg) BLOCK_AF_ALG=1 ;; --upgrade-rolling) UPGRADE_ROLLING=1 ;; --deep) DEEP_REPORT=1 ;; --yes|-y) ASSUME_YES=1 ;; --report-dir) shift case "${1:-}" in ''|--*) printf 'Option --report-dir needs a directory argument.\n' >&2; exit 2 ;; *) REPORT_DIR="$1" ;; esac ;; -h|--help) # Print the leading comment header (every '#' line after the # shebang, up to the first line of real code). awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0" exit 0 ;; *) printf 'Unknown option: %s (use --help)\n' "$1" >&2 exit 2 ;; esac shift done # ----- Output helpers -------------------------------------------------------- if [ -t 1 ]; then C_RED=$'\033[0;31m'; C_GREEN=$'\033[0;32m'; C_YELLOW=$'\033[0;33m' C_BLUE=$'\033[0;34m'; C_CYAN=$'\033[0;36m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m' else C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_BOLD=""; C_RESET="" fi log_info() { printf '%s[INFO]%s %s\n' "$C_BLUE" "$C_RESET" "$1"; } log_ok() { printf '%s[ OK ]%s %s\n' "$C_GREEN" "$C_RESET" "$1"; } log_warn() { printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$1"; } log_err() { printf '%s[FAIL]%s %s\n' "$C_RED" "$C_RESET" "$1"; } log_head() { printf '\n%s===== %s =====%s\n' "$C_BOLD$C_CYAN" "$1" "$C_RESET"; } # ----- Summary tracking ------------------------------------------------------ declare -a SUMMARY=() add_summary() { SUMMARY+=("$1"); } # ----- Reboot tracking ------------------------------------------------------- REBOOT_NEEDED=0 REBOOT_REASON="" # NEW_KERNEL is set when a kernel was (re)installed this run, so the reboot # prompt can warn the operator to verify /boot + the bootloader first. NEW_KERNEL=0 flag_reboot() { REBOOT_NEEDED=1; REBOOT_REASON="${REBOOT_REASON:+$REBOOT_REASON; }$1"; } # ----- Exit status tracking -------------------------------------------------- # The script is non-fatal by design: one failed step never aborts the others. # But fleet orchestration still needs to know whether the security-critical work # actually landed, so RUN_RC carries that out through the exit code. It stays 0 # on a clean run and becomes 1 only when a critical step — currently, applying # the Copy Fail mitigation — could not be completed, so a wrapper can do # `if ! copyfail-setup.sh ...; then alert; fi`. Best-effort steps that are # expected to flake on EOL boxes (package upgrade, tool install) do NOT change # it; their status is surfaced in the summary instead. RUN_RC=0 fail_critical() { RUN_RC=1; } # ----- Platform sanity ------------------------------------------------------- if [ "$(uname -s)" != "Linux" ]; then log_err "This script targets Linux only (uname -s = $(uname -s)). Aborting." exit 3 fi # ----- Privilege detection --------------------------------------------------- # Privileged steps (upgrade, modprobe, package install, parts of the report) # need root. If we are not root, route them through sudo. Dotfile edits are # always done as the *invoking* user, never root. SUDO="" if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then SUDO="sudo" else log_warn "Not running as root and 'sudo' not found — privileged steps" log_warn "(upgrade, mitigation, tool install) will be skipped where needed." fi fi # run_priv CMD... — run a command with privilege if available, else as-is. run_priv() { if [ -n "$SUDO" ]; then $SUDO "$@" else "$@" fi } # can_priv — true if we can perform privileged actions (root or have sudo). can_priv() { [ "$(id -u)" -eq 0 ] || [ -n "$SUDO" ]; } # write_priv PATH — write stdin to a root-owned path. write_priv() { if [ -n "$SUDO" ]; then $SUDO tee "$1" >/dev/null else tee "$1" >/dev/null fi } # ----- Target user / home for dotfile edits ---------------------------------- # If the whole script was launched under sudo, edit the invoking user's home, # not root's. Otherwise use the current user. if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then TARGET_USER="$SUDO_USER" else TARGET_USER="$(id -un)" fi TARGET_HOME="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f6)" [ -z "$TARGET_HOME" ] && TARGET_HOME="${HOME:-/root}" TARGET_GROUP="$(id -gn "$TARGET_USER" 2>/dev/null || echo "$TARGET_USER")" # Login shell of the dotfile target. The managed ~/.bashrc block is bash syntax # (arrays, [[ ]], shopt, bind); if the user logs in under dash/sh/zsh we must not # write it or wire a profile to source it, or every login spews parse errors. TARGET_SHELL="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f7)" # Numeric group of the home directory itself. Used as the group for dotfiles we # create *fresh*, so a hand-rolled shared-group home isn't forced to the user's # primary group. Pre-existing files keep their own owner:group (see section_bashrc). TARGET_HOME_GROUP="$(stat -c %g "$TARGET_HOME" 2>/dev/null)" [ -z "$TARGET_HOME_GROUP" ] && TARGET_HOME_GROUP="$TARGET_GROUP" # fix_owner PATH [GROUP] — if we created a file as root for another user, hand # ownership back to that user. GROUP defaults to the user's primary group; pass # the home-dir group for freshly created dotfiles. fix_owner() { [ "$(id -u)" -eq 0 ] && [ "$TARGET_USER" != "root" ] || return 0 chown "$TARGET_USER:${2:-$TARGET_GROUP}" "$1" 2>/dev/null \ || log_warn "Could not hand $1 back to $TARGET_USER (left root-owned)." } # ----- Distribution / package-manager detection ------------------------------ DISTRO_ID="unknown"; DISTRO_LIKE=""; DISTRO_PRETTY="unknown"; DISTRO_VERSION_ID="" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_LIKE="${ID_LIKE:-}" DISTRO_PRETTY="${PRETTY_NAME:-unknown}" DISTRO_VERSION_ID="${VERSION_ID:-}" fi PKG_MGR="" if command -v apt-get >/dev/null 2>&1; then PKG_MGR="apt" elif command -v dnf >/dev/null 2>&1; then PKG_MGR="dnf" elif command -v yum >/dev/null 2>&1; then PKG_MGR="yum" elif command -v zypper >/dev/null 2>&1; then PKG_MGR="zypper" elif command -v pacman >/dev/null 2>&1; then PKG_MGR="pacman" elif command -v apk >/dev/null 2>&1; then PKG_MGR="apk" fi # Is this an Enterprise-Linux (RHEL family) box, and is it Fedora? case " $DISTRO_ID $DISTRO_LIKE " in *" rhel "*|*" centos "*|*" fedora "*) RHEL_FAMILY=1 ;; *) RHEL_FAMILY=0 ;; esac case "$DISTRO_ID" in fedora) IS_FEDORA=1 ;; *) IS_FEDORA=0 ;; esac # Resolve a hostname without depending on the `hostname` binary, which is not # installed on many minimal/Arch systems and whose -f form also fails when the # FQDN can't be resolved. Prefer FQDN when available, then $HOSTNAME, then uname. get_host() { local h # 'hostname -f' does a (reverse-)DNS lookup that hangs for the resolver # timeout on a box with a dead /etc/resolv.conf; cap it when 'timeout' is # available, and fall back to the plain (lookup-free) hostname otherwise. if command -v timeout >/dev/null 2>&1; then h=$( { timeout 1 hostname -f || timeout 1 hostname; } 2>/dev/null ) else h=$(hostname 2>/dev/null) fi [ -n "$h" ] || h="${HOSTNAME:-$(uname -n 2>/dev/null)}" printf '%s' "${h:-localhost}" } log_head "copyfail-setup" log_info "Host : $(get_host)" log_info "Distro : $DISTRO_PRETTY ($DISTRO_ID ${DISTRO_VERSION_ID:-?})" log_info "Kernel : $(uname -r)" log_info "Pkg mgr : ${PKG_MGR:-none detected}" log_info "Dotfiles : $TARGET_HOME/.bashrc (user: $TARGET_USER)" log_info "Privilege : $( [ "$(id -u)" -eq 0 ] && echo root || { [ -n "$SUDO" ] && echo "sudo" || echo "unprivileged"; } )" # ============================================================================= # Section 1 — Copy Fail mitigation + full upgrade # ============================================================================= # Parse the "verdict" field out of check-copy-fail.sh --json output. copyfail_verdict() { [ -r "$CHECKER" ] || { echo "checker-missing"; return; } local out out="$(bash "$CHECKER" --json 2>/dev/null)" || true printf '%s' "$out" | grep -o '"verdict":"[^"]*"' | head -1 | cut -d'"' -f4 } VERDICT_BEFORE="" VERDICT_AFTER="" section_mitigation() { log_head "1/5 Copy Fail mitigation (CVE-2026-31431)" VERDICT_BEFORE="$(copyfail_verdict)" log_info "Verdict before: ${VERDICT_BEFORE:-unknown}" if ! can_priv; then log_err "No privilege to apply mitigation — skipping." add_summary "Mitigation : SKIPPED (no privilege)" fail_critical return fi # Persistent autoload prevention. By default we block ONLY the specific # vulnerable interface (algif_aead) — that alone neutralizes the exploit's # bind(AF_ALG, {.salg_type="aead", ...}). We deliberately leave the af_alg # socket family itself loadable, because blanket-blocking it breaks # legitimate users (IPsec af_alg ESP offload, OpenSSL afalg engine, # libkcapi). check-copy-fail.sh probes the aead *bind* (not just the family # socket), so an algif_aead-only block still verifies as MITIGATED. # --block-af-alg opts into the bigger hammer for hosts that don't need # AF_ALG at all. local conf="/etc/modprobe.d/copyfail-mitigation.conf" local -a conf_lines mods conf_lines=( "# CVE-2026-31431 (Copy Fail) mitigation — installed by copyfail-setup.sh" "# Neutralizes the vulnerable algif_aead AEAD interface: an unprivileged" "# bind(AF_ALG, {.salg_type=\"aead\"}) can no longer reach it." "# Revert: delete this file, run 'modprobe algif_aead', reboot." "install algif_aead /bin/true" "blacklist algif_aead" ) mods=(algif_aead) if [ "$BLOCK_AF_ALG" -eq 1 ]; then conf_lines+=( "# --block-af-alg: also close the whole AF_ALG userspace crypto socket" "# family. Broader; can break IPsec af_alg / OpenSSL afalg / libkcapi." "install af_alg /bin/true" "blacklist af_alg" ) mods+=(af_alg) fi if printf '%s\n' "${conf_lines[@]}" | write_priv "$conf"; then log_ok "Wrote $conf (blacklisted: ${mods[*]})." else log_err "Could not write $conf." add_summary "Mitigation : FAILED (modprobe.d write)" fail_critical return fi # NOTE: we deliberately do NOT rebuild the initramfs here. algif_aead is not # loaded during early boot, so the /etc/modprobe.d blacklist above is fully # sufficient for the normal (post-initramfs) module path. Regenerating the # *running* kernel's initramfs (update-initramfs -u / dracut --force) buys no # extra protection for this CVE but is the single biggest way to brick a # non-standard box — a full /boot, a hand-built initramfs, or a missing # root/storage/crypto module would leave the host unbootable on next reboot. # Unload currently-loaded modules now. We only unload what we blacklisted # (algif_aead by default; af_alg too under --block-af-alg). algif_aead # depends on af_alg and is listed first, so removing it never drags out a # family still in use elsewhere. A module that is in use can't be unloaded # live — the blacklist still prevents re-autoload, but a reboot is needed to # fully close the surface. local m loaded_any=0 for m in "${mods[@]}"; do if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$m"; then loaded_any=1 if run_priv modprobe -r "$m" 2>/dev/null; then log_ok "Unloaded the running $m module." else log_warn "$m is loaded and in use; could not unload live." flag_reboot "$m loaded — reboot required to fully close the Copy Fail surface" fi fi done [ "$loaded_any" -eq 0 ] && log_ok "None of the targeted modules (${mods[*]}) are currently loaded." # Decide how honestly to report the result. The decisive interface is # algif_aead. Two cases where the modprobe.d blacklist does NOT actually # close the surface: # * It is built INTO the kernel (=y, not a module). Then blacklist/unload # are both inert and a reboot won't help — only a patched kernel will. # modules.builtin lists compiled-in modules. # * It is still loaded after the unload attempt (in use). The persistent # blacklist IS applied, but the live surface stays open until a reboot. local builtin_hit="" md for md in "/lib/modules/$(uname -r)/modules.builtin" \ "/usr/lib/modules/$(uname -r)/modules.builtin"; do [ -r "$md" ] && grep -qE '(^|/)algif_aead\.ko' "$md" 2>/dev/null \ && { builtin_hit=1; break; } done local still_loaded=0 lsmod 2>/dev/null | awk '{print $1}' | grep -qx algif_aead && still_loaded=1 if [ -n "$builtin_hit" ]; then log_err "algif_aead is BUILT INTO this kernel (not a module) — the blacklist cannot disable it." log_err "Only a patched/replacement kernel closes this surface; the modprobe.d file is inert here." add_summary "Mitigation : PARTIAL (algif_aead built into kernel — needs a patched kernel)" fail_critical elif [ "$still_loaded" -eq 1 ]; then log_warn "algif_aead is still loaded — autoload is now blocked, but a reboot is required to fully close the surface." add_summary "Mitigation : APPLIED — REBOOT REQUIRED (blacklisted ${mods[*]}; algif_aead still loaded)" else add_summary "Mitigation : APPLIED (blacklisted: ${mods[*]})" fi } # boot_space_ok — true if the filesystem holding /boot (or / when /boot does not # exist) has at least COPYFAIL_MIN_BOOT_MB free. `df /boot` resolves to whatever # filesystem physically holds /boot, so this is correct whether or not /boot is a # separate mount. On a parse failure we return true (don't block on uncertainty). BOOT_SPACE_DIR=""; BOOT_SPACE_AVAIL_MB=""; BOOT_SPACE_MIN_MB="" boot_space_ok() { local min="${COPYFAIL_MIN_BOOT_MB:-150}" dir=/ avail_kb [ -d /boot ] && dir=/boot avail_kb="$(df -Pk "$dir" 2>/dev/null | awk 'NR==2{print $4}')" case "$avail_kb" in ''|*[!0-9]*) return 0 ;; # unknown → don't block the upgrade esac BOOT_SPACE_DIR="$dir" BOOT_SPACE_AVAIL_MB="$((avail_kb / 1024))" BOOT_SPACE_MIN_MB="$min" [ "$BOOT_SPACE_AVAIL_MB" -ge "$min" ] } section_update() { log_head "Full package upgrade" if ! can_priv; then log_err "No privilege to upgrade packages — skipping." add_summary "Upgrade : SKIPPED (no privilege)" return fi if [ -z "$PKG_MGR" ]; then log_warn "No supported package manager detected — skipping upgrade." add_summary "Upgrade : SKIPPED (no pkg manager)" return fi # A full upgrade can pull a new kernel, whose postinst rebuilds the initramfs # and updates the bootloader under /boot. On a box with a (near-)full /boot # that postinst fails mid-way — wedging dpkg/rpm and possibly leaving the host # unbootable. Refuse the upgrade if free space is below COPYFAIL_MIN_BOOT_MB. if ! boot_space_ok; then log_err "Low free space on ${BOOT_SPACE_DIR} (${BOOT_SPACE_AVAIL_MB} MB < ${BOOT_SPACE_MIN_MB} MB) — skipping upgrade." log_warn "A new-kernel postinst (initramfs+bootloader) could fill ${BOOT_SPACE_DIR} and wedge the package manager or leave the box unbootable." log_warn "Free space (e.g. remove old kernels) and re-run, or override the threshold with COPYFAIL_MIN_BOOT_MB." add_summary "Upgrade : SKIPPED (low ${BOOT_SPACE_DIR} space: ${BOOT_SPACE_AVAIL_MB}MB < ${BOOT_SPACE_MIN_MB}MB)" return fi local rc=0 case "$PKG_MGR" in apt) log_info "apt-get update ..." run_priv env DEBIAN_FRONTEND=noninteractive apt-get update || rc=$? # Plain 'upgrade' (NOT 'full-upgrade'/'dist-upgrade') so a package is # never removed to resolve a conflict — on a client box that # "never removes" guarantee matters more than squeezing out the last # few upgrades, and whatever is held back is left for a human to # look at. APT::Get::Upgrade-Allow-New still lets a genuinely new # dependency (e.g. a new kernel package) come in; apt versions too # old to know that option just ignore it, degrading to a no-removal # upgrade. We also drop the old unconditional 'autoremove' — silently # deleting packages on someone else's server is exactly the surprise # this script exists to avoid. log_info "apt-get upgrade (no removals; new deps such as kernels allowed) ..." run_priv env DEBIAN_FRONTEND=noninteractive apt-get -y \ -o Dpkg::Options::=--force-confold \ -o Dpkg::Options::=--force-confdef \ -o APT::Get::Upgrade-Allow-New=true \ upgrade || rc=$? ;; dnf) log_info "dnf -y upgrade ..." run_priv dnf -y upgrade || rc=$? ;; yum) log_info "yum -y update ..." run_priv yum -y update || rc=$? ;; zypper) # zypper uses informational exit codes (100-106) that are NOT errors: # 102 = reboot needed, 103 = zypper self-update/restart, 106 = some # repos skipped. Map those to success so a reboot-needed update is not # mis-reported as a failure. 'update' never removes packages. log_info "zypper refresh ..." run_priv zypper --non-interactive refresh || true log_info "zypper update (no removals) ..." run_priv zypper --non-interactive update --auto-agree-with-licenses local zrc=$? case "$zrc" in 0|100|101|104|105|106) ;; 102|103) flag_reboot "zypper reports a reboot/restart is needed" ;; *) rc=$zrc ;; esac ;; pacman) # Rolling release: an *unattended* full upgrade is unsafe on a # neglected box. A stale archlinux-keyring fails signature checks; a # partial-upgrade state or a file conflict makes --noconfirm abort # mid-transaction, leaving a mixed old/new (broken) system. So we skip # it by default and ask for it to be run by hand; --upgrade-rolling # opts back in. (-Syu is still the only safe form — never a bare -Sy.) if [ "$UPGRADE_ROLLING" -eq 1 ]; then log_info "pacman -Syu (--upgrade-rolling) ..." run_priv pacman -Syu --noconfirm || rc=$? else log_warn "Rolling-release distro: skipping unattended 'pacman -Syu'." log_warn "Run it by hand (attend keyring / partial-upgrade / file-conflict prompts), or re-run with --upgrade-rolling." add_summary "Upgrade : SKIPPED (rolling distro; run 'pacman -Syu' by hand or use --upgrade-rolling)" return fi ;; apk) log_info "apk update ..." run_priv apk update || rc=$? log_info "apk upgrade (no removals) ..." run_priv apk upgrade || rc=$? ;; esac if [ "$rc" -eq 0 ]; then log_ok "Package upgrade completed." add_summary "Upgrade : OK" else log_err "Package upgrade returned errors (rc=$rc) — common on EOL repos." add_summary "Upgrade : PARTIAL/FAILED (rc=$rc)" fi detect_reboot_needed } # Set REBOOT_NEEDED if a newer kernel is installed than the one running. detect_reboot_needed() { if [ -f /var/run/reboot-required ] || [ -f /run/reboot-required ]; then flag_reboot "new kernel/libs installed (reboot-required flag set)" # The .pkgs companion lists what triggered it; a linux-image there means # the kernel itself was updated (Debian/Ubuntu). grep -qi 'linux-image' /run/reboot-required.pkgs /var/run/reboot-required.pkgs 2>/dev/null \ && NEW_KERNEL=1 return fi if command -v needs-restarting >/dev/null 2>&1; then if ! run_priv needs-restarting -r >/dev/null 2>&1; then flag_reboot "needs-restarting reports a reboot is required" fi return fi # Fallback: compare running kernel to newest installed kernel package. local running newest running="$(uname -r)" if command -v rpm >/dev/null 2>&1; then # kernel / kernel-core (RHEL family), kernel-default (SUSE). newest="$(rpm -q kernel kernel-core kernel-default 2>/dev/null \ | sed -E 's/^kernel(-core|-default)?-//' \ | grep -E '^[0-9]' | sort -V | tail -1)" if [ -n "$newest" ] && [ "${running%.*}" != "$newest" ] \ && [ "$(printf '%s\n%s\n' "$newest" "$running" | sort -V | tail -1)" = "$newest" ] \ && [ "$newest" != "$running" ]; then flag_reboot "installed kernel ($newest) newer than running ($running)" NEW_KERNEL=1 fi elif command -v dpkg >/dev/null 2>&1; then newest="$(dpkg -l 'linux-image-[0-9]*' 2>/dev/null | awk '/^ii/{print $2}' \ | sed 's/^linux-image-//' | sort -V | tail -1)" if [ -n "$newest" ] && [ "$newest" != "$running" ] \ && [ "$(printf '%s\n%s\n' "$newest" "$running" | sort -V | tail -1)" = "$newest" ]; then flag_reboot "installed kernel ($newest) newer than running ($running)" NEW_KERNEL=1 fi fi # Universal backstop for pacman/zypper/apk (and anything the package-name # heuristics above miss): if a kernel-modules tree exists, holds at least one # kernel, but the *running* kernel's directory is gone, the kernel package # was replaced and the box must reboot before it can load further modules. # apt/dnf keep old kernels installed, so this stays a no-op there; the # "tree is non-empty" guard avoids false positives in module-less containers. if [ "$REBOOT_NEEDED" -eq 0 ]; then local moddir="" [ -d /lib/modules ] && moddir=/lib/modules [ -d /usr/lib/modules ] && moddir=/usr/lib/modules if [ -n "$moddir" ] && [ ! -d "$moddir/$running" ] \ && [ -n "$(ls -1 "$moddir" 2>/dev/null)" ]; then flag_reboot "modules for the running kernel ($running) are gone — kernel updated; reboot needed" NEW_KERNEL=1 fi fi } # ============================================================================= # Sections 2 & 3 — Welcome banner + aliases (per-user ~/.bashrc) # ============================================================================= # The start marker carries the last-updated date so it ages well. Stripping # (below) matches by a stable PATTERN, so a re-run replaces any prior block — # the old static format or any dated one — keeping the operation idempotent. BLOCK_START="# >>> copyfail-setup (updated $(date +%F)) >>>" BLOCK_END="# <<< copyfail-setup <<<" BLOCK_START_RE='^# >>> copyfail-setup.*>>>' BLOCK_END_RE='^# <<< copyfail-setup.*<<<' # Emit the managed ~/.bashrc block to stdout. The body is a quoted heredoc so # nothing is expanded now — it is stored verbatim and evaluated at login time. emit_bashrc_block() { printf '%s\n' "$BLOCK_START" printf '%s\n' "# Managed by copyfail-setup.sh — edits between these markers are overwritten." if [ "$DO_ALIASES" -eq 1 ]; then cat <<'COPYFAIL_ALIASES' if [[ $- == *i* ]]; then # --- aliases (interactive shells) --- alias ls='ls -Alh --color=auto' alias grep='grep --color=auto' alias egrep='grep -E --color=auto' alias fgrep='grep -F --color=auto' alias df='df -h' alias du='du -h' alias free='free -h' alias mkdir='mkdir -pv' alias ports='ss -tulpn' alias ipc='ip -c' alias myip='curl -fsS --max-time 3 https://api.ipify.org && echo' alias rm='rm -I' alias cp='cp -i' alias mv='mv -i' export HISTSIZE=10000 export HISTFILESIZE=20000 export HISTCONTROL=ignoreboth export HISTTIMEFORMAT='%F %T ' shopt -s histappend 2>/dev/null # --- smarter tab-completion (readline; each line is harmless if unsupported) --- bind 'set completion-ignore-case on' 2>/dev/null bind 'set show-all-if-ambiguous on' 2>/dev/null bind 'set colored-stats on' 2>/dev/null bind 'set mark-symlinked-directories on' 2>/dev/null # Up/Down: search history by the prefix already typed (empty line = normal cycle) bind '"\e[A": history-search-backward' 2>/dev/null bind '"\e[B": history-search-forward' 2>/dev/null # Load system bash-completion (systemctl/git/… tab-complete) if not already # active — with alias expansion off so our aliases can't leak into its funcs. if ! type _init_completion >/dev/null 2>&1; then _cf_ea2=$(shopt -p expand_aliases 2>/dev/null); shopt -u expand_aliases for _cf_bc in /usr/share/bash-completion/bash_completion /etc/bash_completion; do [ -r "$_cf_bc" ] && { . "$_cf_bc"; break; } done eval "${_cf_ea2:-:}" 2>/dev/null; unset _cf_bc _cf_ea2 fi # --- shared history across sessions / tmux panes --- HISTIGNORE='ls:cd:pwd:exit:clear:history' # append this session's commands and pull in other sessions' on each prompt if [[ "${PROMPT_COMMAND:-}" != *"history -a"* ]]; then PROMPT_COMMAND="history -a; history -n${PROMPT_COMMAND:+; ${PROMPT_COMMAND}}" fi # --- fzf: fuzzy, scrollable Ctrl+R history search (when fzf is installed) --- # Also gives Ctrl+T (files) and Alt+C (cd). Loaded with alias expansion off # so our aliases can't leak into fzf's internal functions. if command -v fzf >/dev/null 2>&1; then _cf_ea3=$(shopt -p expand_aliases 2>/dev/null); shopt -u expand_aliases _cf_fzbash=$(fzf --bash 2>/dev/null) # one invocation, not two if [ -n "$_cf_fzbash" ]; then eval "$_cf_fzbash" # fzf >= 0.48 else for _cf_fz in /usr/share/fzf/key-bindings.bash \ /usr/share/doc/fzf/examples/key-bindings.bash \ /usr/share/fzf/shell/key-bindings.bash \ /usr/local/share/fzf/key-bindings.bash \ /usr/local/opt/fzf/shell/key-bindings.bash \ "$HOME/.fzf.bash"; do [ -r "$_cf_fz" ] && { . "$_cf_fz"; break; } done fi # Force Ctrl+R onto the fzf widget if it loaded but the bind didn't take # (or a later rc rebound it). Harmless when it's already bound. declare -F __fzf_history__ >/dev/null 2>&1 && bind -x '"\C-r": __fzf_history__' 2>/dev/null eval "${_cf_ea3:-:}" 2>/dev/null; unset _cf_fz _cf_fzbash _cf_ea3 fi fi COPYFAIL_ALIASES fi if [ "$DO_BANNER" -eq 1 ]; then cat <<'COPYFAIL_BANNER' # Disable alias expansion while the banner function is parsed, so user aliases # (e.g. mkdir='mkdir -pv', mv='mv -i') cannot leak into its body. Restored after. _cf_ea=$(shopt -p expand_aliases 2>/dev/null); shopt -u expand_aliases 2>/dev/null # Print one "DD-MM-YYYY HH:MM PATH" cache line, middle-truncating an over-long # path Mac-style (/really/long/.../file.txt) so it never wraps and breaks the # left rule. The date column is a fixed 18-char prefix and is kept intact; the # truncation is biased toward the tail so the filename stays readable. Reads # $bar and $cols from the calling banner (bash dynamic scope). _cf_recent_line() { local line="$1" date path avail n keep head tail date="${line:0:18}"; path="${line:18}" # Path budget = width minus bar+indent (6), date column (18), and 1 spare so # nothing touches the right edge (some terminals wrap at exactly $cols). avail=$(( ${cols:-80} - 25 )) [ "$avail" -lt 12 ] && avail=12 n=${#path} if [ "$n" -gt "$avail" ]; then keep=$(( avail - 3 )) tail=$(( keep * 2 / 3 )); head=$(( keep - tail )) path="${path:0:head}...${path:n-tail}" fi printf '%s %s%s\n' "$bar" "$date" "$path" } if [[ $- == *i* ]] && shopt -q login_shell 2>/dev/null; then _copyfail_banner() { local C0 CB CC CG CY CR if [ -t 1 ]; then C0=$'\033[0m'; CB=$'\033[1m'; CC=$'\033[0;36m' CG=$'\033[0;32m'; CY=$'\033[0;33m'; CR=$'\033[0;31m' else C0=""; CB=""; CC=""; CG=""; CY=""; CR="" fi local bar; bar="${CC}│${C0}" # Terminal width, used to truncate long paths so they never wrap onto a # second line and break the left rule. Prefer bash's COLUMNS, fall back # to tput, then a sane default for a non-tty (piped) banner. local cols; cols=${COLUMNS:-} [ -n "$cols" ] || cols=$(tput cols 2>/dev/null) case "$cols" in ''|*[!0-9]*) cols=80;; esac local host os kern # 'hostname -f' does a DNS lookup that can hang for the resolver timeout # on a box with a dead /etc/resolv.conf — cap it like every other slow # probe in this banner; without 'timeout', use the lookup-free hostname. if command -v timeout >/dev/null 2>&1; then host=$( { timeout 1 hostname -f || timeout 1 hostname; } 2>/dev/null ) else host=$(hostname 2>/dev/null) fi [ -n "$host" ] || host="${HOSTNAME:-$(uname -n 2>/dev/null)}" if [ -r /etc/os-release ]; then os=$(. /etc/os-release 2>/dev/null; printf '%s' "${PRETTY_NAME:-Linux}") else os="Linux"; fi kern=$(uname -r) printf '\n%s┌─ %s%s%s ── %s%s\n' "$CC" "$CB" "$host" "$C0$CC" \ "$(date '+%a %Y-%m-%d %H:%M:%S %Z')" "$C0" printf '%s %s — kernel %s\n' "$bar" "$os" "$kern" # Uptime + load average (cheap CPU proxy) local cores load cores=$(nproc 2>/dev/null || echo '?') load=$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null) local up; up=$(uptime -p 2>/dev/null | sed 's/^up //') printf '%s Uptime : %s\n' "$bar" "${up:-n/a}" printf '%s Load : %s (%s cores)\n' "$bar" "${load:-n/a}" "$cores" # Memory if command -v free >/dev/null 2>&1; then printf '%s Memory : %s\n' "$bar" \ "$(free -h | awk '/^Mem:/{u=$3;t=$2} END{print u" / "t}')" fi # Disk (root mount), highlighted if >=85% full if command -v df >/dev/null 2>&1; then local dline duse dcol dline=$(df -h / | awk 'NR==2{print $3" / "$2" ("$5") on "$6}') duse=$(df -P / | awk 'NR==2{gsub(/%/,"",$5); print $5+0}') dcol=$CG; [ "${duse:-0}" -ge 85 ] 2>/dev/null && dcol=$CR printf '%s Disk : %s%s%s\n' "$bar" "$dcol" "$dline" "$C0" fi # Local IPs local lan lan=$(ip -o -4 addr show scope global 2>/dev/null \ | awk '{print $4}' | head -4 | paste -sd' ' -) [ -z "$lan" ] && lan=$(hostname -I 2>/dev/null \ | tr ' ' '\n' | grep . | head -4 | paste -sd' ' -) printf '%s IP : %s\n' "$bar" "${lan:-n/a}" # Public IP — OFF by default: nothing on this box ever contacts a third # party. Enable it per host by exporting COPYFAIL_PUBIP_TTL before login. # When enabled it is fetched synchronously and ONLY as part of an # interactive login (no background job, no timer), cached so back-to-back # logins reuse it, and capped by --max-time so a box with no outbound # route adds at most a couple of seconds, once per TTL. Tunables (export # before login): # COPYFAIL_PUBIP_TTL=off never contact a third party (DEFAULT) # COPYFAIL_PUBIP_TTL= enable; refresh interval (e.g. 21600 = 6h) # COPYFAIL_PUBIP_TTL=0 enable; refresh on every login local cache="${HOME}/.cache/copyfail-pubip" now ts pub ttl fresh ttl="${COPYFAIL_PUBIP_TTL:-off}" now=$(date +%s 2>/dev/null || echo 0) ts=0; [ -r "$cache" ] && ts=$(stat -c %Y "$cache" 2>/dev/null || echo 0) pub=$(cat "$cache" 2>/dev/null) if [ "$ttl" != off ] && [ "$((now - ts))" -ge "$ttl" ] 2>/dev/null \ && command -v curl >/dev/null 2>&1; then mkdir -p "${HOME}/.cache" 2>/dev/null fresh=$( { curl -fsS --max-time 2 https://api.ipify.org \ || curl -fsS --max-time 2 https://ifconfig.me; } 2>/dev/null ) if [ -n "$fresh" ]; then pub="$fresh" printf '%s' "$fresh" > "$cache" 2>/dev/null fi fi printf '%s Pub IP : %s\n' "$bar" "${pub:-n/a}" # Listening ports if command -v ss >/dev/null 2>&1; then local ports ports=$(ss -tuHln 2>/dev/null | awk '{print $5}' \ | sed 's/.*://' | grep -E '^[0-9]+$' | sort -nu | paste -sd' ' -) printf '%s Ports : %s\n' "$bar" "${ports:-none}" fi # systemd health if command -v systemctl >/dev/null 2>&1; then local st failed scol # is-system-running prints the state (e.g. 'degraded') but exits # non-zero when not 'running'; '|| true' keeps the text without # appending anything. Failed-unit names come from --plain ($1). st=$(systemctl is-system-running 2>/dev/null || true); st=${st:-unknown} failed=$(systemctl list-units --state=failed --no-legend --plain 2>/dev/null \ | awk '{print $1}' | paste -sd' ' -) scol=$CG; [ "$st" != "running" ] && scol=$CY printf '%s Systemd: %s%s%s' "$bar" "$scol" "$st" "$C0" [ -n "$failed" ] && printf ' %sfailed:%s %s' "$CR" "$C0" "$failed" printf '\n' fi # Containers — shown only when docker is installed AND queryable by THIS # user (root or the 'docker' group); the login path never uses sudo, so an # unprivileged user just sees nothing rather than a permission error. One # daemon round-trip, time-capped so an unresponsive daemon can't stall the # login; a missing/down daemon leaves the line off entirely. '.Status' is # used (present in every docker version) — running containers report "Up". if command -v docker >/dev/null 2>&1; then local dtimeout='' dkr command -v timeout >/dev/null 2>&1 && dtimeout='timeout 2' dkr=$($dtimeout docker ps -a --format '{{.Status}}' 2>/dev/null) if [ $? -eq 0 ]; then local dtot drun dcol2 dtot=$(printf '%s\n' "$dkr" | grep -c .) drun=$(printf '%s\n' "$dkr" | grep -c '^Up') dcol2=$CG; [ "${drun:-0}" -eq 0 ] && dcol2=$CY printf '%s Docker : %s%s running%s / %s total\n' \ "$bar" "$dcol2" "$drun" "$C0" "$dtot" fi fi # Previous login for this user local ll ll=$(last -wn2 "$USER" 2>/dev/null | sed -n '2p' \ | awk '{$1=""; sub(/^ +/,""); print}') [ -z "$ll" ] && ll=$(lastlog -u "$USER" 2>/dev/null | sed -n '2p' \ | awk '{$1=""; sub(/^ +/,""); print}') printf '%s Last in: %s\n' "$bar" "${ll:-n/a}" # Last 5 files edited by this user. Printed instantly from a cache that # is refreshed in the background (same approach as the /etc scan below), # so a large or slow/NFS $HOME never adds latency to the login itself — # the old foreground 'find' here stalled every login by up to 3s. The # first login (no cache yet) shows a placeholder until the background # pass lands; an empty cache means the scan ran and found nothing. local hcache; hcache="${HOME}/.cache/copyfail-home-recent" printf '%s Recent edits in %s%s%s:\n' "$bar" "$CB" "$HOME" "$C0" if [ ! -e "$hcache" ]; then printf '%s (scanning in background; ready next login)\n' "$bar" elif [ -s "$hcache" ]; then while IFS= read -r line; do [ -n "$line" ] && _cf_recent_line "$line" done < "$hcache" else printf '%s (none found)\n' "$bar" fi local hnow hts; hnow=$(date +%s 2>/dev/null || echo 0) hts=0; [ -r "$hcache" ] && hts=$(stat -c %Y "$hcache" 2>/dev/null || echo 0) if [ "$((hnow - hts))" -gt 3600 ]; then mkdir -p "${HOME}/.cache" 2>/dev/null local hfind='find' command -v timeout >/dev/null 2>&1 && hfind='timeout 15 find' # A hidden compact-ISO sort key (tab-separated, leading field) keeps # sort -r in true reverse-chronological order; cut -f2- then drops it # so the cache holds only the day-month-year display line. ( $hfind "$HOME" -xdev -type f \ -not -path '*/.cache/*' -not -path '*/.git/*' \ -not -path '*/.local/share/Trash/*' \ -printf '%TY%Tm%Td%TH%TM\t%Td-%Tm-%TY %TH:%TM %p\n' 2>/dev/null \ | sort -r | head -5 | cut -f2- > "${hcache}.tmp" 2>/dev/null \ && mv "${hcache}.tmp" "$hcache" 2>/dev/null \ || rm -f "${hcache}.tmp" 2>/dev/null & ) /dev/null 2>&1 fi # Recently changed /etc configs — printed instantly from a cache, which # is refreshed in the background (hourly). A synchronous /etc scan would # slow the login, so it is never run on the login path. Shown only when # there are recent changes. Runs as the logging-in user, so root-only # files (e.g. /etc/ssl/private) are not seen; for full coverage install # a root-run refresh (see notes). local ecache; ecache="${HOME}/.cache/copyfail-etc-recent" if [ -s "$ecache" ]; then printf '%s %s/etc%s changed (last 7d):\n' "$bar" "$CB" "$C0" while IFS= read -r line; do [ -n "$line" ] && _cf_recent_line "$line" done < "$ecache" fi local enow ets; enow=$(date +%s 2>/dev/null || echo 0) ets=0; [ -r "$ecache" ] && ets=$(stat -c %Y "$ecache" 2>/dev/null || echo 0) if [ "$((enow - ets))" -gt 3600 ]; then mkdir -p "${HOME}/.cache" 2>/dev/null local efind='find' command -v timeout >/dev/null 2>&1 && efind='timeout 15 find' ( $efind /etc -xdev -type f -mtime -7 \ -printf '%TY%Tm%Td%TH%TM\t%Td-%Tm-%TY %TH:%TM %p\n' 2>/dev/null \ | sort -r | head -8 | cut -f2- > "${ecache}.tmp" 2>/dev/null \ && mv "${ecache}.tmp" "$ecache" 2>/dev/null \ || rm -f "${ecache}.tmp" 2>/dev/null & ) /dev/null 2>&1 fi printf '%s└─%s\n\n' "$CC" "$C0" } _copyfail_banner fi eval "${_cf_ea:-:}" 2>/dev/null; unset _cf_ea COPYFAIL_BANNER fi printf '%s\n' "$BLOCK_END" } # Make sure the user's login shell actually sources ~/.bashrc. ensure_bashrc_sourced() { local prof="" local f for f in .bash_profile .bash_login .profile; do [ -f "$TARGET_HOME/$f" ] && { prof="$TARGET_HOME/$f"; break; } done if [ -z "$prof" ]; then prof="$TARGET_HOME/.bash_profile" if printf '%s\n' '# created by copyfail-setup.sh' \ '[ -f ~/.bashrc ] && . ~/.bashrc' > "$prof"; then fix_owner "$prof" "$TARGET_HOME_GROUP" log_info "Created $prof to source ~/.bashrc on login." else log_warn "Could not create $prof — the banner may not load on login." fi elif ! grep -q 'bashrc' "$prof" 2>/dev/null; then if printf '\n%s\n%s\n' '# added by copyfail-setup.sh' \ '[ -f ~/.bashrc ] && . ~/.bashrc' >> "$prof"; then log_info "Patched $prof to source ~/.bashrc on login." else log_warn "Could not patch $prof — the banner may not load on login." fi fi } section_bashrc() { # The managed block is bash syntax (arrays, [[ ]], shopt, bind). If the target # user's login shell is not bash — dash/sh/zsh, Alpine's ash, an unreadable # passwd entry — writing ~/.bashrc and wiring a profile to source it would # only spew parse errors at every login. Skip the whole section in that case; # mitigation/upgrade/report are unaffected. case "${TARGET_SHELL##*/}" in bash) ;; *) log_head "2/5 + 3/5 Banner / aliases" log_warn "Login shell of $TARGET_USER is '${TARGET_SHELL:-unknown}', not bash — skipping banner/aliases." [ "$DO_BANNER" -eq 1 ] && add_summary "Banner : SKIPPED (login shell ${TARGET_SHELL:-unknown} is not bash)" [ "$DO_ALIASES" -eq 1 ] && add_summary "Aliases : SKIPPED (login shell ${TARGET_SHELL:-unknown} is not bash)" return ;; esac [ "$DO_BANNER" -eq 1 ] && log_head "2/5 Welcome banner" [ "$DO_ALIASES" -eq 1 ] && log_head "3/5 Shell aliases" local bashrc="$TARGET_HOME/.bashrc" local dir; dir="$(dirname "$bashrc")" # Record a FAILED summary line for whichever of banner/aliases was requested, # so a problem here is visible in the at-a-glance summary, not just in the # scrolled-past log. fail_bashrc() { # fail_bashrc "short reason" [ "$DO_BANNER" -eq 1 ] && add_summary "Banner : FAILED ($1)" [ "$DO_ALIASES" -eq 1 ] && add_summary "Aliases : FAILED ($1)" return 0 } # We replace ~/.bashrc atomically: build the new content in a temp *in the # same directory*, then mv it into place. The mv is the only thing that # touches the live file, so a full disk or a mid-write error can never # truncate an existing ~/.bashrc (the old 'cat tmp > file' did, and then # deleted the temp that still held the recoverable content). An atomic mv # needs write access to the home directory, not to the file itself. if [ ! -w "$dir" ]; then log_err "$dir is not writable by $(id -un) — skipping banner/aliases." [ "$DO_BANNER" -eq 1 ] && add_summary "Banner : SKIPPED (no write to $dir)" [ "$DO_ALIASES" -eq 1 ] && add_summary "Aliases : SKIPPED (no write to $dir)" return fi # Capture the existing file's mode/owner so the replacement keeps them — a # pre-existing dotfile's (possibly non-standard) owner:group is preserved # exactly. For a brand-new file, default to 0644 and the home dir's group. local existed=0 orig_mode="" orig_owner="" if [ -e "$bashrc" ]; then existed=1 orig_mode="$(stat -c %a "$bashrc" 2>/dev/null)" orig_owner="$(stat -c %u:%g "$bashrc" 2>/dev/null)" fi local tmp tmp="$(mktemp "${bashrc}.copyfail.XXXXXX")" || { log_err "mktemp in $dir failed — cannot edit $bashrc; skipping banner/aliases." fail_bashrc "mktemp" return } # Strip any previous managed block from the current file into the temp (the # temp simply starts empty when there is no file yet). if [ "$existed" -eq 1 ]; then # Guard against a corrupt/partial managed block: the strip below sets # skip=1 at the start marker and only clears it at the end marker, so a # start marker with NO end marker (a hand-edit, a truncated paste) would # drop everything after it. If we see that, refuse to touch the file # rather than risk eating the user's ~/.bashrc. local have_start=0 have_end=0 grep -qE "$BLOCK_START_RE" "$bashrc" 2>/dev/null && have_start=1 grep -qE "$BLOCK_END_RE" "$bashrc" 2>/dev/null && have_end=1 if [ "$have_start" -eq 1 ] && [ "$have_end" -eq 0 ]; then log_err "$bashrc has a copyfail start marker but no end marker — not editing it (would drop trailing content)." log_err "Remove the stray '# >>> copyfail-setup ... >>>' line by hand, then re-run." rm -f "$tmp"; fail_bashrc "unterminated managed block"; return fi if ! awk -v s="$BLOCK_START_RE" -v e="$BLOCK_END_RE" ' $0 ~ s {skip=1} skip && $0 ~ e {skip=0; next} !skip {print} ' "$bashrc" > "$tmp"; then log_err "Could not read $bashrc — leaving it unchanged." rm -f "$tmp"; fail_bashrc "read error"; return fi fi # Append the fresh managed block (one blank line before it when extending an # existing file; none for a brand-new file). if ! { [ "$existed" -eq 1 ] && printf '\n'; emit_bashrc_block; } >> "$tmp"; then log_err "Could not assemble new $bashrc content — leaving it unchanged." rm -f "$tmp"; fail_bashrc "assemble error"; return fi # Stamp the temp with the target metadata BEFORE the mv, so the live file is # never momentarily wrong. chown/chmod failures (e.g. NFS, non-member group) # are non-fatal — the content is what matters. if [ "$existed" -eq 1 ]; then [ -n "$orig_mode" ] && chmod "$orig_mode" "$tmp" 2>/dev/null [ -n "$orig_owner" ] && chown "$orig_owner" "$tmp" 2>/dev/null else chmod 0644 "$tmp" 2>/dev/null fix_owner "$tmp" "$TARGET_HOME_GROUP" fi # Atomic replace. On failure the original is left intact and the temp (with # the full new content) is kept and reported, not deleted — no data loss. if ! mv -f "$tmp" "$bashrc"; then log_err "Could not move new content into $bashrc — the original is UNCHANGED." log_err "New content preserved at: $tmp (apply by hand if needed)." fail_bashrc "atomic move"; return fi ensure_bashrc_sourced if [ "$DO_BANNER" -eq 1 ]; then log_ok "Installed login banner into $bashrc." add_summary "Banner : OK ($bashrc)" fi if [ "$DO_ALIASES" -eq 1 ]; then log_ok "Installed shell aliases into $bashrc." add_summary "Aliases : OK ($bashrc)" fi } # ============================================================================= # Section 4 — Install tmux + ripgrep # ============================================================================= section_tools() { log_head "4/5 Install tmux + ripgrep + fzf" if ! can_priv; then log_err "No privilege to install packages — skipping." add_summary "Tools : SKIPPED (no privilege)" return fi if [ -z "$PKG_MGR" ]; then log_warn "No supported package manager — skipping tool install." add_summary "Tools : SKIPPED (no pkg manager)" return fi local installed=() failed=() INSTALL_OUT="" install_one() { local pkg="$1" case "$PKG_MGR" in apt) INSTALL_OUT=$(run_priv env DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg" 2>&1) ;; dnf) INSTALL_OUT=$(run_priv dnf install -y "$pkg" 2>&1) ;; yum) INSTALL_OUT=$(run_priv yum install -y "$pkg" 2>&1) ;; zypper) INSTALL_OUT=$(run_priv zypper --non-interactive install --auto-agree-with-licenses "$pkg" 2>&1) ;; pacman) INSTALL_OUT=$(run_priv pacman -S --needed --noconfirm "$pkg" 2>&1) ;; apk) INSTALL_OUT=$(run_priv apk add "$pkg" 2>&1) ;; esac } # NOTE: on Enterprise Linux, ripgrep/fzf live in EPEL — but we deliberately do # NOT auto-enable EPEL here. Adding a third-party repo to a client box is a # persistent change that alters future 'dnf/yum update' behaviour and may # violate site policy or a Satellite/locked-channel setup — too invasive a # side effect for two convenience tools. If they aren't in the configured # repos the install below simply reports them as unavailable, with a hint. local pkg for pkg in tmux ripgrep fzf; do # Already present? (ripgrep's binary is 'rg') local bin="$pkg"; [ "$pkg" = "ripgrep" ] && bin="rg" if command -v "$bin" >/dev/null 2>&1; then log_ok "$pkg already installed." installed+=("$pkg") continue fi log_info "Installing $pkg ..." if install_one "$pkg"; then log_ok "$pkg installed." installed+=("$pkg") else log_err "Could not install $pkg (not in repos / dead mirror?). Details:" printf '%s\n' "$INSTALL_OUT" | tail -3 | sed 's/^/ | /' failed+=("$pkg") fi done # On Enterprise Linux, a ripgrep/fzf miss is almost always "needs EPEL". Hint # at it (operator's choice to add the repo) rather than enabling it for them. if [ "$RHEL_FAMILY" -eq 1 ] && [ "$IS_FEDORA" -eq 0 ] && [ "${#failed[@]}" -gt 0 ]; then case " ${failed[*]} " in *" ripgrep "*|*" fzf "*) log_info "ripgrep/fzf ship in EPEL on Enterprise Linux. To add it (operator decision):" log_info " ${SUDO:+sudo }$PKG_MGR install -y epel-release # then re-run with --no-mitigation --no-update --no-banner --no-aliases" ;; esac fi local msg="Tools : " msg+="installed=[${installed[*]:-}]" [ "${#failed[@]}" -gt 0 ] && msg+=" FAILED=[${failed[*]}]" add_summary "$msg" } # ============================================================================= # Section 5 — Security baseline report (read-only) # ============================================================================= section_report() { log_head "5/5 Security baseline report" local host stamp report umask_old host="$(get_host)"; host="${host%%.*}" stamp="$(date '+%Y%m%d-%H%M%S')" # The report aggregates sensitive config (sudoers, `sshd -T`, open # listeners, empty-password account names, container ports), so it must # never land world-readable. Create everything under a private umask and # pin the final file to 0600 (defends a re-run that truncates a looser # pre-existing file too). umask_old="$(umask)"; umask 077 mkdir -p "$REPORT_DIR" 2>/dev/null report="$REPORT_DIR/copyfail-secreport-${host}-${stamp}.txt" # Create the report with O_CREAT|O_EXCL (noclobber, 'set -C'): open() with # O_EXCL fails on a symlink, so we never follow one a local user pre-planted # at this predictable path — which, as root in a shared --report-dir, would # truncate the link target. On a pre-existing path (e.g. a re-run) or an # unwritable dir, fall back to a private temp created atomically by mktemp — # never the old predictable /tmp path that was itself symlink-plantable. if ! ( set -C; : > "$report" ) 2>/dev/null; then report="$(mktemp "${TMPDIR:-/tmp}/copyfail-secreport-${host}-XXXXXX.txt" 2>/dev/null)" || { log_err "Cannot create report file."; add_summary "Report : FAILED" umask "$umask_old"; return } log_warn "Falling back to $report (report dir not writable or path exists)." fi chmod 600 "$report" 2>/dev/null umask "$umask_old" # Helpers that append to the report file. rh() { printf '\n========== %s ==========\n' "$1" >> "$report"; } rl() { printf '%s\n' "$*" >> "$report"; } rc() { # rc "label" cmd... local label="$1"; shift printf -- '--- %s ---\n' "$label" >> "$report" { "$@" 2>&1 || printf '(command failed or not available)\n'; } >> "$report" printf '\n' >> "$report" } rcp() { # privileged variant local label="$1"; shift printf -- '--- %s ---\n' "$label" >> "$report" { run_priv "$@" 2>&1 || printf '(command failed / insufficient privilege)\n'; } >> "$report" printf '\n' >> "$report" } { printf 'Copy Fail security baseline report\n' printf 'Generated : %s\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')" printf 'Host : %s\n' "$(get_host)" printf 'By : %s (privilege: %s)\n' "$(id -un)" \ "$( [ "$(id -u)" -eq 0 ] && echo root || { [ -n "$SUDO" ] && echo sudo || echo user; } )" printf 'NOTE: read-only baseline for later hardening. Pairs with check-copy-fail.sh.\n' } >> "$report" rh "SYSTEM" rc "OS" sh -c '. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}"' rc "Kernel" uname -a rc "Virtualization" sh -c 'command -v systemd-detect-virt >/dev/null && systemd-detect-virt || echo "n/a"' rc "Uptime" uptime rh "COPY FAIL (CVE-2026-31431)" if [ -r "$CHECKER" ]; then printf -- '--- check-copy-fail.sh ---\n' >> "$report" bash "$CHECKER" >> "$report" 2>&1 || true printf '\n' >> "$report" else rl "check-copy-fail.sh not found next to this script." fi rc "modprobe blacklist present" sh -c 'grep -RHs "algif_aead" /etc/modprobe.d /usr/lib/modprobe.d /run/modprobe.d 2>/dev/null || echo "none"' rc "algif_aead loaded now" sh -c 'lsmod 2>/dev/null | grep -w algif_aead || echo "not loaded"' rh "PRIVILEGE / ACCOUNTS" rc "UID 0 accounts" sh -c "awk -F: '\$3==0{print \$1}' /etc/passwd" rc "root group members" getent group root rc "wheel group members" getent group wheel rc "sudo group members" getent group sudo rc "sudoers entries" sh -c "grep -vE '^[[:space:]]*(#|\$)' /etc/sudoers /etc/sudoers.d/* 2>/dev/null || echo 'n/a'" rc "users with login shells" sh -c "awk -F: '\$7!~/(nologin|false|sync|halt|shutdown)\$/{print \$1\" -> \"\$7}' /etc/passwd" rcp "empty-password accounts" sh -c "awk -F: '(\$2==\"\"){print \$1\" HAS EMPTY PASSWORD\"}' /etc/shadow 2>/dev/null || echo 'n/a'" rh "SSH CONFIGURATION" if can_priv && run_priv sh -c 'command -v sshd >/dev/null 2>&1'; then rcp "sshd -T (effective)" sh -c "sshd -T 2>/dev/null | grep -Ei '^(permitrootlogin|passwordauthentication|pubkeyauthentication|permitemptypasswords|port|x11forwarding|challengeresponseauthentication|kbdinteractiveauthentication) ' || echo 'n/a'" else rc "sshd_config (grep)" sh -c "grep -Ei '^[[:space:]]*(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|PermitEmptyPasswords|Port|X11Forwarding)' /etc/ssh/sshd_config 2>/dev/null || echo 'n/a'" fi rh "NETWORK EXPOSURE" rcp "listening sockets" sh -c "ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null || echo 'n/a'" rc "non-loopback listeners" sh -c "ss -tuln 2>/dev/null | awk 'NR>1{a=\$5; sub(/:[^:]*\$/,\"\",a); gsub(/[][]/,\"\",a); if(a!~/^127\\./ && a!=\"::1\" && a!~/%lo\$/) print}' || echo 'n/a'" rh "FIREWALL" if command -v ufw >/dev/null 2>&1; then rcp "ufw status" ufw status verbose elif command -v firewall-cmd >/dev/null 2>&1; then rcp "firewalld state" firewall-cmd --state rcp "firewalld rules" firewall-cmd --list-all elif command -v nft >/dev/null 2>&1 && run_priv nft list ruleset >/dev/null 2>&1; then rcp "nftables ruleset" nft list ruleset else rcp "iptables rules" sh -c "iptables -S 2>/dev/null || echo 'n/a'" fi # "Is Docker running, and how many containers are live?" — plus the two # things that matter for a baseline: which ports those containers publish, # and whether any run --privileged (root-equivalent on the host). Routed # through run_priv so it works whether or not the invoking user is in the # 'docker' group. NOTE: per-user *rootless* podman containers are owned by # their user and are not visible to root, so this counts the system # (rootful) runtime only. rh "CONTAINERS" if ! command -v docker >/dev/null 2>&1 && ! command -v podman >/dev/null 2>&1; then rl "No container runtime found (neither docker nor podman is installed)." fi if command -v docker >/dev/null 2>&1; then rcp "docker daemon" sh -c ' s=$(systemctl is-active docker 2>/dev/null) if [ "$s" = active ] || [ "$s" = activating ]; then echo "$s" elif docker info >/dev/null 2>&1; then echo active else echo "${s:-inactive/unreachable}"; fi' rc "docker version" sh -c 'docker --version 2>/dev/null || echo n/a' rcp "running containers (count)" sh -c 'docker ps -q 2>/dev/null | wc -l' rcp "containers total (count)" sh -c 'docker ps -aq 2>/dev/null | wc -l' rcp "running containers" sh -c 'docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo n/a' rcp "privileged containers" sh -c ' out="" for c in $(docker ps -q 2>/dev/null); do [ "$(docker inspect -f "{{.HostConfig.Privileged}}" "$c" 2>/dev/null)" = true ] && out="$out $(docker inspect -f "{{.Name}}" "$c" 2>/dev/null | sed "s#^/##")" done [ -n "$out" ] && echo $out || echo none' fi if command -v podman >/dev/null 2>&1; then rc "podman version" sh -c 'podman --version 2>/dev/null || echo n/a' rcp "podman running (count)" sh -c 'podman ps -q 2>/dev/null | wc -l' rcp "podman running containers" sh -c 'podman ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo n/a' fi rh "UPDATES / MAINTENANCE" case "$PKG_MGR" in apt) rcp "pending upgrades (count)" sh -c "apt-get -s upgrade 2>/dev/null | grep -c '^Inst' || echo '?'" rc "unattended-upgrades" sh -c "dpkg -l unattended-upgrades 2>/dev/null | grep -q '^ii' && echo installed || echo 'not installed'" ;; dnf|yum) rcp "pending updates" sh -c "$PKG_MGR -q check-update 2>/dev/null | grep -cE '^[a-zA-Z0-9]' || echo '0 or unknown'" rc "dnf-automatic" sh -c "(rpm -q dnf-automatic 2>/dev/null || rpm -q yum-cron 2>/dev/null) || echo 'not installed'" ;; zypper) rcp "pending updates" sh -c "zypper --non-interactive list-updates 2>/dev/null | grep -E '^v \\|' | wc -l" rc "auto-update (os-update)" sh -c 's=$(systemctl is-enabled os-update.timer 2>/dev/null); echo "${s:-not configured}"' ;; pacman) # Read-only: checkupdates (pacman-contrib) uses a throwaway db; the # 'pacman -Qu' fallback reads the already-synced db. We never run # 'pacman -Sy' here — that would risk a partial-upgrade state. rcp "pending updates" sh -c "command -v checkupdates >/dev/null 2>&1 && checkupdates 2>/dev/null | wc -l || pacman -Qu 2>/dev/null | wc -l" rc "auto-update" sh -c "echo 'n/a (Arch updates are manual by design)'" ;; apk) rcp "pending updates" sh -c "apk version -l '<' 2>/dev/null | wc -l" ;; esac rc "time sync" sh -c "command -v timedatectl >/dev/null && timedatectl 2>/dev/null || echo 'n/a'" rc "fail2ban" sh -c "command -v fail2ban-client >/dev/null && echo present || echo 'not installed'" rh "SYSTEMD" rc "failed units" sh -c "systemctl --failed --no-legend 2>/dev/null || echo 'n/a'" rh "RECENT ACCESS" rc "last logins" sh -c "last -wn 10 2>/dev/null || echo 'n/a'" rcp "failed SSH (recent count)" sh -c "(journalctl -u ssh -u sshd --since '7 days ago' 2>/dev/null | grep -ci 'failed password') || (grep -ci 'failed password' /var/log/auth.log /var/log/secure 2>/dev/null) || echo '0 / unknown'" rh "KERNEL HARDENING SYSCTLS (baseline)" rc "sysctls" sh -c "for k in kernel.kptr_restrict kernel.dmesg_restrict kernel.randomize_va_space kernel.unprivileged_bpf_disabled kernel.yama.ptrace_scope net.ipv4.conf.all.rp_filter net.ipv4.ip_forward; do printf '%s = %s\n' \"\$k\" \"\$(sysctl -n \$k 2>/dev/null || echo n/a)\"; done" if [ "$DEEP_REPORT" -eq 1 ]; then rh "DEEP SCAN (slow)" # Whole-filesystem walks are I/O-heavy; run them at idle I/O + low CPU # priority so they don't add load to a busy production box. lowprio is a # no-op prefix when neither tool is present. local lowprio="" command -v ionice >/dev/null 2>&1 && lowprio="ionice -c3" command -v nice >/dev/null 2>&1 && lowprio="${lowprio:+$lowprio }nice -n19" rcp "SUID/SGID binaries" sh -c "$lowprio find / -xdev -type f \\( -perm -4000 -o -perm -2000 \\) 2>/dev/null" rcp "world-writable files" sh -c "$lowprio find / -xdev -type f -perm -0002 2>/dev/null | head -200" else rh "DEEP SCAN" rl "Skipped. Re-run with --deep for SUID/SGID + world-writable filesystem scans." fi fix_owner "$report" log_ok "Security report written to: $report" add_summary "Report : OK ($report)" } # ============================================================================= # Run sections # ============================================================================= [ "$DO_MITIGATION" -eq 1 ] && section_mitigation if [ "$DO_UPDATE" -eq 1 ]; then section_update else add_summary "Upgrade : SKIPPED (not requested; pass --update)" fi { [ "$DO_BANNER" -eq 1 ] || [ "$DO_ALIASES" -eq 1 ]; } && section_bashrc [ "$DO_TOOLS" -eq 1 ] && section_tools [ "$DO_REPORT" -eq 1 ] && section_report # ============================================================================= # Summary # ============================================================================= log_head "Summary" if [ -n "$VERDICT_BEFORE$VERDICT_AFTER" ] || [ "$DO_MITIGATION" -eq 1 ]; then VERDICT_AFTER="$(copyfail_verdict)" printf ' Copy Fail verdict : %s%s%s -> %s%s%s\n' \ "$C_YELLOW" "${VERDICT_BEFORE:-unknown}" "$C_RESET" \ "$C_GREEN" "${VERDICT_AFTER:-unknown}" "$C_RESET" fi if [ "${#SUMMARY[@]}" -gt 0 ]; then for line in "${SUMMARY[@]}"; do case "$line" in *FAILED*) printf ' %s%s%s\n' "$C_RED" "$line" "$C_RESET" ;; *SKIPPED*) printf ' %s%s%s\n' "$C_YELLOW" "$line" "$C_RESET" ;; *) printf ' %s%s%s\n' "$C_GREEN" "$line" "$C_RESET" ;; esac done fi if [ "$REBOOT_NEEDED" -eq 1 ]; then printf '\n%s[REBOOT]%s Reboot recommended: %s\n' "$C_YELLOW" "$C_RESET" "$REBOOT_REASON" if [ "$NEW_KERNEL" -eq 1 ]; then printf '%s[REBOOT]%s A new kernel was installed this run — verify /boot holds the new vmlinuz+initramfs and the bootloader was updated BEFORE rebooting.\n' "$C_YELLOW" "$C_RESET" fi if [ "$ASSUME_YES" -eq 0 ] && [ -t 0 ] && can_priv; then printf '%sReboot now? [y/N]%s ' "$C_BOLD" "$C_RESET" answer="" read -r answer || true case "$answer" in [yY]|[yY][eE][sS]) log_warn "Rebooting now ..." run_priv reboot || log_err "Reboot command failed — reboot manually with 'sudo reboot'." ;; *) log_info "Reboot deferred. Run 'sudo reboot' during a maintenance window." ;; esac else log_info "Reboot deferred (non-interactive or no privilege). Reboot when convenient." fi else printf '\n%s[OK]%s No reboot required from this run.\n' "$C_GREEN" "$C_RESET" fi printf '\nDone.\n' # Exit non-zero if a security-critical step failed, so fleet orchestration can # flag this host. Reached only when we did NOT reboot above (reboot replaces us). exit "$RUN_RC"