Private/Analyze/Invoke-PpaDlpAnalyzer.ps1

# Invoke-PpaDlpAnalyzer.ps1 - analyzer for section 02 (Data Loss Prevention).
# Produces DLP-01..03 per CHECK_CATALOG.md, asserting from readable signals (policy
# mode, locations, endpoint scope). The DLP-03 device-onboarded count is not readable
# read-only (open item D3), so that row reports Verify manually rather than a
# fabricated value. DLP-04 (HIPAA template detector tiering) was RETIRED in Wave 5
# cleanup Part 4 with nothing in its place - see the CHECK_CATALOG.md tombstone; the
# ID is never reused.
# ASCII-only source (Windows PowerShell 5.1). Depends on New-PpaFinding/New-PpaSection.

Set-StrictMode -Off

function Test-PpaDlpEnforcing {
    param([string]$Mode)
    return ($Mode -match '(?i)^enable$|enforce')
}

function Invoke-PpaDlpAnalyzer {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] $Raw,
        [datetime]$AsOf = (Get-Date),
        # Parsed Data/license-requirements.json (static annotation map, not detection).
        $LicenseMap
    )

    $mid   = Get-PpaMidDot
    $pols  = @($Raw.policies.items)
    $rules = @($Raw.rules.items)
    $findings = New-Object System.Collections.Generic.List[object]

    # F-001: every DLP check derives from Get-DlpCompliancePolicy. If that read did not
    # complete, DLP posture is UNKNOWN - do not let the count-based checks below report
    # "0 policies / Teams not in scope / Endpoint not configured" as fact. Degrade the
    # whole section to one Verify-manually finding (unknown is never asserted as empty).
    if ([string]$Raw.policies.status -ne 'Ok') {
        $glance = New-PpaGlance -Name 'Data Loss Prevention' -Metric 'not readable' -Sub 'confirm in portal'
        return New-PpaSection -Id 'Data_Loss_Prevention' -Title 'Data Loss Prevention' -Group 'Microsoft Information Protection' `
            -GroupIcon 'fas fa-shield-alt' -Glance $glance -Findings @(
                (New-PpaFinding -Id 'DLP-01' -DomId 'f-dlp-1' -Title 'DLP configuration not readable this session' -Status 'Verify manually' `
                    -Whyline 'DLP policies could not be enumerated this session, so DLP posture was not evaluated - confirm coverage in the Purview portal.' `
                    -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @(
                        New-PpaRow -Cells @('DLP policies', 'Not readable this session') -Status 'Verify manually' -Remark ([string]$Raw.policies.error)
                    )) -LearnMore @(@{ label = 'Learn about data loss prevention'; url = 'https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp'; tag = 'docs' })))
    }

    # Per-policy rollups from rules.
    $polInfo = @{}
    foreach ($p in $pols) {
        $pr = @($rules | Where-Object { $_.policyName -eq $p.name })
        $sits = @($pr | ForEach-Object { $_.sits } | Where-Object { $_ } | Select-Object -Unique)
        $polInfo[$p.name] = [pscustomobject]@{
            enforcing    = (Test-PpaDlpEnforcing $p.mode)
            sits         = $sits
            ruleTotal    = $pr.Count
            ruleDisabled = @($pr | Where-Object { $_.disabled }).Count
        }
    }

    # --- DLP-01: policies exist (enforcing vs test) ---
    $lm01 = @(
        @{ label = 'Microsoft Purview portal - Data Loss Prevention'; url = 'https://purview.microsoft.com'; tag = 'portal' }
        @{ label = 'Learn about data loss prevention'; url = 'https://learn.microsoft.com/en-us/purview/dlp-learn-about-dlp'; tag = 'docs' }
    )
    if ($pols.Count -eq 0) {
        $findings.Add((New-PpaFinding -Id 'DLP-01' -DomId 'f-dlp-1' -Title 'No DLP policies configured' -Status 'Improvement' `
            -Whyline 'With no DLP policies, sensitive data can leave the tenant unmonitored.' `
            -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @((New-PpaRow -Cells @('DLP policies', '0') -Status 'Improvement'))) -LearnMore $lm01))
    }
    else {
        $enfCount = @($pols | Where-Object { $polInfo[$_.name].enforcing }).Count
        $testCount = $pols.Count - $enfCount
        $rows01 = New-Object System.Collections.Generic.List[object]
        foreach ($p in $pols) {
            $info = $polInfo[$p.name]
            $abbr = New-Object System.Collections.Generic.List[string]
            if ($p.locations.exchange)   { $abbr.Add('EXO') }
            if ($p.locations.sharePoint) { $abbr.Add('SPO') }
            if ($p.locations.oneDrive)   { $abbr.Add('ODB') }
            if ($p.locations.teams)      { $abbr.Add('Teams') }
            if ($p.locations.endpoint)   { $abbr.Add('Endpoint') }
            if ($info.enforcing) {
                $remarkCell = if ($abbr.Count -gt 0) { "Enforcing $mid " + (@($abbr) -join ', ') } else { 'Enforcing' }
            }
            elseif ($info.ruleDisabled -gt 0) {
                $remarkCell = "Test mode; $($info.ruleDisabled) of $($info.ruleTotal) rules disabled"
            }
            else {
                $remarkCell = 'Test / simulation mode'
            }
            $rowRemark = $null
            if (-not $info.enforcing -and $p.testModeSince) {
                $since = [datetime]$p.testModeSince
                $rowRemark = "in test mode since $($since.ToString('dd-MMM-yyyy')) - detects but does not block."
            }
            $rows01.Add((New-PpaRow -Cells @($p.name, (@($info.sits) -join ', '), $remarkCell) -Status ($(if ($info.enforcing) { 'OK' } else { 'Improvement' })) -Remark $rowRemark))
        }
        $findings.Add((New-PpaFinding -Id 'DLP-01' -DomId 'f-dlp-1' -Title "$($pols.Count) DLP policies exist ($enfCount enforcing, $testCount in test)" -Status 'Informational' `
            -Whyline "Policy count alone overstates protection; test-mode policies detect but don't block. Each policy:" `
            -Table (New-PpaTable -Columns @('DLP Policy', 'Sensitive Information Type', 'Remarks', 'Status') -Rows $rows01.ToArray()) -LearnMore $lm01))
    }

    # --- DLP-02: Teams scope ---
    $locName = [ordered]@{ exchange = 'Exchange Online'; sharePoint = 'SharePoint Online'; oneDrive = 'OneDrive'; teams = 'Microsoft Teams' }
    $rows02 = New-Object System.Collections.Generic.List[object]
    foreach ($key in $locName.Keys) {
        $count = @($pols | Where-Object { $_.locations.$key }).Count
        $inScope = $count -gt 0
        $display = if ($count -eq $pols.Count -and $count -gt 0) { 'Yes (all policies)' } elseif ($inScope) { 'Yes' } else { 'No' }
        $rows02.Add((New-PpaRow -Cells @($locName[$key], $display) -Status ($(if ($inScope) { 'OK' } else { 'Improvement' }))))
    }
    $teamsInScope = @($pols | Where-Object { $_.locations.teams }).Count -gt 0
    $findings.Add((New-PpaFinding -Id 'DLP-02' -DomId 'f-dlp-2' -Title ($(if ($teamsInScope) { 'Teams is in scope' } else { 'Teams is not in scope' })) -Status ($(if ($teamsInScope) { 'OK' } else { 'Improvement' })) -Requires (Get-PpaRequirement $LicenseMap 'DLP-02') `
        -Whyline "Sensitive data shared in Teams bypasses DLP entirely when Teams isn't a policy location." `
        -Table (New-PpaTable -Columns @('Location', 'In scope', 'Status') -Rows $rows02.ToArray()) `
        -LearnMore @(@{ label = 'Use DLP with Microsoft Teams'; url = 'https://learn.microsoft.com/en-us/purview/dlp-microsoft-teams'; tag = 'docs' })))

    # --- DLP-03: Endpoint DLP (device count not readable read-only -> Verify manually) ---
    $endpointCount = @($pols | Where-Object { $_.locations.endpoint }).Count
    $rows03 = @(
        New-PpaRow -Cells @('Devices onboarded', 'Not readable read-only') -Status 'Verify manually'
        New-PpaRow -Cells @('Endpoint locations in policy', ($(if ($endpointCount -gt 0) { "$endpointCount policies" } else { 'None' }))) -Status ($(if ($endpointCount -gt 0) { 'OK' } else { 'Improvement' }))
    )
    $findings.Add((New-PpaFinding -Id 'DLP-03' -DomId 'f-dlp-3' -Title ($(if ($endpointCount -gt 0) { 'Endpoint DLP is configured' } else { 'Endpoint DLP is not configured' })) -Status ($(if ($endpointCount -gt 0) { 'OK' } else { 'Improvement' })) -Requires (Get-PpaRequirement $LicenseMap 'DLP-03') `
        -Whyline "Without Endpoint DLP there's no control over copy-to-USB, print, or cloud-upload egress on managed devices." `
        -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows $rows03) `
        -LearnMore @(@{ label = 'Learn about Endpoint DLP'; url = 'https://learn.microsoft.com/en-us/purview/endpoint-dlp-learn-about'; tag = 'docs' })))

    # (DLP-04, the HIPAA template detector-tiering check, was emitted here until it
    # was RETIRED in Wave 5 cleanup Part 4 - removed with nothing in its place. The
    # industry-neutral signals it might have carried already live in DLP-01 remarks
    # (enforcement mode), DLP-02 (workload coverage) and DLP-03 (endpoint posture).)

    # --- glance ---
    $enfCount = @($pols | Where-Object { $polInfo[$_.name].enforcing }).Count
    $teamsText = if ($teamsInScope) { 'Teams scoped' } else { 'Teams unscoped' }
    $glance = New-PpaGlance -Name 'Data Loss Prevention' -Metric "$($pols.Count) policies" -Sub "$enfCount enforcing $mid $teamsText"

    return New-PpaSection -Id 'Data_Loss_Prevention' -Title 'Data Loss Prevention' -Group 'Microsoft Information Protection' `
        -GroupIcon 'fas fa-shield-alt' -Glance $glance -Findings $findings.ToArray()
}