src/report/Get-ScoutInventoryDrift.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Compute cross-run RESOURCE/inventory drift (Added/Removed/Changed) between the current collect.json and the immediately previous run, and append the current run's inventory snapshot to a durable inventory-history log. .DESCRIPTION Get-ScoutDrift (src/report/Get-ScoutDrift.ps1) tracks FINDINGS drift — the scored Pass/Fail/Manual state of each rule across runs. This function fills the adjacent gap: RESOURCE drift — what actually changed in the collected Azure estate itself between two runs, independent of how any rule scored it. Given the canonical collect.json shape Invoke-Collect produces (a nested object of arrays of resource rows — networking.virtualNetworks, compute.virtualMachines, domains.storage.storageAccounts, etc.), this function: 1. Walks -Collect recursively, treating every array found anywhere in the tree as a collection of resource rows, and every object in that array as one resource. The dotted property path to that array (e.g. 'domains.storage.storageAccounts') is recorded alongside each resource so callers can group/label drift by resource type. 2. Builds a STABLE resource id for each resource from an ordered list of recognized identity-ish field names (id, name, vnet, subnet, nsg, rule, machineId, policyName, type, key) plus scope fields (resourceGroup, subscriptionId) — whichever of those are actually present on the resource, joined together and prefixed with its array path. This deliberately does NOT depend on Azure Resource Graph exposing a full ARM `id` (most collect.json queries project only name/resourceGroup/subscriptionId + scalar fields, no `id`). A resource with none of those recognized fields falls back to a SHA-256 hash of its full, name-sorted property set, so it still gets a deterministic id rather than being silently dropped — the tradeoff being that such a resource's id changes if ANY of its fields change (it will show as Removed+Added instead of Changed). Duplicate ids within the same run (two resources whose recognized identity fields collide) are disambiguated with a `#2`, `#3`, ... suffix in encounter order, so no resource is ever silently dropped. 3. Compares the current run's resource-id set against the previous run's persisted snapshot: Added - id present now, absent from the previous run Removed - id present in the previous run, absent now Changed - id present in both, but one or more field values differ (every property name from either side is compared; each differing field is reported as {Field, PreviousValue, CurrentValue}) Unchanged - id present in both with no field differences (rolled into Summary as a count only — not enumerated, since an unchanged resource carries no new information for a drift report). 4. Appends/replaces the current run's snapshot in the history file (a rerun with the same -RunId overwrites its own prior record rather than duplicating it) — same replace-on-rerun contract Get-ScoutDrift uses for findings-history.json. On the first-ever run for a given -HistoryPath (no usable prior snapshot), returns an explicit baseline result — IsBaseline = $true, Added/Removed/Changed all empty, Summary counts all zero except TotalCurrent — rather than treating every resource as "Added". A brand new inventory has nothing to have drifted from; it establishes the baseline the NEXT run will be compared against. .PARAMETER Collect The canonical collect object (Invoke-Collect's output, or the parsed contents of a run's collect.json) to compute inventory drift for. .PARAMETER HistoryPath Folder the inventory-history.json log lives in. Created if missing. Defaults to a .scout-history folder under the current location if not supplied — callers should normally pass the SAME -HistoryPath they pass to Get-ScoutDrift (e.g. a .scout-history folder under the assessment's -OutputPath root) so findings-history.json and inventory-history.json live side by side for one run tree. .PARAMETER RunId The caller-supplied run identifier stamped onto this run's inventory snapshot. Required — drift has no meaning without a stable, caller- controlled id to compare across. .OUTPUTS [pscustomobject] with RunId/GeneratedOn/IsBaseline/PreviousRunId/Summary/ Added/Removed/Changed — see tests/Report.InventoryDrift.Tests.ps1 for the exact shape exercised. .NOTES Tracks ADO Story AB#326. #> function Get-ScoutInventoryDrift { param( $Collect, [string] $HistoryPath, [string] $RunId ) if ($null -eq $Collect) { throw 'Get-ScoutInventoryDrift: -Collect is required.' } if ([string]::IsNullOrWhiteSpace($RunId)) { throw 'Get-ScoutInventoryDrift: -RunId is required (pass the caller-controlled run id, e.g. the assessment run-folder name).' } if ([string]::IsNullOrWhiteSpace($HistoryPath)) { $HistoryPath = Join-Path (Get-Location) 'output' '.scout-history' } if (-not (Test-Path $HistoryPath)) { New-Item -ItemType Directory -Path $HistoryPath -Force | Out-Null } $historyFile = Join-Path $HistoryPath 'inventory-history.json' function Get-InvProp { param($Obj, [string] $Name) if ($null -eq $Obj) { return $null } if ($Obj -isnot [System.Management.Automation.PSCustomObject]) { return $null } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } else { return $null } } # ---- recursively flatten -Collect into (Path, Resource) entries ------ # Every array found anywhere in the tree is treated as a resource # collection; every PSCustomObject inside it is one resource. Plain # objects (not arrays) are descended into by property name to build the # dotted path. `_meta` is skipped — it's run metadata (generatedOn, # scope, categories), never inventory. function Get-InventoryResourceEntry { param($Node, [string] $Path) $result = [System.Collections.Generic.List[pscustomobject]]::new() if ($null -eq $Node) { return $result } if ($Node -is [System.Management.Automation.PSCustomObject]) { foreach ($prop in $Node.PSObject.Properties) { if ($prop.Name -eq '_meta') { continue } $childPath = if ($Path) { "$Path.$($prop.Name)" } else { $prop.Name } foreach ($sub in (Get-InventoryResourceEntry -Node $prop.Value -Path $childPath)) { $result.Add($sub) } } return $result } if ($Node -is [string]) { return $result } if ($Node -is [System.Collections.IEnumerable]) { foreach ($item in $Node) { if ($null -eq $item) { continue } if ($item -is [string]) { continue } if ($item -is [System.Management.Automation.PSCustomObject]) { $result.Add([pscustomobject]@{ Path = $Path; Resource = $item }) continue } if ($item -is [System.Collections.IEnumerable]) { # Defensive: a stray nested array-of-arrays — flatten under the same path. foreach ($sub in (Get-InventoryResourceEntry -Node $item -Path $Path)) { $result.Add($sub) } continue } # Scalar row with no object shape (e.g. a stray empty-string # placeholder row) — nothing to key or diff, skip it. } return $result } return $result } # ---- stable resource id: recognized identity fields, else content hash ---- $script:idFieldOrder = @('id', 'name', 'vnet', 'subnet', 'nsg', 'rule', 'machineId', 'policyName', 'type', 'key') $script:scopeFieldOrder = @('resourceGroup', 'subscriptionId') function Get-InventoryResourceId { param([string] $Path, $Resource) $parts = [System.Collections.Generic.List[string]]::new() foreach ($f in $script:idFieldOrder) { $v = Get-InvProp $Resource $f if (-not [string]::IsNullOrEmpty([string]$v)) { $parts.Add("$f=$v") } } foreach ($f in $script:scopeFieldOrder) { $v = Get-InvProp $Resource $f if (-not [string]::IsNullOrEmpty([string]$v)) { $parts.Add("$f=$v") } } if ($parts.Count -eq 0) { # No recognized identity field at all — fall back to a deterministic # hash of the full, name-sorted property set so the resource still # gets a stable id instead of being silently dropped. $sortedJson = ( $Resource.PSObject.Properties | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Value | ConvertTo-Json -Compress -Depth 100)" } ) -join '|' $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sortedJson)) } finally { $sha256.Dispose() } $hash = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() $parts.Add("hash=$hash") } return "$Path::$($parts -join '|')" } # ---- build the current run's id -> {Path, Fields} map ------------------ $currentEntries = Get-InventoryResourceEntry -Node $Collect -Path '' $currentMap = [ordered]@{} $idSeenCount = @{} foreach ($entry in $currentEntries) { $baseId = Get-InventoryResourceId -Path $entry.Path -Resource $entry.Resource if ($idSeenCount.ContainsKey($baseId)) { $idSeenCount[$baseId]++ $id = "$baseId#$($idSeenCount[$baseId])" } else { $idSeenCount[$baseId] = 1 $id = $baseId } $currentMap[$id] = [pscustomobject]@{ Path = $entry.Path; Fields = $entry.Resource } } # ---- read prior history (tolerant: missing/corrupt => baseline, never throw) ---- $history = @() if (Test-Path $historyFile) { try { $raw = Get-Content $historyFile -Raw -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($raw)) { $parsed = $raw | ConvertFrom-Json -Depth 100 -ErrorAction Stop if ($null -ne $parsed) { $history = @($parsed) } } } catch { # Malformed/corrupt history file — treat as "no history" (baseline). $history = @() } } $priorCandidates = @($history | Where-Object { (Get-InvProp $_ 'RunId') -ne $RunId }) $previous = if ($priorCandidates.Count -gt 0) { $priorCandidates[-1] } else { $null } $isBaseline = ($null -eq $previous) $previousMap = [ordered]@{} if (-not $isBaseline) { $previousResources = Get-InvProp $previous 'Resources' if ($null -ne $previousResources) { foreach ($prop in $previousResources.PSObject.Properties) { $previousMap[$prop.Name] = $prop.Value } } } # ---- field-level diff between a previous and current resource's Fields ---- function Get-InventoryFieldChange { param($PreviousFields, $CurrentFields) $changes = [System.Collections.Generic.List[pscustomobject]]::new() $names = [System.Collections.Generic.List[string]]::new() if ($null -ne $PreviousFields -and $PreviousFields -is [System.Management.Automation.PSCustomObject]) { foreach ($p in $PreviousFields.PSObject.Properties) { if (-not $names.Contains($p.Name)) { $names.Add($p.Name) } } } if ($null -ne $CurrentFields -and $CurrentFields -is [System.Management.Automation.PSCustomObject]) { foreach ($p in $CurrentFields.PSObject.Properties) { if (-not $names.Contains($p.Name)) { $names.Add($p.Name) } } } foreach ($name in ($names | Sort-Object)) { $prevVal = Get-InvProp $PreviousFields $name $currVal = Get-InvProp $CurrentFields $name $prevJson = $prevVal | ConvertTo-Json -Compress -Depth 100 $currJson = $currVal | ConvertTo-Json -Compress -Depth 100 if ($prevJson -ne $currJson) { $changes.Add([pscustomobject]@{ Field = $name PreviousValue = $prevVal CurrentValue = $currVal }) } } return $changes } # ---- classify: Added / Removed / Changed / Unchanged ------------------- $added = [System.Collections.Generic.List[object]]::new() $removed = [System.Collections.Generic.List[object]]::new() $changed = [System.Collections.Generic.List[object]]::new() $unchangedCount = 0 if ($isBaseline) { # First-ever run for this history: nothing to have drifted from. # Persist the snapshot as the baseline the NEXT run compares against, # but report no Added/Removed/Changed — a baseline is not drift. } else { foreach ($id in $currentMap.Keys) { $curr = $currentMap[$id] if (-not $previousMap.Contains($id)) { $added.Add([pscustomobject]@{ Id = $id; Path = $curr.Path; Fields = $curr.Fields }) continue } $prevEntry = $previousMap[$id] $prevFields = Get-InvProp $prevEntry 'Fields' # @() forces an array even when Get-InventoryFieldChange returns zero or # one item — PowerShell unrolls a function's List[T] "return" onto the # pipeline, so an unwrapped assignment would collapse an empty list to # $null (breaking .Count below) or a single-item list to a bare scalar. $fieldChanges = @(Get-InventoryFieldChange -PreviousFields $prevFields -CurrentFields $curr.Fields) if ($fieldChanges.Count -gt 0) { $changed.Add([pscustomobject]@{ Id = $id; Path = $curr.Path; Changes = @($fieldChanges) }) } else { $unchangedCount++ } } foreach ($id in $previousMap.Keys) { if (-not $currentMap.Contains($id)) { $prevEntry = $previousMap[$id] $removed.Add([pscustomobject]@{ Id = $id; Path = (Get-InvProp $prevEntry 'Path'); Fields = (Get-InvProp $prevEntry 'Fields') }) } } } $result = [pscustomobject]@{ RunId = $RunId GeneratedOn = (Get-Date).ToString('o') IsBaseline = $isBaseline PreviousRunId = if ($isBaseline) { $null } else { Get-InvProp $previous 'RunId' } Summary = [pscustomobject]@{ Added = $added.Count Removed = $removed.Count Changed = $changed.Count Unchanged = $unchangedCount TotalCurrent = $currentMap.Count TotalPrevious = if ($isBaseline) { 0 } else { $previousMap.Count } } Added = @($added | Sort-Object Path, Id) Removed = @($removed | Sort-Object Path, Id) Changed = @($changed | Sort-Object Path, Id) } # ---- persist this run's snapshot (replace-on-rerun, else append) ------ $resourcesForStorage = [ordered]@{} foreach ($id in $currentMap.Keys) { $resourcesForStorage[$id] = [pscustomobject]@{ Path = $currentMap[$id].Path; Fields = $currentMap[$id].Fields } } $newRecord = [pscustomobject]@{ RunId = $RunId Timestamp = (Get-Date).ToString('o') Resources = [pscustomobject]$resourcesForStorage } $updatedHistory = @($history | Where-Object { (Get-InvProp $_ 'RunId') -ne $RunId }) + $newRecord $updatedHistory | ConvertTo-Json -Depth 100 | Out-File $historyFile -Encoding utf8 return $result } |