Add local Windows Server inventory collection and validation; include core package

This commit is contained in:
2026-10-30 11:24:40 -07:00
parent 967b2bd46a
commit f65ade9fff
35 changed files with 2200 additions and 56 deletions

View File

@@ -0,0 +1,17 @@
function Get-SamsaraCertificates {
param($Result)
$collection = @{ stores = @() }
Invoke-SamsaraRead $Result 'Local machine certificate store list' {
$collection.stores = @(Get-ChildItem -LiteralPath 'Cert:\LocalMachine' -ErrorAction Stop)
}
foreach ($store in $collection.stores) {
Invoke-SamsaraRead $Result 'Local machine public certificate metadata' {
Get-ChildItem -LiteralPath $store.PSPath -ErrorAction Stop | ForEach-Object {
$Result.data += [pscustomobject]@{ subject = [string]$_.Subject; issuer = [string]$_.Issuer
expires = $_.NotAfter.ToUniversalTime().ToString('o'); thumbprint = [string]$_.Thumbprint
store = [string]$store.PSChildName }
}
}
}
$Result.limitations += 'Public LocalMachine certificate metadata only. Private keys, password-protected files, CurrentUser stores, application bindings and off-store certificates are not read.'
}

View File

@@ -0,0 +1,56 @@
#requires -Version 5.1
[CmdletBinding()]
param([ValidateSet('all','system','roles','services','network','tasks','software','storage','shares','certificates')][string]$Section = 'all')
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$WarningPreference = 'SilentlyContinue'
$InformationPreference = 'SilentlyContinue'
$env:PSModulePath = Join-Path $PSHOME 'Modules'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
[Console]::Error.WriteLine('This collector requires a local Windows Server execution host.')
exit 2
}
. (Join-Path $PSScriptRoot 'Inventory.Common.ps1')
foreach ($file in @('System','Roles','Services','Network','Tasks','Software','Storage','Certificates')) {
. (Join-Path $PSScriptRoot ('Get-Server' + $file + '.ps1'))
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
try {
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$script:SamsaraIsElevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} finally { $identity.Dispose() }
$collectors = [ordered]@{
system = { param($r) Get-SamsaraSystem $r }; roles = { param($r) Get-SamsaraRoles $r }
services = { param($r) Get-SamsaraServices $r }; network = { param($r) Get-SamsaraNetwork $r }
tasks = { param($r) Get-SamsaraTasks $r }; software = { param($r) Get-SamsaraSoftware $r }
storage = { param($r) Get-SamsaraStorage $r }; shares = { param($r) Get-SamsaraShares $r }
certificates = { param($r) Get-SamsaraCertificates $r }
}
if ($Section -ne 'all') {
$result = Invoke-SamsaraSection $Section $collectors[$Section]
ConvertTo-Json -InputObject $result -Depth 12 -Compress
exit 0
}
# Direct PowerShell use emits the same complete inventory envelope as the Python backend.
$inventory = [ordered]@{ roles=@(); services=@(); network=@(); tasks=@(); software=@(); storage=@(); certificates=@() }
$checks = [ordered]@{}
$collectedAt = Get-SamsaraTimestamp
$server = $null
foreach ($name in $collectors.Keys) {
$result = Invoke-SamsaraSection $name $collectors[$name]
$checks[$name] = ConvertTo-SamsaraCheck $result
if ($name -eq 'system') {
$server = $result.data
if ([int]$server.product_type -notin @(2,3) -or $server.os_version -notin @('2016','2019','2022','2025')) {
[Console]::Error.WriteLine('Unable to verify a supported Windows Server identity. No inventory emitted.')
exit 2
}
} elseif ($name -eq 'shares') { $inventory.storage += @($result.data) }
else { $inventory[$name] = @($result.data) }
}
ConvertTo-Json -InputObject ([ordered]@{ schema_version='1.0'; synthetic=$false; collected_at=$collectedAt
server=$server; checks=$checks; inventory=$inventory }) -Depth 14 -Compress
exit 0

View File

@@ -0,0 +1,16 @@
function Get-SamsaraNetwork {
param($Result)
$processes = @{}
Invoke-SamsaraRead $Result 'Process names' {
Get-Process -ErrorAction Stop | ForEach-Object { $processes[[int]$_.Id] = [string]$_.ProcessName }
}
Invoke-SamsaraRead $Result 'TCP connections' {
$Result.data = @(Get-NetTCPConnection -ErrorAction Stop | ForEach-Object {
$processName = if ($processes.ContainsKey([int]$_.OwningProcess)) { $processes[[int]$_.OwningProcess] } else { 'Unavailable or process exited' }
[pscustomobject]@{ state = [string]$_.State; local_address = [string]$_.LocalAddress; local_port = [int]$_.LocalPort
remote_address = [string]$_.RemoteAddress; remote_port = [int]$_.RemotePort
pid = [int]$_.OwningProcess; process = $processName; direction = 'Unknown' }
})
}
$Result.limitations += 'Point-in-time TCP only. Connection direction, historical activity, UDP and other network compartments are unverified. Process names may race process exit or be hidden.'
}

View File

@@ -0,0 +1,10 @@
function Get-SamsaraRoles {
param($Result)
Invoke-SamsaraRead $Result 'Server roles and features' {
$Result.data = @(Get-WindowsFeature -ErrorAction Stop | Where-Object { $_.Installed } | ForEach-Object {
[pscustomobject]@{ name = [string]$_.Name; display_name = [string]$_.DisplayName
state = [string]$_.InstallState; feature_type = [string]$_.FeatureType }
})
}
$Result.limitations += 'Installed roles/features only; installed state does not prove current use. Requires local ServerManager availability.'
}

View File

@@ -0,0 +1,11 @@
function Get-SamsaraServices {
param($Result)
Invoke-SamsaraRead $Result 'Windows services' {
# Do not query PathName: it can include secret-bearing command arguments.
$Result.data = @(Get-CimInstance -ClassName Win32_Service -Property Name,DisplayName,State,StartMode,StartName,ProcessId -ErrorAction Stop | ForEach-Object {
[pscustomobject]@{ name = [string]$_.Name; display_name = [string]$_.DisplayName
state = [string]$_.State; startup = [string]$_.StartMode; account = [string]$_.StartName; pid = [int]$_.ProcessId }
})
}
$Result.limitations += 'All visible service states are inventoried, including running services. Executable command lines and credentials are not collected; service accounts do not prove consumers.'
}

View File

@@ -0,0 +1,37 @@
function Read-SamsaraSoftwareEntries {
param($Result, $Uninstall, [string]$View)
foreach ($name in $Uninstall.GetSubKeyNames()) {
$entry = $null
try {
$entry = $Uninstall.OpenSubKey($name, $false)
if ($null -eq $entry) { throw 'Software entry not readable.' }
$display = [string]$entry.GetValue('DisplayName')
if ($display) {
$Result.data += [pscustomobject]@{ name = $display; publisher = [string]$entry.GetValue('Publisher')
version = [string]$entry.GetValue('DisplayVersion'); installation_date = [string]$entry.GetValue('InstallDate')
registry_view = $View }
}
} catch { Add-SamsaraError $Result $_ 'Machine software entry' }
finally { if ($entry) { $entry.Dispose() } }
}
}
function Get-SamsaraSoftware {
param($Result)
# Explicit registry views avoid 32-bit redirection and require no installer queries.
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
Invoke-SamsaraRead $Result ('Machine software registry ' + [string]$view) {
$hive = $null; $uninstall = $null
try {
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
$uninstall = $hive.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', $false)
if ($null -eq $uninstall) { return }
Read-SamsaraSoftwareEntries $Result $uninstall ([string]$view)
} finally {
if ($uninstall) { $uninstall.Dispose() }
if ($hive) { $hive.Dispose() }
}
}
}
$Result.limitations += 'Machine uninstall registrations in both registry views only. Per-user, portable, container and unregistered software may be absent. Install dates are raw registry values and may be empty. No installer consistency checks are triggered.'
}

View File

@@ -0,0 +1,23 @@
function Get-SamsaraStorage {
param($Result)
Invoke-SamsaraRead $Result 'Local volumes' {
$Result.data = @(Get-Volume -ErrorAction Stop | ForEach-Object {
[pscustomobject]@{ type = 'Volume'; name = [string]$_.FileSystemLabel
path = if ($_.DriveLetter) { [string]$_.DriveLetter + ':\' } else { [string]$_.Path }
filesystem = [string]$_.FileSystem; capacity_gib = [math]::Round(([double]$_.Size / 1GB), 2)
free_gib = [math]::Round(([double]$_.SizeRemaining / 1GB), 2) }
})
}
$Result.limitations += 'Local volume metadata only. Share contents, mounted remote storage, storage-array dependencies, snapshots and backups are not inspected.'
}
function Get-SamsaraShares {
param($Result)
Invoke-SamsaraRead $Result 'SMB shares' {
$Result.data = @(Get-SmbShare -ErrorAction Stop | ForEach-Object {
[pscustomobject]@{ type = 'SMB Share'; name = [string]$_.Name; path = [string]$_.Path
capacity_gib = $null; free_gib = $null; special = [bool]$_.Special }
})
}
$Result.limitations += 'Configured SMB shares, including administrative shares. No share contents, ACLs, credentials, sessions or consumers queried. Existence does not establish use.'
}

View File

@@ -0,0 +1,34 @@
function Get-SamsaraSystem {
param($Result)
$Result.data = [ordered]@{
hostname = [Environment]::MachineName; platform = 'windows'
collection_source = 'Local Windows read-only collection'
operating_system = 'Not collected'; os_version = 'Not collected'; os_build = 'Not collected'
product_type = 0; last_boot = 'Not collected'; ip_addresses = @(); network_adapters = @()
domain = 'Not collected'; domain_joined = $null
}
Invoke-SamsaraRead $Result 'OS identity' {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Caption,Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop
$years = @{ '14393' = '2016'; '17763' = '2019'; '20348' = '2022'; '26100' = '2025' }
if ([int]$os.ProductType -notin @(2,3) -or -not $years.ContainsKey([string]$os.BuildNumber)) {
throw 'Unsupported Windows release or workstation edition.'
}
$Result.data.operating_system = [string]$os.Caption
$Result.data.os_version = $years[[string]$os.BuildNumber]
$Result.data.os_build = [string]$os.BuildNumber
$Result.data.kernel_version = [string]$os.Version
$Result.data.product_type = [int]$os.ProductType
$Result.data.last_boot = $os.LastBootUpTime.ToUniversalTime().ToString('o')
}
Invoke-SamsaraRead $Result 'Computer domain membership' {
$computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain,PartOfDomain -ErrorAction Stop
$Result.data.domain = [string]$computer.Domain
$Result.data.domain_joined = [bool]$computer.PartOfDomain
}
Invoke-SamsaraRead $Result 'Network adapter configuration' {
$adapters = @(Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled = True' -Property Description,IPAddress -ErrorAction Stop)
$Result.data.network_adapters = @($adapters | ForEach-Object { [string]$_.Description })
$Result.data.ip_addresses = @($adapters | ForEach-Object { foreach ($address in $_.IPAddress) { [string]$address } })
}
$Result.limitations += 'Identity and addresses are local observations. No DNS probes or Active Directory enrichment were performed.'
}

View File

@@ -0,0 +1,20 @@
function Get-SamsaraTasks {
param($Result)
Invoke-SamsaraRead $Result 'Scheduled tasks' {
$Result.data = @(Get-ScheduledTask -ErrorAction Stop | ForEach-Object {
$task = $_
$triggers = @($task.Triggers | ForEach-Object {
$type = if ($_.CimClass) { [string]$_.CimClass.CimClassName } else { 'Unknown trigger' }
# Select only known scheduler metadata; never serialize entire CIM objects.
$repetition = if ($_.Repetition) { [string]$_.Repetition.Interval } else { '' }
'{0}; start={1}; enabled={2}; daysInterval={3}; weeksInterval={4}; daysOfWeek={5}; repeat={6}' -f $type, $_.StartBoundary, $_.Enabled, $_.DaysInterval, $_.WeeksInterval, $_.DaysOfWeek, $repetition
})
$executables = @($task.Actions | ForEach-Object { Get-SamsaraSafeExecutable ([string]$_.Execute) })
[pscustomobject]@{ name = [string]$task.TaskName; path = [string]$task.TaskPath
enabled = [bool]$task.Settings.Enabled; state = [string]$task.State
account = [string]$task.Principal.UserId; executables = ($executables -join '; ')
triggers = if ($triggers.Count) { $triggers -join ' | ' } else { 'No trigger metadata visible; manual or external invocation remains possible' } }
})
}
$Result.limitations += 'Visible Task Scheduler registrations only. Arguments, inline scripts, task XML, event queries and execution history are omitted. Non-executable actions may lack an executable name. Trigger fields are descriptive, not a prediction of execution.'
}

View File

@@ -0,0 +1,66 @@
# Shared helpers, compatible with Windows PowerShell 5.1. No raw errors are serialized.
function Get-SamsaraTimestamp { [DateTime]::UtcNow.ToString('o') }
function Add-SamsaraError {
param($Result, $Record, [string]$Scope)
$code = 'collection_failed'
if ($Record.CategoryInfo.Category -in @('PermissionDenied', 'SecurityError') -or
$Record.Exception -is [UnauthorizedAccessException] -or
$Record.Exception.HResult -eq -2147024891) { $code = 'access_denied' }
elseif ($Record.Exception -is [System.Management.Automation.CommandNotFoundException]) { $code = 'command_unavailable' }
$message = switch ($code) {
'access_denied' { 'Current account cannot read this scope.' }
'command_unavailable' { 'Required local inventory command is unavailable.' }
default { 'Local query could not complete; raw diagnostics are excluded.' }
}
$Result.errors += [pscustomobject]@{ code = $code; scope = $Scope; message = $message }
if ($code -eq 'access_denied') { $Result.permission_limitations += $Scope + ': read access denied.' }
}
function Invoke-SamsaraRead {
param($Result, [string]$Scope, [scriptblock]$Query)
$Result.attempted_subchecks++
try {
& $Query
$Result.successful_subchecks++
} catch { Add-SamsaraError $Result $_ $Scope }
}
function Get-SamsaraSafeExecutable {
param([string]$Value)
# Task action arguments, scripts and environment values are deliberately omitted.
$name = [IO.Path]::GetFileName(($Value -replace '\\', '/'))
if ($name -match '^[A-Za-z0-9_. -]+\.(exe|com|cmd|bat|ps1|vbs|js|wsf|msc)$') { return $name }
return '[executable name omitted]'
}
function Invoke-SamsaraSection {
param([string]$Name, [scriptblock]$Collector)
$result = [ordered]@{
section = $Name; status = 'complete'; message = 'Local read-only query completed within the documented scope.'
started_at = Get-SamsaraTimestamp; completed_at = $null
errors = @(); permission_limitations = @(); limitations = @()
attempted_subchecks = 0; successful_subchecks = 0; data = @()
}
try { & $Collector $result } catch { Add-SamsaraError $result $_ $Name }
if ($result.errors.Count -gt 0) {
$result.status = if ($result.successful_subchecks -gt 0) { 'partial' } else { 'failed' }
$result.message = 'One or more local queries failed; see sanitized errors and limitations.'
}
if (-not $script:SamsaraIsElevated -and $Name -in @('roles','services','network','tasks','shares','storage','certificates')) {
$result.permission_limitations += 'Not elevated: protected objects may be hidden by the current account permissions.'
if ($result.status -eq 'complete') { $result.status = 'partial' }
}
$result.completed_at = Get-SamsaraTimestamp
[pscustomobject]$result
}
function ConvertTo-SamsaraCheck {
param($Section)
[ordered]@{
status = $Section.status; message = $Section.message
started_at = $Section.started_at; completed_at = $Section.completed_at
errors = @($Section.errors); permission_limitations = @($Section.permission_limitations)
limitations = @($Section.limitations)
}
}

View File

@@ -1,5 +1,27 @@
# Windows collection engine (planned)
# Local Windows Server inventory
Phase 1 does not execute PowerShell. Phase 2 will introduce Get-ServerInventory.ps1 and independent Get-ServerServices.ps1, Get-ServerNetwork.ps1, Get-ServerTasks.ps1, Get-ServerSoftware.ps1, Get-ServerStorage.ps1 and Get-ServerCertificates.ps1 scripts, plus optional AD enrichment.
Targets: Windows Server 2016/2019/2022/2025, Windows PowerShell 5.1, 64-bit execution. **Real Windows host validation is still pending in this Linux development workspace.** No remoting or credentials are used.
Use existing authorized PowerShell remoting from Windows. Never configure WinRM/SSH, restart services, install software, collect private keys or use Win32_Product. Inventory software via registry uninstall keys (both registry views). Return per-check failures without fabricating results. Least privilege is required; restricted inventory may produce partial results. Task/service command arguments need redaction before export.
`Get-ServerInventory.ps1` is the fixed JSON driver. With no arguments it emits a full v1 inventory envelope. `-Section system|roles|services|network|tasks|software|storage|shares|certificates` emits an internal per-section JSON result. The Python collector invokes these independently to enforce time/output limits while preserving other sections after failures.
| Module | Local read-only source |
| --- | --- |
| Get-ServerSystem.ps1 | CIM OS/product type/build, boot, domain membership, adapter configuration |
| Get-ServerRoles.ps1 | Installed Get-WindowsFeature entries |
| Get-ServerServices.ps1 | Win32_Service metadata and service identities; no PathName/arguments |
| Get-ServerNetwork.ps1 | Get-NetTCPConnection and Get-Process names/IDs |
| Get-ServerTasks.ps1 | Get-ScheduledTask visible definitions, accounts, safe executable basenames, selected triggers |
| Get-ServerSoftware.ps1 | HKLM uninstall registrations using explicit Registry32/Registry64 read-only handles |
| Get-ServerStorage.ps1 | Get-Volume and Get-SmbShare; volumes/shares share the existing storage inventory category |
| Get-ServerCertificates.ps1 | Public metadata in accessible LocalMachine certificate stores |
| Inventory.Common.ps1 | Section timing, safe errors and permission limitations |
No Win32_Product, installer operations, private-key export, task execution, service changes, registry writes or remoting setup. No execution-policy bypass; use approved script signing/distribution if enterprise policy blocks scripts. Non-elevated sensitive checks are conservatively partial even if their visible query succeeds. Elevation is never attempted automatically. Domain Administrator privileges are not required; assess with the least-privileged authorized account and treat omitted evidence explicitly.
The Windows Server gate uses CIM ProductType 2/3 plus the release build map; Windows 10/11 are not Server targets even where their kernel builds overlap. The gate is enforced by Python and by full-envelope PowerShell use. `-Section system` can return failed/partial identity details for diagnostics, but Python will not accept an unverified identity as an assessment.
Direct full-script use has no Python timeout/cancellation supervisor; use the Python CLI or GUI for bounded execution. Both paths honor local script execution policy. Every script emits only selected public/administrative metadata, never raw CIM objects, exception text, task XML, task arguments or service command lines.
See README.md for exact GUI, headless and real-host validation instructions. `tests/powershell/Test-Collectors.ps1` parses scripts and mocks their data sources; this is synthetic testing, not real host validation.
Implementation references: [Get-WindowsFeature](https://learn.microsoft.com/en-us/powershell/module/servermanager/get-windowsfeature), [Win32_Service](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-service), [Get-NetTCPConnection](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-nettcpconnection), [Get-ScheduledTask](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/get-scheduledtask), [registry software inventory](https://learn.microsoft.com/en-us/powershell/scripting/samples/working-with-software-installations), [certificate provider](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/about/about_certificate_provider).