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:
@@ -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
14
tests/report_helpers.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
130
tests/test_privacy.py
Normal 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)
|
||||
@@ -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('<script>',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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user