Private/Collect/Get-PpaDspmAi.ps1

# Get-PpaDspmAi.ps1 - collector for section 08 (DSPM for AI / Copilot Data Security).
# Reads Get-DlpCompliancePolicy and keeps the Microsoft 365 Copilot-scoped policies.
# Everything here is readable over Connect-IPPSSession. SIT NAMES only, no content.
# Detection keys verified against a live sandbox tenant 2026-07-02 (docs/specs/
# ai-findings-build-spec.md): EnforcementPlanes containing CopilotExperiences, and the
# Locations JSON string carrying Workload=Applications entries with Copilot* locations
# (observed: Copilot.M365). Policy NAME is corroboration only - names are admin-editable
# and never flag a policy on their own.
# ASCII-only source. Depends on Invoke-PpaReadCmdlet.ps1.

Set-StrictMode -Off

# The Microsoft 365 Copilot DLP location id, from the New-DlpCompliancePolicy reference:
# Locations [{"Workload":"Applications","Location":"470f2276-...","Inclusions":...}]
# -EnforcementPlanes @("CopilotExperiences")
$script:PpaCopilotLocationGuid = '470f2276-e011-4e9d-a6ec-20768be3a4b0'

function ConvertFrom-PpaJsonText {
    # Parse a JSON-serialized string property (global rule 4 of the AI findings spec).
    # Returns the parsed object, or $null on any parse failure - the caller then falls
    # back to regex containment on the raw string and flags reduced confidence.
    param([string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    try { return ($Text | ConvertFrom-Json -ErrorAction Stop) }
    catch { return $null }
}

function Get-PpaCopilotDlpSignals {
    # Detection detail for one DLP policy, in the spec's priority order.
    # Returns: isCopilot, copilotLocations[], parseFallback (Locations JSON did not parse
    # and regex fallback was used), oneClick (Microsoft-deployed one-click default
    # fingerprint - informational tag, never a severity input).
    param($Policy)

    $isCopilot     = $false
    $locations     = New-Object System.Collections.Generic.List[string]
    $parseFallback = $false
    $purviewConfig = $false

    # 1) [VERIFIED] EnforcementPlanes contains CopilotExperiences (string or array).
    if ($Policy.PSObject.Properties.Name -contains 'EnforcementPlanes') {
        if ((($Policy.EnforcementPlanes | Out-String)) -match '(?i)CopilotExperiences') { $isCopilot = $true }
    }

    # 2) [VERIFIED] Locations JSON string: Workload=Applications, Location like Copilot*
    # (observed Copilot.M365; other Copilot-family values are reported as found, not
    # matched against a fixed list). LocationSource=PurviewConfig feeds the one-click
    # fingerprint.
    if ($Policy.PSObject.Properties.Name -contains 'Locations' -and -not [string]::IsNullOrEmpty([string]$Policy.Locations)) {
        $locRaw = [string]$Policy.Locations
        $parsed = ConvertFrom-PpaJsonText $locRaw
        if ($null -ne $parsed) {
            foreach ($entry in @($parsed)) {
                if (([string]$entry.Workload) -eq 'Applications' -and ([string]$entry.Location) -match '(?i)^Copilot') {
                    $isCopilot = $true
                    [void]$locations.Add([string]$entry.Location)
                }
                if (([string]$entry.LocationSource) -match '(?i)PurviewConfig') { $purviewConfig = $true }
            }
        }
        else {
            # Parse failure -> regex containment on the raw string, reduced confidence.
            if ($locRaw -match '(?i)"Workload"\s*:\s*"Applications"' -and $locRaw -match '(?i)"Location"\s*:\s*"Copilot[^"]*"') {
                $isCopilot = $true
                $parseFallback = $true
                foreach ($m in [regex]::Matches($locRaw, '(?i)"Location"\s*:\s*"(Copilot[^"]*)"')) { [void]$locations.Add($m.Groups[1].Value) }
            }
            if ($locRaw -match '(?i)PurviewConfig') { $purviewConfig = $true }
        }
        # The documented Copilot location GUID remains a corroborating structural signal.
        if ($locRaw -match [regex]::Escape($script:PpaCopilotLocationGuid)) { $isCopilot = $true }
    }

    # 3) Other explicit Copilot location properties (unverified shapes, null-safe).
    foreach ($prop in @('CopilotLocation', 'Microsoft365CopilotLocation')) {
        if ($Policy.PSObject.Properties.Name -contains $prop) {
            if (([string]$Policy.$prop) -match '(?i)copilot') { $isCopilot = $true }
        }
    }
    # Policy NAME (dspm|copilot|AI) is deliberately NOT a detection signal on its own -
    # it only corroborates in the analyzer's drill-down copy.

    # One-click artifact fingerprint [VERIFIED]: name prefix, comment opening, and
    # LocationSource=PurviewConfig. Two of three suffices (tolerates admin edits).
    $fpName    = ([string]$Policy.Name) -like 'Default DLP policy - *'
    $fpComment = $false
    if ($Policy.PSObject.Properties.Name -contains 'Comment') {
        $fpComment = ([string]$Policy.Comment) -match '(?i)^Prevent data leakage and oversharing by restricting\s+Microsoft 365 Copilot'
    }
    $fpScore = @($fpName, $fpComment, $purviewConfig | Where-Object { $_ }).Count
    $oneClick = ($fpScore -ge 2)

    return [pscustomobject]@{
        isCopilot       = $isCopilot
        copilotLocations = @($locations | Select-Object -Unique)
        parseFallback   = $parseFallback
        oneClick        = $oneClick
    }
}

function Get-PpaRuleLabelReferences {
    # Best-effort: sensitivity-label NAMES referenced by a DLP rule's conditions - the
    # signal behind label-based Copilot content exclusion ('Content contains -> Sensitivity
    # labels'). Labels appear as label groups (type 'Sensitivity') inside
    # ContentContainsSensitiveInformation, or inside the AdvancedRule JSON.
    # Pre-publish Part 8: on a live tenant the .name of a label condition carries the
    # label GUID, not the display name - each reference resolves through the caller's
    # Get-Label inventory map (verbatim fallback when unmapped, so friendly-name
    # fixtures render exactly as before). The AdvancedRule text-scan below only sets
    # the hasLabelCondition boolean and stays unchanged.
    # CONFIRM against a live tenant: rule condition shapes vary by how the policy was built.
    param($Rule, $LabelMap = @{})
    $names = New-Object System.Collections.Generic.List[string]
    # Walk ContentContainsSensitiveInformation: groups -> labels (type Sensitivity).
    foreach ($entry in @($Rule.ContentContainsSensitiveInformation)) {
        foreach ($grp in @($entry.groups)) {
            foreach ($lab in @($grp.labels)) {
                if (([string]$lab.type) -match '(?i)sensitivity' -and $lab.name) {
                    $raw = [string]$lab.name
                    $disp = if ($LabelMap.ContainsKey($raw)) { $LabelMap[$raw] } else { $raw }
                    [void]$names.Add($disp)
                }
            }
        }
        # Flat shape: the entry itself may be a label reference.
        if (([string]$entry.type) -match '(?i)sensitivity' -and $entry.name) {
            $raw = [string]$entry.name
            $disp = if ($LabelMap.ContainsKey($raw)) { $LabelMap[$raw] } else { $raw }
            [void]$names.Add($disp)
        }
    }
    $hasLabelCondition = ($names.Count -gt 0)
    # Fallback: AdvancedRule JSON text mentions a Sensitivity-typed label condition.
    if (-not $hasLabelCondition -and ($Rule.PSObject.Properties.Name -contains 'AdvancedRule')) {
        if (([string]$Rule.AdvancedRule) -match '"type"\s*:\s*"Sensitivity"') { $hasLabelCondition = $true }
    }
    return [pscustomobject]@{ hasLabelCondition = $hasLabelCondition; labelNames = @($names | Select-Object -Unique) }
}

function Get-PpaDspmPolicyItems {
    # Schema-defensive projection of Get-DspmPolicy objects. The cmdlet family is
    # VERIFIED present and readable with Compliance-Reader-tier roles (2026-07-02), but it
    # returned 0 objects in the sandbox, so the object schema is UNKNOWN. Capture Name if
    # present plus every property name/value pair generically; never bind to specific
    # property names beyond Name. (Import-DlpComplianceRuleCollection matched the probe's
    # 'collection' keyword but is legacy DLP rule-collection import tooling - ignored.)
    param($Data)
    $items = New-Object System.Collections.Generic.List[object]
    $artifactNames = Get-PpaSessionArtifactNames
    foreach ($o in @($Data)) {
        if ($null -eq $o) { continue }
        $name = ''
        if ($o.PSObject.Properties.Name -contains 'Name') { $name = [string]$o.Name }
        $props = New-Object System.Collections.Generic.List[object]
        foreach ($pr in $o.PSObject.Properties) {
            # Generic projection: remoting session artifacts must not survive (A.3).
            if ($artifactNames -contains $pr.Name) { continue }
            # Guid is projected as the top-level guid identity field (A.5); keeping it
            # in the bag would double-report identity in delta (ruled at Part C review).
            if ($pr.Name -eq 'Guid') { continue }
            $v = ''
            if ($null -ne $pr.Value) { $v = ("" + ($pr.Value | Out-String)).Trim() }
            if ($v.Length -gt 120) { $v = $v.Substring(0, 117) + '...' }
            $props.Add([pscustomobject]@{ n = [string]$pr.Name; v = $v })
        }
        $items.Add([pscustomobject]@{ name = $name; guid = Get-PpaOptionalGuid $o; props = $props.ToArray() })
    }
    return $items.ToArray()
}

function Get-PpaAppRetentionItems {
    # Projection of Get-AppRetentionCompliancePolicy - the modern AI retention locations
    # ("Microsoft Copilot experiences", "Enterprise AI apps", "Other AI apps") live in the
    # App retention family. [VERIFIED 2026-07, Wave 5 cleanup Part 1] the carrier property
    # is 'Applications'; the observed Copilot token is 'Users:M365Copilot' (plural 'Users:',
    # not the doc-grounded 'User:' singular - the M365Copilot containment match below covers
    # both). Each item still records whether the property was present at all - absent/odd
    # shapes degrade to a not-assertable transparency line in the analyzer, never a false
    # absence.
    param($Data)
    $items = New-Object System.Collections.Generic.List[object]
    foreach ($p in @($Data)) {
        if ($null -eq $p) { continue }
        $hasApplications = ($p.PSObject.Properties.Name -contains 'Applications')
        $apps = @()
        if ($hasApplications) { $apps = @($p.Applications | Where-Object { $_ } | ForEach-Object { [string]$_ }) }
        $enabled = ''
        if ($p.PSObject.Properties.Name -contains 'Enabled') { $enabled = [string]$p.Enabled }
        $items.Add([pscustomobject]@{
            name            = [string]$p.Name
            guid            = Get-PpaOptionalGuid $p
            enabled         = $enabled
            hasApplications = $hasApplications
            applications    = $apps
            copilotCovered  = (@($apps -match '(?i)M365Copilot').Count -gt 0)
        })
    }
    return $items.ToArray()
}

function Get-PpaCcCopilotItems {
    # Communication Compliance Copilot scoping (spec F4, VERIFIED end to end 2026-07-02).
    # Scoping lives on the RULE, not the policy - the policy-level Locations property was
    # observed empty even for a Copilot-scoped policy. ContentSources on the rule is a JSON
    # string; observed shape: {"RevieweeName":"AllUsersGroupsOfTenant", ...,
    # "Workloads":["Copilot"], "ThirdPartyWorkloads":null, "UnifiedGenAIWorkloads":null}.
    # Rule->policy association: the rule's policy reference property when present, else
    # Name equality (VERIFIED: the template-created pair shares the name
    # "Microsoft 365 Copilot interactions").
    param($Policies, $Rules)
    $items = New-Object System.Collections.Generic.List[object]
    foreach ($p in @($Policies)) {
        if ($null -eq $p) { continue }
        $rule = $null
        foreach ($r in @($Rules)) {
            if ($null -eq $r) { continue }
            $assoc = $false
            foreach ($prop in @('Policy', 'PolicyId', 'ParentPolicyName')) {
                if ($r.PSObject.Properties.Name -contains $prop -and $r.$prop) {
                    $ref = [string]$r.$prop
                    if ($ref -eq [string]$p.Name -or ($p.PSObject.Properties.Name -contains 'Guid' -and $ref -eq [string]$p.Guid)) { $assoc = $true; break }
                }
            }
            if (-not $assoc -and ([string]$r.Name) -eq ([string]$p.Name)) { $assoc = $true }
            if ($assoc) { $rule = $r; break }
        }

        $workloads = New-Object System.Collections.Generic.List[string]
        $unified = $null; $thirdP = $null; $parseFallback = $false
        if ($null -ne $rule -and $rule.PSObject.Properties.Name -contains 'ContentSources' -and -not [string]::IsNullOrEmpty([string]$rule.ContentSources)) {
            $csRaw = [string]$rule.ContentSources
            $parsed = ConvertFrom-PpaJsonText $csRaw
            if ($null -ne $parsed) {
                foreach ($entry in @($parsed)) {
                    foreach ($w in @($entry.Workloads | Where-Object { $_ })) { [void]$workloads.Add([string]$w) }
                    if ($null -ne $entry.UnifiedGenAIWorkloads) { $unified = (@($entry.UnifiedGenAIWorkloads | Where-Object { $_ }) -join ', ') }
                    if ($null -ne $entry.ThirdPartyWorkloads)   { $thirdP  = (@($entry.ThirdPartyWorkloads | Where-Object { $_ }) -join ', ') }
                }
            }
            else {
                # Parse failure -> regex containment on the raw string, reduced confidence.
                $parseFallback = $true
                if ($csRaw -match '(?i)"Workloads"\s*:\s*\[[^\]]*"Copilot"') { [void]$workloads.Add('Copilot') }
                if ($csRaw -match '(?i)"UnifiedGenAIWorkloads"\s*:\s*(?!null)\S') { $unified = '(present - unparsed)' }
                if ($csRaw -match '(?i)"ThirdPartyWorkloads"\s*:\s*(?!null)\S')   { $thirdP  = '(present - unparsed)' }
            }
        }

        $enabled = ''
        if ($p.PSObject.Properties.Name -contains 'Enabled') { $enabled = [string]$p.Enabled }
        $items.Add([pscustomobject]@{
            name          = [string]$p.Name
            guid          = Get-PpaOptionalGuid $p
            enabled       = $enabled
            workloads     = @($workloads | Select-Object -Unique)
            unifiedGenAI  = $unified
            thirdParty    = $thirdP
            parseFallback = $parseFallback
            hasRule       = ($null -ne $rule)
        })
    }
    return $items.ToArray()
}

function Get-PpaDspmAi {
    [CmdletBinding()]
    param()

    $rawPols    = Invoke-PpaReadCmdlet -Name 'Get-DlpCompliancePolicy'
    $rawRules   = Invoke-PpaReadCmdlet -Name 'Get-DlpComplianceRule'
    $rawDspm    = Invoke-PpaReadCmdlet -Name 'Get-DspmPolicy'
    $rawAppRet  = Invoke-PpaReadCmdlet -Name 'Get-AppRetentionCompliancePolicy'
    $rawRet     = Invoke-PpaReadCmdlet -Name 'Get-RetentionCompliancePolicy'
    $rawCcPols  = Invoke-PpaReadCmdlet -Name 'Get-SupervisoryReviewPolicyV2'
    $rawCcRules = Invoke-PpaReadCmdlet -Name 'Get-SupervisoryReviewRule'
    # Pre-publish Part 8: the sensitivity-label inventory behind label-reference
    # resolution in Get-PpaRuleLabelReferences (live DLP rules key label conditions
    # on the label GUID). Folded into the outcome below like every other read.
    $rawLabels  = Invoke-PpaReadCmdlet -Name 'Get-Label'

    # Label resolution map (sensitivity-fix shape): Guid, ImmutableId and Name all
    # key the DisplayName (raw Name fallback). PS hashtable literals compare string
    # keys case-insensitively, which covers GUID casing differences for free.
    $labelMap = @{}
    foreach ($l in @($rawLabels.Data)) {
        if ($null -eq $l) { continue }
        $displayName = if ($l.DisplayName) { [string]$l.DisplayName } else { [string]$l.Name }
        foreach ($k in @([string]$l.Guid, [string]$l.ImmutableId, [string]$l.Name)) {
            if (-not [string]::IsNullOrEmpty($k) -and -not $labelMap.ContainsKey($k)) { $labelMap[$k] = $displayName }
        }
    }

    $items = New-Object System.Collections.Generic.List[object]
    $thirdPartyAiDlp = New-Object System.Collections.Generic.List[string]
    foreach ($p in @($rawPols.Data)) {
        # [UNVERIFIED] probable carriers for non-Copilot AI app DLP scoping - reported
        # factually when populated on ANY policy, silent when empty (above-E5 rule).
        foreach ($prop in @('ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException')) {
            if ($p.PSObject.Properties.Name -contains $prop -and @($p.$prop | Where-Object { $_ }).Count -gt 0) {
                [void]$thirdPartyAiDlp.Add([string]$p.Name)
                break
            }
        }

        $signals = Get-PpaCopilotDlpSignals $p
        if (-not $signals.isCopilot) { continue }
        $rules = @($rawRules.Data | Where-Object { $_.ParentPolicyName -eq $p.Name -or $_.Policy -eq $p.Guid })
        $sits = @($rules | ForEach-Object { $_.ContentContainsSensitiveInformation } | ForEach-Object { $_.Name } | Where-Object { $_ } | ForEach-Object { [string]$_ } | Select-Object -Unique)
        $labelNames = New-Object System.Collections.Generic.List[string]
        $hasLabelCondition = $false
        foreach ($r in $rules) {
            $ref = Get-PpaRuleLabelReferences -Rule $r -LabelMap $labelMap
            if ($ref.hasLabelCondition) { $hasLabelCondition = $true }
            foreach ($n in @($ref.labelNames)) { [void]$labelNames.Add($n) }
        }
        $created = ''
        foreach ($prop in @('WhenCreated', 'WhenCreatedUTC', 'CreationTimeUtc')) {
            if ($p.PSObject.Properties.Name -contains $prop -and $p.$prop) { $created = ([datetime]$p.$prop).ToString('yyyy-MM-dd'); break }
        }
        $items.Add([pscustomobject]@{
            name = [string]$p.Name; guid = Get-PpaOptionalGuid $p; mode = [string]$p.Mode; sits = @($sits)
            hasLabelCondition = $hasLabelCondition
            labelRefs = @($labelNames | Select-Object -Unique)
            copilotLocations = @($signals.copilotLocations)
            oneClick = [bool]$signals.oneClick
            parseFallback = [bool]$signals.parseFallback
            created = $created
        })
    }

    $dspmItems   = @($(if ($rawDspm.Status -eq 'Ok') { Get-PpaDspmPolicyItems $rawDspm.Data } else { @() }))
    $appRetItems = @($(if ($rawAppRet.Status -eq 'Ok') { Get-PpaAppRetentionItems $rawAppRet.Data } else { @() }))
    $ccItems     = @($(if ($rawCcPols.Status -eq 'Ok' -and $rawCcRules.Status -eq 'Ok') {
        Get-PpaCcCopilotItems $rawCcPols.Data $rawCcRules.Data
    } else { @() }))
    $outcome = Resolve-PpaCollectorOutcome `
        -ReadStatuses @($rawPols.Status, $rawRules.Status, $rawDspm.Status, $rawAppRet.Status, $rawRet.Status, $rawCcPols.Status, $rawCcRules.Status, $rawLabels.Status) `
        -ItemCount ($items.Count + $dspmItems.Count + $appRetItems.Count + @($rawRet.Data).Count + $ccItems.Count)

    return [pscustomobject]@{
        outcome         = $outcome
        copilotPolicies = [pscustomobject]@{
            status = $rawPols.Status; error = $rawPols.Error; items = $items.ToArray()
            thirdPartyAiDlpPolicies = @($thirdPartyAiDlp | Select-Object -Unique)
        }
        dspmPolicies = [pscustomobject]@{
            status = $rawDspm.Status; error = $rawDspm.Error
            items = $dspmItems
        }
        appRetention = [pscustomobject]@{
            status = $rawAppRet.Status; error = $rawAppRet.Error
            items = $appRetItems
        }
        # Classic retention family: only the legacy pre-split combined "Teams chats and
        # Copilot interactions" signal (TeamsChatLocation populated) plus a total count -
        # the full classic projection stays with the Retention section's own collector.
        retentionLegacy = [pscustomobject]@{
            status = $rawRet.Status; error = $rawRet.Error
            totalCount = @($rawRet.Data).Count
            teamsChatPolicies = @($(if ($rawRet.Status -eq 'Ok') {
                @($rawRet.Data) | Where-Object { @($_.TeamsChatLocation | Where-Object { $_ }).Count -gt 0 } | ForEach-Object { [string]$_.Name }
            } else { @() }))
        }
        ccCopilot = [pscustomobject]@{
            policiesStatus = $rawCcPols.Status; policiesError = $rawCcPols.Error
            rulesStatus = $rawCcRules.Status; rulesError = $rawCcRules.Error
            items = $ccItems
        }
    }
}