mirror of
https://github.com/ivuorinen/dotfiles.git
synced 2026-01-26 11:14:08 +00:00
feat(bin): x-ssh-audit
This commit is contained in:
892
local/bin/x-ssh-audit
Normal file
892
local/bin/x-ssh-audit
Normal 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
190
local/bin/x-ssh-audit.md
Normal 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 : -->
|
||||
Reference in New Issue
Block a user