Private/Collect/Get-PpaSensitivityLabels.ps1
|
# Get-PpaSensitivityLabels.ps1 - collector for section 01 (Sensitivity Labels). # Reads Get-Label, Get-LabelPolicy, Get-AutoSensitivityLabelPolicy and # Get-AutoSensitivityLabelRule (all read-only) and projects each to a client-safe # metadata shape: names, priorities, scopes, modes, SIT NAMES only - never document # content or matched values (PLAN.md guardrails). # ASCII-only source (Windows PowerShell 5.1). Depends on Invoke-PpaReadCmdlet.ps1. # # Container coverage (LABELS-04 "0 of 143 labeled") has no single read-only cmdlet; the # collector marks it NotCollected and the analyzer degrades. A future phase can populate # it from Graph groups + SPO site inventory. # # Wave 5 cleanup Part 2 (grouped conditions): policies built with grouped conditions # leave the flat SensitiveInformationTypeNames property empty and store the conditions # as an AdvancedRule JSON string on the RULE. When the flat property is empty, this # collector flattens the rule blob into the same sits field (distinct NAMES - named # SITs and trainable classifiers alike - ordinal-ignore-case sorted for engine-stable # snapshots) and stamps conditionsSource: flat | grouped | unparsed | none | unreadable. # The raw AdvancedRule blob is parsed transiently and NEVER persisted: snapshot items # serialize collector output verbatim and the blob can carry non-name condition values. Set-StrictMode -Off function Get-PpaAdvancedRuleConditionNames { # Flatten Get-AutoSensitivityLabelRule AdvancedRule JSON string(s) into the # distinct detected-item names they reference. JSON paths pinned from the REAL # committed capture (Samples/sample-raw/autolabel-advancedrule.json): # Condition.SubConditions[] (recursing into nested SubConditions) -> # ConditionName 'ContentContainsSensitiveInformation' -> Value[] -> Groups[] # -> Sensitivetypes[] -> Name (Classifiertype 'MLModel' marks trainable # classifiers - captured the same as named SITs, never filtered out). # The AND/OR group operators are deliberately discarded (ruled: present the set # of things detected, not the boolean logic); empty operator-only group entries # (observed in the real blob) are tolerated. Unparseable blobs contribute # nothing - the CALLER distinguishes unparsed from none by blob presence. param([string[]]$Texts) $found = New-Object System.Collections.Generic.List[string] foreach ($text in @($Texts)) { $parsed = ConvertFrom-PpaJsonText $text if ($null -eq $parsed -or $null -eq $parsed.Condition) { continue } $stack = New-Object System.Collections.Generic.Stack[object] $stack.Push($parsed.Condition) while ($stack.Count -gt 0) { $node = $stack.Pop() if ($null -eq $node) { continue } foreach ($sub in @($node.SubConditions)) { if ($null -eq $sub) { continue } if ($null -ne $sub.SubConditions) { $stack.Push($sub); continue } if ([string]$sub.ConditionName -ne 'ContentContainsSensitiveInformation') { continue } foreach ($val in @($sub.Value)) { if ($null -eq $val) { continue } foreach ($grp in @($val.Groups)) { if ($null -eq $grp) { continue } foreach ($st in @($grp.Sensitivetypes)) { if ($null -eq $st) { continue } $n = [string]$st.Name if (-not [string]::IsNullOrEmpty($n)) { $found.Add($n) } } } } } } } # Dedupe (keep first casing) + sort, both ordinal-ignore-case: deterministic # across PS 5.1 and 7 regardless of culture (pinned by the contract tests). $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $distinct = New-Object System.Collections.Generic.List[string] foreach ($n in $found) { if ($seen.Add($n)) { $distinct.Add($n) } } $sorted = $distinct.ToArray() [Array]::Sort($sorted, [System.StringComparer]::OrdinalIgnoreCase) return @($sorted) } function ConvertTo-PpaScopeTokens { # Normalize a Get-Label ContentType value (comma string, array, or flags) to tokens. param($ContentType) if ($null -eq $ContentType) { return @() } if ($ContentType -is [array]) { return @($ContentType | ForEach-Object { [string]$_ }) } return @(([string]$ContentType) -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } function Get-PpaSensitivityLabels { [CmdletBinding()] param() $rawLabels = Invoke-PpaReadCmdlet -Name 'Get-Label' $rawPols = Invoke-PpaReadCmdlet -Name 'Get-LabelPolicy' $rawAuto = Invoke-PpaReadCmdlet -Name 'Get-AutoSensitivityLabelPolicy' $rawAutoRules = Invoke-PpaReadCmdlet -Name 'Get-AutoSensitivityLabelRule' # LABELS-05 (Wave 6 reincorporation Part 2): Azure RMS state. Exchange Online # cmdlet - the ONE read in this collector that needs the EXO session rather # than Security & Compliance; a missing session degrades only the LABELS-05 # finding (containers precedent), never the section outcome. $rawIrm = Invoke-PpaReadCmdlet -Name 'Get-IRMConfiguration' # Pre-publish Part 4: the label inventory doubles as the resolution map for the # policy loop below. On a live tenant Get-LabelPolicy's .Labels carries label # GUIDs / ImmutableIds, never display names (hand-authored fixtures used names, # which is why sample builds never showed the leak), and the reference can key # on EITHER id - so the map keys on Guid AND ImmutableId, plus the raw internal # Name as a last resort. PS hashtable literals compare keys case-insensitively, # which covers GUID casing differences for free. $labelNameByKey = @{} $labelItems = foreach ($l in @($rawLabels.Data)) { $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 $labelNameByKey.ContainsKey($k)) { $labelNameByKey[$k] = $displayName } } [pscustomobject]@{ name = $displayName guid = [string]$l.Guid immutableId = [string]$l.ImmutableId priority = [int]($l.Priority) scopes = @(ConvertTo-PpaScopeTokens $l.ContentType) parentId = [string]$l.ParentId } } $policyItems = foreach ($p in @($rawPols.Data)) { # "Assigned To" from the location properties CHECK_CATALOG documents for LABELS-02 # (ExchangeLocation / ModernGroupLocation). 'All' means published to all users. # Real Get-LabelPolicy has no ScopeSummary; an empty scope renders as a dash. $locs = @() $locs += @($p.ExchangeLocation | ForEach-Object { [string]$_ }) $locs += @($p.ModernGroupLocation | ForEach-Object { [string]$_ }) $locs = @($locs | Where-Object { $_ } | Select-Object -Unique) $scope = if ($locs -contains 'All') { 'All users' } elseif ($locs.Count -gt 0) { ($locs -join ', ') } else { '' } # Resolve each .Labels entry to its display name via the inventory map; an # entry with no match (a deleted-but-still-referenced label) passes through # verbatim - an orphan must still show SOMETHING, never vanish. The projected # shape stays a flat array of strings (render/normalize/snapshot/delta contract). [pscustomobject]@{ name = [string]$p.Name guid = Get-PpaOptionalGuid $p labels = @($p.Labels | ForEach-Object { $k = [string]$_ if ($labelNameByKey.ContainsKey($k)) { $labelNameByKey[$k] } else { $k } }) enabled = [bool]($p.Enabled -ne $false) scope = $scope } } $autoItems = foreach ($a in @($rawAuto.Data)) { # Wave 5 cleanup Part 2: sits is the canonical detected-item name set. Flat # property populated -> unchanged passthrough (cmdlet order kept). Flat empty # -> flatten the associated rules' AdvancedRule blobs into sits (sorted) and # record where the answer came from so downstream renders the four outcomes # distinctly and the snapshot carries the derived set, not an empty array. $sits = @($a.SensitiveInformationTypeNames | ForEach-Object { [string]$_ } | Where-Object { $_ }) $condSource = 'flat' if ($sits.Count -eq 0) { if ($rawAutoRules.Status -ne 'Ok') { $condSource = 'unreadable' } else { # Associate rules by their policy reference, else name equality # (mirrors the retention and CC Copilot rule associations). $blobs = New-Object System.Collections.Generic.List[string] foreach ($r in @($rawAutoRules.Data)) { if ($null -eq $r) { continue } $assoc = $false foreach ($prop in @('Policy', 'ParentPolicyName')) { if ($r.PSObject.Properties.Name -contains $prop -and $r.$prop) { $ref = [string]$r.$prop if ($ref -eq [string]$a.Name -or ($a.PSObject.Properties.Name -contains 'Guid' -and $ref -eq [string]$a.Guid)) { $assoc = $true; break } } } if (-not $assoc -and ([string]$r.Name) -eq ([string]$a.Name)) { $assoc = $true } if (-not $assoc) { continue } if ($r.PSObject.Properties.Name -contains 'AdvancedRule' -and -not [string]::IsNullOrWhiteSpace([string]$r.AdvancedRule)) { $blobs.Add([string]$r.AdvancedRule) } } if ($blobs.Count -eq 0) { $condSource = 'none' } else { $names = @(Get-PpaAdvancedRuleConditionNames -Texts $blobs.ToArray()) if ($names.Count -gt 0) { $sits = $names; $condSource = 'grouped' } else { $condSource = 'unparsed' } } } } [pscustomobject]@{ name = [string]$a.Name guid = Get-PpaOptionalGuid $a mode = [string]$a.Mode # Part D matrix grounding (documented-only shape): auto-labeling # supports Exchange/SharePoint/OneDrive locations only. locationScope = [pscustomobject]@{ exchange = (Get-PpaLocationScopeToken $a.ExchangeLocation) sharePoint = (Get-PpaLocationScopeToken $a.SharePointLocation) oneDrive = (Get-PpaLocationScopeToken $a.OneDriveLocation) } locationExceptions = [pscustomobject]@{ exchange = (Test-PpaLocationException $a.ExchangeLocationException) sharePoint = (Test-PpaLocationException $a.SharePointLocationException) oneDrive = (Test-PpaLocationException $a.OneDriveLocationException) } sits = @($sits) conditionsSource = $condSource simulationStartDate = ConvertTo-PpaIso8601 $a.SimulationStartDate simulationItemCount = [int]($a.SimulationItemCount) } } # LABELS-05: $null means "not read this session" (failed EXO read OR the property # absent from the returned object) - distinct from $false, so the analyzer degrades # to Verify manually instead of guessing a boolean. $rmsEnabled = $null if ($rawIrm.Status -eq 'Ok' -and @($rawIrm.Data).Count -gt 0) { $irm = $rawIrm.Data[0] if ($irm.PSObject.Properties.Name -contains 'AzureRMSLicensingEnabled' -and $null -ne $irm.AzureRMSLicensingEnabled) { $rmsEnabled = [bool]$irm.AzureRMSLicensingEnabled } } # Outcome from the three original reads only - the containers block is NotCollected # by design and must not degrade the outcome, and the Wave 5 Part 2 rule read is # excluded the same way (containers precedent): a tenant/session without # Get-AutoSensitivityLabelRule degrades ONLY the conditions display # (conditionsSource 'unreadable'), never the section or the coverage matrix. # The Get-IRMConfiguration read (EXO session) is excluded for the same reason: # it degrades only LABELS-05. return [pscustomobject]@{ outcome = Resolve-PpaCollectorOutcome -ReadStatuses @($rawLabels.Status, $rawPols.Status, $rawAuto.Status) -ItemCount (@($labelItems).Count + @($policyItems).Count + @($autoItems).Count) labels = [pscustomobject]@{ status = $rawLabels.Status; error = $rawLabels.Error; items = @($labelItems) } policies = [pscustomobject]@{ status = $rawPols.Status; error = $rawPols.Error; items = @($policyItems) } autoLabels = [pscustomobject]@{ status = $rawAuto.Status; error = $rawAuto.Error rulesStatus = $rawAutoRules.Status; rulesError = $rawAutoRules.Error items = @($autoItems) } containers = [pscustomobject]@{ status = 'NotCollected'; groups = $null; sites = $null } irmConfig = [pscustomobject]@{ status = $rawIrm.Status; error = $rawIrm.Error; azureRmsEnabled = $rmsEnabled } } } |