<# .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}"