Public/Compare-TCMBaseline.ps1
|
function Compare-TCMBaseline { <# .SYNOPSIS Detect new or deleted resources not tracked by TCM drift monitoring. .DESCRIPTION TCM monitors only detect property changes on resources that exist in the baseline. New resources added to the tenant (e.g., a rogue CA policy) or deleted resources are invisible to drift detection. This cmdlet fills that gap by comparing the monitor's baseline against a fresh snapshot to find: - New resources: present in the tenant but not in the baseline - Deleted resources: in the baseline but no longer in the tenant - Matched resources: covered by TCM drift detection (shown with -Detailed) The snapshot covers all resource types from the monitoring profile (default: Recommended), not just types with existing data in the baseline. This ensures new resources in previously-empty types are detected. .PARAMETER MonitorId The monitor to compare. If omitted, uses the first active monitor. .PARAMETER Profile Monitoring profile that defines which resource types to snapshot. Default: Recommended. This should match the profile used to create the baseline. .PARAMETER Detailed Show per-resource instance details, not just summary counts. .PARAMETER KeepSnapshot Don't auto-delete the comparison snapshot job after analysis. .PARAMETER Force Bypass the 1-hour result cache and take a fresh snapshot. .PARAMETER WhatIf Preview which resource types will be snapshotted and the quota cost. .EXAMPLE Compare-TCMBaseline .EXAMPLE Compare-TCMBaseline -Detailed .EXAMPLE Compare-TCMBaseline -MonitorId 'eca21d95-...' -KeepSnapshot #> [CmdletBinding(SupportsShouldProcess)] param( [string]$MonitorId, [ValidateSet('SecurityCritical', 'Recommended', 'Full')] [string]$Profile = 'Recommended', [switch]$Detailed, [switch]$KeepSnapshot, [switch]$Force ) # Check file cache (1-hour TTL) — survives Import-Module -Force and session restarts if (-not $Force -and (Test-Path $script:CompareBaselineCachePath)) { $cacheFile = Get-Item $script:CompareBaselineCachePath if ($cacheFile.LastWriteTime -gt (Get-Date).AddHours(-1)) { $cached = Get-Content $script:CompareBaselineCachePath -Raw | ConvertFrom-Json Write-Host "Using cached baseline comparison from $($cacheFile.LastWriteTime.ToString('HH:mm:ss')) ($($cached.NewCount) new, $($cached.DeletedCount) deleted). Use -Force to refresh." -ForegroundColor DarkGray return $cached } } # 1. Resolve monitor and get baseline Write-Host 'Retrieving monitor baseline...' -ForegroundColor Cyan $monitor = if ($MonitorId) { Get-TCMMonitor -Id $MonitorId -IncludeBaseline } else { $all = Get-TCMMonitor -IncludeBaseline if ($all -is [array]) { $all[0] } else { $all } } if (-not $monitor) { throw 'No monitor found. Create one first with New-TCMMonitor.' } $monitorName = $monitor.DisplayName $baselineObj = $monitor.Baseline if (-not $baselineObj) { throw "Could not retrieve baseline for monitor '$monitorName'." } $baselineResources = if ($baselineObj.resources) { @($baselineObj.resources) } elseif ($baselineObj.Resources) { @($baselineObj.Resources) } else { @() } if ($baselineResources.Count -eq 0) { throw "Monitor '$monitorName' has an empty baseline." } # 2. Extract distinct resource types from baseline AND profile $baselineTypes = @($baselineResources | ForEach-Object { if ($_ -is [System.Collections.IDictionary]) { $_['ResourceType'] ?? $_['resourceType'] } else { $_.ResourceType ?? $_.resourceType } } | Select-Object -Unique) # Include all types from the monitoring profile — not just baseline types # This catches new resources in types that had zero instances at baseline time $profiles = Get-TCMMonitoringProfile $profileTypes = if ($Profile -eq 'Full') { # For Full, use all known types from all workloads $workloads = Get-TCMWorkloadResources $workloads.Values | ForEach-Object { $_ } | Select-Object -Unique } else { $profiles[$Profile] } $snapshotTypes = @(@($baselineTypes) + @($profileTypes) | Select-Object -Unique | Sort-Object) Write-Host " Monitor: $monitorName ($($baselineResources.Count) resources across $($baselineTypes.Count) types)" -ForegroundColor DarkGray Write-Host " Profile: $Profile ($($snapshotTypes.Count) types to scan)" -ForegroundColor DarkGray # 3. WhatIf: show preview if (-not $PSCmdlet.ShouldProcess("Snapshot $($snapshotTypes.Count) resource types ($Profile profile)", 'Create comparison snapshot')) { Write-Host "`nPreview — resource types that would be snapshotted:" -ForegroundColor Yellow foreach ($t in $snapshotTypes | Sort-Object) { $count = @($baselineResources | Where-Object { $rt = if ($_ -is [System.Collections.IDictionary]) { $_['ResourceType'] ?? $_['resourceType'] } else { $_.ResourceType ?? $_.resourceType } $rt -eq $t }).Count $marker = if ($count -eq 0) { ' (no baseline data)' } else { " ($count in baseline)" } Write-Host " $t$marker" } Write-Host "`nEstimated snapshot quota cost against 20,000/month limit" -ForegroundColor Yellow return } # 4. Take a fresh snapshot of all profile resource types Write-Host 'Taking comparison snapshot...' -ForegroundColor Cyan $snapshotName = "Compare $(Get-Date -Format 'yyyyMMdd HHmmss')" $snapshotJob = New-TCMSnapshot -DisplayName $snapshotName -Resources $snapshotTypes -Wait -TimeoutSeconds 300 $jobId = if ($snapshotJob -is [System.Collections.IDictionary]) { $snapshotJob['id'] } else { $snapshotJob.id } $jobStatus = if ($snapshotJob -is [System.Collections.IDictionary]) { $snapshotJob['status'] } else { $snapshotJob.status } if ($jobStatus -notin @('succeeded', 'partiallySuccessful')) { Write-Warning "Snapshot status: $jobStatus — comparison may be incomplete." } # 5. Fetch snapshot content Write-Host 'Fetching snapshot content...' -ForegroundColor Cyan $snapshot = Get-TCMSnapshot -Id $jobId -IncludeContent $snapshotContent = if ($snapshot -is [System.Collections.IDictionary]) { $snapshot['snapshotContent'] } else { $snapshot.snapshotContent } $snapshotResources = @() if ($snapshotContent) { $snapshotResources = if ($snapshotContent.resources) { @($snapshotContent.resources) } elseif ($snapshotContent.Resources) { @($snapshotContent.Resources) } elseif ($snapshotContent -is [System.Collections.IList]) { @($snapshotContent) } else { @() } } Write-Host " Snapshot: $($snapshotResources.Count) resources returned" -ForegroundColor DarkGray # 6. Build lookup tables keyed by resourceType + unique identifier # Fallback chain: Properties.Id → Identity → Name → top-level displayName # Some types (transportrule, dlpcompliancepolicy) lack Id/Identity but have Name. $baselineLookup = @{} foreach ($r in $baselineResources) { $rt = if ($r -is [System.Collections.IDictionary]) { $r['ResourceType'] ?? $r['resourceType'] } else { $r.ResourceType ?? $r.resourceType } $props = if ($r -is [System.Collections.IDictionary]) { $r['Properties'] ?? $r['properties'] } else { $r.Properties ?? $r.properties } $id = if ($props -is [System.Collections.IDictionary]) { $props['Id'] ?? $props['id'] ?? $props['Identity'] ?? $props['identity'] ?? $props['Name'] ?? $props['name'] } else { $props.Id ?? $props.id ?? $props.Identity ?? $props.identity ?? $props.Name ?? $props.name } if (-not $id) { $id = if ($r -is [System.Collections.IDictionary]) { $r['DisplayName'] ?? $r['displayName'] } else { $r.DisplayName ?? $r.displayName } if ($id -and $id.Length -gt 128) { $id = $id.Substring(0, 128) } } $dn = if ($props -is [System.Collections.IDictionary]) { $props['DisplayName'] ?? $props['displayName'] ?? $props['Name'] ?? $props['name'] } else { $props.DisplayName ?? $props.displayName ?? $props.Name ?? $props.name } $key = "$rt|$id" $baselineLookup[$key] = @{ ResourceType = $rt; Id = $id; DisplayName = $dn; Source = 'Baseline' } } $snapshotLookup = @{} foreach ($r in $snapshotResources) { $rt = if ($r -is [System.Collections.IDictionary]) { $r['resourceType'] ?? $r['ResourceType'] } else { $r.resourceType ?? $r.ResourceType } $props = if ($r -is [System.Collections.IDictionary]) { $r['properties'] ?? $r['Properties'] } else { $r.properties ?? $r.Properties } $id = if ($props -is [System.Collections.IDictionary]) { $props['Id'] ?? $props['id'] ?? $props['Identity'] ?? $props['identity'] ?? $props['Name'] ?? $props['name'] } else { $props.Id ?? $props.id ?? $props.Identity ?? $props.identity ?? $props.Name ?? $props.name } if (-not $id) { $id = if ($r -is [System.Collections.IDictionary]) { $r['displayName'] ?? $r['DisplayName'] } else { $r.displayName ?? $r.DisplayName } if ($id -and $id.Length -gt 128) { $id = $id.Substring(0, 128) } } $dn = if ($props -is [System.Collections.IDictionary]) { $props['DisplayName'] ?? $props['displayName'] ?? $props['Name'] ?? $props['name'] } else { $props.DisplayName ?? $props.displayName ?? $props.Name ?? $props.name } $key = "$rt|$id" $snapshotLookup[$key] = @{ ResourceType = $rt; Id = $id; DisplayName = $dn; Source = 'Snapshot' } } # 7. Compare: find new, deleted, matched $newResources = [System.Collections.Generic.List[object]]::new() $deletedResources = [System.Collections.Generic.List[object]]::new() $matchedCount = 0 foreach ($key in $snapshotLookup.Keys) { if (-not $baselineLookup.ContainsKey($key)) { $newResources.Add($snapshotLookup[$key]) } else { $matchedCount++ } } foreach ($key in $baselineLookup.Keys) { if (-not $snapshotLookup.ContainsKey($key)) { $deletedResources.Add($baselineLookup[$key]) } } # 8. Build per-type summary $allTypes = @($baselineTypes + @($snapshotResources | ForEach-Object { if ($_ -is [System.Collections.IDictionary]) { $_['resourceType'] ?? $_['ResourceType'] } else { $_.resourceType ?? $_.ResourceType } })) | Select-Object -Unique | Sort-Object $results = foreach ($t in $allTypes) { $bCount = @($baselineResources | Where-Object { $rt = if ($_ -is [System.Collections.IDictionary]) { $_['ResourceType'] ?? $_['resourceType'] } else { $_.ResourceType ?? $_.resourceType } $rt -eq $t }).Count $sCount = @($snapshotResources | Where-Object { $rt = if ($_ -is [System.Collections.IDictionary]) { $_['resourceType'] ?? $_['ResourceType'] } else { $_.resourceType ?? $_.ResourceType } $rt -eq $t }).Count $newCount = @($newResources | Where-Object { $_.ResourceType -eq $t }).Count $delCount = @($deletedResources | Where-Object { $_.ResourceType -eq $t }).Count $status = if ($newCount -gt 0 -and $delCount -gt 0) { "+$newCount New / -$delCount Deleted" } elseif ($newCount -gt 0) { "+$newCount New" } elseif ($delCount -gt 0) { "-$delCount Deleted" } else { 'OK' } # Extract short type name for display $shortType = ($t -split '\.')[-1] [PSCustomObject]@{ PSTypeName = 'EasyTCM.BaselineComparison' ResourceType = $t ShortType = $shortType Baseline = $bCount Current = $sCount New = $newCount Deleted = $delCount Status = $status } } # 9. Display results $hasChanges = ($newResources.Count -gt 0 -or $deletedResources.Count -gt 0) Write-Host '' if ($hasChanges) { Write-Host " BASELINE DRIFT DETECTED" -ForegroundColor Red Write-Host " $($newResources.Count) new resource(s), $($deletedResources.Count) deleted resource(s), $matchedCount matched" -ForegroundColor Yellow } else { Write-Host " NO BASELINE DRIFT" -ForegroundColor Green Write-Host " All $matchedCount resources in baseline match the current tenant" -ForegroundColor DarkGray } Write-Host '' # Summary table $results | Format-Table ResourceType, Baseline, Current, New, Deleted, Status -AutoSize | Out-String | Write-Host # Detailed view if ($Detailed -and $hasChanges) { if ($newResources.Count -gt 0) { Write-Host ' NEW RESOURCES (not in baseline):' -ForegroundColor Yellow foreach ($r in $newResources | Sort-Object { $_.ResourceType }) { $shortType = ($r.ResourceType -split '\.')[-1] Write-Host " [+] $shortType — $($r.DisplayName) (Id: $($r.Id))" -ForegroundColor Green } Write-Host '' } if ($deletedResources.Count -gt 0) { Write-Host ' DELETED RESOURCES (in baseline but gone):' -ForegroundColor Yellow foreach ($r in $deletedResources | Sort-Object { $_.ResourceType }) { $shortType = ($r.ResourceType -split '\.')[-1] Write-Host " [-] $shortType — $($r.DisplayName) (Id: $($r.Id))" -ForegroundColor Red } Write-Host '' } } # 10. Cleanup snapshot if (-not $KeepSnapshot -and $jobId) { Write-Host 'Cleaning up comparison snapshot...' -ForegroundColor DarkGray try { Remove-TCMSnapshot -Id $jobId -Confirm:$false } catch { Write-Debug "Could not auto-delete snapshot $jobId : $_" } } # Return structured output $result = [PSCustomObject]@{ PSTypeName = 'EasyTCM.BaselineComparisonResult' Monitor = $monitorName MonitorId = $monitor.Id BaselineCount = $baselineResources.Count CurrentCount = $snapshotResources.Count NewCount = $newResources.Count DeletedCount = $deletedResources.Count MatchedCount = $matchedCount HasDrift = $hasChanges NewResources = $newResources DeletedResources = $deletedResources TypeSummary = $results SnapshotId = if ($KeepSnapshot) { $jobId } else { $null } } # Cache result to file (1-hour TTL via LastWriteTime) $result | ConvertTo-Json -Depth 20 | Set-Content -Path $script:CompareBaselineCachePath -Encoding utf8 $result } |