#!/usr/bin/env bash # AI Exposure Check for Microsoft Entra -- Azure Cloud Shell edition. # # STATUS: not yet run inside Azure Cloud Shell itself. It has run read-only # against a real tenant through a laptop's Azure CLI (the same `az rest`), and # tests/check.test.mjs checks its numbers match the browser check's on the # sample tenant. # # What it does: read-only GETs against Microsoft Graph using your own Azure CLI # sign-in (no app to consent, nothing installed), then prints the same headline # numbers as the browser check. Nothing is sent anywhere except Microsoft Graph, # and the downloaded JSON is deleted when the script exits. # # Run it in https://shell.azure.com (Bash): # bash barndoor-ai-exposure-check.sh # Needs: az (signed in, as Cloud Shell is), jq. Your account needs a directory # role that can read applications and consent grants, e.g. Global Reader. # The agent identity section also needs a role that can read Entra Agent ID # objects; if that read is refused the script falls back to the service # principal list, the same way the browser page does. set -euo pipefail GRAPH="https://graph.microsoft.com/v1.0" DAYS=90 # Every AI_VENDORS pattern in src/vendors.js, joined with "|" (build.mjs fails # if the two drift). Matched case-insensitively against app name, app display # name and verified publisher. AI_PATTERN='\bopenai\b|chatgpt|\bcodex\b|anthropic|\bclaude\b|gemini|notebooklm|copilot|power virtual agents|perplexity|^cursor\b|anysphere|windsurf|codeium|\bglean\b|zapier|\bn8n\b|\bmake\.com\b|integromat|^make$|\bmistral\b|\bcohere\b|hugging ?face|^poe\b|otter\.ai|^otter\b|fireflies|\bread[ .]?ai\b|\bfathom\b|\bgranola\b|\bjasper\b|^writer\b|copy\.ai|\blindy\b|relevance ?ai|^dust\b|bardeen|\breplit\b|\blovable\b|\bdevin\b|cognition ai|^gamma\b|elevenlabs|\bmcp\b|model context protocol|\bai agent\b|\bgpt\b|\bllm\b' for bin in az jq; do command -v "$bin" >/dev/null || { echo "error: $bin not found (run this in Azure Cloud Shell)" >&2; exit 1; } done WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT # get_all URL OUTFILE: follow @odata.nextLink until the last page, writing one # JSON object per line. az rest retries nothing, so back off on 429 ourselves. get_all() { local url="$1" out="$2" page attempt : >"$out" while [[ -n "$url" ]]; do attempt=0 until page="$(az rest --method GET --url "$url" --output json 2>"$WORK/err")"; do if grep -qi 'Too Many Requests' "$WORK/err" && ((attempt < 5)); then attempt=$((attempt + 1)) sleep $((2 ** attempt)) continue fi cat "$WORK/err" >&2 return 1 done jq -c '.value[]' <<<"$page" >>"$out" url="$(jq -r '."@odata.nextLink" // empty' <<<"$page")" printf '\r %-40s %6d' "$3" "$(wc -l <"$out")" >&2 done printf '\n' >&2 } TENANT_ID="$(az account show --query tenantId --output tsv)" echo "Reading tenant $TENANT_ID (read-only)..." >&2 get_all "$GRAPH/servicePrincipals?\$select=id,appId,displayName,appDisplayName,appOwnerOrganizationId,servicePrincipalType,verifiedPublisher,accountEnabled&\$top=999" \ "$WORK/sp.ndjson" "Enterprise apps / service principals" get_all "$GRAPH/oauth2PermissionGrants" "$WORK/grants.ndjson" "Delegated permission grants" AGENT_SOURCE="agentIdentity endpoint" if ! get_all "$GRAPH/servicePrincipals/microsoft.graph.agentIdentity?\$select=id,displayName,createdDateTime,agentIdentityBlueprintId,accountEnabled&\$top=100" \ "$WORK/agents.ndjson" "Agent identities" 2>"$WORK/agent-err"; then AGENT_SOURCE="service principal list (agent endpoint refused: $(grep -oE '[A-Za-z_]+RequestDenied|Forbidden|[0-9]{3}' "$WORK/agent-err" | head -1 || true))" rm -f "$WORK/agents.ndjson" fi get_all "$GRAPH/applications?\$select=id,appId,displayName,createdDateTime,signInAudience&\$top=999" \ "$WORK/apps.ndjson" "App registrations" [[ -f "$WORK/agents.ndjson" ]] || echo "null" >"$WORK/agents.ndjson" jq -n -r \ --arg tenant "$TENANT_ID" \ --arg ai "$AI_PATTERN" \ --arg agentSource "$AGENT_SOURCE" \ --argjson days "$DAYS" \ --slurpfile sp "$WORK/sp.ndjson" \ --slurpfile grants "$WORK/grants.ndjson" \ --slurpfile agentsRaw "$WORK/agents.ndjson" \ --slurpfile apps "$WORK/apps.ndjson" ' def scopes: (.scope // "") | split(" ") | map(select(length > 0)); def high_risk: test("^(Mail\\.|Files\\..*\\.All$|Sites\\.|Chat\\.|EWS\\.AccessAsUser\\.All$|full_access_as_user$|AllSites\\.)"; "i"); def data_access: test("^(Mail\\.|Files\\.|Sites\\.|EWS\\.AccessAsUser\\.All$|full_access_as_user$|AllSites\\.|MyFiles\\.)"; "i"); def is_ai: [.displayName, .appDisplayName, .verifiedPublisher.displayName] | map(select(. != null and . != "")) | any(test($ai; "i")); (now - $days * 86400) as $cutoff | ($sp | map({key: .id, value: .}) | from_entries) as $byId | (if $agentsRaw == [null] then $sp | map(select(."@odata.type" == "#microsoft.graph.agentIdentity" or .servicePrincipalType == "ServiceIdentity")) else $agentsRaw end) as $agents | ($grants | map(. + {s: scopes})) as $g | ($g | map(.clientId) | unique | map(select($byId[.] // {} | is_ai))) as $aiIds | ($g | map(select(.consentType != "AllPrincipals" and .principalId != null and any(.s[]; data_access))) | map(.principalId) | unique | length) as $dataUsers | ($g | map(select(.consentType == "AllPrincipals" and any(.s[]; data_access))) | map(.clientId) | unique | length) as $tenantWideData | ($g | map(select(any(.s[]; high_risk)))) as $risky | ($risky | length) as $highRisk | ($risky | map(select(any(.s[]; ascii_downcase == "offline_access"))) | length) as $highRiskPersistent | ($apps | map(select(.createdDateTime != null and ((.createdDateTime | sub("\\.[0-9]+"; "") | fromdateiso8601) >= $cutoff)))) as $newApps # The service principal list has no creation dates, so in fallback mode the # recent count is unknown rather than zero. | (if $agentsRaw == [null] then null else $agents | map(select(.createdDateTime != null and ((.createdDateTime | sub("\\.[0-9]+"; "") | fromdateiso8601) >= $cutoff))) | length end) as $agentsRecent | ($g | map(select(.clientId as $c | $aiIds | index($c)) | .principalId) | map(select(. != null)) | unique | length) as $aiUsers | { agents: ($agents | length), agentsRecent: $agentsRecent, aiApps: ($aiIds | length), aiUsers: $aiUsers, dataUsers: $dataUsers, tenantWideData: $tenantWideData, highRisk: $highRisk, highRiskPersistent: $highRiskPersistent, grants: ($g | length), newApps: ($newApps | length), servicePrincipals: ($sp | length), aiRows: ($aiIds | map(. as $id | { name: ($byId[$id].displayName // $id), publisher: ($byId[$id].verifiedPublisher.displayName // ""), users: ($g | map(select(.clientId == $id and .principalId != null)) | map(.principalId) | unique | length), tenantWide: ($g | any(.clientId == $id and .consentType == "AllPrincipals")), risky: ($g | map(select(.clientId == $id)) | map(.s[]) | unique | map(select(high_risk)) | join(" ")) }) | sort_by(-.users)) } ' >"$WORK/result.json" jq -r --arg tenant "$TENANT_ID" --arg agentSource "$AGENT_SOURCE" --argjson days "$DAYS" ' "", "AI EXPOSURE CHECK tenant \($tenant)", "============================================================", "\(.agents) agents · \(.aiApps) AI apps · \(.dataUsers) users granted mailbox/file access", (if .tenantWideData > 0 then " ...plus \(.tenantWideData) app(s) an admin approved for EVERYONE with mailbox/file scopes" else empty end), "", "Agent identities \(.agents) (\(if .agentsRecent == null then "creation dates unavailable" else "\(.agentsRecent) created in the last \($days) days" end); source: \($agentSource))", "AI apps with delegated grants \(.aiApps) (\(.aiUsers) users consented)", "Users with mailbox/file scope \(.dataUsers)", "High-risk grants \(.highRisk) of \(.grants) (Mail.*, Files.*.All, Sites.*, Chat.*; \(.highRiskPersistent) also hold offline_access)", "New app registrations \(.newApps) (last \($days) days; \(.servicePrincipals) service principals in total)", "", "AI APPS" ' "$WORK/result.json" jq -r '.aiRows[] | [" " + .name, (if .tenantWide then "ALL USERS" else "\(.users) users" end), (if .publisher == "" then "(unverified)" else .publisher end), (if .risky == "" then "-" else .risky end)] | @tsv' \ "$WORK/result.json" | column -t -s $'\t' cat <<'EOF' Not visible from Entra: local MCP servers, pasted API keys, personal accounts, and agents acting with a user's own token. EOF