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.
650 lines
27 KiB
Bash
Executable File
650 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
#########################################################################################
|
|
#### azure-ad-audit.sh — Audit Azure Entra ID for stale users, MFA gaps, risky ####
|
|
#### sign-ins, excessive permissions, and service principal hygiene via az CLI ####
|
|
#### Requires: bash 4+, az CLI, jq ####
|
|
#### ####
|
|
#### Author: Phil Connor ####
|
|
#### Contact: contact@mylinux.work ####
|
|
#### License: MIT ####
|
|
#### Version 1.01 ####
|
|
#### ####
|
|
#### Usage: ####
|
|
#### ./azure-ad-audit.sh --full ####
|
|
#### ####
|
|
#### See --help for all options. ####
|
|
#########################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Colors (pre-initialized) ─────────────────────────────────────────
|
|
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
|
|
|
|
setup_colors() {
|
|
if [[ "${COLOR:-auto}" == "never" ]]; then
|
|
return
|
|
fi
|
|
if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
RESET='\033[0m'
|
|
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 "${DIM}[DEBUG]${RESET} $*"; fi; }
|
|
die() { err "$*"; exit 1; }
|
|
|
|
# ── Severity counters ────────────────────────────────────────────────
|
|
TOTAL_CRIT=0
|
|
TOTAL_WARN=0
|
|
TOTAL_INFO=0
|
|
TOTAL_OK=0
|
|
|
|
flag_crit() { ((TOTAL_CRIT++)) || true; }
|
|
flag_warn() { ((TOTAL_WARN++)) || true; }
|
|
flag_info() { ((TOTAL_INFO++)) || true; }
|
|
flag_ok() { ((TOTAL_OK++)) || true; }
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────
|
|
RUN_MODE=""
|
|
STALE_DAYS="${STALE_DAYS:-90}"
|
|
VERBOSE="${VERBOSE:-false}"
|
|
COLOR="${COLOR:-auto}"
|
|
|
|
# ── State ─────────────────────────────────────────────────────────────
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
readonly SCRIPT_NAME
|
|
START_TIME=""
|
|
|
|
# ── Dependency checks ────────────────────────────────────────────────
|
|
check_deps() {
|
|
command -v az &>/dev/null || die "az CLI is required (https://aka.ms/install-azure-cli)"
|
|
command -v jq &>/dev/null || die "jq is required"
|
|
}
|
|
|
|
check_credentials() {
|
|
if ! az account show &>/dev/null 2>&1; then
|
|
die "Not logged in to Azure CLI — run 'az login' first"
|
|
fi
|
|
verbose "Azure CLI authenticated"
|
|
}
|
|
|
|
# ── Date helpers ─────────────────────────────────────────────────────
|
|
days_since() {
|
|
local date_str="$1"
|
|
if [[ -z "$date_str" || "$date_str" == "null" || "$date_str" == "None" ]]; then
|
|
echo "never"
|
|
return
|
|
fi
|
|
local then_epoch now_epoch
|
|
then_epoch=$(date -d "${date_str}" +%s 2>/dev/null || echo 0)
|
|
now_epoch=$(date +%s)
|
|
if [[ "$then_epoch" -eq 0 ]]; then
|
|
echo "unknown"
|
|
return
|
|
fi
|
|
echo $(( (now_epoch - then_epoch) / 86400 ))
|
|
}
|
|
|
|
days_until() {
|
|
local date_str="$1"
|
|
if [[ -z "$date_str" || "$date_str" == "null" || "$date_str" == "None" ]]; then
|
|
echo "unknown"
|
|
return
|
|
fi
|
|
local target_epoch now_epoch
|
|
target_epoch=$(date -d "${date_str}" +%s 2>/dev/null || echo 0)
|
|
now_epoch=$(date +%s)
|
|
if [[ "$target_epoch" -eq 0 ]]; then
|
|
echo "unknown"
|
|
return
|
|
fi
|
|
echo $(( (target_epoch - now_epoch) / 86400 ))
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# STALE USERS AUDIT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
audit_stale_users() {
|
|
log "Auditing stale user accounts (threshold: ${STALE_DAYS} days)..."
|
|
echo ""
|
|
|
|
printf " %-36s %-24s %-20s %-10s %s\n" \
|
|
"UPN" "DISPLAY_NAME" "LAST_SIGN_IN" "DAYS_IDLE" "SEVERITY"
|
|
printf " %s\n" "$(printf '%.0s─' {1..110})"
|
|
|
|
local users_json
|
|
# shellcheck disable=SC2016
|
|
users_json=$(az rest --method GET \
|
|
--url 'https://graph.microsoft.com/v1.0/users?$select=userPrincipalName,displayName,signInActivity,accountEnabled&$top=999' \
|
|
2>/dev/null || echo '{"value":[]}')
|
|
|
|
echo "$users_json" | jq -c '.value[]? // empty' 2>/dev/null | while IFS= read -r user; do
|
|
local upn display_name enabled last_sign_in
|
|
upn=$(echo "$user" | jq -r '.userPrincipalName // "unknown"' 2>/dev/null)
|
|
display_name=$(echo "$user" | jq -r '.displayName // "unknown"' 2>/dev/null)
|
|
enabled=$(echo "$user" | jq -r '.accountEnabled // true' 2>/dev/null)
|
|
last_sign_in=$(echo "$user" | jq -r '.signInActivity.lastSignInDateTime // "null"' 2>/dev/null)
|
|
|
|
[[ "$enabled" == "false" ]] && continue
|
|
|
|
local idle_days last_sign_display
|
|
idle_days=$(days_since "$last_sign_in")
|
|
|
|
if [[ "$last_sign_in" == "null" || -z "$last_sign_in" ]]; then
|
|
last_sign_display="Never"
|
|
else
|
|
last_sign_display="${last_sign_in:0:10}"
|
|
fi
|
|
|
|
if [[ "$idle_days" == "never" ]]; then
|
|
printf " %-36s %-24s %-20s %-10s %b%s%b\n" \
|
|
"${upn:0:34}" "${display_name:0:22}" "$last_sign_display" \
|
|
"N/A" "$YELLOW" "WARN" "$RESET"
|
|
flag_warn
|
|
elif [[ "$idle_days" == "unknown" ]]; then
|
|
verbose "Skipping ${upn}: unable to parse sign-in date"
|
|
elif [[ "$idle_days" -gt "$STALE_DAYS" ]]; then
|
|
printf " %-36s %-24s %-20s %-10s %b%s%b\n" \
|
|
"${upn:0:34}" "${display_name:0:22}" "$last_sign_display" \
|
|
"$idle_days" "$YELLOW" "WARN" "$RESET"
|
|
flag_warn
|
|
else
|
|
verbose "User ${upn}: active (${idle_days}d idle)"
|
|
flag_ok
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# MFA AUDIT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
audit_mfa() {
|
|
log "Auditing MFA registration status..."
|
|
echo ""
|
|
|
|
printf " %-36s %-14s %-10s %s\n" \
|
|
"UPN" "MFA_STATUS" "IS_ADMIN" "SEVERITY"
|
|
printf " %s\n" "$(printf '%.0s─' {1..80})"
|
|
|
|
local reg_json
|
|
# shellcheck disable=SC2016
|
|
reg_json=$(az rest --method GET \
|
|
--url 'https://graph.microsoft.com/v1.0/reports/credentialUserRegistrationDetails?$top=999' \
|
|
2>/dev/null || echo '{"value":[]}')
|
|
|
|
local admin_upns
|
|
admin_upns=$(az rest --method GET \
|
|
--url 'https://graph.microsoft.com/v1.0/directoryRoles' \
|
|
2>/dev/null | jq -r '.value[]? | select(.displayName | test("Admin|Administrator"; "i")) | .id' 2>/dev/null || true)
|
|
|
|
local admin_members=""
|
|
while IFS= read -r role_id; do
|
|
[[ -z "$role_id" ]] && continue
|
|
local members
|
|
members=$(az rest --method GET \
|
|
--url "https://graph.microsoft.com/v1.0/directoryRoles/${role_id}/members" \
|
|
2>/dev/null | jq -r '.value[]?.userPrincipalName // empty' 2>/dev/null || true)
|
|
admin_members="${admin_members}${members}"$'\n'
|
|
done <<< "$admin_upns"
|
|
|
|
echo "$reg_json" | jq -c '.value[]? // empty' 2>/dev/null | while IFS= read -r entry; do
|
|
local upn mfa_registered
|
|
upn=$(echo "$entry" | jq -r '.userPrincipalName // "unknown"' 2>/dev/null)
|
|
mfa_registered=$(echo "$entry" | jq -r '.isMfaRegistered // false' 2>/dev/null)
|
|
|
|
local is_admin="No"
|
|
if echo "$admin_members" | grep -qi "^${upn}$" 2>/dev/null; then
|
|
is_admin="Yes"
|
|
fi
|
|
|
|
local mfa_display severity
|
|
if [[ "$mfa_registered" == "true" ]]; then
|
|
mfa_display="Registered"
|
|
severity="OK"
|
|
printf " %-36s %-14s %-10s %b%s%b\n" \
|
|
"${upn:0:34}" "$mfa_display" "$is_admin" "$GREEN" "$severity" "$RESET"
|
|
flag_ok
|
|
else
|
|
mfa_display="Not registered"
|
|
if [[ "$is_admin" == "Yes" ]]; then
|
|
severity="CRITICAL"
|
|
printf " %-36s %-14s %-10s %b%s%b\n" \
|
|
"${upn:0:34}" "$mfa_display" "$is_admin" "$RED" "$severity" "$RESET"
|
|
flag_crit
|
|
else
|
|
severity="WARN"
|
|
printf " %-36s %-14s %-10s %b%s%b\n" \
|
|
"${upn:0:34}" "$mfa_display" "$is_admin" "$YELLOW" "$severity" "$RESET"
|
|
flag_warn
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# ADMIN ROLES AUDIT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
audit_admins() {
|
|
log "Auditing privileged role assignments..."
|
|
echo ""
|
|
|
|
printf " %-36s %-28s %-20s %s\n" \
|
|
"UPN" "ROLE" "SCOPE" "SEVERITY"
|
|
printf " %s\n" "$(printf '%.0s─' {1..95})"
|
|
|
|
local owner_json
|
|
owner_json=$(az role assignment list --role "Owner" --all 2>/dev/null || echo '[]')
|
|
|
|
echo "$owner_json" | jq -c '.[]? // empty' 2>/dev/null | while IFS= read -r assignment; do
|
|
local upn role scope
|
|
upn=$(echo "$assignment" | jq -r '.principalName // "unknown"' 2>/dev/null)
|
|
role=$(echo "$assignment" | jq -r '.roleDefinitionName // "unknown"' 2>/dev/null)
|
|
scope=$(echo "$assignment" | jq -r '.scope // "/"' 2>/dev/null)
|
|
|
|
[[ -z "$upn" || "$upn" == "unknown" ]] && continue
|
|
|
|
local scope_display
|
|
scope_display="${scope##*/}"
|
|
[[ -z "$scope_display" ]] && scope_display="$scope"
|
|
|
|
printf " %-36s %-28s %-20s %b%s%b\n" \
|
|
"${upn:0:34}" "${role:0:26}" "${scope_display:0:18}" \
|
|
"$CYAN" "INFO" "$RESET"
|
|
flag_info
|
|
done
|
|
|
|
local ga_json
|
|
ga_json=$(az rest --method GET \
|
|
--url 'https://graph.microsoft.com/v1.0/directoryRoles' \
|
|
2>/dev/null | jq -c '.value[]? | select(.displayName == "Global Administrator")' 2>/dev/null || echo '')
|
|
|
|
if [[ -n "$ga_json" ]]; then
|
|
local ga_role_id
|
|
ga_role_id=$(echo "$ga_json" | jq -r '.id' 2>/dev/null)
|
|
|
|
local ga_members
|
|
ga_members=$(az rest --method GET \
|
|
--url "https://graph.microsoft.com/v1.0/directoryRoles/${ga_role_id}/members" \
|
|
2>/dev/null || echo '{"value":[]}')
|
|
|
|
local ga_count
|
|
ga_count=$(echo "$ga_members" | jq '[.value[]?] | length' 2>/dev/null || echo 0)
|
|
|
|
echo "$ga_members" | jq -c '.value[]? // empty' 2>/dev/null | while IFS= read -r member; do
|
|
local m_upn
|
|
m_upn=$(echo "$member" | jq -r '.userPrincipalName // "unknown"' 2>/dev/null)
|
|
|
|
printf " %-36s %-28s %-20s %b%s%b\n" \
|
|
"${m_upn:0:34}" "Global Administrator" "Tenant" \
|
|
"$CYAN" "INFO" "$RESET"
|
|
flag_info
|
|
done
|
|
|
|
if [[ "$ga_count" -gt 5 ]]; then
|
|
echo ""
|
|
warn "Excessive Global Administrators: ${ga_count} found (recommended: ≤5)"
|
|
printf " %-36s %-28s %-20s %b%s%b\n" \
|
|
"— policy —" "Global Admin count: ${ga_count}" ">5 threshold" \
|
|
"$YELLOW" "WARN" "$RESET"
|
|
flag_warn
|
|
elif [[ "$ga_count" -gt 0 ]]; then
|
|
verbose "Global Administrator count: ${ga_count} (within threshold)"
|
|
flag_ok
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# SERVICE PRINCIPALS AUDIT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
audit_service_principals() {
|
|
log "Auditing service principal credentials..."
|
|
echo ""
|
|
|
|
printf " %-30s %-14s %-20s %-14s %s\n" \
|
|
"APP_NAME" "CRED_TYPE" "EXPIRY" "STATUS" "SEVERITY"
|
|
printf " %s\n" "$(printf '%.0s─' {1..100})"
|
|
|
|
local sp_json
|
|
sp_json=$(az ad sp list --all --query "[].{appDisplayName:appDisplayName,appId:appId,keyCredentials:keyCredentials,passwordCredentials:passwordCredentials}" 2>/dev/null || echo '[]')
|
|
|
|
echo "$sp_json" | jq -c '.[]? // empty' 2>/dev/null | while IFS= read -r sp; do
|
|
local app_name app_id
|
|
app_name=$(echo "$sp" | jq -r '.appDisplayName // "unnamed"' 2>/dev/null)
|
|
app_id=$(echo "$sp" | jq -r '.appId // "unknown"' 2>/dev/null)
|
|
|
|
[[ "$app_name" == "unnamed" || -z "$app_name" ]] && app_name="$app_id"
|
|
|
|
echo "$sp" | jq -c '.passwordCredentials[]? // empty' 2>/dev/null | while IFS= read -r cred; do
|
|
local end_date
|
|
end_date=$(echo "$cred" | jq -r '.endDateTime // "null"' 2>/dev/null)
|
|
|
|
local remaining status severity
|
|
remaining=$(days_until "$end_date")
|
|
|
|
if [[ "$remaining" == "unknown" ]]; then
|
|
status="Unknown"
|
|
severity="INFO"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Password" "Unknown" "$status" \
|
|
"$CYAN" "$severity" "$RESET"
|
|
flag_info
|
|
elif [[ "$remaining" -lt 0 ]]; then
|
|
status="Expired"
|
|
severity="WARN"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Password" "${end_date:0:10}" "$status" \
|
|
"$YELLOW" "$severity" "$RESET"
|
|
flag_warn
|
|
elif [[ "$remaining" -lt 30 ]]; then
|
|
status="Expiring (${remaining}d)"
|
|
severity="WARN"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Password" "${end_date:0:10}" "$status" \
|
|
"$YELLOW" "$severity" "$RESET"
|
|
flag_warn
|
|
else
|
|
verbose "SP ${app_name}: password credential valid (${remaining}d remaining)"
|
|
flag_ok
|
|
fi
|
|
done
|
|
|
|
echo "$sp" | jq -c '.keyCredentials[]? // empty' 2>/dev/null | while IFS= read -r cred; do
|
|
local end_date
|
|
end_date=$(echo "$cred" | jq -r '.endDateTime // "null"' 2>/dev/null)
|
|
|
|
local remaining status severity
|
|
remaining=$(days_until "$end_date")
|
|
|
|
if [[ "$remaining" == "unknown" ]]; then
|
|
status="Unknown"
|
|
severity="INFO"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Certificate" "Unknown" "$status" \
|
|
"$CYAN" "$severity" "$RESET"
|
|
flag_info
|
|
elif [[ "$remaining" -lt 0 ]]; then
|
|
status="Expired"
|
|
severity="WARN"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Certificate" "${end_date:0:10}" "$status" \
|
|
"$YELLOW" "$severity" "$RESET"
|
|
flag_warn
|
|
elif [[ "$remaining" -lt 30 ]]; then
|
|
status="Expiring (${remaining}d)"
|
|
severity="WARN"
|
|
printf " %-30s %-14s %-20s %-14s %b%s%b\n" \
|
|
"${app_name:0:28}" "Certificate" "${end_date:0:10}" "$status" \
|
|
"$YELLOW" "$severity" "$RESET"
|
|
flag_warn
|
|
else
|
|
verbose "SP ${app_name}: key credential valid (${remaining}d remaining)"
|
|
flag_ok
|
|
fi
|
|
done
|
|
done
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# GUEST USERS AUDIT
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
audit_guests() {
|
|
log "Auditing guest user accounts (threshold: ${STALE_DAYS} days)..."
|
|
echo ""
|
|
|
|
printf " %-36s %-20s %-20s %s\n" \
|
|
"UPN" "CREATED" "LAST_ACTIVITY" "SEVERITY"
|
|
printf " %s\n" "$(printf '%.0s─' {1..90})"
|
|
|
|
local guests_json
|
|
# shellcheck disable=SC2016
|
|
guests_json=$(az rest --method GET \
|
|
--url 'https://graph.microsoft.com/v1.0/users?$filter=userType%20eq%20%27Guest%27&$select=userPrincipalName,displayName,createdDateTime,signInActivity&$top=999' \
|
|
2>/dev/null || echo '{"value":[]}')
|
|
|
|
echo "$guests_json" | jq -c '.value[]? // empty' 2>/dev/null | while IFS= read -r guest; do
|
|
local upn created last_activity
|
|
upn=$(echo "$guest" | jq -r '.userPrincipalName // "unknown"' 2>/dev/null)
|
|
created=$(echo "$guest" | jq -r '.createdDateTime // "null"' 2>/dev/null)
|
|
last_activity=$(echo "$guest" | jq -r '.signInActivity.lastSignInDateTime // "null"' 2>/dev/null)
|
|
|
|
local created_display last_display
|
|
if [[ "$created" == "null" || -z "$created" ]]; then
|
|
created_display="Unknown"
|
|
else
|
|
created_display="${created:0:10}"
|
|
fi
|
|
|
|
if [[ "$last_activity" == "null" || -z "$last_activity" ]]; then
|
|
last_display="Never"
|
|
else
|
|
last_display="${last_activity:0:10}"
|
|
fi
|
|
|
|
local idle_days
|
|
idle_days=$(days_since "$last_activity")
|
|
|
|
if [[ "$idle_days" == "never" ]]; then
|
|
printf " %-36s %-20s %-20s %b%s%b\n" \
|
|
"${upn:0:34}" "$created_display" "$last_display" \
|
|
"$YELLOW" "WARN" "$RESET"
|
|
flag_warn
|
|
elif [[ "$idle_days" == "unknown" ]]; then
|
|
verbose "Guest ${upn}: unable to determine activity"
|
|
flag_info
|
|
elif [[ "$idle_days" -gt "$STALE_DAYS" ]]; then
|
|
printf " %-36s %-20s %-20s %b%s%b\n" \
|
|
"${upn:0:34}" "$created_display" "$last_display" \
|
|
"$YELLOW" "WARN" "$RESET"
|
|
flag_warn
|
|
else
|
|
verbose "Guest ${upn}: active (${idle_days}d idle)"
|
|
flag_ok
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# SUMMARY
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
print_summary() {
|
|
local elapsed
|
|
elapsed=$(( $(date +%s) - START_TIME ))
|
|
|
|
echo ""
|
|
echo " ══════════════════════════════════════════"
|
|
echo " Azure Entra ID Audit Summary"
|
|
echo " ══════════════════════════════════════════"
|
|
printf " %-20s %b%d%b\n" "CRITICAL:" "$RED" "$TOTAL_CRIT" "$RESET"
|
|
printf " %-20s %b%d%b\n" "WARN:" "$YELLOW" "$TOTAL_WARN" "$RESET"
|
|
printf " %-20s %b%d%b\n" "INFO:" "$CYAN" "$TOTAL_INFO" "$RESET"
|
|
printf " %-20s %b%d%b\n" "OK:" "$GREEN" "$TOTAL_OK" "$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 " • Enable MFA for all admin accounts immediately"
|
|
echo " • Review and remove stale user accounts"
|
|
echo " • Rotate expired service principal credentials"
|
|
echo " • Reduce the number of Global Administrators to ≤5"
|
|
echo " • Remove inactive guest accounts"
|
|
echo ""
|
|
elif [[ "$TOTAL_WARN" -gt 0 ]]; then
|
|
echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)"
|
|
echo ""
|
|
echo " Suggestions:"
|
|
echo " • Disable or remove stale user accounts"
|
|
echo " • Enforce MFA registration for all users"
|
|
echo " • Renew expiring service principal credentials"
|
|
echo " • Clean up inactive guest accounts"
|
|
echo ""
|
|
else
|
|
echo -e " ${GREEN}All checks passed${RESET}"
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# USAGE
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
show_help() {
|
|
cat <<EOF
|
|
${BOLD}${SCRIPT_NAME}${RESET} — Azure Entra ID Auditor
|
|
|
|
Audit Azure Entra ID (Azure AD) for stale users, MFA gaps, excessive
|
|
permissions, service principal hygiene, and guest accounts via az CLI.
|
|
|
|
${BOLD}MODES${RESET}
|
|
--full Run all audits
|
|
--stale-users Find users with no sign-in activity > N days
|
|
--mfa Check MFA registration status
|
|
--admins Audit privileged role assignments
|
|
--service-principals Audit service principal credential expiry
|
|
--guests Find stale guest accounts
|
|
|
|
${BOLD}OPTIONS${RESET}
|
|
--stale-days N Override stale threshold in days (default: 90)
|
|
--verbose Debug output
|
|
--no-color Disable colored output
|
|
--help Show this help message
|
|
|
|
${BOLD}ENVIRONMENT VARIABLES${RESET}
|
|
STALE_DAYS Days before a user is considered stale (default: 90)
|
|
VERBOSE Enable verbose output (true/false)
|
|
COLOR Color mode: auto, always, never
|
|
|
|
${BOLD}PREREQUISITES${RESET}
|
|
• Azure CLI authenticated: az login
|
|
• Microsoft Graph API permissions for sign-in activity and reports
|
|
• Reader role or higher for role assignment queries
|
|
|
|
${BOLD}EXAMPLES${RESET}
|
|
# Full audit
|
|
${SCRIPT_NAME} --full
|
|
|
|
# Check stale users only
|
|
${SCRIPT_NAME} --stale-users
|
|
|
|
# MFA audit with custom stale threshold
|
|
${SCRIPT_NAME} --mfa --stale-days 60
|
|
|
|
# Service principal credential check
|
|
${SCRIPT_NAME} --service-principals
|
|
|
|
# Guest user audit
|
|
${SCRIPT_NAME} --guests
|
|
|
|
${BOLD}EXIT CODES${RESET}
|
|
0 All checks passed
|
|
1 Warnings found (review recommended)
|
|
2 Critical findings (action required)
|
|
EOF
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
# PARSE ARGS
|
|
# ══════════════════════════════════════════════════════════════════════
|
|
parse_args() {
|
|
local modes=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--full)
|
|
modes=(stale-users mfa admins service-principals guests)
|
|
shift ;;
|
|
--stale-users)
|
|
modes+=(stale-users); shift ;;
|
|
--mfa)
|
|
modes+=(mfa); shift ;;
|
|
--admins)
|
|
modes+=(admins); shift ;;
|
|
--service-principals)
|
|
modes+=(service-principals); shift ;;
|
|
--guests)
|
|
modes+=(guests); shift ;;
|
|
--stale-days)
|
|
STALE_DAYS="${2:?--stale-days requires a value}"; shift 2 ;;
|
|
--verbose)
|
|
VERBOSE="true"; shift ;;
|
|
--no-color)
|
|
COLOR="never"; shift ;;
|
|
--help|-h)
|
|
setup_colors; show_help; exit 0 ;;
|
|
*)
|
|
die "Unknown option: $1 (see --help)" ;;
|
|
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
|
|
check_credentials
|
|
|
|
START_TIME=$(date +%s)
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Azure Entra ID Auditor${RESET}"
|
|
echo -e "Mode: ${RUN_MODE}"
|
|
echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
echo ""
|
|
|
|
for mode in $RUN_MODE; do
|
|
case "$mode" in
|
|
stale-users) audit_stale_users ;;
|
|
mfa) audit_mfa ;;
|
|
admins) audit_admins ;;
|
|
service-principals) audit_service_principals ;;
|
|
guests) audit_guests ;;
|
|
esac
|
|
done
|
|
|
|
print_summary
|
|
|
|
if [[ "$TOTAL_CRIT" -gt 0 ]]; then
|
|
exit 2
|
|
elif [[ "$TOTAL_WARN" -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|