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