Privacy hardening, remote-peer IP masking and review fixes

- Add privacy projection (core/privacy.py), private report storage, bounded
  process capture; omit identities, free text and unapproved paths
- Mask remote peer IPs: only loopback/host/wildcard stay numeric, others are
  reduced to remote_scope private/public (schema + scoring updated)
- Map ss TCP states to canonical names; ASCII-only numeric fields; fix
  "Microsoft Visual C++" product matching (Python and PowerShell)
- Merge 7fed3b9: -Diagnostic switch, serialization exit code 3, PS7 module
  path, UI error detail; keep hardened task/certificate collectors
- Add audit docs, privacy docs and regression tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 18:50:24 -08:00
parent 7fed3b9ab9
commit 610a3bb447
51 changed files with 2719 additions and 604 deletions

View File

@@ -12,37 +12,25 @@ foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1
$script:SamsaraIsElevated = $true
$script:SyntheticBuild = '20348'
function Get-CimInstance {
[CmdletBinding()] param($ClassName, $Property, $Filter)
[CmdletBinding()] param($ClassName, $Property, $Filter, $Namespace)
switch ($ClassName) {
'Win32_OperatingSystem' { [pscustomobject]@{ Caption='Synthetic Windows Server'; Version=('10.0.' + $script:SyntheticBuild); BuildNumber=$script:SyntheticBuild; ProductType=3; LastBootUpTime=[DateTime]::UtcNow.AddDays(-2) } }
'Win32_ComputerSystem' { [pscustomobject]@{ Domain='example.test'; PartOfDomain=$true } }
'Win32_NetworkAdapterConfiguration' { [pscustomobject]@{ Description='Synthetic NIC'; IPAddress=@('192.0.2.20') } }
'Win32_Service' { [pscustomobject]@{ Name='W3SVC'; DisplayName='Synthetic Web'; State='Running'; StartMode='Auto'; StartName='EXAMPLE\svc_web'; ProcessId=123; PathName='SECRET command arguments' } }
'MSFT_ScheduledTask' { [pscustomobject]@{ TaskName='SECRET'; TaskPath='\Custom\SECRET\'; State=3 } }
'Win32_LogicalDisk' { [pscustomobject]@{ DeviceID='C:'; Size=100GB; FreeSpace=50GB; FileSystem='NTFS' } }
default { throw 'Unexpected synthetic CIM query' }
}
}
function Get-WindowsFeature { [CmdletBinding()] param() [pscustomobject]@{ Name='Web-Server'; DisplayName='Synthetic Web Server'; Installed=$true; InstallState='Installed'; FeatureType='Role' } }
function Get-Process { [CmdletBinding()] param() [pscustomobject]@{ Id=123; ProcessName='SyntheticWorker'; CommandLine='SECRET' } }
function Get-NetTCPConnection { [CmdletBinding()] param() [pscustomobject]@{ State='Established'; LocalAddress='192.0.2.20'; LocalPort=443; RemoteAddress='192.0.2.21'; RemotePort=50000; OwningProcess=123 } }
function Get-ScheduledTask {
[CmdletBinding()] param()
[pscustomobject]@{ TaskName='Synthetic report'; TaskPath='\Example\'; State='Ready'
Settings=[pscustomobject]@{ Enabled=$true }; Principal=[pscustomobject]@{ UserId='EXAMPLE\svc_report' }
Actions=@([pscustomobject]@{ Execute='C:\Tools\report.exe'; Arguments='--password=SECRET' })
Triggers=@([pscustomobject]@{ CimClass=[pscustomobject]@{ CimClassName='MSFT_TaskDailyTrigger' }; StartBoundary='2026-10-01T02:00:00'; Enabled=$true; DaysInterval=1; Repetition=$null }) }
}
function Get-Volume { [CmdletBinding()] param() [pscustomobject]@{ FileSystemLabel='Synthetic OS'; DriveLetter='C'; Path='C:\'; FileSystem='NTFS'; Size=100GB; SizeRemaining=50GB } }
function Get-SmbShare { [CmdletBinding()] param() [pscustomobject]@{ Name='SyntheticReports'; Path='C:\Reports'; Special=$false } }
function Get-SmbOpenFile {
[CmdletBinding()] param()
[pscustomobject]@{ Path='C:\Reports\SECRET-file.txt'; ShareRelativePath='SECRET-file.txt'; ClientComputerName='192.0.2.21'; ClientUserName='SECRET' }
[pscustomobject]@{ Path='C:\Reports\SECRET-admin.txt'; ShareRelativePath='Reports\SECRET-admin.txt'; ClientComputerName='192.0.2.21'; ClientUserName='SECRET' }
}
function Get-ChildItem {
[CmdletBinding()] param($LiteralPath)
if ($LiteralPath -eq 'Cert:\LocalMachine') { [pscustomobject]@{ PSPath='Cert:\LocalMachine\My'; PSChildName='My' } }
else { [pscustomobject]@{ Subject='CN=synthetic.example.test'; Issuer='CN=Synthetic CA'; NotAfter=[DateTime]::UtcNow.AddDays(30); Thumbprint='0000000000000000000000000000000000000000'; PrivateKey='SECRET' } }
}
function Get-ScheduledTask { throw 'Forbidden task object enumeration' }
function Get-Volume { throw 'Forbidden volume label enumeration' }
function Get-SmbShare { [CmdletBinding()] param() [pscustomobject]@{ Name='SECRET'; Path='C:\SECRET'; Special=$false } }
function Get-SmbOpenFile { throw 'Forbidden open-file enumeration' }
function Get-ChildItem { throw 'Forbidden certificate enumeration' }
function Assert-True { param($Value, [string]$Message) if (-not $Value) { throw $Message } }
$collectors = [ordered]@{
system={param($r) Get-SamsaraSystem $r}; roles={param($r) Get-SamsaraRoles $r}
@@ -52,12 +40,13 @@ $collectors = [ordered]@{
}
foreach ($name in $collectors.Keys) {
$section = Invoke-SamsaraSection $name $collectors[$name]
Assert-True ($section.status -eq 'complete') ('Synthetic section failed: ' + $name)
$expected = if ($name -eq 'certificates') { 'not_collected' } elseif ($name -in @('tasks','storage','shares')) { 'partial' } else { 'complete' }
Assert-True ($section.status -eq $expected) ('Synthetic section failed: ' + $name)
Assert-True ($section.started_at -and $section.completed_at) 'Missing timestamps'
Assert-True (($section | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Secret field serialized'
if ($name -eq 'network') { Assert-True ($section.data[0].process -eq 'SyntheticWorker') 'Process correlation failed' }
if ($name -eq 'shares') { Assert-True ($section.data[0].observed_open_files -eq 1) 'Share activity correlation missing' }
if ($name -eq 'tasks') { Assert-True ($section.data[0].executables -eq 'report.exe') 'Executable basename missing' }
if ($name -eq 'network') { Assert-True ($section.data[0].process -eq 'processes-123') 'Process correlation failed' }
if ($name -eq 'shares') { Assert-True ($null -eq $section.data[0].observed_open_files) 'Unexpected open-file evidence' }
if ($name -eq 'tasks') { Assert-True ($null -eq $section.data[0].executables) 'Task action leaked' }
}
foreach ($build in @('14393','17763','20348','26100')) {
$script:SyntheticBuild=$build
@@ -66,13 +55,13 @@ foreach ($build in @('14393','17763','20348','26100')) {
}
# Test registry projection with in-memory objects; never open a real registry hive.
$fakeEntry = New-Object PSObject
$fakeEntry | Add-Member ScriptMethod GetValue { param($key) switch ($key) { 'DisplayName' {'Synthetic application'}; 'DisplayVersion' {'1.0'}; 'Publisher' {'Example'}; 'InstallDate' {'20260101'}; default {throw 'Unexpected registry value requested'} } }
$fakeEntry | Add-Member ScriptMethod GetValue { param($key,$default,$options) switch ($key) { 'DisplayName' {'Synthetic application'}; 'DisplayVersion' {'1.0'}; 'Publisher' {'Example'}; 'InstallDate' {'20260101'}; default {throw 'Unexpected registry value requested'} } }
$fakeEntry | Add-Member ScriptMethod Dispose { }
$fakeUninstall = New-Object PSObject
$fakeUninstall | Add-Member ScriptMethod GetSubKeyNames { @('Example') }
$fakeUninstall | Add-Member ScriptMethod OpenSubKey { param($name,$writable) if ($writable) {throw 'Writable registry access'}; return $fakeEntry }
$section=Invoke-SamsaraSection 'software' {param($r) Invoke-SamsaraRead $r 'Synthetic registry view' { Read-SamsaraSoftwareEntries $r $fakeUninstall 'Registry64' } }
Assert-True ($section.status -eq 'complete' -and $section.data[0].name -eq 'Synthetic application') 'Registry metadata projection failed'
Assert-True ($section.status -eq 'complete' -and $section.data[0].name -eq 'products-1') 'Registry metadata projection failed'
function Get-SmbShare { [CmdletBinding()] param() throw [UnauthorizedAccessException]::new('SECRET protected details') }
$section=Invoke-SamsaraSection 'shares' {param($r) Get-SamsaraShares $r}
Assert-True ($section.status -eq 'partial' -and $section.data.Count -eq 0) 'Denied share enumeration was not partial/empty'
@@ -82,63 +71,3 @@ $script:SamsaraIsElevated=$false
$section=Invoke-SamsaraSection 'services' {param($r) Get-SamsaraServices $r}
Assert-True ($section.status -eq 'partial' -and $section.permission_limitations.Count -gt 0) 'Non-elevated scope not marked partial'
Write-Output 'PowerShell AST and synthetic collector tests passed. No real Windows inventory was collected.'
# Reliability cases: mocks never access real tasks, certificates or private keys.
$script:SamsaraIsElevated=$true
$script:SamsaraDiagnostic=$true
function New-TestTask {
param([bool]$Enabled=$true)
[pscustomobject]@{TaskName='Example';TaskPath='\';State='Ready';Settings=[pscustomobject]@{Enabled=$Enabled};Principal=[pscustomobject]@{UserId='SYSTEM'};Actions=@([pscustomobject]@{Execute='"C:\Tools\bad.exe"';Arguments='SECRET'});Triggers=@()}
}
function Get-ScheduledTask { [CmdletBinding()]param() New-TestTask $true; New-TestTask $false }
$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r}
Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 2 -and -not $r.data[1].enabled) 'Normal/disabled tasks lost'
Assert-True ($r.data[0].executables -eq '[executable name omitted]') 'Malformed executable not sanitized'
function Get-ScheduledTask { [CmdletBinding()]param() }
$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r}
Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 0) 'Successful empty task query failed'
function Get-ScheduledTask { [CmdletBinding()]param() Write-Error 'SECRET' -Category PermissionDenied }
$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r}
Assert-True ($r.status -eq 'failed' -and $r.data.Count -eq 0) 'Failed task query treated as empty success'
function Get-ScheduledTask {
[CmdletBinding()]param()
New-TestTask
$bad=New-TestTask
$bad | Add-Member ScriptProperty TaskName { throw [UnauthorizedAccessException]::new('SECRET') } -Force
# Throw from ToString to reliably exercise projection failures on both runtimes.
$value=New-Object PSObject
$value | Add-Member ScriptMethod ToString { throw [UnauthorizedAccessException]::new('SECRET') } -Force
$bad.TaskPath=$value
$bad
}
$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r}
Assert-True ($r.status -eq 'partial' -and $r.data.Count -eq 1 -and $r.errors.Count -gt 0) 'Individual task failure discarded healthy records'
Assert-True (($r | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Diagnostic leaked secret'
$script:StoreMode='normal'
function Get-ChildItem {
[CmdletBinding()]param($LiteralPath)
if ($LiteralPath -eq 'Cert:\LocalMachine') {
foreach ($n in @('My','Root','Empty')) { [pscustomobject]@{PSChildName=$n;PSPath=('Microsoft.PowerShell.Security\Certificate::'+$n)} }
} elseif ($LiteralPath -eq 'Cert:\LocalMachine\Empty') { return }
elseif ($LiteralPath -in @('Cert:\LocalMachine\My','Cert:\LocalMachine\Root')) {
if ($script:StoreMode -eq 'denied' -or ($script:StoreMode -eq 'partial' -and $LiteralPath -like '*Root')) { throw [UnauthorizedAccessException]::new('SECRET') }
[pscustomobject]@{Subject='CN=Example';Issuer='CN=CA';Thumbprint='00';NotAfter=[datetime]::UtcNow;PrivateKey='SECRET'}
} else { throw 'Incorrect certificate provider path' }
}
$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r}
Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 2 -and @($r.data.store | Select-Object -Unique).Count -eq 2) 'Multiple stores/empty store failed'
$script:StoreMode='partial'
$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r}
Assert-True ($r.status -eq 'partial' -and $r.data.Count -eq 1 -and $r.errors[0].code -eq 'access_denied') 'Partial store failure lost records'
Assert-True (($r | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Certificate diagnostics leaked secret'
function Get-ChildItem { [CmdletBinding()]param($LiteralPath) if ($LiteralPath -eq 'Cert:\LocalMachine') { [pscustomobject]@{PSChildName='My'} } else { throw [UnauthorizedAccessException]::new('SECRET') } }
$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r}
Assert-True ($r.status -eq 'failed' -and $r.data.Count -eq 0) 'Inaccessible stores treated as empty success'
function Get-ChildItem { [CmdletBinding()]param($LiteralPath) }
$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r}
Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 0) 'Empty store list failed'
function Get-ScheduledTask { [CmdletBinding()]param() New-TestTask }
function ConvertTo-Json { [CmdletBinding()]param($InputObject,$Depth,[switch]$Compress) throw [InvalidOperationException]::new('SECRET serialization') }
$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r}
Assert-True ($r.status -ne 'complete' -and $r.data.Count -eq 0 -and $r.errors.Count -eq 1) 'Serialization failure accepted as evidence'
Remove-Item Function:\ConvertTo-Json
Write-Output 'Reliability collector tests passed.'

14
tests/report_helpers.py Normal file
View File

@@ -0,0 +1,14 @@
"""Authorize a private temporary root for legacy report rendering tests only."""
from pathlib import Path
from unittest.mock import patch
from samsara.reporting.export import export_html as _html, export_json as _json
def export_html(path, data, assessment):
with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(path).parent):
return _html(path,data,assessment)
def export_json(path, data, assessment):
with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(path).parent):
return _json(path,data,assessment)

View File

@@ -5,7 +5,7 @@ import unittest
from samsara.core.validation import validate_hostname
from samsara.collectors.demo import collect_demo, DEMO_HOST
from samsara.analyzers.assessment import assess
from samsara.reporting.export import export_html, export_json
from report_helpers import export_html, export_json
class CoreTests(unittest.TestCase):
def test_hostname(self):

View File

@@ -23,7 +23,7 @@ class ImportTests(unittest.TestCase):
data = self.load(collect_demo(LINUX_DEMO_HOST))
self.assertEqual(data['server']['platform'], 'linux')
result = assess(data)
self.assertTrue(any('/srv/reports' in item['evidence'] for item in result['findings']))
self.assertTrue(any('Share-' in item['evidence'] for item in result['findings']))
self.assertTrue(any('certificates:' in item['evidence'] for item in result['findings']))
def test_report_wrapper_recomputed_and_sensitive_fields_removed(self):

View File

@@ -8,7 +8,7 @@ from samsara.collectors.linux import (collect_local_linux, parse_connections,
parse_units, run_command, CollectionCancelled)
from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST
from samsara.analyzers.assessment import assess
from samsara.reporting.export import export_html
from report_helpers import export_html
class LinuxTests(unittest.TestCase):
@@ -83,7 +83,44 @@ class LinuxTests(unittest.TestCase):
@unittest.skipUnless(__import__('sys').platform.startswith('linux'), 'Linux command test')
def test_command_timeout_and_cancellation(self):
with self.assertRaisesRegex(RuntimeError, 'timed out'):
with self.assertRaisesRegex(RuntimeError, 'timeout'):
run_command(['sleep', '1'], timeout=.05)
with self.assertRaises(CollectionCancelled):
run_command(['sleep', '1'], cancelled=lambda: True)
class RegressionReviewTests(unittest.TestCase):
def test_ss_states_map_to_canonical_names(self):
from samsara.collectors.linux import parse_connections
rows = parse_connections('TIME-WAIT 0 0 10.0.0.5:44321 10.0.0.9:443\nCLOSE-WAIT 0 0 10.0.0.5:1 10.0.0.9:2\nFIN-WAIT-1 0 0 10.0.0.5:3 10.0.0.9:4\n')
self.assertEqual([r['state'] for r in rows], ['TimeWait', 'CloseWait', 'FinWait1'])
def test_non_ascii_digits_and_cpp_product_label(self):
import json
from pathlib import Path
from samsara.core.privacy import minimize_inventory, numeric_version, safe_time
self.assertEqual(numeric_version('١٢.٣٤'), 'Not collected')
self.assertIsNone(safe_time('٢٠٢٦-٠١-٠١T٠٠:٠٠:٠٠Z'))
data = json.loads(Path('samsara/assets/demo_inventory.json').read_text())
data['server']['os_build'] = '١٢٣٤٥'
data['inventory']['software'] = [{'name': 'Microsoft Visual C++ 2015-2022 Redistributable (x64)', 'version': '14.38.33135'}]
clean = minimize_inventory(data)
self.assertNotEqual(clean['server']['os_build'], '١٢٣٤٥')
self.assertEqual(clean['inventory']['software'][0]['name'], 'Microsoft Visual C++')
def test_remote_peer_addresses_are_masked_but_scoring_survives(self):
import copy
from samsara.core.privacy import minimize_inventory
from samsara.models.inventory import validate_inventory
data = json.loads(Path('tests/fixtures/scoring/active_sql_server.json').read_text())
before = assess(minimize_inventory(copy.deepcopy(data)))['score']
for row in data['inventory']['network']:
if row.get('remote_address') not in ('', '0.0.0.0', '::'):
row['remote_address'] = '93.184.216.34'
clean = minimize_inventory(data)
validate_inventory(clean)
self.assertNotIn('93.184.216.34', json.dumps(clean))
self.assertEqual(minimize_inventory(clean), clean)
self.assertEqual(assess(clean)['score'], before)
self.assertNotIn('93.184.216.34', json.dumps(assess(clean)))
self.assertIn('public', {r['remote_scope'] for r in clean['inventory']['network']})

130
tests/test_privacy.py Normal file
View File

@@ -0,0 +1,130 @@
"""Synthetic disclosure regressions. No real inventory or patient data is used."""
import copy
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import unittest
from unittest.mock import patch
from samsara.core.privacy import minimize_inventory, load_privacy_policy, canonical_share_root
from samsara.core.storage import write_private, require_local_path
from samsara.core.process import capture, ProcessFailure, CollectionCancelled
from samsara.models.inventory import validate_inventory
from samsara.reporting.export import export_html, export_json
from samsara.analyzers.assessment import assess
CANARY='SYNTHETIC_PERSON_CANARY_9f4'
def fixture(name='active_sql_server'):
return json.loads(Path('tests/fixtures/scoring',name+'.json').read_text())
class PrivacyTests(unittest.TestCase):
def test_all_free_text_and_stale_findings_are_removed(self):
raw=fixture();original=copy.deepcopy(raw)
raw['server'].update(hostname=CANARY,domain=CANARY,network_adapters=[CANARY])
raw['inventory']['services'][-1].update(name='MSSQL$'+CANARY,account=CANARY,display_name=CANARY)
raw['inventory']['tasks']=[{'name':CANARY,'path':'\\'+CANARY+'\\','enabled':True,'account':CANARY,'triggers':CANARY,'arguments':CANARY}]
raw['inventory']['software']=[{'name':'Microsoft SQL Server '+CANARY,'publisher':CANARY,'version':'16.0.1 '+CANARY}]
raw['inventory']['storage']=[{'type':'SMB Share','name':CANARY,'path':'C:\\'+CANARY}]
raw['inventory']['certificates']=[{'subject':CANARY,'issuer':CANARY,'thumbprint':CANARY,'expires':'2027-01-01T00:00:00Z'}]
raw['checks']['services'].update(message=CANARY,errors=[{'code':CANARY,'scope':CANARY,'message':CANARY}])
raw['checks'][CANARY]={'status':'failed','message':CANARY}
raw['privacy']={'profile':'minimized-metadata-v1'} # A marker cannot bypass projection.
snapshot=copy.deepcopy(raw)
clean=minimize_inventory(raw)
self.assertEqual(raw,snapshot)
self.assertNotIn(CANARY,json.dumps(clean))
validate_inventory(clean)
self.assertEqual(assess(clean)['score'],70)
with tempfile.TemporaryDirectory() as folder:
policy=load_privacy_policy();policy['report_directory']=folder
with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.load_privacy_policy',return_value=policy):
assessment=assess(original)
assessment['findings'][0]['evidence']=CANARY
export_html(Path(folder)/'test.html',raw,assessment)
export_json(Path(folder)/'test.json',raw,assessment)
for path in Path(folder).iterdir(): self.assertNotIn(CANARY,path.read_text())
self.assertEqual(len(list(Path(folder).iterdir())),2)
def test_exact_roots_only_no_descendant_or_network_approval(self):
policy=load_privacy_policy();policy['approved_share_roots']=['D:\\Approved']
raw=fixture();raw['inventory']['storage']=[{'type':'SMB Share','name':'private','path':p} for p in ['D:\\Approved','D:\\Approved\\child','D:\\Other']]
clean=minimize_inventory(raw,policy)
self.assertEqual(clean['inventory']['storage'][0]['path'],'D:\\Approved')
self.assertNotIn('child',json.dumps(clean))
for bad in ['\\\\server\\share','//server/share','D:\\Approved\\..\\Other']:
with self.assertRaises(ValueError): canonical_share_root(bad)
def test_workload_scores_survive_minimization(self):
for name,score in [('clean_windows',0),('inactive_game_server',0),('active_sql_server',70),('active_iis_server',70),('domain_controller',95),('active_file_server',80),('incomplete_collection',0)]:
with self.subTest(name=name):
clean=minimize_inventory(fixture(name))
self.assertEqual(assess(clean)['score'],score)
self.assertTrue(assess(clean)['review_required'])
self.assertEqual(minimize_inventory(clean),clean)
def test_dates_closed_schema_and_oversized_input_fail(self):
raw=fixture();raw['collected_at']='not a date'
with self.assertRaises(ValueError):validate_inventory(raw)
raw=fixture();raw['inventory']['services'][0]['unexpected']={'sensitive':CANARY}
with self.assertRaises(ValueError):validate_inventory(raw)
raw=fixture();raw['inventory']['tasks']=[{}]*20001
with self.assertRaises(ValueError):minimize_inventory(raw)
def test_default_collectors_do_not_request_high_risk_scopes(self):
for name,forbidden in [('Get-ServerStorage.ps1','Get-SmbOpenFile'),('Get-ServerCertificates.ps1','Get-ChildItem'),('Get-ServerTasks.ps1','Get-ScheduledTask'),('Get-ServerServices.ps1','StartName')]:
self.assertNotIn(forbidden,Path('powershell',name).read_text())
self.assertNotIn('TemporaryFile',Path('samsara/collectors/windows.py').read_text())
self.assertNotIn('TemporaryFile',Path('samsara/collectors/linux.py').read_text())
class StorageTests(unittest.TestCase):
@unittest.skipIf(os.name=='nt','POSIX permission test; native Windows ACL acceptance requires a provisioned lab root.')
def test_private_root_exclusive_files_and_destination_boundary(self):
with tempfile.TemporaryDirectory() as base:
root=Path(base)/'reports';policy=load_privacy_policy();policy['report_directory']=str(root)
with patch('samsara.core.storage.load_privacy_policy',return_value=policy):
output=root/'report.json';write_private(output,'{}')
if os.name!='nt':
self.assertEqual(stat.S_IMODE(root.stat().st_mode),0o700)
self.assertEqual(stat.S_IMODE(output.stat().st_mode),0o600)
with self.assertRaises(FileExistsError):write_private(output,'overwrite')
with self.assertRaises(ValueError):write_private(Path(base)/'outside.json','{}')
self.assertEqual(output.read_text(),'{}')
if os.name!='nt':
root.chmod(0o755)
with self.assertRaises(ValueError):write_private(root/'unsafe.json','{}')
def test_network_uri_symlink_paths_rejected(self):
for value in ['\\\\server\\share\\data.json','//server/share/data.json','https://host/path']:
with self.assertRaises(ValueError):require_local_path(value)
if os.name!='nt':
with tempfile.TemporaryDirectory() as base:
link=Path(base)/'link';link.symlink_to(Path(base),target_is_directory=True)
with self.assertRaises(ValueError):require_local_path(link/'data.json')
@unittest.skipIf(os.name=='nt','POSIX FIFO regression')
def test_import_rejects_fifo_without_waiting_for_a_writer(self):
from samsara.collectors.importer import load_inventory
with tempfile.TemporaryDirectory() as base:
pipe=Path(base)/'inventory.json'
os.mkfifo(pipe)
with self.assertRaises(ValueError): load_inventory(pipe)
class CaptureTests(unittest.TestCase):
def test_bounded_memory_capture_timeout_cancel_and_sanitized_errors(self):
env={'PATH':os.defpath, **{key:os.environ[key] for key in ('SystemRoot','WINDIR') if key in os.environ}}
self.assertEqual(capture([sys.executable,'-c','print("synthetic")'],env=env,timeout=3),b'synthetic\n')
for code,script,options in [('output_limit','print("x"*200000)',{'limit':100}),('timeout','import time; time.sleep(10)',{'timeout':.05}),('collection_failed','raise RuntimeError("'+CANARY+'")',{})]:
with self.subTest(code=code), self.assertRaises(ProcessFailure) as raised:
capture([sys.executable,'-c',script],env=env,**dict({'timeout':3},**options))
self.assertEqual(raised.exception.code,code)
self.assertNotIn(CANARY,str(raised.exception))
with self.assertRaises(CollectionCancelled):
capture([sys.executable,'-c','print(1)'],env=env,timeout=3,cancelled=lambda:True)

View File

@@ -7,7 +7,7 @@ from samsara.analyzers.assessment import assess
from samsara.analyzers.dependencies import CONFIRMED, POTENTIAL, STANDARD, UNKNOWN
from samsara.analyzers.policy import load_policy
from samsara.models.inventory import validate_inventory
from samsara.reporting.export import export_html, export_json
from report_helpers import export_html, export_json
from samsara.collectors.importer import load_inventory
@@ -191,15 +191,14 @@ class ScoringRegressionTests(unittest.TestCase):
self.assertIn('Historical usage validation',text)
self.assertNotIn('medium-risk dependencies',text)
self.assertLess(text.count('<tr>'),60)
inventory=list(Path(folder).glob('*.inventory-*.json'))
self.assertEqual(len(inventory),1)
raw=json.loads(inventory[0].read_text())
self.assertEqual(raw,data)
self.assertEqual(len(raw['inventory']['services']),86)
# Re-export reuses an identical artifact rather than losing inventory.
export_html(report,data,assess(data))
export_json(Path(folder)/'assessment.json',data,assess(data))
self.assertEqual(load_inventory(inventory[0])['inventory'],load_inventory(Path(folder)/'assessment.json')['inventory'])
self.assertEqual(list(Path(folder).glob('*.inventory-*.json')),[])
with self.assertRaises(FileExistsError):
export_html(report,data,assess(data))
output=Path(folder)/'assessment.json'
export_json(output,data,assess(data))
clean=load_inventory(output)
self.assertEqual(len(clean['inventory']['services']),86)
self.assertNotEqual(clean['server']['hostname'], data['server']['hostname'])
def test_custom_jobs_not_misclassified_by_substrings_and_zero_points_maintenance(self):
data=fixture('clean_windows')
@@ -218,9 +217,7 @@ class ScoringRegressionTests(unittest.TestCase):
report=Path(folder)/'assessment.html'
export_html(report,data,result)
self.assertNotIn('<script>',report.read_text())
self.assertIn('&lt;script&gt;',report.read_text())
artifact=next(Path(folder).glob('*.inventory-*.json'))
artifact.write_text('unrelated data')
self.assertNotIn('owned',report.read_text())
self.assertEqual(list(Path(folder).glob('*.inventory-*.json')),[])
with self.assertRaises(FileExistsError):
export_html(report,data,result)
self.assertEqual(artifact.read_text(),'unrelated data')

View File

@@ -78,7 +78,7 @@ class UITests(unittest.TestCase):
self.assertTrue(window.report_buttons[0].isEnabled())
with tempfile.TemporaryDirectory() as folder:
report = Path(folder) / 'local.html'
with patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(folder)), patch('samsara.ui.window.report_directory',return_value=Path(folder)), patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
window.quick_save.click()
self.assertIn('LOCAL LINUX INVENTORY', report.read_text())
window.close()
@@ -137,7 +137,7 @@ class UITests(unittest.TestCase):
self.assertTrue(window.report_buttons[0].isEnabled())
with tempfile.TemporaryDirectory() as folder:
report = Path(folder)/'windows.html'
with patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(folder)), patch('samsara.ui.window.report_directory',return_value=Path(folder)), patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
window.quick_save.click()
self.assertIn('LOCAL WINDOWS INVENTORY', report.read_text())
window.close()
@@ -171,3 +171,20 @@ class UITests(unittest.TestCase):
self.assertEqual(window.assessment['scoring']['policy_id'],original_policy)
window.reset_policy()
window.close()
def test_privacy_and_bounded_history_clear(self):
import json
window=MainWindow()
raw=collect_demo(LINUX_DEMO_HOST)
raw['server']['hostname']='SYNTHETIC_PERSON_UI_CANARY'
for _ in range(12): window.collection_done(raw)
self.assertEqual(len(window.records),10)
self.assertEqual(window.history_list.count(),10)
self.assertNotIn('SYNTHETIC_PERSON_UI_CANARY',json.dumps(window.records))
window.clear_history_button.click()
self.assertEqual(window.records,[])
self.assertEqual(window.history,[])
self.assertIsNone(window.data)
self.assertFalse(window.quick_save.isEnabled())
self.assertEqual(window.scheduled_jobs.text(),'')
window.close()

View File

@@ -1,4 +1,5 @@
import copy
import io
import json
from pathlib import Path
import tempfile
@@ -11,7 +12,7 @@ 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
from report_helpers import export_html, export_json
def fixture(year='2022'):
@@ -49,9 +50,9 @@ class WindowsTests(unittest.TestCase):
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.assertEqual(set(result['checks']),set(SECTIONS)|{'privacy'})
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()))
self.assertTrue(all('started_at' in c and 'completed_at' in c for k,c in result['checks'].items() if k!='privacy'))
def test_profiles_and_workstation_cannot_masquerade_as_server(self):
for mutation in ['client','build','mismatch']:
@@ -144,6 +145,7 @@ class WindowsTests(unittest.TestCase):
class FakeProcess:
def __init__(self,args,**kwargs):
self.stdout=io.BytesIO()
self.returncode=None
self.args=args
self.kwargs=kwargs
@@ -151,29 +153,26 @@ class FakeProcess:
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
def wait(self,timeout=None): self.waited=True; return self.returncode
class ProcessTests(unittest.TestCase):
def test_serialization_exit_and_diagnostic_switch(self):
def create(args, **kwargs):
from samsara.core.process import ProcessFailure
def fake_capture(args, **kwargs):
self.assertIn('-Diagnostic', args)
process = FakeProcess(args, **kwargs)
process.returncode = 3
kwargs['stdout'].write(b'SECRET')
return process
with patch('samsara.collectors.windows.subprocess.Popen', side_effect=create):
raise ProcessFailure('collection_failed', 3)
with patch('samsara.collectors.windows.capture', side_effect=fake_capture):
with self.assertRaises(WindowsCollectionError) as error:
run_section('tasks', 'powershell.exe', diagnostic=True)
self.assertEqual(error.exception.code, 'serialization_failed')
self.assertNotIn('SECRET', str(error.exception))
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])
calls=iter([False,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)
@@ -189,7 +188,8 @@ class ProcessTests(unittest.TestCase):
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')
process.stdout.write(b'not JSON SECRET')
process.stdout.seek(0)
return process
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=create):
with self.assertRaises(WindowsCollectionError) as error:
@@ -208,8 +208,9 @@ class ProcessTests(unittest.TestCase):
process.returncode=0
record=record_for(fixture(),'services')
record['data']=[]
kwargs['stdout'].write(b'\xef\xbb\xbf'+json.dumps(record).encode('utf-8'))
process.stdout.write(b'\xef\xbb\xbf'+json.dumps(record).encode('utf-8'))
processes.append(process)
process.stdout.seek(0)
return process
with patch('samsara.collectors.windows.subprocess.Popen',side_effect=create):
record=run_section('services','powershell.exe')
@@ -218,18 +219,20 @@ class ProcessTests(unittest.TestCase):
self.assertTrue(processes[0].waited)
def failed(args,**kwargs):
process=FakeProcess(args,**kwargs);process.returncode=1
kwargs['stdout'].write(b'SECRET')
process.stdout.write(b'SECRET')
process.stdout.seek(0)
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.assertEqual(error.exception.code,'collection_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)
process.stdout.write(b'x'*20)
process.stdout.seek(0)
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: