feat(bin): x-ssh-audit

This commit is contained in:
2025-10-24 07:56:26 +03:00
parent 56b2fdabf1
commit 33db70e725
2 changed files with 1082 additions and 0 deletions

892
local/bin/x-ssh-audit Normal file
View File

@@ -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 <https://github.com/ivuorinen>
# 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 <host_list_file> [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 "$@"

190
local/bin/x-ssh-audit.md Normal file
View File

@@ -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 <host_list_file> [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 <https://github.com/ivuorinen>
<!-- vim: set ft=markdown spell spelllang=en_us cc=80 : -->