Discover-WindowsServer.psm1
|
<# Discover-WindowsServer.psm1 Orchestration engine for the Ultimate Modular Windows Server Discovery Toolkit. Responsibilities: - Discover, preflight, select, and run collector + synthesis modules. - Isolate each module (import -> run contract -> remove) so the ~30 modules can all use the same generic contract function names without collisions. - Guarantee every required output artifact is produced even if collectors return no rows or fail. The thin entry script Discover-WindowsServer.ps1 defines parameters, prepares the output folder / context, and calls Invoke-Discovery. #> # --- Module discovery / isolation ------------------------------------------- function Import-DiscoveryModuleFile { <# Imports a module by folder name; returns its module name or $null on failure. #> param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$ModulesRoot, [object]$Context) $psm1 = Join-Path (Join-Path $ModulesRoot $Name) ("{0}.psm1" -f $Name) if (-not (Test-Path -LiteralPath $psm1)) { if ($Context) { Add-Limitation -Context $Context -Module $Name -Message ("Module file not found: {0}" -f $psm1) -Impact 'Module skipped' | Out-Null } return $null } try { Import-Module -Name $psm1 -Force -DisableNameChecking -Global -ErrorAction Stop return $Name } catch { if ($Context) { Write-Log -Level ERROR -Message ("Failed to import module '{0}'." -f $Name) -Module $Name -Exception $_ -Context $Context } return $null } } function Remove-DiscoveryModuleFile { param([Parameter(Mandatory)][string]$Name) try { if (Get-Module -Name $Name -ErrorAction SilentlyContinue) { Remove-Module -Name $Name -Force -ErrorAction SilentlyContinue } } catch { } } function Start-DiscoverWindowsServerGui { <# Launches the optional WPF GUI launcher (Discover-WindowsServer-GUI.ps1) from wherever this module is actually installed - a git clone or an Install-Module'd copy under $env:PSModulePath. $PSScriptRoot inside a function defined in this .psm1 always resolves to the directory containing THIS file, the same pattern Invoke-Discovery already relies on (Join-Path $PSScriptRoot 'modules'), so no new resolution mechanism is needed. #> [CmdletBinding()] param([string[]]$ArgumentList = @()) $guiPath = Join-Path $PSScriptRoot 'Discover-WindowsServer-GUI.ps1' if (-not (Test-Path -LiteralPath $guiPath)) { throw "GUI launcher not found at $guiPath. If this module was installed via Install-Module, reinstall it - the GUI script did not ship with this copy." } & $guiPath @ArgumentList } function Invoke-DiscoverWindowsServer { <# .SYNOPSIS Runs a discovery scan - the installed-module equivalent of .\Discover-WindowsServer.ps1. .DESCRIPTION Takes exactly the same parameters as Discover-WindowsServer.ps1 (mirrored at runtime from that script, so they never drift) and passes them straight through. Full parameter help: Get-Help (Join-Path (Get-Module Discover-WindowsServer).ModuleBase 'Discover-WindowsServer.ps1') -Full .EXAMPLE Invoke-DiscoverWindowsServer -Mode Deep -ProjectType Decommission -OutputRoot D:\Scans #> [CmdletBinding()] param() dynamicparam { $entry = Get-Command -Name (Join-Path $PSScriptRoot 'Discover-WindowsServer.ps1') -ErrorAction SilentlyContinue $dict = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() if (-not $entry) { return $dict } $common = [System.Management.Automation.Cmdlet]::CommonParameters + [System.Management.Automation.Cmdlet]::OptionalCommonParameters foreach ($p in $entry.Parameters.Values) { if ($common -contains $p.Name) { continue } $dict.Add($p.Name, [System.Management.Automation.RuntimeDefinedParameter]::new($p.Name, $p.ParameterType, $p.Attributes)) } return $dict } end { $entryPath = Join-Path $PSScriptRoot 'Discover-WindowsServer.ps1' if (-not (Test-Path -LiteralPath $entryPath)) { throw "Entry script not found at $entryPath. If this module was installed via Install-Module, reinstall it." } & $entryPath @PSBoundParameters } } function Test-ModuleCommandExists { # Uses the loaded module's ExportedCommands map so probing for optional contract # functions does not pollute $Error with CommandNotFound records. param([string]$ModuleName, [string]$Command) # Filter per module and count: a collector can share its name with an OS module that # autoloads mid-run (Storage, ActiveDirectory). [bool] of the two-element array that # member enumeration returns is always $true, which made absent hooks "exist". try { return (@(Get-Module -Name $ModuleName -ErrorAction SilentlyContinue | Where-Object { $_.ExportedCommands.ContainsKey($Command) }).Count -gt 0) } catch { return $false } } # --- Preflight metadata pass ------------------------------------------------- function Get-AllModuleMetadata { <# Imports each module briefly to collect its metadata for the plan/selection. #> param([object]$Context, [string]$ModulesRoot, [string[]]$ModuleNames) $meta = [System.Collections.Generic.List[object]]::new() foreach ($name in $ModuleNames) { $imported = Import-DiscoveryModuleFile -Name $name -ModulesRoot $ModulesRoot -Context $Context if (-not $imported) { continue } try { if (Test-ModuleCommandExists -ModuleName $name -Command 'Get-DiscoveryModuleMetadata') { $m = Get-DiscoveryModuleMetadata if ($m) { $meta.Add($m); $Context.ModuleMetadata.Add($m) } } else { Add-Limitation -Context $Context -Module $name -Message 'Module does not expose Get-DiscoveryModuleMetadata.' -Impact 'Metadata unavailable' | Out-Null } } catch { Write-Log -Level WARN -Message ("Metadata read failed for '{0}'." -f $name) -Module $name -Exception $_ -Context $Context } finally { Remove-DiscoveryModuleFile -Name $name } } return $meta } # --- Selection --------------------------------------------------------------- function Resolve-ModuleSelection { <# Determines which collector modules run based on mode, metadata, and Include/Exclude. #> param( [object]$Context, [object[]]$Metadata, [string]$Mode, [string[]]$CollectorOrder, [string[]]$Include, [string[]]$Exclude, [object]$ModeConfig ) $selected = [System.Collections.Generic.List[string]]::new() $metaByName = @{} foreach ($m in $Metadata) { $metaByName[$m.ModuleName] = $m } foreach ($name in $CollectorOrder) { $m = $metaByName[$name] $default = $false if ($m) { if ($Mode -eq 'Deep') { $default = [bool]$m.DefaultInDeep } elseif ($Mode -eq 'Fast') { $default = [bool]$m.DefaultInFast } else { $default = $false } # Custom: nothing by default } if ($default) { [void]$selected.Add($name) } } # Mode config force enable/disable. if ($ModeConfig) { foreach ($fe in @($ModeConfig.forceEnableModules)) { if ($fe -and ($CollectorOrder -contains $fe) -and ($selected -notcontains $fe)) { [void]$selected.Add($fe) } } foreach ($fd in @($ModeConfig.forceDisableModules)) { if ($fd) { [void]$selected.Remove($fd) } } } # Explicit includes (add even if not default; valid for Fast + Custom). foreach ($inc in @($Include)) { if (-not $inc) { continue } if ($CollectorOrder -contains $inc) { if ($selected -notcontains $inc) { [void]$selected.Add($inc) } } else { Add-Limitation -Context $Context -Module $inc -Message 'IncludeModules referenced an unknown collector module.' -Impact 'Ignored' | Out-Null } } # Explicit excludes. foreach ($exc in @($Exclude)) { if ($exc) { [void]$selected.Remove($exc) } } # Preserve canonical order. $ordered = @($CollectorOrder | Where-Object { $selected -contains $_ }) $Context.IncludedModules = $ordered $Context.ExcludedModules = @($CollectorOrder | Where-Object { $ordered -notcontains $_ }) return $ordered } # --- Collector execution ----------------------------------------------------- function Invoke-CollectionWithDeadline { <# Runs a collector's Invoke-DiscoveryCollection with a deadline. Collectors run on the engine's own thread, so a hung cmdlet (a remote RPC that never answers, a wedged provider) used to hang the entire run with no way out. The collection step runs in a child runspace of the same process instead: $Context is shared by reference, so datasets, limitations and log lines the collector adds are visible to the engine. On timeout the child is abandoned (Stop is asked for but not waited on, because a call blocked in native code may never honour it), the module is reported as a limitation, and the run carries on. Returns the collector's raw result, or $null on timeout. TimeoutSeconds <= 0 runs inline as before. #> param([object]$Context, [string]$Name, [string]$ModulesRoot, [int]$TimeoutSeconds) if ($TimeoutSeconds -le 0) { return (Invoke-DiscoveryCollection -Context $Context) } $rs = [runspacefactory]::CreateRunspace($Host); $rs.Open() $ps = [powershell]::Create(); $ps.Runspace = $rs [void]$ps.AddScript({ param($root, $name, $ctx) $ErrorActionPreference = 'Continue' foreach ($m in 'Core', 'Output') { Import-Module (Join-Path $root "$m\$m.psm1") -Force -DisableNameChecking -Global -ErrorAction Stop } Import-Module (Join-Path $root "$name\$name.psm1") -Force -DisableNameChecking -Global -ErrorAction Stop Invoke-DiscoveryCollection -Context $ctx }).AddArgument($ModulesRoot).AddArgument($Name).AddArgument($Context) $h = $ps.BeginInvoke() if (-not $h.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) { try { [void]$ps.BeginStop($null, $null) } catch { } Add-Limitation -Context $Context -Module $Name -Message ("Collection did not finish within {0} seconds and was abandoned; this module's data is missing or partial." -f $TimeoutSeconds) -Impact 'Module timed out' | Out-Null Write-Log -Level WARN -Message ("Collection exceeded the {0}s deadline and was abandoned." -f $TimeoutSeconds) -Module $Name -Context $Context return $null } try { $out = $ps.EndInvoke($h) # rethrows a terminating error from the collector to the caller's catch foreach ($e in $ps.Streams.Error) { Write-Log -Level WARN -Message ("Collector error: {0}" -f $e.ToString()) -Module $Name -Context $Context } if ($out -and $out.Count -gt 0) { $r = $out[$out.Count - 1]; if ($null -ne $r) { return $r.PSObject.BaseObject } } return $null } finally { try { $ps.Dispose(); $rs.Dispose() } catch { } } } function Invoke-CollectorModule { <# Runs the six-function contract for one collector, isolated and defensive. #> param([object]$Context, [string]$Name, [string]$ModulesRoot) $sw = [System.Diagnostics.Stopwatch]::StartNew() $status = 'Ready'; $reason = ''; $canRun = $true; $limitations = @() Write-SectionStatus -Title $Name -Status 'Collecting' -Context $Context $imported = Import-DiscoveryModuleFile -Name $Name -ModulesRoot $ModulesRoot -Context $Context if (-not $imported) { $sw.Stop() Add-ModuleStatus -Context $Context -ModuleName $Name -CanRun $false -Status 'ImportFailed' -Reason 'Module could not be imported.' -Outcome 'Skipped' -DurationMs $sw.ElapsedMilliseconds | Out-Null return } try { if (Test-ModuleCommandExists -ModuleName $Name -Command 'Test-DiscoveryPrerequisites') { try { $pre = Test-DiscoveryPrerequisites -Context $Context if ($pre) { $canRun = [bool]$pre.CanRun; $status = [string]$pre.Status; $reason = [string]$pre.Reason if ($pre.Limitations) { $limitations = @($pre.Limitations) } foreach ($l in $limitations) { if ($l) { Add-Limitation -Context $Context -Module $Name -Message ([string]$l) -Impact 'Prerequisite' | Out-Null } } } } catch { Write-Log -Level WARN -Message 'Prerequisite check failed.' -Module $Name -Exception $_ -Context $Context } } if ($canRun) { $raw = $null if (Test-ModuleCommandExists -ModuleName $Name -Command 'Invoke-DiscoveryCollection') { $deadline = 600 try { $tv = $Context.Config.Default.defaults.moduleTimeoutSeconds; if ($null -ne $tv) { $deadline = [int]$tv } } catch { } try { $raw = Invoke-CollectionWithDeadline -Context $Context -Name $Name -ModulesRoot $ModulesRoot -TimeoutSeconds $deadline } catch { Write-Log -Level ERROR -Message 'Collection failed.' -Module $Name -Exception $_ -Context $Context } } if (Test-ModuleCommandExists -ModuleName $Name -Command 'ConvertTo-DiscoveryDatasets') { try { ConvertTo-DiscoveryDatasets -Context $Context -RawData $raw | Out-Null } catch { Write-Log -Level ERROR -Message 'Dataset conversion failed.' -Module $Name -Exception $_ -Context $Context } } if (Test-ModuleCommandExists -ModuleName $Name -Command 'Invoke-DiscoveryRiskAnalysis') { try { Invoke-DiscoveryRiskAnalysis -Context $Context | Out-Null } catch { Write-Log -Level WARN -Message 'Module risk analysis failed.' -Module $Name -Exception $_ -Context $Context } } if (Test-ModuleCommandExists -ModuleName $Name -Command 'Get-DiscoveryFollowUpQuestions') { try { Get-DiscoveryFollowUpQuestions -Context $Context | Out-Null } catch { Write-Log -Level WARN -Message 'Follow-up question generation failed.' -Module $Name -Exception $_ -Context $Context } } $outcome = 'Completed' } else { $outcome = 'Skipped' Write-Log -Level INFO -Message ("Skipped (prerequisites not met): {0}" -f $reason) -Module $Name -Context $Context } } catch { $outcome = 'Failed' Write-Log -Level ERROR -Message 'Unhandled module error.' -Module $Name -Exception $_ -Context $Context } finally { Remove-DiscoveryModuleFile -Name $Name $sw.Stop() } Add-ModuleStatus -Context $Context -ModuleName $Name -CanRun $canRun -Status $status -Reason $reason -Limitations $limitations -Outcome $outcome -DurationMs $sw.ElapsedMilliseconds | Out-Null } function Invoke-SynthesisModule { param([object]$Context, [string]$Name, [string]$ModulesRoot, [string]$EntryCommand = 'Invoke-DiscoverySynthesis') $imported = Import-DiscoveryModuleFile -Name $Name -ModulesRoot $ModulesRoot -Context $Context if (-not $imported) { return } try { if (Test-ModuleCommandExists -ModuleName $Name -Command $EntryCommand) { & $EntryCommand -Context $Context } } catch { Write-Log -Level ERROR -Message ("Synthesis module '{0}' failed." -f $Name) -Module $Name -Exception $_ -Context $Context } finally { Remove-DiscoveryModuleFile -Name $Name } } # --- Output finalization ----------------------------------------------------- function Get-OutputTargetRoot { <# Where the narrative deliverables live. Under the two-folder layout that is reports\supporting\, not the run root: the run root now holds README.txt and the two top-level folders and nothing else, so a recipient opening it is not confronted with thirty files and forced to guess which two matter. Falls back to the run root if the key is absent, so an older context object still produces output rather than throwing. #> param([object]$Context) if ($Context.Paths -and $Context.Paths.Contains('ReportsSupporting')) { return $Context.Paths['ReportsSupporting'] } return $Context.OutputPath } function Save-EvidenceLogFile { <# Writes a run artifact into evidence\logs\ (falling back to the run root). #> param([object]$Context, [string]$FileName, [string]$Content) $dir = if ($Context.Paths -and $Context.Paths.Contains('Logs')) { $Context.Paths['Logs'] } else { $Context.OutputPath } try { $Content | Out-File -LiteralPath (Join-Path $dir $FileName) -Encoding UTF8 -Force } catch { } } function Save-OutputCopy { <# Writes content to the supporting-deliverables folder and mirrors it into a themed subfolder when one is requested. The mirror is SKIPPED when the subfolder resolves to the same directory as the primary target. Under the new layout Markdown and ReportsSupporting are the same folder, and without this guard every markdown deliverable would be written twice to the same path - harmless, but it doubles the I/O and looks like a bug to the next person reading it. #> param([object]$Context, [string]$FileName, [string]$Content, [string]$SubfolderKey) $root = Get-OutputTargetRoot -Context $Context $primary = Join-Path $root $FileName try { $Content | Out-File -LiteralPath $primary -Encoding UTF8 -Force } catch { } if ($SubfolderKey -and $Context.Paths.Contains($SubfolderKey)) { $mirror = Join-Path $Context.Paths[$SubfolderKey] $FileName if ($mirror -ne $primary) { try { $Content | Out-File -LiteralPath $mirror -Encoding UTF8 -Force } catch { } } } } function Build-ApplicationValidationMatrix { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() if ($Context.DataSets.Contains('ApplicationFingerprints')) { foreach ($fp in @($Context.DataSets['ApplicationFingerprints'].Rows)) { $rows.Add([pscustomobject]@{ Application=(Get-RowValue -Row $fp -Column 'ApplicationName'); Vendor=(Get-RowValue -Row $fp -Column 'Vendor') DetectedFrom=(Get-RowValue -Row $fp -Column 'EvidenceSource'); ServerPath=''; ServiceName=''; ProcessName='' DatabaseDependency=''; FileShareDependency=''; ServiceAccount=''; ScheduledTaskDependency='' IISDependency=''; CertificateDependency=''; LicenseDependency=(Get-RowValue -Row $fp -Column 'LikelyDependencyType') VendorSupportNeeded=''; BusinessOwner=''; ValidationContact='' MigrationCriticality=''; CutoverValidationSteps=''; Confidence=(Get-RowValue -Row $fp -Column 'Confidence') }) } } Add-DataSet -Context $Context -Name 'ApplicationValidationMatrix' -Description 'Application validation matrix (owners/contacts blank for manual completion).' -Rows @($rows) -Visibility 'Both' -SourceModule 'Engine' | Out-Null } function Get-DatasetRowsOrEmpty { param([object]$Context, [string]$Name) # Unary comma prevents an empty array from unrolling to $null on return. if ($Context.DataSets.Contains($Name)) { return ,@($Context.DataSets[$Name].Rows) } return ,@() } function Build-ContextListDatasets { <# Exposes the accumulated context lists as named datasets for the workbook/CSV/JSON. #> param([object]$Context) $findRows = foreach ($f in @($Context.Findings)) { [pscustomobject]@{ FindingId=$f.FindingId; Severity=$f.Severity; Confidence=$f.Confidence; Category=$f.Category; Title=$f.Title Subject=$f.Subject; IsEmphasized=$f.IsEmphasized; EmphasisReason=$f.EmphasisReason Evidence=$f.Evidence; WhyItMattersForScoping=$f.WhyItMattersForScoping PotentialProjectImpact=(@($f.PotentialProjectImpact) -join '; '); SuggestedValidationQuestion=$f.SuggestedValidationQuestion ComplianceRelevance=(@($f.ComplianceRelevance) -join '; '); SourceModule=$f.SourceModule; SourceDataset=$f.SourceDataset } } Add-DataSet -Context $Context -Name 'ScopingRisks' -Description 'All findings (scoping risks).' -Rows @($findRows) -Visibility 'Internal' -SourceModule 'RiskEngine' | Out-Null Add-DataSet -Context $Context -Name 'FollowUpQuestions' -Description 'All follow-up / validation questions.' -Rows @($Context.FollowUpQuestions | ForEach-Object { [pscustomobject]@{ Category=$_.Category; Question=$_.Question; Audience=$_.Audience; Module=$_.Module } }) -Visibility 'Both' -SourceModule 'RiskEngine' | Out-Null Add-DataSet -Context $Context -Name 'ScopeLanguage' -Description 'Draft scope language entries.' -Rows @($Context.ScopeLanguage | ForEach-Object { [pscustomobject]@{ Type=$_.Type; Text=$_.Text; Module=$_.Module } }) -Visibility 'Both' -SourceModule 'ScopeLanguage' | Out-Null Add-DataSet -Context $Context -Name 'Limitations' -Description 'Collection limitations.' -Rows @($Context.Limitations | ForEach-Object { [pscustomobject]@{ Module=$_.Module; Message=$_.Message; Impact=$_.Impact; Reason=$_.Reason } }) -Visibility 'Internal' -SourceModule 'Engine' | Out-Null Add-DataSet -Context $Context -Name 'UnknownsThatMatter' -Description 'Unknowns that matter for scoping.' -Rows @($Context.Unknowns | ForEach-Object { [pscustomobject]@{ Unknown=$_.Unknown; WhyItMatters=$_.WhyItMatters; Evidence=$_.Evidence; RecommendedValidationQuestion=$_.RecommendedValidationQuestion; Module=$_.Module } }) -Visibility 'Both' -SourceModule 'RiskEngine' | Out-Null } function Write-DiscoveryMarkdownPack { param([object]$Context) $nl = [Environment]::NewLine $header = { param($t) "# $t$nl$nl> Generated $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) on $($Context.ComputerName). Discovery is read-only; findings are indicators requiring human validation.$nl" } # summary.md + summary.txt $funcs = @(Get-LikelyServerFunctions -Context $Context) $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine((& $header 'Discovery Summary')) [void]$sb.AppendLine(('- Mode: {0} | Project type: {1} | Compliance lens: {2}' -f $Context.Mode, $Context.ProjectType, $Context.ComplianceLens)) [void]$sb.AppendLine(('- Elevated: {0} | As SYSTEM: {1} | PowerShell: {2}' -f $Context.IsAdmin, $Context.IsSystem, $Context.PowerShellVersion)) [void]$sb.AppendLine(('- Datasets: {0} | Findings: {1} | Unknowns: {2} | Limitations: {3}' -f $Context.DataSets.Count, $Context.Findings.Count, $Context.Unknowns.Count, $Context.Limitations.Count)) [void]$sb.AppendLine('') [void]$sb.AppendLine('## Likely Server Functions') if ($funcs.Count -gt 0) { foreach ($f in $funcs) { [void]$sb.AppendLine("- $f") } } else { [void]$sb.AppendLine('- No definitive functions detected.') } [void]$sb.AppendLine('') $emphCount = @($Context.Findings | Where-Object { $_.IsEmphasized }).Count [void]$sb.AppendLine(('- Findings prioritised for project type **{0}**: {1}' -f $Context.ProjectType, $emphCount)) [void]$sb.AppendLine('') [void]$sb.AppendLine('## Findings by Severity') foreach ($sev in @('Critical','High','Medium','Low','Info')) { $c = @($Context.Findings | Where-Object { $_.Severity -eq $sev }).Count [void]$sb.AppendLine(("- {0}: {1}" -f $sev, $c)) } $summaryText = $sb.ToString() Save-OutputCopy -Context $Context -FileName 'summary.md' -Content $summaryText -SubfolderKey 'Markdown' Save-OutputCopy -Context $Context -FileName 'summary.txt' -Content $summaryText # scoping-risks.txt # Sort by severity RANK, not alphabetically: 'Sort-Object Severity' yields # Critical, High, Info, Low, Medium - which buries Medium below Info. $sevRank = @{ 'Critical'=0; 'High'=1; 'Medium'=2; 'Low'=3; 'Info'=4 } $orderedFindings = @($Context.Findings) | Sort-Object ` @{ Expression = { if ($_.IsEmphasized) { 0 } else { 1 } } }, ` @{ Expression = { $sevRank[$_.Severity] } }, Category $riskLines = foreach ($f in $orderedFindings) { "[{0}]{1} ({2}/{3}) {4} :: {5}" -f $f.FindingId, $(if($f.IsEmphasized){' [PRIORITY]'}else{''}), $f.Severity, $f.Confidence, $f.Title, $f.Evidence } Save-EvidenceLogFile -Context $Context -FileName 'scoping-risks.txt' -Content (($riskLines -join $nl)) # limitations.txt -> evidence\logs\ # Collection limitations are evidence about the RUN, not a deliverable, and they sit # next to the logs that explain them. $limLines = foreach ($l in @($Context.Limitations)) { "[{0}] {1} {2}" -f $l.Module, $l.Message, ($(if($l.Impact){"(Impact: $($l.Impact))"}else{''})) } Save-EvidenceLogFile -Context $Context -FileName 'limitations.txt' -Content (($limLines -join $nl)) # errors.txt / warnings.txt are NOT written here. # # Write-Log already maintains evidence\logs\errors.txt and warnings.txt live, for the # whole run. Writing a second snapshot of the same content to the same folder would # overwrite the live file and truncate any entry logged after this point - including # anything that fails during report generation, which is exactly when you want the # error. The old flat layout got away with it only because the duplicate landed at the # run root instead. # unknowns-that-matter.md $u = New-Object System.Text.StringBuilder [void]$u.AppendLine((& $header 'Unknowns That Matter')) if (@($Context.Unknowns).Count -gt 0) { [void]$u.AppendLine((ConvertTo-MarkdownTable -Rows $Context.Unknowns -Columns @('Unknown','WhyItMatters','Evidence','RecommendedValidationQuestion'))) } else { [void]$u.AppendLine('_No unknowns recorded._') } Save-OutputCopy -Context $Context -FileName 'unknowns-that-matter.md' -Content ($u.ToString()) -SubfolderKey 'Markdown' # follow-up-questions.md & client-interview-questions.md $q = New-Object System.Text.StringBuilder [void]$q.AppendLine((& $header 'Follow-Up Questions')) foreach ($grp in (@($Context.FollowUpQuestions) | Group-Object Category)) { [void]$q.AppendLine("## $($grp.Name)") foreach ($item in $grp.Group) { [void]$q.AppendLine("- $($item.Question)") } [void]$q.AppendLine('') } Save-OutputCopy -Context $Context -FileName 'follow-up-questions.md' -Content ($q.ToString()) -SubfolderKey 'Markdown' $ci = New-Object System.Text.StringBuilder [void]$ci.AppendLine((& $header 'Client Interview / Validation Questions')) [void]$ci.AppendLine('> Client-safe. Tailored to what discovery detected. Blank answers are expected to be filled in during the interview.') [void]$ci.AppendLine('') foreach ($grp in (@($Context.FollowUpQuestions | Where-Object { $_.Audience -in @('ClientSafe','Both') }) | Group-Object Category)) { [void]$ci.AppendLine("## $($grp.Name)") foreach ($item in $grp.Group) { [void]$ci.AppendLine("- $($item.Question)") } [void]$ci.AppendLine('') } Save-OutputCopy -Context $Context -FileName 'client-interview-questions.md' -Content ($ci.ToString()) -SubfolderKey 'Markdown' # draft-scope-language.md / scope-assumptions.md / scope-exclusions.md $assume = @($Context.ScopeLanguage | Where-Object { $_.Type -notin @('Exclusion','ChangeOrderTrigger') }) $exclude = @($Context.ScopeLanguage | Where-Object { $_.Type -in @('Exclusion','ChangeOrderTrigger') }) $scope = New-Object System.Text.StringBuilder [void]$scope.AppendLine((& $header 'Draft Scope Language')) [void]$scope.AppendLine('> DRAFT ONLY - requires review before use in any statement of work. Generated only where supported by findings/limitations.') [void]$scope.AppendLine('') [void]$scope.AppendLine('## Assumptions') foreach ($s in $assume) { [void]$scope.AppendLine("> **[$($s.Type)]** $($s.Text)"); [void]$scope.AppendLine('') } [void]$scope.AppendLine('## Exclusions') foreach ($s in $exclude) { [void]$scope.AppendLine("> **[$($s.Type)]** $($s.Text)"); [void]$scope.AppendLine('') } Save-OutputCopy -Context $Context -FileName 'draft-scope-language.md' -Content ($scope.ToString()) -SubfolderKey 'Markdown' $asm = New-Object System.Text.StringBuilder [void]$asm.AppendLine((& $header 'Scope Assumptions (DRAFT)')) foreach ($s in $assume) { [void]$asm.AppendLine("- **[$($s.Type)]** $($s.Text)") } Save-OutputCopy -Context $Context -FileName 'scope-assumptions.md' -Content ($asm.ToString()) -SubfolderKey 'Markdown' $exc = New-Object System.Text.StringBuilder [void]$exc.AppendLine((& $header 'Scope Exclusions (DRAFT)')) foreach ($s in $exclude) { [void]$exc.AppendLine("- **[$($s.Type)]** $($s.Text)") } Save-OutputCopy -Context $Context -FileName 'scope-exclusions.md' -Content ($exc.ToString()) -SubfolderKey 'Markdown' # migration-complexity.md $mc = New-Object System.Text.StringBuilder [void]$mc.AppendLine((& $header 'Migration Complexity')) [void]$mc.AppendLine('> Transparent rubric, not fake precision. Ratings: None / Low / Medium / High / Unknown.') [void]$mc.AppendLine('') [void]$mc.AppendLine((ConvertTo-MarkdownTable -Rows (Get-DatasetRowsOrEmpty -Context $Context -Name 'MigrationComplexity') -Columns @('Category','Rating','Evidence','WhyItMatters','SuggestedValidation'))) Save-OutputCopy -Context $Context -FileName 'migration-complexity.md' -Content ($mc.ToString()) -SubfolderKey 'Markdown' # decommission-readiness.md $class = if ($Context.Paths.Contains('_DecommissionClassification')) { $Context.Paths['_DecommissionClassification'] } else { 'Manual validation required' } $dr = New-Object System.Text.StringBuilder [void]$dr.AppendLine((& $header 'Decommission Readiness')) [void]$dr.AppendLine('> This does NOT assert the server is safe to shut down. It classifies apparent dependency and lists what must be validated.') [void]$dr.AppendLine('') [void]$dr.AppendLine("## Overall Apparent Classification: **$class**") [void]$dr.AppendLine('') [void]$dr.AppendLine((ConvertTo-MarkdownTable -Rows (Get-DatasetRowsOrEmpty -Context $Context -Name 'DecommissionReadiness') -Columns @('Factor','Status','Confidence','Evidence','WhyItMatters','ValidationNeeded'))) Save-OutputCopy -Context $Context -FileName 'decommission-readiness.md' -Content ($dr.ToString()) -SubfolderKey 'Markdown' # scream-test-plan.md # One section per detected function, NOT a table: these 10 fields are full sentences, and # ConvertTo-MarkdownTable rendered them as a ~1200-character-wide row that no reader could # follow. This is the document an engineer works through before powering a server off. $st = New-Object System.Text.StringBuilder [void]$st.AppendLine((& $header 'Scream Test Plan')) [void]$st.AppendLine('> Power-off test planning. Uses placeholders, not real maintenance windows. Validate before any test.') [void]$st.AppendLine('') # NOTE: Get-DatasetRowsOrEmpty returns with a unary comma to stop an empty array # unrolling to $null, so its result is ALREADY an array. Wrapping it in @() again # nests it - the foreach then iterates once over the whole inner array, and $r is # Object[] rather than a row. Member enumeration hides this for a single-property # read ($r.DetectedFunction still works) but $r.$field renders as System.Object[]. $stRows = Get-DatasetRowsOrEmpty -Context $Context -Name 'ScreamTestPlan' if ($stRows.Count -eq 0) { [void]$st.AppendLine('_No data._') } else { [void]$st.AppendLine(('**System:** {0}' -f $stRows[0].System)) [void]$st.AppendLine('') # Field -> heading. Ordered as an engineer works the test: what/why, then when, then # who, then what to watch for, then what closes it out. $stFields = [ordered]@{ ReadinessConcern = 'Readiness concern' RecommendedPowerOffTestWindow = 'Recommended power-off test window' RollbackRequirement = 'Rollback requirement' WhoMustValidate = 'Who must validate' WhatToMonitor = 'What to monitor' WhatWouldConstituteAScream = 'What would constitute a scream' MinimumEvidenceBeforeRetirement = 'Minimum evidence before retirement' } foreach ($r in $stRows) { [void]$st.AppendLine(('## {0}' -f $r.DetectedFunction)) [void]$st.AppendLine('') [void]$st.AppendLine(('- **Confidence:** {0}' -f $r.Confidence)) foreach ($f in $stFields.Keys) { [void]$st.AppendLine(('- **{0}:** {1}' -f $stFields[$f], $r.$f)) } [void]$st.AppendLine('') } } Save-OutputCopy -Context $Context -FileName 'scream-test-plan.md' -Content ($st.ToString()) -SubfolderKey 'Markdown' # licensing-validation.md $lic = New-Object System.Text.StringBuilder [void]$lic.AppendLine((& $header 'Licensing Validation')) [void]$lic.AppendLine('> Licensing indicators for validation only. This tool makes no legal licensing determinations.') [void]$lic.AppendLine('') $licRows = Get-DatasetRowsOrEmpty -Context $Context -Name 'Licensing' if (@($licRows).Count -gt 0) { [void]$lic.AppendLine((ConvertTo-MarkdownTable -Rows $licRows)) } else { [void]$lic.AppendLine('_No licensing dataset was produced._') } [void]$lic.AppendLine('') [void]$lic.AppendLine('## Licensing-Related Findings') $licFindings = @($Context.Findings | Where-Object { $_.Category -eq 'Licensing' }) if ($licFindings.Count -gt 0) { foreach ($f in $licFindings) { [void]$lic.AppendLine("- **$($f.Title)** - $($f.Evidence)") } } else { [void]$lic.AppendLine('_No licensing findings._') } Save-OutputCopy -Context $Context -FileName 'licensing-validation.md' -Content ($lic.ToString()) -SubfolderKey 'Markdown' # vendor-dependencies.md $ven = New-Object System.Text.StringBuilder [void]$ven.AppendLine((& $header 'Vendor Dependencies')) $venRows = Get-DatasetRowsOrEmpty -Context $Context -Name 'VendorAgents' if (@($venRows).Count -gt 0) { [void]$ven.AppendLine((ConvertTo-MarkdownTable -Rows $venRows)) } else { [void]$ven.AppendLine('_No vendor agents detected or module not run._') } Save-OutputCopy -Context $Context -FileName 'vendor-dependencies.md' -Content ($ven.ToString()) -SubfolderKey 'Markdown' # downtime-cutover-considerations.md $dc = New-Object System.Text.StringBuilder [void]$dc.AppendLine((& $header 'Downtime / Cutover Considerations')) $cutFindings = @($Context.Findings | Where-Object { @($_.PotentialProjectImpact) -contains 'Cutover Complexity' -or @($_.PotentialProjectImpact) -contains 'Downtime' }) if ($cutFindings.Count -gt 0) { foreach ($f in $cutFindings) { [void]$dc.AppendLine("- **[$($f.Severity)] $($f.Title)** - $($f.WhyItMattersForScoping)") } } else { [void]$dc.AppendLine('_No specific downtime/cutover-sensitive findings were identified._') } Save-OutputCopy -Context $Context -FileName 'downtime-cutover-considerations.md' -Content ($dc.ToString()) -SubfolderKey 'Markdown' } function Write-SpecialCsvOutputs { param([object]$Context) $root = Get-OutputTargetRoot -Context $Context $delim = Get-CsvDelimiter -Context $Context try { Write-ObjectListToCsv -Rows (Get-DatasetRowsOrEmpty -Context $Context -Name 'DependencyGraph') -Path (Join-Path $root 'dependency-graph.csv') -Delimiter $delim -Columns @('SourceType','SourceName','DependencyType','Target','Evidence','Confidence','SourceDataset','ProjectImpact','ValidationQuestion') | Out-Null } catch { } try { Write-ObjectListToCsv -Rows (Get-DatasetRowsOrEmpty -Context $Context -Name 'WbsInputs') -Path (Join-Path $root 'wbs-inputs.csv') -Delimiter $delim -Columns @('Finding','Subject','PriorityForProject','SuggestedWBSArea','LaborDriver','Complexity','Evidence','SuggestedScopeNote','ValidationQuestion') | Out-Null } catch { } try { Write-ObjectListToCsv -Rows (Get-DatasetRowsOrEmpty -Context $Context -Name 'ApplicationValidationMatrix') -Path (Join-Path $root 'application-validation-matrix.csv') -Delimiter $delim | Out-Null } catch { } } function Write-ClientSafeSummary { param([object]$Context) $funcs = @(Get-LikelyServerFunctions -Context $Context) $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('# Server Discovery - Client Summary') [void]$sb.AppendLine('') [void]$sb.AppendLine("> Plain-English summary of what we found on **$($Context.ComputerName)**. This is for planning and validation. Please review and correct anything that looks wrong.") [void]$sb.AppendLine('') [void]$sb.AppendLine('## What this server appears to do') if ($funcs.Count -gt 0) { foreach ($f in $funcs) { [void]$sb.AppendLine("- $f") } } else { [void]$sb.AppendLine('- We could not automatically determine a primary role. Please help us confirm what this server is used for.') } [void]$sb.AppendLine('') [void]$sb.AppendLine('## Questions we need your help to answer') foreach ($grp in (@($Context.FollowUpQuestions | Where-Object { $_.Audience -in @('ClientSafe','Both') }) | Group-Object Category)) { [void]$sb.AppendLine("**$($grp.Name)**") foreach ($item in $grp.Group) { [void]$sb.AppendLine("- $($item.Question)") } [void]$sb.AppendLine('') } [void]$sb.AppendLine('## Migration / decommission considerations') $class = if ($Context.Paths.Contains('_DecommissionClassification')) { $Context.Paths['_DecommissionClassification'] } else { 'Manual validation required' } [void]$sb.AppendLine("- Apparent dependency level: **$class** (indicator only - not a recommendation to shut anything off).") [void]$sb.AppendLine('- Any migration or shutdown should be validated with the owners/vendors of the systems above.') [void]$sb.AppendLine('') [void]$sb.AppendLine('## Known limitations of this discovery') if (-not $Context.IsAdmin) { [void]$sb.AppendLine('- Discovery was not run with full administrative rights, so some details may be incomplete.') } if ($Context.Mode -eq 'Fast') { [void]$sb.AppendLine('- This was a Fast scan focused on scoping; deeper analysis is available if needed.') } [void]$sb.AppendLine('- Warranty, licensing entitlement, and vendor support status cannot be confirmed from the server itself.') [void]$sb.AppendLine('') [void]$sb.AppendLine('## Suggested client action items') [void]$sb.AppendLine('- Identify a business owner and validation contact for each key application above.') [void]$sb.AppendLine('- Confirm backup coverage and the last successful restore test.') [void]$sb.AppendLine('- Answer the questions above so we can scope accurately.') Save-OutputCopy -Context $Context -FileName 'client-safe-summary.md' -Content ($sb.ToString()) -SubfolderKey 'ClientSafe' } function Write-CollectionMetadata { param([object]$Context) # Resolved effective parameters, sorted by name. These are what make a run reproducible: # the CLI value, mode config, global config and built-in fallback have all already been # collapsed into a single value by Resolve-EffValue, and nothing else records the result. $effective = [ordered]@{} if ($Context.Parameters) { foreach ($k in @($Context.Parameters.Keys | Sort-Object)) { $effective[[string]$k] = $Context.Parameters[$k] } } $meta = [ordered]@{ RunId=$Context.RunId; ComputerName=$Context.ComputerName; Mode=$Context.Mode ProjectType=$Context.ProjectType; ComplianceLens=$Context.ComplianceLens StartTime=$Context.StartTime.ToString('o'); EndTime=(Get-Date).ToString('o') DurationSeconds=[math]::Round(((Get-Date) - $Context.StartTime).TotalSeconds,1) PowerShellVersion=$Context.PowerShellVersion; IsAdmin=$Context.IsAdmin; IsSystem=$Context.IsSystem EffectiveParameters=$effective IncludedModules=@($Context.IncludedModules); ExcludedModules=@($Context.ExcludedModules) ModuleStatuses=@($Context.ModuleStatuses) Datasets=@($Context.DataSets.Keys | ForEach-Object { [ordered]@{ Name=$_; Rows=$Context.DataSets[$_].RowCount; Visibility=$Context.DataSets[$_].Visibility } }) Counters=$Context.Counters } # Run provenance belongs with the evidence, not with the deliverables. $metaDir = if ($Context.Paths.Contains('EvidenceRoot')) { $Context.Paths['EvidenceRoot'] } else { $Context.OutputPath } try { ($meta | ConvertTo-Json -Depth 8) | Out-File -LiteralPath (Join-Path $metaDir 'collection-metadata.json') -Encoding UTF8 -Force } catch { } } function Write-ReadmeFile { param([object]$Context) $class = if ($Context.Paths.Contains('_DecommissionClassification')) { $Context.Paths['_DecommissionClassification'] } else { 'Manual validation required' } $txt = @" Windows Server Discovery Toolkit - Output ========================================= Run ID : $($Context.RunId) Computer : $($Context.ComputerName) Mode : $($Context.Mode) Project type : $($Context.ProjectType) Generated : $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) Apparent dependency classification : $class Read-only discovery output for scoping and planning. Every finding is an INDICATOR that requires human validation. No compliance certification is asserted, and nothing on the server was changed. THERE ARE TWO THINGS TO READ. Both are in reports\. reports\internal-engineering-report.html <- START HERE (internal use only) Full engineering detail: every finding with its evidence, the dependency map, migration complexity, decommission readiness, WBS inputs and draft scope language. Contains service accounts, paths, ports and certificate subjects. Reads start to finish - the one to print or hand to someone reading through. DO NOT SEND THIS TO THE CLIENT. reports\internal-dashboard-report.html <- SAME DATA, for on-screen use (internal only) Identical findings and datasets as the file above, in a sidebar-navigated, scrollable/filterable layout instead of one long page. Jump straight to a severity band or a dataset section instead of scrolling past everything else. reports\client-discovery-report.html <- SEND THIS TO THE CLIENT Plain-English summary of what the server does, what depends on it, what is worth their attention, and the questions only they can answer. Built solely from client-safe datasets; no evidence strings, accounts, paths or ports. The internal engineering report also exists as .md, and the client report as .md too, for pasting into tickets, PSA notes and scoping documents. FOLDER LAYOUT ------------- reports\ internal-engineering-report.html / .md Internal deliverable internal-dashboard-report.html Internal deliverable, interactive client-discovery-report.html / .md Client deliverable supporting\ Everything the reports draw on: summary.md / summary.txt decommission-readiness.md, scream-test-plan.md migration-complexity.md, downtime-cutover-considerations.md draft-scope-language.md, scope-assumptions.md, scope-exclusions.md client-interview-questions.md, follow-up-questions.md unknowns-that-matter.md, licensing-validation.md, vendor-dependencies.md dependency-graph.csv, wbs-inputs.csv, application-validation-matrix.csv workbook.xml All datasets, opens in Excel client-safe\, internal\ Audience-scoped copies evidence\ collection-metadata.json What ran, with what resolved settings discovery-plan.md What the toolkit intended to do data\csv\, data\json\ Every dataset, one file per dataset logs\ summary / errors / warnings / debug, plus limitations.txt and scoping-risks.txt raw\ Raw captures (only when requested) status\ Live status JSON written during the run manifest\ SHA256 evidence manifest (only with -GenerateEvidenceManifest) archive\ ZIP of the whole run (unless -SkipZip) HANDLING -------- This output is client data. It describes real infrastructure. Treat the run folder as confidential, and send only reports\client-discovery-report.html outside your team. "@ Save-OutputCopy -Context $Context -FileName 'README.txt' -Content $txt # README belongs at the run root as well - it is the map, and a map filed inside # one of the folders it describes is no use to whoever opens the run folder first. try { $txt | Out-File -LiteralPath (Join-Path $Context.OutputPath 'README.txt') -Encoding UTF8 -Force } catch { } } function Write-DiscoveryOutputs { <# Produces every required artifact. Runs after collection + synthesis. #> param([object]$Context) Write-SectionStatus -Title 'Generating outputs' -Context $Context # Internal engineering report (HTML) -> reports\internal-engineering-report.html # Renamed from internal-report.html so the filename itself states the audience; a # file called 'internal-report' has been emailed to a client before now. $reportDir = if ($Context.Paths.Contains('Reports')) { $Context.Paths['Reports'] } else { $Context.OutputPath } $htmlRoot = Join-Path $reportDir 'internal-engineering-report.html' try { Write-HtmlReport -Context $Context -Path $htmlRoot | Out-Null } catch { Write-Log -Level ERROR -Message 'HTML report generation failed.' -Module 'Engine' -Exception $_ -Context $Context } if ($Context.Paths.Contains('Internal')) { try { Copy-Item -LiteralPath $htmlRoot -Destination (Join-Path $Context.Paths['Internal'] 'internal-engineering-report.html') -Force -ErrorAction SilentlyContinue } catch { } } # Interactive companion to the report above - same data (Get-InternalReportModel), a # sidebar nav + scrollable/filterable tables instead of one long scroll. For on-screen # reference; the plain report above is still the one to print or read straight through. $dashboardRoot = Join-Path $reportDir 'internal-dashboard-report.html' try { Write-DashboardHtmlReport -Context $Context -Path $dashboardRoot | Out-Null } catch { Write-Log -Level ERROR -Message 'Dashboard HTML report generation failed.' -Module 'Engine' -Exception $_ -Context $Context } if ($Context.Paths.Contains('Internal')) { try { Copy-Item -LiteralPath $dashboardRoot -Destination (Join-Path $Context.Paths['Internal'] 'internal-dashboard-report.html') -Force -ErrorAction SilentlyContinue } catch { } } # Workbook -> root $wbHeader = '#D9EAF7'; $wbFont='Calibri'; $wbSize=11 if ($Context.Config -and $Context.Config.Output -and $Context.Config.Output.workbook) { if ($Context.Config.Output.workbook.headerColorHex) { $wbHeader = $Context.Config.Output.workbook.headerColorHex } if ($Context.Config.Output.workbook.fontName) { $wbFont = $Context.Config.Output.workbook.fontName } if ($Context.Config.Output.workbook.fontSize) { $wbSize = [int]$Context.Config.Output.workbook.fontSize } } try { Write-ExcelXmlWorkbook -Context $Context -Path (Join-Path (Get-OutputTargetRoot -Context $Context) 'workbook.xml') -HeaderColor $wbHeader -FontName $wbFont -FontSize $wbSize | Out-Null } catch { Write-Log -Level ERROR -Message 'Workbook generation failed.' -Module 'Engine' -Exception $_ -Context $Context } # Per-dataset CSV/JSON try { Export-DiscoveryDatasets -Context $Context | Out-Null } catch { Write-Log -Level WARN -Message 'Dataset export failed.' -Module 'Engine' -Exception $_ -Context $Context } # Special CSVs + markdown pack + client-safe + metadata + readme try { Write-SpecialCsvOutputs -Context $Context } catch { Write-Log -Level WARN -Message 'Special CSV outputs failed.' -Module 'Engine' -Exception $_ -Context $Context } try { Write-DiscoveryMarkdownPack -Context $Context } catch { Write-Log -Level WARN -Message 'Markdown pack failed.' -Module 'Engine' -Exception $_ -Context $Context } try { Write-ClientSafeSummary -Context $Context } catch { Write-Log -Level WARN -Message 'Client-safe summary failed.' -Module 'Engine' -Exception $_ -Context $Context } try { Write-CollectionMetadata -Context $Context } catch { Write-Log -Level WARN -Message 'Collection metadata failed.' -Module 'Engine' -Exception $_ -Context $Context } try { Write-ReadmeFile -Context $Context } catch { } } # --- Top-level driver -------------------------------------------------------- function Invoke-Discovery { <# Main driver. Expects a fully prepared context (folders + log paths + config). #> param([Parameter(Mandatory)][object]$Context) $modulesRoot = Join-Path $PSScriptRoot 'modules' $cfg = $Context.Config $collectorOrder = @() $synthesisOrder = @('RiskEngine','DecommissionReadiness','ScopeLanguage','ClientInterviewPack') if ($cfg -and $cfg.Default) { if ($cfg.Default.collectorModuleOrder) { $collectorOrder = @($cfg.Default.collectorModuleOrder) } if ($cfg.Default.synthesisModules) { $synthesisOrder = @($cfg.Default.synthesisModules | Where-Object { $_ -ne 'EvidenceManifest' }) } } if ($collectorOrder.Count -eq 0) { $collectorOrder = @('SystemInventory','RolesFeatures','ServicesTasks','Applications','Network','Storage','FileShares','SecurityPosture','ActiveDirectory','DNS','DHCP','IIS','SQL','HyperV','Cluster','RDS','NPS_RADIUS','Certificates','BackupDR','PrintServer','ConfigDependencyScan','Licensing','VendorAgents','PerformanceSnapshot','EventLogs') } Write-Log -Level INFO -Message ("Discovery started. RunId={0} Mode={1} ProjectType={2}" -f $Context.RunId, $Context.Mode, $Context.ProjectType) -Module 'Engine' -Context $Context # Preflight: gather metadata for collectors + synthesis (for plan). Update-StatusFile -Context $Context -Phase 'Preflight' -TotalModules $collectorOrder.Count # ReportBuilder and EvidenceManifest run outside the ordinary synthesis list (both # need the output files to already exist), but their metadata still belongs in the # plan - a module that runs and is not listed is how a plan stops being trustworthy. $allForMeta = @($collectorOrder) + @($synthesisOrder) + @('ReportBuilder','EvidenceManifest') $metadata = Get-AllModuleMetadata -Context $Context -ModulesRoot $modulesRoot -ModuleNames $allForMeta # Selection. $modeConfig = if ($Context.Mode -eq 'Deep') { $cfg.Deep } elseif ($Context.Mode -eq 'Fast') { $cfg.Fast } else { $null } $include = @(); $exclude = @() if ($Context.Parameters.ContainsKey('IncludeModules') -and $Context.Parameters['IncludeModules']) { $include = @($Context.Parameters['IncludeModules']) } if ($Context.Parameters.ContainsKey('ExcludeModules') -and $Context.Parameters['ExcludeModules']) { $exclude = @($Context.Parameters['ExcludeModules']) } # -IncludeConfigDependencyScan explicitly enables the config scan even in Fast mode. if ($Context.Parameters['IncludeConfigDependencyScan'] -and ($include -notcontains 'ConfigDependencyScan')) { $include = @($include) + 'ConfigDependencyScan' } $selected = Resolve-ModuleSelection -Context $Context -Metadata $metadata -Mode $Context.Mode -CollectorOrder $collectorOrder -Include $include -Exclude $exclude -ModeConfig $modeConfig # Discovery plan (before collectors). $planDir = if ($Context.Paths.Contains('EvidenceRoot')) { $Context.Paths['EvidenceRoot'] } else { $Context.OutputPath } $planPath = Join-Path $planDir 'discovery-plan.md' try { Write-DiscoveryPlan -Context $Context -Path $planPath } catch { Write-Log -Level WARN -Message 'Discovery plan generation failed.' -Module 'Engine' -Exception $_ -Context $Context } # -WhatIf: show exactly what would run and stop here. No collector executes, so nothing on # the server is touched beyond the plan file itself - useful to show a client before a real run. if ($Context.Parameters.ContainsKey('WhatIf') -and $Context.Parameters['WhatIf']) { Write-Log -Level INFO -Message 'WhatIf: stopping after the plan - no collector will run.' -Module 'Engine' -Context $Context if (-not $Context.Quiet) { Write-Host '' Write-Host ('=== WhatIf: {0} module(s) would run, {1} would be skipped ===' -f @($Context.IncludedModules).Count, @($Context.ExcludedModules).Count) -ForegroundColor Cyan foreach ($name in $Context.IncludedModules) { Write-Host (" [run] {0}" -f $name) -ForegroundColor Green } foreach ($name in $Context.ExcludedModules) { Write-Host (" [skip] {0}" -f $name) -ForegroundColor DarkGray } Write-Host '' Write-Host ("Full plan: {0}" -f $planPath) -ForegroundColor Cyan } $Context.EndTime = Get-Date Update-StatusFile -Context $Context -Phase 'Complete' -CompletedModules 0 -TotalModules @($selected).Count return } # Collection phase. $total = @($selected).Count; $done = 0 foreach ($name in $selected) { Update-StatusFile -Context $Context -Phase 'Collecting' -CurrentModule $name -CompletedModules $done -TotalModules $total Invoke-CollectorModule -Context $Context -Name $name -ModulesRoot $modulesRoot $done++ Update-StatusFile -Context $Context -Phase 'Collecting' -CurrentModule $name -CompletedModules $done -TotalModules $total } # Application fingerprint matching. Must sit between collection and synthesis: its # matchers reference datasets from modules that run after Applications (ListeningPorts, # IisSites, SqlInstances), and its output (ApplicationFingerprints) is read by the # RiskEngine, so it cannot be deferred into the synthesis list either. if ($selected -contains 'Applications') { Invoke-SynthesisModule -Context $Context -Name 'Applications' -ModulesRoot $modulesRoot -EntryCommand 'Invoke-DiscoveryFingerprintSynthesis' } # Synthesis phase (RiskEngine first). Update-StatusFile -Context $Context -Phase 'Synthesis' -CompletedModules $done -TotalModules $total foreach ($name in $synthesisOrder) { Invoke-SynthesisModule -Context $Context -Name $name -ModulesRoot $modulesRoot } try { Build-ApplicationValidationMatrix -Context $Context } catch { } try { Build-ContextListDatasets -Context $Context } catch { Write-Log -Level WARN -Message 'Context list dataset build failed.' -Module 'Engine' -Exception $_ -Context $Context } # Output generation. Update-StatusFile -Context $Context -Phase 'Output' -CompletedModules $done -TotalModules $total Write-DiscoveryOutputs -Context $Context # Report build. Deliberately AFTER Write-DiscoveryOutputs: the two finished reports # index the supporting pack and the per-dataset exports, so those files have to # exist before the reports claim they do. It is also why ReportBuilder is not in the # ordinary synthesis list - synthesis runs before output generation. Update-StatusFile -Context $Context -Phase 'Reporting' -CompletedModules $done -TotalModules $total Invoke-SynthesisModule -Context $Context -Name 'ReportBuilder' -ModulesRoot $modulesRoot -EntryCommand 'Invoke-DiscoveryReportBuild' # Evidence manifest (last; only if requested). if ($Context.Parameters.ContainsKey('GenerateEvidenceManifest') -and $Context.Parameters['GenerateEvidenceManifest']) { Invoke-SynthesisModule -Context $Context -Name 'EvidenceManifest' -ModulesRoot $modulesRoot -EntryCommand 'Invoke-DiscoveryEvidenceManifest' } # ZIP archive (unless SkipZip). $skipZip = ($Context.Parameters.ContainsKey('SkipZip') -and $Context.Parameters['SkipZip']) if (-not $skipZip) { Update-StatusFile -Context $Context -Phase 'Archiving' -CompletedModules $done -TotalModules $total $zipName = ("Discover-WindowsServer_{0}_{1}.zip" -f $Context.ComputerName, $Context.StartTime.ToString('yyyyMMdd_HHmmss')) $zipPath = Join-Path $Context.Paths['Archive'] $zipName # Compress-Folder fails soft (returns $false) rather than throwing, so a real failure # must be checked explicitly here or it never reaches errors.txt/warnings.txt at all. $zipOk = Compress-Folder -SourceFolder $Context.OutputPath -DestinationZip $zipPath -ExcludeChildFolders @('archive') if (-not $zipOk) { $zipErr = Get-CompressFolderLastError Write-Log -Level WARN -Message ("Archive creation failed.{0}" -f $(if ($zipErr) { " $zipErr" } else { '' })) -Module 'Engine' -Context $Context } } $Context.EndTime = Get-Date Update-StatusFile -Context $Context -Phase 'Complete' -CompletedModules $done -TotalModules $total Write-Log -Level INFO -Message ("Discovery complete in {0}s. Findings={1} Datasets={2} Limitations={3}" -f ([math]::Round(($Context.EndTime-$Context.StartTime).TotalSeconds,1)), $Context.Findings.Count, $Context.DataSets.Count, $Context.Limitations.Count) -Module 'Engine' -Context $Context if (-not $Context.Quiet) { Write-Host '' Write-Host ('Discovery complete. Output: {0}' -f $Context.OutputPath) -ForegroundColor Green Write-Host (' Findings: {0} | Datasets: {1} | Limitations: {2} | Errors: {3}' -f $Context.Findings.Count, $Context.DataSets.Count, $Context.Limitations.Count, $Context.Counters.Errors) -ForegroundColor Green } return $Context } Export-ModuleMember -Function ` 'Invoke-Discovery','Get-AllModuleMetadata','Resolve-ModuleSelection','Invoke-CollectorModule', ` 'Invoke-SynthesisModule','Write-DiscoveryOutputs','Import-DiscoveryModuleFile','Remove-DiscoveryModuleFile', ` 'Build-ApplicationValidationMatrix','Build-ContextListDatasets','Get-OutputTargetRoot','Save-EvidenceLogFile', ` 'Start-DiscoverWindowsServerGui','Invoke-DiscoverWindowsServer' |