#!/usr/bin/env bash ######################################################################################### #### iam-audit.sh — Audit AWS IAM users, roles, policies, and access keys #### #### Finds stale access keys, users without MFA, unused roles, overly broad policies #### #### Requires: bash 4+, aws-cli v2, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### export AWS_PROFILE="production" #### #### ./iam-audit.sh --full #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── AWS_REGION="${AWS_REGION:-}" KEY_AGE_WARN_DAYS="${KEY_AGE_WARN_DAYS:-90}" KEY_AGE_CRIT_DAYS="${KEY_AGE_CRIT_DAYS:-180}" UNUSED_DAYS="${UNUSED_DAYS:-90}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME RUN_MODE="" START_TIME="" WARNINGS=0 TOTAL_PASS=0 TOTAL_WARN=0 TOTAL_CRIT=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; ((WARNINGS++)) || true; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } pass() { ((TOTAL_PASS++)) || true; } flag_warn() { ((TOTAL_WARN++)) || true; } flag_crit() { ((TOTAL_CRIT++)) || true; } # ── AWS CLI wrapper ─────────────────────────────────────────────────── aws_cmd() { local args=("$@") [[ -n "$AWS_REGION" ]] && args+=(--region "$AWS_REGION") verbose "aws ${args[*]}" aws "${args[@]}" } # ── Date math (portable) ───────────────────────────────────────────── now_epoch() { date +%s } iso_to_epoch() { local iso_date="$1" if date -d "$iso_date" +%s &>/dev/null; then date -d "$iso_date" +%s elif date -jf "%Y-%m-%dT%H:%M:%S" "${iso_date%%+*}" +%s &>/dev/null; then date -jf "%Y-%m-%dT%H:%M:%S" "${iso_date%%+*}" +%s else # Fallback: parse YYYY-MM-DD with awk echo "$iso_date" | awk -F'[-T:+]' '{ y=$1; m=$2; d=$3 t = mktime(y " " m " " d " 0 0 0") print t }' fi } days_since() { local iso_date="$1" local then_epoch now_epoch_val then_epoch=$(iso_to_epoch "$iso_date") now_epoch_val=$(now_epoch) echo $(( (now_epoch_val - then_epoch) / 86400 )) } # ── Credential check ───────────────────────────────────────────────── check_deps() { for cmd in aws jq; do if ! command -v "$cmd" &>/dev/null; then err "${cmd} is required but not installed" exit 1 fi done local identity identity=$(aws sts get-caller-identity 2>&1) || { err "AWS credentials not configured, expired, or invalid" echo "" >&2 echo "Supported credential methods:" >&2 echo " • AWS_PROFILE — named profile from ~/.aws/credentials" >&2 echo " • AWS SSO — run 'aws sso login --profile your-profile'" >&2 echo " • Environment vars — AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN" >&2 echo " • Instance profile — automatic on EC2/ECS" >&2 echo " • AWS_ROLE_ARN — assume role via STS" >&2 exit 1 } local arn arn=$(echo "$identity" | jq -r '.Arn') verbose "Identity: ${arn}" if [[ -n "${AWS_SESSION_TOKEN:-}" ]]; then verbose "Using temporary credentials (session token present)" fi if [[ -z "$AWS_REGION" ]]; then AWS_REGION=$(aws configure get region 2>/dev/null || echo "") if [[ -n "$AWS_REGION" ]]; then verbose "Using region from config: ${AWS_REGION}" fi fi log "Authenticated as ${arn}" } # ══════════════════════════════════════════════════════════════════════ # ACCESS KEY AUDIT # ══════════════════════════════════════════════════════════════════════ audit_access_keys() { log "Auditing access keys..." echo "" printf " %-24s %-22s %-10s %-10s %-20s %s\n" \ "USER" "KEY_ID" "STATUS" "AGE_DAYS" "LAST_USED" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..100})" local users_json users_json=$(aws_cmd iam list-users --output json) local user_count user_count=$(echo "$users_json" | jq '.Users | length') if [[ "$user_count" -eq 0 ]]; then log "No IAM users found" return fi local user_name echo "$users_json" | jq -r '.Users[].UserName' | while IFS= read -r user_name; do local keys_json keys_json=$(aws_cmd iam list-access-keys --user-name "$user_name" --output json 2>/dev/null) || continue local key_count key_count=$(echo "$keys_json" | jq '.AccessKeyMetadata | length') if [[ "$key_count" -eq 0 ]]; then verbose "User ${user_name}: no access keys" continue fi echo "$keys_json" | jq -c '.AccessKeyMetadata[]' | while IFS= read -r key; do local key_id status create_date age_days last_used_date severity last_used_display key_id=$(echo "$key" | jq -r '.AccessKeyId') status=$(echo "$key" | jq -r '.Status') create_date=$(echo "$key" | jq -r '.CreateDate') age_days=$(days_since "$create_date") # Get last used info local last_used_json last_used_json=$(aws_cmd iam get-access-key-last-used --access-key-id "$key_id" --output json 2>/dev/null) || true last_used_date=$(echo "$last_used_json" | jq -r '.AccessKeyLastUsed.LastUsedDate // "N/A"') if [[ "$last_used_date" == "N/A" ]]; then last_used_display="Never" else last_used_display="${last_used_date:0:10}" fi # Determine severity if [[ "$status" == "Inactive" ]]; then severity="INFO" elif [[ "$age_days" -ge "$KEY_AGE_CRIT_DAYS" ]]; then severity="CRITICAL" flag_crit elif [[ "$age_days" -ge "$KEY_AGE_WARN_DAYS" ]]; then severity="WARN" flag_warn elif [[ "$last_used_display" == "Never" && "$age_days" -ge 30 ]]; then severity="WARN" flag_warn else severity="OK" pass fi local color="" case "$severity" in CRITICAL) color="$RED" ;; WARN) color="$YELLOW" ;; OK) color="$GREEN" ;; *) color="" ;; esac printf " %-24s %-22s %-10s %-10s %-20s %b%s%b\n" \ "$user_name" "$key_id" "$status" "$age_days" "$last_used_display" \ "$color" "$severity" "$RESET" done done echo "" } # ══════════════════════════════════════════════════════════════════════ # MFA AUDIT # ══════════════════════════════════════════════════════════════════════ audit_mfa() { log "Auditing MFA status..." echo "" printf " %-24s %-18s %-14s %s\n" \ "USER" "CONSOLE_ACCESS" "MFA_ENABLED" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..75})" local users_json users_json=$(aws_cmd iam list-users --output json) echo "$users_json" | jq -r '.Users[].UserName' | while IFS= read -r user_name; do local has_console="No" local has_mfa="No" local severity="OK" # Check for login profile (console access) if aws_cmd iam get-login-profile --user-name "$user_name" &>/dev/null; then has_console="Yes" fi # Check MFA devices local mfa_json mfa_json=$(aws_cmd iam list-mfa-devices --user-name "$user_name" --output json 2>/dev/null) || true local mfa_count mfa_count=$(echo "$mfa_json" | jq '.MFADevices | length') if [[ "$mfa_count" -gt 0 ]]; then has_mfa="Yes" fi # Severity: console access without MFA is critical if [[ "$has_console" == "Yes" && "$has_mfa" == "No" ]]; then severity="CRITICAL" flag_crit elif [[ "$has_console" == "Yes" && "$has_mfa" == "Yes" ]]; then severity="OK" pass else severity="OK" pass fi local color="" case "$severity" in CRITICAL) color="$RED" ;; WARN) color="$YELLOW" ;; OK) color="$GREEN" ;; *) color="" ;; esac printf " %-24s %-18s %-14s %b%s%b\n" \ "$user_name" "$has_console" "$has_mfa" \ "$color" "$severity" "$RESET" done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED USERS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_unused_users() { log "Auditing user activity (inactive > ${UNUSED_DAYS} days)..." echo "" printf " %-24s %-16s %-16s %s\n" \ "USER" "LAST_CONSOLE" "LAST_API" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..75})" local users_json users_json=$(aws_cmd iam list-users --output json) echo "$users_json" | jq -c '.Users[]' | while IFS= read -r user; do local user_name password_last_used last_console last_api severity user_name=$(echo "$user" | jq -r '.UserName') password_last_used=$(echo "$user" | jq -r '.PasswordLastUsed // "N/A"') if [[ "$password_last_used" == "N/A" ]]; then last_console="Never" else last_console="${password_last_used:0:10}" fi # Check access key last used last_api="Never" local keys_json keys_json=$(aws_cmd iam list-access-keys --user-name "$user_name" --output json 2>/dev/null) || true echo "$keys_json" | jq -r '.AccessKeyMetadata[].AccessKeyId' 2>/dev/null | while IFS= read -r key_id; do local lu lu=$(aws_cmd iam get-access-key-last-used --access-key-id "$key_id" --output json 2>/dev/null | \ jq -r '.AccessKeyLastUsed.LastUsedDate // "N/A"') || true if [[ "$lu" != "N/A" ]]; then echo "${lu:0:10}" fi done | sort -r | head -1 | read -r latest_api || true if [[ -n "${latest_api:-}" ]]; then last_api="$latest_api" fi # Determine severity severity="OK" local console_inactive=false api_inactive=false if [[ "$last_console" == "Never" ]]; then console_inactive=true else local console_days console_days=$(days_since "$password_last_used") if [[ "$console_days" -ge "$UNUSED_DAYS" ]]; then console_inactive=true fi fi if [[ "$last_api" == "Never" ]]; then api_inactive=true else local api_days api_days=$(days_since "${last_api}T00:00:00Z") if [[ "$api_days" -ge "$UNUSED_DAYS" ]]; then api_inactive=true fi fi if [[ "$console_inactive" == "true" && "$api_inactive" == "true" ]]; then severity="WARN" flag_warn else pass fi local color="" case "$severity" in WARN) color="$YELLOW" ;; OK) color="$GREEN" ;; *) color="" ;; esac printf " %-24s %-16s %-16s %b%s%b\n" \ "$user_name" "$last_console" "$last_api" \ "$color" "$severity" "$RESET" done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED ROLES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_unused_roles() { log "Auditing role usage (inactive > ${UNUSED_DAYS} days)..." echo "" printf " %-40s %-16s %-10s %s\n" \ "ROLE" "LAST_USED" "AGE_DAYS" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..80})" local roles_json roles_json=$(aws_cmd iam list-roles --output json) echo "$roles_json" | jq -c '.Roles[]' | while IFS= read -r role; do local role_name role_path last_used create_date age_days severity role_name=$(echo "$role" | jq -r '.RoleName') role_path=$(echo "$role" | jq -r '.Path') create_date=$(echo "$role" | jq -r '.CreateDate') age_days=$(days_since "$create_date") # Skip service-linked roles if [[ "$role_path" == /aws-service-role/* ]]; then verbose "Skipping service-linked role: ${role_name}" continue fi # Skip AWS-managed roles if [[ "$role_name" == AWS* || "$role_name" == aws* ]]; then verbose "Skipping AWS-managed role: ${role_name}" continue fi # Check last used local last_used_date last_used_date=$(echo "$role" | jq -r '.RoleLastUsed.LastUsedDate // "N/A"') if [[ "$last_used_date" == "N/A" ]]; then last_used="Never" if [[ "$age_days" -ge "$UNUSED_DAYS" ]]; then severity="WARN" flag_warn else severity="OK" pass fi else last_used="${last_used_date:0:10}" local used_days used_days=$(days_since "$last_used_date") if [[ "$used_days" -ge "$UNUSED_DAYS" ]]; then severity="WARN" flag_warn else severity="OK" pass fi fi local color="" case "$severity" in WARN) color="$YELLOW" ;; OK) color="$GREEN" ;; *) color="" ;; esac printf " %-40s %-16s %-10s %b%s%b\n" \ "$role_name" "$last_used" "$age_days" \ "$color" "$severity" "$RESET" done echo "" } # ══════════════════════════════════════════════════════════════════════ # POLICY AUDIT # ══════════════════════════════════════════════════════════════════════ audit_policies() { log "Auditing IAM policies..." echo "" printf " %-28s %-10s %-30s %s\n" \ "ENTITY" "TYPE" "POLICY" "ISSUE" printf " %s\n" "$(printf '%.0s─' {1..90})" local users_json users_json=$(aws_cmd iam list-users --output json) # Check users with direct policy attachments echo "$users_json" | jq -r '.Users[].UserName' | while IFS= read -r user_name; do # Attached managed policies directly on user local attached attached=$(aws_cmd iam list-attached-user-policies --user-name "$user_name" --output json 2>/dev/null) || continue echo "$attached" | jq -c '.AttachedPolicies[]' 2>/dev/null | while IFS= read -r pol; do local policy_name policy_name=$(echo "$pol" | jq -r '.PolicyName') # Flag direct attachment printf " %-28s %-10s %-30s %b%s%b\n" \ "$user_name" "User" "$policy_name" \ "$YELLOW" "Direct attachment (use groups)" "$RESET" flag_warn # Flag admin access if [[ "$policy_name" == "AdministratorAccess" ]]; then printf " %-28s %-10s %-30s %b%s%b\n" \ "$user_name" "User" "$policy_name" \ "$RED" "Full admin access" "$RESET" flag_crit fi done # Inline policies on user local inline inline=$(aws_cmd iam list-user-policies --user-name "$user_name" --output json 2>/dev/null) || continue echo "$inline" | jq -r '.PolicyNames[]' 2>/dev/null | while IFS= read -r pol_name; do local pol_doc pol_doc=$(aws_cmd iam get-user-policy --user-name "$user_name" --policy-name "$pol_name" --output json 2>/dev/null) || continue # Check for wildcard local has_star_action has_star_resource has_star_action=$(echo "$pol_doc" | jq '[.PolicyDocument.Statement[] | select(.Effect == "Allow") | .Action] | flatten | any(. == "*")' 2>/dev/null) || has_star_action="false" has_star_resource=$(echo "$pol_doc" | jq '[.PolicyDocument.Statement[] | select(.Effect == "Allow") | .Resource] | flatten | any(. == "*")' 2>/dev/null) || has_star_resource="false" if [[ "$has_star_action" == "true" && "$has_star_resource" == "true" ]]; then printf " %-28s %-10s %-30s %b%s%b\n" \ "$user_name" "Inline" "$pol_name" \ "$RED" "Action:* Resource:*" "$RESET" flag_crit fi done done # Check roles with AdministratorAccess local roles_json roles_json=$(aws_cmd iam list-roles --output json) echo "$roles_json" | jq -c '.Roles[]' | while IFS= read -r role; do local role_name role_path role_name=$(echo "$role" | jq -r '.RoleName') role_path=$(echo "$role" | jq -r '.Path') [[ "$role_path" == /aws-service-role/* ]] && continue local attached attached=$(aws_cmd iam list-attached-role-policies --role-name "$role_name" --output json 2>/dev/null) || continue echo "$attached" | jq -c '.AttachedPolicies[]' 2>/dev/null | while IFS= read -r pol; do local policy_name policy_name=$(echo "$pol" | jq -r '.PolicyName') if [[ "$policy_name" == "AdministratorAccess" ]]; then printf " %-28s %-10s %-30s %b%s%b\n" \ "$role_name" "Role" "$policy_name" \ "$RED" "Full admin access" "$RESET" flag_crit fi done done echo "" } # ══════════════════════════════════════════════════════════════════════ # ROOT ACCOUNT AUDIT # ══════════════════════════════════════════════════════════════════════ audit_root_account() { log "Auditing root account and account settings..." echo "" # Account summary local summary summary=$(aws_cmd iam get-account-summary --output json 2>/dev/null) || { warn "Unable to retrieve account summary (may require root or admin permissions)" return } local root_keys root_mfa root_keys=$(echo "$summary" | jq '.SummaryMap.AccountAccessKeysPresent') root_mfa=$(echo "$summary" | jq '.SummaryMap.AccountMFAEnabled') echo " Root Account Status:" echo " $(printf '%.0s─' {1..50})" if [[ "$root_keys" -gt 0 ]]; then printf " %-30s %b%s%b\n" "Root access keys:" "$RED" "PRESENT (remove them!)" "$RESET" flag_crit else printf " %-30s %b%s%b\n" "Root access keys:" "$GREEN" "None" "$RESET" pass fi if [[ "$root_mfa" -eq 1 ]]; then printf " %-30s %b%s%b\n" "Root MFA:" "$GREEN" "Enabled" "$RESET" pass else printf " %-30s %b%s%b\n" "Root MFA:" "$RED" "DISABLED" "$RESET" flag_crit fi # Password policy echo "" log "Checking password policy..." local policy if policy=$(aws_cmd iam get-account-password-policy --output json 2>/dev/null); then local min_len require_upper require_lower require_numbers require_symbols max_age min_len=$(echo "$policy" | jq '.PasswordPolicy.MinimumPasswordLength') require_upper=$(echo "$policy" | jq '.PasswordPolicy.RequireUppercaseCharacters') require_lower=$(echo "$policy" | jq '.PasswordPolicy.RequireLowercaseCharacters') require_numbers=$(echo "$policy" | jq '.PasswordPolicy.RequireNumbers') require_symbols=$(echo "$policy" | jq '.PasswordPolicy.RequireSymbols') max_age=$(echo "$policy" | jq '.PasswordPolicy.MaxPasswordAge // 0') printf " %-30s %s\n" "Min password length:" "$min_len" printf " %-30s %s\n" "Require uppercase:" "$require_upper" printf " %-30s %s\n" "Require lowercase:" "$require_lower" printf " %-30s %s\n" "Require numbers:" "$require_numbers" printf " %-30s %s\n" "Require symbols:" "$require_symbols" if [[ "$max_age" -gt 0 ]]; then printf " %-30s %s days\n" "Max password age:" "$max_age" else printf " %-30s %s\n" "Max password age:" "No expiry" fi if [[ "$min_len" -lt 14 ]]; then flag_warn printf "\n %b%s%b\n" "$YELLOW" "Recommendation: Set minimum password length to 14+" "$RESET" else pass fi else printf " %-30s %b%s%b\n" "Password policy:" "$YELLOW" "Not configured (using AWS defaults)" "$RESET" flag_warn fi echo "" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local elapsed elapsed=$(( $(now_epoch) - START_TIME )) echo "" echo " ══════════════════════════════════════════" echo " IAM Audit Summary" echo " ══════════════════════════════════════════" printf " %-20s %b%d%b\n" "PASS:" "$GREEN" "$TOTAL_PASS" "$RESET" printf " %-20s %b%d%b\n" "WARN:" "$YELLOW" "$TOTAL_WARN" "$RESET" printf " %-20s %b%d%b\n" "CRITICAL:" "$RED" "$TOTAL_CRIT" "$RESET" echo " ──────────────────────────────────────────" printf " Completed in %ds\n" "$elapsed" echo "" if [[ "$TOTAL_CRIT" -gt 0 ]]; then echo -e " ${RED}${BOLD}Action required:${RESET} ${TOTAL_CRIT} critical finding(s)" echo "" echo " Top recommendations:" echo " • Remove root access keys immediately" echo " • Enable MFA for all console users" echo " • Rotate access keys older than ${KEY_AGE_CRIT_DAYS} days" echo " • Replace Action:*/Resource:* policies with least-privilege" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" echo " Suggestions:" echo " • Rotate access keys older than ${KEY_AGE_WARN_DAYS} days" echo " • Remove or disable inactive users" echo " • Use groups instead of direct policy attachments" echo "" else echo -e " ${GREEN}All checks passed${RESET}" echo "" fi } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; esac done if [[ ${#modes[@]} -eq 0 ]]; then err "No audit mode specified" echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 fi RUN_MODE="${modes[*]}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors check_deps START_TIME=$(now_epoch) echo "" echo -e "${BOLD}IAM Audit${RESET}" echo -e "Mode: ${RUN_MODE}" echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo -e "Thresholds: key-warn=${KEY_AGE_WARN_DAYS}d key-crit=${KEY_AGE_CRIT_DAYS}d unused=${UNUSED_DAYS}d" echo "" for mode in $RUN_MODE; do case "$mode" in keys) audit_access_keys ;; mfa) audit_mfa ;; users) audit_unused_users ;; roles) audit_unused_roles ;; policies) audit_policies ;; root) audit_root_account ;; esac done print_summary if [[ "$TOTAL_CRIT" -gt 0 ]]; then exit 2 elif [[ "$TOTAL_WARN" -gt 0 ]]; then exit 1 fi exit 0 } main "$@"