a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
825 lines
32 KiB
Bash
825 lines
32 KiB
Bash
#!/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 <<EOF
|
|
${SCRIPT_NAME} — Audit AWS IAM users, roles, policies, and access keys
|
|
|
|
USAGE:
|
|
${SCRIPT_NAME} [OPTIONS]
|
|
|
|
MODES:
|
|
--full Run all audits
|
|
--keys Audit access keys (age, usage, status)
|
|
--mfa Audit MFA status for all users
|
|
--users Find inactive users
|
|
--roles Find unused roles
|
|
--policies Audit policy attachments and wildcards
|
|
--root Audit root account and password policy
|
|
|
|
OPTIONS:
|
|
--key-age-warn DAYS Warn threshold for key age (default: ${KEY_AGE_WARN_DAYS})
|
|
--key-age-crit DAYS Critical threshold for key age (default: ${KEY_AGE_CRIT_DAYS})
|
|
--unused-days DAYS Inactive threshold for users/roles (default: ${UNUSED_DAYS})
|
|
--format FORMAT Output format: text, csv, json (default: text)
|
|
--verbose Enable debug output
|
|
--no-color Disable colored output
|
|
--help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
AWS_PROFILE AWS CLI profile name
|
|
AWS_REGION AWS region
|
|
AWS_ACCESS_KEY_ID Access key (with AWS_SECRET_ACCESS_KEY)
|
|
AWS_SESSION_TOKEN Session token for temporary credentials
|
|
KEY_AGE_WARN_DAYS Warn threshold (default: 90)
|
|
KEY_AGE_CRIT_DAYS Critical threshold (default: 180)
|
|
UNUSED_DAYS Inactive threshold (default: 90)
|
|
OUTPUT_FORMAT Output format (default: text)
|
|
|
|
EXAMPLES:
|
|
# Full audit with default thresholds
|
|
AWS_PROFILE=prod ./iam-audit.sh --full
|
|
|
|
# Check only access keys, flag anything older than 60 days
|
|
./iam-audit.sh --keys --key-age-warn 60 --key-age-crit 120
|
|
|
|
# Check MFA status only
|
|
./iam-audit.sh --mfa
|
|
|
|
# Find users and roles inactive for 60+ days
|
|
./iam-audit.sh --users --roles --unused-days 60
|
|
EOF
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ARGUMENT PARSING
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
parse_args() {
|
|
local modes=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--full)
|
|
modes=(keys mfa users roles policies root)
|
|
shift ;;
|
|
--keys)
|
|
modes+=(keys); shift ;;
|
|
--mfa)
|
|
modes+=(mfa); shift ;;
|
|
--users)
|
|
modes+=(users); shift ;;
|
|
--roles)
|
|
modes+=(roles); shift ;;
|
|
--policies)
|
|
modes+=(policies); shift ;;
|
|
--root)
|
|
modes+=(root); shift ;;
|
|
--key-age-warn)
|
|
KEY_AGE_WARN_DAYS="$2"; shift 2 ;;
|
|
--key-age-crit)
|
|
KEY_AGE_CRIT_DAYS="$2"; shift 2 ;;
|
|
--unused-days)
|
|
UNUSED_DAYS="$2"; shift 2 ;;
|
|
--format)
|
|
OUTPUT_FORMAT="$2"; shift 2 ;;
|
|
--verbose)
|
|
VERBOSE="true"; shift ;;
|
|
--no-color)
|
|
COLOR="never"; shift ;;
|
|
--help|-h)
|
|
setup_colors
|
|
usage
|
|
exit 0 ;;
|
|
*)
|
|
err "Unknown option: $1"
|
|
echo "Run ${SCRIPT_NAME} --help for usage" >&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 "$@"
|