Private/Invoke-RaidinessGraphCollect.ps1
|
<# The Microsoft Graph collection step of Invoke-Raidiness, on its own so the orchestrator above it stays readable. Walks collect.manifest.json and issues only GET requests; see Invoke-Raidiness for the evidence shapes. Progress uses three nested bars: the caller owns id 1 (the phases of the run), this function owns id 2 (collector n of 52) and id 3 (the evidence item, and the page inside a paged one). Without id 3 a tenant with 40 000 sign-ins looks exactly like a hang. Skips are objects rather than sentences now. They used to be strings that Invoke-Raidiness reduced to a count, which threw away the only answer to "why is this not measured?" -- they are returned, logged, and written to the run folder. #> # psrunner-lint allow: New-Item — creates the local output directory for evidence files; never a tenant object # psrunner-lint allow: Remove-Item — deletes the local temp file a CSV report was downloaded to; never a tenant object function Invoke-RaidinessGraphCollect { [CmdletBinding()] param( [string] $OutputPath = './raidiness-data', [switch] $IncludeSlow, [string[]] $Collector, [switch] $Quiet, # Microsoft Graph SDK's per-request client timeout. This is separate # from the browser/report timeout exposed by the checkup command. [ValidateRange(1, 2147483647)] [int] $GraphRequestTimeoutSeconds = 100, # A runaway @odata.nextLink chain is the most likely way a collection # hangs. Stop, record it, and let the checks read a truncated source # as what it is rather than following the link forever. [int] $MaxPage = 200 ) $ErrorActionPreference = 'Stop' if (-not (Get-MgContext)) { throw 'Not connected to Microsoft Graph. Run Connect-Raidiness first.' } $manifestPath = Join-Path $PSScriptRoot '..' 'collect.manifest.json' $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json $grantedScopes = @((Get-MgContext).Scopes) New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null $collected = 0 $skipped = [System.Collections.Generic.List[object]]::new() $notes = [System.Collections.Generic.List[string]]::new() $activity = 'Raidiness: collecting from Microsoft Graph' $entries = @($manifest.collectors | Where-Object { -not $Collector -or $_.key -in $Collector }) $total = [math]::Max($entries.Count, 1) $position = 0 $started = Get-Date # ClientTimeout is process-wide SDK context, rather than an argument on # Invoke-MgGraphRequest. Only change it when this SDK version exposes the # request-context commands, and put the caller's value back below. $restoreRequestTimeout = $false $previousRequestTimeout = $null $getRequestContext = Get-Command -Name Get-MgRequestContext -ErrorAction SilentlyContinue $setRequestContext = Get-Command -Name Set-MgRequestContext -ErrorAction SilentlyContinue if ($getRequestContext -and $setRequestContext -and $setRequestContext.Parameters.ContainsKey('ClientTimeout')) { $requestContext = Get-MgRequestContext if ($requestContext -and $requestContext.PSObject.Properties['ClientTimeout']) { $previousRequestTimeout = $requestContext.ClientTimeout $clientTimeoutType = $setRequestContext.Parameters['ClientTimeout'].ParameterType $configuredRequestTimeout = ConvertTo-RaidinessGraphClientTimeout ` -Value $GraphRequestTimeoutSeconds -ParameterType $clientTimeoutType Set-MgRequestContext -ClientTimeout $configuredRequestTimeout | Out-Null $restoreRequestTimeout = $true } } Write-RaidinessLog -Phase 'collect' -Message "$($entries.Count) collector(s) selected" try { foreach ($entry in $entries) { $position++ $elapsed = ((Get-Date) - $started).TotalSeconds $remaining = $position -gt 1 ? [int]($elapsed / ($position - 1) * ($total - $position + 1)) : -1 Write-RaidinessProgress -Id 2 -ParentId 1 -Quiet:$Quiet -Activity $activity ` -Status "$($entry.key) ($position of $total)" ` -PercentComplete ([int](100 * ($position - 1) / $total)) ` -SecondsRemaining $remaining $requiredScopes = @($entry.evidence.permission | Where-Object { $_ } | Sort-Object -Unique) $missingScopes = @($requiredScopes | Where-Object { $_ -notin $grantedScopes }) if ($missingScopes.Count -gt 0) { $groups = @($entry.optionalScopeGroups) -join ', ' $reason = "missing delegated scope(s): $($missingScopes -join ', ')" if ($groups) { $reason += "; reconnect with Connect-Raidiness -OptionalScopeGroup $groups" } $skipped.Add([pscustomobject]@{ Key = $entry.key; Kind = 'scope'; Endpoint = $null; Reason = $reason }) Write-RaidinessLog -Phase 'collect' -Source $entry.key -Message "skipped: $reason" continue } if ((Get-Member -InputObject $entry -Name 'slow') -and $entry.slow -and -not $IncludeSlow) { # Silent before: the operator could not tell a slow-skip from a # collector that was never in the manifest. $skipped.Add([pscustomobject]@{ Key = $entry.key; Kind = 'slow'; Endpoint = $null; Reason = 'marked slow in the manifest; re-run with -IncludeSlow' }) Write-RaidinessLog -Phase 'collect' -Source $entry.key -Message 'skipped: marked slow (use -IncludeSlow)' continue } $items = @() $failed = $null $failedKind = 'error' $evidenceCount = @($entry.evidence).Count $evidenceIndex = 0 $collectorStarted = Get-Date foreach ($evidence in $entry.evidence) { $evidenceIndex++ Write-RaidinessProgress -Id 3 -ParentId 2 -Quiet:$Quiet -Activity $entry.key ` -Status $evidence.endpoint ` -PercentComplete ([int](100 * ($evidenceIndex - 1) / [math]::Max($evidenceCount, 1))) try { $headers = @{} if ($evidence.endpoint -match '\$count|\$search') { $headers.ConsistencyLevel = 'eventual' } $item = [ordered]@{ endpoint = $evidence.endpoint method = $evidence.method capturedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } if (Get-Member -InputObject $evidence -Name 'permission') { $item.permission = $evidence.permission } switch ($evidence.kind) { 'paged' { $rows = [System.Collections.Generic.List[object]]::new() $next = $evidence.endpoint $page = 0 while ($next) { $page++ if ($page -gt $MaxPage) { $note = "$($entry.key): stopped after $MaxPage pages ($($rows.Count) rows); the source is truncated" $notes.Add($note) $skipped.Add([pscustomobject]@{ Key = $entry.key; Kind = 'truncated'; Endpoint = $evidence.endpoint; Reason = "more than $MaxPage pages" }) Write-RaidinessLog -Level warn -Phase 'collect' -Source $entry.key -Message $note break } Write-RaidinessProgress -Id 3 -ParentId 2 -Quiet:$Quiet -Activity $entry.key ` -Status "$($evidence.endpoint) — page $page, $($rows.Count) rows" $response = Invoke-MgGraphRequest -Method GET -Uri $next -Headers $headers -OutputType PSObject if ($null -ne $response.PSObject.Properties['value']) { $rows.AddRange(@($response.value)) } $next = $response.PSObject.Properties['@odata.nextLink'] ? $response.'@odata.nextLink' : $null } $item.payload = $rows.ToArray() } 'single' { $item.payload = Invoke-MgGraphRequest -Method GET -Uri $evidence.endpoint -Headers $headers -OutputType PSObject } 'csv' { Write-RaidinessProgress -Id 3 -ParentId 2 -Quiet:$Quiet -Activity $entry.key -Status "$($evidence.endpoint) — downloading report CSV" $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("raidiness-" + [guid]::NewGuid() + '.csv') try { Invoke-MgGraphRequest -Method GET -Uri $evidence.endpoint -OutputFilePath $temporary $rows = @(Import-Csv -Path $temporary) $item.payload = $rows if ($rows.Count -gt 0 -and $rows[0].PSObject.Properties['Report Refresh Date']) { $item.reportRefreshDate = $rows[0].'Report Refresh Date' } } finally { Remove-Item -Path $temporary -ErrorAction SilentlyContinue } } } $items += [pscustomobject]$item } catch { $exception = $_.Exception $isTimeout = $false while ($exception) { if ($exception -is [System.TimeoutException] -or $exception -is [System.Threading.Tasks.TaskCanceledException] -or $exception.Message -match '(?i)timed?\s*out|timeout|operation (was )?cancell?ed') { $isTimeout = $true break } $exception = $exception.InnerException } if ($isTimeout) { $failedKind = 'timeout' $failed = "request timed out after $GraphRequestTimeoutSeconds seconds" } else { $failed = $_.Exception.Message } break } } if ($failed) { # A partially collected file could mislead measure(); leave the # whole collector out and let its checks degrade to "not measured". $skip = [ordered]@{ Key = $entry.key; Kind = $failedKind; Endpoint = $evidence.endpoint; Reason = $failed } if ($failedKind -eq 'timeout') { $skip.TimeoutSeconds = $GraphRequestTimeoutSeconds } $skipped.Add([pscustomobject]$skip) Write-RaidinessLog -Level warn -Phase 'collect' -Source $entry.key -Message "skipped: $failed" -Data @{ endpoint = $evidence.endpoint } Write-Warning "Skipped $($entry.key): $failed" continue } $file = Join-Path $OutputPath "$($entry.key).json" ConvertTo-Json -InputObject $items -Depth 48 -Compress | Out-File -FilePath $file -Encoding utf8 $collected++ $rowCount = @($items | ForEach-Object { @($_.payload).Count } | Measure-Object -Sum).Sum Write-RaidinessLog -Phase 'collect' -Source $entry.key -Message "$rowCount row(s) from $evidenceCount endpoint(s)" -DurationMs ((Get-Date) - $collectorStarted).TotalMilliseconds if (-not $Quiet) { Write-Host " collected $($entry.key)" -ForegroundColor DarkGray } # The bar reaches 100 only if it is also written after the work; it # used to report ($position - 1) and stop one collector short. Write-RaidinessProgress -Id 2 -ParentId 1 -Quiet:$Quiet -Activity $activity -Status $entry.key -PercentComplete ([int](100 * $position / $total)) } } finally { if ($restoreRequestTimeout) { # Graph SDK releases disagree about this property's representation: # Get-MgRequestContext can return a TimeSpan while Set-MgRequestContext # accepts an integer number of seconds. Normalize it before restoring # so successful evidence is not discarded by a parameter-binding error. $restoreTimeout = ConvertTo-RaidinessGraphClientTimeout ` -Value $previousRequestTimeout -ParameterType $clientTimeoutType Set-MgRequestContext -ClientTimeout $restoreTimeout | Out-Null } Write-RaidinessProgress -Id 3 -Quiet:$Quiet -Activity 'done' -Completed Write-RaidinessProgress -Id 2 -Quiet:$Quiet -Activity $activity -Completed } [pscustomobject]@{ Collected = $collected; Skipped = $skipped.ToArray(); Notes = $notes.ToArray() } } |