Public/Get-AACConfigurationDrift.ps1

function Get-AACConfigurationDrift {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Finds configuration drift: what's deployed against what it should be
        - a baseline you saved, desired-state rules (yours, or a built-in
        security baseline), or your Terraform code - with who or what changed
        it, how to put it right, and whether drift is trending better or
        worse.
    .DESCRIPTION
        Desired state, one or more of:
          -BaselinePath a snapshot saved earlier with -SaveBaseline:
                             every setting of every resource (SKU, tags,
                             properties - not provisioning states, timestamps
                             or counters) compared; resources created or
                             deleted since
          -DesiredStatePath your rules (.psd1 or .json): a resource type, a
                             setting, what it must be (Equals, NotEquals, In,
                             Match, Exists), a severity and the fix
          -UseDefaultRules a built-in security baseline: TLS 1.2, HTTPS
                             only, no anonymous blob access, soft delete and
                             purge protection, RBAC, no local or shared-key
                             authentication, no admin users...
          -TerraformPlanPath a plan's JSON (terraform show -json plan.out):
                             its resource_drift - what changed outside
                             Terraform
        -SaveBaseline writes today's snapshot, for the next run's
        -BaselinePath (it can be the same file: compare, then save).
 
        Why it drifted: Resource Graph's change history (14 days) - who
        changed the setting, when, and whether a person (Manual), an
        application or pipeline (Automation) or Azure (a platform update).
        The strategy follows: Fix (a rule), Revert or Re-deploy (a manual
        change), Update the baseline (an intended or platform change),
        Review (unknown).
 
        -HistoryPath keeps a CSV line per run - resources checked, drifted,
        high - and the report shows whether drift is better or worse than
        last time.
 
        Read-only: nothing is changed or remediated; the fixes are the
        operator's to apply (through infrastructure as code, ideally).
        Reader is enough.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER ResourceType
        Only these resource types (wildcards work).
    .PARAMETER BaselinePath
        A snapshot saved earlier with -SaveBaseline, to compare with.
    .PARAMETER SaveBaseline
        Save today's snapshot to this file.
    .PARAMETER DesiredStatePath
        Desired-state rules (.psd1 or .json).
    .PARAMETER UseDefaultRules
        Check the built-in security baseline too.
    .PARAMETER TerraformPlanPath
        A Terraform plan in JSON: its resource_drift.
    .PARAMETER HistoryPath
        A CSV file that keeps a line per run, for the trend.
    .PARAMETER MaxResources
        At most this many resources (1 to 20000; 5000 by default).
    .PARAMETER CsvPath
        Write the drift to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the drift.
    .PARAMETER NoDisplay
        Return the drift without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACConfigurationDrift -ResourceGroupName 'rg-app-prod' -SaveBaseline .\baseline\app-prod.json
        Save the production app's configuration as its baseline.
    .EXAMPLE
        Get-AACConfigurationDrift -ResourceGroupName 'rg-app-prod' -BaselinePath .\baseline\app-prod.json -HistoryPath .\baseline\history.csv
        What changed since, who changed it, and the trend.
    .EXAMPLE
        Get-AACConfigurationDrift -UseDefaultRules -DesiredStatePath .\desired.psd1 -HtmlPath .\out\Drift.html
        The built-in security baseline and your own rules, as a report.
    .EXAMPLE
        terraform show -json plan.out > plan.json; Get-AACConfigurationDrift -TerraformPlanPath .\plan.json
        What changed outside Terraform, and who changed it.
    .OUTPUTS
        AAC.ConfigurationDrift
    #>

    [CmdletBinding()]
    [OutputType('AAC.ConfigurationDrift')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $ResourceType,

        [string] $BaselinePath,

        [string] $SaveBaseline,

        [string] $DesiredStatePath,

        [switch] $UseDefaultRules,

        [string] $TerraformPlanPath,

        [string] $HistoryPath,

        [ValidateRange(1, 20000)]
        [int] $MaxResources = 5000,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Configuration drift',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    if (-not ($BaselinePath -or $SaveBaseline -or $DesiredStatePath -or $UseDefaultRules -or $TerraformPlanPath)) {
        $problem = [System.ArgumentException]::new('Nothing to compare with.')
        $problem.Data['AACHint'] = 'Give a desired state: -UseDefaultRules, -DesiredStatePath, -BaselinePath (saved earlier with -SaveBaseline) or -TerraformPlanPath.'
        throw $problem
    }
    # Read the files first, so a bad one fails before any Azure call.
    $baseline = $null
    $baselineInfo = ''
    if ($BaselinePath) {
        $file = & $resolve $BaselinePath
        if (-not (Test-Path -LiteralPath $file)) { throw "The baseline $file doesn't exist. Save one first with -SaveBaseline." }
        $saved = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json -AsHashtable -Depth 50
        if ($saved -isnot [System.Collections.IDictionary] -or -not $saved.Contains('Resources')) { throw "$file isn't a baseline saved by Get-AACConfigurationDrift -SaveBaseline." }
        $baseline = @{}
        foreach ($k in $saved['Resources'].Keys) { $entry = $saved['Resources'][$k]; $baseline[$k] = @{ Type = [string]$entry['Type']; Name = [string]$entry['Name']; Settings = $(if ($entry['Settings']) { $entry['Settings'] } else { @{} }) } }
        $baselineInfo = "$(Split-Path -Leaf $file), saved $([string]$saved['CapturedAt'])"
    }
    $rules = @(Get-AACDriftRule -Path (& $resolve $DesiredStatePath) -Default:$UseDefaultRules)
    $terraform = @()
    if ($TerraformPlanPath) {
        $planFile = & $resolve $TerraformPlanPath
        if (-not (Test-Path -LiteralPath $planFile)) { throw "The Terraform plan $planFile doesn't exist." }
        $plan = Get-Content -LiteralPath $planFile -Raw | ConvertFrom-Json -AsHashtable -Depth 100
        if ($plan -isnot [System.Collections.IDictionary] -or -not ($plan.Contains('resource_changes') -or $plan.Contains('resource_drift') -or $plan.Contains('format_version'))) { throw "$planFile isn't a Terraform plan in JSON: make one with terraform show -json plan.out." }
        $terraform = @($plan['resource_drift'] | Where-Object { $_ })
    }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ })
        ResourceType = @($ResourceType | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() }); MaxResources = $MaxResources
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Configuration drift' -Color 'deepskyblue3_1' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $quote = { param([string] $Text) "'" + ($Text -replace "'", "\'") + "'" }
        $filters = @(
            if ($request.ResourceGroupName.Count) { "resourceGroup in~ ($((@($request.ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" }
            if ($request.ResourceType.Count) { "($((@($request.ResourceType | ForEach-Object { "tolower(type) matches regex @'^$([regex]::Escape($_) -replace '\\\*', '.*')$'" })) -join ' or '))" }
        )
        $where = if ($filters.Count) { " | where $($filters -join ' and ')" } else { '' }
        $queries = [ordered]@{ resources = "resources$where | project id, name, type, location, sku, kind, tags, identity, properties | take $($request.MaxResources)" }
        $queries['changes'] = "resourcechanges | extend p = properties | extend at = todatetime(p.changeAttributes.timestamp) | where at > ago(14d) | extend resourceId = tolower(tostring(p.targetResourceId))$(if ($request.ResourceGroupName.Count) { " | where tostring(split(resourceId, '/')[4]) in~ ($((@($request.ResourceGroupName | ForEach-Object { & $quote $_.ToLowerInvariant() })) -join ', '))" }) | project id, at, resourceId, changedBy = tostring(p.changeAttributes.changedBy), clientType = tostring(p.changeAttributes.clientType), changes = p.changes"
        Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading the configuration and its change history'
        $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('changes') -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total)" }
        $notices = [System.Collections.Generic.List[string]]::new()
        if ($read.Errors['changes']) { $notices.Add("The change history couldn't be read, so who changed what is unknown: $($read.Errors['changes'])") }
        $resources = @($read.Rows['resources'] | Where-Object { $_ })
        if ($resources.Count -ge $request.MaxResources) { $notices.Add("Only $($request.MaxResources) resources were read (-MaxResources): narrow the scope for the rest.") }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} resource(s) and {1:N0} recorded change(s)' -f $resources.Count, @($read.Rows['changes']).Count)
        Update-AACProgress -Id 'drift' -Indeterminate -Description 'Comparing with the desired state'
        $snapshot = ConvertTo-AACConfigurationSnapshot -Resource $resources
        $driftArgs = @{ Current = $snapshot; Rule = $rules; TerraformDrift = $terraform; Change = @($read.Rows['changes'] | Where-Object { $_ }); SubscriptionName = $scope.Names }
        if ($null -ne $baseline) { $driftArgs.Baseline = $baseline }
        $result = ConvertTo-AACConfigurationDrift @driftArgs
        Update-AACProgress -Id 'drift' -Complete -Description ('{0:N0} resource(s) checked: {1:N0} drifted, {2:N0} item(s), {3:N0} high' -f $result.Stats.Checked, $result.Stats.Resources, $result.Stats.Items, $result.Stats.High)
        @{ Result = $result; Scope = $scope; Snapshot = $snapshot; Notices = $notices }
    }

    $result = $state.Result
    $drift = @($result.Drift)
    $s = $result.Stats
    $notices = $state.Notices
    # Today's snapshot - after the comparison, so the same file can be compared with, then replaced.
    if ($SaveBaseline) {
        $target = & $resolve $SaveBaseline
        $folder = Split-Path -Path $target -Parent
        if ($folder -and -not (Test-Path -LiteralPath $folder)) { $null = New-Item -ItemType Directory -Path $folder -Force }
        $json = ConvertTo-Json -InputObject ([ordered]@{ Version = 1; CapturedAt = [datetime]::UtcNow.ToString('o'); Scope = $state.Scope.Label; Resources = $state.Snapshot }) -Depth 10 -Compress
        [System.IO.File]::WriteAllText($target, $json, [System.Text.UTF8Encoding]::new($false))
        $notices.Add("Baseline saved: $($state.Snapshot.Count) resource(s) in $target.")
    }
    # The trend: a line per run.
    $history = @()
    if ($HistoryPath) {
        $historyFile = & $resolve $HistoryPath
        if (Test-Path -LiteralPath $historyFile) { $history = @(Import-Csv -LiteralPath $historyFile) }
        $line = [pscustomobject][ordered]@{ Date = [datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm'); Scope = $state.Scope.Label; Checked = $s.Checked; Drifted = $s.Resources; Items = $s.Items; High = $s.High; Manual = $s.Manual }
        $folder = Split-Path -Path $historyFile -Parent
        if ($folder -and -not (Test-Path -LiteralPath $folder)) { $null = New-Item -ItemType Directory -Path $folder -Force }
        $line | Export-Csv -LiteralPath $historyFile -NoTypeInformation -Append -Encoding utf8
        $history = @($history) + $line
    }
    $trend = if ($history.Count -ge 2) {
        $previous = $history[-2]
        $delta = [int]$s.Items - [int]$previous.Items
        if ($delta -lt 0) { "better: $(-$delta) fewer drift item(s) than on $($previous.Date)" } elseif ($delta -gt 0) { "worse: $delta more drift item(s) than on $($previous.Date)" } else { "unchanged since $($previous.Date)" }
    }
    $rank = Get-AACSeverityRank
    $strategyTones = @{ Fix = 'bad'; Revert = 'warn'; 'Re-deploy' = 'warn'; 'Update the baseline' = 'info'; Review = 'neutral' }
    $sources = @(if ($BaselinePath) { "baseline ($baselineInfo)" }; if ($DesiredStatePath) { "rules ($(Split-Path -Leaf $DesiredStatePath))" }; if ($UseDefaultRules) { 'the built-in security baseline' }; if ($TerraformPlanPath) { "Terraform ($(Split-Path -Leaf $TerraformPlanPath))" })
    $report = @{
        Subtitle = 'Configuration drift: the deployed configuration against its desired state'
        Facts    = [ordered]@{ Scope = $state.Scope.Label; 'Desired state' = $(if ($sources.Count) { $sources -join '; ' } else { 'none - baseline saved only' }); Trend = $(if ($trend) { $trend } else { '-' }) }
        Status   = $(if ($s.High) { 'Failed' } elseif ($s.Items) { 'Warning' } else { 'Success' })
        Headline = $(if (-not $sources.Count) { "Baseline saved for $($s.Checked) resource(s): compare with it next time (-BaselinePath)." } elseif ($s.Items) { "$($s.Resources) of $($s.Checked) resource(s) drifted: $($s.Items) item(s), $($s.High) high, $($s.Manual) changed by hand$(if ($trend) { " - $trend" })" } else { "No drift: $($s.Checked) resource(s) match their desired state$(if ($trend) { " - $trend" })." })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $s.Resources; Label = "drifted of $($s.Checked)"; Tone = $(if ($s.Resources) { 'warn' } else { 'good' }); Table = 'drift' }
            @{ Value = '{0:N0}' -f $s.High; Label = 'high'; Tone = $(if ($s.High) { 'bad' } else { 'good' }); Table = 'drift'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f $s.Violations; Label = 'rule violations'; Tone = $(if ($s.Violations) { 'bad' } else { 'good' }); Table = 'drift'; Filters = @{ Source = 'Rule' } }
            @{ Value = '{0:N0}' -f $s.Manual; Label = 'changed by hand'; Tone = $(if ($s.Manual) { 'warn' } else { 'good' }); Table = 'drift'; Filters = @{ Origin = 'Manual' } }
            @{ Value = $(if ($trend) { ($trend -split ':')[0] } else { '-' }); Label = 'since last run'; Tone = $(if ($trend -like 'better*') { 'good' } elseif ($trend -like 'worse*') { 'bad' } else { 'neutral' }) }
        )
        Notices  = @($notices | ForEach-Object { @{ Status = $(if ($_ -like 'Baseline saved*') { 'Success' } else { 'Warning' }); Text = $_ } })
        Charts   = @(
            @{ Title = 'Drift by strategy'; Kind = 'donut'; CenterLabel = 'items'; Items = @($drift | Group-Object Strategy | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $strategyTones[$_.Name]; Filter = $_.Name } }); Table = 'drift'; Column = 'Strategy'; Console = $true }
            @{ Title = 'Drift by origin'; Items = @($drift | Group-Object Origin | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'drift'; Column = 'Origin'; Tone = 'warn' }
            @{ Title = 'Most drifted resources'; Items = @($drift | Group-Object Resource | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'drift'; Column = 'Resource'; Tone = 'violet' }
        )
        Tables   = @(
            @{ Id = 'drift'; Title = 'Drift'; Section = 'Drift'; Rows = $drift; Noun = 'items'; GroupBy = @('Resource', 'Source', 'Strategy', 'Origin', 'Severity'); ConsoleLimit = 30
                Empty = $(if ($sources.Count) { 'No drift: everything matches its desired state.' } else { 'Nothing compared: the baseline was saved.' }); EmptyStatus = $(if ($sources.Count) { 'Success' } else { 'Info' })
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Category'; Label = 'Drift'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Property'; Label = 'Setting'; Type = 'mono'; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Expected -> actual'; Type = 'wide'; Console = $true; Pdf = $true }
                    @{ Key = 'ChangedBy'; Label = 'Changed by'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Strategy'; Label = 'Strategy'; Type = 'badge'; Tones = $strategyTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'ChangedAt'; Label = 'Changed'; Type = 'datetime' }
                    @{ Key = 'Origin'; Label = 'Origin'; Type = 'badge'; Tones = @{ Manual = 'warn'; Automation = 'info'; Azure = 'neutral'; Unknown = 'neutral' }; Facet = $true }
                    @{ Key = 'Source'; Label = 'Source'; Facet = $true }
                    @{ Key = 'ResourceType'; Label = 'Type'; Type = 'mono'; Facet = $true }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide' }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'history'; Title = 'History'; Section = 'History'; Rows = @($history); Noun = 'runs'; ConsoleLimit = 10
                Columns = @(@{ Key = 'Date'; Label = 'Date'; Console = $true }, @{ Key = 'Checked'; Label = 'Checked'; Console = $true }, @{ Key = 'Drifted'; Label = 'Drifted'; Console = $true }, @{ Key = 'Items'; Label = 'Items'; Console = $true }, @{ Key = 'High'; Label = 'High'; Console = $true }, @{ Key = 'Manual'; Label = 'By hand'; Console = $true }) }
        )
        Hint     = '-SaveBaseline, then -BaselinePath next time; -UseDefaultRules or -DesiredStatePath for rules; -TerraformPlanPath for Terraform; -HistoryPath for the trend.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $drift -Noun 'drift item' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $drift -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}