Private/Read-AACM365Graph.ps1
|
function Read-AACM365Graph { <# .SYNOPSIS Reads what Invoke-AACM365Assessment needs from Microsoft Graph, a batch at a time, with one token, and looks up the redirect URIs' hosts in DNS. .DESCRIPTION The queries of -Query (Get-AACM365AssessmentQuery) are read in batches, one after another, each in parallel: Entra ID: tenant and policies Entra ID: admin roles, MFA and users Sign-in and audit logs Applications Microsoft 365 Intune A batch reads its queries, then those that need their answers (DependsOn): Microsoft Graph's app-role grants after its service principal; Users - not every user, which with their last sign-in takes many minutes in a large tenant, but the principals of the role assignments and the users Conditional Access excludes, by ID 15 to a query (Graph's limit for 'in'), and break-glass-like names - and those lookups again without the last sign-in (Fallback) if Graph refuses it. LegacySignIns is read once per legacy protocol, from -SignInDays ago; a query with MaxItems stops after that many (the latest audit event, the latest Secure Score). Last, every redirect URI host, in DNS (Resolve-AACHostName). -OnProgress is called with (batch, what was just read, reads done, reads in the batch so far, what is still being read, done?) - the batch's first call has nothing read yet, its last has done = $true. Returns @{ Data; Errors (as Read-AACGraphQuery's, the per-protocol and per-batch reads merged under LegacySignIns and Users); Resolved (host -> $true, $false or $null); Fallbacks (the queries answered by their Fallback) }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Query, [ValidateRange(1, 30)] [int] $SignInDays = 7, [datetime] $Now = [datetime]::UtcNow, [scriptblock] $OnProgress ) $batches = [ordered]@{ 'Entra ID: tenant and policies' = @('Organization', 'Licenses', 'SecurityDefaults', 'AuthorizationPolicy', 'AuthenticationMethods', 'CrossTenantAccess', 'ConditionalAccess', 'NamedLocations', 'AdminConsentPolicy', 'IdentityProviders', 'DirectorySync', 'PimPolicies') 'Entra ID: admin roles, MFA and users' = @('RoleDefinitions', 'RoleAssignments', 'RoleEligibility', 'Registration', 'AccessReviews', 'Users', 'Groups', 'GroupMembers') 'Sign-in and audit logs' = @('LegacySignIns', 'DirectoryAudits') 'Threat protection' = @('RiskyUsers', 'RiskDetections', 'Incidents') 'Applications' = @('Applications', 'ServicePrincipals', 'GraphServicePrincipal', 'DelegatedGrants', 'GraphAppRoleGrants') 'Microsoft 365' = @('Domains', 'SecureScore', 'SecureScoreProfiles', 'SharePoint', 'UsageReport') 'Intune' = @('DeviceManagement', 'EnrollmentRestrictions', 'ComplianceSummary', 'CompliancePolicies', 'ConfigurationPolicies', 'Intents', 'Templates', 'AppProtectionIos', 'AppProtectionAndroid', 'ManagedDevices', 'EntraDevices') } # A query in no batch (added later, and not placed above) still gets read. $placed = @($batches.Values | ForEach-Object { $_ }) $others = @($Query.Keys | Where-Object { $placed -notcontains $_ }) if ($others.Count) { $batches['Other'] = $others } $legacyProtocols = @('IMAP4', 'POP3', 'Authenticated SMTP', 'Exchange ActiveSync', 'Exchange Web Services', 'MAPI Over HTTP', 'Outlook Anywhere (RPC over HTTP)', 'Autodiscover', 'Exchange Online PowerShell', 'Other clients') $since = $Now.AddDays(-$SignInDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $quote = { param([string] $Text) "'" + ($Text -replace "'", "''") + "'" } $spec = { param($Q, [string] $Uri) if ($Q['MaxItems']) { @{ Uri = $Uri; MaxItems = $Q['MaxItems'] } } else { $Uri } } $data = @{}; $errors = @{} $fallbacks = [System.Collections.Generic.List[string]]::new() $split = [System.Collections.Generic.List[string]]::new() # keys read in parts: Name|part # The callbacks run inside Invoke-AACHttpBatch: every name they use is m365-prefixed. $m365Progress = $OnProgress $m365Labels = @{} $m365State = @{ Batch = ''; Done = 0; Total = 0; Pending = [System.Collections.Generic.List[string]]::new() } $m365Round = { param([System.Collections.IDictionary] $Reads) $m365State.Total += $Reads.Count foreach ($k in $Reads.Keys) { $m365State.Pending.Add($k) } if ($m365Progress) { & $m365Progress $m365State.Batch '' $m365State.Done $m365State.Total '' $false } $result = Read-AACGraphQuery -Query $Reads -ThrottleLimit 8 -OnDone { param($Key, $Failure, $Done, $Total) $m365State.Done++; $null = $m365State.Pending.Remove($Key) if ($m365Progress) { $waiting = @($m365State.Pending | ForEach-Object { $m365Labels[$_] } | Select-Object -Unique) & $m365Progress $m365State.Batch $m365Labels[$Key] $m365State.Done $m365State.Total ($waiting -join ', ') $false } } foreach ($k in $Reads.Keys) { if ($result.Data.Contains($k)) { $data[$k] = $result.Data[$k]; $errors.Remove($k) } else { $errors[$k] = $result.Errors[$k] } } } foreach ($batch in $batches.Keys) { $names = @($batches[$batch] | Where-Object { $Query.Contains($_) }) if (-not $names.Count) { continue } $m365State.Batch = $batch; $m365State.Done = 0; $m365State.Total = 0; $m365State.Pending.Clear() # --- What the batch reads first ------------------------------------------------------------------------------ $first = [ordered]@{} foreach ($name in $names) { $q = $Query[$name] if ($q['DependsOn']) { continue } if ($name -eq 'LegacySignIns') { foreach ($protocol in $legacyProtocols) { $key = "LegacySignIns|$protocol" $first[$key] = & $spec $q ($q.Uri -f $since, [System.Uri]::EscapeDataString($protocol)) $m365Labels[$key] = 'legacy authentication sign-ins'; $split.Add($key) } continue } $first[$name] = & $spec $q ($q.Uri.Replace('{since}', $since)) $m365Labels[$name] = ([string]$q.Data).ToLowerInvariant() } if ($first.Count) { & $m365Round $first } # --- Then what needed those answers ------------------------------------------------------------------------------ $second = [ordered]@{} $lookups = [ordered]@{} # Users part -> its $filter foreach ($name in $names) { $q = $Query[$name] if (-not $q['DependsOn']) { continue } if ($name -eq 'GroupMembers') { continue } # after the groups are known, below if ($q['Lookup']) { # Users: the principals of the role assignments, and whom Conditional Access # excludes. Groups: the groups it excludes, and any role principal that is one. $principals = @(@($data['RoleAssignments']) + @($data['RoleEligibility']) | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { [string]$_['principalId'] }) $policyUsers = @(@($data['ConditionalAccess']) | Where-Object { $_ -is [System.Collections.IDictionary] -and $_['conditions'] -is [System.Collections.IDictionary] -and $_['conditions']['users'] -is [System.Collections.IDictionary] } | ForEach-Object { $_['conditions']['users'] }) $ids = if ($name -eq 'Groups') { @($principals) + @($policyUsers | ForEach-Object { @($_['excludeGroups']) }) } else { @($principals) + @($policyUsers | ForEach-Object { @($_['excludeUsers']) }) } $ids = @($ids | Where-Object { $_ -and $_ -notin 'All', 'None', 'GuestsOrExternalUsers' -and $_ -notmatch '\s' } | ForEach-Object { ([string]$_).ToLowerInvariant() } | Select-Object -Unique) $mine = [ordered]@{} for ($i = 0; $i -lt $ids.Count; $i += 15) { $mine["$name|ids$i"] = "id in ($((@($ids[$i..([Math]::Min($i + 14, $ids.Count - 1))] | ForEach-Object { & $quote $_ })) -join ','))" } if ($name -eq 'Users') { $mine["$name|names"] = (@(foreach ($field in 'displayName', 'userPrincipalName') { foreach ($prefix in 'break', 'emergency', 'bg-', 'bga') { "startswith($field,$(& $quote $prefix))" } }) -join ' or ') } if (-not $mine.Count) { # Nothing to look up: none, or because what names them couldn't be read. $sources = @('RoleAssignments', 'RoleEligibility', 'ConditionalAccess' | Where-Object { $names -contains $_ }) if ($sources.Count -and -not @($sources | Where-Object { $data.Contains($_) }).Count) { $errors[$name] = "Not read: it needs $($sources -join ' or '), which couldn't be read." } else { $data[$name] = @() } continue } foreach ($key in $mine.Keys) { $lookups[$key] = $mine[$key]; $second[$key] = ($q.Uri -f [System.Uri]::EscapeDataString($mine[$key])); $m365Labels[$key] = ([string]$q.Data).ToLowerInvariant(); $split.Add($key) } continue } $parent = $data[$q.DependsOn] $id = if ($parent -is [System.Collections.IDictionary]) { [string]$parent['id'] } else { '' } if ($id) { $second[$name] = & $spec $q ($q.Uri -f $id); $m365Labels[$name] = ([string]$q.Data).ToLowerInvariant() } else { $errors[$name] = "Not read: it needs $($q.DependsOn), which couldn't be read." } } if ($second.Count) { & $m365Round $second } # --- And the lookups Graph refused, without what needs a licence; the groups' members ---------------------------------- $third = [ordered]@{} foreach ($key in $lookups.Keys) { $name = $key.Split('|')[0] if ($errors.Contains($key) -and $Query[$name]['Fallback']) { $third[$key] = ($Query[$name].Fallback -f [System.Uri]::EscapeDataString($lookups[$key])) } } if ($names -contains 'GroupMembers') { $q = $Query['GroupMembers'] $groupIds = @($split | Where-Object { $_ -like 'Groups|*' -and $data.Contains($_) } | ForEach-Object { @($data[$_]) } | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { [string]$_['id'] } | Select-Object -Unique) # At most 30 groups: the ones that matter are a handful; a tenant that excludes more has a finding anyway. foreach ($groupId in @($groupIds | Select-Object -First 30)) { $key = "GroupMembers|$groupId" $third[$key] = & $spec $q ($q.Uri -f $groupId); $m365Labels[$key] = ([string]$q.Data).ToLowerInvariant(); $split.Add($key) } $groupParts = @($split | Where-Object { $_ -like 'Groups|*' }) if ($groupParts.Count -and -not @($groupParts | Where-Object { $data.Contains($_) }).Count) { $errors['GroupMembers'] = 'Not read: it needs the groups, which couldn''t be read.' } elseif ($errors.Contains('Groups')) { $errors['GroupMembers'] = 'Not read: it needs the groups, which couldn''t be read.' } elseif (-not $groupIds.Count) { $data['GroupMembers'] = @() } } if ($third.Count) { & $m365Round $third foreach ($key in $third.Keys) { if ($data.Contains($key) -and $lookups.Contains($key)) { $name = $key.Split('|')[0]; if (-not $fallbacks.Contains($name)) { $fallbacks.Add($name) } } } } if ($m365Progress) { & $m365Progress $batch '' $m365State.Done $m365State.Total '' $true } } # --- One name per query: the per-protocol and per-batch parts merged ---------------------------------------------------------- foreach ($name in @($split | ForEach-Object { $_.Split('|')[0] } | Select-Object -Unique)) { $parts = @($split | Where-Object { $_ -like "$name|*" } | Select-Object -Unique) $readParts = @($parts | Where-Object { $data.Contains($_) }) if ($readParts.Count) { $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $data[$name] = @(foreach ($part in $readParts) { foreach ($item in @($data[$part])) { # A group's members: each tagged with its group (a user may be in several). if ($name -eq 'GroupMembers') { if ($item -is [System.Collections.IDictionary]) { $item['_groupId'] = $part.Split('|')[1] }; $item; continue } if ($item -is [System.Collections.IDictionary] -and $item['id'] -and -not $seen.Add([string]$item['id'])) { continue } $item } }) $errors.Remove($name) } else { $errors[$name] = [string](@($parts | ForEach-Object { $errors[$_] } | Where-Object { $_ }) | Select-Object -First 1) } foreach ($part in $parts) { $data.Remove($part); $errors.Remove($part) } } # --- Redirect URI hosts, in DNS ----------------------------------------------------------------------------------------------- $hosts = @(foreach ($app in @($data['Applications'])) { if ($app -isnot [System.Collections.IDictionary]) { continue } foreach ($kind in 'web', 'spa', 'publicClient') { foreach ($uri in @($app[$kind] | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { $_['redirectUris'] })) { $parsed = $null if ([System.Uri]::TryCreate([string]$uri, [System.UriKind]::Absolute, [ref]$parsed) -and $parsed.Scheme -in 'http', 'https' -and $parsed.Host -and $parsed.Host -notmatch '^(localhost|127\.|\[::1\])' -and $parsed.Host -notmatch '\*') { $parsed.Host } } } }) if ($hosts.Count -and $m365Progress) { & $m365Progress 'Redirect URIs in DNS' '' 0 ($hosts | Select-Object -Unique).Count 'DNS lookups' $false } $resolved = Resolve-AACHostName -HostName $hosts if ($hosts.Count -and $m365Progress) { & $m365Progress 'Redirect URIs in DNS' '' $resolved.Count $resolved.Count '' $true } @{ Data = $data; Errors = $errors; Resolved = $resolved; Fallbacks = $fallbacks.ToArray() } } |