Add local Windows Server inventory collection and validation; include core package
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user