Functions/AssessmentResults.ps1

function Get-AzDevOpsAssessmentCatalog {
    [CmdletBinding()]
    param()
    Get-Content "$PSScriptRoot/../Assessment/catalog.json" -Raw | ConvertFrom-Json
}

function Get-AzDevOpsAssessmentHash {
    param([string]$Value)
    $algorithm = [System.Security.Cryptography.SHA256]::Create()
    try { [BitConverter]::ToString($algorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() }
    finally { $algorithm.Dispose() }
}

function ConvertTo-AzDevOpsAssessment {
    <#
    .SYNOPSIS
    Project detailed PSRule results into versioned assessment findings and correlated risk objectives.
    .DESCRIPTION
    Pass results obtained with -Outcome All and their original exported targets. Raw results
    remain unchanged. ScopeId must identify the organization/project represented by this run.
    Unknown evidence and unclassified severity prevent a complete security score.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Result,
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$TargetObject,
        [Parameter(Mandatory)][string]$ScopeId,
        [string]$Baseline = 'Baseline.Default',
        [System.Collections.IDictionary]$Configuration = @{}
    )
    $catalog = Get-AzDevOpsAssessmentCatalog
    $rules = @{}
    foreach ($rule in $catalog.Rules) { $rules[$rule.RuleId] = $rule }
    $unmapped = @($Result | Where-Object { -not $rules.ContainsKey($_.RuleName) -and $_.RuleName -notin @($catalog.SupplementalRules.RuleId) }).Count
    $targets = @{}
    foreach ($target in $TargetObject) {
        $key = "$($target.ObjectType)/$($target.ObjectName)"
        if ($targets.ContainsKey($key)) { throw "Duplicate target identity in assessment scope: $key" }
        $targets[$key] = $target
    }
    $findings = @()
    foreach ($raw in $Result) {
        $rule = $rules[$raw.RuleName]
        # Debug and supplemental runtime diagnostics are preserved by the caller's raw results.
        if ($null -eq $rule -or $raw.TargetType -ne $rule.TargetType) { continue }
        $target = $targets[$raw.TargetName]
        $outcome = [string]$raw.Outcome
        $reason = [string]$raw.OutcomeReason
        if ($reason -in @('Inconclusive', 'Error') -or $outcome -notin @('Pass', 'Fail', 'None')) {
            $outcome = 'Unknown'
        } elseif ($outcome -eq 'None') {
            if ($null -eq $target) {
                $outcome = 'Unknown'; $reason = 'OriginalTargetUnavailable'
            } elseif ($rule.Applicability -in @('Policy', 'DefaultPolicy')) {
                $assessment = Get-AzDevOpsPolicyAssessment -TargetObject $target -RuleId $rule.RuleId -Policy $Configuration.assessmentPolicies.($rule.RuleId)
                $outcome = $assessment.Outcome; $reason = $assessment.ReasonCode
            } elseif ($rule.RuleId -like '*.InheritedPermissions') {
                $assessment = Get-AzDevOpsEffectivePermissionAssessment -Evidence $target.PermissionEvidence -Policy $Configuration.effectivePermissionPolicies.($rule.TargetType)
                $outcome = $assessment.Outcome; $reason = $assessment.ReasonCode
            } elseif ($rule.RuleId -like '*.ProductionCheckProtection') {
                $assessment = Get-AzDevOpsProductionGateAssessment -TargetObject $target
                $outcome = $assessment.Outcome; $reason = $assessment.ReasonCode
            } elseif ($rule.RiskGroup -eq 'VariableGroup.SecretExposure') {
                $assessment = Get-AzDevOpsVariableGroupSecretAssessment -TargetObject $target
                $outcome = $assessment.Outcome; $reason = $assessment.ReasonCode
            } else { $outcome = 'NotApplicable' }
            # A skipped diagnostic cannot become a proven pass/fail solely through projection.
            if ($outcome -in @('Pass', 'Fail')) { $outcome = 'Unknown'; $reason = 'RawDiagnosticNotEvaluated' }
        }
        $riskGroup = $rule.RiskGroup
        if ($rule.Correlation -eq 'MissingProductionGateOnly' -and $null -ne $target -and
            $outcome -eq 'Fail' -and (Get-AzDevOpsProductionGateAssessment -TargetObject $target).ReasonCode -in @('MissingProductionGate', 'DisabledProductionGate', 'InvalidProductionGate')) {
            $riskGroup = if ($rule.RuleId -like '*Environments*') { 'Environment.ProductionProtection' } else { 'ServiceConnection.ProductionProtection' }
        }
        $objectiveId = [ordered]@{ ScopeId = $ScopeId; TargetName = $raw.TargetName; RiskGroup = $riskGroup } | ConvertTo-Json -Compress
        $findings += [pscustomobject][ordered]@{
            RuleId = $rule.RuleId; TargetName = $raw.TargetName; TargetType = $raw.TargetType
            Outcome = $outcome; ReasonCode = $reason; RawOutcome = [string]$raw.Outcome
            RawOutcomeReason = [string]$raw.OutcomeReason; RawReasons = @($raw.Reason)
            Domain = $rule.Domain; AssessmentSeverity = $rule.AssessmentSeverity
            PSRuleLevel = $rule.PSRuleLevel; RiskGroup = $riskGroup; ObjectiveId = $objectiveId
            DeprecatedAlias = $rule.DeprecatedAlias
        }
    }
    $objectives = @()
    foreach ($group in $findings | Group-Object ObjectiveId) {
        $members = @($group.Group)
        $applicable = @($members | Where-Object Outcome -NE 'NotApplicable')
        $state = if ($members.Outcome -contains 'Fail') { 'Fail' } elseif ($members.Outcome -contains 'Unknown') { 'Unknown' } elseif ($members.Outcome -contains 'Pass') { 'Pass' } else { 'NotApplicable' }
        $severity = $null; $weight = $null
        if ($applicable.Count -gt 0 -and @($applicable | Where-Object { -not $_.AssessmentSeverity }).Count -eq 0) {
            foreach ($member in $applicable) {
                $candidate = $catalog.RiskWeights.($member.AssessmentSeverity)
                if ($null -eq $weight -or $candidate -gt $weight) { $weight = $candidate; $severity = $member.AssessmentSeverity }
            }
        }
        $objectives += [pscustomobject]@{
            ObjectiveId = $group.Name; TargetName = $members[0].TargetName; RiskGroup = $members[0].RiskGroup
            Domain = $members[0].Domain; Outcome = $state; AssessmentSeverity = $severity; Weight = $weight
            DiagnosticRuleIds = @($members.RuleId | Sort-Object -Unique)
        }
    }
    $security = @($objectives | Where-Object { $_.Domain -eq 'Security' -and $_.Outcome -ne 'NotApplicable' })
    $unknown = @($security | Where-Object Outcome -EQ 'Unknown').Count
    $unclassified = @($security | Where-Object { $null -eq $_.Weight }).Count
    [double]$totalWeight = ($security | Measure-Object Weight -Sum).Sum
    [double]$failedWeight = ($security | Where-Object Outcome -EQ 'Fail' | Measure-Object Weight -Sum).Sum
    $score = if ($unknown -eq 0 -and $unclassified -eq 0 -and $unmapped -eq 0 -and $totalWeight -gt 0) { [Math]::Round(100 * (1 - $failedWeight / $totalWeight), 2) } else { $null }
    $configurationHash = Get-AzDevOpsAssessmentHash ($Configuration | ConvertTo-Json -Depth 50 -Compress)
    $coverageHash = Get-AzDevOpsAssessmentHash (@($security.ObjectiveId | Sort-Object) | ConvertTo-Json -Compress)
    $comparisonKey = Get-AzDevOpsAssessmentHash (@($ScopeId, $Baseline, $catalog.RuleSetVersion, $catalog.SeverityPolicyVersion, $configurationHash, $coverageHash) | ConvertTo-Json -Compress)
    [pscustomobject][ordered]@{
        SchemaVersion = 1; RuleSetVersion = $catalog.RuleSetVersion; SeverityPolicyVersion = $catalog.SeverityPolicyVersion
        ScopeId = $ScopeId; Baseline = $Baseline; ConfigurationHash = $configurationHash; CoverageHash = $coverageHash; ComparisonKey = $comparisonKey
        Findings = $findings; RiskObjectives = $objectives
        Summary = [pscustomobject]@{
            DiagnosticCount = $findings.Count; UnmappedDiagnosticCount = $unmapped; RiskObjectiveCount = $objectives.Count
            UnknownSecurityObjectives = $unknown; UnclassifiedSecurityObjectives = $unclassified
            NotApplicableObjectives = @($objectives | Where-Object Outcome -EQ 'NotApplicable').Count
            KnownFailedSecurityWeight = $failedWeight; SecurityScore = $score
        }
    }
}

function Compare-AzDevOpsAssessment {
    [CmdletBinding()]
    param([Parameter(Mandatory)][object]$Previous, [Parameter(Mandatory)][object]$Current)
    $comparable = $Previous.ComparisonKey -and $Previous.ComparisonKey -eq $Current.ComparisonKey
    $delta = $null
    if ($comparable -and $null -ne $Previous.Summary.SecurityScore -and $null -ne $Current.Summary.SecurityScore) {
        $delta = $Current.Summary.SecurityScore - $Previous.Summary.SecurityScore
    }
    [pscustomobject]@{
        Comparable = [bool]$comparable
        ReasonCode = if ($comparable) { 'SameAssessmentPolicy' } else { 'AssessmentPolicyOrScopeChanged' }
        SecurityScoreDelta = $delta
        PreviousRuleSetVersion = $Previous.RuleSetVersion; CurrentRuleSetVersion = $Current.RuleSetVersion
        PreviousSeverityPolicyVersion = $Previous.SeverityPolicyVersion; CurrentSeverityPolicyVersion = $Current.SeverityPolicyVersion
    }
}
Export-ModuleMember -Function Get-AzDevOpsAssessmentCatalog, ConvertTo-AzDevOpsAssessment, Compare-AzDevOpsAssessment