Private/02-Security.ps1
|
function Get-AtlasPermissionRequirement { [CmdletBinding()] param( [ValidateSet('Core', 'Governance')] [string] $CollectionProfile = 'Core' ) $requirements = @( @{ profile = 'Core'; collector = 'users'; recommended = 'User.Read.All'; accepted = @('User.Read.All', 'User.ReadWrite.All', 'Directory.Read.All', 'Directory.ReadWrite.All') } @{ profile = 'Core'; collector = 'groups'; recommended = 'Group.Read.All'; accepted = @('Group.Read.All', 'Group.ReadWrite.All', 'Directory.Read.All', 'Directory.ReadWrite.All') } @{ profile = 'Core'; collector = 'devicesAndAuthentication'; recommended = 'Device.Read.All'; accepted = @('Device.Read.All', 'Device.ReadWrite.All', 'Directory.Read.All', 'Directory.ReadWrite.All') } @{ profile = 'Core'; collector = 'devicesAndAuthentication'; recommended = 'UserAuthenticationMethod.Read.All'; accepted = @('UserAuthenticationMethod.Read.All', 'UserAuthenticationMethod.ReadWrite.All') } @{ profile = 'Core'; collector = 'applications'; recommended = 'Application.Read.All'; accepted = @('Application.Read.All', 'Application.ReadWrite.All', 'Directory.Read.All', 'Directory.ReadWrite.All') } @{ profile = 'Core'; collector = 'conditionalAccess'; recommended = 'Policy.Read.All'; accepted = @('Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess', 'Policy.ReadWrite.ApplicationConfiguration') } @{ profile = 'Core'; collector = 'directoryRoles'; recommended = 'RoleManagement.Read.Directory'; accepted = @('RoleManagement.Read.Directory', 'RoleManagement.Read.All', 'RoleManagement.ReadWrite.Directory') } @{ profile = 'Core' collector = 'directoryRoles' recommended = 'RoleEligibilitySchedule.Read.Directory' accepted = @('RoleEligibilitySchedule.Read.Directory', 'RoleEligibilitySchedule.ReadWrite.Directory', 'RoleManagement.Read.All', 'RoleManagement.Read.Directory', 'RoleManagement.ReadWrite.Directory') } @{ profile = 'Governance'; collector = 'administrativeUnits'; recommended = 'AdministrativeUnit.Read.All'; accepted = @('AdministrativeUnit.Read.All', 'AdministrativeUnit.ReadWrite.All', 'Directory.Read.All', 'Directory.ReadWrite.All') } @{ profile = 'Governance'; collector = 'pimGroups'; recommended = 'PrivilegedAssignmentSchedule.Read.AzureADGroup'; accepted = @('PrivilegedAssignmentSchedule.Read.AzureADGroup', 'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup') } @{ profile = 'Governance'; collector = 'pimGroups'; recommended = 'PrivilegedEligibilitySchedule.Read.AzureADGroup'; accepted = @('PrivilegedEligibilitySchedule.Read.AzureADGroup', 'PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup') } @{ profile = 'Governance'; collector = 'entitlementManagement'; recommended = 'EntitlementManagement.Read.All'; accepted = @('EntitlementManagement.Read.All', 'EntitlementManagement.ReadWrite.All') } @{ profile = 'Governance'; collector = 'accessReviews'; recommended = 'AccessReview.Read.All'; accepted = @('AccessReview.Read.All', 'AccessReview.ReadWrite.All') } ) if ($CollectionProfile -eq 'Governance') { return $requirements } return @($requirements | Where-Object profile -eq 'Core') } function Get-AtlasRecommendedScope { [CmdletBinding()] param( [ValidateSet('Core', 'Governance')] [string] $CollectionProfile = 'Core' ) return @( Get-AtlasPermissionRequirement -CollectionProfile $CollectionProfile | ForEach-Object { $_.recommended } | Sort-Object -Unique ) } function Get-AtlasAdditionalWriteScope { [CmdletBinding()] param( [AllowNull()] [string[]] $ContextScope ) return @( $ContextScope | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -match '(?i)(^|[._-])(readwrite|write|manage|create|update|delete|send|accessasuser|full_access)([._-]|$)' } | Sort-Object -Unique ) } function Get-AtlasPermissionAssessment { [CmdletBinding()] param( [AllowNull()] [string[]] $ContextScope, [ValidateSet('Core', 'Governance')] [string] $CollectionProfile = 'Core' ) $grantedScope = @( $ContextScope | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } ) $requirements = Get-AtlasPermissionRequirement -CollectionProfile $CollectionProfile $missing = [System.Collections.Generic.List[object]]::new() foreach ($requirement in $requirements) { $satisfied = @( $requirement.accepted | Where-Object { $grantedScope -contains $_ } ).Count -gt 0 if (-not $satisfied) { $missing.Add( [pscustomobject] @{ collector = $requirement.collector recommended = $requirement.recommended accepted = @($requirement.accepted) } ) } } return [pscustomobject] @{ status = if ($missing.Count -eq 0) { 'complete' } else { 'partial' } grantedScopes = $grantedScope missingRequirements = @($missing) recommendedScopes = @($requirements.recommended | Sort-Object -Unique) } } function Get-AtlasMissingRecommendedScope { [CmdletBinding()] param( [AllowNull()] [object[]] $MissingRequirement ) return @( $MissingRequirement | ForEach-Object { $_.recommended } | Sort-Object -Unique ) } function New-AtlasPermissionPreflightResult { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory collection result and does not change external state.' )] [CmdletBinding()] param( [AllowNull()] [string[]] $ContextScope, [ValidateSet('Core', 'Governance')] [string] $CollectionProfile = 'Core' ) $assessment = Get-AtlasPermissionAssessment -ContextScope $ContextScope -CollectionProfile $CollectionProfile $missingScope = Get-AtlasMissingRecommendedScope -MissingRequirement $assessment.missingRequirements $result = [AtlasCollectionResult]::new() $result.Status = $assessment.status $result.Metrics = @{ grantedScopeCount = $assessment.grantedScopes.Count requiredScopeCount = $assessment.recommendedScopes.Count missingScopeCount = $assessment.missingRequirements.Count missingScopes = $missingScope collectionProfile = $CollectionProfile } if ($assessment.missingRequirements.Count -gt 0) { $result.Warnings.Add( "The Microsoft Graph session is missing recommended delegated read scopes: $($missingScope -join ', '). Affected collectors will continue where Microsoft Graph permits and the report will remain partial." ) } return $result } function Get-AtlasSafeErrorDetail { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord ) $exception = $ErrorRecord.Exception $statusCode = $null if ($exception.PSObject.Properties['ResponseStatusCode']) { $statusCode = [int] $exception.ResponseStatusCode } elseif ( $exception.PSObject.Properties['Response'] -and $exception.Response -and $exception.Response.PSObject.Properties['StatusCode'] ) { $statusCode = [int] $exception.Response.StatusCode } $message = [string] $exception.Message $message = [regex]::Replace( $message, '(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+', 'Bearer [redacted]' ) $message = [regex]::Replace( $message, '(?i)\b(access_token|client_secret|client_assertion|secretText|password|code)=([^&\s]+)', '$1=[redacted]' ) $message = [regex]::Replace( $message, 'https://[^\s''"<>]+', { param($match) try { $uri = [uri] $match.Value return "$($uri.Scheme)://$($uri.Host)$($uri.AbsolutePath)" } catch { return '[request URI]' } } ) $message = ($message -replace '\s+', ' ').Trim() if ($message.Length -gt 400) { $message = "$($message.Substring(0, 397))..." } if ($statusCode) { return "HTTP $statusCode. $message" } return $message } function Invoke-AtlasCollector { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Name, [Parameter(Mandatory)] [string] $DisplayName, [Parameter(Mandatory)] [scriptblock] $Collector, [ValidateRange(1, 30)] [int] $Step = 1 ) Start-AtlasProgressStep -Name $Name -DisplayName $DisplayName -Step $Step try { $result = & $Collector if ($null -eq $result) { throw "The '$Name' collector returned no result." } Complete-AtlasProgressStep -Result $result return $result } catch [System.Management.Automation.PipelineStoppedException] { throw } catch [System.OperationCanceledException] { throw } catch { $result = [AtlasCollectionResult]::new() $result.Status = 'partial' $result.Warnings.Add("$DisplayName could not be collected: $(Get-AtlasSafeErrorDetail -ErrorRecord $_)") $result.Metrics = @{ requestCount = 0 retryCount = 0 failed = $true } Complete-AtlasProgressStep -Result $result return $result } } function Test-AtlasGraphUri { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Uri ) if ($Uri -match '^/(v1\.0|beta)/') { return $true } $absoluteUri = $null if (-not [uri]::TryCreate($Uri, [System.UriKind]::Absolute, [ref] $absoluteUri)) { return $false } $allowedHost = @( 'graph.microsoft.com' 'graph.microsoft.us' 'dod-graph.microsoft.us' 'microsoftgraph.chinacloudapi.cn' ) return ( $absoluteUri.Scheme -eq 'https' -and -not $absoluteUri.UserInfo -and -not $absoluteUri.Fragment -and $allowedHost -contains $absoluteUri.DnsSafeHost -and $absoluteUri.AbsolutePath -match '^/(v1\.0|beta)/' ) } |