modules/Applications/Applications.psm1
|
<#
Applications.psm1 - installed applications, running processes, and application fingerprint matching (read-only). Produces: InstalledApplications, RunningProcesses (during collection), ApplicationFingerprints (AFTER all collectors - see below). ORDERING: fingerprint matchers in config\application-fingerprints.json reference datasets owned by other modules - ListeningPorts (Network), IisSites (IIS), SqlInstances (SQL). Applications runs 4th of 25 collectors, so those datasets do not exist yet while this module is collecting. Matching therefore runs post-collection via Invoke-DiscoveryFingerprintSynthesis, which the orchestrator calls after every collector and before the RiskEngine. Do not move it back into ConvertTo-DiscoveryDatasets: any matcher whose source is collected later would silently never fire. #> function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName='Applications'; DisplayName='Installed Applications & Fingerprints'; Category='Applications'; Version='1.0.0' DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true ProducesDatasets=@('InstalledApplications','RunningProcesses','ApplicationFingerprints') ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$false } } function Test-DiscoveryPrerequisites { param([object]$Context) [pscustomobject]@{ ModuleName='Applications'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } } function Get-InstalledAppsFromKey { param([string]$Path, [string]$Arch, [object]$Context) $rows = [System.Collections.Generic.List[object]]::new() try { if (-not (Test-Path -LiteralPath $Path)) { return ,@($rows) } foreach ($k in (Get-ChildItem -LiteralPath $Path -ErrorAction SilentlyContinue)) { try { $p = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction SilentlyContinue if (-not $p.DisplayName) { continue } $legacy = ([bool]($p.DisplayName -match '(?i)\.NET Framework [1-3]\.|Visual C\+\+ 20(05|08|10)|Java(\s|.*)(SE 6|SE 7|Runtime.*(6|7)\.)|Silverlight|Adobe (Flash|Shockwave|AIR)|Microsoft Visual Basic 6|PowerBuilder|Crystal Reports (X|9|10|11)')) $rows.Add([pscustomobject]@{ DisplayName=$p.DisplayName; DisplayVersion=$p.DisplayVersion; Publisher=$p.Publisher InstallDate=$p.InstallDate; InstallLocation=$p.InstallLocation UninstallString=(Redact-SensitiveValue -InputString $p.UninstallString -Context $Context) EstimatedSizeMB=([math]::Round(([double]($p.EstimatedSize) / 1024), 1)) SystemComponent=([bool]($p.SystemComponent)); RegistrySource=$Path; ArchitectureHint=$Arch Is32Bit=([bool]($Arch -eq '32')); IsLegacyRuntime=$legacy; UnknownPublisher=([bool]([string]::IsNullOrWhiteSpace($p.Publisher))) }) } catch { } } } catch { } return ,@($rows) } function Invoke-DiscoveryCollection { param([object]$Context) $apps = [System.Collections.Generic.List[object]]::new() $procs = [System.Collections.Generic.List[object]]::new() # ---- Installed applications ---- try { foreach ($r in (Get-InstalledAppsFromKey -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' -Arch '64' -Context $Context)) { $apps.Add($r) } foreach ($r in (Get-InstalledAppsFromKey -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' -Arch '32' -Context $Context)) { $apps.Add($r) } foreach ($r in (Get-InstalledAppsFromKey -Path 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' -Arch 'user' -Context $Context)) { $apps.Add($r) } if ($Context.IsSystem) { Add-Limitation -Context $Context -Module 'Applications' -Message 'Running as SYSTEM: per-user (HKCU) installed applications may be invisible.' -Impact 'Incomplete app inventory' | Out-Null } } catch { Add-Limitation -Context $Context -Module 'Applications' -Message 'Installed application enumeration failed.' -Reason $_.Exception.Message | Out-Null } # ---- Running processes ---- try { $cimProcs = @{} foreach ($cp in (Invoke-CimSafe -ClassName 'Win32_Process')) { $cimProcs[[int]$cp.ProcessId] = $cp } foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { try { $cp = $cimProcs[[int]$p.Id] $cmd = if ($cp) { Redact-SensitiveValue -InputString $cp.CommandLine -Context $Context } else { '' } $path = '' try { $path = $p.Path } catch { } if (-not $path -and $cp) { $path = $cp.ExecutablePath } $procs.Add([pscustomobject]@{ Name=$p.Name; Id=$p.Id; Path=$path; CommandLine=$cmd ParentProcessId=($(if ($cp) { $cp.ParentProcessId } else { $null })) Company=$p.Company; Product=$p.Product StartTime=($(try { Normalize-DateTime $p.StartTime } catch { $null })) WorkingSetMB=([math]::Round($p.WorkingSet64 / 1MB, 1)) }) } catch { } } } catch { Add-Limitation -Context $Context -Module 'Applications' -Message 'Process enumeration failed.' -Reason $_.Exception.Message | Out-Null } return ,@{ InstalledApplications=@($apps); RunningProcesses=@($procs) } } function Get-FingerprintMatches { param([object]$Context) $results = [System.Collections.Generic.List[object]]::new() $fpConfig = $null try { $fpConfig = $Context.Config.Fingerprints } catch { } if (-not $fpConfig -or -not $fpConfig.fingerprints) { return ,@($results) } # confidenceModel is config-driven; fall back to sane defaults if absent. $strong = @('Services','InstalledApplications','SqlInstances','IisSites') $labelMultiple = 'Confirmed'; $labelStrong = 'Likely'; $labelWeak = 'Possible' try { $cm = $fpConfig.confidenceModel if ($cm) { if ($cm.strongSources) { $strong = @($cm.strongSources) } if ($cm.multipleMatches) { $labelMultiple = [string]$cm.multipleMatches } if ($cm.oneStrongMatch) { $labelStrong = [string]$cm.oneStrongMatch } if ($cm.oneWeakMatch) { $labelWeak = [string]$cm.oneWeakMatch } } } catch { } foreach ($fp in $fpConfig.fingerprints) { try { $hitSources = [System.Collections.Generic.List[string]]::new() $hitEvidence = [System.Collections.Generic.List[string]]::new() $strongHit = $false foreach ($mchr in @($fp.matchers)) { $src = $mchr.source if (-not $Context.DataSets.Contains($src)) { continue } $rows = @($Context.DataSets[$src].Rows) foreach ($row in $rows) { $val = Get-RowValue -Row $row -Column $mchr.field if ($null -ne $val -and ([string]$val -match $mchr.pattern)) { if (-not $hitSources.Contains($src)) { $hitSources.Add($src) } if ($hitEvidence.Count -lt 3) { $hitEvidence.Add(("{0}.{1}='{2}'" -f $src, $mchr.field, ([string]$val))) } if ($strong -contains $src) { $strongHit = $true } break } } } if ($hitSources.Count -eq 0) { continue } $confidence = if ($hitSources.Count -ge 2) { $labelMultiple } elseif ($strongHit) { $labelStrong } else { $labelWeak } $results.Add([pscustomobject]@{ ApplicationName=$fp.applicationName; Vendor=$fp.vendor; Category=$fp.category EvidenceSource=($hitSources -join '; '); Evidence=($hitEvidence -join ' | '); Confidence=$confidence LikelyDependencyType=$fp.likelyDependencyType PotentialProjectImpact=(@($fp.potentialProjectImpact) -join '; ') SuggestedValidationQuestion=$fp.suggestedValidationQuestion }) } catch { } } return ,@($results) } function ConvertTo-DiscoveryDatasets { param([object]$Context, $RawData) if (-not $RawData) { $RawData = @{} } $apps = @(if ($RawData.InstalledApplications) { @($RawData.InstalledApplications) } else { @() }) $procs = @(if ($RawData.RunningProcesses) { @($RawData.RunningProcesses) } else { @() }) Add-DataSet -Context $Context -Name 'InstalledApplications' -Description 'Installed software from uninstall registry keys.' -Rows $apps -Visibility 'Internal' -SourceModule 'Applications' | Out-Null Add-DataSet -Context $Context -Name 'RunningProcesses' -Description 'Running processes with paths and (redacted) command lines.' -Rows $procs -Visibility 'Internal' -SourceModule 'Applications' | Out-Null # ApplicationFingerprints is deliberately NOT built here - see the ORDERING note at the # top of this file. It is built by Invoke-DiscoveryFingerprintSynthesis after all # collectors have run, so every matcher source dataset exists. } function Invoke-DiscoveryFingerprintSynthesis { <# Post-collection entry point. Runs fingerprint matching once every collector has contributed its datasets, then raises the follow-up questions the matches imply. Invoked by the orchestrator between the collection and synthesis phases. #> param([object]$Context) Write-SectionStatus -Title 'Application Fingerprints' -Status 'Matching' -Context $Context # Get-FingerprintMatches returns ,@($results) - the unary-comma idiom that stops an empty # array unrolling to $null. Assign it BARE: wrapping the call in @() would nest the # returned array one level deeper, giving a single "row" that is itself the whole array. $fps = $null try { $fps = Get-FingerprintMatches -Context $Context } catch { Write-Log -Level WARN -Message 'Application fingerprint matching failed.' -Module 'Applications' -Exception $_ -Context $Context } if ($null -eq $fps) { $fps = @() } Add-DataSet -Context $Context -Name 'ApplicationFingerprints' -Description 'Detected business/vendor applications via fingerprint matching (evaluated after all collectors).' -Rows @($fps) -Visibility 'Both' -SourceModule 'Applications' | Out-Null try { foreach ($fp in @($fps | Where-Object { $_.Confidence -in @('Confirmed','Likely') })) { if ($fp.SuggestedValidationQuestion) { Add-FollowUpQuestion -Context $Context -Category 'Applications' -Module 'Applications' -Audience 'Both' -Question $fp.SuggestedValidationQuestion | Out-Null } } } catch { } Write-Log -Level INFO -Message ("Application fingerprinting matched {0} application(s)." -f @($fps).Count) -Module 'Applications' -Context $Context } Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Invoke-DiscoveryFingerprintSynthesis','Get-FingerprintMatches' |