Last active
November 12, 2025 10:46
-
-
Save s1037989/d497676716f6189a9a7d26f673eae89c to your computer and use it in GitHub Desktop.
windows software inventory generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Collect extensive Windows host inventory & security-relevant configuration. | |
| .DESCRIPTION | |
| Produces JSON + HTML report containing: | |
| - system / OS info | |
| - installed apps (registry, winget/choco/Get-Package) | |
| - installed updates/hotfixes | |
| - services, processes, listening ports | |
| - firewall rules | |
| - local users/groups & local admins | |
| - SMB shares & sessions | |
| - scheduled tasks | |
| - drivers (signed) | |
| - BitLocker status | |
| - Windows Defender/AV status | |
| - optional SHA256 hashing of executables from discovered install locations | |
| WARNING: Some cmds require elevation (Admin). This script prefers to relaunch elevated if needed. | |
| .NOTES | |
| - This script intentionally avoids Win32_Product enumeration (it can trigger MSI repairs). | |
| - Designed for defensive inventory; it does not attempt to exploit anything. | |
| #> | |
| # ---- Configuration ---- | |
| $ComputeFileHash = $true # Toggle: compute SHA256 for discovered executable files (can be slow) | |
| $MaxHashFiles = 200 # Max number of files to hash (protect against huge scans) | |
| $HashIncludePatterns = @('*.exe','*.dll') # file patterns to hash in discovered install locations | |
| $OutputPrefix = "host_inventory_" | |
| $Now = Get-Date -Format "yyyyMMdd_HHmmss" | |
| $JsonOut = "$OutputPrefix$Now.json" | |
| $HtmlOut = "$OutputPrefix$Now.html" | |
| function Ensure-Elevated { | |
| if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { | |
| Write-Host "Not running as Administrator. Attempting to re-launch elevated..." | |
| $psi = New-Object System.Diagnostics.ProcessStartInfo | |
| $psi.FileName = (Get-Process -Id $PID).Path | |
| $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" | |
| $psi.Verb = "runas" | |
| try { | |
| [System.Diagnostics.Process]::Start($psi) | Out-Null | |
| exit | |
| } catch { | |
| Write-Warning "Elevation declined / failed. Continuing non-elevated; some data may be missing." | |
| } | |
| } | |
| } | |
| # Attempt elevation for full results | |
| Ensure-Elevated | |
| # Utility: safe Get-CimInstance wrapper | |
| function Safe-Cim { | |
| param($Class, $Filter = $null) | |
| try { | |
| if ($Filter) { Get-CimInstance -ClassName $Class -Filter $Filter -ErrorAction Stop } | |
| else { Get-CimInstance -ClassName $Class -ErrorAction Stop } | |
| } catch { | |
| Write-Verbose "CIM $Class query failed: $_" | |
| return $null | |
| } | |
| } | |
| # ---- System basics ---- | |
| $sys = @{ | |
| ComputerName = $env:COMPUTERNAME | |
| Timestamp = (Get-Date).ToString("o") | |
| OS = @{ | |
| Caption = (Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -First 1 -Property Caption, Version, BuildNumber, OSArchitecture).Caption | |
| Version = (Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -First 1 -Property Version).Version | |
| Build = (Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -First 1 -Property BuildNumber).BuildNumber | |
| InstallDate = (Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -First 1 -Property InstallDate).InstallDate | |
| } | |
| BIOS = (Get-CimInstance -ClassName Win32_BIOS | Select-Object -First 1 * ) | |
| ComputerSystem = (Get-CimInstance -ClassName Win32_ComputerSystem | Select-Object -First 1 * ) | |
| Processor = (Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 * ) | |
| MemoryGB = [math]::Round((Get-CimInstance -ClassName Win32_ComputerSystem).TotalPhysicalMemory / 1GB,2) | |
| } | |
| # ---- Installed software enumeration ---- | |
| # Read Uninstall keys from HKLM (64/32) and HKCU | |
| function Get-InstalledFromRegistry { | |
| $keys = @( | |
| "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", | |
| "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", | |
| "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" | |
| ) | |
| $out = @() | |
| foreach ($k in $keys) { | |
| try { | |
| Get-ItemProperty -Path $k -ErrorAction SilentlyContinue | | |
| ForEach-Object { | |
| $obj = [PSCustomObject]@{ | |
| DisplayName = $_.DisplayName | |
| DisplayVersion = $_.DisplayVersion | |
| Publisher = $_.Publisher | |
| InstallDate = $_.InstallDate | |
| InstallLocation = $_.InstallLocation | |
| UninstallString = $_.UninstallString | |
| RegistryKey = $_.PSPath | |
| } | |
| $out += $obj | |
| } | |
| } catch { Write-Verbose "Registry key $k read failed: $_" } | |
| } | |
| # remove empties and duplicates | |
| $out | Where-Object { $_.DisplayName } | Sort-Object DisplayName -Unique | |
| } | |
| # Also collect from package managers and PowerShell package provider | |
| function Get-PackageManagersInventory { | |
| $res = [ordered]@{} | |
| # winget (if present) | |
| try { | |
| $wingetText = & winget list --source winget 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $wingetText) { | |
| $res.Winget = $wingetText -split "`n" | |
| } | |
| } catch {} | |
| # choco | |
| try { | |
| $choco = & choco list --localonly 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $choco) { $res.Choco = $choco -split "`n" } | |
| } catch {} | |
| # Get-Package (PowerShell package provider) | |
| try { | |
| $gp = Get-Package -ProviderName Programs -ErrorAction SilentlyContinue | |
| if ($gp) { $res.GetPackage = $gp | Select-Object Name, Version, ProviderName, Source } | |
| } catch {} | |
| return $res | |
| } | |
| $installedApps = Get-InstalledFromRegistry | |
| $packageManagers = Get-PackageManagersInventory | |
| # ---- Installed updates / hotfixes ---- | |
| # Uses Win32_QuickFixEngineering / Get-HotFix (may not return everything on modern Windows; results vary) | |
| $hotfixes = @() | |
| try { | |
| $hotfixes = Get-CimInstance -ClassName Win32_QuickFixEngineering | Select-Object HotFixID, Description, InstalledOn, InstalledBy | |
| } catch { | |
| Write-Warning "Failed to enumerate hotfixes: $_" | |
| } | |
| # ---- Services, Processes, Listening Ports ---- | |
| $services = Get-Service | Select-Object Name, DisplayName, Status, StartType, ServiceType | |
| $processes = Get-Process | Select-Object Id, ProcessName, Path, StartTime -ErrorAction SilentlyContinue | |
| # Listening TCP connections (requires admin for full detail) | |
| $tcp = @() | |
| try { | |
| $tcp = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | |
| # resolve process names | |
| $tcp = $tcp | ForEach-Object { | |
| $p = $_ | |
| $proc = Get-Process -Id $p.OwningProcess -ErrorAction SilentlyContinue | |
| [PSCustomObject]@{ | |
| LocalAddress = $p.LocalAddress | |
| LocalPort = $p.LocalPort | |
| OwningProcess = $p.OwningProcess | |
| ProcessName = if ($proc) { $proc.ProcessName } else { $null } | |
| } | |
| } | |
| } catch { Write-Verbose "Get-NetTCPConnection failed: $_" } | |
| # ---- Firewall rules ---- | |
| $firewallProfiles = @() | |
| try { | |
| $firewallProfiles = Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, AllowInboundRules, AllowLocalFirewallRules | |
| } catch { Write-Verbose "Get-NetFirewallProfile failed: $_" } | |
| $firewallRules = @() | |
| try { | |
| $firewallRules = Get-NetFirewallRule -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Select-Object DisplayName, Direction, Enabled, Profile, Action, @{Name='Ports';Expression={(Get-NetFirewallPortFilter -AssociatedNetFirewallRule $_ | Select-Object -ExpandProperty LocalPort -ErrorAction SilentlyContinue) -join ','}} | |
| } catch {} | |
| # ---- Local users / groups and administrators ---- | |
| $localUsers = @() | |
| $localGroups = @() | |
| try { | |
| $localUsers = Get-LocalUser | Select-Object Name, Enabled, LastLogon | |
| $localGroups = Get-LocalGroup | Select-Object Name | |
| $localAdmins = Get-LocalGroupMember -Group "Administrators" -ErrorAction SilentlyContinue | Select-Object Name, ObjectClass | |
| } catch { Write-Verbose "Local accounts enumeration failed (module may be unavailable or non-elevated)." } | |
| # ---- SMB shares & sessions ---- | |
| $smbShares = @() | |
| $smbSessions = @() | |
| try { | |
| $smbShares = Get-SmbShare | Select-Object Name, Path, Description, ScopeName, ConcurrentUserLimit, EncryptData | |
| $smbSessions = Get-SmbSession | Select-Object ClientComputerName, ClientUserName, NumOpens, SessionId | |
| } catch { Write-Verbose "SMB enumeration failed or not supported on this OS." } | |
| # ---- Scheduled tasks ---- | |
| $scheduledTasks = @() | |
| try { | |
| $scheduledTasks = Get-ScheduledTask | Select-Object TaskName, TaskPath, State, Principal | |
| } catch { Write-Verbose "ScheduledTask enumeration failed: $_" } | |
| # ---- Drivers ---- | |
| $drivers = @() | |
| try { | |
| $drivers = Get-CimInstance -ClassName Win32_PnPSignedDriver | Select-Object DeviceName, DriverVersion, Manufacturer, DriverDate, ClassName, InfName | |
| } catch { Write-Verbose "Driver enumeration failed: $_" } | |
| # ---- BitLocker ---- | |
| $bitlocker = $null | |
| try { | |
| if (Get-Command -Name Get-BitLockerVolume -ErrorAction SilentlyContinue) { | |
| $bitlocker = Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, LockStatus, KeyProtector | |
| } else { | |
| $bitlocker = "Get-BitLockerVolume cmdlet not present on this platform" | |
| } | |
| } catch { $bitlocker = "BitLocker query failed: $_" } | |
| # ---- Windows Defender / Anti-Malware ---- | |
| $defender = $null | |
| try { | |
| if (Get-Command -Name Get-MpComputerStatus -ErrorAction SilentlyContinue) { | |
| $defender = Get-MpComputerStatus | Select-Object AMServiceEnabled, AntivirusEnabled, AntivirusSignatureLastUpdated, AntiSpywareEnabled, ProductUptoDate | |
| } else { | |
| $defender = "Windows Defender cmdlets not available on this system" | |
| } | |
| } catch { $defender = "Get-MpComputerStatus failed: $_" } | |
| # ---- Installed roles/features (server/client) ---- | |
| $features = @() | |
| try { | |
| if (Get-Command -Name Get-WindowsFeature -ErrorAction SilentlyContinue) { | |
| $features = Get-WindowsFeature | Where-Object Installed -eq $true | Select-Object DisplayName, Name | |
| } else { | |
| # fallback: DISM /OptionalFeatures | |
| $features = (dism /online /get-features /format:table) -split "`n" | |
| } | |
| } catch { Write-Verbose "Feature query failed: $_" } | |
| # ---- Scheduled updates / Windows Update last check (best-effort) ---- | |
| $wu = @{ | |
| Hotfixes = $hotfixes | |
| LastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime | |
| } | |
| # Note: Get-HotFix / Win32_QuickFixEngineering may not show all modern update channels; use enterprise tooling (SCCM/WSUS) for authoritative view. :contentReference[oaicite:3]{index=3} | |
| # ---- Scheduled Tasks for discovery / AV / services etc. already captured above ---- | |
| # ---- Collect install locations for hashing (from registry entries' InstallLocation) ---- | |
| $installLocations = $installedApps | Where-Object { $_.InstallLocation } | Select-Object -ExpandProperty InstallLocation | Sort-Object -Unique | |
| # also include ProgramFiles and ProgramFiles(x86) | |
| $installLocations += (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion" -Name ProgramFilesDir -ErrorAction SilentlyContinue).ProgramFilesDir | |
| $installLocations += (Get-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion" -Name ProgramFilesDir -ErrorAction SilentlyContinue).ProgramFilesDir | |
| $installLocations = $installLocations | Where-Object { $_ -and (Test-Path $_) } | Sort-Object -Unique | |
| # ---- Optional file hashing (careful, slow) ---- | |
| $hashes = @() | |
| if ($ComputeFileHash -and $installLocations.Count -gt 0) { | |
| $fileCount = 0 | |
| foreach ($loc in $installLocations) { | |
| foreach ($pattern in $HashIncludePatterns) { | |
| try { | |
| Get-ChildItem -Path $loc -Include $pattern -Recurse -ErrorAction SilentlyContinue | ForEach-Object { | |
| if ($fileCount -ge $MaxHashFiles) { return } | |
| try { | |
| $h = Get-FileHash -Path $_.FullName -Algorithm SHA256 | |
| $hashes += [PSCustomObject]@{ | |
| Path = $_.FullName | |
| Length = $_.Length | |
| LastWriteTime = $_.LastWriteTime | |
| SHA256 = $h.Hash | |
| } | |
| $fileCount++ | |
| } catch { Write-Verbose "Hash failed for $($_.FullName): $_" } | |
| } | |
| } catch { Write-Verbose "Enumerate $loc failed: $_" } | |
| } | |
| if ($fileCount -ge $MaxHashFiles) { break } | |
| } | |
| } | |
| # ---- Scheduled tasks, services, processes already captured ---- | |
| # ---- Build final object ---- | |
| $report = [ordered]@{ | |
| Metadata = @{ | |
| CollectedAt = (Get-Date).ToString("o") | |
| CollectedBy = "$env:USERNAME@$env:COMPUTERNAME" | |
| Notes = "This script gathers inventory and configuration data. It does NOT run active vulnerability plugins." | |
| } | |
| System = $sys | |
| InstalledApplications = $installedApps | |
| PackageManagers = $packageManagers | |
| Hotfixes = $hotfixes | |
| Services = $services | |
| Processes = $processes | |
| ListeningPorts = $tcp | |
| FirewallProfiles = $firewallProfiles | |
| FirewallRules = $firewallRules | |
| LocalUsers = $localUsers | |
| LocalGroups = $localGroups | |
| LocalAdministrators = $localAdmins | |
| SMB = @{ | |
| Shares = $smbShares | |
| Sessions = $smbSessions | |
| } | |
| ScheduledTasks = $scheduledTasks | |
| Drivers = $drivers | |
| BitLocker = $bitlocker | |
| Defender = $defender | |
| Features = $features | |
| InstallLocations = $installLocations | |
| FileHashes = $hashes | |
| } | |
| # ---- Write JSON ---- | |
| try { | |
| $report | ConvertTo-Json -Depth 6 | Out-File -FilePath $JsonOut -Encoding UTF8 | |
| Write-Host "JSON report written to $JsonOut" | |
| } catch { | |
| Write-Warning "Failed to write JSON: $_" | |
| } | |
| # ---- Simple HTML Summary ---- | |
| function ToHtmlSafe($s) { if ($null -eq $s) { "" } else { [System.Web.HttpUtility]::HtmlEncode($s.ToString()) } } | |
| $html = @" | |
| <html><head><meta charset='utf-8'><title>Host Inventory Report - $($sys.ComputerName)</title> | |
| <style> body{font-family:Segoe UI, Arial; font-size:13px} h2{color:#2a5d9f} table{border-collapse:collapse; width:100%} th,td{border:1px solid #ddd; padding:6px} th{background:#f2f2f2}</style> | |
| </head><body> | |
| <h1>Host Inventory Report: $(ToHtmlSafe $sys.ComputerName)</h1> | |
| <p>Collected: $(ToHtmlSafe $report.Metadata.CollectedAt)</p> | |
| <h2>OS</h2> | |
| <table><tr><th>Field</th><th>Value</th></tr> | |
| <tr><td>Caption</td><td>$(ToHtmlSafe $sys.OS.Caption)</td></tr> | |
| <tr><td>Version</td><td>$(ToHtmlSafe $sys.OS.Version)</td></tr> | |
| <tr><td>Build</td><td>$(ToHtmlSafe $sys.OS.Build)</td></tr> | |
| <tr><td>Memory (GB)</td><td>$(ToHtmlSafe $sys.MemoryGB)</td></tr> | |
| </table> | |
| <h2>Top Installed Applications (sample)</h2> | |
| <table><tr><th>Name</th><th>Version</th><th>Publisher</th><th>InstallLocation</th></tr> | |
| "@ | |
| # include a small sample of installed apps (first 30) | |
| $report.InstalledApplications | Select-Object -First 30 | ForEach-Object { | |
| $html += "<tr><td>$(ToHtmlSafe $_.DisplayName)</td><td>$(ToHtmlSafe $_.DisplayVersion)</td><td>$(ToHtmlSafe $_.Publisher)</td><td>$(ToHtmlSafe $_.InstallLocation)</td></tr>`n" | |
| } | |
| $html += @" | |
| </table> | |
| <h2>Listening Ports</h2> | |
| <table><tr><th>LocalAddress</th><th>Port</th><th>PID</th><th>Process</th></tr> | |
| "@ | |
| $report.ListeningPorts | ForEach-Object { | |
| $html += "<tr><td>$(ToHtmlSafe $_.LocalAddress)</td><td>$(ToHtmlSafe $_.LocalPort)</td><td>$(ToHtmlSafe $_.OwningProcess)</td><td>$(ToHtmlSafe $_.ProcessName)</td></tr>`n" | |
| } | |
| $html += @" | |
| </table> | |
| <h2>Local Administrators</h2> | |
| <table><tr><th>Name</th><th>Type</th></tr> | |
| "@ | |
| if ($report.LocalAdministrators) { | |
| $report.LocalAdministrators | ForEach-Object { $html += "<tr><td>$(ToHtmlSafe $_.Name)</td><td>$(ToHtmlSafe $_.ObjectClass)</td></tr>`n" } | |
| } else { $html += "<tr><td colspan='2'>No data (non-elevated or module missing)</td></tr>" } | |
| $html += @" | |
| </table> | |
| <h2>Windows Defender Summary</h2> | |
| <pre>$(ToHtmlSafe ($report.Defender | Out-String))</pre> | |
| <h2>Hotfixes (sample)</h2> | |
| <pre>$(ToHtmlSafe (($report.Hotfixes | Select-Object -First 20 | Format-Table -AutoSize | Out-String)))</pre> | |
| <p>Full JSON report available: $JsonOut</p> | |
| </body></html> | |
| "@ | |
| try { | |
| $html | Out-File -FilePath $HtmlOut -Encoding UTF8 | |
| Write-Host "HTML summary written to $HtmlOut" | |
| } catch { | |
| Write-Warning "Failed to write HTML: $_" | |
| } | |
| # ---- Final message ---- | |
| Write-Host "Done. JSON: $JsonOut HTML: $HtmlOut" | |
| Write-Host "Notes:" | |
| Write-Host " - Some commands require Admin. If you ran non-elevated, re-run elevated for fuller data." | |
| Write-Host " - This collection is inventory/configuration only. To map to CVEs you must compare the inventory (packages, versions, hotfixes) against vulnerability feeds (NVD, vendor advisories, Tenable plugin DB)." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env perl | |
| use strict; | |
| use warnings; | |
| use Text::CSV_XS; | |
| use Unicode::Normalize qw(NFKD); | |
| use Getopt::Long; | |
| # Optional: load a regex-synonym table for publishers | |
| # CSV columns: pattern,publisher_norm (pattern is a Perl regex, e.g. (?i)^microsoft( corporation| corp\.)?$ | |
| my $publisher_synonyms; | |
| GetOptions("publisher-synonyms=s" => \$publisher_synonyms); | |
| my @COMPANY_SUFFIX = qw( | |
| incorporated inc llc l.l.c. ltd limited corp corporation co ag gmbh s.a. sàrl bv oy ab kg kk pty pte sas srl | |
| ); | |
| sub _nfk_clean { | |
| my ($s) = @_; | |
| $s = lc($s // ""); | |
| $s = NFKD($s); | |
| $s =~ s/\pM//g; # strip combining marks | |
| $s =~ s/^\s+|\s+$//g; # trim | |
| return $s; | |
| } | |
| my @pub_patterns; | |
| if ($publisher_synonyms) { | |
| open my $L, "<:utf8", $publisher_synonyms or die "$publisher_synonyms: $!"; | |
| my $csvL = Text::CSV_XS->new({ binary => 1, auto_diag => 1 }); | |
| $csvL->column_names($csvL->getline($L)); | |
| while (my $row = $csvL->getline_hr($L)) { | |
| push @pub_patterns, { pattern => $row->{pattern}, norm => $row->{publisher_norm} } if $row->{pattern} && $row->{publisher_norm}; | |
| } | |
| close $L; | |
| } | |
| sub normalize_publisher { | |
| my ($p) = @_; | |
| $p = _nfk_clean($p); | |
| # drop company suffixes | |
| my $suffix_rx = join "|", map { quotemeta } @COMPANY_SUFFIX; | |
| $p =~ s/\b($suffix_rx)\b//g; | |
| $p =~ s/[^a-z0-9]+/ /g; | |
| $p =~ s/\s+/ /g; | |
| $p =~ s/^\s+|\s+$//g; | |
| # synonym regexes | |
| for my $r (@pub_patterns) { | |
| if ($p =~ /$r->{pattern}/) { | |
| $p = $r->{norm}; | |
| last; | |
| } | |
| } | |
| return $p; | |
| } | |
| # Regex fragments used by both SPL and Perl | |
| my $ARCH = qr/\b(?:x64|x86|amd64|arm64|ia-?32|32-bit|64-bit)\b/i; | |
| my $LOCALE = qr/\b(?:en[-_]us|en[-_]gb|fr[-_]fr|de[-_]de|ja[-_]jp|zh[-_](?:cn|tw))\b/i; | |
| my $NOISE = qr/\b(?:msi|exe|installer|setup|hotfix|security update|cumulative update|rollup|patch|kb\d+|build\s*\d+)\b/i; | |
| my $VER = qr/\b(?:v(?:ersion)?\s*)?(\d+(?:\.\d+)+(?:[-_a-z0-9]+)?)\b/i; | |
| sub extract_version_from_name { | |
| my ($name) = @_; | |
| return $1 if $name =~ $VER; | |
| return undef; | |
| } | |
| sub normalize_product { | |
| my ($prod) = @_; | |
| my $p = _nfk_clean($prod); | |
| # remove arch/locale/noise tails | |
| $p =~ s/$NOISE.*$//i; | |
| $p =~ s/$ARCH//gi; | |
| $p =~ s/$LOCALE//gi; | |
| # remove parenthetical blocks that only contain metadata | |
| $p =~ s/\((?:[^)]*?(?:$ARCH|$LOCALE|kb\d+|build\s*\d+)[^)]*)\)//gi; | |
| # remove apparent version tokens | |
| $p =~ s/$VER//gi; | |
| # collapse and trim | |
| $p =~ s/[^a-z0-9]+/ /g; | |
| $p =~ s/\s+/ /g; | |
| $p =~ s/^\s+|\s+$//g; | |
| return $p; | |
| } | |
| # --- CSV IO --- | |
| my $csv = Text::CSV_XS->new({ binary => 1, auto_diag => 1 }); | |
| binmode STDIN, ":utf8"; | |
| binmode STDOUT, ":utf8"; | |
| # read header | |
| my $hdr = $csv->getline(*STDIN) or die "Expected header line"; | |
| $csv->column_names(@$hdr); | |
| my @out_cols = qw(publisher product version publisher_norm product_norm version_norm group_key); | |
| my $out = Text::CSV_XS->new({ binary => 1, eol => "\n" }); | |
| $out->print(*STDOUT, \@out_cols); | |
| while (my $row = $csv->getline_hr(*STDIN)) { | |
| my ($publisher, $product, $version) = @{$row}{qw/publisher product version/}; | |
| my $publisher_norm = normalize_publisher($publisher); | |
| my $product_norm = normalize_product($product); | |
| my $version_norm = $version // extract_version_from_name($product) // ""; | |
| my $group_key = ($publisher_norm || "")."::".($product_norm || ""); | |
| $out->print(*STDOUT, [$publisher, $product, ($version // ""), $publisher_norm, $product_norm, $version_norm, $group_key]); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Collect a Splunk-style software inventory on a Windows host (agent-friendly). | |
| .DESCRIPTION | |
| - Enumerates installed applications (registry ARP keys), package managers (winget/choco/Get-Package), | |
| MSI product codes, installed updates, services, scheduled tasks, listening ports, file hashes (optional), | |
| and Splunk Universal Forwarder presence/status. | |
| - Produces JSON and CSV outputs in the current directory. | |
| - Optionally posts the JSON to a Splunk HEC endpoint (set $EnableHEC = $true and supply $HECUrl/$HECToken). | |
| - Avoids Win32_Product because it can trigger Windows Installer repairs; uses registry + package managers instead. | |
| - Designed to be run locally (or executed from a Splunk scripted input). | |
| .NOTES | |
| - Run elevated (Admin) for full data; some items (listening ports, local groups, BitLocker) require elevation. | |
| - This is a local inventory collector only — it does not perform active vulnerability checks. | |
| #> | |
| param( | |
| [switch]$ComputeHashes = $false, | |
| [int]$MaxHashFiles = 200, | |
| [string]$OutputPrefix = "splunk_inventory", | |
| [switch]$EnableHEC = $false, | |
| [string]$HECUrl = "", # e.g. https://splunk-collector:8088/services/collector/event/1.0 | |
| [string]$HECToken = "" # Splunk HEC token if posting | |
| ) | |
| function Ensure-Elevated { | |
| if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { | |
| Write-Warning "This script is not running elevated. Some data will be missing." | |
| } | |
| } | |
| # Utility safe wrapper | |
| function Safe-Run { | |
| param($ScriptBlock) | |
| try { & $ScriptBlock } catch { Write-Verbose "Error running block: $_"; return $null } | |
| } | |
| Ensure-Elevated | |
| $now = (Get-Date).ToString("yyyyMMdd_HHmmss") | |
| $jsonFile = "{0}_{1}.json" -f $OutputPrefix, $now | |
| $csvFile = "{0}_{1}.csv" -f $OutputPrefix, $now | |
| # ---- Basic host metadata ---- | |
| $hostMeta = [ordered]@{ | |
| ComputerName = $env:COMPUTERNAME | |
| Hostname = ([System.Net.Dns]::GetHostName()) -as [string] | |
| FQDN = try { ([System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName) } catch { $null } | |
| CollectedAt = (Get-Date).ToString("o") | |
| CollectedBy = "$env:USERNAME@$env:COMPUTERNAME" | |
| OS = try { (Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -First 1 Caption, Version, BuildNumber, OSArchitecture) } catch { $null } | |
| UFInstalled = $false | |
| UFServiceStatus = $null | |
| } | |
| # ---- Detect Splunk Universal Forwarder presence ---- | |
| $ufPaths = @( | |
| "C:\Program Files\SplunkUniversalForwarder", | |
| "C:\Program Files\Splunk\UniversalForwarder", | |
| "C:\Program Files\Splunk\bin", | |
| "C:\Program Files (x86)\SplunkUniversalForwarder" | |
| ) | |
| foreach ($p in $ufPaths) { | |
| if (Test-Path $p) { $hostMeta.UFInstalled = $true; break } | |
| } | |
| # check service | |
| try { | |
| $svc = Get-Service -Name "SplunkForwarder","splunkforwarder","SplunkForwarder" -ErrorAction SilentlyContinue | Select-Object -First 1 Name,Status | |
| if ($svc) { $hostMeta.UFInstalled = $true; $hostMeta.UFServiceStatus = $svc } | |
| } catch {} | |
| # ---- Helper: gather installed apps from registry ARP ---- | |
| function Get-InstalledAppsFromRegistry { | |
| $keys = @( | |
| "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", | |
| "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", | |
| "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" | |
| ) | |
| $apps = @() | |
| foreach ($k in $keys) { | |
| try { | |
| Get-ItemProperty -Path $k -ErrorAction SilentlyContinue | ForEach-Object { | |
| if ($_.DisplayName) { | |
| $apps += [PSCustomObject]@{ | |
| DisplayName = $_.DisplayName | |
| DisplayVersion = $_.DisplayVersion | |
| Publisher = $_.Publisher | |
| InstallDate = $_.InstallDate | |
| InstallLocation = $_.InstallLocation | |
| UninstallString = $_.UninstallString | |
| RegistryKey = $_.PSPath | |
| EstimatedSource = "ARP/Registry" | |
| MsiProductCode = ($_.PSChildName -match '^{.*}$') ? $_.PSChildName : $null | |
| } | |
| } | |
| } | |
| } catch { Write-Verbose "Registry read failed for $k: $_" } | |
| } | |
| # dedupe by DisplayName+Publisher | |
| $apps | Sort-Object DisplayName, Publisher -Unique | |
| } | |
| # ---- Additional package sources (winget, choco, Get-Package) ---- | |
| function Get-PackageManagerInventory { | |
| $pm = [ordered]@{} | |
| # winget | |
| try { | |
| $winget = & winget list --source winget 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $winget) { $pm.winget = ($winget -split "`n" | Where-Object { $_ -and ($_ -notmatch '^Name\s+Id') }) } | |
| } catch {} | |
| # chocolatey | |
| try { | |
| $choco = & choco list --localonly 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $choco) { $pm.choco = ($choco -split "`n" | Where-Object { $_ -and ($_ -notmatch '^\s*' ) }) } | |
| } catch {} | |
| # PowerShell Get-Package | |
| try { | |
| $g = Get-Package -ErrorAction SilentlyContinue | |
| if ($g) { $pm.GetPackage = $g | Select-Object Name, Version, ProviderName, Source } | |
| } catch {} | |
| return $pm | |
| } | |
| # ---- Installed updates/hotfixes ---- | |
| function Get-HotFixesSafe { | |
| try { Get-HotFix -ErrorAction Stop | Select-Object -Property Description, HotFixID, InstalledOn } catch { return $null } | |
| } | |
| # ---- MSI product codes (from ARP and registry GUIDs) ---- | |
| function Get-MSIProductsViaReg { | |
| $products = @() | |
| # HKLM\Software\Classes\Installer\Products and other places are tricky; ARP PSChildName often is product code | |
| $arp = Get-InstalledAppsFromRegistry | |
| foreach ($a in $arp) { | |
| if ($a.MsiProductCode) { | |
| $products += [PSCustomObject]@{ ProductCode = $a.MsiProductCode; Name = $a.DisplayName; Version = $a.DisplayVersion; Source = "ARP-MSI" } | |
| } | |
| } | |
| return $products | |
| } | |
| # ---- Services, listening ports, processes ---- | |
| function Get-HostRuntimeInfo { | |
| $s = Get-Service | Select-Object Name, DisplayName, Status, StartType, ServiceType | |
| $p = Get-Process | Select-Object Id, ProcessName, Path -ErrorAction SilentlyContinue | |
| $tcp = @() | |
| try { | |
| $tcp = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess | |
| $tcp = $tcp | ForEach-Object { | |
| $proc = $null | |
| try { $proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue } catch {} | |
| [PSCustomObject]@{ | |
| LocalAddress = $_.LocalAddress | |
| LocalPort = $_.LocalPort | |
| OwningProcess = $_.OwningProcess | |
| ProcessName = if ($proc) { $proc.ProcessName } else { $null } | |
| } | |
| } | |
| } catch { Write-Verbose "NetTCP failed: $_" } | |
| return [ordered]@{ Services = $s; Processes = $p; Listening = $tcp } | |
| } | |
| # ---- Scheduled tasks, local admins, SMB shares ---- | |
| function Get-ExtraInventory { | |
| $tasks = @(); $localAdmins = @(); $shares = @() | |
| try { $tasks = Get-ScheduledTask | Select-Object TaskName, TaskPath, State, Principal -ErrorAction SilentlyContinue } catch {} | |
| try { | |
| $localAdmins = Get-LocalGroupMember -Group "Administrators" -ErrorAction SilentlyContinue | Select-Object Name, ObjectClass | |
| } catch {} | |
| try { $shares = Get-SmbShare | Select-Object Name, Path, Description -ErrorAction SilentlyContinue } catch {} | |
| return [ordered]@{ Tasks = $tasks; LocalAdmins = $localAdmins; SMB = $shares } | |
| } | |
| # ---- Optional: compute file hashes for discovered install locations ---- | |
| function Compute-FileHashes { | |
| param($Paths, $Patterns = @('*.exe','*.dll'), $Max = 200) | |
| $hashes = @(); $count = 0 | |
| foreach ($p in $Paths) { | |
| if (-not (Test-Path $p)) { continue } | |
| foreach ($pat in $Patterns) { | |
| try { | |
| Get-ChildItem -Path $p -Filter $pat -Recurse -ErrorAction SilentlyContinue | ForEach-Object { | |
| if ($count -ge $Max) { return $hashes } | |
| try { | |
| $h = Get-FileHash -Path $_.FullName -Algorithm SHA256 | |
| $hashes += [PSCustomObject]@{ Path = $_.FullName; Size = $_.Length; LastWrite = $_.LastWriteTime; SHA256 = $h.Hash } | |
| $count++ | |
| } catch { Write-Verbose "Hash fail: $_" } | |
| } | |
| } catch { Write-Verbose "Enum fail $p: $_" } | |
| } | |
| } | |
| return $hashes | |
| } | |
| # ---- Main collection ---- | |
| Write-Host "Collecting installed applications (registry ARP)..." | |
| $apps = Get-InstalledAppsFromRegistry | |
| Write-Host "Collecting package manager inventories (winget/choco/Get-Package)..." | |
| $pkgManagers = Get-PackageManagerInventory | |
| Write-Host "Collecting installed hotfixes..." | |
| $hotfixes = Get-HotFixesSafe | |
| Write-Host "Collecting MSI product codes..." | |
| $msiProducts = Get-MSIProductsViaReg | |
| Write-Host "Collecting services/processes/listening ports..." | |
| $runtime = Get-HostRuntimeInfo | |
| Write-Host "Collecting scheduled tasks / local admins / shares..." | |
| $extras = Get-ExtraInventory | |
| # Build a list of install locations to consider hashing | |
| $installLocations = $apps | Where-Object { $_.InstallLocation } | Select-Object -ExpandProperty InstallLocation -Unique | |
| # add ProgramFiles | |
| try { | |
| $pf = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion" -Name ProgramFilesDir -ErrorAction SilentlyContinue).ProgramFilesDir | |
| if ($pf) { $installLocations += $pf } | |
| $pf86 = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion" -Name ProgramFilesDir -ErrorAction SilentlyContinue).ProgramFilesDir | |
| if ($pf86) { $installLocations += $pf86 } | |
| } catch {} | |
| $installLocations = $installLocations | Where-Object { $_ -and (Test-Path $_) } | Sort-Object -Unique | |
| $hashes = @() | |
| if ($ComputeHashes -and $installLocations.Count -gt 0) { | |
| Write-Host "Computing up to $MaxHashFiles file hashes (this may be slow)..." | |
| $hashes = Compute-FileHashes -Paths $installLocations -Patterns @('*.exe','*.dll') -Max $MaxHashFiles | |
| } | |
| # ---- Prepare final object ---- | |
| $report = [ordered]@{ | |
| Metadata = $hostMeta | |
| InstalledApplications = $apps | |
| PackageManagerInventory = $pkgManagers | |
| MSIProducts = $msiProducts | |
| HotFixes = $hotfixes | |
| Runtime = $runtime | |
| Extras = $extras | |
| InstallLocations = $installLocations | |
| FileHashes = $hashes | |
| } | |
| # ---- Write outputs ---- | |
| try { | |
| $report | ConvertTo-Json -Depth 8 | Out-File -FilePath $jsonFile -Encoding UTF8 | |
| Write-Host "JSON written to $jsonFile" | |
| } catch { Write-Warning "Failed to write JSON: $_" } | |
| # For CSV — flatten installed apps to a simple inventory CSV | |
| try { | |
| $apps | Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation, UninstallString | | |
| Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 | |
| Write-Host "CSV written to $csvFile" | |
| } catch { Write-Warning "Failed to write CSV: $_" } | |
| # ---- Optional: send to Splunk HEC ---- | |
| if ($EnableHEC) { | |
| if (-not ($HECUrl) -or -not ($HECToken)) { | |
| Write-Warning "HEC enabled but HECUrl or HECToken is empty. Skipping send." | |
| } else { | |
| Write-Host "Posting JSON to Splunk HEC..." | |
| try { | |
| $body = @{ | |
| time = [int][double]::Parse((Get-Date -UFormat %s)) | |
| host = $env:COMPUTERNAME | |
| source = "script:collect-splunk-inventory" | |
| sourcetype = "inventory:software:windows" | |
| event = (Get-Content -Path $jsonFile -Raw) | |
| } | ConvertTo-Json -Depth 5 | |
| $hdr = @{ "Authorization" = "Splunk $HECToken" } | |
| Invoke-RestMethod -Uri $HECUrl -Method Post -Headers $hdr -Body $body -ContentType "application/json" -ErrorAction Stop | |
| Write-Host "Posted to HEC: $HECUrl" | |
| } catch { Write-Warning "Failed to post to HEC: $_" } | |
| } | |
| } | |
| Write-Host "Done. Files: $jsonFile , $csvFile" | |
| Write-Host "Notes:" | |
| Write-Host " - This script collects the same sort of local inventory Splunk admins forward from UF/scripted inputs (registry, WMI, package managers). To match the depth of a centralized Splunk software inventory, deploy this as a scripted input or have the UF forward the generated JSON to your indexers. Splunk UF supports scripted inputs and arbitrary host-local scripts to gather inventory. :contentReference[oaicite:1]{index=1}" | |
| Write-Host " - Avoid using Win32_Product for inventory (it can cause MSI repairs). Use registry ARP keys + package managers + product codes. :contentReference[oaicite:2]{index=2}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment