- tenant-sweep.sh line 12: renamed tier `graph` to `investigator` to match the valid tier name expected by get-token.sh - tenants.md: updated Kittle Design & Construction consent status from NO to PARTIAL with notes on what was consented and what remains pending Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
83 lines
5.1 KiB
Bash
Executable File
83 lines
5.1 KiB
Bash
Executable File
#!/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 <tenant-id-or-domain>
|
|
# 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|domain>}"
|
|
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"
|