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

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

View File

@@ -2,22 +2,9 @@
import json
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
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):
@@ -32,16 +19,35 @@ def load_inventory(path):
# A report wrapper is accepted, but its precomputed assessment is never trusted.
if isinstance(data, dict) and 'inventory_result' in data:
data = data['inventory_result']
schema = json.loads(SCHEMA.read_text(encoding='utf-8'))
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.')
validate_inventory(data)
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
# contain command lines or credentials from external collection tools.
data['checks'] = {name: {'status': check['status'], 'message':
'Imported check status. Consult collector diagnostics separately; details excluded to limit secret exposure.'}
for name, check in data['checks'].items()}
data['server'] = {key: value for key, value in data['server'].items() if key in SERVER_FIELDS}
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
safe_errors = {
'access_denied': 'Current account could not read this scope.',
'command_unavailable': 'Required command was unavailable.',
'timeout': 'Section time limit exceeded.',
'overall_timeout': 'Overall collection time limit exceeded.',
'output_limit': 'Collection output limit exceeded.',
'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)

View 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
View File

@@ -0,0 +1 @@

49
samsara/core/detection.py Normal file
View 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
View 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

View 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()

View 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

View File

@@ -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>"
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>'
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>"
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>"

View File

@@ -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.importer import load_inventory
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.analyzers.assessment import assess
from samsara.analyzers.summary import summarize
@@ -28,11 +29,12 @@ class CollectionJob(QThread):
if self.source == 'local':
self.stage.emit('Detecting operating system…')
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…')
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':
self.stage.emit('Reading saved inventory…')
data = load_inventory(self.inventory_path)
@@ -308,7 +310,7 @@ class MainWindow(QMainWindow):
reports.addStretch()
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('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.addStretch()