src/collect/Get-ScoutOktaEvidence.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-ScoutOktaEvidence { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateScript({ $parsed = $null [uri]::TryCreate($_, [System.UriKind]::Absolute, [ref]$parsed) -and $parsed.Scheme -eq 'https' })] [string] $OrganizationUrl, [Parameter(Mandatory)] [securestring] $ApiToken ) $baseUri = $OrganizationUrl.TrimEnd('/') $plainToken = ConvertFrom-SecureString -SecureString $ApiToken -AsPlainText $headers = @{ Authorization = "SSWS $plainToken"; Accept = 'application/json' } $resources = [System.Collections.Generic.List[object]]::new() $sourceOperations = [System.Collections.Generic.List[object]]::new() $collectionHealth = [System.Collections.Generic.List[object]]::new() function Get-OktaValue { param([AllowNull()][object] $InputObject, [Parameter(Mandatory)][string] $Path) $current = $InputObject foreach ($segment in $Path.Split('.')) { if ($null -eq $current) { return $null } if ($current -is [System.Collections.IDictionary]) { $key = @($current.Keys | Where-Object { [string]$_ -ieq $segment } | Select-Object -First 1) if ($key.Count -eq 0) { return $null } $current = $current[$key[0]] } else { $property = @($current.PSObject.Properties | Where-Object Name -ieq $segment | Select-Object -First 1) if ($property.Count -eq 0) { return $null } $current = $property[0].Value } } return $current } function Add-OktaEnvelope { param([string] $Dataset, [object] $Raw, [string] $Uri, [datetime] $CollectedAt, [string] $ParentId) $rawId = [string](Get-OktaValue $Raw 'id') if ([string]::IsNullOrWhiteSpace($rawId)) { $rawId = "$Dataset-$($resources.Count + 1)" } $rawName = [string](Get-OktaValue $Raw 'name') if ([string]::IsNullOrWhiteSpace($rawName)) { $rawName = [string](Get-OktaValue $Raw 'label') } if ([string]::IsNullOrWhiteSpace($rawName)) { $rawName = $rawId } $resources.Add([pscustomobject][ordered]@{ id = "/providers/AzureScout/okta/$Dataset/$([uri]::EscapeDataString($rawId))" name = $rawName type = "AZSC/Okta/$Dataset" PARENTID = $ParentId properties = [pscustomobject][ordered]@{ Raw = $Raw } AZSC = [pscustomobject][ordered]@{ Source = 'Okta Management API'; Dataset = $Dataset; Operation = 'GET'; Uri = $Uri Permission = 'Read-only Okta administrator or OAuth read scopes'; CollectedAt = $CollectedAt.ToString('o') } }) } function Get-OktaNextLink { param([AllowNull()][object] $Response) if ($null -eq $Response -or -not $Response.PSObject.Properties['Headers']) { return $null } $link = $Response.Headers['Link'] foreach ($part in @([string]$link -split ',')) { if ($part -match '<([^>]+)>\s*;\s*rel="next"') { return $Matches[1] } } return $null } function Invoke-OktaRead { param( [Parameter(Mandatory)][string] $Dataset, [Parameter(Mandatory)][string] $RelativeUri, [string] $ParentId, [switch] $Optional ) $items = [System.Collections.Generic.List[object]]::new() $currentUri = if ([uri]::IsWellFormedUriString($RelativeUri, [System.UriKind]::Absolute)) { $RelativeUri } else { "$baseUri$RelativeUri" } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) while ($currentUri) { if (-not $seen.Add($currentUri)) { throw "Okta returned a repeated next link for $Dataset." } $startedAt = Get-Date try { $response = Invoke-WebRequest -Uri $currentUri -Method GET -Headers $headers -ErrorAction Stop $statusCode = [int]$response.StatusCode if ($statusCode -lt 200 -or $statusCode -ge 300) { throw "Okta returned HTTP $statusCode." } $content = if ([string]::IsNullOrWhiteSpace([string]$response.Content)) { $null } else { $response.Content | ConvertFrom-Json } [object[]]$pageItems = @() if ($null -ne $content) { if ($content -is [System.Collections.IEnumerable] -and $content -isnot [string] -and $content -isnot [System.Collections.IDictionary] -and $content -isnot [pscustomobject]) { $pageItems = @($content | Where-Object { $null -ne $_ }) } else { $pageItems = @($content) } } $collectedAt = Get-Date foreach ($item in $pageItems) { $items.Add($item) Add-OktaEnvelope -Dataset $Dataset -Raw $item -Uri $currentUri -CollectedAt $collectedAt -ParentId $ParentId } $sourceOperations.Add([pscustomobject][ordered]@{ Source='Okta Management API'; Dataset=$Dataset; Operation='GET'; Uri=$currentUri Status=if($pageItems.Count){'Success'}else{'Empty'}; Count=$pageItems.Count; Reason=$null StartedAt=$startedAt.ToString('o'); CompletedAt=$collectedAt.ToString('o') }) $currentUri = Get-OktaNextLink -Response $response } catch { $reason = $_.Exception.Message -replace [regex]::Escape($plainToken), '[REDACTED]' $status = if ($Optional -and $reason -match '(?i)HTTP\s+(404|501)') { 'NotApplicable' } else { 'Unavailable' } $sourceOperations.Add([pscustomobject][ordered]@{ Source='Okta Management API'; Dataset=$Dataset; Operation='GET'; Uri=$currentUri Status=$status; Count=0; Reason=$reason; StartedAt=$startedAt.ToString('o'); CompletedAt=(Get-Date).ToString('o') }) if ($status -eq 'Unavailable') { $collectionHealth.Add([pscustomobject]@{ Dataset="Okta/$Dataset"; Operation='GET'; Status='Unavailable'; Reason=$reason ResourceTypes=@("AZSC/Okta/$Dataset") }) } break } } return @($items) } try { $apps = @(Invoke-OktaRead -Dataset 'Applications' -RelativeUri '/api/v1/apps?limit=200') $microsoftApps = @($apps | Where-Object { [string](Get-OktaValue $_ 'name') -match '(?i)(office365|microsoft)' -or [string](Get-OktaValue $_ 'label') -match '(?i)(office\s*365|microsoft)' }) foreach ($app in $microsoftApps) { $appId = [string](Get-OktaValue $app 'id') if (-not $appId) { continue } $null = Invoke-OktaRead -Dataset 'MicrosoftAppGroupAssignments' -RelativeUri "/api/v1/apps/$appId/groups?limit=200" -ParentId $appId $null = Invoke-OktaRead -Dataset 'MicrosoftAppUserAssignments' -RelativeUri "/api/v1/apps/$appId/users?limit=200" -ParentId $appId } $policies = [System.Collections.Generic.List[object]]::new() foreach ($policyType in 'OKTA_SIGN_ON','ACCESS_POLICY','MFA_ENROLL','PASSWORD') { foreach ($policy in @(Invoke-OktaRead -Dataset 'Policies' -RelativeUri "/api/v1/policies?type=$policyType&limit=200")) { $policies.Add($policy) } } foreach ($policy in $policies) { $policyId = [string](Get-OktaValue $policy 'id') if ($policyId) { $null = Invoke-OktaRead -Dataset 'PolicyRules' -RelativeUri "/api/v1/policies/$policyId/rules?limit=200" -ParentId $policyId } } $null = Invoke-OktaRead -Dataset 'NetworkZones' -RelativeUri '/api/v1/zones?limit=200' $null = Invoke-OktaRead -Dataset 'IdentityProviders' -RelativeUri '/api/v1/idps?limit=200' $null = Invoke-OktaRead -Dataset 'ThreatInsight' -RelativeUri '/api/v1/threats/configuration' -Optional $directories = @(Invoke-OktaRead -Dataset 'Directories' -RelativeUri '/api/v1/directories?limit=200' -Optional) foreach ($directory in $directories) { $directoryId = [string](Get-OktaValue $directory 'id') if ($directoryId) { $null = Invoke-OktaRead -Dataset 'DirectoryAgents' -RelativeUri "/api/v1/directories/$directoryId/agents?limit=200" -ParentId $directoryId -Optional } } $null = Invoke-OktaRead -Dataset 'DefaultUserSchema' -RelativeUri '/api/v1/meta/schemas/user/default' -Optional # The System Log is the read-only source of application usage. Retain every returned # event for the requested 30-day window, then derive a top-20 view without discarding the # underlying events. No actor names are copied into the aggregate rows. $since = (Get-Date).ToUniversalTime().AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $filter = [uri]::EscapeDataString('eventType eq "user.authentication.sso"') $systemLogEvents = @(Invoke-OktaRead -Dataset 'SystemLogSsoEvents' -RelativeUri "/api/v1/logs?since=$since&filter=$filter&limit=1000" -Optional) $users = @(Invoke-OktaRead -Dataset 'ActiveUsers' -RelativeUri '/api/v1/users?limit=200&filter=status%20eq%20%22ACTIVE%22') foreach ($user in $users) { $userId = [string](Get-OktaValue $user 'id') if (-not $userId) { continue } $null = Invoke-OktaRead -Dataset 'UserFactors' -RelativeUri "/api/v1/users/$userId/factors" -ParentId $userId $null = Invoke-OktaRead -Dataset 'UserAdminRoles' -RelativeUri "/api/v1/users/$userId/roles" -ParentId $userId } $factorRows = @($resources | Where-Object type -eq 'AZSC/Okta/UserFactors') $factorTypes = @($factorRows | Group-Object { [string](Get-OktaValue $_.properties.Raw 'factorType') }) $usersWithFactors = @($factorRows | ForEach-Object { $_.PARENTID } | Where-Object { $_ } | Sort-Object -Unique) $summaryGroups = @($factorTypes) if ($summaryGroups.Count -eq 0) { $summaryGroups = @([pscustomobject]@{ Name = 'No enrolled factors returned'; Count = 0 }) } foreach ($group in $summaryGroups) { $weakFactor = [string]$group.Name -match '(?i)^(sms|call|question|token:software:totp)$' $resources.Add([pscustomobject][ordered]@{ id="/providers/AzureScout/okta/FactorEnrollmentSummary/$([uri]::EscapeDataString($group.Name))" name=$group.Name; type='AZSC/Okta/FactorEnrollmentSummary' properties=[pscustomobject]@{ FactorType=$group.Name; EnrollmentCount=$group.Count; ActiveUserCount=$users.Count; ActiveUsersWithoutFactorCount=[Math]::Max(0,$users.Count-$usersWithFactors.Count); WeakFactor=$weakFactor } AZSC=[pscustomobject]@{Source='Azure Scout derived evidence';Dataset='OktaFactorEnrollmentSummary';Operation='Aggregate retained Okta users and factors';SourceDatasets=@('ActiveUsers','UserFactors');CollectedAt=(Get-Date).ToString('o')} }) } foreach ($group in @($resources | Where-Object type -eq 'AZSC/Okta/UserAdminRoles' | Group-Object { [string](Get-OktaValue $_.properties.Raw 'type') })) { $resources.Add([pscustomobject][ordered]@{ id="/providers/AzureScout/okta/AdminRoleSummary/$([uri]::EscapeDataString($group.Name))" name=$group.Name; type='AZSC/Okta/AdminRoleSummary' properties=[pscustomobject]@{ RoleType=$group.Name; AssignmentCount=$group.Count } AZSC=[pscustomobject]@{Source='Azure Scout derived evidence';Dataset='OktaAdminRoleSummary';Operation='Aggregate retained Okta role assignments';SourceDatasets=@('UserAdminRoles');CollectedAt=(Get-Date).ToString('o')} }) } $applicationUsage = [System.Collections.Generic.List[object]]::new() foreach ($event in $systemLogEvents) { $appTarget = @(@(Get-OktaValue $event 'target') | Where-Object { [string](Get-OktaValue $_ 'type') -match '(?i)^App' } | Select-Object -First 1) if ($appTarget.Count -eq 0) { continue } $applicationUsage.Add([pscustomobject]@{ Id = [string](Get-OktaValue $appTarget[0] 'id') Name = [string](Get-OktaValue $appTarget[0] 'displayName') }) } foreach ($group in @($applicationUsage | Group-Object { if ($_.Id) { $_.Id } else { $_.Name } } | Sort-Object Count -Descending | Select-Object -First 20)) { $representative = $group.Group[0] $appKey = if ($representative.Id) { $representative.Id } else { $representative.Name } $resources.Add([pscustomobject][ordered]@{ id="/providers/AzureScout/okta/ApplicationUsageSummary/$([uri]::EscapeDataString($appKey))" name=$representative.Name; type='AZSC/Okta/ApplicationUsageSummary' properties=[pscustomobject]@{ ApplicationId=$representative.Id; ApplicationName=$representative.Name; SignOnCount30Days=$group.Count; Rank=$null } AZSC=[pscustomobject]@{Source='Azure Scout derived evidence';Dataset='OktaApplicationUsageSummary';Operation='Aggregate retained Okta System Log SSO events';SourceDatasets=@('SystemLogSsoEvents');CollectedAt=(Get-Date).ToString('o')} }) } } finally { $plainToken = $null $headers.Clear() } [pscustomobject]@{ Resources=@($resources); SourceOperations=@($sourceOperations); CollectionHealth=@($collectionHealth) } } |