#!/usr/bin/env bash # Tenant-wide signals sweep: failed sign-ins, foreign successful sign-ins, directory audits, # risky users, B2B guest invites, per-user location profile. # Usage: tenant-sweep.sh # Writes raw JSON to /tmp/remediation-tool/{tenant-id}/sweep/ # Prints a priority summary to stdout. set -euo pipefail SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" TENANT_INPUT="${1:?usage: tenant-sweep.sh }" TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT") GT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" investigator) OUT="/tmp/remediation-tool/$TENANT_ID/sweep" mkdir -p "$OUT" FROM=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ) echo "[info] tenant=$TENANT_ID window=30d from=$FROM" # Enabled users list curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/users?\$top=999&\$filter=accountEnabled%20eq%20true&\$select=id,displayName,userPrincipalName,accountEnabled,userType,externalUserState,lastPasswordChangeDateTime,createdDateTime" \ > "$OUT/users.json" & # Failed sign-ins tenant-wide curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=(createdDateTime%20ge%20${FROM})%20and%20(status/errorCode%20ne%200)&\$top=999" \ > "$OUT/failed_signins.json" & # Successful sign-ins tenant-wide (to find non-US) curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=(createdDateTime%20ge%20${FROM})%20and%20(status/errorCode%20eq%200)&\$top=999" \ > "$OUT/success_signins.json" & # Directory audits, filtered by risky activity names curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDateTime%20ge%20${FROM}&\$top=999" \ > "$OUT/dir_audits.json" & # Risky users (may 403 if IdentityRiskyUser scope absent) curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers?\$top=100" \ > "$OUT/risky_users.json" & # B2B guest invites curl -s -H "Authorization: Bearer $GT" \ "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\$filter=activityDateTime%20ge%20${FROM}%20and%20activityDisplayName%20eq%20'Invite%20external%20user'&\$top=100" \ > "$OUT/guest_invites.json" & wait echo "" echo "=== Priority 1: accounts with foreign failed sign-ins (credential stuffing candidates) ===" jq '[.value[] | select(.location.countryOrRegion != "US" and .location.countryOrRegion != null) | {user: .userPrincipalName, ip: .ipAddress, country: .location.countryOrRegion, city: .location.city, t: .createdDateTime, err: .status.errorCode, fail: .status.failureReason}] | group_by(.user) | map({user: .[0].user, attempts: length, unique_ips: ([.[]|.ip]|unique|length), countries: ([.[]|.country]|unique), first: ([.[]|.t]|min), last: ([.[]|.t]|max)}) | sort_by(-.attempts)' "$OUT/failed_signins.json" echo "" echo "=== Priority 2: successful sign-ins from non-US (suspicious) ===" jq '[.value[] | select(.location.countryOrRegion != "US" and .location.countryOrRegion != null) | {user: .userPrincipalName, ip: .ipAddress, country: .location.countryOrRegion, city: .location.city, t: .createdDateTime, app: .appDisplayName, clientApp: .clientAppUsed}] | sort_by(.t) | reverse | .[:30]' "$OUT/success_signins.json" echo "" echo "=== Priority 3: B2B guest invites (30d) ===" jq '[.value[] | {t: .activityDateTime, by: (.initiatedBy.user.userPrincipalName // .initiatedBy.app.displayName), target: [.targetResources[]?|{name: .displayName, upn: .userPrincipalName}], result: .result}] | sort_by(.t) | reverse' "$OUT/guest_invites.json" echo "" echo "=== Priority 4: directory audit - consent/role/auth-method changes ===" jq '[.value[] | select(.activityDisplayName | test("[Cc]onsent|[Aa]uthentication [Mm]ethod|Add service principal|Add delegated permission grant|Add app role|Add member to role"; "")) | {t: .activityDateTime, act: .activityDisplayName, by: (.initiatedBy.user.userPrincipalName // .initiatedBy.app.displayName // "system"), target: [.targetResources[]?|{type: .type, name: .displayName, upn: .userPrincipalName}], result: .result}] | sort_by(.t) | reverse | .[:50]' "$OUT/dir_audits.json" echo "" echo "=== Risky users (if Identity Protection accessible) ===" if jq -e '.error' "$OUT/risky_users.json" >/dev/null 2>&1; then echo "BLOCKED: $(jq -r '.error.code // "?"' "$OUT/risky_users.json") — $(jq -r '.error.message // ""' "$OUT/risky_users.json")" echo "(Check references/gotchas.md for how to unblock IdentityRiskyUser scope)" else jq '[.value[] | {upn: .userPrincipalName, level: .riskLevel, state: .riskState, detail: .riskDetail, lastUpdated: .riskLastUpdatedDateTime}]' "$OUT/risky_users.json" fi echo "" echo "=== User locations profile (successful sign-ins) ===" jq '[.value[] | {user: .userPrincipalName, country: .location.countryOrRegion, city: .location.city}] | unique | group_by(.user) | map({user: .[0].user, locations: [.[]|{country, city}]|unique})' "$OUT/success_signins.json" echo "" echo "[info] Enabled users in tenant: $(jq '.value | length' "$OUT/users.json")" echo "[info] raw artifacts: $OUT"