tools/Compare-DiscoveryRuns.ps1
|
<# .SYNOPSIS Compares two completed Discover-WindowsServer runs of the same server (or two fleet engagement folders) and reports what changed between them. .DESCRIPTION Every discovery run today is a standalone snapshot - there's no way to see "what changed since last time we scanned this client" without opening two report folders side by side and comparing by eye. This reads each run's evidence\data\json\<Dataset>.json files directly (the same per-dataset exports every run already produces) and diffs matching datasets by a natural key, rather than a raw text/JSON diff, so a share being renamed shows up as one row that changed, not as a removed-then-added pair, and so noisy-but-meaningless fields (a scheduled task's LastRunTime, a file share's usage counters) don't drown out real drift. Only datasets in $script:DriftKeyMap are compared - a dataset with no known natural key, or one that's been deliberately excluded (EventLogSamples, RunningProcesses - neither has a stable identity across two runs, since timestamps and PIDs are never the same twice), is reported as Unsupported rather than guessed at. This list is deliberately incomplete and easy to extend - see Compare-DiscoveryDataset. Two comparison modes: 1. Single-server: -BaselineRunFolder / -CurrentRunFolder, each a Discover-WindowsServer_<ComputerName>_<timestamp>\ folder. 2. Fleet-wide: -BaselineEngagementFolder / -CurrentEngagementFolder, each a folder containing one or more of those run folders (exactly what tools\Invoke-FleetDiscovery.ps1 produces). Servers are matched by ComputerName across the two folders; a server present in only one side is reported under OnlyInBaseline/OnlyInCurrent rather than silently dropped. Dot-sources tools\Merge-FleetDiscoveryResults.ps1 to reuse its run-folder enumeration (Get-FleetRunFolder/Get-FleetRunMetadata), its report plumbing (ConvertTo-FleetHtmlTable/ ConvertTo-FleetMarkdownTable), and its branding (Get-FleetBranding/Get-FleetLogoHtml) rather than re-implementing any of that a second time - safe because that file's own "only runs when executed directly" guard never fires for a dot-sourced call, exactly the same way the GUI already dot-sources it. CLIENT SAFETY: the client-facing summary (drift-client-summary.html/.md) only ever surfaces a dataset's drift if that dataset's Visibility (read from the CURRENT run's own collection-metadata.json, the same per-run recorded value Get-FleetClientSafeDataset already reads) is ClientSafe or Both, and even then only as a generic count ("3 new, 1 removed") - never the actual field-level Added/Removed/Changed detail, which is Internal-only. .PARAMETER BaselineRunFolder Single-server mode: the OLDER Discover-WindowsServer_<ComputerName>_<timestamp> run folder. .PARAMETER CurrentRunFolder Single-server mode: the NEWER run folder to compare against the baseline. .PARAMETER BaselineEngagementFolder Fleet mode: the OLDER engagement folder (containing one or more run folders). .PARAMETER CurrentEngagementFolder Fleet mode: the NEWER engagement folder to compare against the baseline. .PARAMETER OutputPath Where the drift report files land. Defaults to a drift\ subfolder of whichever "current" folder was passed. .EXAMPLE .\tools\Compare-DiscoveryRuns.ps1 -BaselineRunFolder 'C:\Out\Discover-WindowsServer_SRV1_20260901_090000' -CurrentRunFolder 'C:\Out\Discover-WindowsServer_SRV1_20260927_090000' .EXAMPLE .\tools\Compare-DiscoveryRuns.ps1 -BaselineEngagementFolder 'C:\Temp\FleetRuns\Fleet_20260901' -CurrentEngagementFolder 'C:\Temp\FleetRuns\Fleet_20260927' #> [CmdletBinding(DefaultParameterSetName = 'SingleServer')] param( [Parameter(ParameterSetName = 'SingleServer')][string]$BaselineRunFolder, [Parameter(ParameterSetName = 'SingleServer')][string]$CurrentRunFolder, [Parameter(ParameterSetName = 'Fleet')][string]$BaselineEngagementFolder, [Parameter(ParameterSetName = 'Fleet')][string]$CurrentEngagementFolder, [string]$OutputPath ) $ErrorActionPreference = 'Stop' # Reuses Get-FleetRunFolder/Get-FleetRunMetadata (run-folder enumeration), ConvertTo-FleetHtmlTable/ # ConvertTo-FleetMarkdownTable (table rendering) and Get-FleetBranding/Get-FleetLogoHtml (report # branding) rather than re-implementing any of them here - see this file's own header comment. . (Join-Path $PSScriptRoot 'Merge-FleetDiscoveryResults.ps1') #region Natural-key table ----------------------------------------------------- # Which field(s) uniquely identify a row within one dataset, so a row can be matched across two # runs (rather than the whole dataset being diffed as an opaque blob). Verified against real # evidence\data\json\*.json output - NOT guessed. Deliberately incomplete: a dataset not listed # here is reported as Unsupported (see Compare-DiscoveryDataset), never silently mis-keyed. $script:DriftKeyMap = @{ 'SmbShares' = @('Name') 'Services' = @('Name') 'LocalUsers' = @('Name') 'LocalGroups' = @('Name') 'LocalGroupMembers' = @('Group', 'Member') 'SharePermissions' = @('Share', 'Account') 'ScheduledTasks' = @('TaskName', 'TaskPath') 'InstalledApplications' = @('DisplayName', 'DisplayVersion') 'NtfsAclSummary' = @('Share') } # Fields that exist on a keyed row but churn on ordinary usage rather than representing real # drift - excluded from the field-by-field comparison so they don't bury genuine changes (or, for # NtfsAclSummary, generate a "changed" row on literally every re-scan of an active file share). $script:DriftIgnoredFields = @{ 'ScheduledTasks' = @('ActionsText', 'LastRunTime', 'NextRunTime', 'LastTaskResult', 'LastRunFailed') 'InstalledApplications' = @('InstallDate', 'EstimatedSizeMB') 'NtfsAclSummary' = @('FileCount', 'TotalSizeGB', 'LargeFilesOverThreshold', 'OldFilesOverYears', 'RecentlyModified30d', 'RecycleBinFilesFound', 'RecycleBinSizeGB') } # No stable row identity across two runs at all - a timestamp or a PID is never the same twice, # so "diffing" these would only ever report 100% churn, contradicting this toolkit's own # fail-soft, don't-overclaim design. Excluded outright rather than mis-keyed. $script:DriftExcludedDatasets = @('EventLogSamples', 'RunningProcesses') # Row-level noise, not field-level: confirmed live comparing two real fleet runs, every # ScheduledTasks diff reported exactly "1 added, 1 removed" for a task named # DiscoverWindowsServer_<8 hex chars> - that's this toolkit's OWN remote-run scheduled task # (Invoke-FleetDiscovery.ps1's $taskName), fresh-named per run and cleaned up after, so it's # always present-and-different between any two fleet-triggered scans. It's the scanner scanning # itself, not something a client cares about - filtered out before keying/comparing rather than # left to show up as drift every single time. $script:DriftRowExclusionFilters = @{ 'ScheduledTasks' = { param($row) $row.TaskName -match '^DiscoverWindowsServer_[0-9a-f]{8}$' } } #endregion #region Pure diff functions --------------------------------------------------- function Compare-DiscoveryDataset { <# Keyed diff of one dataset's rows between two runs. Pure - no file I/O. Returns a plain array via .ToArray() on an internal List, never ,@() - the exact pattern that silently collapsed the fleet client summary's tables earlier (see Merge-FleetDiscoveryResults.ps1's Get-FleetClientSafeDataset/Merge-FleetDataset fix) - every caller here assigns the result before any further piping, matching that fix. #> param( [Parameter(Mandatory)][string]$DatasetName, [AllowNull()][object[]]$BaselineRows, [AllowNull()][object[]]$CurrentRows ) if ($script:DriftExcludedDatasets -contains $DatasetName) { return [pscustomobject]@{ DatasetName = $DatasetName; Supported = $false; SkipReason = 'Excluded from drift - no stable row identity across runs (timestamps/PIDs are never the same twice).'; Added = @(); Removed = @(); Changed = @(); UnchangedCount = 0 } } if (-not $script:DriftKeyMap.ContainsKey($DatasetName)) { return [pscustomobject]@{ DatasetName = $DatasetName; Supported = $false; SkipReason = 'Unsupported - no known natural key for this dataset yet.'; Added = @(); Removed = @(); Changed = @(); UnchangedCount = 0 } } $keyFields = $script:DriftKeyMap[$DatasetName] $ignoreFields = @($script:DriftIgnoredFields[$DatasetName]) $baseline = @(if ($null -eq $BaselineRows) { @() } else { @($BaselineRows) }) $current = @(if ($null -eq $CurrentRows) { @() } else { @($CurrentRows) }) if ($script:DriftRowExclusionFilters.ContainsKey($DatasetName)) { $rowFilter = $script:DriftRowExclusionFilters[$DatasetName] $baseline = @($baseline | Where-Object { -not (& $rowFilter $_) }) $current = @($current | Where-Object { -not (& $rowFilter $_) }) } $getKey = { param($row) (($keyFields | ForEach-Object { [string]$row.$_ }) -join '|') } $baselineByKey = [ordered]@{} foreach ($row in $baseline) { $baselineByKey[(& $getKey $row)] = $row } $currentByKey = [ordered]@{} foreach ($row in $current) { $currentByKey[(& $getKey $row)] = $row } $added = [System.Collections.Generic.List[object]]::new() $removed = [System.Collections.Generic.List[object]]::new() $changed = [System.Collections.Generic.List[object]]::new() $unchangedCount = 0 foreach ($key in $currentByKey.Keys) { if (-not $baselineByKey.Contains($key)) { $added.Add($currentByKey[$key]); continue } $baseRow = $baselineByKey[$key] $curRow = $currentByKey[$key] $fieldNames = @(@($baseRow.PSObject.Properties.Name) + @($curRow.PSObject.Properties.Name) | Sort-Object -Unique | Where-Object { $ignoreFields -notcontains $_ }) $fieldChanges = [System.Collections.Generic.List[object]]::new() foreach ($f in $fieldNames) { $bVal = [string]$baseRow.$f $cVal = [string]$curRow.$f if ($bVal -ne $cVal) { $fieldChanges.Add([pscustomobject]@{ Field = $f; Baseline = $bVal; Current = $cVal }) } } if ($fieldChanges.Count -gt 0) { $changed.Add([pscustomobject]@{ Key = $key; Fields = $fieldChanges.ToArray() }) } else { $unchangedCount++ } } foreach ($key in $baselineByKey.Keys) { if (-not $currentByKey.Contains($key)) { $removed.Add($baselineByKey[$key]) } } return [pscustomobject]@{ DatasetName = $DatasetName; Supported = $true; SkipReason = '' Added = $added.ToArray(); Removed = $removed.ToArray(); Changed = $changed.ToArray(); UnchangedCount = $unchangedCount } } function Get-DriftDatasetRows { <# Reads evidence\data\json\<name>.json from one run folder. Empty array (not $null, never throws) when missing/unreadable - a dataset a module didn't produce on one side is not a fatal error, mirrors Merge-FleetDataset's own per-run tolerance. #> param([Parameter(Mandatory)][string]$RunFolder, [Parameter(Mandatory)][string]$DatasetName) $path = Join-Path $RunFolder ("evidence\data\json\{0}.json" -f $DatasetName) if (-not (Test-Path -LiteralPath $path)) { return @() } try { return @(Get-Content -LiteralPath $path -Raw | ConvertFrom-Json) } catch { return @() } } function Compare-DiscoveryRunPair { <# Diffs every dataset the UNION of both runs' collection-metadata.json recorded, for one server. A dataset that existed in the baseline but vanished from the current run still gets compared (and shows up entirely under Removed), never silently skipped. #> param([Parameter(Mandatory)][string]$BaselineRunFolder, [Parameter(Mandatory)][string]$CurrentRunFolder) $baselineMeta = Get-FleetRunMetadata -RunFolder $BaselineRunFolder $currentMeta = Get-FleetRunMetadata -RunFolder $CurrentRunFolder $computerName = if ($currentMeta) { $currentMeta.ComputerName } elseif ($baselineMeta) { $baselineMeta.ComputerName } else { Split-Path $CurrentRunFolder -Leaf } $baselineDatasetNames = @(if ($baselineMeta -and $baselineMeta.Datasets) { @($baselineMeta.Datasets) | ForEach-Object { $_.Name } } else { @() }) $currentDatasetNames = @(if ($currentMeta -and $currentMeta.Datasets) { @($currentMeta.Datasets) | ForEach-Object { $_.Name } } else { @() }) $allDatasetNames = @(@($baselineDatasetNames) + @($currentDatasetNames) | Sort-Object -Unique) # The CURRENT run's own recorded Visibility wins when both sides recorded the same dataset - # this is "what would the client report show TODAY," which is what a client-facing drift # summary should reflect. Falls back to the baseline's value for a dataset that disappeared # entirely (nothing else could tell us what it would have been). $visibilityByName = @{} if ($baselineMeta -and $baselineMeta.Datasets) { foreach ($d in @($baselineMeta.Datasets)) { $visibilityByName[$d.Name] = $d.Visibility } } if ($currentMeta -and $currentMeta.Datasets) { foreach ($d in @($currentMeta.Datasets)) { $visibilityByName[$d.Name] = $d.Visibility } } $datasetResults = [System.Collections.Generic.List[object]]::new() foreach ($name in $allDatasetNames) { $baselineRows = Get-DriftDatasetRows -RunFolder $BaselineRunFolder -DatasetName $name $currentRows = Get-DriftDatasetRows -RunFolder $CurrentRunFolder -DatasetName $name $datasetResults.Add((Compare-DiscoveryDataset -DatasetName $name -BaselineRows $baselineRows -CurrentRows $currentRows)) } return [pscustomobject]@{ ComputerName = $computerName BaselineRunId = if ($baselineMeta) { $baselineMeta.RunId } else { $null } CurrentRunId = if ($currentMeta) { $currentMeta.RunId } else { $null } BaselineTimestamp = if ($baselineMeta) { $baselineMeta.StartTime } else { $null } CurrentTimestamp = if ($currentMeta) { $currentMeta.StartTime } else { $null } DatasetResults = $datasetResults.ToArray() DatasetVisibility = $visibilityByName } } function Compare-DiscoveryFleetRuns { <# Matches servers by ComputerName across two engagement folders and diffs each match. A server present on only one side is reported, never dropped. #> param([Parameter(Mandatory)][string]$BaselineEngagementFolder, [Parameter(Mandatory)][string]$CurrentEngagementFolder) # Get-FleetRunFolder returns ,@(...) - bare-assign only, never re-wrap in @() (see that # function's own Pester coverage for why re-wrapping collapses it). $baselineRunFolders = Get-FleetRunFolder -EngagementFolder $BaselineEngagementFolder $currentRunFolders = Get-FleetRunFolder -EngagementFolder $CurrentEngagementFolder # If the same ComputerName appears more than once within one engagement folder, keep the # lexicographically-latest folder name - the timestamp suffix in # Discover-WindowsServer_<ComputerName>_<timestamp> sorts correctly as a plain string. $baselineByComputer = @{} foreach ($f in $baselineRunFolders) { $meta = Get-FleetRunMetadata -RunFolder $f.FullName $name = if ($meta) { $meta.ComputerName } else { $f.Name } if (-not $baselineByComputer.ContainsKey($name) -or $f.Name -gt $baselineByComputer[$name].Name) { $baselineByComputer[$name] = $f } } $currentByComputer = @{} foreach ($f in $currentRunFolders) { $meta = Get-FleetRunMetadata -RunFolder $f.FullName $name = if ($meta) { $meta.ComputerName } else { $f.Name } if (-not $currentByComputer.ContainsKey($name) -or $f.Name -gt $currentByComputer[$name].Name) { $currentByComputer[$name] = $f } } $serverResults = [System.Collections.Generic.List[object]]::new() $onlyInBaseline = [System.Collections.Generic.List[string]]::new() $onlyInCurrent = [System.Collections.Generic.List[string]]::new() foreach ($name in $currentByComputer.Keys) { if ($baselineByComputer.ContainsKey($name)) { $serverResults.Add((Compare-DiscoveryRunPair -BaselineRunFolder $baselineByComputer[$name].FullName -CurrentRunFolder $currentByComputer[$name].FullName)) } else { $onlyInCurrent.Add($name) } } foreach ($name in $baselineByComputer.Keys) { if (-not $currentByComputer.ContainsKey($name)) { $onlyInBaseline.Add($name) } } return [pscustomobject]@{ ServerResults = $serverResults.ToArray() OnlyInBaseline = $onlyInBaseline.ToArray() OnlyInCurrent = $onlyInCurrent.ToArray() } } function Get-ClientSafeDriftSummary { <# Reduces dataset-level drift to a generic count-only label ("3 new, 1 removed"), and only for datasets the CURRENT run itself recorded as ClientSafe or Both - the exact same per-run Visibility field Get-FleetClientSafeDataset already reads, never a second, hardcoded list. Never returns row-level Field/Baseline/Current detail - that's Internal information about the server's own configuration, not something to hand to a client, matching Get-RbClientHeadlines/Get-FleetClientHeadlines's existing "generic label only" contract for anything client-facing. #> param([Parameter(Mandatory)][object[]]$DatasetResults, [Parameter(Mandatory)][hashtable]$DatasetVisibility) $lines = [System.Collections.Generic.List[object]]::new() foreach ($r in @($DatasetResults)) { if (-not $r.Supported) { continue } if ($DatasetVisibility[$r.DatasetName] -notin @('ClientSafe', 'Both')) { continue } $addedCount = @($r.Added).Count $removedCount = @($r.Removed).Count $changedCount = @($r.Changed).Count if ($addedCount -eq 0 -and $removedCount -eq 0 -and $changedCount -eq 0) { continue } $parts = [System.Collections.Generic.List[string]]::new() if ($addedCount -gt 0) { [void]$parts.Add("$addedCount new") } if ($removedCount -gt 0) { [void]$parts.Add("$removedCount removed") } if ($changedCount -gt 0) { [void]$parts.Add("$changedCount changed") } $lines.Add([pscustomobject]@{ DatasetName = $r.DatasetName; Label = ($parts -join ', ') }) } return $lines.ToArray() } #endregion #region Report model ----------------------------------------------------------- function Get-DriftReportModel { <# Normalizes either a single Compare-DiscoveryRunPair result or a whole Compare-DiscoveryFleetRuns result into ONE shape (ServerCount=1 for the single-server case) - the same "one model, multiple renderers" pattern every report in this repo already follows (Get-FleetRollupModel, Get-RbClientModel, ...), so the HTML and Markdown writers below can never drift apart from each other. #> param([Parameter(Mandatory)][object[]]$ServerResults, [string[]]$OnlyInBaseline = @(), [string[]]$OnlyInCurrent = @()) $servers = [System.Collections.Generic.List[object]]::new() $totalAdded = 0; $totalRemoved = 0; $totalChanged = 0 foreach ($s in @($ServerResults)) { $added = 0; $removed = 0; $changed = 0 foreach ($dr in @($s.DatasetResults)) { if (-not $dr.Supported) { continue } $added += @($dr.Added).Count $removed += @($dr.Removed).Count $changed += @($dr.Changed).Count } $clientSafe = Get-ClientSafeDriftSummary -DatasetResults $s.DatasetResults -DatasetVisibility $s.DatasetVisibility $servers.Add([pscustomobject]@{ ComputerName = $s.ComputerName BaselineRunId = $s.BaselineRunId CurrentRunId = $s.CurrentRunId BaselineTimestamp = $s.BaselineTimestamp CurrentTimestamp = $s.CurrentTimestamp DatasetResults = $s.DatasetResults ClientSafeSummary = $clientSafe TotalAdded = $added; TotalRemoved = $removed; TotalChanged = $changed }) $totalAdded += $added; $totalRemoved += $removed; $totalChanged += $changed } return [pscustomobject]@{ Generated = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') ServerCount = $servers.Count Servers = $servers.ToArray() OnlyInBaseline = @($OnlyInBaseline) OnlyInCurrent = @($OnlyInCurrent) TotalAdded = $totalAdded; TotalRemoved = $totalRemoved; TotalChanged = $totalChanged } } #endregion #region Reports ----------------------------------------------------------------- function Get-DriftReportCss { <# Same visual language as Get-FleetReportCss - kept local rather than shared, matching every other report format in this repo owning its own CSS. #> param([string]$AccentColorHex = '#1F4E79') if ([string]::IsNullOrWhiteSpace($AccentColorHex)) { $AccentColorHex = '#1F4E79' } $css = @' :root{--accent:__ACCENT__;--bg:#ffffff;--surface:#F7F8FA;--fg:#1A1F27;--muted:#5B6472;--line:#E1E5EA;--add:#1A7F37;--add-bg:#E9F7EE;--rem:#B42318;--rem-bg:#FBEAE9;--chg:#B54708;--chg-bg:#FDF1E7} *{box-sizing:border-box}body{font-family:Segoe UI,Calibri,Arial,sans-serif;color:var(--fg);background:var(--bg);margin:0;line-height:1.55;font-size:14px} header{background:var(--accent);color:#fff;padding:28px 36px}header .brand-logo{max-height:40px;vertical-align:middle;margin-right:12px}header h1{margin:0 0 5px;font-size:22px;font-weight:600}header .sub{opacity:.9;font-size:13px} main{max-width:1200px;margin:0 auto;padding:8px 36px 36px} h2{color:var(--accent);border-bottom:2px solid var(--line);padding-bottom:7px;margin-top:38px;font-size:19px} h3{color:#333;margin-top:20px;font-size:15px} table{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px} th,td{border:1px solid var(--line);padding:7px 9px;text-align:left;vertical-align:top} th{background:var(--surface);font-weight:600} .kpis{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0} .kpi{flex:1;min-width:130px;border-radius:8px;padding:14px;text-align:center;background:var(--surface)} .kpi .n{font-size:26px;font-weight:700;color:var(--accent)}.kpi .l{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-top:2px} .muted{color:var(--muted);font-size:12px} .tag{display:inline-block;padding:2px 9px;border-radius:100px;font-size:11px;font-weight:600} .tag-add{background:var(--add-bg);color:var(--add)}.tag-rem{background:var(--rem-bg);color:var(--rem)}.tag-chg{background:var(--chg-bg);color:var(--chg)} .skip{color:var(--muted);font-style:italic;font-size:12.5px;margin:4px 0} @media print{header{background:#fff;color:var(--accent);border-bottom:3px solid var(--accent)}main{padding:0 8px}} '@ return ($css -replace '__ACCENT__', $AccentColorHex) } function Write-DriftHtmlReport { <# Full internal detail: every supported dataset's Added/Removed/Changed rows, per server. #> param([Parameter(Mandatory)][object]$Model, [Parameter(Mandatory)][string]$Path) $esc = { param($s) [System.Net.WebUtility]::HtmlEncode([string]$s) } $b = Get-FleetBranding $html = [System.Text.StringBuilder]::new() [void]$html.AppendLine('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">') [void]$html.AppendLine('<title>Discovery Drift Report</title>') [void]$html.AppendLine(('<style>{0}</style></head><body>' -f (Get-DriftReportCss -AccentColorHex $b.Accent))) [void]$html.AppendLine("<header>$(Get-FleetLogoHtml -Branding $b)<h1>Discovery Drift Report</h1>") [void]$html.AppendLine("<div class=`"sub`">$(& $esc $b.Brand) · Generated $(& $esc $Model.Generated)</div></header><main>") [void]$html.AppendLine('<div class="kpis">') [void]$html.AppendLine(('<div class="kpi"><div class="n">{0}</div><div class="l">Servers compared</div></div>' -f $Model.ServerCount)) [void]$html.AppendLine(('<div class="kpi"><div class="n">{0}</div><div class="l">Rows added</div></div>' -f $Model.TotalAdded)) [void]$html.AppendLine(('<div class="kpi"><div class="n">{0}</div><div class="l">Rows removed</div></div>' -f $Model.TotalRemoved)) [void]$html.AppendLine(('<div class="kpi"><div class="n">{0}</div><div class="l">Rows changed</div></div>' -f $Model.TotalChanged)) [void]$html.AppendLine('</div>') if (@($Model.OnlyInBaseline).Count -gt 0) { [void]$html.AppendLine(('<p><span class="tag tag-rem">Baseline only</span> no longer in the current run: {0}</p>' -f (& $esc (@($Model.OnlyInBaseline) -join ', ')))) } if (@($Model.OnlyInCurrent).Count -gt 0) { [void]$html.AppendLine(('<p><span class="tag tag-add">Current only</span> new since the baseline: {0}</p>' -f (& $esc (@($Model.OnlyInCurrent) -join ', ')))) } foreach ($s in $Model.Servers) { [void]$html.AppendLine(('<h2>{0}</h2>' -f (& $esc $s.ComputerName))) [void]$html.AppendLine(('<p class="muted">Baseline {0} ({1}) · Current {2} ({3}) · {4} added, {5} removed, {6} changed</p>' -f (& $esc $s.BaselineRunId), (& $esc $s.BaselineTimestamp), (& $esc $s.CurrentRunId), (& $esc $s.CurrentTimestamp), $s.TotalAdded, $s.TotalRemoved, $s.TotalChanged)) foreach ($dr in $s.DatasetResults) { if (-not $dr.Supported) { continue } $dsAdded = @($dr.Added).Count; $dsRemoved = @($dr.Removed).Count; $dsChanged = @($dr.Changed).Count if ($dsAdded -eq 0 -and $dsRemoved -eq 0 -and $dsChanged -eq 0) { continue } [void]$html.AppendLine(('<h3>{0} <span class="tag tag-add">+{1}</span> <span class="tag tag-rem">-{2}</span> <span class="tag tag-chg">~{3}</span></h3>' -f (& $esc $dr.DatasetName), $dsAdded, $dsRemoved, $dsChanged)) if ($dsAdded -gt 0) { [void]$html.AppendLine('<p class="muted">Added</p>'); [void]$html.AppendLine((ConvertTo-FleetHtmlTable -Rows $dr.Added)) } if ($dsRemoved -gt 0) { [void]$html.AppendLine('<p class="muted">Removed</p>'); [void]$html.AppendLine((ConvertTo-FleetHtmlTable -Rows $dr.Removed)) } if ($dsChanged -gt 0) { [void]$html.AppendLine('<p class="muted">Changed</p>') $changeRows = @(foreach ($c in $dr.Changed) { foreach ($fld in $c.Fields) { [pscustomobject]@{ Key = $c.Key; Field = $fld.Field; Baseline = $fld.Baseline; Current = $fld.Current } } }) [void]$html.AppendLine((ConvertTo-FleetHtmlTable -Rows $changeRows)) } } $skipped = @($s.DatasetResults | Where-Object { -not $_.Supported }) if ($skipped.Count -gt 0) { [void]$html.AppendLine('<p class="skip">Not compared (no known key or explicitly excluded): ') foreach ($sk in $skipped) { [void]$html.AppendLine(("{0} ({1}) " -f (& $esc $sk.DatasetName), (& $esc $sk.SkipReason))) } [void]$html.AppendLine('</p>') } } [void]$html.AppendLine('</main></body></html>') Set-Content -LiteralPath $Path -Value $html.ToString() -Encoding UTF8 } function Write-DriftMarkdownReport { param([Parameter(Mandatory)][object]$Model, [Parameter(Mandatory)][string]$Path) $b = Get-FleetBranding $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Discovery Drift Report') [void]$sb.AppendLine('') [void]$sb.AppendLine(('**{0}** · Generated {1}' -f $b.Brand, $Model.Generated)) [void]$sb.AppendLine('') [void]$sb.AppendLine(('- Servers compared: {0}' -f $Model.ServerCount)) [void]$sb.AppendLine(('- Rows added: {0} · removed: {1} · changed: {2}' -f $Model.TotalAdded, $Model.TotalRemoved, $Model.TotalChanged)) if (@($Model.OnlyInBaseline).Count -gt 0) { [void]$sb.AppendLine(('- Baseline only (no longer present): {0}' -f (@($Model.OnlyInBaseline) -join ', '))) } if (@($Model.OnlyInCurrent).Count -gt 0) { [void]$sb.AppendLine(('- Current only (new since baseline): {0}' -f (@($Model.OnlyInCurrent) -join ', '))) } [void]$sb.AppendLine('') foreach ($s in $Model.Servers) { [void]$sb.AppendLine(('## {0}' -f $s.ComputerName)) [void]$sb.AppendLine('') [void]$sb.AppendLine(('Baseline {0} ({1}) · Current {2} ({3}) · {4} added, {5} removed, {6} changed' -f $s.BaselineRunId, $s.BaselineTimestamp, $s.CurrentRunId, $s.CurrentTimestamp, $s.TotalAdded, $s.TotalRemoved, $s.TotalChanged)) [void]$sb.AppendLine('') foreach ($dr in $s.DatasetResults) { if (-not $dr.Supported) { continue } $dsAdded = @($dr.Added).Count; $dsRemoved = @($dr.Removed).Count; $dsChanged = @($dr.Changed).Count if ($dsAdded -eq 0 -and $dsRemoved -eq 0 -and $dsChanged -eq 0) { continue } [void]$sb.AppendLine(('### {0} (+{1} -{2} ~{3})' -f $dr.DatasetName, $dsAdded, $dsRemoved, $dsChanged)) [void]$sb.AppendLine('') if ($dsAdded -gt 0) { [void]$sb.AppendLine('**Added**'); [void]$sb.AppendLine(''); [void]$sb.AppendLine((ConvertTo-FleetMarkdownTable -Rows $dr.Added)); [void]$sb.AppendLine('') } if ($dsRemoved -gt 0) { [void]$sb.AppendLine('**Removed**'); [void]$sb.AppendLine(''); [void]$sb.AppendLine((ConvertTo-FleetMarkdownTable -Rows $dr.Removed)); [void]$sb.AppendLine('') } if ($dsChanged -gt 0) { [void]$sb.AppendLine('**Changed**') [void]$sb.AppendLine('') $changeRows = @(foreach ($c in $dr.Changed) { foreach ($fld in $c.Fields) { [pscustomobject]@{ Key = $c.Key; Field = $fld.Field; Baseline = $fld.Baseline; Current = $fld.Current } } }) [void]$sb.AppendLine((ConvertTo-FleetMarkdownTable -Rows $changeRows)) [void]$sb.AppendLine('') } } } Set-Content -LiteralPath $Path -Value $sb.ToString() -Encoding UTF8 } function Write-DriftClientHtmlReport { <# Client-safe: generic counts only, only for ClientSafe/Both datasets - see Get-ClientSafeDriftSummary. #> param([Parameter(Mandatory)][object]$Model, [Parameter(Mandatory)][string]$Path) $esc = { param($s) [System.Net.WebUtility]::HtmlEncode([string]$s) } $b = Get-FleetBranding $html = [System.Text.StringBuilder]::new() [void]$html.AppendLine('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">') [void]$html.AppendLine('<title>Discovery Changes Summary</title>') [void]$html.AppendLine(('<style>{0}</style></head><body>' -f (Get-DriftReportCss -AccentColorHex $b.Accent))) [void]$html.AppendLine("<header>$(Get-FleetLogoHtml -Branding $b)<h1>Discovery Changes Summary</h1>") [void]$html.AppendLine("<div class=`"sub`">$(& $esc $b.Brand) · {0} server(s) compared · $(& $esc $Model.Generated)</div></header><main>" -f $Model.ServerCount) [void]$html.AppendLine('<p class="lead">A read-only comparison against the last time we reviewed these servers. Nothing was changed by this comparison itself - this only reports what each server now reports about itself versus last time.</p>') $anyRows = $false foreach ($s in $Model.Servers) { if (@($s.ClientSafeSummary).Count -eq 0) { continue } $anyRows = $true [void]$html.AppendLine(('<h2>{0}</h2><table><tr><th>Area</th><th>What changed</th></tr>' -f (& $esc $s.ComputerName))) foreach ($row in $s.ClientSafeSummary) { [void]$html.AppendLine(('<tr><td>{0}</td><td>{1}</td></tr>' -f (& $esc $row.DatasetName), (& $esc $row.Label))) } [void]$html.AppendLine('</table>') } if (-not $anyRows) { [void]$html.AppendLine('<p>No client-visible changes were detected between these two reviews.</p>') } [void]$html.AppendLine('</main></body></html>') Set-Content -LiteralPath $Path -Value $html.ToString() -Encoding UTF8 } function Write-DriftClientMarkdownReport { param([Parameter(Mandatory)][object]$Model, [Parameter(Mandatory)][string]$Path) $b = Get-FleetBranding $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Discovery Changes Summary') [void]$sb.AppendLine('') [void]$sb.AppendLine(('**{0}** · {1} server(s) compared · {2}' -f $b.Brand, $Model.ServerCount, $Model.Generated)) [void]$sb.AppendLine('') $anyRows = $false foreach ($s in $Model.Servers) { if (@($s.ClientSafeSummary).Count -eq 0) { continue } $anyRows = $true [void]$sb.AppendLine(('## {0}' -f $s.ComputerName)) [void]$sb.AppendLine('') foreach ($row in $s.ClientSafeSummary) { [void]$sb.AppendLine(('- **{0}:** {1}' -f $row.DatasetName, $row.Label)) } [void]$sb.AppendLine('') } if (-not $anyRows) { [void]$sb.AppendLine('_No client-visible changes were detected between these two reviews._') } Set-Content -LiteralPath $Path -Value $sb.ToString() -Encoding UTF8 } function New-DriftReport { <# Single public entry point - mirrors New-FleetRollupReport's role. Builds the model once, writes all five output files from it. #> [CmdletBinding(DefaultParameterSetName = 'SingleServer')] param( [Parameter(Mandatory, ParameterSetName = 'SingleServer')][string]$BaselineRunFolder, [Parameter(Mandatory, ParameterSetName = 'SingleServer')][string]$CurrentRunFolder, [Parameter(Mandatory, ParameterSetName = 'Fleet')][string]$BaselineEngagementFolder, [Parameter(Mandatory, ParameterSetName = 'Fleet')][string]$CurrentEngagementFolder, [string]$OutputPath ) if ($PSCmdlet.ParameterSetName -eq 'SingleServer') { $pairResult = Compare-DiscoveryRunPair -BaselineRunFolder $BaselineRunFolder -CurrentRunFolder $CurrentRunFolder $model = Get-DriftReportModel -ServerResults @($pairResult) if (-not $OutputPath) { $OutputPath = Join-Path $CurrentRunFolder 'drift' } } else { $fleetResult = Compare-DiscoveryFleetRuns -BaselineEngagementFolder $BaselineEngagementFolder -CurrentEngagementFolder $CurrentEngagementFolder $model = Get-DriftReportModel -ServerResults $fleetResult.ServerResults -OnlyInBaseline $fleetResult.OnlyInBaseline -OnlyInCurrent $fleetResult.OnlyInCurrent if (-not $OutputPath) { $OutputPath = Join-Path $CurrentEngagementFolder 'drift' } } if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $jsonPath = Join-Path $OutputPath 'drift-report.json' $htmlPath = Join-Path $OutputPath 'drift-report.html' $mdPath = Join-Path $OutputPath 'drift-report.md' $clientHtmlPath = Join-Path $OutputPath 'drift-client-summary.html' $clientMdPath = Join-Path $OutputPath 'drift-client-summary.md' ($model | ConvertTo-Json -Depth 12) | Set-Content -LiteralPath $jsonPath -Encoding UTF8 Write-DriftHtmlReport -Model $model -Path $htmlPath Write-DriftMarkdownReport -Model $model -Path $mdPath Write-DriftClientHtmlReport -Model $model -Path $clientHtmlPath Write-DriftClientMarkdownReport -Model $model -Path $clientMdPath return [pscustomobject]@{ JsonPath = $jsonPath; HtmlPath = $htmlPath; MarkdownPath = $mdPath ClientHtmlPath = $clientHtmlPath; ClientMarkdownPath = $clientMdPath ServerCount = $model.ServerCount; TotalAdded = $model.TotalAdded; TotalRemoved = $model.TotalRemoved; TotalChanged = $model.TotalChanged } } #endregion # Only runs when executed directly - dot-source for the functions above (Pester, or the GUI # calling New-DriftReport in-process), same convention as every other file in tools\. if ($MyInvocation.InvocationName -ne '.') { if ($BaselineRunFolder -and $CurrentRunFolder) { $report = New-DriftReport -BaselineRunFolder $BaselineRunFolder -CurrentRunFolder $CurrentRunFolder -OutputPath $OutputPath } elseif ($BaselineEngagementFolder -and $CurrentEngagementFolder) { $report = New-DriftReport -BaselineEngagementFolder $BaselineEngagementFolder -CurrentEngagementFolder $CurrentEngagementFolder -OutputPath $OutputPath } else { throw 'Provide either -BaselineRunFolder/-CurrentRunFolder or -BaselineEngagementFolder/-CurrentEngagementFolder.' } Write-Host ("Drift report built: {0} server(s), +{1} -{2} ~{3} -> {4}" -f $report.ServerCount, $report.TotalAdded, $report.TotalRemoved, $report.TotalChanged, $report.HtmlPath) -ForegroundColor Green } |