From 33db70e725fd84d4626e957bf8cad01341633de4 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Fri, 24 Oct 2025 07:56:26 +0300 Subject: [PATCH] feat(bin): x-ssh-audit --- local/bin/x-ssh-audit | 892 +++++++++++++++++++++++++++++++++++++++ local/bin/x-ssh-audit.md | 190 +++++++++ 2 files changed, 1082 insertions(+) create mode 100644 local/bin/x-ssh-audit create mode 100644 local/bin/x-ssh-audit.md diff --git a/local/bin/x-ssh-audit b/local/bin/x-ssh-audit new file mode 100644 index 0000000..df488a0 --- /dev/null +++ b/local/bin/x-ssh-audit @@ -0,0 +1,892 @@ +#!/bin/sh +# +# POSIX-Compliant SSH Security Auditing and Management Script +# Purpose: Audit and manage SSH security configurations across multiple hosts +# Compatible with: sh, dash, bash, ksh, zsh +# +# Author: Ismo Vuorinen +# Copyright: MIT +# Version: 2.0-POSIX +# Date: 2025-10-17 + +set -eu + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +SCRIPT_NAME=$(basename "$0") +# SCRIPT_DIR=$(dirname "$(readlink -f "$0" 2> /dev/null || pwd)") # Reserved for future use +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +AUDIT_BASE_DIR="./ssh-audit/${TIMESTAMP}" +BACKUP_DIR="${AUDIT_BASE_DIR}/backup" +LOG_FILE="${AUDIT_BASE_DIR}/log.log" +REPORT_FILE="${AUDIT_BASE_DIR}/report.csv" +TEMP_DIR="${AUDIT_BASE_DIR}/tmp" + +# SSH connection parameters +SSH_TIMEOUT=10 +SSH_RETRIES=3 + +# Fallback usernames for SSH connections (in order of priority) +FALLBACK_USERS="root ubuntu ivuorinen" + +# Common default SSH key locations (checked in order) +DEFAULT_SSH_KEYS=" + ~/.ssh/id_ed25519 + ~/.ssh/id_rsa + ~/.ssh/id_ecdsa + ~/.ssh/id_dsa +" + +# Color codes (using tput for POSIX compliance) +if [ -t 1 ]; then + RED=$(tput setaf 1 2> /dev/null || echo '') + GREEN=$(tput setaf 2 2> /dev/null || echo '') + YELLOW=$(tput setaf 3 2> /dev/null || echo '') + BLUE=$(tput setaf 4 2> /dev/null || echo '') + RESET=$(tput sgr0 2> /dev/null || echo '') + BOLD=$(tput bold 2> /dev/null || echo '') +else + RED='' GREEN='' YELLOW='' BLUE='' RESET='' BOLD='' +fi + +# Global state files (replacing bash associative arrays) +HOST_STATUS_FILE="${TEMP_DIR}/host-status" +HOST_RESULTS_FILE="${TEMP_DIR}/host-results" +HOSTS_LIST_FILE="${TEMP_DIR}/hosts-list" +FAILED_HOSTS_FILE="${TEMP_DIR}/failed-hosts" + +# ============================================================================ +# HELPER FUNCTIONS FOR STATE MANAGEMENT +# ============================================================================ + +# Initialize state files +init_state_files() +{ + : > "$HOST_STATUS_FILE" + : > "$HOST_RESULTS_FILE" + : > "$HOSTS_LIST_FILE" + : > "$FAILED_HOSTS_FILE" +} + +# Detect available default SSH keys +detect_available_ssh_keys() +{ + available_keys="" + + for key in $DEFAULT_SSH_KEYS; do + # Expand tilde to home directory + expanded_key=$(eval echo "$key") + + if [ -f "$expanded_key" ] && [ -r "$expanded_key" ]; then + available_keys="${available_keys}${available_keys:+ }${expanded_key}" + fi + done + + printf '%s' "$available_keys" +} + +# Trim whitespace from string (POSIX compatible) +trim_whitespace() +{ + printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + +# Extract SSH config value from sshd -T output +get_sshd_value() +{ + config_file="$1" + key="$2" + grep -i "^${key}" "$config_file" | awk '{print $2}' +} + +# Check security setting and log if it matches bad value +check_security_issue() +{ + host="$1" + # $2 is setting_name (unused but kept for API clarity) + actual_value="$3" + bad_value="$4" + level="${5:-WARNING}" + message="$6" + + if [ "$actual_value" = "$bad_value" ]; then + log_message "$level" "$host: $message" + return 0 # Issue found + fi + return 1 # No issue +} + +# Set a key-value pair in state file +set_state() +{ + state_file="$1" + key="$2" + value="$3" + + # Remove existing entry if present + if [ -f "$state_file" ]; then + grep -v "^${key}=" "$state_file" > "${state_file}.tmp" 2> /dev/null || true + mv "${state_file}.tmp" "$state_file" + fi + + # Add new entry + printf '%s=%s\n' "$key" "$value" >> "$state_file" +} + +# Get a value from state file +get_state() +{ + state_file="$1" + key="$2" + default="${3:-unknown}" + + if [ -f "$state_file" ]; then + value=$(grep "^${key}=" "$state_file" 2> /dev/null | cut -d= -f2- | head -n1) + printf '%s' "${value:-$default}" + else + printf '%s' "$default" + fi +} + +# ============================================================================ +# ERROR HANDLING AND CLEANUP +# ============================================================================ + +cleanup() +{ + exit_code=$? + printf '\n%sCleaning up...%s\n' "$YELLOW" "$RESET" + + # Clean up temporary directory (keep logs, reports, and backups) + if [ -d "$TEMP_DIR" ]; then + rm -rf "$TEMP_DIR" + fi + + if [ $exit_code -ne 0 ]; then + printf '%s[ERROR] Script failed with exit code: %d%s\n' "$RED" "$exit_code" "$RESET" >&2 + fi + + exit $exit_code +} + +trap cleanup EXIT INT TERM + +# ============================================================================ +# LOGGING FUNCTIONS +# ============================================================================ + +setup_logging() +{ + # Create all necessary directories + mkdir -p "$BACKUP_DIR" "$TEMP_DIR" 2> /dev/null || { + printf '%s[ERROR] Failed to create audit directories%s\n' "$RED" "$RESET" >&2 + exit 1 + } + + { + echo "==========================================" + echo "SSH Security Audit Log" + echo "Started: $(date)" + echo "Script: $SCRIPT_NAME" + echo "User: $(whoami)" + echo "Working Directory: $(pwd)" + echo "Output Directory: $AUDIT_BASE_DIR" + echo "==========================================" + } >> "$LOG_FILE" +} + +log_message() +{ + level="$1" + message="$2" + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + printf '[%s] [%s] %s\n' "$timestamp" "$level" "$message" >> "$LOG_FILE" + + case "$level" in + ERROR) + printf '%s[ERROR] %s%s\n' "$RED" "$message" "$RESET" >&2 + ;; + WARNING) + printf '%s[WARNING] %s%s\n' "$YELLOW" "$message" "$RESET" >&2 + ;; + INFO) + printf '%s[INFO] %s%s\n' "$BLUE" "$message" "$RESET" + ;; + SUCCESS) + printf '%s[SUCCESS] %s%s\n' "$GREEN" "$message" "$RESET" + ;; + esac +} + +# ============================================================================ +# INPUT VALIDATION +# ============================================================================ + +validate_hostname() +{ + hostname="$1" + + # Basic hostname validation + case "$hostname" in + *[!a-zA-Z0-9.-]*) + log_message "ERROR" "Invalid hostname format: $hostname" + return 1 + ;; + .*) + log_message "ERROR" "Invalid hostname format: $hostname" + return 1 + ;; + esac + + return 0 +} + +validate_username() +{ + username="$1" + + # Basic username validation + case "$username" in + *[!a-zA-Z0-9_-]*) + log_message "ERROR" "Invalid username format: $username" + return 1 + ;; + [0-9]*) + log_message "ERROR" "Invalid username format: $username" + return 1 + ;; + esac + + return 0 +} + +parse_host_list() +{ + input_file="$1" + + if [ ! -f "$input_file" ]; then + log_message "ERROR" "Input file not found: $input_file" + exit 1 + fi + + while IFS=':' read -r host username ssh_key; do + # Skip empty lines and comments + case "$host" in + '' | '#'*) continue ;; + esac + + # Trim whitespace (POSIX compatible) + host=$(trim_whitespace "$host") + username=$(trim_whitespace "$username") + ssh_key=$(trim_whitespace "$ssh_key") + + if validate_hostname "$host" && validate_username "$username"; then + # Store with optional SSH key (empty if not provided) + printf '%s:%s:%s\n' "$host" "$username" "$ssh_key" >> "$HOSTS_LIST_FILE" + if [ -n "$ssh_key" ]; then + log_message "INFO" "Added host: $host with user: $username and SSH key: $ssh_key" + else + log_message "INFO" "Added host: $host with user: $username" + fi + else + log_message "WARNING" "Skipping invalid entry: ${host}:${username}" + fi + done < "$input_file" + + if [ ! -s "$HOSTS_LIST_FILE" ]; then + log_message "ERROR" "No valid hosts found in input file" + exit 1 + fi + + host_count=$(wc -l < "$HOSTS_LIST_FILE") + log_message "INFO" "Loaded $host_count hosts for auditing" +} + +# ============================================================================ +# SSH CONNECTION FUNCTIONS +# ============================================================================ + +ssh_with_retry() +{ + host="$1" + username="$2" + command="$3" + ssh_key="${4:-}" + max_retries="${5:-$SSH_RETRIES}" + + # Build list of keys to try + keys_to_try="" + + # Priority 1: Specific key if provided + if [ -n "$ssh_key" ] && [ -f "$ssh_key" ]; then + keys_to_try="$ssh_key" + log_message "INFO" "Will try specific SSH key: $ssh_key" + fi + + # Priority 2: Detect and add available default keys + available_default_keys=$(detect_available_ssh_keys) + if [ -n "$available_default_keys" ]; then + keys_to_try="${keys_to_try}${keys_to_try:+ }${available_default_keys}" + fi + + # Priority 3: Try without explicit key (agent/default) + keys_to_try="${keys_to_try}${keys_to_try:+ }NO_KEY" + + attempt=1 + while [ "$attempt" -le "$max_retries" ]; do + log_message "INFO" "Connecting to $host as $username (attempt $attempt/$max_retries)" + + # Try each key in sequence + for try_key in $keys_to_try; do + ssh_opts="-o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes -o StrictHostKeyChecking=yes" + + if [ "$try_key" != "NO_KEY" ]; then + ssh_opts="$ssh_opts -i $try_key" + log_message "INFO" "Trying SSH key: $try_key" + else + log_message "INFO" "Trying SSH agent/default authentication" + fi + + # shellcheck disable=SC2086 + if ssh $ssh_opts "${username}@${host}" "$command" 2> /dev/null; then + if [ "$try_key" != "NO_KEY" ]; then + log_message "SUCCESS" "Connected using SSH key: $try_key" + else + log_message "SUCCESS" "Connected using SSH agent/default authentication" + fi + return 0 + fi + done + + status=$? + log_message "WARNING" "SSH connection failed to $host with all authentication methods (status: $status)" + + if [ "$attempt" -lt "$max_retries" ]; then + sleep $((attempt * 2)) + fi + attempt=$((attempt + 1)) + done + + return 1 +} + +test_ssh_connectivity() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + + if ssh_with_retry "$host" "$username" "echo 'SSH_OK'" "$ssh_key" | grep -q "SSH_OK"; then + log_message "SUCCESS" "SSH connectivity verified for ${username}@${host}" + return 0 + else + log_message "ERROR" "Cannot establish SSH connection to ${username}@${host}" + return 1 + fi +} + +# ============================================================================ +# SSH SECURITY AUDIT FUNCTIONS +# ============================================================================ + +check_sshd_config() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + temp_file="${TEMP_DIR}/sshd-config-${host}" + + log_message "INFO" "Checking SSH configuration on $host" + + # Get sshd configuration + if ! ssh_with_retry "$host" "$username" "sudo sshd -T 2>/dev/null || sshd -T 2>/dev/null" "$ssh_key" > "$temp_file"; then + log_message "ERROR" "Failed to retrieve SSH configuration from $host" + return 1 + fi + + # Parse configuration (POSIX compatible) + password_auth=$(get_sshd_value "$temp_file" "passwordauthentication") + pubkey_auth=$(get_sshd_value "$temp_file" "pubkeyauthentication") + permit_root=$(get_sshd_value "$temp_file" "permitrootlogin") + protocol=$(get_sshd_value "$temp_file" "protocol") + x11_forwarding=$(get_sshd_value "$temp_file" "x11forwarding") + permit_empty=$(get_sshd_value "$temp_file" "permitemptypasswords") + + # Store results + set_state "$HOST_RESULTS_FILE" "${host}_password_auth" "${password_auth:-unknown}" + set_state "$HOST_RESULTS_FILE" "${host}_pubkey_auth" "${pubkey_auth:-unknown}" + set_state "$HOST_RESULTS_FILE" "${host}_permit_root" "${permit_root:-unknown}" + set_state "$HOST_RESULTS_FILE" "${host}_protocol" "${protocol:-unknown}" + set_state "$HOST_RESULTS_FILE" "${host}_x11_forwarding" "${x11_forwarding:-unknown}" + set_state "$HOST_RESULTS_FILE" "${host}_permit_empty_passwords" "${permit_empty:-unknown}" + + # Security assessment + security_issues=0 + + check_security_issue "$host" "password_auth" "$password_auth" "yes" \ + "WARNING" "Password authentication is enabled" && security_issues=$((security_issues + 1)) + + # Special handling for permit_root (check if NOT "no") + if [ "$permit_root" != "no" ]; then + log_message "WARNING" "$host: Root login is not disabled (current: $permit_root)" + security_issues=$((security_issues + 1)) + fi + + check_security_issue "$host" "permit_empty" "$permit_empty" "yes" \ + "ERROR" "Empty passwords are permitted!" && security_issues=$((security_issues + 1)) + + check_security_issue "$host" "x11_forwarding" "$x11_forwarding" "yes" \ + "WARNING" "X11 forwarding is enabled" && security_issues=$((security_issues + 1)) + + set_state "$HOST_RESULTS_FILE" "${host}_security_issues" "$security_issues" + + rm -f "$temp_file" + return 0 +} + +# ============================================================================ +# AUTOMATED UPDATES DETECTION +# ============================================================================ + +check_automated_updates() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + + log_message "INFO" "Checking automated updates on $host" + + # Detect distribution + distro=$(ssh_with_retry "$host" "$username" " + if [ -f /etc/os-release ]; then + . /etc/os-release + printf '%s' \"\$ID\" + elif [ -f /etc/debian_version ]; then + printf 'debian' + elif [ -f /etc/redhat-release ]; then + printf 'rhel' + else + printf 'unknown' + fi + " "$ssh_key") + + auto_updates="disabled" + + case "$distro" in + ubuntu | debian) + unattended_status=$(ssh_with_retry "$host" "$username" " + if dpkg -l unattended-upgrades >/dev/null 2>&1; then + if [ -f /etc/apt/apt.conf.d/20auto-upgrades ]; then + if grep '^APT::Periodic::Unattended-Upgrade' /etc/apt/apt.conf.d/20auto-upgrades | grep -q '\"1\"'; then + printf 'enabled' + else + printf 'disabled' + fi + else + printf 'not-configured' + fi + else + printf 'not-installed' + fi + " "$ssh_key") + auto_updates="$unattended_status" + ;; + + rhel | centos | rocky | almalinux | fedora) + dnf_status=$(ssh_with_retry "$host" "$username" " + if command -v dnf >/dev/null 2>&1 && rpm -q dnf-automatic >/dev/null 2>&1; then + if systemctl is-enabled dnf-automatic.timer >/dev/null 2>&1; then + printf 'enabled' + else + printf 'disabled' + fi + elif rpm -q yum-cron >/dev/null 2>&1; then + if systemctl is-enabled yum-cron >/dev/null 2>&1; then + printf 'enabled' + else + printf 'disabled' + fi + else + printf 'not-installed' + fi + " "$ssh_key") + auto_updates="$dnf_status" + ;; + + *) + auto_updates="unknown" + ;; + esac + + set_state "$HOST_RESULTS_FILE" "${host}_auto_updates" "$auto_updates" + set_state "$HOST_RESULTS_FILE" "${host}_distro" "$distro" + + log_message "INFO" "$host: Automated updates status: $auto_updates (distro: $distro)" + return 0 +} + +# ============================================================================ +# PENDING REBOOT DETECTION +# ============================================================================ + +check_pending_reboot() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + + log_message "INFO" "Checking pending reboot status on $host" + + # Multiple detection methods (POSIX compliant) + check_result=$(ssh_with_retry "$host" "$username" ' + REBOOT_NEEDED=0 + REASONS="" + + # Method 1: Check /var/run/reboot-required + if [ -f /var/run/reboot-required ]; then + REBOOT_NEEDED=1 + REASONS="${REASONS:+$REASONS,}reboot-required" + fi + + # Method 2: Check kernel version mismatch + if command -v rpm >/dev/null 2>&1; then + CURRENT_KERNEL=$(uname -r) + LATEST_KERNEL=$(rpm -q kernel --last 2>/dev/null | head -1 | sed "s/^kernel-//;s/ .*//") + if [ -n "$LATEST_KERNEL" ] && [ "$CURRENT_KERNEL" != "$LATEST_KERNEL" ]; then + REBOOT_NEEDED=1 + REASONS="${REASONS:+$REASONS,}kernel-mismatch" + fi + fi + + # Method 3: Check needs-restarting + if command -v needs-restarting >/dev/null 2>&1; then + if ! needs-restarting -r >/dev/null 2>&1; then + REBOOT_NEEDED=1 + REASONS="${REASONS:+$REASONS,}needs-restarting" + fi + fi + + # Method 4: Check deleted libraries + DELETED_LIBS=$(lsof 2>/dev/null | grep -c "DEL.*deleted" || echo 0) + if [ "$DELETED_LIBS" -gt 10 ]; then + REBOOT_NEEDED=1 + REASONS="${REASONS:+$REASONS,}deleted-libraries" + fi + + if [ $REBOOT_NEEDED -eq 1 ]; then + printf "yes|%s" "${REASONS:-unknown}" + else + printf "no|none" + fi + ' "$ssh_key") + + reboot_required=$(printf '%s' "$check_result" | cut -d'|' -f1) + reboot_reason=$(printf '%s' "$check_result" | cut -d'|' -f2) + + set_state "$HOST_RESULTS_FILE" "${host}_reboot_required" "$reboot_required" + set_state "$HOST_RESULTS_FILE" "${host}_reboot_reason" "$reboot_reason" + + if [ "$reboot_required" = "yes" ]; then + log_message "WARNING" "$host: Reboot required (reason: $reboot_reason)" + else + log_message "INFO" "$host: No reboot required" + fi + + return 0 +} + +# ============================================================================ +# REMEDIATION FUNCTIONS +# ============================================================================ + +backup_sshd_config() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + + log_message "INFO" "Creating backup of sshd_config on $host" + + ssh_with_retry "$host" "$username" " + sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup.${TIMESTAMP} + printf 'Backup created: /etc/ssh/sshd_config.backup.%s\n' '${TIMESTAMP}' + " "$ssh_key" +} + +disable_password_auth() +{ + host="$1" + username="$2" + ssh_key="${3:-}" + + log_message "INFO" "Disabling password authentication on $host" + + # Create backup first + backup_sshd_config "$host" "$username" "$ssh_key" + + # Create hardening configuration + config_content='# SSH Hardening - Automated Security Audit +# Generated: '"$(date)"' +# Disable password authentication +PasswordAuthentication no +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +UsePAM no + +# Ensure key-based authentication +PubkeyAuthentication yes +AuthenticationMethods publickey + +# Additional security settings +PermitRootLogin no +PermitEmptyPasswords no +MaxAuthTries 3 +MaxSessions 2 +ClientAliveInterval 300 +ClientAliveCountMax 2 +' + + # Apply configuration + if ssh_with_retry "$host" "$username" " + printf '%s\n' '$config_content' | sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null + sudo sshd -t && sudo systemctl reload sshd + " "$ssh_key"; then + log_message "SUCCESS" "Password authentication disabled on $host" + set_state "$HOST_RESULTS_FILE" "${host}_remediation" "success" + return 0 + else + log_message "ERROR" "Failed to disable password authentication on $host" + set_state "$HOST_RESULTS_FILE" "${host}_remediation" "failed" + return 1 + fi +} + +# ============================================================================ +# REPORTING FUNCTIONS +# ============================================================================ + +generate_csv_report() +{ + report_file="$1" + + # Header + printf 'Timestamp,Hostname,Username,SSH Status,Password Auth,Key Auth,Root Login,Auto Updates,Reboot Required,Security Issues,Remediation\n' > "$report_file" + + # Data rows + while IFS=':' read -r host username ssh_key; do + { + printf '%s,' "$(date '+%Y-%m-%d %H:%M:%S')" + printf '%s,' "$host" + printf '%s,' "$username" + printf '%s,' "$(get_state "$HOST_STATUS_FILE" "$host" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_password_auth" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_pubkey_auth" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_permit_root" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_auto_updates" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_reboot_required" "unknown")" + printf '%s,' "$(get_state "$HOST_RESULTS_FILE" "${host}_security_issues" "0")" + printf '%s\n' "$(get_state "$HOST_RESULTS_FILE" "${host}_remediation" "none")" + } >> "$report_file" + done < "$HOSTS_LIST_FILE" +} + +display_summary() +{ + printf '\n' + printf '%s==========================================\n' "$BOLD" + printf ' SSH Security Audit Summary\n' + printf '==========================================%s\n' "$RESET" + printf '\n' + + total_hosts=$(wc -l < "$HOSTS_LIST_FILE") + successful_audits=0 + security_issues_found=0 + reboots_required=0 + password_auth_enabled=0 + + while IFS=':' read -r host username ssh_key; do + status=$(get_state "$HOST_STATUS_FILE" "$host" "unknown") + [ "$status" = "audited" ] && successful_audits=$((successful_audits + 1)) + + password_auth=$(get_state "$HOST_RESULTS_FILE" "${host}_password_auth" "unknown") + [ "$password_auth" = "yes" ] && password_auth_enabled=$((password_auth_enabled + 1)) + + reboot_req=$(get_state "$HOST_RESULTS_FILE" "${host}_reboot_required" "unknown") + [ "$reboot_req" = "yes" ] && reboots_required=$((reboots_required + 1)) + + issues=$(get_state "$HOST_RESULTS_FILE" "${host}_security_issues" "0") + [ "$issues" -gt 0 ] && security_issues_found=$((security_issues_found + 1)) + done < "$HOSTS_LIST_FILE" + + printf '%sTotal Hosts:%s %d\n' "$BLUE" "$RESET" "$total_hosts" + printf '%sSuccessfully Audited:%s %d\n' "$GREEN" "$RESET" "$successful_audits" + printf '%sPassword Auth Enabled:%s %d\n' "$YELLOW" "$RESET" "$password_auth_enabled" + printf '%sSecurity Issues Found:%s %d\n' "$YELLOW" "$RESET" "$security_issues_found" + printf '%sReboots Required:%s %d\n' "$YELLOW" "$RESET" "$reboots_required" + + if [ -s "$FAILED_HOSTS_FILE" ]; then + printf '\n%sFailed Hosts:%s\n' "$RED" "$RESET" + while read -r failed; do + printf ' - %s\n' "$failed" + done < "$FAILED_HOSTS_FILE" + fi + + printf '\n' + printf '%sReport saved to:%s %s\n' "$BOLD" "$RESET" "$REPORT_FILE" + printf '%sLog file:%s %s\n' "$BOLD" "$RESET" "$LOG_FILE" +} + +# ============================================================================ +# MAIN AUDIT FUNCTION +# ============================================================================ + +audit_host() +{ + host_entry="$1" + host=$(printf '%s' "$host_entry" | cut -d':' -f1) + primary_user=$(printf '%s' "$host_entry" | cut -d':' -f2) + ssh_key=$(printf '%s' "$host_entry" | cut -d':' -f3) + + log_message "INFO" "Starting audit of $host" + + # Try multiple usernames (primary user first, then fallbacks) + connected=0 + connected_user="" + + for username in "$primary_user" $FALLBACK_USERS; do + if test_ssh_connectivity "$host" "$username" "$ssh_key"; then + connected=1 + connected_user="$username" + log_message "SUCCESS" "Connected to $host as $username" + break + fi + done + + if [ $connected -eq 0 ]; then + log_message "ERROR" "Could not connect to $host with any username" + set_state "$HOST_STATUS_FILE" "$host" "connection_failed" + printf '%s\n' "$host" >> "$FAILED_HOSTS_FILE" + return 1 + fi + + # Perform audit checks + check_sshd_config "$host" "$connected_user" "$ssh_key" + check_automated_updates "$host" "$connected_user" "$ssh_key" + check_pending_reboot "$host" "$connected_user" "$ssh_key" + + set_state "$HOST_STATUS_FILE" "$host" "audited" + set_state "$HOST_RESULTS_FILE" "${host}_connected_user" "$connected_user" + + log_message "SUCCESS" "Completed audit of $host" + return 0 +} + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ + +main() +{ + input_file="${1:-}" + auto_remediate="${2:-no}" + + # Validate arguments + if [ -z "$input_file" ]; then + printf 'Usage: %s [auto-remediate:yes|no]\n' "$SCRIPT_NAME" + printf '\n' + printf 'Host list file format (one per line):\n' + printf ' hostname1:username1\n' + printf ' hostname2:username2\n' + printf ' hostname3:username3:/path/to/ssh_key # Optional SSH key\n' + printf '\n' + printf 'Format: hostname:username[:ssh_key]\n' + printf ' - hostname: The host to connect to\n' + printf ' - username: SSH username\n' + printf ' - ssh_key: (Optional) Path to SSH private key file\n' + printf '\n' + printf 'SSH Key Authentication Priority:\n' + printf ' 1. Specific key (if provided in host file)\n' + printf ' 2. Auto-detected default keys (~/.ssh/id_ed25519, id_rsa, etc.)\n' + printf ' 3. SSH agent or system default authentication\n' + printf '\n' + printf 'Example:\n' + printf ' %s hosts.txt\n' "$SCRIPT_NAME" + printf ' %s hosts.txt yes # Auto-remediate security issues\n' "$SCRIPT_NAME" + exit 1 + fi + + # Setup + setup_logging + init_state_files + + log_message "INFO" "Starting SSH Security Audit" + + # Detect available SSH keys and log + available_keys=$(detect_available_ssh_keys) + if [ -n "$available_keys" ]; then + key_count=$(printf '%s\n' "$available_keys" | wc -w | tr -d ' ') + log_message "INFO" "Detected $key_count default SSH key(s) for authentication" + for key in $available_keys; do + log_message "INFO" " - $key" + done + else + log_message "INFO" "No default SSH keys detected, will use SSH agent/default authentication" + fi + + # Parse input file + parse_host_list "$input_file" + + # Audit each host + while IFS=':' read -r host username ssh_key; do + audit_host "${host}:${username}:${ssh_key}" + done < "$HOSTS_LIST_FILE" + + # Generate report + generate_csv_report "$REPORT_FILE" + + # Display summary + display_summary + + # Offer remediation + if [ "$auto_remediate" != "yes" ]; then + printf '\n%sWould you like to automatically remediate security issues? (y/N)%s ' "$YELLOW" "$RESET" + read -r response + + case "$response" in + [Yy]*) auto_remediate="yes" ;; + esac + fi + + if [ "$auto_remediate" = "yes" ]; then + printf '\n%sStarting automatic remediation...%s\n' "$BOLD" "$RESET" + + while IFS=':' read -r host username ssh_key; do + password_auth=$(get_state "$HOST_RESULTS_FILE" "${host}_password_auth" "unknown") + pubkey_auth=$(get_state "$HOST_RESULTS_FILE" "${host}_pubkey_auth" "unknown") + + if [ "$password_auth" = "yes" ] && [ "$pubkey_auth" = "yes" ]; then + printf '\n%sDisabling password authentication on %s...%s\n' "$YELLOW" "$host" "$RESET" + + connected_user=$(get_state "$HOST_RESULTS_FILE" "${host}_connected_user" "root") + if disable_password_auth "$host" "$connected_user" "$ssh_key"; then + printf '%s✓ Successfully disabled password authentication on %s%s\n' "$GREEN" "$host" "$RESET" + else + printf '%s✗ Failed to disable password authentication on %s%s\n' "$RED" "$host" "$RESET" + fi + fi + done < "$HOSTS_LIST_FILE" + + # Regenerate report after remediation + remediated_report="${REPORT_FILE%.csv}-remediated.csv" + generate_csv_report "$remediated_report" + printf '\n%sRemediation complete. Updated report: %s%s\n' "$BOLD" "$remediated_report" "$RESET" + fi + + log_message "INFO" "SSH Security Audit completed" +} + +# Run main function +main "$@" diff --git a/local/bin/x-ssh-audit.md b/local/bin/x-ssh-audit.md new file mode 100644 index 0000000..372b1d3 --- /dev/null +++ b/local/bin/x-ssh-audit.md @@ -0,0 +1,190 @@ +# x-ssh-audit + +POSIX-compliant SSH security auditing and management script for analyzing and +hardening SSH configurations across multiple hosts. + +## Features + +- **Security Auditing**: Analyze SSH configurations for security issues +- **Multi-Host Support**: Audit multiple servers from a single host list +- **Smart Authentication**: Automatic SSH key detection with intelligent fallback +- **Comprehensive Checks**: Password auth, root login, key auth, empty passwords, + X11 forwarding +- **System Analysis**: Automated updates detection, pending reboot status +- **Auto-Remediation**: Optional automatic security hardening +- **Detailed Reporting**: CSV reports and comprehensive logs +- **POSIX Compliant**: Works with sh, dash, bash, ksh, zsh + +## Usage + +```bash +x-ssh-audit [auto-remediate:yes|no] +``` + +### Host List File Format + +```text +hostname:username[:ssh_key] +``` + +- `hostname` – The host to connect to (FQDN or IP address) +- `username` – SSH username for authentication +- `ssh_key` – (Optional) Path to SSH private key file + +### SSH Key Authentication Priority + +The script automatically tries authentication methods in this order: + +1. **Specific key** (if provided in host file) +2. **Auto-detected default keys** (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`, + `id_dsa`) +3. **SSH agent or system default authentication** + +This means you can mix hosts with and without specific keys, and the script will +intelligently try all available authentication methods. + +## Host List Examples + +```bash +# Simple format without specific SSH keys +server1.example.com:admin +192.168.1.10:root + +# With specific SSH keys +production.example.com:deploy:~/.ssh/production_key +staging.example.com:staging-user:~/.ssh/staging_key +database.example.com:dbadmin:/home/user/.ssh/db_server_key + +# Cloud instances with specific keys +aws-instance.compute.amazonaws.com:ec2-user:~/.ssh/aws-keypair.pem +gcp-instance.compute.google.com:ubuntu:~/.ssh/gcp-instance-key + +# Mixed authentication (specific keys + fallback) +cluster-node-01.example.com:cluster-admin:~/.ssh/cluster_key +cluster-node-02.example.com:cluster-admin +cluster-node-03.example.com:cluster-admin +``` + +## Usage Examples + +```bash +# Basic audit +x-ssh-audit hosts.txt + +# Audit with automatic remediation +x-ssh-audit hosts.txt yes + +# Review results +cat ./ssh-audit/20251017_143022/report.csv +tail ./ssh-audit/20251017_143022/log.log +``` + +## Output Structure + +All output is organized in a timestamped directory: + +```text +./ssh-audit/ +└── 20251017_143022/ + ├── backup/ # SSH config backups from remote hosts + ├── tmp/ # Temporary state files (auto-cleaned) + ├── log.log # Detailed audit log with timestamps + └── report.csv # Summary report with all findings +``` + +## Security Checks + +- **Password Authentication**: Warns if password auth is enabled +- **Root Login**: Warns if root login is not disabled +- **Empty Passwords**: Error if empty passwords are permitted +- **X11 Forwarding**: Warns if X11 forwarding is enabled +- **Public Key Authentication**: Verifies key-based auth is available +- **SSH Protocol**: Checks protocol version +- **Automated Updates**: Detects if automatic updates are configured +- **Pending Reboots**: Checks if system requires reboot + +## Auto-Remediation + +When enabled, the script will: + +1. Create backups of SSH configurations +2. Disable password authentication +3. Ensure key-based authentication is required +4. Disable root login +5. Set conservative SSH connection limits +6. Reload SSH daemon with new configuration +7. Generate updated report with remediation status + +## Configuration + +Fallback usernames (tried if primary user fails): + +```bash +FALLBACK_USERS="root ubuntu ivuorinen" +``` + +Default SSH keys (automatically detected): + +```bash +~/.ssh/id_ed25519 +~/.ssh/id_rsa +~/.ssh/id_ecdsa +~/.ssh/id_dsa +``` + +SSH connection parameters: + +```bash +SSH_TIMEOUT=10 +SSH_RETRIES=3 +``` + +## Requirements + +- POSIX-compliant shell (sh, dash, bash, ksh, zsh) +- SSH client with key-based authentication +- `sudo` access on remote hosts for configuration changes +- Standard Unix utilities: `cut`, `grep`, `sed`, `awk`, `wc` + +## Exit Codes + +- `0` – Audit completed successfully +- `1` – Error occurred (check log file for details) + +## CSV Report Columns + +- **Timestamp**: When the host was audited +- **Hostname**: The target host +- **Username**: Connected username +- **SSH Status**: Connection status (audited, connection_failed) +- **Password Auth**: Password authentication status (yes/no) +- **Key Auth**: Public key authentication status (yes/no) +- **Root Login**: Root login permission status +- **Auto Updates**: Automated updates status +- **Reboot Required**: Pending reboot status (yes/no) +- **Security Issues**: Number of security issues found +- **Remediation**: Remediation status (none, success, failed) + +## Supported Distributions + +- **Debian/Ubuntu**: unattended-upgrades detection +- **RHEL/CentOS/Rocky/AlmaLinux/Fedora**: dnf-automatic and yum-cron detection +- **Other**: Basic SSH security checks + +## Tips + +1. **Test First**: Run without auto-remediation first to review findings +2. **Backup Keys**: Ensure you have backup SSH keys before hardening +3. **Staged Rollout**: Test on non-critical hosts first +4. **Review Logs**: Check log files for detailed information +5. **Preserve Access**: Script ensures key-based auth works before disabling + passwords + +## Version + +Version: 2.0-POSIX +Date: 2025-10-17 +License: MIT +Author: Ismo Vuorinen + +