#!/usr/bin/env bash set -o errexit set -o nounset set -o pipefail since_value="1 hour ago" warning_count=20 critical_count=50 top_count=10 usage() { cat <<'USAGE' Usage: check_failed_ssh_logins.sh [--since TEXT] [--warning COUNT] [--critical COUNT] [--top N] [--help] Detect failed SSH login bursts from journal or readable authentication logs. USAGE } is_number() { [[ "$1" =~ ^[0-9]+$ ]] } while (($# > 0)); do case "$1" in --since) [[ $# -ge 2 ]] || { printf 'CRITICAL: --since requires a value\n'; exit 2; }; since_value="$2"; shift 2 ;; --warning) [[ $# -ge 2 ]] || { printf 'CRITICAL: --warning requires a value\n'; exit 2; }; warning_count="$2"; shift 2 ;; --critical) [[ $# -ge 2 ]] || { printf 'CRITICAL: --critical requires a value\n'; exit 2; }; critical_count="$2"; shift 2 ;; --top) [[ $# -ge 2 ]] || { printf 'CRITICAL: --top requires a value\n'; exit 2; }; top_count="$2"; shift 2 ;; --help|-h) usage; exit 0 ;; *) printf 'CRITICAL: unknown option: %s\n' "$1"; usage; exit 2 ;; esac done for value in "$warning_count" "$critical_count" "$top_count"; do if ! is_number "$value"; then printf 'CRITICAL: numeric option expected, got: %s\n' "$value" exit 2 fi done if ((warning_count >= critical_count)); then printf 'CRITICAL: --warning must be lower than --critical\n' exit 2 fi tmp_log="$(mktemp)" trap 'rm -f "$tmp_log"' EXIT log_source="journalctl" if command -v journalctl >/dev/null 2>&1; then journalctl --since "$since_value" --no-pager 2>/dev/null \ | grep -Ei 'sshd.*(Failed password|Invalid user|authentication failure)|authentication failure.*sshd' > "$tmp_log" || true else log_source="log file fallback" fi if [[ ! -s "$tmp_log" ]]; then for log_file in /var/log/auth.log /var/log/secure /var/log/messages; do if [[ -r "$log_file" ]]; then grep -Ei 'sshd.*(Failed password|Invalid user|authentication failure)|authentication failure.*sshd' "$log_file" >> "$tmp_log" || true log_source="$log_file" fi done fi attempts="$(wc -l < "$tmp_log" | awk '{print $1}')" status="OK" exit_code=0 if ((attempts >= critical_count)); then status="CRITICAL" exit_code=3 elif ((attempts >= warning_count)); then status="WARNING" exit_code=1 fi printf '%s: Found %s failed SSH login attempt(s) for requested window\n\n' "$status" "$attempts" printf 'Top source IPs:\n' if [[ -s "$tmp_log" ]]; then grep -Eo 'from ([0-9]{1,3}\.){3}[0-9]{1,3}|rhost=([0-9]{1,3}\.){3}[0-9]{1,3}' "$tmp_log" \ | sed -E 's/^(from|rhost=) //' \ | sort | uniq -c | sort -rn | head -n "$top_count" || true else printf 'OK: no failed SSH attempts found in available sources\n' fi printf '\n' printf 'Top attempted users:\n' if [[ -s "$tmp_log" ]]; then sed -nE 's/.*Invalid user ([^ ]+).*/\1/p; s/.*Failed password for invalid user ([^ ]+).*/\1/p; s/.*Failed password for ([^ ]+).*/\1/p; s/.*user=([^ ]+).*/\1/p' "$tmp_log" \ | sort | uniq -c | sort -rn | head -n "$top_count" || true else printf 'OK: no attempted users extracted\n' fi printf '\n' printf 'Sample recent lines:\n' if [[ -s "$tmp_log" ]]; then tail -n "$top_count" "$tmp_log" else printf 'OK: no sample lines available\n' fi printf '\n\n' printf 'Evidence:\n' printf 'Thresholds: warning=%s critical=%s since="%s"\n' "$warning_count" "$critical_count" "$since_value" printf 'Log source: %s\n' "$log_source" if [[ "$log_source" != "journalctl" ]]; then printf 'WARNING: log file fallback may include entries outside the requested --since window\n' fi if [[ "${EUID:-$(id -u 2>/dev/null || printf '1')}" != "0" ]]; then printf 'WARNING: running without root; authentication log visibility may be limited\n' fi printf '\n' printf 'Recommended next steps:\n' printf -- '- Verify source IPs against expected scanners, admins, or automation\n' printf -- '- Check firewall, fail2ban, or security tooling state\n' printf -- '- Confirm whether the attempts are expected for this host\n' printf -- '- Review successful logins too, not only failures\n' printf -- '- Attach this output to incident ticket\n' exit "$exit_code"