194 lines
11 KiB
Python
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()
|