#!/usr/bin/env bash ######################################################################################### #### user-audit.sh — Audit local user accounts, sudo access, SSH keys, and passwords #### #### Shows last login, password age, group memberships, and security warnings #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./user-audit.sh #### #### ./user-audit.sh --system #### #### ./user-audit.sh --section sudo,ssh #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── SECTIONS="${SECTIONS:-all}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" FILTER="${FILTER:-human}" WARN_DAYS="${WARN_DAYS:-14}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME TOTAL_USERS=0 SUDO_USERS=0 SSH_KEY_USERS=0 PASSWORD_WARNINGS=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } should_show() { [[ "$SECTIONS" == "all" ]] || [[ ",$SECTIONS," == *",$1,"* ]] } is_sudo_user() { local user="$1" local sudo_groups="sudo wheel adm" for grp in $sudo_groups; do if getent group "$grp" 2>/dev/null | grep -qw "$user"; then return 0 fi done return 1 } get_ssh_key_count() { local user="$1" local home_dir="$2" local auth_file="${home_dir}/.ssh/authorized_keys" if [[ -r "$auth_file" ]]; then grep -cE "^(ssh-|ecdsa-|sk-)" "$auth_file" 2>/dev/null || echo "0" else echo "0" fi } get_last_login() { local user="$1" local last_info last_info=$(lastlog -u "$user" 2>/dev/null | tail -1) if echo "$last_info" | grep -q "Never logged in"; then echo "Never" else echo "$last_info" | awk '{print $4, $5, $6, $7, $9}' 2>/dev/null || echo "Unknown" fi } get_password_age() { local user="$1" if [[ ! -r /etc/shadow ]]; then echo "no-access" return fi local shadow_entry shadow_entry=$(grep "^${user}:" /etc/shadow 2>/dev/null || true) if [[ -z "$shadow_entry" ]]; then echo "no-entry" return fi local last_change last_change=$(echo "$shadow_entry" | cut -d: -f3) if [[ -z "$last_change" || "$last_change" == "0" ]]; then echo "must-change" return fi local today today=$(( $(date +%s) / 86400 )) local age=$(( today - last_change )) echo "$age" } get_password_max_days() { local user="$1" if [[ ! -r /etc/shadow ]]; then echo "" return fi local shadow_entry shadow_entry=$(grep "^${user}:" /etc/shadow 2>/dev/null || true) if [[ -z "$shadow_entry" ]]; then echo "" return fi local max_days max_days=$(echo "$shadow_entry" | cut -d: -f5) if [[ -z "$max_days" || "$max_days" == "99999" ]]; then echo "" else echo "$max_days" fi } password_status_color() { local age="$1" local max_days="$2" if [[ "$age" == "no-access" ]]; then echo "${DIM}(shadow unreadable)${RESET}" return fi if [[ "$age" == "no-entry" ]]; then echo "${DIM}N/A${RESET}" return fi if [[ "$age" == "must-change" ]]; then echo "${YELLOW}must change${RESET}" PASSWORD_WARNINGS=$((PASSWORD_WARNINGS + 1)) return fi local status="${age} days" if [[ -n "$max_days" ]]; then local remaining=$(( max_days - age )) if [[ "$remaining" -le 0 ]]; then status="${RED}EXPIRED (${age}d / ${max_days}d max)${RESET}" PASSWORD_WARNINGS=$((PASSWORD_WARNINGS + 1)) elif [[ "$remaining" -le "$WARN_DAYS" ]]; then status="${YELLOW}${age}d (expires in ${remaining}d)${RESET}" PASSWORD_WARNINGS=$((PASSWORD_WARNINGS + 1)) else status="${GREEN}${age}d (${remaining}d remaining)${RESET}" fi else status="${GREEN}${age}d (no expiry)${RESET}" fi echo "$status" } shell_color() { local shell="$1" case "$shell" in */nologin|*/false) echo "${RED}${shell}${RESET}" ;; *) echo "${GREEN}${shell}${RESET}" ;; esac } # ══════════════════════════════════════════════════════════════════════ # USER LISTING # ══════════════════════════════════════════════════════════════════════ show_users() { section_header "User Accounts" printf " ${BOLD}%-16s %6s %-18s %-14s %s${RESET}\n" "USERNAME" "UID" "SHELL" "LAST LOGIN" "PASSWORD AGE" printf " %s\n" "$(printf '%.0s─' {1..78})" while IFS=: read -r username _ uid _ _ home shell; do if [[ "$FILTER" == "human" && "$uid" -lt 1000 && "$username" != "root" ]]; then continue fi TOTAL_USERS=$((TOTAL_USERS + 1)) local last_login last_login=$(get_last_login "$username") if [[ ${#last_login} -gt 14 ]]; then last_login="${last_login:0:14}" fi local pw_age pw_max pw_status pw_age=$(get_password_age "$username") pw_max=$(get_password_max_days "$username") pw_status=$(password_status_color "$pw_age" "$pw_max") local shell_display shell_display=$(shell_color "$shell") printf " %-16s %6s %b %-14s %b\n" \ "$username" "$uid" \ "$(printf '%-18b' "$shell_display")" \ "$last_login" "$pw_status" verbose " ${username}: home=${home} shell=${shell}" done < /etc/passwd } # ══════════════════════════════════════════════════════════════════════ # SUDO ACCESS # ══════════════════════════════════════════════════════════════════════ show_sudo() { section_header "Sudo / Wheel Membership" local found=0 while IFS=: read -r username _ uid _ _ _ _; do if [[ "$FILTER" == "human" && "$uid" -lt 1000 && "$username" != "root" ]]; then continue fi if is_sudo_user "$username"; then local groups_list groups_list=$(groups "$username" 2>/dev/null | cut -d: -f2 | xargs) printf " ${GREEN}✓${RESET} %-20s %s\n" "$username" "$groups_list" SUDO_USERS=$((SUDO_USERS + 1)) found=1 fi done < /etc/passwd if [[ "$found" -eq 0 ]]; then echo " No sudo/wheel users found" fi # Check sudoers.d if [[ -d /etc/sudoers.d ]] && [[ -r /etc/sudoers.d ]]; then local sudoers_files sudoers_files=$(find /etc/sudoers.d -type f ! -name '.*' 2>/dev/null | wc -l) if [[ "$sudoers_files" -gt 0 ]]; then echo "" field "sudoers.d files:" "$sudoers_files" fi fi } # ══════════════════════════════════════════════════════════════════════ # SSH KEYS # ══════════════════════════════════════════════════════════════════════ show_ssh() { section_header "SSH Authorized Keys" printf " ${BOLD}%-20s %s${RESET}\n" "USERNAME" "KEY COUNT" printf " %s\n" "$(printf '%.0s─' {1..35})" local found=0 while IFS=: read -r username _ uid _ _ home _; do if [[ "$FILTER" == "human" && "$uid" -lt 1000 && "$username" != "root" ]]; then continue fi local key_count key_count=$(get_ssh_key_count "$username" "$home") if [[ "$key_count" -gt 0 ]]; then printf " %-20s ${GREEN}%s${RESET}\n" "$username" "$key_count" SSH_KEY_USERS=$((SSH_KEY_USERS + 1)) found=1 fi done < /etc/passwd if [[ "$found" -eq 0 ]]; then echo " No SSH authorized keys found" fi } # ══════════════════════════════════════════════════════════════════════ # PASSWORD STATUS # ══════════════════════════════════════════════════════════════════════ show_passwords() { section_header "Password Status" if [[ ! -r /etc/shadow ]]; then warn "Cannot read /etc/shadow — run as root for password details" return fi printf " ${BOLD}%-16s %-12s %-12s %s${RESET}\n" "USERNAME" "LAST CHANGE" "MAX DAYS" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..62})" while IFS=: read -r username _ uid _ _ _ _; do if [[ "$FILTER" == "human" && "$uid" -lt 1000 && "$username" != "root" ]]; then continue fi local shadow_entry shadow_entry=$(grep "^${username}:" /etc/shadow 2>/dev/null || true) if [[ -z "$shadow_entry" ]]; then continue fi local pw_field last_change max_days pw_field=$(echo "$shadow_entry" | cut -d: -f2) last_change=$(echo "$shadow_entry" | cut -d: -f3) max_days=$(echo "$shadow_entry" | cut -d: -f5) # Skip locked/disabled accounts if [[ "$pw_field" == "!" || "$pw_field" == "!!" || "$pw_field" == "*" ]]; then local lock_status="${DIM}locked${RESET}" printf " %-16s %-12s %-12s %b\n" "$username" "-" "-" "$lock_status" continue fi local change_date="-" if [[ -n "$last_change" && "$last_change" != "0" ]]; then change_date=$(date -d "1970-01-01 + ${last_change} days" +%Y-%m-%d 2>/dev/null || echo "$last_change") fi local max_display="-" if [[ -n "$max_days" && "$max_days" != "99999" ]]; then max_display="${max_days}d" fi local pw_age pw_status pw_age=$(get_password_age "$username") pw_status=$(password_status_color "$pw_age" "$(get_password_max_days "$username")") printf " %-16s %-12s %-12s %b\n" "$username" "$change_date" "$max_display" "$pw_status" done < /etc/passwd } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { echo "" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo -e " ${BOLD}User Audit Summary${RESET}" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" field "Total users shown:" "$TOTAL_USERS" field_color "Sudo/wheel users:" "${GREEN}${SUDO_USERS}${RESET}" field "Users with SSH keys:" "$SSH_KEY_USERS" if [[ "$PASSWORD_WARNINGS" -gt 0 ]]; then field_color "Password warnings:" "${YELLOW}${PASSWORD_WARNINGS}${RESET}" else field_color "Password warnings:" "${GREEN}0${RESET}" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <= 1000 + root, default) --system Include system users (all UIDs) --section SECTIONS Comma-separated sections to show (default: all) Available: users, sudo, ssh, passwords --warn-days DAYS Password expiry warning threshold (default: ${WARN_DAYS}) --verbose Enable debug output --no-color Disable colored output --help Show this help EXAMPLES: # Audit human users (default) ./user-audit.sh # Include system accounts ./user-audit.sh --system # Show only sudo and SSH key info ./user-audit.sh --section sudo,ssh # Full audit with password details (needs root) sudo ./user-audit.sh --section passwords EOF } # ══════════════════════════════════════════════════════════════════════ # ARGUMENT PARSING # ══════════════════════════════════════════════════════════════════════ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --human) FILTER="human"; shift ;; --system) FILTER="system"; shift ;; --section) SECTIONS="$2"; shift 2 ;; --warn-days) WARN_DAYS="$2"; shift 2 ;; --verbose) VERBOSE="true"; shift ;; --no-color) COLOR="never"; shift ;; --help|-h) setup_colors usage exit 0 ;; *) echo "Unknown option: $1" >&2 echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors echo "" echo -e "${BOLD}User Audit — $(hostname -f 2>/dev/null || hostname)${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" echo -e "Filter: ${FILTER}" should_show "users" && show_users should_show "sudo" && show_sudo should_show "ssh" && show_ssh should_show "passwords" && show_passwords print_summary } main "$@"