Private/Analyze/Invoke-PpaInsiderRiskAnalyzer.ps1
|
# Invoke-PpaInsiderRiskAnalyzer.ps1 - analyzer for section 04 (Insider Risk Management). # Assume-E5 model (decision D9): verdicts come from evidence exactly like an E3 workload - # policies returned -> inventory; genuinely empty -> a normal Improvement; unreadable -> # Verify manually (unknown is never asserted as empty). The 'Requires' tier annotation # rides alongside and never changes the verdict. # Policy counts and inventories EXCLUDE the tenant-settings pseudo-policy # (InsiderRiskScenario = TenantSetting) - the collector filters it (VERIFIED 2026-07-02). # Wave 6 reincorporation Part 3 adds IRM-04 (departing-user data theft) and IRM-05 # (data-leaks family, one finding spanning the three CAMP-2022 leak enums), both # modeled on IRM-03: pattern-matched scenarios until confirmed live, absence never # asserted from a failed read, and unknown policy mode treated as enabled. # ASCII-only source. Depends on New-PpaFinding/New-PpaSection/Get-PpaRequirement. Set-StrictMode -Off function Test-PpaIrmAiScenario { # True when a policy's InsiderRiskScenario looks like the risky-AI-usage template. # The exact enum value is UNVERIFIED (no risky-AI policy existed in the 2026-07-02 # sandbox) - when first observed on a populated tenant, record the exact # InsiderRiskScenario value here and tighten this to an -eq match. # Case-sensitive 'AI' catches CamelCase enums (e.g. RiskyAIUsage) without tripping on # unrelated lowercase substrings ('Maintenance'); the bounded case-insensitive # alternative catches spaced/underscored shapes ('AI usage', 'risky_ai'). # Policy Name is corroboration only - never a detection signal on its own. param([string]$Scenario) if ([string]::IsNullOrEmpty($Scenario)) { return $false } return (($Scenario -cmatch 'AI') -or ($Scenario -match '(?i)(^|[^a-z0-9])ai([^a-z0-9]|$)')) } function Test-PpaIrmTheftScenario { # True when a policy's InsiderRiskScenario looks like the departing-user / # data-theft template family (IRM-04). Known shapes: IntellectualPropertyTheft # (CAMP-2022 enum) and DataTheft. The exact live enum is UNVERIFIED - when first # observed on a populated tenant, record it here and tighten to an -eq match # (same discipline as Test-PpaIrmAiScenario). Policy Name is corroboration only. param([string]$Scenario) if ([string]::IsNullOrEmpty($Scenario)) { return $false } return ($Scenario -match '(?i)theft') } function Test-PpaIrmLeakScenario { # True when a policy's InsiderRiskScenario looks like the data-leaks template # family (IRM-05) - ONE check spanning the three CAMP-2022 enums # (LeakOfInformation, DisgruntledEmployeeDataLeak, HighValueEmployeeDataLeak), # deliberately never split into per-enum cards. Same tighten-to-eq discipline # as above once the live values are observed. param([string]$Scenario) if ([string]::IsNullOrEmpty($Scenario)) { return $false } return ($Scenario -match '(?i)leak') } function Test-PpaIrmPolicyEnabled { # Enabled unless the projected mode SAYS otherwise: '' (older captures or the # property missing live) counts as enabled - unknown metadata never turns a # scenario match into a false gap. param($Policy) $mode = '' if ($Policy.PSObject.Properties.Name -contains 'mode') { $mode = [string]$Policy.mode } if ([string]::IsNullOrEmpty($mode)) { return $true } return ($mode -match '(?i)^enable') } function New-PpaIrmScenarioFinding { # Shared IRM-04/IRM-05 shape: OK inventory when any enabled policy carries the # scenario; Recommendation otherwise (matched-but-not-enabled policies are # listed with a remark, never counted as coverage). Caller guards on a # completed read - absence is never asserted from a failed read. param( [string]$Id, [string]$DomId, [string]$TitleOk, [string]$TitleGap, [string]$WhyOk, [string]$WhyGap, [string]$GapRowLabel, $Matched, $Requires, $LearnMore ) $enabled = @($Matched | Where-Object { Test-PpaIrmPolicyEnabled $_ }) if ($enabled.Count -gt 0) { $rows = foreach ($p in $Matched) { $isOn = Test-PpaIrmPolicyEnabled $p $remark = $null if (-not $isOn) { $remark = "Policy is not enabled (mode: $([string]$p.mode)) - it is not counted as coverage." } elseif ([string]::IsNullOrEmpty([string]$p.mode)) { $remark = 'Policy mode not readable this run - treated as enabled.' } New-PpaRow -Cells @([string]$p.name, [string]$p.scenario, [string]$p.workloads, [string]$p.created) -Status ($(if ($isOn) { 'OK' } else { 'Informational' })) -Remark $remark } return (New-PpaFinding -Id $Id -DomId $DomId -Title $TitleOk -Status 'OK' -Requires $Requires ` -Whyline $WhyOk ` -Table (New-PpaTable -Columns @('IRM Policy', 'Scenario', 'Workloads', 'Created', 'Status') -Rows @($rows)) -LearnMore $LearnMore) } if (@($Matched).Count -gt 0) { $rows = foreach ($p in $Matched) { New-PpaRow -Cells @([string]$p.name, [string]$p.scenario, [string]$p.workloads, [string]$p.created) -Status 'Informational' ` -Remark "Policy is not enabled (mode: $([string]$p.mode)) - it is not counted as coverage." } return (New-PpaFinding -Id $Id -DomId $DomId -Title $TitleGap -Status 'Recommendation' -Requires $Requires ` -Whyline $WhyGap ` -Table (New-PpaTable -Columns @('IRM Policy', 'Scenario', 'Workloads', 'Created', 'Status') -Rows @($rows)) -LearnMore $LearnMore) } return (New-PpaFinding -Id $Id -DomId $DomId -Title $TitleGap -Status 'Recommendation' -Requires $Requires ` -Whyline $WhyGap ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @($GapRowLabel, '0') -Status 'Recommendation' )) -LearnMore $LearnMore) } function Invoke-PpaInsiderRiskAnalyzer { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Raw, $LicenseMap ) $mid = Get-PpaMidDot $count = $Raw.policies.count # $null when the read did not complete (D7) $items = @($Raw.policies.items | Where-Object { $null -ne $_ }) # absent on older raw shapes -> @() $req = Get-PpaRequirement $LicenseMap 'IRM-01' $findings = New-Object System.Collections.Generic.List[object] $lm01 = @(@{ label = 'Learn about Insider Risk Management'; url = 'https://learn.microsoft.com/en-us/purview/insider-risk-management'; tag = 'docs' }) # --- IRM-01: evidence from Get-InsiderRiskPolicy --- if ($null -eq $count) { # Unreadable is not empty: never assert absence from a failed read. $findings.Add((New-PpaFinding -Id 'IRM-01' -DomId 'f-irm-1' -Title 'IRM policy inventory not readable this session' -Status 'Verify manually' -Requires $req ` -Whyline 'IRM policies cannot be enumerated read-only from this session - confirm coverage in the Purview portal.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('IRM policies', 'Not readable read-only') -Status 'Verify manually' )) -LearnMore $lm01)) } elseif ($count -eq 0) { # Empty workload -> a normal Improvement, same as any empty workload. $findings.Add((New-PpaFinding -Id 'IRM-01' -DomId 'f-irm-1' -Title 'No IRM policies configured' -Status 'Improvement' -Requires $req ` -Whyline 'No insider-risk policy is scoring activity, so leaver data theft and risky-user signals go unwatched.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('IRM policies', '0') -Status 'Improvement' )) -LearnMore $lm01)) } else { # Per-policy inventory when the collector projected items; count row otherwise. if ($items.Count -gt 0) { $rows01 = foreach ($p in $items) { New-PpaRow -Cells @([string]$p.name, [string]$p.scenario, [string]$p.workloads) -Status 'Informational' } $findings.Add((New-PpaFinding -Id 'IRM-01' -DomId 'f-irm-1' -Title "IRM in use - $count policies" -Status 'Informational' -Requires $req ` -Whyline 'Insider-risk policies are scoring activity in the tenant.' ` -Table (New-PpaTable -Columns @('IRM Policy', 'Scenario', 'Workloads', 'Status') -Rows @($rows01)) -LearnMore $lm01)) } else { $findings.Add((New-PpaFinding -Id 'IRM-01' -DomId 'f-irm-1' -Title "IRM in use - $count policies" -Status 'Informational' -Requires $req ` -Whyline 'Insider-risk policies are scoring activity in the tenant.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('IRM policies', [string]$count) -Status 'Informational' )) -LearnMore $lm01)) } } # --- IRM-02: advisory (fires on evidence of absence; unknown is not absent) --- if ($count -eq 0) { $findings.Add((New-PpaFinding -Id 'IRM-02' -DomId 'f-irm-2' -Title 'Consider IRM for departing-employee risk' -Status 'Recommendation' -Requires (Get-PpaRequirement $LicenseMap 'IRM-02') ` -Whyline 'Data theft by leavers is a common uncovered scenario. Needs HR/Legal alignment before scoping - not a quick config win.' ` -Table $null ` -LearnMore @(@{ label = 'IRM policy templates'; url = 'https://learn.microsoft.com/en-us/purview/insider-risk-management-policies'; tag = 'docs' }))) } # --- IRM-03: risky-AI-usage template coverage (spec F5) --- # Only assertable from a completed read; on a failed read the IRM-01 Verify-manually # degradation already covers the section (absence is never asserted from a failed read). if ($null -ne $count) { $lm03 = @(@{ label = 'IRM policy templates'; url = 'https://learn.microsoft.com/en-us/purview/insider-risk-management-policy-templates'; tag = 'docs' }) $aiPolicies = @($items | Where-Object { Test-PpaIrmAiScenario ([string]$_.scenario) }) if ($aiPolicies.Count -gt 0) { $rows03 = foreach ($p in $aiPolicies) { $remark = $null if ([string]$p.name -match '(?i)\bAI\b|copilot|risky') { $remark = 'Policy name corroborates the risky-AI template match.' } New-PpaRow -Cells @([string]$p.name, [string]$p.scenario, [string]$p.workloads, [string]$p.created) -Status 'OK' -Remark $remark } $findings.Add((New-PpaFinding -Id 'IRM-03' -DomId 'f-irm-3' -Title 'Risky AI usage policy in place' -Status 'OK' -Requires (Get-PpaRequirement $LicenseMap 'IRM-03') ` -Whyline 'An Insider Risk policy with an AI scenario is scoring risky Copilot/AI usage signals such as prompt injection and protected-material access.' ` -Table (New-PpaTable -Columns @('IRM Policy', 'Scenario', 'Workloads', 'Created', 'Status') -Rows @($rows03)) -LearnMore $lm03)) } else { $findings.Add((New-PpaFinding -Id 'IRM-03' -DomId 'f-irm-3' -Title 'No Risky AI usage policy' -Status 'Recommendation' -Requires (Get-PpaRequirement $LicenseMap 'IRM-03') ` -Whyline 'No Insider Risk policy based on the Risky AI usage template - prompt-injection and protected-material access signals from Copilot are not being scored.' ` -Table (New-PpaTable -Columns @('Configuration', 'Setting', 'Status') -Rows @( New-PpaRow -Cells @('IRM policies with an AI scenario', '0') -Status 'Recommendation' )) -LearnMore $lm03)) } } # --- IRM-04 / IRM-05: universal-scenario coverage (Wave 6 reincorporation Part 3) --- # Modeled on IRM-03: only assertable from a completed read; on a failed read the # IRM-01 Verify-manually degradation covers the section. Two items, split by # scenario family - departing-user data theft (IRM-04) and the data-leaks family # (IRM-05, ONE finding spanning the three CAMP-2022 leak enums). if ($null -ne $count) { $lmTpl = @(@{ label = 'IRM policy templates'; url = 'https://learn.microsoft.com/en-us/purview/insider-risk-management-policy-templates'; tag = 'docs' }) $theftMatched = @($items | Where-Object { Test-PpaIrmTheftScenario ([string]$_.scenario) }) $findings.Add((New-PpaIrmScenarioFinding -Id 'IRM-04' -DomId 'f-irm-4' ` -TitleOk 'Departing-employee data theft policy in place' -TitleGap 'No departing-employee data theft policy' ` -WhyOk 'An Insider Risk policy with a data-theft scenario is scoring exfiltration signals around departing and offboarding users.' ` -WhyGap 'No enabled Insider Risk policy carries the departing-user data theft template - exfiltration signals around resignation and offboarding are not being scored.' ` -GapRowLabel 'IRM policies with a data-theft scenario' ` -Matched $theftMatched -Requires (Get-PpaRequirement $LicenseMap 'IRM-04') -LearnMore $lmTpl)) $leakMatched = @($items | Where-Object { Test-PpaIrmLeakScenario ([string]$_.scenario) }) $findings.Add((New-PpaIrmScenarioFinding -Id 'IRM-05' -DomId 'f-irm-5' ` -TitleOk 'Data leaks policy in place' -TitleGap 'No data leaks policy' ` -WhyOk 'An Insider Risk policy with a data-leak scenario is scoring accidental oversharing and intentional leak signals.' ` -WhyGap 'No enabled Insider Risk policy carries a data-leaks template - accidental oversharing and intentional leak signals are not being scored.' ` -GapRowLabel 'IRM policies with a data-leak scenario' ` -Matched $leakMatched -Requires (Get-PpaRequirement $LicenseMap 'IRM-05') -LearnMore $lmTpl)) } # --- glance --- $metric = if ($null -eq $count) { 'not readable' } else { "$count policies" } $glance = New-PpaGlance -Name 'Insider Risk' -Metric $metric -Sub 'requires E5' return New-PpaSection -Id 'Insider_Risk' -Title 'Insider Risk Management' -Group 'Insider Risk' ` -GroupIcon 'fas fa-user-secret' -Glance $glance -Findings $findings.ToArray() } |