Src/Private/Get-AbrSPSecurityPosture.ps1
|
function Get-AbrSPSecurityPosture { <# .SYNOPSIS Produces an opinionated security posture advisory for SharePoint Online and OneDrive. .DESCRIPTION Analyses the data gathered in earlier sections and produces a prioritised advisory covering: 1. External Sharing Risk Score - Combines SP sharing level, OD sharing level, Anyone link status - Produces an overall risk rating: LOW / MEDIUM / HIGH / CRITICAL 2. Top Remediation Priorities - Ranked list of the highest-risk findings from ACSC E8 and CIS checks 3. Compliance Summary Scorecard - E8 pass/warn/fail counts by section - CIS L1 and L2 pass/warn/fail counts by section 4. Sharing Configuration Gaps - Sites/OneDrive with sharing level more permissive than tenant default - Anyone links with no expiry 5. Access Control Gaps - Legacy auth not blocked - Unmanaged devices have full access - Idle session not enforced 6. Governance Gaps - Inactive sites - Sites with high storage usage - No audit logging .NOTES Version: 0.1.0 Author: Pai Wei Sing #> [CmdletBinding()] param ( [Parameter(Position = 0, Mandatory)] [string]$TenantId ) begin { Write-PScriboMessage -Message "Generating SharePoint Security Posture advisory for $TenantId." Show-AbrDebugExecutionTime -Start -TitleMessage 'Security Posture' } process { Section -Style Heading1 'Security Posture & Advisory' { Paragraph "The following section provides an opinionated security advisory for SharePoint Online and OneDrive for Business in tenant $TenantId. Findings are derived from the configuration data collected in earlier sections and mapped against ACSC Essential Eight and CIS Microsoft 365 Foundations Benchmark controls." BlankLine #region Compliance Scorecard if (($script:IncludeACSCe8 -and $script:E8AllChecks.Count -gt 0) -or ($script:IncludeCISBaseline -and $script:CISAllChecks.Count -gt 0)) { Section -Style Heading2 'Compliance Scorecard' { Paragraph "Summary of compliance check results across all assessed sections." BlankLine #region E8 Scorecard if ($script:IncludeACSCe8 -and $script:E8AllChecks.Count -gt 0) { $E8Pass = @($script:E8AllChecks | Where-Object { $_.Status -eq '[OK]' }).Count $E8Warn = @($script:E8AllChecks | Where-Object { $_.Status -eq '[WARN]' }).Count $E8Fail = @($script:E8AllChecks | Where-Object { $_.Status -eq '[FAIL]' }).Count $E8Info = @($script:E8AllChecks | Where-Object { $_.Status -eq '[INFO]' }).Count $E8Total = $script:E8AllChecks.Count $E8ScorecardObj = [System.Collections.ArrayList]::new() $e8sInObj = [ordered] @{ 'Total Checks' = $E8Total 'Passed [OK]' = $E8Pass 'Warnings [WARN]' = $E8Warn 'Failed [FAIL]' = $E8Fail 'Manual Review [INFO]' = $E8Info 'Pass Rate' = if ($E8Total -gt 0) { "$([math]::Round(($E8Pass / $E8Total) * 100, 0))%" } else { '--' } } $E8ScorecardObj.Add([pscustomobject]$e8sInObj) | Out-Null $null = (& { $passRate = if ($E8Total -gt 0) { [math]::Round(($E8Pass / $E8Total) * 100, 0) } else { 0 } if ($passRate -lt 50) { $E8ScorecardObj | Set-Style -Style Critical | Out-Null } elseif ($passRate -lt 80) { $E8ScorecardObj | Set-Style -Style Warning | Out-Null } else { $E8ScorecardObj | Set-Style -Style OK | Out-Null } }) $E8ScoreTableParams = @{ Name = "ACSC E8 Scorecard - $TenantId"; List = $true; ColumnWidths = 40, 60 } if ($Report.ShowTableCaptions) { $E8ScoreTableParams['Caption'] = "- $($E8ScoreTableParams.Name)" } $E8ScorecardObj | Table @E8ScoreTableParams BlankLine # Per-section breakdown $E8Sections = $script:E8AllChecks | Group-Object -Property Section if ($E8Sections) { $E8SectionObj = [System.Collections.ArrayList]::new() foreach ($SectionGroup in ($E8Sections | Sort-Object Name)) { $sPass = @($SectionGroup.Group | Where-Object { $_.Status -eq '[OK]' }).Count $sWarn = @($SectionGroup.Group | Where-Object { $_.Status -eq '[WARN]' }).Count $sFail = @($SectionGroup.Group | Where-Object { $_.Status -eq '[FAIL]' }).Count $sInfo = @($SectionGroup.Group | Where-Object { $_.Status -eq '[INFO]' }).Count $sectInObj = [ordered] @{ 'Section' = $SectionGroup.Name 'Pass' = $sPass 'Warn' = $sWarn 'Fail' = $sFail 'Info' = $sInfo } $E8SectionObj.Add([pscustomobject]$sectInObj) | Out-Null } $null = (& { $null = ($E8SectionObj | Where-Object { $_.Fail -gt 0 } | Set-Style -Style Critical | Out-Null) $null = ($E8SectionObj | Where-Object { $_.Fail -eq 0 -and $_.Warn -gt 0 } | Set-Style -Style Warning | Out-Null) $null = ($E8SectionObj | Where-Object { $_.Fail -eq 0 -and $_.Warn -eq 0 } | Set-Style -Style OK | Out-Null) }) $E8SectTableParams = @{ Name = "ACSC E8 Results by Section - $TenantId"; ColumnWidths = 40, 15, 15, 15, 15 } if ($Report.ShowTableCaptions) { $E8SectTableParams['Caption'] = "- $($E8SectTableParams.Name)" } $E8SectionObj | Table @E8SectTableParams BlankLine } } #endregion #region CIS Scorecard if ($script:IncludeCISBaseline -and $script:CISAllChecks.Count -gt 0) { $CISPass = @($script:CISAllChecks | Where-Object { $_.Status -eq '[OK]' }).Count $CISWarn = @($script:CISAllChecks | Where-Object { $_.Status -eq '[WARN]' }).Count $CISFail = @($script:CISAllChecks | Where-Object { $_.Status -eq '[FAIL]' }).Count $CISInfo = @($script:CISAllChecks | Where-Object { $_.Status -eq '[INFO]' }).Count $CISTotal = $script:CISAllChecks.Count # Split by level $CISL1Pass = @($script:CISAllChecks | Where-Object { $_.Level -eq 'L1' -and $_.Status -eq '[OK]' }).Count $CISL1Fail = @($script:CISAllChecks | Where-Object { $_.Level -eq 'L1' -and $_.Status -eq '[FAIL]' }).Count $CISL2Pass = @($script:CISAllChecks | Where-Object { $_.Level -eq 'L2' -and $_.Status -eq '[OK]' }).Count $CISL2Fail = @($script:CISAllChecks | Where-Object { $_.Level -eq 'L2' -and $_.Status -eq '[FAIL]' }).Count $CISScorecardObj = [System.Collections.ArrayList]::new() $cissInObj = [ordered] @{ 'Total Checks' = $CISTotal 'Passed [OK]' = $CISPass 'Warnings [WARN]' = $CISWarn 'Failed [FAIL]' = $CISFail 'Manual Review [INFO]' = $CISInfo 'L1 Controls Passed' = "$CISL1Pass passed / $CISL1Fail failed" 'L2 Controls Passed' = "$CISL2Pass passed / $CISL2Fail failed" 'Overall Pass Rate' = if ($CISTotal -gt 0) { "$([math]::Round(($CISPass / $CISTotal) * 100, 0))%" } else { '--' } } $CISScorecardObj.Add([pscustomobject]$cissInObj) | Out-Null $null = (& { $passRate = if ($CISTotal -gt 0) { [math]::Round(($CISPass / $CISTotal) * 100, 0) } else { 0 } if ($passRate -lt 50) { $CISScorecardObj | Set-Style -Style Critical | Out-Null } elseif ($passRate -lt 80) { $CISScorecardObj | Set-Style -Style Warning | Out-Null } else { $CISScorecardObj | Set-Style -Style OK | Out-Null } }) $CISScoreTableParams = @{ Name = "CIS M365 Scorecard - $TenantId"; List = $true; ColumnWidths = 40, 60 } if ($Report.ShowTableCaptions) { $CISScoreTableParams['Caption'] = "- $($CISScoreTableParams.Name)" } $CISScorecardObj | Table @CISScoreTableParams BlankLine } #endregion } } #endregion #region Top Remediation Priorities Section -Style Heading2 'Top Remediation Priorities' { Paragraph "The following table lists all FAIL-status checks across both frameworks, ranked by severity. Address FAIL items before WARN items." BlankLine $AllFailChecks = [System.Collections.ArrayList]::new() if ($script:IncludeACSCe8) { foreach ($check in ($script:E8AllChecks | Where-Object { $_.Status -eq '[FAIL]' })) { $null = $AllFailChecks.Add([pscustomobject][ordered]@{ 'Framework' = 'ACSC E8' 'Section' = $check.Section 'Control' = if ($check.Control) { $check.Control } else { $check.ML } 'Status' = '[FAIL]' 'Finding' = $check.Detail }) } } if ($script:IncludeCISBaseline) { foreach ($check in ($script:CISAllChecks | Where-Object { $_.Status -eq '[FAIL]' })) { $null = $AllFailChecks.Add([pscustomobject][ordered]@{ 'Framework' = "CIS $($check.Level)" 'Section' = $check.Section 'Control' = $check.CISControl 'Status' = '[FAIL]' 'Finding' = $check.Detail }) } } if ($AllFailChecks.Count -gt 0) { $null = (& { $null = ($AllFailChecks | Set-Style -Style Critical | Out-Null) }) $FailTableParams = @{ Name = "Failed Controls - $TenantId"; ColumnWidths = 10, 16, 10, 8, 56 } if ($Report.ShowTableCaptions) { $FailTableParams['Caption'] = "- $($FailTableParams.Name)" } $AllFailChecks | Table @FailTableParams } else { Paragraph "No FAIL-status controls found. Review WARN and INFO items to further improve your posture." } BlankLine # Also surface WARN items $AllWarnChecks = [System.Collections.ArrayList]::new() if ($script:IncludeACSCe8) { foreach ($check in ($script:E8AllChecks | Where-Object { $_.Status -eq '[WARN]' })) { $null = $AllWarnChecks.Add([pscustomobject][ordered]@{ 'Framework' = 'ACSC E8' 'Section' = $check.Section 'Control' = if ($check.Control) { $check.Control } else { $check.ML } 'Status' = '[WARN]' 'Finding' = $check.Detail }) } } if ($script:IncludeCISBaseline) { foreach ($check in ($script:CISAllChecks | Where-Object { $_.Status -eq '[WARN]' })) { $null = $AllWarnChecks.Add([pscustomobject][ordered]@{ 'Framework' = "CIS $($check.Level)" 'Section' = $check.Section 'Control' = $check.CISControl 'Status' = '[WARN]' 'Finding' = $check.Detail }) } } if ($AllWarnChecks.Count -gt 0) { $null = (& { $null = ($AllWarnChecks | Set-Style -Style Warning | Out-Null) }) $WarnTableParams = @{ Name = "Warning Controls - $TenantId"; ColumnWidths = 10, 16, 10, 8, 56 } if ($Report.ShowTableCaptions) { $WarnTableParams['Caption'] = "- $($WarnTableParams.Name)" } $AllWarnChecks | Table @WarnTableParams } # Export priorities to Excel if ($AllFailChecks.Count -gt 0 -or $AllWarnChecks.Count -gt 0) { $PriorityData = [System.Collections.ArrayList]::new() foreach ($r in $AllFailChecks) { $null = $PriorityData.Add($r) } foreach ($r in $AllWarnChecks) { $null = $PriorityData.Add($r) } $script:ExcelSheets['Remediation Priorities'] = $PriorityData } } #endregion #region Manual Review Items Section -Style Heading2 'Manual Review Items' { Paragraph "The following controls require manual verification in the Microsoft admin portals. They cannot be automatically assessed via the Microsoft Graph API." BlankLine $AllInfoChecks = [System.Collections.ArrayList]::new() if ($script:IncludeACSCe8) { foreach ($check in ($script:E8AllChecks | Where-Object { $_.Status -eq '[INFO]' })) { $null = $AllInfoChecks.Add([pscustomobject][ordered]@{ 'Framework' = 'ACSC E8' 'Section' = $check.Section 'Control' = if ($check.Control) { $check.Control } else { '--' } 'Guidance' = $check.Detail }) } } if ($script:IncludeCISBaseline) { foreach ($check in ($script:CISAllChecks | Where-Object { $_.Status -eq '[INFO]' })) { $null = $AllInfoChecks.Add([pscustomobject][ordered]@{ 'Framework' = "CIS $($check.Level)" 'Section' = $check.Section 'Control' = $check.CISControl 'Guidance' = $check.Detail }) } } if ($AllInfoChecks.Count -gt 0) { $InfoTableParams = @{ Name = "Manual Review Items - $TenantId"; ColumnWidths = 10, 16, 12, 62 } if ($Report.ShowTableCaptions) { $InfoTableParams['Caption'] = "- $($InfoTableParams.Name)" } $AllInfoChecks | Table @InfoTableParams } else { Paragraph "No manual review items found." } } #endregion #region Quick Reference Links Section -Style Heading2 'Admin Portal Quick Reference' { Paragraph "The following table provides direct links to the Microsoft admin portals referenced in this report's remediation guidance." BlankLine $LinksObj = [System.Collections.ArrayList]::new() $links = @( @{ Portal = 'SharePoint Admin Center -- Sharing'; URL = 'https://admin.microsoft.com/sharepoint#/policies/sharing' } @{ Portal = 'SharePoint Admin Center -- Access Control'; URL = 'https://admin.microsoft.com/sharepoint#/policies/access-control' } @{ Portal = 'SharePoint Admin Center -- Active Sites'; URL = 'https://admin.microsoft.com/sharepoint#/sites/activeSites' } @{ Portal = 'SharePoint Admin Center -- Settings'; URL = 'https://admin.microsoft.com/sharepoint#/settings/oneDrive' } @{ Portal = 'Microsoft Purview -- Audit'; URL = 'https://compliance.microsoft.com/auditlogsearch' } @{ Portal = 'Microsoft Purview -- DLP Policies'; URL = 'https://compliance.microsoft.com/datalossprevention/policies' } @{ Portal = 'Microsoft Purview -- Sensitivity Labels'; URL = 'https://compliance.microsoft.com/informationprotection' } @{ Portal = 'Microsoft Purview -- Retention Policies'; URL = 'https://compliance.microsoft.com/informationgovernance' } @{ Portal = 'Entra ID -- Conditional Access'; URL = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade' } @{ Portal = 'Microsoft 365 Admin Center -- Licences'; URL = 'https://admin.microsoft.com/Adminportal/Home#/licenses' } ) foreach ($link in $links) { $null = $LinksObj.Add([pscustomobject][ordered]@{ 'Portal' = $link.Portal 'URL' = $link.URL }) } $LinksTableParams = @{ Name = "Admin Portal Quick Reference"; ColumnWidths = 40, 60 } if ($Report.ShowTableCaptions) { $LinksTableParams['Caption'] = "- $($LinksTableParams.Name)" } $LinksObj | Table @LinksTableParams } #endregion } } end { Show-AbrDebugExecutionTime -End -TitleMessage 'Security Posture' } } |