src/assess/engine/Get-ScoutComplianceScore.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Score one Azure Policy regulatory-compliance initiative from already-collected policy compliance state — a rendering job, not rule authoring (AB#6792/AB#6793). .DESCRIPTION Azure Policy evaluates every control in an assigned initiative continuously. This function does not assert anything itself; it reads the compliance state Scout has already collected (domains.management.policyComplianceStates) and rolls it up to one result per policy ("control") in the named initiative. Three states, deliberately (AB#6793) — never two: Pass — every compliance-state row seen for this control is 'Compliant'. Fail — at least one compliance-state row for this control is 'NonCompliant'. NonCompliant wins over Compliant, because "some resources fail this control" is the honest read of a mixed result, not a pass. NotAssessed — Azure returned NO compliance-state row for this control at all (e.g. it is a member of the initiative but nothing has been evaluated against it yet, or every row for it was Exempt). This is the state AB#6793 exists to protect: it is never counted as a pass or a fail, by ANY caller, because it carries neither status string. A control is identified by (PolicySetDefinitionId, PolicyDefinitionId) so the same policy definition reused by two initiatives is scored independently in each. .PARAMETER Collect The assessment collect object. Reads $Collect.domains.management.policyComplianceStates (rows) and, when supplied, $Initiative.PolicyCount to report coverage. .PARAMETER Initiative One descriptor from Resolve-ScoutAssignedInitiative -- must carry Id (the PolicySetDefinitionId), DisplayName, and Version. .OUTPUTS [pscustomobject] with: InitiativeId, DisplayName, Version, Framework (the exact "name + version" string every finding below is tagged with, so two versions of the same initiative never merge into one score card — AB#6794) Findings — one row per control: Id, Title, Status ('Pass'|'Fail'|'NotAssessed'), EvidenceCount, Evidence (the compliance-state rows themselves, capped), PolicyDefinitionId (AB#6792 — "identify the policy that produced it") CompliancePercent — Compliant / (Compliant + Fail) * 100, rounded, matching how Defender for Cloud's compliance blade reads a policy-level pass/fail split. $null when there is nothing scorable (every control NotAssessed). NotAssessedCount .NOTES Tracks AB#6792 / AB#6793 (Feature AB#6744, Epic AB#6454). #> function Get-ScoutComplianceScore { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] $Collect, [Parameter(Mandatory)] $Initiative ) $initiativeId = [string]$Initiative.Id $displayName = [string]$Initiative.DisplayName $version = [string]$Initiative.Version $frameworkName = if ([string]::IsNullOrWhiteSpace($version) -or $version -eq 'N/A') { "$displayName (version unknown)" } else { "$displayName $version" } # StrictMode-safe descent: $Collect.domains.management.policyComplianceStates throws # PropertyNotFoundException the moment any hop is absent (a fixture built without the full # shape, or a real collect.json from before this feature existed), rather than returning # $null. Walk it defensively. $allStates = @() if ($null -ne $Collect -and $Collect.PSObject.Properties['domains'] -and $null -ne $Collect.domains -and $Collect.domains.PSObject.Properties['management'] -and $null -ne $Collect.domains.management -and $Collect.domains.management.PSObject.Properties['policyComplianceStates'] -and $null -ne $Collect.domains.management.policyComplianceStates) { $allStates = @($Collect.domains.management.policyComplianceStates) } # Rows belonging to THIS initiative only. A policy-definition id is not unique across # initiatives (the same built-in policy is a member of several), so PolicySetDefinitionId is # the join key, not PolicyDefinitionId alone. $initiativeStates = @($allStates | Where-Object { $_ -and $_.PSObject.Properties['PolicySetDefinitionId'] -and [string]$_.PolicySetDefinitionId -eq $initiativeId }) $byControl = $initiativeStates | Group-Object PolicyDefinitionId $findings = @( foreach ($group in $byControl) { $rows = @($group.Group) $title = ($rows | Where-Object { $_.PSObject.Properties['PolicyDefinitionName'] -and $_.PolicyDefinitionName } | Select-Object -First 1 -ExpandProperty PolicyDefinitionName) if (-not $title) { $title = $group.Name } $states = @($rows | ForEach-Object { if ($_.PSObject.Properties['ComplianceState']) { [string]$_.ComplianceState } else { $null } }) # NonCompliant wins over Compliant on a mixed result (AB#6792) -- "some resources # fail this control" is not a pass. Rows that are only Exempt/blank/unknown never # produce Pass or Fail; that is what routes them to NotAssessed below, which is the # exact AB#6793 guard: a control this function never SAW a Compliant/NonCompliant row # for must not silently read as passed. $status = if ($states -contains 'NonCompliant') { 'Fail' } elseif ($states -contains 'Compliant') { 'Pass' } else { 'NotAssessed' } [pscustomobject]@{ Id = $group.Name PolicyDefinitionId = $group.Name Title = $title Status = $status EvidenceCount = $rows.Count Evidence = @($rows | Select-Object -First 25) Remediation = if ($status -eq 'Fail') { "Remediate the non-compliant resource(s) flagged by policy '$title' (Azure Policy assignment already in place; no new policy needed)." } elseif ($status -eq 'NotAssessed') { "No compliance evaluation has been recorded yet for policy '$title' in this initiative -- trigger an on-demand policy scan or wait for the next evaluation cycle." } else { $null } } } ) # A control this initiative's definition names but that never produced a single compliance # row (assigned but not yet evaluated at all, distinct from "evaluated and Exempt") is still # NotAssessed, never silently dropped -- but Scout only knows a control's NAME once Azure has # returned at least one row for it (policyComplianceStates carries no membership list on its # own), so an initiative with zero rows entirely produces zero findings here. That case is # handled one layer up, in Resolve-ScoutAssignedInitiative / Invoke-ScoutComplianceAssessment, # by not offering the initiative as assigned at all (AB#6793's "not assigned -> no card, not a # fabricated pass" rule). $passCount = @($findings | Where-Object Status -eq 'Pass').Count $failCount = @($findings | Where-Object Status -eq 'Fail').Count $notAssessedCount = @($findings | Where-Object Status -eq 'NotAssessed').Count $scorableCount = $passCount + $failCount [pscustomobject]@{ InitiativeId = $initiativeId DisplayName = $displayName Version = $version Framework = $frameworkName Findings = $findings Pass = $passCount Fail = $failCount NotAssessed = $notAssessedCount CompliancePercent = if ($scorableCount -gt 0) { [math]::Round($passCount / $scorableCount * 100, 0, [System.MidpointRounding]::AwayFromZero) } else { $null } } } |