Private/Analyze/Invoke-PpaDspmAiAnalyzer.ps1
|
# Invoke-PpaDspmAiAnalyzer.ps1 - analyzer for section 08 (DSPM for AI / Copilot). # Evidence from Security & Compliance artifacts: DLP policies scoped to Microsoft 365 # Copilot (EnforcementPlanes=CopilotExperiences / Locations Workload=Applications with # Copilot* locations - both VERIFIED 2026-07-02, docs/specs/ai-findings-build-spec.md). # Severity policy (spec global rule 3): Copilot-experiences DLP is an E5-included feature, # so its ABSENCE is a legitimate Recommendation; simulation mode is an Improvement. # Pay-as-you-go / Agent 365 gated surfaces are only ever reported as Informational. # ASCII-only source. Depends on New-PpaFinding/New-PpaSection/Get-PpaRequirement. Set-StrictMode -Off function New-PpaAiDegradedFinding { # Honest degradation for one AI sub-read (spec global rule 2): # - CommandNotFound -> Informational transparency note: the surface is not exposed to # this session/tenant, so there is nothing to verify and no severity. # - AccessDenied / Error -> Verify manually; the remark carries the real reason (for # AccessDenied, the note names the missing role when the caller provides one). param( [Parameter(Mandatory = $true)][string]$Id, [Parameter(Mandatory = $true)][string]$DomId, [Parameter(Mandatory = $true)][string]$Surface, [Parameter(Mandatory = $true)][string]$CollectorStatus, [string]$ErrorText, $LearnMore = @(), [string]$Requires, [string]$AccessDeniedNote ) $params = @{ Id = $Id; DomId = $DomId; LearnMore = $LearnMore } if (-not [string]::IsNullOrEmpty($Requires)) { $params.Requires = $Requires } if ($CollectorStatus -eq 'CommandNotFound') { $params.Title = "$Surface - surface not exposed to this session" $params.Status = 'Informational' $params.Whyline = "The $Surface read surface is not exposed to this session or tenant, so there is nothing to assess - a transparency note, not a gap." $params.Table = New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @($Surface, 'Not exposed to this session / tenant') -Status 'Informational' -Remark $ErrorText) } else { $note = if ($CollectorStatus -eq 'AccessDenied' -and -not [string]::IsNullOrEmpty($AccessDeniedNote)) { $AccessDeniedNote } else { $ErrorText } $params.Title = "$Surface not readable this session" $params.Status = 'Verify manually' $params.Whyline = "$Surface could not be read this session, so this posture was not evaluated - confirm in the Purview portal." $params.Table = New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @($Surface, 'Not readable this session') -Status 'Verify manually' -Remark $note) } return New-PpaFinding @params } function Invoke-PpaDspmAiAnalyzer { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Raw, # Parsed Data/license-requirements.json (static annotation map, not detection). $LicenseMap, # Whether any site/container-scoped sensitivity label exists (from the labels collector). [bool]$HasSiteLabels = $false ) $mid = Get-PpaMidDot $dlpStatus = [string]$Raw.copilotPolicies.status $policies = @($Raw.copilotPolicies.items) $thirdParty = @($Raw.copilotPolicies.thirdPartyAiDlpPolicies | Where-Object { $_ }) $findings = New-Object System.Collections.Generic.List[object] $lm01 = @( @{ label = 'Microsoft Purview portal - DSPM for AI'; url = 'https://purview.microsoft.com'; tag = 'portal' } @{ label = 'Data security for AI'; url = 'https://learn.microsoft.com/en-us/purview/ai-microsoft-purview'; tag = 'docs' } ) # --- DLP read did not complete -> unknown is never asserted as empty. The section # still continues to AI-04+ below: each AI sub-read degrades independently (rule 2). --- $dlpReadable = ($dlpStatus -eq 'Ok') if (-not $dlpReadable) { $findings.Add((New-PpaFinding -Id 'AI-01' -DomId 'f-ai-1' -Title 'Copilot DLP posture not readable this session' -Status 'Verify manually' -Requires (Get-PpaRequirement $LicenseMap 'AI-01') ` -Whyline 'DLP policies could not be enumerated this session, so Copilot data security posture was not evaluated - confirm in the Purview portal.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('Copilot-scoped DLP policies', 'Not readable this session') -Status 'Verify manually' -Remark ([string]$Raw.copilotPolicies.error) )) -LearnMore $lm01)) } # --- AI-01: AI surface, from S&C evidence --- if ($dlpReadable -and $policies.Count -gt 0) { $findings.Add((New-PpaFinding -Id 'AI-01' -DomId 'f-ai-1' -Title 'Copilot-scoped DLP artifacts present - AI surface in scope' -Status 'Informational' -Requires (Get-PpaRequirement $LicenseMap 'AI-01') ` -Whyline 'DLP policies scoped to the Microsoft 365 Copilot location exist, which puts AI data security posture in scope for this tenant.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('Copilot-scoped DLP policies', [string]$policies.Count) -Status 'Informational' )) -LearnMore $lm01)) } elseif ($dlpReadable) { # No artifacts is NOT evidence that Copilot is absent - deployment is not # detectable read-only from the S&C session. $findings.Add((New-PpaFinding -Id 'AI-01' -DomId 'f-ai-1' -Title 'Copilot deployment not detectable from this session' -Status 'Informational' -Requires (Get-PpaRequirement $LicenseMap 'AI-01') ` -Whyline 'No Copilot-scoped DLP artifacts were found, and Copilot deployment itself is not readable from the Security & Compliance session. If Copilot is deployed, DSPM for AI applies.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('Copilot-scoped DLP policies', '0') -Status 'Informational' New-PpaRow -Cells @('Microsoft 365 Copilot deployment', 'Not detectable read-only') -Status 'Verify manually' -Remark 'confirm Copilot deployment with the client; if deployed, configure DSPM for AI (Purview portal) and re-run.' )) -LearnMore $lm01)) } # --- AI-02: Copilot DLP posture (absence / simulation / enforcing - spec F2) --- $lm02 = @( @{ label = 'Data Security Posture Management for AI'; url = 'https://learn.microsoft.com/en-us/purview/dspm-for-ai'; tag = 'docs' } @{ label = 'DLP for Microsoft 365 Copilot and Copilot Chat'; url = 'https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-learn-about'; tag = 'docs' } ) # A row reported factually when the [UNVERIFIED] third-party AI app DLP carriers are # populated on ANY policy; silent when empty (above-E5 rule - no meaning asserted). $tpRemark = 'Reported factually - third-party AI app scoping is a pay-as-you-go / Agent 365 gated surface and its meaning is not asserted from cmdlet output.' if (-not $dlpReadable) { # No AI-02/AI-03 verdicts from a failed read - AI-01 above carries the Verify. } elseif ($policies.Count -eq 0) { $rows02 = New-Object System.Collections.Generic.List[object] $rows02.Add((New-PpaRow -Cells @('Copilot-targeting DLP policies', '0') -Status 'Recommendation')) if ($thirdParty.Count -gt 0) { $rows02.Add((New-PpaRow -Cells @('Third-party app DLP locations configured', ($thirdParty -join ', ')) -Status 'Informational' -Remark $tpRemark)) } $findings.Add((New-PpaFinding -Id 'AI-02' -DomId 'f-ai-2' -Title 'Copilot interactions not governed by DLP' -Status 'Recommendation' -Requires (Get-PpaRequirement $LicenseMap 'AI-02') ` -Whyline 'Microsoft 365 Copilot interactions are not governed by any DLP policy - prompts and responses carry no data security control.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows $rows02.ToArray()) -LearnMore $lm02)) } else { $enforcing = @($policies | Where-Object { [string]$_.mode -match '(?i)^enable$|enforce' }) $offModes = @($policies | Where-Object { [string]$_.mode -notmatch '(?i)^enable$|enforce' } | ForEach-Object { [string]$_.mode } | Select-Object -Unique) $status02 = if ($enforcing.Count -eq $policies.Count) { 'OK' } else { 'Improvement' } $title02 = if ($status02 -eq 'OK') { 'Copilot DLP policy is enforcing' } else { 'Copilot DLP coverage in simulation mode - not enforced' } $why02 = if ($status02 -eq 'OK') { 'Copilot-scoped DLP coverage is active and enforcing.' } else { "Copilot DLP coverage exists but is in simulation mode ($($offModes -join ', ')) - interactions are not being enforced. Review simulation results and enable when tuned." } $rows02 = New-Object System.Collections.Generic.List[object] foreach ($p in $policies) { $isEnf = [string]$p.mode -match '(?i)^enable$|enforce' $remarkParts = New-Object System.Collections.Generic.List[string] if (@($p.copilotLocations).Count -gt 0) { [void]$remarkParts.Add('Copilot locations: ' + (@($p.copilotLocations) -join ', ') + '.') } if ($p.oneClick) { [void]$remarkParts.Add('Microsoft-deployed one-click default (fingerprint match).') } if ($p.parseFallback) { [void]$remarkParts.Add('Locations parsed via text fallback - reduced confidence.') } # New-PpaRow drops a null/empty Remark, so passing it unconditionally is safe. $remark = if ($remarkParts.Count -gt 0) { $remarkParts -join ' ' } else { $null } $rows02.Add((New-PpaRow -Cells @($p.name, (@($p.sits) -join ', '), [string]$p.mode, [string]$p.created) -Status ($(if ($isEnf) { 'OK' } else { 'Improvement' })) -Remark $remark)) } if ($thirdParty.Count -gt 0) { $rows02.Add((New-PpaRow -Cells @('Third-party app DLP locations configured', ($thirdParty -join ', '), '', '') -Status 'Informational' -Remark $tpRemark)) } $findings.Add((New-PpaFinding -Id 'AI-02' -DomId 'f-ai-2' -Title $title02 -Status $status02 -Requires (Get-PpaRequirement $LicenseMap 'AI-02') ` -Whyline $why02 ` -Table (New-PpaTable -Columns @('AI Policy', 'Conditions', 'Mode', 'Created', 'Status') -Rows $rows02.ToArray()) -LearnMore $lm02)) } # --- AI-03: label-based Copilot content exclusion (the oversharing control itself) --- # Evidence from the Copilot-location rules: does any rule use a 'Content contains -> # Sensitivity labels' condition (restrict Copilot from processing labeled content)? # Only assessable when Copilot-scoped policies exist to carry such rules. if ($policies.Count -gt 0) { $labelRules = @($policies | Where-Object { $_.hasLabelCondition }) $labelNames = @($policies | ForEach-Object { $_.labelRefs } | Where-Object { $_ } | Select-Object -Unique) $status03 = if ($labelRules.Count -gt 0) { 'OK' } else { 'Recommendation' } $title03 = if ($labelRules.Count -gt 0) { 'Label-based Copilot content exclusion configured' } else { 'No label-based Copilot content exclusion' } $ruleSetting = if ($labelRules.Count -gt 0) { if ($labelNames.Count -gt 0) { "$($labelRules.Count) policies $mid labels: " + ($labelNames -join ', ') } else { "$($labelRules.Count) policies" } } else { 'None detected' } $rows03 = @( New-PpaRow -Cells @('Copilot-location rules referencing sensitivity labels', $ruleSetting) -Status $status03 New-PpaRow -Cells @('Container/site-scoped sensitivity labels defined', ($(if ($HasSiteLabels) { 'Yes' } else { 'No' }))) -Status 'Informational' ) $findings.Add((New-PpaFinding -Id 'AI-03' -DomId 'f-ai-3' -Title $title03 -Status $status03 ` -Whyline "Without label-based exclusion, Copilot can process and surface labeled content users can technically open but shouldn't - oversharing risk." ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows $rows03) ` -LearnMore @( @{ label = 'DLP for Microsoft 365 Copilot and Copilot Chat'; url = 'https://learn.microsoft.com/en-us/purview/dlp-microsoft365-copilot-location-learn-about'; tag = 'docs' } @{ label = 'Considerations for Copilot & oversharing'; url = 'https://learn.microsoft.com/en-us/purview/ai-microsoft-purview-considerations'; tag = 'docs' }))) } # --- AI-04: DSPM collection policies (spec F1; PAYG / Agent 365 gated surface) --- # Skipped entirely when the raw shape predates this sub-read (older captures). if ($null -ne $Raw.dspmPolicies) { $lm04 = @(@{ label = 'Retention and AI app locations (prerequisites)'; url = 'https://learn.microsoft.com/en-us/purview/create-retention-policies'; tag = 'docs' }) $dspmStatus = [string]$Raw.dspmPolicies.status if ($dspmStatus -eq 'Ok') { $dspmItems = @($Raw.dspmPolicies.items | Where-Object { $null -ne $_ }) if ($dspmItems.Count -gt 0) { # Object schema is UNKNOWN (0 objects in the verified sandbox) - render every # property name/value pair the collector captured, dynamically. $rows04 = New-Object System.Collections.Generic.List[object] foreach ($it in $dspmItems) { $label = if ([string]::IsNullOrEmpty([string]$it.name)) { '(unnamed policy)' } else { [string]$it.name } $first = $true foreach ($pr in @($it.props)) { $rows04.Add((New-PpaRow -Cells @($(if ($first) { $label } else { '' }), [string]$pr.n, [string]$pr.v) -Status 'Informational' -Indent:(-not $first))) $first = $false } if ($first) { $rows04.Add((New-PpaRow -Cells @($label, '', '') -Status 'Informational')) } } $findings.Add((New-PpaFinding -Id 'AI-04' -DomId 'f-ai-4' -Title "DSPM collection policies configured: $($dspmItems.Count)" -Status 'Informational' ` -Whyline 'Collection policies govern interaction capture for Enterprise AI apps and Other AI apps (pay-as-you-go / Agent 365 surfaces) and are a prerequisite for governing AI apps other than Microsoft 365 Copilot and Copilot Studio.' ` -Table (New-PpaTable -Columns @('Collection Policy', 'Property', 'Value', 'Status') -Rows $rows04.ToArray()) -LearnMore $lm04)) } else { # Above-E5 rule: absence of a PAYG/Agent 365 feature is Informational, never a gap. $findings.Add((New-PpaFinding -Id 'AI-04' -DomId 'f-ai-4' -Title 'No DSPM collection policies detected' -Status 'Informational' ` -Whyline 'Collection policies govern interaction capture for Enterprise AI apps and Other AI apps and require pay-as-you-go billing or Agent 365 licensing; applicable only if licensed. They are a prerequisite for governing AI apps other than Microsoft 365 Copilot and Copilot Studio.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('DSPM collection policies', '0') -Status 'Informational' )) -LearnMore $lm04)) } } else { $findings.Add((New-PpaAiDegradedFinding -Id 'AI-04' -DomId 'f-ai-4' -Surface 'DSPM collection policies (Get-DspmPolicy)' ` -CollectorStatus $dspmStatus -ErrorText ([string]$Raw.dspmPolicies.error) -LearnMore $lm04)) } } # --- AI-05: Copilot / AI-app retention coverage (spec F3) --- # Skipped entirely when the raw shape predates this sub-read (older captures). if ($null -ne $Raw.appRetention) { $lm05 = @( @{ label = 'Retention cmdlets (older vs newer locations)'; url = 'https://learn.microsoft.com/en-us/purview/retention-cmdlets'; tag = 'docs' } @{ label = 'Create and configure retention policies'; url = 'https://learn.microsoft.com/en-us/purview/create-retention-policies'; tag = 'docs' } ) $appStatus = [string]$Raw.appRetention.status if ($appStatus -ne 'Ok') { $findings.Add((New-PpaAiDegradedFinding -Id 'AI-05' -DomId 'f-ai-5' -Surface 'Copilot / AI app retention (Get-AppRetentionCompliancePolicy)' ` -CollectorStatus $appStatus -ErrorText ([string]$Raw.appRetention.error) -LearnMore $lm05)) } else { $appItems = @($Raw.appRetention.items | Where-Object { $null -ne $_ }) $covering = @($appItems | Where-Object { $_.copilotCovered }) $noAppsShape = @($appItems | Where-Object { -not $_.hasApplications }) $otherTokens = @($appItems | ForEach-Object { $_.applications } | Where-Object { $_ -and ($_ -notmatch '(?i)teams|yammer|viva|copilot') } | Select-Object -Unique) $legacyOk = ([string]$Raw.retentionLegacy.status -eq 'Ok') $legacyTeams = @($Raw.retentionLegacy.teamsChatPolicies | Where-Object { $_ }) $classicTotal = if ($legacyOk) { [int]$Raw.retentionLegacy.totalCount } else { 0 } $rows05 = New-Object System.Collections.Generic.List[object] if ($covering.Count -gt 0) { $status05 = 'OK' $title05 = 'Copilot interaction data covered by retention' $why05 = 'A policy in the App retention family covers Microsoft Copilot experiences, giving Copilot interaction data a retain/delete lifecycle.' foreach ($p in $covering) { $setting = (@($p.applications) -join ', ') if (-not [string]::IsNullOrEmpty([string]$p.enabled)) { $setting = "$setting (Enabled: $($p.enabled))" } $rows05.Add((New-PpaRow -Cells @([string]$p.name, $setting) -Status 'OK')) } } elseif ($noAppsShape.Count -gt 0) { # [carrier VERIFIED live: 'Users:M365Copilot' (Wave 5 cleanup Part 1)] this # branch stays for tenants where policies exist but the Applications property # is absent/shaped differently - coverage there is genuinely un-assertable. $status05 = 'Verify manually' $title05 = 'Copilot retention coverage not assertable this session' $why05 = 'App retention policies exist, but Copilot retention coverage is not assertable from cmdlet output on this tenant - confirm the policy locations in the Purview portal.' foreach ($p in $appItems) { $rows05.Add((New-PpaRow -Cells @([string]$p.name, $(if ([string]::IsNullOrEmpty([string]$p.enabled)) { '' } else { "Enabled: $($p.enabled)" })) -Status 'Verify manually')) } } elseif ($appItems.Count -eq 0 -and $legacyOk -and $classicTotal -eq 0) { # Sparse-tenant regression case: zero retention policies of any kind renders # as a clean Recommendation (E5-included feature), never a crash or blank. $status05 = 'Recommendation' $title05 = 'No retention lifecycle for Copilot interactions' $why05 = 'No retention policies exist tenant-wide, so Copilot interaction data has no retention/deletion lifecycle policy.' $rows05.Add((New-PpaRow -Cells @('Copilot-experiences retention coverage', 'None detected') -Status 'Recommendation')) } else { $status05 = 'Improvement' $title05 = 'Copilot interaction data has no retention lifecycle' $why05 = 'Retention exists in the tenant, but no policy covers Microsoft Copilot experiences - Copilot interaction data has no retention/deletion lifecycle policy.' $rows05.Add((New-PpaRow -Cells @('Copilot-experiences retention coverage', 'None detected') -Status 'Improvement')) } if ($legacyTeams.Count -gt 0) { $rows05.Add((New-PpaRow -Cells @('Legacy combined Teams/Copilot-era policies', ($legacyTeams -join ', ')) -Status 'Informational' ` -Remark 'TeamsChatLocation is populated - this may be the pre-split combined "Teams chats and Copilot interactions" location. Copilot coverage is not asserted from this legacy shape.')) } if ($otherTokens.Count -gt 0) { # [UNVERIFIED] exact tokens for Enterprise / Other AI apps - report verbatim. $rows05.Add((New-PpaRow -Cells @('Other AI app retention tokens (verbatim)', ($otherTokens -join ', ')) -Status 'Informational' ` -Remark 'Reported verbatim - the exact Applications tokens for Enterprise AI apps / Other AI apps are not yet documented.')) } else { $rows05.Add((New-PpaRow -Cells @('Enterprise / Other AI app retention locations', 'None detected') -Status 'Informational' ` -Remark 'Enterprise AI apps and Other AI apps retention locations require pay-as-you-go billing or Agent 365 licensing; applicable only if licensed.')) } $findings.Add((New-PpaFinding -Id 'AI-05' -DomId 'f-ai-5' -Title $title05 -Status $status05 ` -Whyline $why05 ` -Table (New-PpaTable -Columns @('Configuration / Policy', 'Setting', 'Status') -Rows $rows05.ToArray()) -LearnMore $lm05)) } } # --- AI-06: Communication Compliance Copilot monitoring (spec F4, VERIFIED) --- # Skipped entirely when the raw shape predates this sub-read (older captures). if ($null -ne $Raw.ccCopilot) { $lm06 = @(@{ label = 'Communication Compliance for generative AI'; url = 'https://learn.microsoft.com/en-us/purview/communication-compliance-copilot'; tag = 'docs' }) $req06 = Get-PpaRequirement $LicenseMap 'AI-06' $ccRoleNote = 'Communication Compliance objects require membership in a Communication Compliance role group; not readable with the roles used for this run.' # The IRM cross-reference (spec rule 7): risky-AI scoring renders in the IRM section. $irmXrefRow = New-PpaRow -Cells @('Insider Risk risky-AI coverage', 'Assessed in the Insider Risk Management section (IRM-03)', '') -Status 'Informational' $ccPolStatus = [string]$Raw.ccCopilot.policiesStatus $ccRuleStatus = [string]$Raw.ccCopilot.rulesStatus if ($ccPolStatus -ne 'Ok' -or $ccRuleStatus -ne 'Ok') { # Degrade on EITHER cmdlet failing (scoping needs the rule read too). $badStatus = if ($ccPolStatus -ne 'Ok') { $ccPolStatus } else { $ccRuleStatus } $badError = if ($ccPolStatus -ne 'Ok') { [string]$Raw.ccCopilot.policiesError } else { [string]$Raw.ccCopilot.rulesError } $findings.Add((New-PpaAiDegradedFinding -Id 'AI-06' -DomId 'f-ai-6' -Surface 'Communication Compliance Copilot monitoring' ` -CollectorStatus $badStatus -ErrorText $badError -LearnMore $lm06 -Requires $req06 -AccessDeniedNote $ccRoleNote)) } else { $ccItems = @($Raw.ccCopilot.items | Where-Object { $null -ne $_ }) $ccCopilotScoped = @($ccItems | Where-Object { @($_.workloads) -match '(?i)^Copilot$' }) $rows06 = New-Object System.Collections.Generic.List[object] if ($ccCopilotScoped.Count -eq 0) { $status06 = 'Recommendation' $title06 = 'AI prompts and responses not monitored by Communication Compliance' $why06 = "AI prompts and responses are not monitored by Communication Compliance. The 'Detect Microsoft 365 Copilot interactions' template provides a one-step baseline." $rows06.Add((New-PpaRow -Cells @('Copilot-scoped Communication Compliance policies', '0', '') -Status 'Recommendation')) } else { $enabledScoped = @($ccCopilotScoped | Where-Object { [string]$_.enabled -match '(?i)^true$' }) $status06 = if ($enabledScoped.Count -gt 0) { 'OK' } else { 'Improvement' } $title06 = if ($status06 -eq 'OK') { 'Copilot interactions monitored by Communication Compliance' } else { 'Copilot Communication Compliance policy is disabled' } $why06 = if ($status06 -eq 'OK') { 'A Communication Compliance policy scopes the Copilot workload - AI prompts and responses are captured for review.' } else { 'A Copilot-scoped Communication Compliance policy exists but is disabled - AI prompts and responses are not being captured.' } foreach ($p in $ccCopilotScoped) { $remark = if ($p.parseFallback) { 'ContentSources parsed via text fallback - reduced confidence.' } else { $null } $rows06.Add((New-PpaRow -Cells @([string]$p.name, [string]$p.enabled, (@($p.workloads) -join ', ')) -Status ($(if ([string]$p.enabled -match '(?i)^true$') { 'OK' } else { 'Improvement' })) -Remark $remark)) } } # PAYG-gated channels: non-null -> factual Informational rows; null -> silent. $genAi = @($ccItems | Where-Object { $null -ne $_.unifiedGenAI } | ForEach-Object { "$($_.name): $($_.unifiedGenAI)" }) if ($genAi.Count -gt 0) { $rows06.Add((New-PpaRow -Cells @('Unified GenAI workloads (PAYG-gated)', ($genAi -join ' ; '), '') -Status 'Informational')) } $tp06 = @($ccItems | Where-Object { $null -ne $_.thirdParty } | ForEach-Object { "$($_.name): $($_.thirdParty)" }) if ($tp06.Count -gt 0) { $rows06.Add((New-PpaRow -Cells @('Third-party workloads (PAYG-gated)', ($tp06 -join ' ; '), '') -Status 'Informational')) } $rows06.Add($irmXrefRow) $findings.Add((New-PpaFinding -Id 'AI-06' -DomId 'f-ai-6' -Title $title06 -Status $status06 -Requires $req06 ` -Whyline $why06 ` -Table (New-PpaTable -Columns @('CC Policy / Configuration', 'Enabled / Setting', 'Workloads', 'Status') -Rows $rows06.ToArray()) -LearnMore $lm06)) } } # --- glance --- $noun = if ($policies.Count -eq 1) { 'policy' } else { 'policies' } if (-not $dlpReadable) { $glance = New-PpaGlance -Name 'DSPM for AI' -Metric 'not readable' -Sub 'confirm in portal' } elseif ($policies.Count -eq 0) { $glance = New-PpaGlance -Name 'DSPM for AI' -Metric '0 policies' -Sub "AI surface $mid not detected" } else { $enforcingG = @($policies | Where-Object { [string]$_.mode -match '(?i)^enable$|enforce' }) $modeState = if ($enforcingG.Count -eq $policies.Count) { 'enforcing' } else { 'audit only' } $glance = New-PpaGlance -Name 'DSPM for AI' -Metric "$($policies.Count) $noun" -Sub "$modeState $mid AI in scope" } return New-PpaSection -Id 'DSPM_for_AI' -Title 'DSPM for AI - Copilot Data Security' -Group 'AI Security' ` -GroupIcon 'fas fa-robot' -GroupTag 'NEW' -Glance $glance -Findings $findings.ToArray() } |