modules/Core/Core.psm1
|
<# Core.psm1 Ultimate Modular Windows Server Discovery Toolkit - Core module. Provides the runtime context, structured logging, safety wrappers (CIM / registry / command-line), redaction, the standard Finding / Dataset / FollowUpQuestion / Limitation / Dependency object models, and the Add-* helpers that collector modules use to contribute results to the shared context. Design rules: - PowerShell 5.1 compatible. - Read-only. Nothing in Core changes the system except writing toolkit output. - Defensive: every external call is wrapped so one failure never stops discovery. #> # NOTE: StrictMode is intentionally NOT enabled. Collectors work with highly # dynamic CIM / registry / .NET objects whose properties are frequently absent; # strict mode would turn benign missing-property reads into terminating errors, # which conflicts with the "one failure must never stop discovery" rule. We rely # on explicit -ErrorAction handling and try/catch for defensiveness instead. # Enumerations used for light validation of the object model. $script:ValidSeverities = @('Info','Low','Medium','High','Critical') $script:ValidConfidence = @('Confirmed','Likely','Possible','NotDetected','Unknown') $script:ValidImpacts = @('Labor','Licensing','Downtime','Vendor Dependency','Security/Compliance','Data Migration','Cutover Complexity','Client Coordination','Architecture Decision','Rollback Planning') $script:ValidVisibility = @('Internal','ClientSafe','Both','SensitiveRedacted') #region Configuration loading ------------------------------------------------- function Import-DiscoveryConfig { <# Reads a single JSON config file safely. Returns $null on failure. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) try { if (-not (Test-Path -LiteralPath $Path)) { return $null } $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($raw)) { return $null } return ($raw | ConvertFrom-Json -ErrorAction Stop) } catch { Write-Warning ("Failed to load config '{0}': {1}" -f $Path, $_.Exception.Message) return $null } } function Get-DiscoveryConfigBundle { <# Loads every known config file from the config directory into one object. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$ConfigDirectory, # Tests pass a disposable directory so they never read this machine's real branding. [string]$BrandingDirectory = (Get-DiscoveryBrandingDirectory -ConfigDirectory $ConfigDirectory) ) $join = { param($n) Join-Path -Path $ConfigDirectory -ChildPath $n } return [pscustomobject]@{ Default = Import-DiscoveryConfig -Path (& $join 'default.discovery.json') Fast = Import-DiscoveryConfig -Path (& $join 'fast.discovery.json') Deep = Import-DiscoveryConfig -Path (& $join 'deep.discovery.json') Redaction = Import-DiscoveryConfig -Path (& $join 'redaction-patterns.json') RiskRules = Import-DiscoveryConfig -Path (& $join 'risk-rules.json') Fingerprints = Import-DiscoveryConfig -Path (& $join 'application-fingerprints.json') Compliance = Import-DiscoveryConfig -Path (& $join 'compliance-lenses.json') Output = Merge-LocalBranding -Output (Import-DiscoveryConfig -Path (& $join 'output-settings.json')) -BrandingDirectory $BrandingDirectory ConfigDir = $ConfigDirectory BrandingDir = $BrandingDirectory } } function Get-DiscoveryBrandingDirectory { <# Where this machine's own branding (branding.local.json + its logo, saved by the GUI's Branding tab) lives. %ProgramData%\Discover-WindowsServer\branding first: outside the module folder, so it survives Update-Module (which installs each version into a new folder) and saving it doesn't need write access to Program Files. Falls back to the config directory: the pre-move location, and where Invoke-FleetDiscovery stages it on remote targets. Merge-FleetDiscoveryResults.ps1, Invoke-FleetDiscovery.ps1 and the GUI mirror this path on purpose (none of them import Core). #> param( [Parameter(Mandatory)][string]$ConfigDirectory, [string]$ProgramDataDirectory = (Join-Path $env:ProgramData 'Discover-WindowsServer\branding') ) if (Test-Path -LiteralPath (Join-Path $ProgramDataDirectory 'branding.local.json')) { return $ProgramDataDirectory } if (Test-Path -LiteralPath (Join-Path $ConfigDirectory 'branding.local.json')) { return $ConfigDirectory } return $ProgramDataDirectory } function Merge-LocalBranding { <# Overlays branding.local.json's html block onto output-settings.json's. The local file is per-machine (never in git, never in the Gallery package) and holds the real client-facing brand, so the tracked output-settings.json keeps generic defaults and its shared keys still sync via git. Merge-FleetDiscoveryResults.ps1's Get-FleetBranding mirrors this. #> param([object]$Output, [Parameter(Mandatory)][string]$BrandingDirectory) $local = Import-DiscoveryConfig -Path (Join-Path $BrandingDirectory 'branding.local.json') if (-not $Output -or -not $local -or -not $local.html) { return $Output } if (-not $Output.html) { $Output | Add-Member -NotePropertyName html -NotePropertyValue ([pscustomobject]@{}) -Force } foreach ($p in $local.html.PSObject.Properties) { $Output.html | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value -Force } return $Output } function Get-DiscoveryBranding { <# Single source of truth for report branding (single-server AND fleet reports go through this or its local duplicate - see Merge-FleetDiscoveryResults.ps1's Get-FleetBranding, which can't import this module and mirrors this logic on purpose). Fail-soft throughout: a missing/malformed config, or a missing/oversized/unreadable logo file, degrades to no branding rather than throwing - report generation must never fail because of a cosmetic setting. #> [CmdletBinding()] param([Parameter(Mandatory)][object]$Context) $result = [ordered]@{ Title = 'Windows Server Discovery - Internal Engineering Report' Brand = 'Your Company' Accent = '#1F4E79' LogoDataUri = $null } try { $html = $Context.Config.Output.html if ($html) { if ($html.title) { $result.Title = [string]$html.title } if ($html.brandName) { $result.Brand = [string]$html.brandName } if ($html.accentColorHex) { $result.Accent = [string]$html.accentColorHex } if ($html.logoPath) { # Branding directory first (where the GUI saves the logo), then the config directory. foreach ($dir in @($Context.Config.BrandingDir, $Context.Config.ConfigDir)) { if (-not $dir) { continue } $result.LogoDataUri = Get-DiscoveryLogoDataUri -ConfigDirectory $dir -LogoPath ([string]$html.logoPath) if ($result.LogoDataUri) { break } } } } } catch { } return [pscustomobject]$result } function Get-DiscoveryLogoDataUri { <# Reads a logo image (relative to the config directory) and returns it as a base64 data: URI, or $null if the file is missing, too large (a report shouldn't balloon because of an accidentally huge image), or an unrecognized type. Shared by Get-DiscoveryBranding and Merge-FleetDiscoveryResults.ps1's Get-FleetBranding. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$ConfigDirectory, [Parameter(Mandatory)][string]$LogoPath) $mimeByExtension = @{ '.png' = 'image/png'; '.jpg' = 'image/jpeg'; '.jpeg' = 'image/jpeg'; '.gif' = 'image/gif'; '.svg' = 'image/svg+xml' } try { $fullPath = Join-Path -Path $ConfigDirectory -ChildPath $LogoPath if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { return $null } $ext = [System.IO.Path]::GetExtension($fullPath).ToLowerInvariant() if (-not $mimeByExtension.ContainsKey($ext)) { return $null } $bytes = [System.IO.File]::ReadAllBytes($fullPath) if ($bytes.Length -gt 512KB) { return $null } return ('data:{0};base64,{1}' -f $mimeByExtension[$ext], [Convert]::ToBase64String($bytes)) } catch { return $null } } #endregion #region Runtime context ------------------------------------------------------- function New-DiscoveryContext { <# Builds the central runtime context object shared across modules. Reference-type collections (Lists / ordered dictionaries) mutate in place, so modules never need global state. #> [CmdletBinding()] param( [string]$Mode = 'Fast', [string]$ProjectType = 'GeneralDiscovery', [string]$ComplianceLens = 'None', [string]$OutputRoot = 'C:\Temp', [string]$OutputPath, [bool]$IsAdmin = $false, [bool]$IsSystem = $false, [hashtable]$Parameters = @{}, [object]$Config, [bool]$Quiet = $false, [bool]$VerboseLogging = $false ) $script:RedactMemo = @{} # per-run: the redaction patterns come from this context's config $ctx = [ordered]@{ RunId = ([guid]::NewGuid()).Guid StartTime = (Get-Date) EndTime = $null ComputerName = $env:COMPUTERNAME Mode = $Mode ProjectType = $ProjectType ComplianceLens = $ComplianceLens OutputRoot = $OutputRoot OutputPath = $OutputPath Paths = [ordered]@{} LogPaths = [ordered]@{} IsAdmin = $IsAdmin IsSystem = $IsSystem Quiet = $Quiet VerboseLogging = $VerboseLogging PowerShellVersion = $PSVersionTable.PSVersion.ToString() Parameters = $Parameters Config = $Config IncludedModules = @() ExcludedModules = @() ModuleStatuses = [System.Collections.Generic.List[object]]::new() ModuleMetadata = [System.Collections.Generic.List[object]]::new() DataSets = [ordered]@{} Findings = [System.Collections.Generic.List[object]]::new() FollowUpQuestions = [System.Collections.Generic.List[object]]::new() ScopeLanguage = [System.Collections.Generic.List[object]]::new() Limitations = [System.Collections.Generic.List[object]]::new() Unknowns = [System.Collections.Generic.List[object]]::new() DependencyEdges = [System.Collections.Generic.List[object]]::new() Logs = [System.Collections.Generic.List[object]]::new() Counters = [ordered]@{ Findings = 0; Errors = 0; Warnings = 0; Limitations = 0; Questions = 0 } } return $ctx } #endregion #region Filesystem & safety helpers ------------------------------------------- function Ensure-Directory { <# Creates a directory (and parents) if missing. Returns the path. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) try { if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null } } catch { Write-Warning ("Ensure-Directory failed for '{0}': {1}" -f $Path, $_.Exception.Message) } return $Path } function ConvertTo-SafeFileName { <# Replaces characters that are invalid in Windows file names. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Name, [string]$Replacement = '_' ) if ([string]::IsNullOrEmpty($Name)) { return 'unnamed' } $invalid = [System.IO.Path]::GetInvalidFileNameChars() $sb = New-Object System.Text.StringBuilder foreach ($ch in $Name.ToCharArray()) { if ($invalid -contains $ch) { [void]$sb.Append($Replacement) } else { [void]$sb.Append($ch) } } $result = $sb.ToString().Trim() if ([string]::IsNullOrWhiteSpace($result)) { return 'unnamed' } return $result } function Test-IsAdministrator { <# True if the current process token is in the local Administrators role. #> [CmdletBinding()] param() try { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object System.Security.Principal.WindowsPrincipal($id) return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Test-IsSystem { <# True if running as the LocalSystem (S-1-5-18) account. #> [CmdletBinding()] param() try { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() return ($id.User.Value -eq 'S-1-5-18') } catch { return $false } } function Test-WindowsPowerShellModule { <# True when this is PowerShell Core AND Windows PowerShell has the named module on disk: i.e. the module cannot be loaded here but a powershell.exe child could run it. #> param([Parameter(Mandatory)][string]$Name) if ($PSVersionTable.PSEdition -ne 'Core') { return $false } return (Test-Path -LiteralPath (Join-Path $env:windir "System32\WindowsPowerShell\v1.0\Modules\$Name")) } function Invoke-WindowsPowerShellJson { <# Runs a READ-ONLY snippet in Windows PowerShell (powershell.exe) and returns its output parsed from JSON, or $null on any failure. For cmdlets PowerShell 7 cannot load on this host (e.g. ServerManager's Get-WindowsFeature on Windows Server 2012 R2, where PowerShell 7's own compatibility session is unavailable because it needs Windows PowerShell 5.1). Time-bounded via Invoke-CommandLineSafe. The script travels as -EncodedCommand, so quoting cannot break it. #> param([Parameter(Mandatory)][string]$Script, [int]$TimeoutSeconds = 120) $exe = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' if (-not (Test-Path -LiteralPath $exe)) { return $null } $cmd = "`$ErrorActionPreference = 'Stop'; & { $Script } | ConvertTo-Json -Compress -Depth 3" $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)) $r = Invoke-CommandLineSafe -FilePath $exe -Arguments @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $enc) -TimeoutSeconds $TimeoutSeconds if (-not $r.Succeeded -or [string]::IsNullOrWhiteSpace($r.StdOut)) { return $null } try { return ($r.StdOut | ConvertFrom-Json) } catch { return $null } } function Get-CommandAvailable { <# True if a command / cmdlet / exe is available. This gates optional collection in 17 modules across 57 call sites, so a false negative here silently skips a whole dataset - usually while logging a "cmdlet not available" limitation that is simply untrue. It used to call $ExecutionContext.InvokeCommand.GetCommands(), chosen because it returns an empty set for a missing name instead of polluting $Error with a CommandNotFound. That was the right goal but the wrong mechanism: GetCommands() does NOT trigger module auto-loading, so any cmdlet in a module that has not been imported yet reads as absent. Measured on a Windows 11 host, in a fresh session, 7 of 14 probed cmdlets were false negatives - and inconsistently so WITHIN the same module (Get-Disk found, Get-Partition not; Get-Printer found, Get-PrinterDriver not), because the result depends on module-analysis-cache state and on which module happened to be auto-loaded first. Real cost on one Deep run: FirewallRules 0 rows instead of 557, ListeningPorts 1 instead of 42, LocalGroups 0 instead of 22, Partitions 0 instead of 4, PrinterDrivers 0 instead of 9, SharePermissions 0 instead of 3. Get-Command DOES auto-load. The trick is -ErrorAction Ignore rather than SilentlyContinue: Ignore suppresses the CommandNotFoundException without recording it, so $Error stays clean and the original design goal is preserved. (Measured: probing 7 absent commands leaves $Error.Count at 0 with Ignore, and at 6 with SilentlyContinue.) Callers must still guard the actual invocation - a cmdlet can exist and then fail, e.g. Get-VM is present whenever the Hyper-V module is installed even if Hyper-V itself is not enabled. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Name) try { if (Get-Command -Name $Name -ErrorAction Ignore) { return $true } } catch { } if (Import-WindowsModuleForCommand -Name $Name) { try { return [bool](Get-Command -Name $Name -ErrorAction Ignore) } catch { return $false } } return $false } $script:CoreCompatMap = $null $script:CoreCompatTried = @{} function Import-WindowsModuleForCommand { <# PowerShell 7 will not auto-load a Windows PowerShell module whose manifest lacks CompatiblePSEditions (every one of them on Windows Server 2012 R2), and its compatibility session needs Windows PowerShell 5.1, which 2012 R2 does not have. So on 2012 R2 under PowerShell 7, Get-NetFirewallRule / Get-SmbShare / Get-Volume ... read as "not available" and whole datasets silently come back empty. Many of those modules are CIM-based and load fine with -SkipEditionCheck; those that are not (they need .NET Framework types) simply fail to import and stay unavailable. Only acts under PowerShell Core, only after Get-Command has already failed, and tries each module once. #> param([string]$Name) if ($PSVersionTable.PSEdition -ne 'Core') { return $false } if ($null -eq $script:CoreCompatMap) { $script:CoreCompatMap = @{} try { foreach ($m in (Get-Module -ListAvailable -SkipEditionCheck -ErrorAction SilentlyContinue 2>$null)) { foreach ($c in @($m.ExportedCommands.Keys)) { if (-not $script:CoreCompatMap.ContainsKey($c)) { $script:CoreCompatMap[$c] = $m.Name } } } } catch { } } $mod = $script:CoreCompatMap[$Name] if (-not $mod -or $script:CoreCompatTried.ContainsKey($mod)) { return $false } $script:CoreCompatTried[$mod] = $true try { Import-Module -Name $mod -SkipEditionCheck -Global -DisableNameChecking -ErrorAction Stop -WarningAction SilentlyContinue 2>$null 3>$null | Out-Null; return $true } catch { return $false } } function Get-ModuleAvailable { <# True if a PowerShell module is installed (loaded or available). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Name) try { if (Get-Module -Name $Name -ErrorAction SilentlyContinue) { return $true } return [bool](Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue) } catch { return $false } } function Get-RegistryValueSafe { <# Reads a single registry value, returning $null instead of throwing. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Name ) try { if (-not (Test-Path -LiteralPath $Path)) { return $null } $item = Get-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction Stop return $item.$Name } catch { return $null } } function Test-RegistryPathSafe { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) try { return (Test-Path -LiteralPath $Path) } catch { return $false } } function Invoke-CimSafe { <# Wrapper around Get-CimInstance with automatic WMI fallback and full error suppression. Always returns an array (possibly empty). #> [CmdletBinding()] param( [string]$ClassName, [string]$Namespace = 'root/cimv2', [string]$Filter, [string]$Query, [string[]]$Property ) try { $params = @{ ErrorAction = 'Stop' } if ($Query) { $params['Query'] = $Query } elseif ($ClassName) { $params['ClassName'] = $ClassName; if ($Namespace) { $params['Namespace'] = $Namespace } } else { return @() } if ($Filter) { $params['Filter'] = $Filter } if ($Property) { $params['Property'] = $Property } $result = Get-CimInstance @params if ($null -eq $result) { return @() } return @($result) } catch { # Fallback to legacy WMI for older hosts / edge cases. try { $wmi = @{ ErrorAction = 'Stop' } if ($Query) { $wmi['Query'] = $Query } elseif ($ClassName) { $wmi['Class'] = $ClassName; if ($Namespace) { $wmi['Namespace'] = $Namespace } } else { return @() } if ($Filter) { $wmi['Filter'] = $Filter } $res2 = Get-WmiObject @wmi if ($null -eq $res2) { return @() } return @($res2) } catch { return @() } } } function Invoke-CommandLineSafe { <# Runs a read-only external command (e.g. gpresult, slmgr, dism, appcmd) capturing stdout/stderr with a timeout. Never throws. Returns a result object with ExitCode, StdOut, StdErr, TimedOut, and Succeeded. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$FilePath, [string[]]$Arguments = @(), [int]$TimeoutSeconds = 60 ) $result = [pscustomobject]@{ FilePath = $FilePath; Arguments = ($Arguments -join ' ') ExitCode = $null; StdOut = ''; StdErr = ''; TimedOut = $false; Succeeded = $false; Error = $null } $proc = $null try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $FilePath $psi.Arguments = ($Arguments -join ' ') $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $proc = New-Object System.Diagnostics.Process $proc.StartInfo = $psi [void]$proc.Start() $stdOutTask = $proc.StandardOutput.ReadToEndAsync() $stdErrTask = $proc.StandardError.ReadToEndAsync() if ($proc.WaitForExit($TimeoutSeconds * 1000)) { $result.ExitCode = $proc.ExitCode $result.StdOut = $stdOutTask.Result $result.StdErr = $stdErrTask.Result $result.Succeeded = ($proc.ExitCode -eq 0) } else { $result.TimedOut = $true try { $proc.Kill() } catch { } } } catch { $result.Error = $_.Exception.Message } finally { if ($proc) { try { $proc.Dispose() } catch { } } } return $result } #endregion #region Conversion helpers ---------------------------------------------------- function Convert-BytesToGB { [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()]$Bytes, [int]$Decimals = 2) if ($null -eq $Bytes) { return $null } try { return [math]::Round(([double]$Bytes) / 1GB, $Decimals) } catch { return $null } } function Convert-BytesToMB { [CmdletBinding()] param([Parameter(Mandatory)][AllowNull()]$Bytes, [int]$Decimals = 2) if ($null -eq $Bytes) { return $null } try { return [math]::Round(([double]$Bytes) / 1MB, $Decimals) } catch { return $null } } function Normalize-DateTime { <# Returns a consistent 'yyyy-MM-dd HH:mm:ss' string, or $null. #> [CmdletBinding()] param([AllowNull()]$Value) if ($null -eq $Value) { return $null } try { if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM-dd HH:mm:ss') } # Attempt DMTF / string conversion. $dt = $null if ([datetime]::TryParse([string]$Value, [ref]$dt)) { return $dt.ToString('yyyy-MM-dd HH:mm:ss') } try { $dt = [System.Management.ManagementDateTimeConverter]::ToDateTime([string]$Value); return $dt.ToString('yyyy-MM-dd HH:mm:ss') } catch { } return [string]$Value } catch { return $null } } #endregion #region Redaction ------------------------------------------------------------- function Test-SensitiveKeyLabel { <# True if a key/label name looks sensitive per redaction-patterns.json. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Name, [object]$Context ) if ([string]::IsNullOrWhiteSpace($Name)) { return $false } $labels = @('password','passwd','pwd','secret','token','apikey','api key','client secret','private key','shared secret','snmp community','bearer','sas token','access key','connection string password','bitlocker recovery key','credential') if ($Context -and $Context.Config -and $Context.Config.Redaction -and $Context.Config.Redaction.keyLabels) { $labels = $Context.Config.Redaction.keyLabels } $lower = $Name.ToLowerInvariant() foreach ($l in $labels) { if ($lower -like ("*{0}*" -f ([string]$l).ToLowerInvariant())) { return $true } } return $false } function Redact-SensitiveValue { <# Redacts sensitive-looking substrings from a string using the value patterns in redaction-patterns.json. Conservative by default: only obvious secrets are replaced, structure is preserved where configured. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][AllowNull()]$InputString, [object]$Context ) if ($null -eq $InputString) { return $null } $text = [string]$InputString if ([string]::IsNullOrEmpty($text)) { return $text } $replacement = '[REDACTED - sensitive-looking value detected]' $patterns = $null if ($Context -and $Context.Config -and $Context.Config.Redaction) { if ($Context.Config.Redaction.replacement) { $replacement = $Context.Config.Redaction.replacement } $patterns = $Context.Config.Redaction.valuePatterns } if (-not $patterns) { # Minimal built-in fallback patterns. $patterns = @( [pscustomobject]@{ name='Pwd'; pattern='(?i)(password|pwd)\s*=\s*[^;\r\n"'']+'; keepStructure=$true; structureReplacement='$1=[REDACTED]' } ) } foreach ($p in $patterns) { try { if ($p.keepStructure -and $p.structureReplacement) { $text = [regex]::Replace($text, $p.pattern, $p.structureReplacement) } else { $text = [regex]::Replace($text, $p.pattern, $replacement) } } catch { } } return $text } $script:RedactMemo = @{} function Protect-DatasetRows { <# Safety net run by Add-DataSet on every dataset: redacts string cells with the same patterns as Redact-SensitiveValue. Redaction used to be opt-in per field in five collectors, so any free-text field elsewhere leaked (a Hyper-V VM's Notes held a plaintext password in the CSV, JSON and workbook). Memoised - many cells repeat - and cells that already carry a redaction marker are left alone so the pass is idempotent. #> param($Rows, [object]$Context) foreach ($row in @($Rows)) { if ($row -isnot [pscustomobject]) { continue } foreach ($p in $row.PSObject.Properties) { $v = $p.Value if ($v -isnot [string] -or $v.Length -lt 8 -or $v.Contains('[REDACTED')) { continue } $red = $script:RedactMemo[$v] if ($null -eq $red) { $red = Redact-SensitiveValue -InputString $v -Context $Context; $script:RedactMemo[$v] = $red } if ($red -ne $v) { try { $p.Value = $red } catch { } } } } } #endregion #region Structured logging ---------------------------------------------------- function Write-Log { <# Structured logging. - summary.txt : INFO / WARN / ERROR - errors.txt : ERROR only - warnings.txt: WARN only - debug.log : DEBUG only when VerboseLogging is enabled Console output is concise unless VerboseLogging; Quiet suppresses nonessential console output but never suppresses files. #> [CmdletBinding()] param( [ValidateSet('INFO','WARN','ERROR','DEBUG')][string]$Level = 'INFO', [Parameter(Mandatory)][AllowEmptyString()][string]$Message, [string]$Module = 'Core', [object]$Exception, [object]$Context ) $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') $exText = '' if ($Exception) { if ($Exception -is [System.Management.Automation.ErrorRecord]) { $exText = " | Exception: {0}" -f $Exception.Exception.Message } elseif ($Exception -is [System.Exception]) { $exText = " | Exception: {0}" -f $Exception.Message } else { $exText = " | Exception: {0}" -f ([string]$Exception) } } $line = "{0} [{1}] [{2}] {3}{4}" -f $ts, $Level, $Module, $Message, $exText if ($Context) { try { $Context.Logs.Add([pscustomobject]@{ Timestamp=$ts; Level=$Level; Module=$Module; Message=$Message; Exception=$exText.TrimStart(' |') }) if ($Level -eq 'ERROR') { $Context.Counters.Errors++ } elseif ($Level -eq 'WARN') { $Context.Counters.Warnings++ } } catch { } } # File targets. $logPaths = $null if ($Context -and $Context.LogPaths) { $logPaths = $Context.LogPaths } if ($logPaths) { try { if ($Level -in @('INFO','WARN','ERROR') -and $logPaths.Summary) { Add-Content -LiteralPath $logPaths.Summary -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } if ($Level -eq 'ERROR' -and $logPaths.Errors) { Add-Content -LiteralPath $logPaths.Errors -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } if ($Level -eq 'WARN' -and $logPaths.Warnings) { Add-Content -LiteralPath $logPaths.Warnings -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } $verbose = $false if ($Context) { $verbose = [bool]$Context.VerboseLogging } if ($Level -eq 'DEBUG' -and $verbose -and $logPaths.Debug) { Add-Content -LiteralPath $logPaths.Debug -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } } catch { } } # Console. $quiet = $false; $verbose = $false if ($Context) { $quiet = [bool]$Context.Quiet; $verbose = [bool]$Context.VerboseLogging } switch ($Level) { 'ERROR' { Write-Host $line -ForegroundColor Red } 'WARN' { if (-not $quiet) { Write-Host $line -ForegroundColor Yellow } } 'DEBUG' { if ($verbose) { Write-Host $line -ForegroundColor DarkGray } } default { if ($verbose) { Write-Host $line -ForegroundColor Gray } elseif (-not $quiet) { Write-Host (" {0}" -f $Message) -ForegroundColor Gray } } } } function Write-SectionStatus { <# Concise console banner announcing a module/section. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Title, [string]$Status = '', [object]$Context ) $quiet = $false if ($Context) { $quiet = [bool]$Context.Quiet } if (-not $quiet) { $msg = if ($Status) { "==> {0} [{1}]" -f $Title, $Status } else { "==> {0}" -f $Title } Write-Host $msg -ForegroundColor Cyan } } #endregion #region Object model factories ------------------------------------------------ function New-DiscoveryDataset { <# Builds a normalized dataset object. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [string]$Description = '', $Rows = @(), [ValidateSet('Internal','ClientSafe','Both','SensitiveRedacted')][string]$Visibility = 'Internal', [bool]$IncludeInWorkbook = $true, [bool]$IncludeInClientReport = $false, [string]$SourceModule = '', # Optional, smaller row set for the workbook only (e.g. dropping thousands of ephemeral # UDP sockets from ListeningPorts). CSV/JSON always export the full $Rows - "raw export # stays complete" - only the workbook view is reduced. Defaults to $Rows: every existing # caller is unaffected. $WorkbookRows = $null ) if ($null -eq $Rows) { $Rows = @() } if ($null -eq $WorkbookRows) { $WorkbookRows = $Rows } return [pscustomobject]@{ Name = $Name Description = $Description Rows = @($Rows) WorkbookRows = @($WorkbookRows) Visibility = $Visibility IncludeInWorkbook = $IncludeInWorkbook IncludeInClientReport = $IncludeInClientReport SourceModule = $SourceModule RowCount = @($Rows).Count } } function New-DiscoveryFinding { <# Builds a standard finding object (no context side effects). #> [CmdletBinding()] param( [string]$FindingId = '', [Parameter(Mandatory)][string]$Category, [ValidateSet('Info','Low','Medium','High','Critical')][string]$Severity = 'Info', [ValidateSet('Confirmed','Likely','Possible','NotDetected','Unknown')][string]$Confidence = 'Unknown', [Parameter(Mandatory)][string]$Title, [string]$EvidenceSource = '', [string]$Evidence = '', [string]$WhyItMattersForScoping = '', [string[]]$PotentialProjectImpact = @(), [string]$SuggestedValidationQuestion = '', [string]$SuggestedScopeLanguage = '', [string[]]$LikelyAffectedWBSAreas = @(), [string[]]$ComplianceRelevance = @(), [string]$Subject = '', [bool]$IsEmphasized = $false, [string]$EmphasisReason = '', [string]$SourceModule = '', [string]$SourceDataset = '' ) return [pscustomobject]@{ FindingId = $FindingId Category = $Category Severity = $Severity Confidence = $Confidence Title = $Title EvidenceSource = $EvidenceSource Evidence = $Evidence WhyItMattersForScoping = $WhyItMattersForScoping PotentialProjectImpact = @($PotentialProjectImpact) SuggestedValidationQuestion = $SuggestedValidationQuestion SuggestedScopeLanguage = $SuggestedScopeLanguage LikelyAffectedWBSAreas = @($LikelyAffectedWBSAreas) ComplianceRelevance = @($ComplianceRelevance) # Subject is the row's primary identifier (service name, task name, application, ...) # so a per-row finding is machine-readable without parsing the Evidence string. Subject = $Subject # Emphasis is presentation only: it reorders and surfaces findings that matter most for # the active -ProjectType. It deliberately does NOT change Severity - see # docs\RISK-SCORING.md ("Do not exaggerate severity"). IsEmphasized = $IsEmphasized EmphasisReason = $EmphasisReason SourceModule = $SourceModule SourceDataset = $SourceDataset } } function New-ScopingRisk { <# Alias-style factory returning a finding shaped as a scoping risk. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Title, [string]$Category = 'Scoping', [string]$Severity = 'Medium', [string]$Confidence = 'Likely', [string]$Evidence = '', [string]$WhyItMattersForScoping = '', [string[]]$PotentialProjectImpact = @(), [string]$SuggestedValidationQuestion = '', [string]$SourceModule = '' ) return New-DiscoveryFinding -Title $Title -Category $Category -Severity $Severity -Confidence $Confidence ` -Evidence $Evidence -WhyItMattersForScoping $WhyItMattersForScoping -PotentialProjectImpact $PotentialProjectImpact ` -SuggestedValidationQuestion $SuggestedValidationQuestion -SourceModule $SourceModule } function New-FollowUpQuestion { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Question, [string]$Category = 'General', [string]$Module = '', [string]$RelatedFinding = '', [ValidateSet('Internal','ClientSafe','Both')][string]$Audience = 'Both' ) return [pscustomobject]@{ Category = $Category Question = $Question Module = $Module RelatedFinding = $RelatedFinding Audience = $Audience } } function New-DependencyEdge { [CmdletBinding()] param( [Parameter(Mandatory)][string]$SourceType, [Parameter(Mandatory)][string]$SourceName, [Parameter(Mandatory)][string]$DependencyType, [string]$Target = '', [string]$Evidence = '', [string]$Confidence = 'Likely', [string]$SourceDataset = '', [string]$ProjectImpact = '', [string]$ValidationQuestion = '' ) return [pscustomobject]@{ SourceType = $SourceType SourceName = $SourceName DependencyType = $DependencyType Target = $Target Evidence = $Evidence Confidence = $Confidence SourceDataset = $SourceDataset ProjectImpact = $ProjectImpact ValidationQuestion = $ValidationQuestion } } #endregion #region Context mutators (Add-*) ---------------------------------------------- function Add-DataSet { <# Adds or merges a dataset into the context. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Name, [string]$Description = '', $Rows = @(), [ValidateSet('Internal','ClientSafe','Both','SensitiveRedacted')][string]$Visibility = 'Internal', [bool]$IncludeInWorkbook = $true, [bool]$IncludeInClientReport = $false, [string]$SourceModule = '', $WorkbookRows = $null ) if ($null -eq $Rows) { $Rows = @() } if ($null -eq $WorkbookRows) { $WorkbookRows = $Rows } Protect-DatasetRows -Rows $Rows -Context $Context if (-not [object]::ReferenceEquals($WorkbookRows, $Rows)) { Protect-DatasetRows -Rows $WorkbookRows -Context $Context } if ($Context.DataSets.Contains($Name)) { # Merge rows into the existing dataset. $existing = $Context.DataSets[$Name] $merged = @($existing.Rows) + @($Rows) $existing.Rows = $merged $existing.RowCount = $merged.Count $existing.WorkbookRows = @($existing.WorkbookRows) + @($WorkbookRows) return $existing } $ds = New-DiscoveryDataset -Name $Name -Description $Description -Rows $Rows -Visibility $Visibility ` -IncludeInWorkbook $IncludeInWorkbook -IncludeInClientReport $IncludeInClientReport -SourceModule $SourceModule ` -WorkbookRows $WorkbookRows $Context.DataSets[$Name] = $ds return $ds } function Add-Finding { <# Creates a finding, assigns a sequential FindingId, and appends it. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Category, [ValidateSet('Info','Low','Medium','High','Critical')][string]$Severity = 'Info', [ValidateSet('Confirmed','Likely','Possible','NotDetected','Unknown')][string]$Confidence = 'Unknown', [Parameter(Mandatory)][string]$Title, [string]$EvidenceSource = '', [string]$Evidence = '', [string]$WhyItMattersForScoping = '', [string[]]$PotentialProjectImpact = @(), [string]$SuggestedValidationQuestion = '', [string]$SuggestedScopeLanguage = '', [string[]]$LikelyAffectedWBSAreas = @(), [string[]]$ComplianceRelevance = @(), [string]$Subject = '', [bool]$IsEmphasized = $false, [string]$EmphasisReason = '', [string]$SourceModule = '', [string]$SourceDataset = '' ) $Context.Counters.Findings++ $id = "FIND-{0:D4}" -f $Context.Counters.Findings $finding = New-DiscoveryFinding -FindingId $id -Category $Category -Severity $Severity -Confidence $Confidence ` -Title $Title -EvidenceSource $EvidenceSource -Evidence $Evidence -WhyItMattersForScoping $WhyItMattersForScoping ` -PotentialProjectImpact $PotentialProjectImpact -SuggestedValidationQuestion $SuggestedValidationQuestion ` -SuggestedScopeLanguage $SuggestedScopeLanguage -LikelyAffectedWBSAreas $LikelyAffectedWBSAreas ` -ComplianceRelevance $ComplianceRelevance -Subject $Subject -IsEmphasized $IsEmphasized -EmphasisReason $EmphasisReason ` -SourceModule $SourceModule -SourceDataset $SourceDataset $Context.Findings.Add($finding) return $finding } function Add-Limitation { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Module, [Parameter(Mandatory)][string]$Message, [string]$Impact = '', [string]$Reason = '' ) $Context.Counters.Limitations++ $lim = [pscustomobject]@{ Module=$Module; Message=$Message; Impact=$Impact; Reason=$Reason; Timestamp=(Get-Date).ToString('yyyy-MM-dd HH:mm:ss') } $Context.Limitations.Add($lim) return $lim } function Add-Unknown { <# Adds an 'unknown that matters' entry (distinct from errors/limitations). #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Unknown, [string]$WhyItMatters = '', [string]$Evidence = '', [string]$RecommendedValidationQuestion = '', [string]$Module = '' ) $u = [pscustomobject]@{ Unknown = $Unknown; WhyItMatters = $WhyItMatters; Evidence = $Evidence RecommendedValidationQuestion = $RecommendedValidationQuestion; Module = $Module } $Context.Unknowns.Add($u) return $u } function Add-FollowUpQuestion { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Question, [string]$Category = 'General', [string]$Module = '', [string]$RelatedFinding = '', [ValidateSet('Internal','ClientSafe','Both')][string]$Audience = 'Both' ) $Context.Counters.Questions++ $q = New-FollowUpQuestion -Question $Question -Category $Category -Module $Module -RelatedFinding $RelatedFinding -Audience $Audience $Context.FollowUpQuestions.Add($q) return $q } function Add-ScopeLanguage { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$Text, [ValidateSet('Assumption','Exclusion','ClientResponsibility','VendorResponsibility','ChangeOrderTrigger','Cutover','Validation','Licensing','Credential','BackupRollback')][string]$Type = 'Assumption', [string]$Module = '', [string]$RelatedFinding = '' ) $s = [pscustomobject]@{ Type=$Type; Text=$Text; Module=$Module; RelatedFinding=$RelatedFinding } $Context.ScopeLanguage.Add($s) return $s } function Add-DependencyEdge { [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$SourceType, [Parameter(Mandatory)][string]$SourceName, [Parameter(Mandatory)][string]$DependencyType, [string]$Target = '', [string]$Evidence = '', [string]$Confidence = 'Likely', [string]$SourceDataset = '', [string]$ProjectImpact = '', [string]$ValidationQuestion = '' ) $edge = New-DependencyEdge -SourceType $SourceType -SourceName $SourceName -DependencyType $DependencyType ` -Target $Target -Evidence $Evidence -Confidence $Confidence -SourceDataset $SourceDataset ` -ProjectImpact $ProjectImpact -ValidationQuestion $ValidationQuestion $Context.DependencyEdges.Add($edge) return $edge } function Add-ModuleStatus { <# Records a module's prerequisite / run status for the discovery plan. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [Parameter(Mandatory)][string]$ModuleName, [bool]$CanRun = $true, [string]$Status = 'Ready', [string]$Reason = '', [string[]]$Limitations = @(), [string]$Outcome = 'Pending', [int]$DurationMs = 0 ) $ms = [pscustomobject]@{ ModuleName=$ModuleName; CanRun=$CanRun; Status=$Status; Reason=$Reason Limitations=@($Limitations); Outcome=$Outcome; DurationMs=$DurationMs } $Context.ModuleStatuses.Add($ms) return $ms } #endregion #region Status files ---------------------------------------------------------- function Update-StatusFile { <# Writes the live status/progress/findings JSON files for the future GUI. Called after each module completes. #> [CmdletBinding()] param( [Parameter(Mandatory)][object]$Context, [string]$Phase = 'Collecting', [string]$CurrentModule = '', [int]$CompletedModules = 0, [int]$TotalModules = 0 ) if (-not $Context.Paths -or -not $Context.Paths.Contains('Status')) { return } $statusDir = $Context.Paths['Status'] $pct = 0 if ($TotalModules -gt 0) { $pct = [math]::Round(($CompletedModules / $TotalModules) * 100, 0) } $status = [ordered]@{ RunId=$Context.RunId; ComputerName=$Context.ComputerName; Mode=$Context.Mode ProjectType=$Context.ProjectType; ComplianceLens=$Context.ComplianceLens Phase=$Phase; CurrentModule=$CurrentModule; PercentComplete=$pct StartTime=$Context.StartTime.ToString('o'); UpdatedTime=(Get-Date).ToString('o') FindingCount=$Context.Findings.Count; ErrorCount=$Context.Counters.Errors WarningCount=$Context.Counters.Warnings; LimitationCount=$Context.Limitations.Count DatasetCount=$Context.DataSets.Count } $progress = [ordered]@{ Phase=$Phase; CurrentModule=$CurrentModule; CompletedModules=$CompletedModules TotalModules=$TotalModules; PercentComplete=$pct; UpdatedTime=(Get-Date).ToString('o') Modules=@($Context.ModuleStatuses | ForEach-Object { [ordered]@{ Name=$_.ModuleName; Status=$_.Status; Outcome=$_.Outcome; DurationMs=$_.DurationMs } }) } $findingsLive = @($Context.Findings | ForEach-Object { [ordered]@{ FindingId=$_.FindingId; Severity=$_.Severity; Confidence=$_.Confidence; Category=$_.Category; Title=$_.Title; SourceModule=$_.SourceModule } }) try { ($status | ConvertTo-Json -Depth 6) | Out-File -LiteralPath (Join-Path $statusDir 'status.json') -Encoding UTF8 -Force } catch { } try { ($progress | ConvertTo-Json -Depth 6) | Out-File -LiteralPath (Join-Path $statusDir 'progress.json') -Encoding UTF8 -Force } catch { } try { ($findingsLive | ConvertTo-Json -Depth 6) | Out-File -LiteralPath (Join-Path $statusDir 'findings-live.json') -Encoding UTF8 -Force } catch { } } #endregion Export-ModuleMember -Function ` 'Import-DiscoveryConfig','Get-DiscoveryConfigBundle','Get-DiscoveryBrandingDirectory','Merge-LocalBranding','Get-DiscoveryBranding','Get-DiscoveryLogoDataUri','New-DiscoveryContext', ` 'Ensure-Directory','ConvertTo-SafeFileName','Test-IsAdministrator','Test-IsSystem', ` 'Get-CommandAvailable','Get-ModuleAvailable','Get-RegistryValueSafe','Test-RegistryPathSafe', ` 'Invoke-CimSafe','Invoke-CommandLineSafe','Invoke-WindowsPowerShellJson','Test-WindowsPowerShellModule','Convert-BytesToGB','Convert-BytesToMB','Normalize-DateTime', ` 'Test-SensitiveKeyLabel','Redact-SensitiveValue','Write-Log','Write-SectionStatus', ` 'New-DiscoveryDataset','New-DiscoveryFinding','New-ScopingRisk','New-FollowUpQuestion','New-DependencyEdge', ` 'Add-DataSet','Add-Finding','Add-Limitation','Add-Unknown','Add-FollowUpQuestion','Add-ScopeLanguage', ` 'Add-DependencyEdge','Add-ModuleStatus','Update-StatusFile' |