Files
Samsara/samsara/collectors/windows.py

194 lines
11 KiB
Python

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