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'
    }
}