Files
linux-scripts/azure-ad-audit.sh
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
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.
2026-05-25 03:31:08 +02:00

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 "$@"