Private/Analyze/Get-PpaCoverageModel.ps1
|
# Get-PpaCoverageModel.ps1 - the coverage matrix analyzer (Wave 4 spec section 5). # A PURE PROJECTION: builds the CoverageModel from ALREADY-NORMALIZED collector # outputs. It performs NO tenant reads, creates NO findings, assigns NO severities; # cells anchor-link to EXISTING check ids only. Pinned by Tests/Coverage.Tests.ps1; # the read-only guard scans this file like every other Private file. # # Grid: 7 workloads x 3 location-scoped controls. Cell states (closed enum): # Covered | Partial | Test-only | None | Unknown | N/A # (The Wave 4 'Held' render-hold on Copilot x Retention was LIFTED in Wave 5 # cleanup Part 1: the cell now classifies live from the DSPM app-retention # projection and counts in the totals like any other cell.) # Partial always carries >=1 reason code: ScopedInclude, HasExceptions, # SubsetOfLocations, AdaptiveScope, RuleDisabled. # Aggregation across policies: best-of Covered > Partial > Test-only > None. # Unknown iff the governing collector outcome is outside { Populated, Empty } - # never otherwise - and Unknown never counts toward any gap total. # ASCII-only source (Windows PowerShell 5.1). Set-StrictMode -Off function Get-PpaCoverageApplicability { $path = Join-Path $PSScriptRoot 'ppa-coverage-applicability.json' return ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json) } function Get-PpaCoverageProvenance { $path = Join-Path $PSScriptRoot 'ppa-coverage-provenance.json' return ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json) } function Get-PpaCoverageItemScope { # Scope token for one normalized policy item and workload key. Prefers the # Part D locationScope projection; falls back to the legacy shapes so old # fixtures/snapshots (sparse regression case) still classify: # - DLP boolean object: true -> 'All', false -> 'None' # - Retention token array: token present -> 'All', absent -> 'None' param($Item, [string]$Key) if ($Item.PSObject.Properties.Name -contains 'locationScope' -and $null -ne $Item.locationScope) { if ($Item.locationScope.PSObject.Properties.Name -contains $Key) { return [string]$Item.locationScope.$Key } return 'None' } if ($Item.PSObject.Properties.Name -contains 'locations' -and $null -ne $Item.locations) { $loc = $Item.locations if ($loc -is [System.Management.Automation.PSCustomObject]) { if ($loc.PSObject.Properties.Name -contains $Key -and $loc.$Key) { return 'All' } return 'None' } $tokenMap = @{ exchange = 'Exchange'; sharePoint = 'SharePoint'; oneDrive = 'OneDrive'; groups = 'Groups' } if ($tokenMap.ContainsKey($Key) -and (@($loc) -contains $tokenMap[$Key])) { return 'All' } return 'None' } return 'None' } function Get-PpaCoverageItemException { param($Item, [string]$Key) if ($Item.PSObject.Properties.Name -contains 'locationExceptions' -and $null -ne $Item.locationExceptions) { if ($Item.locationExceptions.PSObject.Properties.Name -contains $Key) { return [bool]$Item.locationExceptions.$Key } } return $false } function Merge-PpaCoverageContribution { # Best-of aggregation (ruled): Covered > Partial > Test-only > None. Reason # codes surface only when the FINAL state is Partial, as the union of reasons # from the Partial contributions. param($Contributions) $rank = @{ 'Covered' = 3; 'Partial' = 2; 'Test-only' = 1; 'None' = 0 } $state = 'None' $contributors = New-Object System.Collections.Generic.List[string] foreach ($c in @($Contributions)) { if ($null -eq $c) { continue } $contributors.Add([string]$c.name) if ($rank[[string]$c.tier] -gt $rank[$state]) { $state = [string]$c.tier } } $reasons = @() if ($state -eq 'Partial') { $all = New-Object System.Collections.Generic.List[string] foreach ($c in @($Contributions)) { if ($null -ne $c -and [string]$c.tier -eq 'Partial') { foreach ($r in @($c.reasons)) { if ($all -notcontains $r) { $all.Add($r) } } } } $reasons = @($all | Sort-Object) } return [pscustomobject]@{ state = $state; reasons = $reasons; contributors = $contributors.ToArray() } } function Get-PpaCoverageModel { [CmdletBinding()] param( # Hashtable: section id -> normalized collector output (null/absent allowed). [Parameter(Mandatory = $true)] $RawMap ) $applic = Get-PpaCoverageApplicability $proven = Get-PpaCoverageProvenance $readable = @('Populated', 'Empty') $rowDefs = @( @{ key = 'exchange'; label = 'Exchange Online' } @{ key = 'sharePoint'; label = 'SharePoint' } @{ key = 'oneDrive'; label = 'OneDrive' } @{ key = 'teams'; label = 'Teams' } @{ key = 'endpoint'; label = 'Endpoint' } @{ key = 'powerBI'; label = 'Power BI' } @{ key = 'copilot'; label = 'Copilot' } ) # Column axis follows the canonical solution order (Wave 5 cleanup Part 5): # each column's OWNING SECTION appears in Get-PpaCanonicalSectionOrder # sequence - Sensitivity Labels (auto-labeling) before DLP before Retention. # The three-surface guardrail in Tests/Coverage.Tests.ps1 pins this. $colDefs = @( @{ key = 'autoLabel'; label = 'Auto-labeling'; section = 'Sensitivity_Labels'; checkId = 'LABELS-03' } @{ key = 'dlp'; label = 'DLP'; section = 'Data_Loss_Prevention'; checkId = 'DLP-01' } @{ key = 'retention'; label = 'Retention'; section = 'Retention'; checkId = 'RET-01' } ) function Get-RawFor([string]$SectionId) { if ($RawMap -is [System.Collections.IDictionary]) { if ($RawMap.Contains($SectionId)) { return $RawMap[$SectionId] } return $null } return $RawMap.$SectionId } function Get-OutcomeFor([string]$SectionId) { $raw = Get-RawFor $SectionId if ($null -eq $raw) { return 'NotRun' } $o = [string]$raw.outcome if ([string]::IsNullOrEmpty($o)) { return 'NotRun' } return $o } # ---- contributions per column ---- $dlpRaw = Get-RawFor 'Data_Loss_Prevention' $dlpRules = @() if ($null -ne $dlpRaw) { $dlpRules = @($dlpRaw.rules.items) } function Get-DlpContribution([string]$RowKey) { $out = New-Object System.Collections.Generic.List[object] if ($null -eq $dlpRaw) { return @() } foreach ($p in @($dlpRaw.policies.items)) { $scope = Get-PpaCoverageItemScope $p $RowKey if ($scope -eq 'None') { continue } $enforcing = Test-PpaDlpEnforcing ([string]$p.mode) if (-not $enforcing) { $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Test-only'; reasons = @() }) continue } $reasons = New-Object System.Collections.Generic.List[string] if ($scope -eq 'Scoped') { $reasons.Add('ScopedInclude') } if (Get-PpaCoverageItemException $p $RowKey) { $reasons.Add('HasExceptions') } $pr = @($dlpRules | Where-Object { [string]$_.policyName -eq [string]$p.name }) if ($pr.Count -gt 0 -and @($pr | Where-Object { $_.disabled }).Count -eq $pr.Count) { $reasons.Add('RuleDisabled') } if ($reasons.Count -gt 0) { $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Partial'; reasons = $reasons.ToArray() }) } else { $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Covered'; reasons = @() }) } } return $out.ToArray() } function Get-CopilotDlpContribution { $raw = Get-RawFor 'DSPM_for_AI' if ($null -eq $raw) { return @() } $out = New-Object System.Collections.Generic.List[object] foreach ($p in @($raw.copilotPolicies.items)) { $tier = 'Test-only' if (Test-PpaDlpEnforcing ([string]$p.mode)) { $tier = 'Covered' } $out.Add([pscustomobject]@{ name = [string]$p.name; tier = $tier; reasons = @() }) } return $out.ToArray() } function Get-CopilotRetentionContribution { # Wave 5 cleanup Part 1 (docs/testday-activation.md item 1): grounded in the # DSPM collector's app-retention projection. Coverage = the VERIFIED # 'Users:M365Copilot' Applications token (the provenance registry rowOverrides # entry carries the grounding). A covered policy contributes Covered regardless # of its Enabled flag - the cell mirrors the AI-05 verdict, which reports # coverage with the Enabled state visible in its drill-down. Falls back to # matching the applications tokens when copilotCovered is absent (older # captures), like the legacy-shape fallbacks in Get-PpaCoverageItemScope. $raw = Get-RawFor 'DSPM_for_AI' if ($null -eq $raw) { return @() } $out = New-Object System.Collections.Generic.List[object] foreach ($p in @($raw.appRetention.items)) { if ($null -eq $p) { continue } $covered = $false if ($p.PSObject.Properties.Name -contains 'copilotCovered') { $covered = [bool]$p.copilotCovered } elseif ($p.PSObject.Properties.Name -contains 'applications') { $covered = (@(@($p.applications) -match '(?i)M365Copilot').Count -gt 0) } if (-not $covered) { continue } $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Covered'; reasons = @() }) } return $out.ToArray() } function Get-AutoLabelContribution([string]$RowKey) { $raw = Get-RawFor 'Sensitivity_Labels' if ($null -eq $raw) { return @() } $out = New-Object System.Collections.Generic.List[object] foreach ($a in @($raw.autoLabels.items)) { $scope = Get-PpaCoverageItemScope $a $RowKey if ($scope -eq 'None') { continue } $enforcing = -not ([string]$a.mode -match '(?i)test|simul') if (-not $enforcing) { $out.Add([pscustomobject]@{ name = [string]$a.name; tier = 'Test-only'; reasons = @() }) continue } $reasons = New-Object System.Collections.Generic.List[string] if ($scope -eq 'Scoped') { $reasons.Add('ScopedInclude') } if (Get-PpaCoverageItemException $a $RowKey) { $reasons.Add('HasExceptions') } if ($reasons.Count -gt 0) { $out.Add([pscustomobject]@{ name = [string]$a.name; tier = 'Partial'; reasons = $reasons.ToArray() }) } else { $out.Add([pscustomobject]@{ name = [string]$a.name; tier = 'Covered'; reasons = @() }) } } return $out.ToArray() } function Get-RetentionContribution([string]$RowKey) { $raw = Get-RawFor 'Retention' if ($null -eq $raw) { return @() } $out = New-Object System.Collections.Generic.List[object] foreach ($p in @($raw.policies.items)) { $scope = Get-PpaCoverageItemScope $p $RowKey # A Teams row is covered by either Teams retention location. if ($RowKey -eq 'teams') { $ch = Get-PpaCoverageItemScope $p 'teamsChannel' $chat = Get-PpaCoverageItemScope $p 'teamsChat' $scope = 'None' if ($ch -eq 'All' -or $chat -eq 'All') { $scope = 'All' } elseif ($ch -eq 'Scoped' -or $chat -eq 'Scoped') { $scope = 'Scoped' } } if ($scope -eq 'None') { continue } $reasons = New-Object System.Collections.Generic.List[string] if ($scope -eq 'Scoped') { $reasons.Add('SubsetOfLocations') } if ([bool]$p.adaptive) { $reasons.Add('AdaptiveScope') } if ($reasons.Count -gt 0) { $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Partial'; reasons = $reasons.ToArray() }) } else { $out.Add([pscustomobject]@{ name = [string]$p.name; tier = 'Covered'; reasons = @() }) } } return $out.ToArray() } # ---- assemble the grid ---- $cells = New-Object System.Collections.Generic.List[object] $degraded = New-Object System.Collections.Generic.List[object] $seenDegraded = @{} $counts = @{ 'Covered' = 0; 'Partial' = 0; 'Test-only' = 0; 'None' = 0; 'Unknown' = 0; 'N/A' = 0 } foreach ($row in $rowDefs) { foreach ($col in $colDefs) { $rowKey = [string]$row.key; $colKey = [string]$col.key # Cell provenance: column-level registry entry unless a rowOverrides # entry (keyed '<rowKey>.<columnKey>') exists for this exact cell. $prov = [string]$proven.columns.$colKey.provenance $ovKey = $rowKey + '.' + $colKey if ($null -ne $proven.rowOverrides -and $proven.rowOverrides.PSObject.Properties.Name -contains $ovKey) { $prov = [string]$proven.rowOverrides.$ovKey.provenance } $cell = [ordered]@{ row = $rowKey; rowLabel = [string]$row.label column = $colKey; columnLabel = [string]$col.label state = ''; reasons = @(); contributors = @() provenance = $prov checkId = [string]$col.checkId naReason = '' } # Copilot x Retention: LIVE since Wave 5 cleanup Part 1 (un-held per # docs/testday-activation.md item 1). Anchors to AI-05, the existing # Wave 2 check that assesses this exact surface - never RET-01, which # reads classic retention only. if ($rowKey -eq 'copilot' -and $colKey -eq 'retention') { $cell.checkId = 'AI-05' } # Static applicability (reviewed data file) drives N/A cells. $na = @($applic.na | Where-Object { [string]$_.row -eq $rowKey -and [string]$_.column -eq $colKey }) if ($na.Count -gt 0) { $cell.state = 'N/A' $cell.naReason = [string]$na[0].rationale $counts['N/A']++ $cells.Add([pscustomobject]$cell) continue } # Governing collector: both Copilot cells are grounded in the DSPM # collector (Copilot DLP policies and the app-retention projection). $governId = [string]$col.section if ($rowKey -eq 'copilot' -and ($colKey -eq 'dlp' -or $colKey -eq 'retention')) { $governId = 'DSPM_for_AI' } $outcome = Get-OutcomeFor $governId if ($readable -notcontains $outcome) { $cell.state = 'Unknown' $counts['Unknown']++ if (-not $seenDegraded.ContainsKey($governId)) { $seenDegraded[$governId] = $true $degraded.Add([pscustomobject]@{ collector = $governId; outcome = $outcome }) } $cells.Add([pscustomobject]$cell) continue } $contrib = @() if ($colKey -eq 'dlp') { if ($rowKey -eq 'copilot') { $contrib = Get-CopilotDlpContribution } else { $contrib = Get-DlpContribution $rowKey } } elseif ($colKey -eq 'autoLabel') { $contrib = Get-AutoLabelContribution $rowKey } elseif ($rowKey -eq 'copilot') { $contrib = Get-CopilotRetentionContribution } else { $contrib = Get-RetentionContribution $rowKey } $merged = Merge-PpaCoverageContribution $contrib $cell.state = $merged.state $cell.reasons = @($merged.reasons) $cell.contributors = @($merged.contributors) $counts[$merged.state]++ $cells.Add([pscustomobject]$cell) } } # ---- tenant-level audit strip: grounded on the AuditConfig singleton, NOT # the audit collector outcome (Part A review addendum) ---- $audRaw = Get-RawFor 'Audit' $audState = 'NotObserved' if ($null -ne $audRaw -and $audRaw.PSObject.Properties.Name -contains 'unifiedAuditEnabled' -and $null -ne $audRaw.unifiedAuditEnabled) { $audState = $(if ([bool]$audRaw.unifiedAuditEnabled) { 'On' } else { 'Off' }) } # ---- principal-scoped strip: present/absent summaries with counts ---- $labelsRaw = Get-RawFor 'Sensitivity_Labels' $pubCount = $null; $pubNote = '' if ($null -ne $labelsRaw -and $readable -contains (Get-OutcomeFor 'Sensitivity_Labels')) { $pubCount = @($labelsRaw.policies.items).Count } else { $pubNote = 'not readable this run' } $irmRaw = Get-RawFor 'Insider_Risk' $irmCount = $null; $irmNote = '' if ($null -ne $irmRaw -and $readable -contains (Get-OutcomeFor 'Insider_Risk')) { $irmCount = @($irmRaw.policies.items).Count } else { $irmNote = 'not readable this run' } $ccRaw = Get-RawFor 'Communication_Compliance' $ccCount = $null; $ccNote = '' if ($null -ne $ccRaw -and $readable -contains (Get-OutcomeFor 'Communication_Compliance')) { $ccCount = [int]$ccRaw.policies.count } else { $ccNote = 'not readable this run' } # eDiscovery cross-check (F-001): a non-grid capability like IRM/CC, so it rides the # same strip. Case count when readable, "not readable this run" otherwise - so the # matrix reflects an unreadable eDiscovery read instead of leaving it absent. Rendered # via the note (count stays null) to read "N cases", not the strip's "N policies". $edRaw = Get-RawFor 'eDiscovery' $edNote = 'not readable this run' if ($null -ne $edRaw -and $readable -contains (Get-OutcomeFor 'eDiscovery')) { $edN = @($edRaw.cases.items).Count $edNote = "$edN " + $(if ($edN -eq 1) { 'case' } else { 'cases' }) } return [pscustomobject][ordered]@{ rows = @($rowDefs | ForEach-Object { [pscustomobject]@{ key = [string]$_.key; label = [string]$_.label } }) columns = @($colDefs | ForEach-Object { [pscustomobject]@{ key = [string]$_.key; label = [string]$_.label; section = [string]$_.section } }) cells = $cells.ToArray() totals = [pscustomobject][ordered]@{ covered = $counts['Covered']; partial = $counts['Partial']; testOnly = $counts['Test-only'] gaps = $counts['None'] # None only: Unknown and N/A are NEVER gap-counted unknown = $counts['Unknown']; na = $counts['N/A'] } banner = [pscustomobject]@{ show = ($degraded.Count -gt 0); degraded = $degraded.ToArray() } auditStrip = [pscustomobject]@{ state = $audState; checkId = 'AUD-01'; sectionId = 'Audit' } principal = @( [pscustomobject]@{ name = 'Label publishing'; count = $pubCount; note = $pubNote; sectionId = 'Sensitivity_Labels'; checkId = 'LABELS-02' } [pscustomobject]@{ name = 'Insider Risk'; count = $irmCount; note = $irmNote; sectionId = 'Insider_Risk'; checkId = 'IRM-01' } [pscustomobject]@{ name = 'Communication Compliance'; count = $ccCount; note = $ccNote; sectionId = 'Communication_Compliance'; checkId = 'CC-01' } [pscustomobject]@{ name = 'eDiscovery'; count = $null; note = $edNote; sectionId = 'eDiscovery'; checkId = 'ED-01' } ) } } |