Private/Scoring.ps1
|
function Invoke-M365SnapshotScoring { param( [Parameter(Mandatory=$true)] [string]$ModuleRoot, [AllowNull()] [object[]]$Groups = @(), [AllowNull()] [object[]]$Sites = @(), [AllowNull()] [object[]]$AppRegistrations = @(), [Parameter(Mandatory=$false)] [switch]$IncludeAppRegistrations, [AllowNull()] [object[]]$SitesWithoutSensitivityLabel = @(), [AllowNull()] [object[]]$SitesWithEveryoneSharing = @(), [AllowNull()] [object[]]$SitesWithAnonymousLinks = @(), [AllowNull()] [object[]]$SitesWithExternalUsers = @(), [AllowNull()] [object[]]$SitesWithExternalAccess = @(), [AllowNull()] [object[]]$SitesWithAdHocPermissions = @(), [AllowNull()] [object[]]$DisabledWithLicenses = @(), [AllowNull()] [object[]]$DisabledUsers = @(), [AllowNull()] [object[]]$AppsWithSecretRisk = @(), [AllowNull()] [object[]]$UnusedAppRegistrations = @(), [AllowNull()] [object[]]$SensitiveAppRegistrations = @() ) if ($null -eq $Groups) { $Groups = @() } if ($null -eq $Sites) { $Sites = @() } if ($null -eq $AppRegistrations) { $AppRegistrations = @() } if ($null -eq $SitesWithoutSensitivityLabel) { $SitesWithoutSensitivityLabel = @() } if ($null -eq $SitesWithEveryoneSharing) { $SitesWithEveryoneSharing = @() } if ($null -eq $SitesWithAnonymousLinks) { $SitesWithAnonymousLinks = @() } if ($null -eq $SitesWithExternalUsers) { $SitesWithExternalUsers = @() } if ($null -eq $SitesWithExternalAccess) { $SitesWithExternalAccess = @() } if ($null -eq $SitesWithAdHocPermissions) { $SitesWithAdHocPermissions = @() } if ($null -eq $DisabledWithLicenses) { $DisabledWithLicenses = @() } if ($null -eq $DisabledUsers) { $DisabledUsers = @() } if ($null -eq $AppsWithSecretRisk) { $AppsWithSecretRisk = @() } if ($null -eq $UnusedAppRegistrations) { $UnusedAppRegistrations = @() } if ($null -eq $SensitiveAppRegistrations) { $SensitiveAppRegistrations = @() } function Get-ScorePercentage { param( [double]$Numerator, [double]$Denominator ) if ($Denominator -le 0) { return 0 } return [Math]::Min(100, [Math]::Round(($Numerator / $Denominator) * 100, 0)) } $scoringConfigPath = Join-Path $ModuleRoot 'scoring-rules.json' $scoringConfig = $null if (Test-Path $scoringConfigPath) { try { $scoringConfig = Get-Content -Path $scoringConfigPath -Raw | ConvertFrom-Json } catch { Write-Warning "Failed to parse scoring rules at $scoringConfigPath. Falling back to defaults." } } else { Write-Warning "Scoring rules not found at $scoringConfigPath. Falling back to defaults." } if (-not $scoringConfig) { $defaultScoringRulesJson = @' { "description": "M365 Security Assessment Score - Scoring Rules Configuration", "baseScore": 100, "rules": [ { "id": "sensitivity-labels", "name": "Missing Sensitivity Labels", "description": "Sites without information protection labels applied", "enabled": true, "maxPenalty": 15, "calculation": "percentage-based", "threshold": 50, "message": "Missing sensitivity labels on {percentage}% of sites: -{deduction} points" }, { "id": "anonymous-sharing", "name": "Anonymous Sharing Enabled", "description": "Sites that allow anonymous access (anyone with link, no sign-in required)", "enabled": true, "maxPenalty": 20, "calculation": "flat", "threshold": 1, "message": "Sites with anonymous sharing enabled ({count}): -{deduction} points" }, { "id": "anonymous-links", "name": "Active Anonymous Sharing Links", "description": "Sites with active anonymous sharing links created", "enabled": true, "maxPenalty": 15, "calculation": "flat", "threshold": 1, "message": "Sites with active anonymous links ({count}): -{deduction} points" }, { "id": "external-users", "name": "External Users (Guests)", "description": "Sites with external users who have been granted access", "enabled": true, "maxPenalty": 15, "calculation": "prevalence-based", "threshold": 100, "message": "Sites with external users ({count}): -{deduction} points" }, { "id": "adhoc-permissions", "name": "Ad-Hoc Permissions", "description": "Sites where users have direct permissions (not via SharePoint groups)", "enabled": true, "maxPenalty": 10, "calculation": "percentage-based", "threshold": 20, "message": "Sites with ad-hoc permissions ({count}): -{deduction} points" }, { "id": "external-access-prevalence", "name": "High External Access Prevalence", "description": "Too many sites have external sharing enabled", "enabled": true, "maxPenalty": 10, "calculation": "percentage-above-threshold", "threshold": 75, "message": "High external access prevalence ({percentage}%): -{deduction} points" }, { "id": "disabled-users-licenses", "name": "Disabled Users with Licenses", "description": "Inactive users still consuming paid licenses (cost optimization)", "enabled": true, "maxPenalty": 10, "calculation": "percentage-based", "threshold": 50, "message": "Disabled users with licenses ({count}): -{deduction} points" }, { "id": "app-secrets-expiring", "name": "App Registrations with Expired/Expiring Secrets", "description": "Applications that have expired secrets or secrets expiring in the next 30 days", "enabled": true, "maxPenalty": 15, "calculation": "flat", "threshold": 1, "message": "Apps with expired or soon-to-expire secrets ({count}): -{deduction} points" }, { "id": "unused-app-registrations", "name": "Unused App Registrations", "description": "Applications with no sign-in activity in the last 90 days (for apps where usage data is available)", "enabled": true, "maxPenalty": 10, "calculation": "flat", "threshold": 1, "message": "Unused app registrations ({count}): -{deduction} points" }, { "id": "sensitive-app-registrations", "name": "Sensitive App Registrations", "description": "Applications with elevated, write-capable, or privilege-granting permissions", "enabled": true, "maxPenalty": 15, "calculation": "flat", "threshold": 1, "message": "Sensitive app registrations ({count}): -{deduction} points" }, { "id": "group-owner-governance", "name": "Group Owner Governance Violations", "description": "Groups with owner count outside allowed range or group principals as owners", "enabled": true, "maxPenalty": 10, "calculation": "flat", "threshold": 1, "message": "Group owner governance violations ({count}): -{deduction} points" }, { "id": "site-owner-governance", "name": "Site Owner Governance Violations", "description": "Sites with owner/admin count outside allowed range or group principals with full control", "enabled": true, "maxPenalty": 15, "calculation": "flat", "threshold": 1, "message": "Site owner governance violations ({count}): -{deduction} points" } ], "ownerGovernance": { "enabled": true, "minOwners": 2, "maxOwners": 5, "flagGroupOwners": true }, "privilegedPermissionDetection": { "enabled": true, "minPrivilegedPermissionCount": 3, "excludePermissionPatterns": [ "^openid$", "^profile$", "^email$", "^offline_access$", "^User\\.Read$" ], "alwaysSensitivePermissionPatterns": [ "^Directory\\.AccessAsUser\\.All$", "^AppRoleAssignment\\.ReadWrite\\.All$", "^DelegatedPermissionGrant\\.ReadWrite\\.All$", "^RoleManagement\\.ReadWrite\\..*$", "^RoleAssignmentSchedule\\.ReadWrite\\..*$", "^RoleEligibilitySchedule\\.ReadWrite\\..*$", "^Application\\.ReadWrite\\..*$", "^Directory\\.ReadWrite\\.All$", "^Sites\\.FullControl\\.All$", "^Sites\\.Manage\\.All$", "^Exchange\\.ManageAsApp$", "^GroupMember\\.ReadWrite\\.All$", "^Group\\.ReadWrite\\.All$", "^User\\.ReadWrite\\.All$", "^RoleManagement\\.ReadWrite\\.Directory$" ], "privilegedPermissionPatterns": [ "\\b(ReadWrite|Write|Manage|FullControl|Delete|Create|Update|Send|AccessAsUser|PrivilegedOperations|ReadWrite\\.All|ReadWrite\\.OwnedBy)\\b", "^(RoleManagement|RoleAssignmentSchedule|RoleEligibilitySchedule|Policy\\.ReadWrite|AppRoleAssignment\\.ReadWrite|DelegatedPermissionGrant\\.ReadWrite|Directory\\.ReadWrite|Application\\.ReadWrite|Sites\\.(Manage|FullControl)|GroupMember\\.ReadWrite|Group\\.ReadWrite|User\\.ReadWrite).*" ] }, "grades": [ { "grade": "Excellent", "minScore": 90, "maxScore": 100, "color": "green" }, { "grade": "Good", "minScore": 80, "maxScore": 89, "color": "green" }, { "grade": "Fair", "minScore": 70, "maxScore": 79, "color": "yellow" }, { "grade": "Poor", "minScore": 60, "maxScore": 69, "color": "yellow" }, { "grade": "Critical", "minScore": 0, "maxScore": 59, "color": "red" } ] } '@ $scoringConfig = $defaultScoringRulesJson | ConvertFrom-Json } $score = if ($null -ne $scoringConfig.baseScore) { [double]$scoringConfig.baseScore } else { 100 } $scoreDetails = @() $ownerGovernance = $scoringConfig.ownerGovernance if (-not $ownerGovernance) { $ownerGovernance = [PSCustomObject]@{ enabled = $true minOwners = 2 maxOwners = 5 flagGroupOwners = $true } } $ownerGovernanceEnabled = ($ownerGovernance.enabled -ne $false) $minOwnerThreshold = if ($null -ne $ownerGovernance.minOwners) { [int]$ownerGovernance.minOwners } else { 2 } $maxOwnerThreshold = if ($null -ne $ownerGovernance.maxOwners) { [int]$ownerGovernance.maxOwners } else { 5 } $flagGroupOwnersAsViolation = ($ownerGovernance.flagGroupOwners -ne $false) $groupOwnerGovernanceViolations = @() $siteOwnerGovernanceViolations = @() if ($ownerGovernanceEnabled) { foreach ($group in $Groups) { $violationReasons = @() if ([int]$group.OwnerCount -lt $minOwnerThreshold) { $violationReasons += 'OwnerCountBelowMin' } if ([int]$group.OwnerCount -gt $maxOwnerThreshold) { $violationReasons += 'OwnerCountAboveMax' } if ($flagGroupOwnersAsViolation -and [bool]$group.OwnerIsGroup) { $violationReasons += 'OwnerContainsGroup' } if ($violationReasons.Count -gt 0) { $groupOwnerGovernanceViolations += [PSCustomObject]@{ DisplayName = $group.DisplayName GroupId = $group.GroupId OwnerCount = [int]$group.OwnerCount OwnerIsGroup = [bool]$group.OwnerIsGroup ViolationReason = ($violationReasons -join ', ') } } } foreach ($site in $Sites) { if (-not [bool]$site.OwnerDataAvailable) { continue } $violationReasons = @() if ([int]$site.OwnerAdminCount -lt $minOwnerThreshold) { $violationReasons += 'OwnerCountBelowMin' } if ([int]$site.OwnerAdminCount -gt $maxOwnerThreshold) { $violationReasons += 'OwnerCountAboveMax' } if ($flagGroupOwnersAsViolation -and [bool]$site.OwnerIsGroup) { $violationReasons += 'OwnerContainsGroup' } if ($violationReasons.Count -gt 0) { $siteOwnerGovernanceViolations += [PSCustomObject]@{ Title = $site.Title Url = $site.Url Group = $site.Group OwnerAdminCount = [int]$site.OwnerAdminCount OwnerIsGroup = [bool]$site.OwnerIsGroup OwnerSource = $site.OwnerSource ViolationReason = ($violationReasons -join ', ') } } } } $privilegedPermissionDetection = $scoringConfig.privilegedPermissionDetection if (-not $privilegedPermissionDetection) { $privilegedPermissionDetection = [PSCustomObject]@{ enabled = $true minPrivilegedPermissionCount = 3 excludePermissionPatterns = @('^openid$','^profile$','^email$','^offline_access$','^User\.Read$') alwaysSensitivePermissionPatterns = @( '^Directory\.AccessAsUser\.All$','^AppRoleAssignment\.ReadWrite\.All$','^DelegatedPermissionGrant\.ReadWrite\.All$', '^RoleManagement\.ReadWrite\..*$','^RoleAssignmentSchedule\.ReadWrite\..*$','^RoleEligibilitySchedule\.ReadWrite\..*$', '^Application\.ReadWrite\..*$','^Directory\.ReadWrite\.All$','^Sites\.FullControl\.All$','^Sites\.Manage\.All$', '^Exchange\.ManageAsApp$','^GroupMember\.ReadWrite\.All$','^Group\.ReadWrite\.All$','^User\.ReadWrite\.All$','^RoleManagement\.ReadWrite\.Directory$' ) privilegedPermissionPatterns = @( '\b(ReadWrite|Write|Manage|FullControl|Delete|Create|Update|Send|AccessAsUser|PrivilegedOperations|ReadWrite\.All|ReadWrite\.OwnedBy)\b', '^(RoleManagement|RoleAssignmentSchedule|RoleEligibilitySchedule|Policy\.ReadWrite|AppRoleAssignment\.ReadWrite|DelegatedPermissionGrant\.ReadWrite|Directory\.ReadWrite|Application\.ReadWrite|Sites\.(Manage|FullControl)|GroupMember\.ReadWrite|Group\.ReadWrite|User\.ReadWrite).*' ) } } $sensitiveAppRegistrationsResult = @($SensitiveAppRegistrations) if ($IncludeAppRegistrations -and $AppRegistrations.Count -gt 0 -and $privilegedPermissionDetection.enabled -ne $false) { $minPrivilegedPermissionCount = if ($null -ne $privilegedPermissionDetection.minPrivilegedPermissionCount) { [int]$privilegedPermissionDetection.minPrivilegedPermissionCount } else { 3 } $excludePermissionPatterns = @($privilegedPermissionDetection.excludePermissionPatterns) $alwaysSensitivePermissionPatterns = @($privilegedPermissionDetection.alwaysSensitivePermissionPatterns) $privilegedPermissionPatterns = @($privilegedPermissionDetection.privilegedPermissionPatterns) foreach ($app in $AppRegistrations) { $permissionEntries = @() if (-not [string]::IsNullOrWhiteSpace([string]$app.Permissions)) { $permissionEntries = @(([string]$app.Permissions).Split(';') | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } $matchedHighRisk = @() $matchedPrivileged = @() foreach ($entry in $permissionEntries) { $permissionName = $entry if ($entry -match ':\s*(.+?)\s*\[(Scope|Role)\]\s*$') { $permissionName = $matches[1] } $isExcluded = $false foreach ($excludePattern in $excludePermissionPatterns) { if (-not [string]::IsNullOrWhiteSpace([string]$excludePattern) -and $permissionName -match [string]$excludePattern) { $isExcluded = $true; break } } if ($isExcluded) { continue } $isHighRisk = $false foreach ($highRiskPattern in $alwaysSensitivePermissionPatterns) { if (-not [string]::IsNullOrWhiteSpace([string]$highRiskPattern) -and $permissionName -match [string]$highRiskPattern) { $isHighRisk = $true; break } } if ($isHighRisk) { $matchedHighRisk += $entry; continue } $isPrivileged = $false foreach ($privilegedPattern in $privilegedPermissionPatterns) { if (-not [string]::IsNullOrWhiteSpace([string]$privilegedPattern) -and $permissionName -match [string]$privilegedPattern) { $isPrivileged = $true; break } } if ($isPrivileged) { $matchedPrivileged += $entry } } $matchedHighRisk = @($matchedHighRisk | Sort-Object -Unique) $matchedPrivileged = @($matchedPrivileged | Sort-Object -Unique) $app.HighRiskPermissionCount = $matchedHighRisk.Count $app.PrivilegedPermissionCount = $matchedPrivileged.Count $app.SensitivePermissionCount = $app.HighRiskPermissionCount + $app.PrivilegedPermissionCount $allSensitivePermissions = @($matchedHighRisk + $matchedPrivileged | Sort-Object -Unique) $app.SensitivePermissions = if ($allSensitivePermissions.Count -gt 0) { $allSensitivePermissions -join '; ' } else { '(none)' } if ($app.HighRiskPermissionCount -gt 0) { $app.IsSensitive = $true; $app.SensitiveReason = 'Contains high-risk permission(s)' } elseif ($app.PrivilegedPermissionCount -ge $minPrivilegedPermissionCount) { $app.IsSensitive = $true; $app.SensitiveReason = "Contains $($app.PrivilegedPermissionCount) privileged permission(s)" } else { $app.IsSensitive = $false; $app.SensitiveReason = '(none)' } } $sensitiveAppRegistrationsResult = @($AppRegistrations | Where-Object { $_.IsSensitive }) } $regularSites = $Sites | Where-Object { $_.Url -notlike '*-my.sharepoint.com/personal/*' -and $_.Url -notlike '*/search' } $regularSiteCount = [Math]::Max($regularSites.Count, 1) $ruleData = @{ 'sensitivity-labels' = @{ Count = $SitesWithoutSensitivityLabel.Count; Percentage = Get-ScorePercentage $SitesWithoutSensitivityLabel.Count $regularSiteCount } 'anonymous-sharing' = @{ Count = $SitesWithEveryoneSharing.Count } 'anonymous-links' = @{ Count = $SitesWithAnonymousLinks.Count } 'external-users' = @{ Count = $SitesWithExternalUsers.Count; Percentage = Get-ScorePercentage $SitesWithExternalUsers.Count ([Math]::Max($SitesWithExternalAccess.Count, 1)) } 'adhoc-permissions' = @{ Count = $SitesWithAdHocPermissions.Count; Percentage = Get-ScorePercentage $SitesWithAdHocPermissions.Count $regularSiteCount } 'external-access-prevalence' = @{ Count = $SitesWithExternalAccess.Count; Percentage = Get-ScorePercentage $SitesWithExternalAccess.Count $regularSiteCount } 'disabled-users-licenses' = @{ Count = $DisabledWithLicenses.Count; Percentage = Get-ScorePercentage $DisabledWithLicenses.Count ([Math]::Max($DisabledUsers.Count, 1)) } 'app-secrets-expiring' = @{ Count = $AppsWithSecretRisk.Count; Percentage = Get-ScorePercentage $AppsWithSecretRisk.Count ([Math]::Max($AppRegistrations.Count, 1)) } 'unused-app-registrations' = @{ Count = $UnusedAppRegistrations.Count; Percentage = Get-ScorePercentage $UnusedAppRegistrations.Count ([Math]::Max($AppRegistrations.Count, 1)) } 'sensitive-app-registrations' = @{ Count = $sensitiveAppRegistrationsResult.Count; Percentage = Get-ScorePercentage $sensitiveAppRegistrationsResult.Count ([Math]::Max($AppRegistrations.Count, 1)) } 'group-owner-governance' = @{ Count = $groupOwnerGovernanceViolations.Count; Percentage = Get-ScorePercentage $groupOwnerGovernanceViolations.Count ([Math]::Max($Groups.Count, 1)) } 'site-owner-governance' = @{ Count = $siteOwnerGovernanceViolations.Count; Percentage = Get-ScorePercentage $siteOwnerGovernanceViolations.Count ([Math]::Max($regularSites.Count, 1)) } } foreach ($rule in ($scoringConfig.rules | Where-Object { $_.enabled -ne $false })) { if (-not $ruleData.ContainsKey($rule.id)) { Write-Warning "Scoring rule '$($rule.id)' has no matching dataset and was skipped." continue } $context = $ruleData[$rule.id] $count = [int]$context.Count $percentage = if ($context.ContainsKey('Percentage')) { [double]$context.Percentage } else { 0 } $threshold = if ($null -ne $rule.threshold) { [double]$rule.threshold } else { 0 } $maxPenalty = if ($null -ne $rule.maxPenalty) { [double]$rule.maxPenalty } else { 0 } $deduction = 0 switch ($rule.calculation) { 'flat' { if ($count -ge $threshold -and $maxPenalty -gt 0) { $deduction = $maxPenalty } } 'percentage-based' { if ($percentage -gt 0 -and $threshold -gt 0 -and $maxPenalty -gt 0) { $deduction = [Math]::Min($maxPenalty, ($percentage / $threshold) * $maxPenalty) } } 'prevalence-based' { if ($percentage -gt 0 -and $threshold -gt 0 -and $maxPenalty -gt 0) { $deduction = [Math]::Min($maxPenalty, ($percentage / $threshold) * $maxPenalty) } } 'percentage-above-threshold' { if ($percentage -gt $threshold -and $threshold -lt 100 -and $maxPenalty -gt 0) { $deduction = [Math]::Min($maxPenalty, (($percentage - $threshold) / (100 - $threshold)) * $maxPenalty) } } } if ($deduction -gt 0) { $score -= $deduction $detail = $rule.message if (-not $detail) { $detail = "$($rule.name): -$([Math]::Round($deduction, 0)) points" } $detail = $detail -replace '\{percentage\}', ([Math]::Round($percentage, 0)) $detail = $detail -replace '\{count\}', $count $detail = $detail -replace '\{deduction\}', ([Math]::Round($deduction, 0)) $scoreDetails += $detail } } $score = [Math]::Max(0, [Math]::Min(100, $score)) $score = [Math]::Round($score, 0) $scoreGrade = $null $scoreColor = $null if ($scoringConfig.grades -and $scoringConfig.grades.Count -gt 0) { $gradeMatch = $scoringConfig.grades | Where-Object { $score -ge $_.minScore -and $score -le $_.maxScore } | Select-Object -First 1 if ($gradeMatch) { $scoreGrade = $gradeMatch.grade switch (([string]$gradeMatch.color).ToLower()) { 'green' { $scoreColor = 'Green' } 'yellow' { $scoreColor = 'Yellow' } 'red' { $scoreColor = 'Red' } default { $scoreColor = 'White' } } } } if (-not $scoreGrade) { $scoreGrade = switch ($score) { {$_ -ge 90} { 'Excellent'; break } {$_ -ge 80} { 'Good'; break } {$_ -ge 70} { 'Fair'; break } {$_ -ge 60} { 'Poor'; break } default { 'Critical' } } } if (-not $scoreColor) { $scoreColor = switch ($score) { {$_ -ge 80} { 'Green'; break } {$_ -ge 60} { 'Yellow'; break } default { 'Red' } } } return [PSCustomObject]@{ Score = $score ScoreDetails = $scoreDetails ScoreGrade = $scoreGrade ScoreColor = $scoreColor SensitiveAppRegistrations = $sensitiveAppRegistrationsResult GroupOwnerGovernanceViolations = $groupOwnerGovernanceViolations SiteOwnerGovernanceViolations = $siteOwnerGovernanceViolations } } |