Add local Windows Server inventory collection and validation; include core package
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -22,6 +22,9 @@ reports/
|
|||||||
# Local assessment artifacts (may contain infrastructure details)
|
# Local assessment artifacts (may contain infrastructure details)
|
||||||
*-assessment.*
|
*-assessment.*
|
||||||
*-local-*.json
|
*-local-*.json
|
||||||
core
|
/core
|
||||||
core.*
|
/core.*
|
||||||
*.bundle
|
*.bundle
|
||||||
|
|
||||||
|
# Direct PowerShell inventory export
|
||||||
|
windows-inventory*.json
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -29,9 +29,85 @@ Explicit profiles include Windows Server 2016, 2019, 2022 and 2025; RHEL 7 (lega
|
|||||||
|
|
||||||
Choose **Saved inventory file** to analyze existing JSON; the recorded OS is displayed without probing another computer. **Windows Server sample** and **RHEL sample** provide clearly labeled synthetic examples. No synthetic results are loaded into history automatically.
|
Choose **Saved inventory file** to analyze existing JSON; the recorded OS is displayed without probing another computer. **Windows Server sample** and **RHEL sample** provide clearly labeled synthetic examples. No synthetic results are loaded into history automatically.
|
||||||
|
|
||||||
## Current availability
|
## Windows Server assessments: local collection
|
||||||
|
|
||||||
Local Linux collection and offline reporting are implemented. Live Windows and remote collection are not yet available; the UI reports that limitation explicitly. Windows desktops can use saved inventories and samples, but Windows desktop execution still requires platform validation. No server is contacted automatically.
|
Local Windows collection is implemented for the Server 2016/2019/2022/2025 release builds. **Acceptance is pending a successful real inventory on an authorized Windows Server.** Development was performed on Linux, without a PowerShell runtime. Synthetic tests are not Windows compatibility certification.
|
||||||
|
|
||||||
|
Use an existing authorized account on the Windows Server being assessed. The collector runs locally; a Windows 10/11 desktop cannot scan another server in this phase. No WinRM, SSH, agent installation, domain administrator account or saved credentials are required. Local elevation is never automatic. Start with least privilege; denied/hidden scopes become failed or partial checks. If your organization authorizes a local elevated token for broader inventory, open that session yourself. AppLocker/WDAC, constrained language and script signing/execution policies may block collection; follow your organization's approved signing/distribution process. The app does not bypass policy.
|
||||||
|
|
||||||
|
### GUI on a compatible Windows desktop environment
|
||||||
|
|
||||||
|
Use 64-bit Python 3.11+ already available on the authorized execution host. Keep `powershell/`, `schemas/` and `samsara/` together in the checkout. From its root in Command Prompt:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
py -3 -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
.venv\Scripts\python.exe -m samsara.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose **Analyze → This computer → Auto-detect → Analyze**, or select the matching Windows Server release. Windows PowerShell 5.1 at the Windows system path performs the local inventory in the background. Review the Summary and each tab, especially **Collection checks**, then save HTML/JSON. The history and report source say **Local Windows read-only collection**. Cancel terminates and reaps the active PowerShell process and keeps the previous assessment. Each section has a 45-second limit, overall execution a 300-second budget, and output a 10 MiB limit; timed-out sections remain visible as incomplete.
|
||||||
|
|
||||||
|
Qt desktop compatibility is separate from collector compatibility. Current Qt 6.12 lists Windows 10 1809+ and Windows 11 as supported desktops; do **not** assume a Qt GUI will run on Server 2016 or Server Core. Use headless collection on those hosts and analyze the resulting file on a compatible desktop. See [Qt's Windows platform requirements](https://doc.qt.io/qt-6/windows.html).
|
||||||
|
|
||||||
|
### Headless Windows collection (including Server Core / Server 2016)
|
||||||
|
|
||||||
|
With existing 64-bit Python 3.11+, no PySide6 installation or graphical desktop is required. From the repository root in Command Prompt:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
py -3 -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install "jsonschema>=4.21,<5"
|
||||||
|
mkdir reports
|
||||||
|
.venv\Scripts\python.exe -m samsara.collectors.windows --output reports\windows-inventory.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a new filename; the CLI will not overwrite an existing file. The CLI uses the same supervised collector, server identity gate and schema validation as the desktop. Copy the JSON manually to the reporting desktop and choose **Saved inventory file**, then Analyze. Imports retain timestamps, safe error codes and the presence of permission limitations, remove raw diagnostics/source claims, and recalculate risk. No remote transport is implemented.
|
||||||
|
|
||||||
|
If Python is unavailable, the PowerShell-only driver can produce an inventory with no Python/Qt dependency, subject to your existing script policy. From a local Windows PowerShell 5.1 prompt in the checkout root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$json = & .\powershell\Get-ServerInventory.ps1
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Local inventory collection failed.' }
|
||||||
|
[IO.File]::WriteAllText((Join-Path $PWD 'windows-inventory.json'), $json, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose a new destination in the final line to retain earlier evidence. This direct path is **not supervised by Python's time/output/cancellation bounds**; prefer the Python CLI where available. Import validates the returned schema before desktop analysis.
|
||||||
|
|
||||||
|
### Coverage and missing evidence
|
||||||
|
|
||||||
|
| Section | Collected | Known limitations |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| System | OS caption, Server release/build, boot, IPs/adapters, domain membership | Requires readable CIM identity; AD enrichment not performed |
|
||||||
|
| Roles | Installed roles/features, state and feature type | Local ServerManager/module access needed; installation does not prove use |
|
||||||
|
| Services | All visible states, startup type, account, PID | Includes running services; command lines and secrets deliberately excluded |
|
||||||
|
| Networking | TCP listeners/connections, endpoints, PID/name | Snapshot only; direction, historical use, UDP and protected processes may be unknown |
|
||||||
|
| Tasks | Visible task names/paths, state, enabled setting, principal, safe executable basename and selected trigger fields | No arguments, XML, inline scripts, event query details or execution history; visibility depends on account |
|
||||||
|
| Software | Machine uninstall registrations in both registry views | No Win32_Product; per-user, portable, container and unregistered applications not covered |
|
||||||
|
| Storage / shares | Local volumes, capacity/free space, configured SMB share paths including administrative shares | No contents/ACLs/consumers, backups or external-array relationships |
|
||||||
|
| Certificates | Subject, issuer, expiry, thumbprint and machine store | No private keys, user stores, file-based certificates or application bindings |
|
||||||
|
|
||||||
|
Each check includes status, UTC start/end times, sanitized errors, permission limitations and scope limitations. `complete` means the stated query scope succeeded; it does not prove complete retirement evidence. Sensitive checks run non-elevated are conservatively partial. Some Windows providers can silently filter objects by ACL; even elevated results need administrator review. Partial accessible data are retained. Invalid/unsupported Server identity stops the assessment; failed other checks never receive demo replacements.
|
||||||
|
|
||||||
|
### Required real-host validation (still outstanding)
|
||||||
|
|
||||||
|
On a supported, authorized Server, use the headless environment above and a new output directory:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
.venv\Scripts\python.exe -m unittest discover -s tests -v
|
||||||
|
.venv\Scripts\python.exe tools\validate_windows_host.py --output-dir reports\windows-validation-01
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command performs **real local collection**, validates the inventory schema and writes `windows-assessment.json`, `windows-assessment.html` and `validation-summary.json`. Review the full report privately. The summary omits hostnames, service accounts, IPs, paths and certificate identifiers and is suitable for sharing validation status after review. It reports per-section status/error codes and row counts; it does not claim retirement approval. Run under the intended least-privilege account and, only if authorized, repeat in an elevated session with a different directory to assess visibility differences.
|
||||||
|
|
||||||
|
| Release | Python synthetic fixtures / orchestration / UI | Native PowerShell runtime | Actual host inventory |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Server 2016 | Passed | Not run here | Pending |
|
||||||
|
| Server 2019 | Passed | Not run here | Pending |
|
||||||
|
| Server 2022 | Passed | Not run here | Pending |
|
||||||
|
| Server 2025 | Passed | Not run here | Pending |
|
||||||
|
|
||||||
|
The implementation is **not considered complete** until real-host evidence is obtained. Do not describe the pending rows as tested Windows support. Packaged Windows installers are also unvalidated.
|
||||||
|
|
||||||
|
## Linux collection and common safety
|
||||||
|
|
||||||
The Linux collector observes OS/kernel/boot information, addresses, loaded systemd services and timers (including available calendar/next-run metadata), readable system cron entries, TCP snapshots, RPM or dpkg software and mounted filesystems. Missing commands or restricted permissions produce visible failed checks. Service accounts, user crontabs/timers, anacron/periodic-script configuration, certificate locations, share exports, disk capacity/free space and directory integration remain incomplete or uncollected. Results reflect the execution process's namespace and permissions. Remote mounts are not accessed.
|
The Linux collector observes OS/kernel/boot information, addresses, loaded systemd services and timers (including available calendar/next-run metadata), readable system cron entries, TCP snapshots, RPM or dpkg software and mounted filesystems. Missing commands or restricted permissions produce visible failed checks. Service accounts, user crontabs/timers, anacron/periodic-script configuration, certificate locations, share exports, disk capacity/free space and directory integration remain incomplete or uncollected. Results reflect the execution process's namespace and permissions. Remote mounts are not accessed.
|
||||||
|
|
||||||
@@ -57,4 +133,4 @@ Use a new filename; existing files are not overwritten. Open the result using **
|
|||||||
python -m unittest discover -s tests -v
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Qt tests run offscreen. See [platform support](docs/PLATFORMS.md), [architecture and plan](docs/IMPLEMENTATION_PLAN.md) and [developer guide](docs/DEVELOPMENT.md). Run from source; Windows packaging and production collector validation remain pending.
|
Qt tests run offscreen. See [platform support](docs/PLATFORMS.md), [architecture and plan](docs/IMPLEMENTATION_PLAN.md) and [developer guide](docs/DEVELOPMENT.md). Run from source; Windows packaging and real-host collector acceptance remain pending. PowerShell AST/synthetic tests run when a native runtime is available and are explicitly skipped otherwise. On this Linux development host, 47 tests were discovered: 46 passed and the native PowerShell test was skipped; no real Windows inventory has been collected.
|
||||||
|
|||||||
@@ -35,3 +35,17 @@ Summary presentation is derived by analyzers/summary.py without altering assessm
|
|||||||
## Application branding
|
## Application branding
|
||||||
|
|
||||||
The supplied Samsara JPEG is bundled unchanged as `samsara/assets/samsara-logo.jpg`. `ui/branding.py` provides the shared Qt application/window icon and Windows taskbar application identity. The sidebar uses the same asset. Future packaged builds must include this asset; the original `App Logo` folder is not required at runtime. Icon loading was checked at 16–256 pixel sizes and the sidebar preview was reviewed offscreen. Native desktop/taskbar appearance still needs platform verification.
|
The supplied Samsara JPEG is bundled unchanged as `samsara/assets/samsara-logo.jpg`. `ui/branding.py` provides the shared Qt application/window icon and Windows taskbar application identity. The sidebar uses the same asset. Future packaged builds must include this asset; the original `App Logo` folder is not required at runtime. Icon loading was checked at 16–256 pixel sizes and the sidebar preview was reviewed offscreen. Native desktop/taskbar appearance still needs platform verification.
|
||||||
|
|
||||||
|
## Local Windows backend
|
||||||
|
|
||||||
|
`collectors/windows.py` implements supervised local execution; `powershell/Get-ServerInventory.ps1` dispatches independent section functions. Windows desktop detection routes through the verified CIM identity in the Windows backend, rather than inferring a Server edition from `platform.win32_ver()`. Explicit profiles are checked after the critical identity query and before other inventory work. No Windows module is executed on Linux.
|
||||||
|
|
||||||
|
The executable path comes from Windows GetSystemDirectoryW, with 64-bit Python required, never PATH/SystemRoot search. Argument lists use `-NoLogo -NoProfile -NonInteractive -File` with a trusted repository script and a fixed section enum. No shell, `-Command`, credential parameters, user-supplied command text, automatic elevation or execution-policy bypass. PSModulePath is limited to inbox modules. Native stdout is UTF-8 JSON; stderr/raw provider exceptions are excluded. Temporary stdout is bounded; each process is polled for cancellation/time/output limits and killed/reaped on exit paths. Provider services are not stopped or reconfigured.
|
||||||
|
|
||||||
|
Per-section default timeout is 45 seconds; overall budget is 300 seconds; per-section and accepted total inventory limits are 10 MiB. Timed-out/invalid sections are explicitly failed, remaining unattempted checks are not_collected when the overall budget expires, and successful observations survive. Cancellation rejects the new result and retains the previous UI assessment. The direct PowerShell `all` path emits the complete schema but has no Python timeout supervisor.
|
||||||
|
|
||||||
|
OS ProductType 2/3 plus build mapping gates Server 2016/2019/2022/2025. Workstation identities and unknown builds are rejected. A partial system check can be accepted only if the OS identity itself is verified; domain/IP failures remain explicit. Each section has start/end UTC timestamps, status, safe error codes/messages, access limitations and scope limitations. Complete means successful documented scope, not comprehensive dependency evidence. Non-elevated sensitive queries are conservatively partial. ACL-based silent filtering remains a manual verification concern.
|
||||||
|
|
||||||
|
`models/inventory.py` now owns the shared v1 validator and field allowlists. Metadata fields are additive and old demo/Linux results remain valid. Live Windows results are projected to safe fields; raw CIM instances are never serialized. Imported errors retain recognized categorical codes and timestamps but discard arbitrary diagnostic/permission text. Imports cannot assert the trusted local source label. Registry software covers both read-only machine uninstall views; services omit PathName; tasks emit safe action basenames and selected triggers rather than Arguments/XML; certificates never access private-key properties. Reports include all check statuses, timestamps and limitations.
|
||||||
|
|
||||||
|
Fixtures for all four Server releases live in tests/fixtures/windows. Python tests cover contract/identity/array handling, partial permission results, invalid output, process cancellation/reaping, timeouts, output bounds, secret-field exclusion, imports and the unchanged GUI/history/report flow. `tests/powershell/Test-Collectors.ps1` uses the native PowerShell AST parser and synthetic function/registry objects to test the scripts without querying the host; it runs automatically where PowerShell is present. This test is skipped in the current Linux environment. **No real Windows inventory has yet been produced here; do not mark the Windows milestone complete.** Use the README's explicit real-host validation command and review private reports before recording compatibility.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Samsara is a Windows Server and enterprise Linux retirement assessment tool. Des
|
|||||||
- `samsara/models`: reserved for typed collection models.
|
- `samsara/models`: reserved for typed collection models.
|
||||||
- `samsara/assets`: labeled synthetic fixtures.
|
- `samsara/assets`: labeled synthetic fixtures.
|
||||||
- `schemas`: versioned collection JSON contract.
|
- `schemas`: versioned collection JSON contract.
|
||||||
- `powershell`: Windows collection requirements; scripts pending.
|
- `powershell`: Windows PowerShell 5.1 local inventory modules, shared safe-error helpers and JSON driver.
|
||||||
- `tests`: core, parsing, detection, worker, UI and export tests.
|
- `tests`: core, parsing, detection, worker, UI and export tests.
|
||||||
|
|
||||||
## Implemented
|
## Implemented
|
||||||
@@ -23,10 +23,14 @@ Linux collection provides read-only OS, service, timer calendar, system cron, TC
|
|||||||
|
|
||||||
## Remaining phases
|
## Remaining phases
|
||||||
|
|
||||||
1. Independent Windows inventory scripts for system/roles, services, TCP/processes, tasks, registry software, storage/shares and public certificate metadata. Do not use Win32_Product. Optional AD enrichment must not become a prerequisite.
|
1. Validate the implemented local Windows inventory on authorized Server hosts (2016/2019/2022/2025) before marking the milestone complete. Synthetic Windows contracts pass; native execution remains pending. Optional AD enrichment must not become a prerequisite.
|
||||||
2. Authorized Windows execution-host remoting and Linux SSH transport, explicit targets/authentication, host verification, bounded output, timeouts and cancellation. No infrastructure configuration.
|
2. Authorized Windows execution-host remoting and Linux SSH transport, explicit targets/authentication, host verification, bounded output, timeouts and cancellation. No infrastructure configuration.
|
||||||
3. Expand Linux inventory for user schedulers and remaining cron/anacron coverage, identities, share exports, disk usage and application-specific public certificates with least-privilege access.
|
3. Expand Linux inventory for user schedulers and remaining cron/anacron coverage, identities, share exports, disk usage and application-specific public certificates with least-privilege access.
|
||||||
4. Validate OS-specific application/critical infrastructure rules, observed versus inferred relationships and historical/manual verification workflows. No automatic retirement approval.
|
4. Validate OS-specific application/critical infrastructure rules, observed versus inferred relationships and historical/manual verification workflows. No automatic retirement approval.
|
||||||
5. Production schemas, redaction, Windows/RHEL platform testing, packaging and signed releases.
|
5. Production schemas, redaction, Windows/RHEL platform testing, packaging and signed releases.
|
||||||
|
|
||||||
The original empty repository had no existing application or project instructions. All current collection is local or offline; no enterprise targets are contacted in development.
|
The original empty repository had no existing application or project instructions. All current collection is local or offline; no enterprise targets are contacted in development.
|
||||||
|
|
||||||
|
## Windows local collection milestone
|
||||||
|
|
||||||
|
Added independent read-only PowerShell modules and the local Python backend, without replacing the existing application. OS identity is accepted only for known Server build/product-type combinations. The existing QThread routes Windows auto-detect/profile choices to the backend and retains the same result, history and reporting flow. Python runs each fixed local section in a separate supervised PowerShell process, with cancellation, per-section/overall budgets, output bounds and cleanup. Per-check UTC timestamps, sanitized errors, scope and permission limitations extend schema v1 additively; old fixtures remain valid. The real-host validation tool produces full private evidence and a separate metadata-only summary for review. Remote Windows collection is not part of this milestone.
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
# Platform support
|
# Platform support
|
||||||
|
|
||||||
Samsara targets retirement assessment of Windows Server 2016, 2019, 2022 and 2025, RHEL 7 (legacy)/8/9/10, and compatible enterprise Linux distributions. OS profiles express the intended assessment target, not a tested support guarantee for live collection.
|
Samsara targets retirement assessment of Windows Server 2016/2019/2022/2025 and enterprise Linux. Collector availability and tested platform support are different claims.
|
||||||
|
|
||||||
| Capability | Current availability |
|
| Capability | Implementation | Verification |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| Local Linux collection | Implemented; RPM and dpkg inventory paths |
|
| Local Windows Server inventory | Windows PowerShell 5.1 modules plus supervised Python controller | Python contracts/fixtures/UI pass; native runtime and real-host acceptance pending |
|
||||||
| OS auto-detection | Local OS identification; Linux distribution family and major version checks |
|
| Local Linux inventory | Existing read-only RPM/dpkg, service, scheduler, TCP and mount collection | Local Linux smoke and automated regression tests pass; full distribution matrix pending |
|
||||||
| Saved inventory analysis | Windows/Linux v1 JSON, with OS supplied by inventory metadata |
|
| Local OS selection | Auto-detect and explicit profiles | Linux and Windows routing/profile tests pass; Windows Server identity verified by CIM/build/product type in collector |
|
||||||
| Windows live collection | Not implemented |
|
| Offline reporting | v1 JSON import, HTML/JSON export, Summary, tabs, session history | Automated tests pass |
|
||||||
| Remote SSH/PowerShell collection | Not implemented |
|
| Remote Windows/Linux collection | Not implemented | Separate follow-up phase |
|
||||||
| Desktop | Python 3.11+ with Qt on Linux and Windows; Windows execution validation pending |
|
| Qt desktop | Existing Linux/Windows UI code | Offscreen Linux tests; native Windows desktop verification pending |
|
||||||
|
|
||||||
Auto-detection reads local OS metadata without remote name resolution or probing. Explicit RHEL selections check family and major version. Other Linux distributions can use Auto-detect for local testing. Windows identification does not infer a Server edition from a desktop kernel number. Unsupported live collectors return an explicit error while keeping prior results.
|
Windows Server builds: 14393=2016, 17763=2019, 20348=2022, 26100=2025. ProductType must be 2 (domain controller) or 3 (server). Workstations sharing a build number are rejected. Explicit selected profiles are checked against this verified identity. A failed critical OS identity check prevents acceptance of an assessment. Other sections may fail or be partial without losing successful observations.
|
||||||
|
|
||||||
The local collector uses fixed read-only system commands and records partial or failed checks for unsupported commands, permissions and missing evidence. Test each RHEL/distribution release before relying on production output. Containers may restrict systemd, interfaces, processes and mount namespaces.
|
Use headless collection for Server Core and Server 2016. A collector target is not necessarily a supported Qt desktop: current Qt 6.12 lists Windows 10 1809+ and Windows 11 in its desktop support matrix. The Python headless path needs only existing 64-bit Python 3.11+ and jsonschema; the PowerShell-only driver needs Windows PowerShell 5.1. See [Qt requirements](https://doc.qt.io/qt-6/windows.html) and README instructions. No runtime is automatically installed on an assessed host.
|
||||||
|
|
||||||
Future Windows collection must use existing authorized remoting from a Windows execution host, compatible with Windows PowerShell 5.1. Windows administration modules must not be run natively on Linux. Future Linux remote collection must use existing authorized SSH with host-key verification. Neither transport should enable services, install an agent or escalate privileges automatically.
|
Windows checks require local CIM and inbox ServerManager, NetTCPIP, ScheduledTasks, Storage, SmbShare and certificate-provider access. Missing modules, denied scopes or policy restrictions are visible in check status. Least privilege is preferred; Domain Administrator is not a prerequisite. No elevation, remoting configuration or credential prompt is performed. Non-elevated sensitive sections are conservatively partial, and ACL filtering can remain invisible even when a query succeeds.
|
||||||
|
|
||||||
Required production validation includes Windows desktop/server release testing, RHEL/distribution testing, incomplete permissions, process lifecycle behavior, schema contracts, report redaction, packaging and signing. Linux inventory coverage and current omissions are documented in README.md.
|
Run `tools/validate_windows_host.py` on an explicitly authorized supported host before closing the Windows collection milestone. Record actual version, check states, errors and row counts; synthetic fixtures or mock-provider tests cannot substitute for this. Every release still needs native validation. Windows packaging, signing, constrained-language environments and remote transports remain follow-up work.
|
||||||
|
|||||||
17
powershell/Get-ServerCertificates.ps1
Normal file
17
powershell/Get-ServerCertificates.ps1
Normal 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.'
|
||||||
|
}
|
||||||
56
powershell/Get-ServerInventory.ps1
Normal file
56
powershell/Get-ServerInventory.ps1
Normal 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
|
||||||
16
powershell/Get-ServerNetwork.ps1
Normal file
16
powershell/Get-ServerNetwork.ps1
Normal 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.'
|
||||||
|
}
|
||||||
10
powershell/Get-ServerRoles.ps1
Normal file
10
powershell/Get-ServerRoles.ps1
Normal 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.'
|
||||||
|
}
|
||||||
11
powershell/Get-ServerServices.ps1
Normal file
11
powershell/Get-ServerServices.ps1
Normal 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.'
|
||||||
|
}
|
||||||
37
powershell/Get-ServerSoftware.ps1
Normal file
37
powershell/Get-ServerSoftware.ps1
Normal 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.'
|
||||||
|
}
|
||||||
23
powershell/Get-ServerStorage.ps1
Normal file
23
powershell/Get-ServerStorage.ps1
Normal 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.'
|
||||||
|
}
|
||||||
34
powershell/Get-ServerSystem.ps1
Normal file
34
powershell/Get-ServerSystem.ps1
Normal 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.'
|
||||||
|
}
|
||||||
20
powershell/Get-ServerTasks.ps1
Normal file
20
powershell/Get-ServerTasks.ps1
Normal 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.'
|
||||||
|
}
|
||||||
66
powershell/Inventory.Common.ps1
Normal file
66
powershell/Inventory.Common.ps1
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -2,22 +2,9 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator, FormatChecker
|
from samsara.models.inventory import validate_inventory, restrict_inventory_fields
|
||||||
|
|
||||||
MAX_IMPORT_BYTES = 10 * 1024 * 1024
|
MAX_IMPORT_BYTES = 10 * 1024 * 1024
|
||||||
SCHEMA = Path(__file__).resolve().parents[2] / 'schemas' / 'inventory-v1.schema.json'
|
|
||||||
# Inventory fields allowed through the report boundary. Command arguments, environment
|
|
||||||
# variables and private key material are deliberately excluded.
|
|
||||||
ALLOWED = {
|
|
||||||
'roles': {'name', 'state'},
|
|
||||||
'services': {'name', 'display_name', 'state', 'startup', 'account'},
|
|
||||||
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
|
|
||||||
'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit'},
|
|
||||||
'software': {'name', 'publisher', 'version', 'installation_date'},
|
|
||||||
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib'},
|
|
||||||
'certificates': {'subject', 'issuer', 'expires', 'thumbprint'},
|
|
||||||
}
|
|
||||||
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
|
|
||||||
|
|
||||||
|
|
||||||
def load_inventory(path):
|
def load_inventory(path):
|
||||||
@@ -32,16 +19,35 @@ def load_inventory(path):
|
|||||||
# A report wrapper is accepted, but its precomputed assessment is never trusted.
|
# A report wrapper is accepted, but its precomputed assessment is never trusted.
|
||||||
if isinstance(data, dict) and 'inventory_result' in data:
|
if isinstance(data, dict) and 'inventory_result' in data:
|
||||||
data = data['inventory_result']
|
data = data['inventory_result']
|
||||||
schema = json.loads(SCHEMA.read_text(encoding='utf-8'))
|
validate_inventory(data)
|
||||||
errors = list(Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors(data))
|
|
||||||
if errors:
|
|
||||||
raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.')
|
|
||||||
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
|
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
|
||||||
# contain command lines or credentials from external collection tools.
|
# contain command lines or credentials from external collection tools.
|
||||||
data['checks'] = {name: {'status': check['status'], 'message':
|
safe_errors = {
|
||||||
'Imported check status. Consult collector diagnostics separately; details excluded to limit secret exposure.'}
|
'access_denied': 'Current account could not read this scope.',
|
||||||
for name, check in data['checks'].items()}
|
'command_unavailable': 'Required command was unavailable.',
|
||||||
data['server'] = {key: value for key, value in data['server'].items() if key in SERVER_FIELDS}
|
'timeout': 'Section time limit exceeded.',
|
||||||
data['inventory'] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]}
|
'overall_timeout': 'Overall collection time limit exceeded.',
|
||||||
for row in rows] for section, rows in data['inventory'].items()}
|
'output_limit': 'Collection output limit exceeded.',
|
||||||
return data
|
'powershell_failed': 'PowerShell could not execute the collector.',
|
||||||
|
'invalid_output': 'Invalid section output.',
|
||||||
|
'collection_failed': 'A local query failed.'
|
||||||
|
}
|
||||||
|
checks = {}
|
||||||
|
for name, check in data['checks'].items():
|
||||||
|
clean = {'status': check['status'], 'message':
|
||||||
|
'Imported check status. Source assertions are unverified; raw diagnostics excluded.'}
|
||||||
|
for field in ('started_at', 'completed_at'):
|
||||||
|
if field in check:
|
||||||
|
clean[field] = check[field]
|
||||||
|
if 'errors' in check:
|
||||||
|
clean['errors'] = []
|
||||||
|
for error in check['errors']:
|
||||||
|
code = error['code'] if error['code'] in safe_errors else 'collection_failed'
|
||||||
|
clean['errors'].append({'code': code, 'scope': 'Imported check', 'message': safe_errors[code]})
|
||||||
|
if 'permission_limitations' in check:
|
||||||
|
clean['permission_limitations'] = ['Collector reported limited read access; details excluded.'] if check['permission_limitations'] else []
|
||||||
|
if 'limitations' in check:
|
||||||
|
clean['limitations'] = ['Collector reported scope limitations; consult the inventory owner.'] if check['limitations'] else []
|
||||||
|
checks[name] = clean
|
||||||
|
data['checks'] = checks
|
||||||
|
return restrict_inventory_fields(data)
|
||||||
|
|||||||
193
samsara/collectors/windows.py
Normal file
193
samsara/collectors/windows.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Local Windows Server collector. No remoting, credentials or shell interpolation."""
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
from samsara.collectors.linux import CollectionCancelled
|
||||||
|
from samsara.core.detection import check_profile
|
||||||
|
from samsara.models.inventory import inventory_schema, validate_inventory, restrict_inventory_fields
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[2] / 'powershell' / 'Get-ServerInventory.ps1'
|
||||||
|
SECTIONS = ('system', 'roles', 'services', 'network', 'tasks', 'software', 'storage', 'shares', 'certificates')
|
||||||
|
SERVER_BUILDS = {'14393': '2016', '17763': '2019', '20348': '2022', '26100': '2025'}
|
||||||
|
MAX_OUTPUT = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsCollectionError(RuntimeError):
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def powershell_executable():
|
||||||
|
if platform.system() != 'Windows':
|
||||||
|
raise ValueError('Local Windows collection requires an authorized Windows Server execution host.')
|
||||||
|
if struct.calcsize('P') != 8:
|
||||||
|
raise ValueError('Use 64-bit Python for Windows Server collection.')
|
||||||
|
# Query Windows itself; never look up powershell.exe in PATH or trust SystemRoot.
|
||||||
|
buffer = ctypes.create_unicode_buffer(32768)
|
||||||
|
size = ctypes.windll.kernel32.GetSystemDirectoryW(buffer, len(buffer))
|
||||||
|
if size == 0 or size >= len(buffer):
|
||||||
|
raise ValueError('Unable to locate the Windows system directory.')
|
||||||
|
executable = Path(buffer.value) / 'WindowsPowerShell' / 'v1.0' / 'powershell.exe'
|
||||||
|
if not executable.is_file() or not SCRIPT.is_file():
|
||||||
|
raise ValueError('Windows PowerShell 5.1 or bundled collector scripts are unavailable.')
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def run_section(section, executable, cancelled=lambda: False, timeout=45):
|
||||||
|
if section not in SECTIONS:
|
||||||
|
raise ValueError('Unknown Windows inventory section.')
|
||||||
|
if cancelled():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
args = [str(executable), '-NoLogo', '-NoProfile', '-NonInteractive', '-File', str(SCRIPT), '-Section', section]
|
||||||
|
environment = os.environ.copy()
|
||||||
|
# Only inbox modules are needed. Do not autoload user-supplied modules or profiles.
|
||||||
|
environment['PSModulePath'] = str(Path(executable).parent / 'Modules')
|
||||||
|
with tempfile.TemporaryFile() as output:
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=output,
|
||||||
|
stderr=subprocess.DEVNULL, shell=False, env=environment,
|
||||||
|
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||||
|
except OSError:
|
||||||
|
raise WindowsCollectionError('powershell_failed', 'Windows PowerShell could not start. Check executable permissions and local policy.') from None
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
try:
|
||||||
|
while process.poll() is None:
|
||||||
|
if cancelled():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise WindowsCollectionError('timeout', 'The local section exceeded its time limit.')
|
||||||
|
if os.fstat(output.fileno()).st_size > MAX_OUTPUT:
|
||||||
|
raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.')
|
||||||
|
time.sleep(.05)
|
||||||
|
if cancelled():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
if process.returncode:
|
||||||
|
raise WindowsCollectionError('powershell_failed', 'The trusted script could not run. Check script signing policy, module availability and read permissions; raw diagnostics are excluded.')
|
||||||
|
output.seek(0)
|
||||||
|
raw = output.read(MAX_OUTPUT + 1)
|
||||||
|
if len(raw) > MAX_OUTPUT:
|
||||||
|
raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.')
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode('utf-8-sig'))
|
||||||
|
except (UnicodeError, ValueError, RecursionError):
|
||||||
|
raise WindowsCollectionError('invalid_output', 'PowerShell did not return a valid UTF-8 JSON section.') from None
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_section(record, section):
|
||||||
|
schema = inventory_schema()
|
||||||
|
properties = dict(schema['properties']['checks']['additionalProperties']['properties'])
|
||||||
|
properties['section'] = {'const': section}
|
||||||
|
if section == 'system':
|
||||||
|
properties['data'] = {'type': 'object', 'required': ['hostname','platform','os_build','product_type','os_version'],
|
||||||
|
'properties': schema['properties']['server']['properties']}
|
||||||
|
else:
|
||||||
|
properties['data'] = schema['properties']['inventory']['properties']['storage' if section == 'shares' else section]
|
||||||
|
section_schema = {'type': 'object', 'required': ['section','status','message','started_at','completed_at','errors','permission_limitations','limitations','data'], 'properties': properties}
|
||||||
|
if not Draft202012Validator(section_schema, format_checker=FormatChecker()).is_valid(record):
|
||||||
|
raise WindowsCollectionError('invalid_output', 'PowerShell returned a section that violates the inventory contract.')
|
||||||
|
if record['completed_at'] < record['started_at']:
|
||||||
|
# Both native timestamps use the same UTC format. This is not a trust check.
|
||||||
|
raise WindowsCollectionError('invalid_output', 'PowerShell returned inconsistent section timestamps.')
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def verify_server(server, profile='auto'):
|
||||||
|
year = SERVER_BUILDS.get(server.get('os_build'))
|
||||||
|
if server.get('platform') != 'windows' or server.get('product_type') not in (2, 3) or year is None:
|
||||||
|
raise ValueError('Unable to verify Windows Server 2016, 2019, 2022 or 2025. Workstation editions and unknown builds are not supported.')
|
||||||
|
if server.get('os_version') != year:
|
||||||
|
raise ValueError('Windows Server identity fields are inconsistent; no assessment was accepted.')
|
||||||
|
check_profile({'family': 'windows', 'version': year, 'name': server.get('operating_system', 'Windows Server ' + year)}, profile)
|
||||||
|
|
||||||
|
|
||||||
|
def failure_check(code, message, section, start, status='failed'):
|
||||||
|
return {'status': status, 'message': message, 'started_at': start, 'completed_at': timestamp(),
|
||||||
|
'errors': [{'code': code, 'scope': section, 'message': message}],
|
||||||
|
'permission_limitations': ['Permissions could not be verified for this check.'],
|
||||||
|
'limitations': ['No successful evidence was substituted for this failure.']}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_local_windows(stage=lambda message: None, cancelled=lambda: False, profile='auto',
|
||||||
|
section_timeout=45, total_timeout=300, runner=None):
|
||||||
|
if platform.system() != 'Windows':
|
||||||
|
raise ValueError('Local Windows collection requires an authorized Windows Server execution host.')
|
||||||
|
if profile != 'auto' and not profile.startswith('windows:'):
|
||||||
|
raise ValueError('Select Auto-detect or a Windows Server profile for this execution host.')
|
||||||
|
executable = powershell_executable()
|
||||||
|
runner = runner or run_section
|
||||||
|
started = time.monotonic()
|
||||||
|
result = {'schema_version': '1.0', 'synthetic': False, 'collected_at': timestamp(), 'server': {},
|
||||||
|
'checks': {}, 'inventory': {key: [] for key in ('roles','services','network','tasks','software','storage','certificates')}}
|
||||||
|
total_bytes = 0
|
||||||
|
for section in SECTIONS:
|
||||||
|
if cancelled():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
stage('Collecting local Windows ' + section + '…')
|
||||||
|
start = timestamp()
|
||||||
|
remaining = total_timeout - (time.monotonic() - started)
|
||||||
|
if remaining <= 0:
|
||||||
|
result['checks'][section] = failure_check('overall_timeout', 'Overall collection time limit reached before this check.', section, start, 'not_collected')
|
||||||
|
if section == 'system':
|
||||||
|
raise ValueError('The Windows identity check could not run within the time limit.')
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = validate_section(runner(section, executable, cancelled=cancelled, timeout=min(section_timeout, remaining)), section)
|
||||||
|
total_bytes += len(json.dumps(record).encode('utf-8'))
|
||||||
|
if total_bytes > MAX_OUTPUT:
|
||||||
|
raise WindowsCollectionError('output_limit', 'Total inventory exceeded the 10 MiB limit; this section was omitted.')
|
||||||
|
check = {key: record[key] for key in ('status','message','started_at','completed_at','errors','permission_limitations','limitations')}
|
||||||
|
result['checks'][section] = check
|
||||||
|
if section == 'system':
|
||||||
|
if record['status'] == 'failed':
|
||||||
|
raise ValueError('Windows identity collection failed. Verify local CIM read access and Windows PowerShell policy.')
|
||||||
|
verify_server(record['data'], profile)
|
||||||
|
result['server'] = record['data']
|
||||||
|
result['server']['collection_source'] = 'Local Windows read-only collection'
|
||||||
|
elif section == 'shares':
|
||||||
|
result['inventory']['storage'].extend(record['data'])
|
||||||
|
else:
|
||||||
|
result['inventory'][section] = record['data']
|
||||||
|
except WindowsCollectionError as exc:
|
||||||
|
result['checks'][section] = failure_check(exc.code, str(exc), section, start)
|
||||||
|
if section == 'system':
|
||||||
|
raise ValueError('Windows identity collection failed: ' + str(exc)) from None
|
||||||
|
if cancelled():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
return validate_inventory(restrict_inventory_fields(result, local_source='Local Windows read-only collection'))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Read-only local Windows Server inventory. No remoting is used.')
|
||||||
|
parser.add_argument('--output', required=True, help='New local JSON file to create')
|
||||||
|
parser.add_argument('--profile', choices=['auto','windows:2016','windows:2019','windows:2022','windows:2025'], default='auto')
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
data = collect_local_windows(profile=args.profile)
|
||||||
|
with open(args.output, 'x', encoding='utf-8') as output:
|
||||||
|
json.dump(data, output, indent=2)
|
||||||
|
print('Windows inventory saved. Review all failed/partial checks and permission limitations before retirement decisions.')
|
||||||
|
except (OSError, ValueError, WindowsCollectionError):
|
||||||
|
parser.exit(1, 'Windows inventory was not saved. Check supported local Server OS, Python architecture, script policy, permissions and a new writable output filename.\n')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
1
samsara/core/__init__.py
Normal file
1
samsara/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
49
samsara/core/detection.py
Normal file
49
samsara/core/detection.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""OS identification and explicit profile checks; never probes a remote host."""
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
PROFILES = [
|
||||||
|
('Auto-detect', 'auto'),
|
||||||
|
('Windows Server 2016', 'windows:2016'),
|
||||||
|
('Windows Server 2019', 'windows:2019'),
|
||||||
|
('Windows Server 2022', 'windows:2022'),
|
||||||
|
('Windows Server 2025', 'windows:2025'),
|
||||||
|
('Red Hat Enterprise Linux 7 (legacy)', 'rhel:7'),
|
||||||
|
('Red Hat Enterprise Linux 8', 'rhel:8'),
|
||||||
|
('Red Hat Enterprise Linux 9', 'rhel:9'),
|
||||||
|
('Red Hat Enterprise Linux 10', 'rhel:10'),
|
||||||
|
('RHEL-compatible Linux', 'rhel:any'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_local_os():
|
||||||
|
system = platform.system()
|
||||||
|
if system == 'Linux':
|
||||||
|
release = {}
|
||||||
|
for line in Path('/etc/os-release').read_text(encoding='utf-8').splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
release[key] = ' '.join(shlex.split(value))
|
||||||
|
distro = release.get('ID', '')
|
||||||
|
related = set(release.get('ID_LIKE', '').split())
|
||||||
|
rhel = distro in {'rhel', 'rocky', 'almalinux', 'centos', 'ol'} or 'rhel' in related
|
||||||
|
return {'platform': 'linux', 'family': 'rhel' if rhel else 'linux',
|
||||||
|
'version': release.get('VERSION_ID', '').split('.')[0],
|
||||||
|
'name': release.get('PRETTY_NAME', 'Linux')}
|
||||||
|
if system == 'Windows':
|
||||||
|
# Do not infer a Server edition from a desktop kernel/build number.
|
||||||
|
return {'platform': 'windows', 'family': 'windows', 'version': '',
|
||||||
|
'name': 'Windows ' + platform.win32_ver()[0]}
|
||||||
|
raise ValueError('Local collection is available on Linux. Import an inventory file for other platforms.')
|
||||||
|
|
||||||
|
|
||||||
|
def check_profile(detected, profile):
|
||||||
|
if profile == 'auto':
|
||||||
|
return
|
||||||
|
valid = {value for _, value in PROFILES}
|
||||||
|
if profile not in valid:
|
||||||
|
raise ValueError('Unknown operating system profile.')
|
||||||
|
family, version = profile.split(':')
|
||||||
|
if detected['family'] != family or (version != 'any' and detected['version'] != version):
|
||||||
|
raise ValueError(f"Detected {detected['name']}, which does not match the selected profile. Select Auto-detect to assess this computer.")
|
||||||
20
samsara/core/startup.py
Normal file
20
samsara/core/startup.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Check common desktop prerequisites before Qt can abort the process."""
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def desktop_error():
|
||||||
|
if not sys.platform.startswith('linux'):
|
||||||
|
return None
|
||||||
|
backend = os.environ.get('QT_QPA_PLATFORM', '').split(':')[0]
|
||||||
|
uses_xcb = backend == 'xcb' or (not backend and os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'))
|
||||||
|
if uses_xcb:
|
||||||
|
try:
|
||||||
|
ctypes.CDLL('libxcb-cursor.so.0')
|
||||||
|
except OSError:
|
||||||
|
return ('Samsara needs the Qt X11 cursor library. On Debian-based desktops run:\n'
|
||||||
|
' sudo apt install libxcb-cursor0\n'
|
||||||
|
'Then retry: python -m samsara.main\n'
|
||||||
|
'For other Linux distributions install the package providing libxcb-cursor.so.0.')
|
||||||
|
return None
|
||||||
10
samsara/core/validation.py
Normal file
10
samsara/core/validation.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def validate_hostname(value):
|
||||||
|
value = value.strip().rstrip(".")
|
||||||
|
if len(value) > 253 or not value or any(
|
||||||
|
not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
||||||
|
for label in value.split(".")
|
||||||
|
):
|
||||||
|
raise ValueError("Enter a hostname or FQDN using letters, digits, hyphens and dots (no URLs or commands).")
|
||||||
|
return value.lower()
|
||||||
41
samsara/models/inventory.py
Normal file
41
samsara/models/inventory.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""The shared v1 inventory contract, used at live collection and import boundaries."""
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
SCHEMA_PATH = Path(__file__).resolve().parents[2] / 'schemas' / 'inventory-v1.schema.json'
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def inventory_schema():
|
||||||
|
return json.loads(SCHEMA_PATH.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_inventory(data):
|
||||||
|
validator = Draft202012Validator(inventory_schema(), format_checker=FormatChecker())
|
||||||
|
if not validator.is_valid(data):
|
||||||
|
raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED = {
|
||||||
|
'roles': {'name', 'state', 'display_name', 'feature_type'},
|
||||||
|
'services': {'name', 'display_name', 'state', 'startup', 'account', 'pid'},
|
||||||
|
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
|
||||||
|
'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit', 'executables'},
|
||||||
|
'software': {'name', 'publisher', 'version', 'installation_date', 'registry_view'},
|
||||||
|
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib', 'filesystem', 'special'},
|
||||||
|
'certificates': {'subject', 'issuer', 'expires', 'thumbprint', 'store'},
|
||||||
|
}
|
||||||
|
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'kernel_version', 'product_type', 'domain_joined', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
|
||||||
|
|
||||||
|
|
||||||
|
def restrict_inventory_fields(data, local_source=None):
|
||||||
|
"""Keep only metadata fields; never serialize raw CIM/task/service objects."""
|
||||||
|
data["server"] = {key: value for key, value in data["server"].items() if key in SERVER_FIELDS}
|
||||||
|
if local_source:
|
||||||
|
data["server"]["collection_source"] = local_source
|
||||||
|
data["inventory"] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]}
|
||||||
|
for row in rows] for section, rows in data["inventory"].items()}
|
||||||
|
return data
|
||||||
@@ -16,6 +16,8 @@ def export_html(path, data, assessment):
|
|||||||
content = "<h1>Samsara assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Samsara assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
|
content = "<h1>Samsara assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Samsara assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
|
||||||
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
|
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
|
||||||
content = '<h1>Samsara assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
|
content = '<h1>Samsara assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
|
||||||
|
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Windows read-only collection':
|
||||||
|
content = '<h1>Samsara assessment</h1><p>LOCAL WINDOWS INVENTORY — read-only snapshot of the execution host.</p>'
|
||||||
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
|
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
|
||||||
summary = summarize(data, assessment)
|
summary = summarize(data, assessment)
|
||||||
content += f"<h2>Summary</h2><p>Retirement risk estimate: {escape(summary['risk_level'])} ({summary['risk_score']}/100). Higher scores mean more retirement concerns.</p>"
|
content += f"<h2>Summary</h2><p>Retirement risk estimate: {escape(summary['risk_level'])} ({summary['risk_score']}/100). Higher scores mean more retirement concerns.</p>"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from samsara.ui.branding import application_icon
|
|||||||
from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
|
from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
|
||||||
from samsara.collectors.importer import load_inventory
|
from samsara.collectors.importer import load_inventory
|
||||||
from samsara.collectors.linux import collect_local_linux, CollectionCancelled
|
from samsara.collectors.linux import collect_local_linux, CollectionCancelled
|
||||||
|
from samsara.collectors.windows import collect_local_windows
|
||||||
from samsara.core.detection import detect_local_os, check_profile, PROFILES
|
from samsara.core.detection import detect_local_os, check_profile, PROFILES
|
||||||
from samsara.analyzers.assessment import assess
|
from samsara.analyzers.assessment import assess
|
||||||
from samsara.analyzers.summary import summarize
|
from samsara.analyzers.summary import summarize
|
||||||
@@ -28,11 +29,12 @@ class CollectionJob(QThread):
|
|||||||
if self.source == 'local':
|
if self.source == 'local':
|
||||||
self.stage.emit('Detecting operating system…')
|
self.stage.emit('Detecting operating system…')
|
||||||
detected = detect_local_os()
|
detected = detect_local_os()
|
||||||
if detected['platform'] != 'linux':
|
|
||||||
raise ValueError('Windows live collection is not available yet. Open a saved inventory or a sample assessment.')
|
|
||||||
check_profile(detected, self.profile)
|
|
||||||
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
|
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
|
||||||
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
if detected['platform'] == 'windows':
|
||||||
|
data = collect_local_windows(profile=self.profile, stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||||||
|
else:
|
||||||
|
check_profile(detected, self.profile)
|
||||||
|
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||||||
elif self.source == 'file':
|
elif self.source == 'file':
|
||||||
self.stage.emit('Reading saved inventory…')
|
self.stage.emit('Reading saved inventory…')
|
||||||
data = load_inventory(self.inventory_path)
|
data = load_inventory(self.inventory_path)
|
||||||
@@ -308,7 +310,7 @@ class MainWindow(QMainWindow):
|
|||||||
reports.addStretch()
|
reports.addStretch()
|
||||||
settings = self.page('Settings', 'Collection availability and assessment behavior.')
|
settings = self.page('Settings', 'Collection availability and assessment behavior.')
|
||||||
settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions'))
|
settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions'))
|
||||||
settings.addWidget(self.label('Local Linux collection is available. Live Windows and remote collection are not available yet. Saved inventories and samples work on either desktop.', muted=True))
|
settings.addWidget(self.label('Local Windows Server and Linux collectors are available. Windows validation on a supported host is pending. Remote collection is a separate follow-up. Saved inventories and samples work on either desktop.', muted=True))
|
||||||
settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True))
|
settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True))
|
||||||
settings.addStretch()
|
settings.addStretch()
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,45 @@
|
|||||||
"windows",
|
"windows",
|
||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"operating_system": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"os_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"os_build": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"kernel_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collection_source": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"product_type": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"domain_joined": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ip_addresses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network_adapters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -58,6 +97,49 @@
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"scope",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission_limitations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"limitations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,6 +175,15 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"feature_type": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +202,18 @@
|
|||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startup": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pid": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +262,24 @@
|
|||||||
},
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"executables": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"associated_unit": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +294,18 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"installation_date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"registry_view": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +328,24 @@
|
|||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filesystem": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"special": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"capacity_gib": {
|
||||||
|
"type": [
|
||||||
|
"number",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"free_gib": {
|
||||||
|
"type": [
|
||||||
|
"number",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +360,18 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"subject": {
|
"subject": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thumbprint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"store": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
213
tests/fixtures/windows/server-2016.json
vendored
Normal file
213
tests/fixtures/windows/server-2016.json
vendored
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"synthetic": true,
|
||||||
|
"collected_at": "2026-10-01T12:00:00Z",
|
||||||
|
"server": {
|
||||||
|
"hostname": "fixture-win2016.example.test",
|
||||||
|
"operating_system": "Microsoft Windows Server 2016 Standard (synthetic)",
|
||||||
|
"os_build": "14393",
|
||||||
|
"last_boot": "2026-09-25T06:30:00Z",
|
||||||
|
"ip_addresses": [
|
||||||
|
"192.0.2.20"
|
||||||
|
],
|
||||||
|
"network_adapters": [
|
||||||
|
"Synthetic Ethernet adapter"
|
||||||
|
],
|
||||||
|
"domain": "example.test",
|
||||||
|
"platform": "windows",
|
||||||
|
"os_version": "2016",
|
||||||
|
"kernel_version": "10.0.14393",
|
||||||
|
"product_type": 3,
|
||||||
|
"domain_joined": true,
|
||||||
|
"collection_source": "Synthetic Windows fixture"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"system": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"software": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shares": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"certificates": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"name": "Web-Server",
|
||||||
|
"state": "Installed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "File-Services",
|
||||||
|
"state": "Installed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "W3SVC",
|
||||||
|
"display_name": "World Wide Web Publishing Service",
|
||||||
|
"state": "Running",
|
||||||
|
"startup": "Automatic",
|
||||||
|
"account": "LocalSystem",
|
||||||
|
"pid": 123
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
{
|
||||||
|
"state": "Listen",
|
||||||
|
"local_address": "0.0.0.0",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "",
|
||||||
|
"remote_port": 0,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "Established",
|
||||||
|
"local_address": "192.0.2.20",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "192.0.2.45",
|
||||||
|
"remote_port": 52104,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Nightly export",
|
||||||
|
"path": "\\Operations\\",
|
||||||
|
"enabled": true,
|
||||||
|
"account": "EXAMPLE\\svc_export",
|
||||||
|
"triggers": "MSFT_TaskDailyTrigger; start=2026-10-01T02:00:00; daysInterval=1",
|
||||||
|
"executables": "export.exe",
|
||||||
|
"state": "Ready"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"software": [
|
||||||
|
{
|
||||||
|
"name": "Example Reporting Service",
|
||||||
|
"publisher": "Example Software",
|
||||||
|
"version": "4.2",
|
||||||
|
"installation_date": "2024-06-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"type": "Volume",
|
||||||
|
"name": "C:",
|
||||||
|
"path": "C:\\",
|
||||||
|
"capacity_gib": 160,
|
||||||
|
"free_gib": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SMB Share",
|
||||||
|
"name": "Reports",
|
||||||
|
"path": "D:\\Reports",
|
||||||
|
"capacity_gib": null,
|
||||||
|
"free_gib": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"certificates": [
|
||||||
|
{
|
||||||
|
"subject": "CN=demo-web-01.example.test",
|
||||||
|
"issuer": "CN=Example Test CA",
|
||||||
|
"expires": "2027-06-01T00:00:00Z",
|
||||||
|
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567",
|
||||||
|
"store": "My"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
213
tests/fixtures/windows/server-2019.json
vendored
Normal file
213
tests/fixtures/windows/server-2019.json
vendored
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"synthetic": true,
|
||||||
|
"collected_at": "2026-10-01T12:00:00Z",
|
||||||
|
"server": {
|
||||||
|
"hostname": "fixture-win2019.example.test",
|
||||||
|
"operating_system": "Microsoft Windows Server 2019 Standard (synthetic)",
|
||||||
|
"os_build": "17763",
|
||||||
|
"last_boot": "2026-09-25T06:30:00Z",
|
||||||
|
"ip_addresses": [
|
||||||
|
"192.0.2.20"
|
||||||
|
],
|
||||||
|
"network_adapters": [
|
||||||
|
"Synthetic Ethernet adapter"
|
||||||
|
],
|
||||||
|
"domain": "example.test",
|
||||||
|
"platform": "windows",
|
||||||
|
"os_version": "2019",
|
||||||
|
"kernel_version": "10.0.17763",
|
||||||
|
"product_type": 3,
|
||||||
|
"domain_joined": true,
|
||||||
|
"collection_source": "Synthetic Windows fixture"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"system": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"software": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shares": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"certificates": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"name": "Web-Server",
|
||||||
|
"state": "Installed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "File-Services",
|
||||||
|
"state": "Installed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "W3SVC",
|
||||||
|
"display_name": "World Wide Web Publishing Service",
|
||||||
|
"state": "Running",
|
||||||
|
"startup": "Automatic",
|
||||||
|
"account": "LocalSystem",
|
||||||
|
"pid": 123
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
{
|
||||||
|
"state": "Listen",
|
||||||
|
"local_address": "0.0.0.0",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "",
|
||||||
|
"remote_port": 0,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "Established",
|
||||||
|
"local_address": "192.0.2.20",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "192.0.2.45",
|
||||||
|
"remote_port": 52104,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Nightly export",
|
||||||
|
"path": "\\Operations\\",
|
||||||
|
"enabled": true,
|
||||||
|
"account": "EXAMPLE\\svc_export",
|
||||||
|
"triggers": "MSFT_TaskDailyTrigger; start=2026-10-01T02:00:00; daysInterval=1",
|
||||||
|
"executables": "export.exe",
|
||||||
|
"state": "Ready"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"software": [
|
||||||
|
{
|
||||||
|
"name": "Example Reporting Service",
|
||||||
|
"publisher": "Example Software",
|
||||||
|
"version": "4.2",
|
||||||
|
"installation_date": "2024-06-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"type": "Volume",
|
||||||
|
"name": "C:",
|
||||||
|
"path": "C:\\",
|
||||||
|
"capacity_gib": 160,
|
||||||
|
"free_gib": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SMB Share",
|
||||||
|
"name": "Reports",
|
||||||
|
"path": "D:\\Reports",
|
||||||
|
"capacity_gib": null,
|
||||||
|
"free_gib": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"certificates": [
|
||||||
|
{
|
||||||
|
"subject": "CN=demo-web-01.example.test",
|
||||||
|
"issuer": "CN=Example Test CA",
|
||||||
|
"expires": "2027-06-01T00:00:00Z",
|
||||||
|
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567",
|
||||||
|
"store": "My"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
213
tests/fixtures/windows/server-2022.json
vendored
Normal file
213
tests/fixtures/windows/server-2022.json
vendored
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"synthetic": true,
|
||||||
|
"collected_at": "2026-10-01T12:00:00Z",
|
||||||
|
"server": {
|
||||||
|
"hostname": "fixture-win2022.example.test",
|
||||||
|
"operating_system": "Microsoft Windows Server 2022 Standard (synthetic)",
|
||||||
|
"os_build": "20348",
|
||||||
|
"last_boot": "2026-09-25T06:30:00Z",
|
||||||
|
"ip_addresses": [
|
||||||
|
"192.0.2.20"
|
||||||
|
],
|
||||||
|
"network_adapters": [
|
||||||
|
"Synthetic Ethernet adapter"
|
||||||
|
],
|
||||||
|
"domain": "example.test",
|
||||||
|
"platform": "windows",
|
||||||
|
"os_version": "2022",
|
||||||
|
"kernel_version": "10.0.20348",
|
||||||
|
"product_type": 3,
|
||||||
|
"domain_joined": true,
|
||||||
|
"collection_source": "Synthetic Windows fixture"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"system": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"software": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shares": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"certificates": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"name": "Web-Server",
|
||||||
|
"state": "Installed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "File-Services",
|
||||||
|
"state": "Installed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "W3SVC",
|
||||||
|
"display_name": "World Wide Web Publishing Service",
|
||||||
|
"state": "Running",
|
||||||
|
"startup": "Automatic",
|
||||||
|
"account": "LocalSystem",
|
||||||
|
"pid": 123
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
{
|
||||||
|
"state": "Listen",
|
||||||
|
"local_address": "0.0.0.0",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "",
|
||||||
|
"remote_port": 0,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "Established",
|
||||||
|
"local_address": "192.0.2.20",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "192.0.2.45",
|
||||||
|
"remote_port": 52104,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Nightly export",
|
||||||
|
"path": "\\Operations\\",
|
||||||
|
"enabled": true,
|
||||||
|
"account": "EXAMPLE\\svc_export",
|
||||||
|
"triggers": "MSFT_TaskDailyTrigger; start=2026-10-01T02:00:00; daysInterval=1",
|
||||||
|
"executables": "export.exe",
|
||||||
|
"state": "Ready"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"software": [
|
||||||
|
{
|
||||||
|
"name": "Example Reporting Service",
|
||||||
|
"publisher": "Example Software",
|
||||||
|
"version": "4.2",
|
||||||
|
"installation_date": "2024-06-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"type": "Volume",
|
||||||
|
"name": "C:",
|
||||||
|
"path": "C:\\",
|
||||||
|
"capacity_gib": 160,
|
||||||
|
"free_gib": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SMB Share",
|
||||||
|
"name": "Reports",
|
||||||
|
"path": "D:\\Reports",
|
||||||
|
"capacity_gib": null,
|
||||||
|
"free_gib": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"certificates": [
|
||||||
|
{
|
||||||
|
"subject": "CN=demo-web-01.example.test",
|
||||||
|
"issuer": "CN=Example Test CA",
|
||||||
|
"expires": "2027-06-01T00:00:00Z",
|
||||||
|
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567",
|
||||||
|
"store": "My"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
213
tests/fixtures/windows/server-2025.json
vendored
Normal file
213
tests/fixtures/windows/server-2025.json
vendored
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"synthetic": true,
|
||||||
|
"collected_at": "2026-10-01T12:00:00Z",
|
||||||
|
"server": {
|
||||||
|
"hostname": "fixture-win2025.example.test",
|
||||||
|
"operating_system": "Microsoft Windows Server 2025 Standard (synthetic)",
|
||||||
|
"os_build": "26100",
|
||||||
|
"last_boot": "2026-09-25T06:30:00Z",
|
||||||
|
"ip_addresses": [
|
||||||
|
"192.0.2.20"
|
||||||
|
],
|
||||||
|
"network_adapters": [
|
||||||
|
"Synthetic Ethernet adapter"
|
||||||
|
],
|
||||||
|
"domain": "example.test",
|
||||||
|
"platform": "windows",
|
||||||
|
"os_version": "2025",
|
||||||
|
"kernel_version": "10.0.26100",
|
||||||
|
"product_type": 3,
|
||||||
|
"domain_joined": true,
|
||||||
|
"collection_source": "Synthetic Windows fixture"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"system": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"software": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shares": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"certificates": {
|
||||||
|
"status": "complete",
|
||||||
|
"message": "Synthetic contract fixture; no Windows collection performed.",
|
||||||
|
"started_at": "2026-10-01T12:00:00Z",
|
||||||
|
"completed_at": "2026-10-01T12:00:01Z",
|
||||||
|
"errors": [],
|
||||||
|
"permission_limitations": [],
|
||||||
|
"limitations": [
|
||||||
|
"Synthetic fixture only."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"name": "Web-Server",
|
||||||
|
"state": "Installed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "File-Services",
|
||||||
|
"state": "Installed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "W3SVC",
|
||||||
|
"display_name": "World Wide Web Publishing Service",
|
||||||
|
"state": "Running",
|
||||||
|
"startup": "Automatic",
|
||||||
|
"account": "LocalSystem",
|
||||||
|
"pid": 123
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
{
|
||||||
|
"state": "Listen",
|
||||||
|
"local_address": "0.0.0.0",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "",
|
||||||
|
"remote_port": 0,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state": "Established",
|
||||||
|
"local_address": "192.0.2.20",
|
||||||
|
"local_port": 443,
|
||||||
|
"remote_address": "192.0.2.45",
|
||||||
|
"remote_port": 52104,
|
||||||
|
"pid": 4,
|
||||||
|
"process": "System",
|
||||||
|
"direction": "Unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Nightly export",
|
||||||
|
"path": "\\Operations\\",
|
||||||
|
"enabled": true,
|
||||||
|
"account": "EXAMPLE\\svc_export",
|
||||||
|
"triggers": "MSFT_TaskDailyTrigger; start=2026-10-01T02:00:00; daysInterval=1",
|
||||||
|
"executables": "export.exe",
|
||||||
|
"state": "Ready"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"software": [
|
||||||
|
{
|
||||||
|
"name": "Example Reporting Service",
|
||||||
|
"publisher": "Example Software",
|
||||||
|
"version": "4.2",
|
||||||
|
"installation_date": "2024-06-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"type": "Volume",
|
||||||
|
"name": "C:",
|
||||||
|
"path": "C:\\",
|
||||||
|
"capacity_gib": 160,
|
||||||
|
"free_gib": 72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SMB Share",
|
||||||
|
"name": "Reports",
|
||||||
|
"path": "D:\\Reports",
|
||||||
|
"capacity_gib": null,
|
||||||
|
"free_gib": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"certificates": [
|
||||||
|
{
|
||||||
|
"subject": "CN=demo-web-01.example.test",
|
||||||
|
"issuer": "CN=Example Test CA",
|
||||||
|
"expires": "2027-06-01T00:00:00Z",
|
||||||
|
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567",
|
||||||
|
"store": "My"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/powershell/Test-Collectors.ps1
Normal file
78
tests/powershell/Test-Collectors.ps1
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
# AST and synthetic collector contract tests only. Does not query any real server.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||||
|
$files = @(Get-ChildItem -LiteralPath (Join-Path $root 'powershell') -Filter '*.ps1')
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$tokens = $null; $parseErrors = $null
|
||||||
|
$null = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors)
|
||||||
|
if ($parseErrors.Count) { throw ('PowerShell syntax invalid: ' + $file.Name) }
|
||||||
|
}
|
||||||
|
foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1' })) { . $file.FullName }
|
||||||
|
$script:SamsaraIsElevated = $true
|
||||||
|
$script:SyntheticBuild = '20348'
|
||||||
|
function Get-CimInstance {
|
||||||
|
[CmdletBinding()] param($ClassName, $Property, $Filter)
|
||||||
|
switch ($ClassName) {
|
||||||
|
'Win32_OperatingSystem' { [pscustomobject]@{ Caption='Synthetic Windows Server'; Version=('10.0.' + $script:SyntheticBuild); BuildNumber=$script:SyntheticBuild; ProductType=3; LastBootUpTime=[DateTime]::UtcNow.AddDays(-2) } }
|
||||||
|
'Win32_ComputerSystem' { [pscustomobject]@{ Domain='example.test'; PartOfDomain=$true } }
|
||||||
|
'Win32_NetworkAdapterConfiguration' { [pscustomobject]@{ Description='Synthetic NIC'; IPAddress=@('192.0.2.20') } }
|
||||||
|
'Win32_Service' { [pscustomobject]@{ Name='W3SVC'; DisplayName='Synthetic Web'; State='Running'; StartMode='Auto'; StartName='EXAMPLE\svc_web'; ProcessId=123; PathName='SECRET command arguments' } }
|
||||||
|
default { throw 'Unexpected synthetic CIM query' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Get-WindowsFeature { [CmdletBinding()] param() [pscustomobject]@{ Name='Web-Server'; DisplayName='Synthetic Web Server'; Installed=$true; InstallState='Installed'; FeatureType='Role' } }
|
||||||
|
function Get-Process { [CmdletBinding()] param() [pscustomobject]@{ Id=123; ProcessName='SyntheticWorker'; CommandLine='SECRET' } }
|
||||||
|
function Get-NetTCPConnection { [CmdletBinding()] param() [pscustomobject]@{ State='Established'; LocalAddress='192.0.2.20'; LocalPort=443; RemoteAddress='192.0.2.21'; RemotePort=50000; OwningProcess=123 } }
|
||||||
|
function Get-ScheduledTask {
|
||||||
|
[CmdletBinding()] param()
|
||||||
|
[pscustomobject]@{ TaskName='Synthetic report'; TaskPath='\Example\'; State='Ready'
|
||||||
|
Settings=[pscustomobject]@{ Enabled=$true }; Principal=[pscustomobject]@{ UserId='EXAMPLE\svc_report' }
|
||||||
|
Actions=@([pscustomobject]@{ Execute='C:\Tools\report.exe'; Arguments='--password=SECRET' })
|
||||||
|
Triggers=@([pscustomobject]@{ CimClass=[pscustomobject]@{ CimClassName='MSFT_TaskDailyTrigger' }; StartBoundary='2026-10-01T02:00:00'; Enabled=$true; DaysInterval=1; Repetition=$null }) }
|
||||||
|
}
|
||||||
|
function Get-Volume { [CmdletBinding()] param() [pscustomobject]@{ FileSystemLabel='Synthetic OS'; DriveLetter='C'; Path='C:\'; FileSystem='NTFS'; Size=100GB; SizeRemaining=50GB } }
|
||||||
|
function Get-SmbShare { [CmdletBinding()] param() [pscustomobject]@{ Name='SyntheticReports'; Path='C:\Reports'; Special=$false } }
|
||||||
|
function Get-ChildItem {
|
||||||
|
[CmdletBinding()] param($LiteralPath)
|
||||||
|
if ($LiteralPath -eq 'Cert:\LocalMachine') { [pscustomobject]@{ PSPath='Cert:\LocalMachine\My'; PSChildName='My' } }
|
||||||
|
else { [pscustomobject]@{ Subject='CN=synthetic.example.test'; Issuer='CN=Synthetic CA'; NotAfter=[DateTime]::UtcNow.AddDays(30); Thumbprint='0000000000000000000000000000000000000000'; PrivateKey='SECRET' } }
|
||||||
|
}
|
||||||
|
function Assert-True { param($Value, [string]$Message) if (-not $Value) { throw $Message } }
|
||||||
|
$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}; storage={param($r) Get-SamsaraStorage $r}
|
||||||
|
shares={param($r) Get-SamsaraShares $r}; certificates={param($r) Get-SamsaraCertificates $r}
|
||||||
|
}
|
||||||
|
foreach ($name in $collectors.Keys) {
|
||||||
|
$section = Invoke-SamsaraSection $name $collectors[$name]
|
||||||
|
Assert-True ($section.status -eq 'complete') ('Synthetic section failed: ' + $name)
|
||||||
|
Assert-True ($section.started_at -and $section.completed_at) 'Missing timestamps'
|
||||||
|
Assert-True (($section | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Secret field serialized'
|
||||||
|
if ($name -eq 'network') { Assert-True ($section.data[0].process -eq 'SyntheticWorker') 'Process correlation failed' }
|
||||||
|
if ($name -eq 'tasks') { Assert-True ($section.data[0].executables -eq 'report.exe') 'Executable basename missing' }
|
||||||
|
}
|
||||||
|
foreach ($build in @('14393','17763','20348','26100')) {
|
||||||
|
$script:SyntheticBuild=$build
|
||||||
|
$section=Invoke-SamsaraSection 'system' {param($r) Get-SamsaraSystem $r}
|
||||||
|
Assert-True ($section.status -eq 'complete') 'Supported build mapping failed'
|
||||||
|
}
|
||||||
|
# Test registry projection with in-memory objects; never open a real registry hive.
|
||||||
|
$fakeEntry = New-Object PSObject
|
||||||
|
$fakeEntry | Add-Member ScriptMethod GetValue { param($key) switch ($key) { 'DisplayName' {'Synthetic application'}; 'DisplayVersion' {'1.0'}; 'Publisher' {'Example'}; 'InstallDate' {'20260101'}; default {throw 'Unexpected registry value requested'} } }
|
||||||
|
$fakeEntry | Add-Member ScriptMethod Dispose { }
|
||||||
|
$fakeUninstall = New-Object PSObject
|
||||||
|
$fakeUninstall | Add-Member ScriptMethod GetSubKeyNames { @('Example') }
|
||||||
|
$fakeUninstall | Add-Member ScriptMethod OpenSubKey { param($name,$writable) if ($writable) {throw 'Writable registry access'}; return $fakeEntry }
|
||||||
|
$section=Invoke-SamsaraSection 'software' {param($r) Invoke-SamsaraRead $r 'Synthetic registry view' { Read-SamsaraSoftwareEntries $r $fakeUninstall 'Registry64' } }
|
||||||
|
Assert-True ($section.status -eq 'complete' -and $section.data[0].name -eq 'Synthetic application') 'Registry metadata projection failed'
|
||||||
|
function Get-SmbShare { [CmdletBinding()] param() throw [UnauthorizedAccessException]::new('SECRET protected details') }
|
||||||
|
$section=Invoke-SamsaraSection 'shares' {param($r) Get-SamsaraShares $r}
|
||||||
|
Assert-True ($section.status -eq 'failed') 'Denied collection was not failed'
|
||||||
|
Assert-True ($section.errors[0].code -eq 'access_denied') 'Permission error not categorized'
|
||||||
|
Assert-True (($section | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Raw error exposed'
|
||||||
|
$script:SamsaraIsElevated=$false
|
||||||
|
$section=Invoke-SamsaraSection 'services' {param($r) Get-SamsaraServices $r}
|
||||||
|
Assert-True ($section.status -eq 'partial' -and $section.permission_limitations.Count -gt 0) 'Non-elevated scope not marked partial'
|
||||||
|
Write-Output 'PowerShell AST and synthetic collector tests passed. No real Windows inventory was collected.'
|
||||||
16
tests/test_powershell.py
Normal file
16
tests/test_powershell.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class PowerShellTests(unittest.TestCase):
|
||||||
|
def test_native_ast_and_synthetic_collectors(self):
|
||||||
|
executable = shutil.which('powershell.exe') if os.name == 'nt' else shutil.which('pwsh')
|
||||||
|
if not executable:
|
||||||
|
self.skipTest('No PowerShell runtime available; native AST/collector tests require Windows PowerShell 5.1 or pwsh.')
|
||||||
|
script=Path('tests/powershell/Test-Collectors.ps1').resolve()
|
||||||
|
result=subprocess.run([executable,'-NoLogo','-NoProfile','-NonInteractive','-File',str(script)],capture_output=True,text=True,timeout=60,shell=False)
|
||||||
|
self.assertEqual(result.returncode,0,result.stdout+'\n'+result.stderr)
|
||||||
|
self.assertIn('synthetic collector tests passed',result.stdout)
|
||||||
@@ -83,7 +83,7 @@ class UITests(unittest.TestCase):
|
|||||||
self.assertIn('LOCAL LINUX INVENTORY', report.read_text())
|
self.assertIn('LOCAL LINUX INVENTORY', report.read_text())
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
def test_mismatched_profile_and_windows_unavailable_keep_prior_results(self):
|
def test_mismatched_profile_and_windows_failure_keep_prior_results(self):
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.collection_done(collect_demo(LINUX_DEMO_HOST))
|
window.collection_done(collect_demo(LINUX_DEMO_HOST))
|
||||||
previous = window.data
|
previous = window.data
|
||||||
@@ -95,10 +95,11 @@ class UITests(unittest.TestCase):
|
|||||||
collector.assert_not_called()
|
collector.assert_not_called()
|
||||||
self.assertIn('does not match', window.status.text())
|
self.assertIn('does not match', window.status.text())
|
||||||
self.assertIs(window.data, previous)
|
self.assertIs(window.data, previous)
|
||||||
with patch('samsara.ui.window.detect_local_os', return_value={'platform': 'windows', 'family': 'windows', 'version': '', 'name': 'Windows'}):
|
with patch('samsara.ui.window.detect_local_os', return_value={'platform': 'windows', 'family': 'windows', 'version': '', 'name': 'Windows'}), patch('samsara.ui.window.collect_local_windows', side_effect=ValueError('Windows identity collection failed.')):
|
||||||
window.start_collection()
|
window.start_collection()
|
||||||
self.finish(window)
|
self.finish(window)
|
||||||
self.assertIn('not available yet', window.status.text())
|
self.assertIn('identity collection failed', window.status.text())
|
||||||
|
self.assertIs(window.data, previous)
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
def test_import_requires_file_and_reads_recorded_os(self):
|
def test_import_requires_file_and_reads_recorded_os(self):
|
||||||
@@ -114,3 +115,29 @@ class UITests(unittest.TestCase):
|
|||||||
loader.assert_called_once_with('/tmp/saved-inventory.json')
|
loader.assert_called_once_with('/tmp/saved-inventory.json')
|
||||||
detect.assert_not_called()
|
detect.assert_not_called()
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
def test_windows_local_route_preserves_summary_history_and_export(self):
|
||||||
|
import json
|
||||||
|
data = json.loads(Path('tests/fixtures/windows/server-2025.json').read_text())
|
||||||
|
data['synthetic'] = False
|
||||||
|
data['server']['collection_source'] = 'Local Windows read-only collection'
|
||||||
|
with patch('samsara.ui.window.detect_local_os', return_value={'platform': 'windows', 'family': 'windows', 'version': '', 'name': 'Windows'}), patch('samsara.ui.window.collect_local_windows', return_value=data) as collector, patch('samsara.ui.window.collect_local_linux') as linux:
|
||||||
|
window = MainWindow()
|
||||||
|
window.target.setCurrentIndex(window.target.findData('windows:2025'))
|
||||||
|
window.start_collection()
|
||||||
|
self.assertFalse(window.analyze_button.isEnabled())
|
||||||
|
self.finish(window)
|
||||||
|
collector.assert_called_once()
|
||||||
|
self.assertEqual(collector.call_args.kwargs['profile'], 'windows:2025')
|
||||||
|
linux.assert_not_called()
|
||||||
|
self.assertIn('Local Windows', window.banner.text())
|
||||||
|
self.assertIn('2025', window.os_version.text())
|
||||||
|
self.assertEqual(len(window.records), 1)
|
||||||
|
self.assertEqual(window.nav.currentRow(), 0)
|
||||||
|
self.assertTrue(window.report_buttons[0].isEnabled())
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
report = Path(folder)/'windows.html'
|
||||||
|
with patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
|
||||||
|
window.quick_save.click()
|
||||||
|
self.assertIn('LOCAL WINDOWS INVENTORY', report.read_text())
|
||||||
|
window.close()
|
||||||
|
|||||||
224
tests/test_windows.py
Normal file
224
tests/test_windows.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from samsara.collectors.windows import (collect_local_windows, run_section, verify_server,
|
||||||
|
validate_section, WindowsCollectionError, SECTIONS)
|
||||||
|
from samsara.collectors.linux import CollectionCancelled
|
||||||
|
from samsara.models.inventory import validate_inventory
|
||||||
|
from samsara.collectors.importer import load_inventory
|
||||||
|
from samsara.analyzers.assessment import assess
|
||||||
|
from samsara.reporting.export import export_html, export_json
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(year='2022'):
|
||||||
|
return json.loads(Path(f'tests/fixtures/windows/server-{year}.json').read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def record_for(data, section):
|
||||||
|
record = {'section': section, **copy.deepcopy(data['checks'][section])}
|
||||||
|
if section == 'system':
|
||||||
|
record['data'] = copy.deepcopy(data['server'])
|
||||||
|
elif section == 'shares':
|
||||||
|
record['data'] = [r for r in data['inventory']['storage'] if r['type'] == 'SMB Share']
|
||||||
|
elif section == 'storage':
|
||||||
|
record['data'] = [r for r in data['inventory']['storage'] if r['type'] != 'SMB Share']
|
||||||
|
else:
|
||||||
|
record['data'] = copy.deepcopy(data['inventory'][section])
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsTests(unittest.TestCase):
|
||||||
|
def collect(self, data=None, runner=None, **kwargs):
|
||||||
|
data = data or fixture()
|
||||||
|
if runner is None:
|
||||||
|
runner = lambda section, executable, **options: record_for(data, section)
|
||||||
|
with patch('samsara.collectors.windows.platform.system', return_value='Windows'), patch('samsara.collectors.windows.powershell_executable', return_value=Path('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe')):
|
||||||
|
return collect_local_windows(runner=runner, **kwargs)
|
||||||
|
|
||||||
|
def test_all_server_contracts_profiles_and_arrays(self):
|
||||||
|
for year in ['2016','2019','2022','2025']:
|
||||||
|
with self.subTest(year=year):
|
||||||
|
data=fixture(year)
|
||||||
|
validate_inventory(data)
|
||||||
|
result=self.collect(data, profile='windows:'+year)
|
||||||
|
self.assertEqual(result['server']['os_version'],year)
|
||||||
|
self.assertFalse(result['synthetic'])
|
||||||
|
self.assertEqual(result['server']['collection_source'],'Local Windows read-only collection')
|
||||||
|
self.assertEqual(len(result['inventory']['storage']),2)
|
||||||
|
self.assertEqual(set(result['checks']),set(SECTIONS))
|
||||||
|
self.assertTrue(all(isinstance(rows,list) for rows in result['inventory'].values()))
|
||||||
|
self.assertTrue(all('started_at' in c and 'completed_at' in c for c in result['checks'].values()))
|
||||||
|
|
||||||
|
def test_profiles_and_workstation_cannot_masquerade_as_server(self):
|
||||||
|
for mutation in ['client','build','mismatch']:
|
||||||
|
data=fixture()
|
||||||
|
if mutation=='client':
|
||||||
|
data['server']['product_type']=1
|
||||||
|
elif mutation=='build':
|
||||||
|
data['server']['os_build']='99999'
|
||||||
|
with self.subTest(mutation=mutation), self.assertRaises(ValueError):
|
||||||
|
self.collect(data,profile='windows:2016' if mutation=='mismatch' else 'auto')
|
||||||
|
|
||||||
|
def test_platform_guard_never_runs_powershell_on_linux(self):
|
||||||
|
with patch('samsara.collectors.windows.platform.system', return_value='Linux'), patch('samsara.collectors.windows.run_section') as runner:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
collect_local_windows()
|
||||||
|
runner.assert_not_called()
|
||||||
|
|
||||||
|
def test_timeout_and_partial_permission_results_preserve_other_sections(self):
|
||||||
|
def runner(section, executable, **kwargs):
|
||||||
|
if section=='roles':
|
||||||
|
raise WindowsCollectionError('timeout','The local section exceeded its time limit.')
|
||||||
|
record=record_for(fixture(),section)
|
||||||
|
if section=='certificates':
|
||||||
|
record['status']='partial'
|
||||||
|
record['errors']=[{'code':'access_denied','scope':'Local machine certificate metadata','message':'Current account cannot read this scope.'}]
|
||||||
|
record['permission_limitations']=['One store denied.']
|
||||||
|
return record
|
||||||
|
data=self.collect(runner=runner)
|
||||||
|
self.assertEqual(data['checks']['roles']['status'],'failed')
|
||||||
|
self.assertEqual(data['checks']['roles']['errors'][0]['code'],'timeout')
|
||||||
|
self.assertTrue(data['inventory']['services'])
|
||||||
|
self.assertTrue(data['inventory']['certificates'])
|
||||||
|
self.assertEqual(data['checks']['certificates']['status'],'partial')
|
||||||
|
self.assertTrue(any('roles:' in f['evidence'] for f in assess(data)['findings']))
|
||||||
|
|
||||||
|
def test_identity_failures_are_fatal_and_bad_sections_are_explicit(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.collect(runner=lambda *args,**kwargs: {})
|
||||||
|
def runner(section, executable, **kwargs):
|
||||||
|
record=record_for(fixture(),section)
|
||||||
|
if section=='network':
|
||||||
|
record['data']=[{'state':'Listen'}]
|
||||||
|
return record
|
||||||
|
data=self.collect(runner=runner)
|
||||||
|
self.assertEqual(data['checks']['network']['errors'][0]['code'],'invalid_output')
|
||||||
|
self.assertEqual(data['inventory']['network'],[])
|
||||||
|
|
||||||
|
def test_global_timeout_marks_unattempted_sections(self):
|
||||||
|
# First iteration/system fits; remaining iterations exceed the deadline.
|
||||||
|
times=iter([0,0,*([10]*8)])
|
||||||
|
with patch('samsara.collectors.windows.time.monotonic',side_effect=lambda:next(times)):
|
||||||
|
data=self.collect(total_timeout=1)
|
||||||
|
self.assertEqual(data['checks']['services']['status'],'not_collected')
|
||||||
|
self.assertEqual(data['checks']['services']['errors'][0]['code'],'overall_timeout')
|
||||||
|
|
||||||
|
def test_cancellation_and_output_limits(self):
|
||||||
|
with self.assertRaises(CollectionCancelled):
|
||||||
|
self.collect(cancelled=lambda:True)
|
||||||
|
with patch('samsara.collectors.windows.MAX_OUTPUT',1), self.assertRaises(ValueError):
|
||||||
|
self.collect()
|
||||||
|
|
||||||
|
def test_secret_fields_are_excluded_and_check_metadata_survives_import(self):
|
||||||
|
def runner(section,executable,**kwargs):
|
||||||
|
record=record_for(fixture(),section)
|
||||||
|
if section=='services':
|
||||||
|
record['data'][0]['arguments']='SECRET'
|
||||||
|
record['data'][0]['PathName']='SECRET'
|
||||||
|
return record
|
||||||
|
data=self.collect(runner=runner)
|
||||||
|
self.assertNotIn('SECRET',json.dumps(data))
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
out=Path(folder)/'inventory.json'
|
||||||
|
export_json(out,data,assess(data))
|
||||||
|
imported=load_inventory(out)
|
||||||
|
self.assertNotIn('collection_source',imported['server'])
|
||||||
|
self.assertEqual(imported['checks']['system']['started_at'],data['checks']['system']['started_at'])
|
||||||
|
self.assertIn('permission_limitations',imported['checks']['services'])
|
||||||
|
html=Path(folder)/'report.html'
|
||||||
|
export_html(html,data,assess(data))
|
||||||
|
text=html.read_text()
|
||||||
|
self.assertIn('LOCAL WINDOWS INVENTORY',text)
|
||||||
|
self.assertIn('started_at',text)
|
||||||
|
self.assertIn('permission_limitations',text)
|
||||||
|
self.assertNotIn('SECRET',text)
|
||||||
|
|
||||||
|
def test_invalid_section_cannot_be_interpolated(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
run_section('services;whoami','powershell.exe')
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self,args,**kwargs):
|
||||||
|
self.returncode=None
|
||||||
|
self.args=args
|
||||||
|
self.kwargs=kwargs
|
||||||
|
self.killed=False
|
||||||
|
self.waited=False
|
||||||
|
def poll(self): return self.returncode
|
||||||
|
def kill(self): self.killed=True; self.returncode=-1
|
||||||
|
def wait(self): self.waited=True; return self.returncode
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessTests(unittest.TestCase):
|
||||||
|
def test_timeout_and_cancel_kill_and_reap_without_shell(self):
|
||||||
|
for cancel in [False,True]:
|
||||||
|
processes=[]
|
||||||
|
def create(args,**kwargs):
|
||||||
|
process=FakeProcess(args,**kwargs);processes.append(process);return process
|
||||||
|
calls=iter([False,True])
|
||||||
|
with self.subTest(cancel=cancel), patch('samsara.collectors.windows.subprocess.Popen',side_effect=create):
|
||||||
|
with self.assertRaises(CollectionCancelled if cancel else WindowsCollectionError):
|
||||||
|
run_section('services',Path('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'), cancelled=(lambda:next(calls)) if cancel else (lambda:False),timeout=0)
|
||||||
|
process=processes[0]
|
||||||
|
self.assertTrue(process.killed and process.waited)
|
||||||
|
self.assertFalse(process.kwargs['shell'])
|
||||||
|
self.assertIn('-NoProfile',process.args)
|
||||||
|
self.assertIn('-File',process.args)
|
||||||
|
self.assertNotIn('-Command',process.args)
|
||||||
|
self.assertNotIn('-ExecutionPolicy',process.args)
|
||||||
|
self.assertEqual(process.kwargs['env']['PSModulePath'],str(Path('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe').parent / 'Modules'))
|
||||||
|
|
||||||
|
def test_invalid_stdout_and_start_failure_are_sanitized(self):
|
||||||
|
def create(args,**kwargs):
|
||||||
|
process=FakeProcess(args,**kwargs);process.returncode=0
|
||||||
|
kwargs['stdout'].write(b'not JSON SECRET')
|
||||||
|
return process
|
||||||
|
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=create):
|
||||||
|
with self.assertRaises(WindowsCollectionError) as error:
|
||||||
|
run_section('services','powershell.exe')
|
||||||
|
self.assertEqual(error.exception.code,'invalid_output')
|
||||||
|
self.assertNotIn('SECRET',str(error.exception))
|
||||||
|
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=OSError('SECRET')):
|
||||||
|
with self.assertRaises(WindowsCollectionError) as error:
|
||||||
|
run_section('services','powershell.exe')
|
||||||
|
self.assertNotIn('SECRET',str(error.exception))
|
||||||
|
|
||||||
|
def test_successful_utf8_bom_zero_rows_and_nonzero_exit(self):
|
||||||
|
processes=[]
|
||||||
|
def create(args,**kwargs):
|
||||||
|
process=FakeProcess(args,**kwargs)
|
||||||
|
process.returncode=0
|
||||||
|
record=record_for(fixture(),'services')
|
||||||
|
record['data']=[]
|
||||||
|
kwargs['stdout'].write(b'\xef\xbb\xbf'+json.dumps(record).encode('utf-8'))
|
||||||
|
processes.append(process)
|
||||||
|
return process
|
||||||
|
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=create):
|
||||||
|
record=run_section('services','powershell.exe')
|
||||||
|
validate_section(record,'services')
|
||||||
|
self.assertEqual(record['data'],[])
|
||||||
|
self.assertTrue(processes[0].waited)
|
||||||
|
def failed(args,**kwargs):
|
||||||
|
process=FakeProcess(args,**kwargs);process.returncode=1
|
||||||
|
kwargs['stdout'].write(b'SECRET')
|
||||||
|
return process
|
||||||
|
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=failed):
|
||||||
|
with self.assertRaises(WindowsCollectionError) as error:
|
||||||
|
run_section('services','powershell.exe')
|
||||||
|
self.assertEqual(error.exception.code,'powershell_failed')
|
||||||
|
self.assertNotIn('SECRET',str(error.exception))
|
||||||
|
|
||||||
|
def test_completed_process_output_limit(self):
|
||||||
|
def create(args,**kwargs):
|
||||||
|
process=FakeProcess(args,**kwargs);process.returncode=0
|
||||||
|
kwargs['stdout'].write(b'x'*20)
|
||||||
|
return process
|
||||||
|
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=create), patch('samsara.collectors.windows.MAX_OUTPUT',10):
|
||||||
|
with self.assertRaises(WindowsCollectionError) as error:
|
||||||
|
run_section('services','powershell.exe')
|
||||||
|
self.assertEqual(error.exception.code,'output_limit')
|
||||||
51
tools/validate_windows_host.py
Normal file
51
tools/validate_windows_host.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Explicit real-host validation. Run manually on an authorized Windows Server only."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from samsara.collectors.windows import collect_local_windows
|
||||||
|
from samsara.analyzers.assessment import assess
|
||||||
|
from samsara.reporting.export import export_html, export_json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Perform local read-only Windows inventory and save validation evidence. No remote connections.')
|
||||||
|
parser.add_argument('--output-dir', required=True, help='A NEW directory for sensitive host inventory and reports')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if platform.system() != 'Windows':
|
||||||
|
parser.error('Real-host validation requires an authorized local Windows Server execution host.')
|
||||||
|
destination = Path(args.output_dir)
|
||||||
|
if destination.exists():
|
||||||
|
parser.error('Choose a new output directory; existing evidence will not be overwritten.')
|
||||||
|
try:
|
||||||
|
data = collect_local_windows(stage=lambda stage: print(stage, flush=True))
|
||||||
|
# collect_local_windows already enforces the supported Server identity and schema.
|
||||||
|
if data['synthetic']:
|
||||||
|
raise ValueError('A synthetic result cannot be used for real-host validation.')
|
||||||
|
destination.mkdir(parents=True, exist_ok=False)
|
||||||
|
assessment = assess(data)
|
||||||
|
export_json(destination / 'windows-assessment.json', data, assessment)
|
||||||
|
export_html(destination / 'windows-assessment.html', data, assessment)
|
||||||
|
summary = {
|
||||||
|
'real_inventory_collected': True, 'synthetic': False, 'collected_at': data['collected_at'],
|
||||||
|
'server_release': data['server']['os_version'], 'os_build': data['server']['os_build'],
|
||||||
|
'product_type': data['server']['product_type'],
|
||||||
|
'schema_validation': 'passed',
|
||||||
|
'checks': {name: {'status': check['status'], 'error_codes': [e['code'] for e in check['errors']],
|
||||||
|
'permission_limitations_reported': bool(check['permission_limitations'])}
|
||||||
|
for name, check in data['checks'].items()},
|
||||||
|
'counts': {name: len(rows) for name, rows in data['inventory'].items()},
|
||||||
|
'review_required': True,
|
||||||
|
}
|
||||||
|
(destination / 'validation-summary.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
print('Review partial/failed checks. Keep full inventory/reports private; validation-summary.json omits hostnames, accounts, IPs, paths and certificate identifiers.')
|
||||||
|
except (OSError, ValueError, RuntimeError):
|
||||||
|
parser.exit(1, 'Validation did not finish. Check supported local Server OS, 64-bit Python, script policy, read permissions and a new writable destination. Raw command diagnostics are excluded.\n')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user