#!/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 < 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 "$@"