#!/usr/bin/env bash ###################################################################################### #### ssh-key-auditor.sh — Audit SSH keys across user accounts for compliance #### #### Scans authorized_keys, flags weak keys, detects duplicates, checks perms. #### #### Requires: bash 4+, coreutils, ssh-keygen #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### sudo ./ssh-key-auditor.sh #### #### sudo ./ssh-key-auditor.sh --min-rsa-bits 4096 --format tap #### #### #### #### See --help for all options. #### ###################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── SCAN_PATH="${SCAN_PATH:-/home}" SCAN_SYSTEM_USERS="${SCAN_SYSTEM_USERS:-false}" MIN_RSA_BITS="${MIN_RSA_BITS:-2048}" SKIP_DUPLICATES="${SKIP_DUPLICATES:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit JUNIT_FILE="${JUNIT_FILE:-ssh-audit-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() START_TIME="" # Key tracking for duplicate detection and summary declare -A KEY_FINGERPRINTS # fingerprint -> "user1 user2 ..." declare -A KEY_TYPE_COUNTS # type -> count TOTAL_KEYS=0 USERS_WITH_KEYS=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_skip() { local name="$1" local reason="${2:-}" ((SKIP++)) || true ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}" fi } # ── JUnit XML Writer ────────────────────────────────────────────────── write_junit() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) cat > "$JUNIT_FILE" < JUNIT_EOF for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # XML-escape name="${name//&/&}" name="${name///>}" name="${name//\"/"}" detail="${detail//&/&}" detail="${detail///>}" detail="${detail//\"/"}" case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; SKIP) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # AUDIT FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # ── Check .ssh directory permissions ────────────────────────────────── check_ssh_dir_perms() { local user="$1" local ssh_dir="$2" if [[ ! -d "$ssh_dir" ]]; then verbose "No .ssh directory for ${user}" return fi local perms perms=$(stat -c '%a' "$ssh_dir" 2>/dev/null) || return if [[ "$perms" == "700" ]]; then record_pass "[${user}] .ssh directory permissions" "${perms}" else record_fail "[${user}] .ssh directory permissions" "${perms} (expected 700)" fi } # ── Check authorized_keys file permissions ──────────────────────────── check_authkeys_perms() { local user="$1" local authkeys_file="$2" if [[ ! -f "$authkeys_file" ]]; then return fi local perms perms=$(stat -c '%a' "$authkeys_file" 2>/dev/null) || return case "$perms" in 600|644) record_pass "[${user}] authorized_keys permissions" "${perms}" ;; *) local world_bit="${perms:2:1}" if [[ "$world_bit" =~ [2367] ]]; then record_fail "[${user}] authorized_keys permissions" "${perms} (world-writable)" else record_fail "[${user}] authorized_keys permissions" "${perms} (expected 600 or 644)" fi ;; esac } # ── Check for deprecated authorized_keys2 ───────────────────────────── check_authkeys2() { local user="$1" local ssh_dir="$2" local ak2="${ssh_dir}/authorized_keys2" if [[ -f "$ak2" ]]; then record_fail "[${user}] authorized_keys2 present" "deprecated, migrate to authorized_keys" fi } # ── Check private key permissions ───────────────────────────────────── check_private_key_perms() { local user="$1" local ssh_dir="$2" local privkey_names=("id_rsa" "id_ed25519" "id_ecdsa" "id_dsa" "id_xmss") for keyname in "${privkey_names[@]}"; do local keyfile="${ssh_dir}/${keyname}" if [[ -f "$keyfile" ]]; then local perms perms=$(stat -c '%a' "$keyfile" 2>/dev/null) || continue local world_bit="${perms:2:1}" if [[ "$world_bit" != "0" ]]; then record_fail "[${user}] Private key ${keyname}" "world-readable (${perms})" else verbose "[${user}] Private key ${keyname} permissions OK (${perms})" fi fi done } # ── Audit a single key line ─────────────────────────────────────────── audit_key_line() { local user="$1" local key_num="$2" local line="$3" local authkeys_file="$4" # Skip empty lines and comments [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && return # Check for command= or from= restrictions local restrictions="" if [[ "$line" =~ ^(.*[[:space:]])?(command=\"[^\"]*\") ]]; then restrictions="${BASH_REMATCH[2]}" fi if [[ "$line" =~ ^(.*[[:space:]])?(from=\"[^\"]*\") ]]; then restrictions="${restrictions:+${restrictions}, }${BASH_REMATCH[2]}" fi # Write key to temp file for ssh-keygen parsing local tmpkey tmpkey=$(mktemp) echo "$line" > "$tmpkey" local key_info key_info=$(ssh-keygen -l -f "$tmpkey" 2>/dev/null) || { rm -f "$tmpkey" verbose "[${user}] Key ${key_num}: could not parse" return } rm -f "$tmpkey" # Parse ssh-keygen output: "2048 SHA256:xxx comment (RSA)" local bits key_type comment fingerprint bits=$(echo "$key_info" | awk '{print $1}') fingerprint=$(echo "$key_info" | awk '{print $2}') key_type=$(echo "$key_info" | grep -oP '\(([A-Z0-9]+)\)' | tr -d '()') comment=$(echo "$key_info" | awk '{for(i=3;i