Private/ConvertTo-AACConfigurationDrift.ps1

function ConvertTo-AACConfigurationDrift {
    <#
    .SYNOPSIS
        Compares the deployed configuration with its desired state - a saved
        baseline, desired-state rules, Terraform's view of the
        infrastructure - and says what drifted, who or what changed it, and
        what to do about it.
    .DESCRIPTION
        Sources:
          Baseline -Baseline (a snapshot saved earlier) against -Current:
                     each setting changed, added or removed; resources
                     created or deleted since
          Rule each -Rule against -Current: a setting that isn't as
                     the rule expects
          Terraform -TerraformDrift (a plan's resource_drift): what changed
                     outside Terraform
        Why: the latest change Resource Graph recorded (-Change, 14 days)
        to the resource, or to that setting - who (changedBy), when, and
        its origin: Manual (a person), Automation (an application or
        pipeline) or Azure (a platform update).
        Strategy: Fix (a rule broken), Revert or Re-deploy (a manual change
        to what infrastructure as code or the baseline says), Update the
        baseline (an Azure or pipeline change that's probably intended),
        Review (unknown origin) - with the remediation.
        Severity: a rule's own; for the baseline and Terraform, High when
        the setting is about security or networking (TLS, public access,
        firewall and network rules, encryption, identity, authentication,
        keys), Medium otherwise; resources created or deleted Low.
        Returns @{ Drift (AAC.ConfigurationDrift); Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        # ConvertTo-AACConfigurationSnapshot's result for now.
        [hashtable] $Current = @{},
        # A snapshot saved earlier (or $null).
        [hashtable] $Baseline,
        [AllowEmptyCollection()] [object[]] $Rule = @(),
        # Terraform plan resource_drift entries.
        [AllowEmptyCollection()] [object[]] $TerraformDrift = @(),
        # Resource Graph resourcechanges rows: resourceId, at, changedBy, clientType, changes.
        [AllowEmptyCollection()] [object[]] $Change = @(),
        [System.Collections.IDictionary] $SubscriptionName = @{}
    )

    $get = { param($Row, [string] $Name) if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] } elseif ($null -ne $Row) { $p = $Row.PSObject.Properties[$Name]; if ($p) { $p.Value } } }
    $subOf = { param($Id) if ([string]$Id -match '(?i)^/subscriptions/([^/]+)') { $s = $Matches[1].ToLowerInvariant(); if ($SubscriptionName.Contains($s)) { [string]$SubscriptionName[$s] } else { $s } } else { '' } }
    $sensitive = '(?i)tls|ssl|https|publicnetworkaccess|public|firewall|networkacls|ipRules|virtualNetworkRules|securityRules|encryption|identity|auth|sharedkey|localaccount|accesspolicies|rbac|softdelete|purge|adminuser|nonsslport|keyvault|key'
    $severity = Get-AACSeverityRank

    # --- Who changed what (Resource Graph's change history) ----------------------------------------------------------
    $byResource = @{}
    foreach ($c in $Change) {
        $id = ([string](& $get $c 'resourceId')).ToLowerInvariant()
        if (-not $byResource.Contains($id)) { $byResource[$id] = [System.Collections.Generic.List[object]]::new() }
        $byResource[$id].Add($c)
    }
    $why = {
        # The latest recorded change to the setting - or to the resource.
        param([string] $Id, [string] $Path)
        $changes = @(@(if ($byResource.Contains($Id)) { $byResource[$Id] }) | Sort-Object -Property { [string](& $get $_ 'at') } -Descending)
        $hit = @($changes | Where-Object { $Path -and (& $get $_ 'changes') -is [System.Collections.IDictionary] -and (& $get $_ 'changes').Contains($Path) }) | Select-Object -First 1
        $category = ''
        if ($hit) { $category = [string](& $get (& $get $hit 'changes')[$Path] 'changeCategory') } else { $hit = $changes | Select-Object -First 1 }
        if (-not $hit) { return @{ By = ''; At = $null; Origin = 'Unknown' } }
        $by = [string](& $get $hit 'changedBy')
        $at = & $get $hit 'at'
        $origin = if ($category -eq 'System' -or $by -match '(?i)^(Microsoft\.|System|Azure)') { 'Azure' } elseif ($by -match '@') { 'Manual' } elseif ($by) { 'Automation' } else { 'Unknown' }
        @{ By = $by; At = $(if ($at -is [datetime]) { $at } elseif ($at) { [datetime]::Parse([string]$at, [cultureinfo]::InvariantCulture).ToUniversalTime() } else { $null }); Origin = $origin }
    }
    $strategyFor = {
        param([string] $Source, [string] $Origin)
        if ($Source -eq 'Rule') { return @{ Strategy = 'Fix'; Text = '' } }
        switch ($Origin) {
            'Manual' { @{ Strategy = $(if ($Source -eq 'Terraform') { 'Re-deploy' } else { 'Revert' }); Text = $(if ($Source -eq 'Terraform') { 'Changed by hand outside Terraform: apply the configuration again (terraform apply), or bring the change into the code if it should stay.' } else { 'Changed by hand: set it back, or - if it should stay - save a new baseline (-SaveBaseline) and put it in the infrastructure as code.' }) } }
            'Azure' { @{ Strategy = 'Update the baseline'; Text = 'Changed by Azure (a platform update or default): accept it - save a new baseline, or update the infrastructure as code to match.' } }
            'Automation' { @{ Strategy = 'Update the baseline'; Text = 'Changed by an application or pipeline: if that''s the deployment that owns it, accept it and save a new baseline; if not, find out which one did.' } }
            default { @{ Strategy = 'Review'; Text = 'Who changed it isn''t recorded (Resource Graph keeps 14 days): check the Activity Log, then revert it or save a new baseline.' } }
        }
    }
    $drift = [System.Collections.Generic.List[object]]::new()
    $add = {
        param([string] $Severity, [string] $Source, [string] $Kind, [string] $Id, [string] $Type, [string] $Name, [string] $Property, [string] $Expected, [string] $Actual, [string] $Fix)
        $w = & $why $Id $Property
        $plan = & $strategyFor $Source $w.Origin
        $remedy = if ($Fix) { $Fix } else { $plan.Text }
        $drift.Add((New-AACFinding -TypeName 'AAC.ConfigurationDrift' -Severity $Severity -Category $Kind -Finding "$Name$(if ($Property) { ": $Property" }) - $($Kind.ToLowerInvariant())" -ResourceId $(if ($Id -like '/subscriptions/*') { $Id } else { '' }) -Resource $Name -ResourceType $Type `
                    -Subscription (& $subOf $Id) -Detail $(if ($Property) { "$(if ($Expected -ne '') { $Expected } else { '(none)' }) -> $(if ($Actual -ne '') { $Actual } else { '(none)' })" } else { '' }) -Remediation $remedy -Effort $(if ($plan.Strategy -in 'Revert', 'Fix') { 'Low' } elseif ($plan.Strategy -eq 'Re-deploy') { 'Low' } else { 'Medium' }) `
                    -Property ([ordered]@{ Source = $Source; Property = $Property; Expected = $Expected; Actual = $Actual; ChangedBy = $w.By; ChangedAt = $w.At; Origin = $w.Origin; Strategy = $plan.Strategy })))
    }
    $sev = { param([string] $Path) if ($Path -match $sensitive) { 'High' } else { 'Medium' } }

    # --- Against the baseline ------------------------------------------------------------------------------------------
    if ($null -ne $Baseline) {
        foreach ($id in $Current.Keys) {
            $now = $Current[$id]
            if (-not $Baseline.Contains($id)) { & $add 'Low' 'Baseline' 'New resource' $id $now.Type $now.Name '' '' '' 'Created since the baseline: if it''s meant to be there, save a new baseline; if not, find who created it and remove it.'; continue }
            $was = $Baseline[$id]
            $wasValues = $was.Settings
            foreach ($path in @(@($now.Settings.Keys) + @($wasValues.Keys) | Select-Object -Unique | Sort-Object)) {
                $before = if ($wasValues.Contains($path)) { [string]$wasValues[$path] } else { $null }
                $after = if ($now.Settings.Contains($path)) { [string]$now.Settings[$path] } else { $null }
                if ($before -ceq $after) { continue }
                $kind = if ($null -eq $before) { 'Added' } elseif ($null -eq $after) { 'Removed' } else { 'Changed' }
                & $add (& $sev $path) 'Baseline' $kind $id $now.Type $now.Name $path $(if ($null -ne $before) { $before } else { '' }) $(if ($null -ne $after) { $after } else { '' }) ''
            }
        }
        foreach ($id in $Baseline.Keys) {
            if ($Current.Contains($id)) { continue }
            $was = $Baseline[$id]
            & $add 'Medium' 'Baseline' 'Deleted resource' $id ([string]$was.Type) ([string]$was.Name) '' '' '' 'Deleted since the baseline: if that was intended, save a new baseline; if not, recreate it from infrastructure as code or a backup.'
        }
    }

    # --- Against the rules -------------------------------------------------------------------------------------------------
    foreach ($id in $Current.Keys) {
        $now = $Current[$id]
        foreach ($r in @($Rule | Where-Object { $now.Type -like $_.ResourceType })) {
            $actual = if ($now.Settings.Contains($r.Property)) { [string]$now.Settings[$r.Property] } else { $null }
            $expected = @($r.Expected | ForEach-Object { [string]$_ })
            $ok = switch ($r.Operator) {
                'Equals' { $null -ne $actual -and $actual -eq $expected[0] }
                'NotEquals' { $actual -ne $expected[0] }
                'In' { $null -ne $actual -and @($expected | Where-Object { $_ -eq $actual }).Count -gt 0 }
                'Match' { $null -ne $actual -and $actual -match $expected[0] }
                'Exists' { $null -ne $actual }
            }
            if ($ok) { continue }
            & $add $r.Severity 'Rule' 'Violation' $id $now.Type $now.Name $r.Property $(switch ($r.Operator) { 'In' { "one of $($expected -join ', ')" } 'NotEquals' { "not $($expected[0])" } 'Match' { "matching $($expected[0])" } 'Exists' { 'set' } default { $expected[0] } }) $(if ($null -ne $actual) { $actual } else { '' }) $r.Remediation
        }
    }

    # --- Terraform: what changed outside it ------------------------------------------------------------------------------
    foreach ($t in $TerraformDrift) {
        $tfChange = & $get $t 'change'
        $before = & $get $tfChange 'before'; $after = & $get $tfChange 'after'
        $address = [string](& $get $t 'address')
        $id = ([string]$(if (& $get $after 'id') { & $get $after 'id' } elseif (& $get $before 'id') { & $get $before 'id' } else { $address })).ToLowerInvariant()
        $flatBefore = (ConvertTo-AACConfigurationSnapshot -Resource @(@{ id = $id; type = 'x'; name = ''; properties = $before }))[$id]['Settings']
        $flatAfter = (ConvertTo-AACConfigurationSnapshot -Resource @(@{ id = $id; type = 'x'; name = ''; properties = $after }))[$id]['Settings']
        $actions = @(& $get $tfChange 'actions')
        if ($actions -contains 'delete') { & $add 'Medium' 'Terraform' 'Deleted resource' $id ([string](& $get $t 'type')) $address '' '' '' 'Deleted outside Terraform: run terraform apply to recreate it, or remove it from the code.'; continue }
        foreach ($path in @(@($flatBefore.Keys) + @($flatAfter.Keys) | Select-Object -Unique | Sort-Object)) {
            $b = if ($flatBefore.Contains($path)) { [string]$flatBefore[$path] } else { $null }
            $a = if ($flatAfter.Contains($path)) { [string]$flatAfter[$path] } else { $null }
            if ($b -ceq $a) { continue }
            $attribute = $path -replace '^properties\.', ''
            & $add (& $sev $attribute) 'Terraform' $(if ($null -eq $b) { 'Added' } elseif ($null -eq $a) { 'Removed' } else { 'Changed' }) $id ([string](& $get $t 'type')) $address $attribute $(if ($null -ne $b) { $b } else { '' }) $(if ($null -ne $a) { $a } else { '' }) ''
        }
    }

    $sorted = @($drift | Sort-Object -Property @{ Expression = { $severity.Rank[$_.Severity] } }, Source, Resource, Property)
    @{
        Drift = $sorted
        Stats = @{
            Items     = $sorted.Count
            Resources = @($sorted | ForEach-Object { if ($_.ResourceId) { $_.ResourceId } else { $_.Resource } } | Select-Object -Unique).Count
            High      = @($sorted | Where-Object Severity -In 'Critical', 'High').Count
            Manual    = @($sorted | Where-Object Origin -EQ 'Manual').Count
            Violations = @($sorted | Where-Object Source -EQ 'Rule').Count
            # A resource deleted since the baseline was checked too.
            Checked   = @(@($Current.Keys) + @(if ($null -ne $Baseline) { $Baseline.Keys }) | Select-Object -Unique).Count
        }
    }
}