From b4c0cf12cafad00959425f0e5ad8b018215d9d64 Mon Sep 17 00:00:00 2001 From: oceans2alaska Date: Wed, 4 Nov 2026 19:03:49 -0800 Subject: [PATCH] Retain infrastructure names and share/mount roots behind policy switches Add a `retain` block to privacy_policy.json (all on by default) so assessments show software, services, processes, tasks, shares/mounts and private peer IPs. Values pass an identifier-pattern filter (digit runs, SSN/phone, e-mail, charset, length, path depth). Accounts, command lines, child paths, filenames and public peer IPs stay omitted. Linux collector and Windows PowerShell collectors updated; tests cover retention, withholding and all-switches-off. Co-Authored-By: Claude Sonnet 5.5 --- docs/PRIVACY_HARDENING.md | 22 ++++++++ powershell/Get-ServerStorage.ps1 | 4 +- powershell/Get-ServerSystem.ps1 | 2 +- powershell/Get-ServerTasks.ps1 | 4 +- powershell/Inventory.Common.ps1 | 13 +++++ samsara/assets/privacy_policy.json | 11 +++- samsara/collectors/linux.py | 2 +- samsara/core/privacy.py | 79 +++++++++++++++++++++++----- samsara/reporting/export.py | 2 +- samsara/ui/window.py | 2 +- schemas/inventory-v1.schema.json | 13 ++++- tests/powershell/Test-Collectors.ps1 | 3 ++ tests/test_import.py | 2 +- tests/test_linux.py | 5 +- tests/test_privacy.py | 52 ++++++++++++++++-- tests/test_scoring.py | 2 +- 16 files changed, 189 insertions(+), 29 deletions(-) diff --git a/docs/PRIVACY_HARDENING.md b/docs/PRIVACY_HARDENING.md index ed1cd46..ff687c0 100644 --- a/docs/PRIVACY_HARDENING.md +++ b/docs/PRIVACY_HARDENING.md @@ -54,6 +54,28 @@ Do not replace their findings with an unsupported claim that all issues are reso `public`, because client IPs on patient-facing servers can be identifiers. Local listener addresses and the host's own IPs are still retained. +## What is retained (the `retain` policy block) + +Assessments are only useful if they name the software, services and storage a server +depends on, so `privacy_policy.json` has a `retain` block of switches, all `true` by default: + +| Switch | Keeps | Always omitted | +| --- | --- | --- | +| `hostname` | server hostname | domain name | +| `software_names` | installed product/package name, numeric version | publisher, install dates, uninstall commands | +| `service_names`, `process_names` | service, role and connection-process names | accounts, descriptions, executable paths and arguments | +| `task_names` | scheduled task name and task folder | actions, arguments, principals, triggers | +| `storage_names_and_roots` | share/export names, share and mount **root** paths, NFS/SMB mount sources | child paths, filenames, file contents, open files, sessions | +| `private_peer_addresses` | RFC 1918 / link-local peer IPs | public peer IPs (always reduced to `remote_scope: public`) | + +Every retained value must pass a pattern filter: ASCII only, no 6+ digit runs (MRN-like), +no SSN or phone patterns, no e-mail addresses, at most 120 characters, and paths of at +most four components with no traversal. Failing values fall back to numbered labels +(`Software-0007`). **This is a heuristic, not PHI detection**: a name that embeds a +patient's name would pass. Collectors only request roots, but an imported file with deeper +paths is limited only by the four-component rule. Set a switch to `false` for any category +CHS does not approve; each switch is independent and the tests cover the all-off profile. + ## Configure approved metadata and report storage Deployment administrators review and protect these bundled files against untrusted diff --git a/powershell/Get-ServerStorage.ps1 b/powershell/Get-ServerStorage.ps1 index 2aee7b0..d1e4c6d 100644 --- a/powershell/Get-ServerStorage.ps1 +++ b/powershell/Get-ServerStorage.ps1 @@ -4,7 +4,7 @@ function Get-SamsaraStorage { $index = 0 $Result.data = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -Property DeviceID,Size,FreeSpace,FileSystem -ErrorAction Stop | ForEach-Object { $index++ - [pscustomobject]@{ type = 'Volume'; name = 'Volume-' + $index + [pscustomobject]@{ type = 'Volume'; name = $(if (Test-SamsaraRetain 'storage_names_and_roots' ([string]$_.DeviceID)) { [string]$_.DeviceID } else { 'Volume-' + $index }) path = Get-SamsaraApprovedRoot (([string]$_.DeviceID) + '\') filesystem = [string]$_.FileSystem; capacity_gib = [math]::Round(([double]$_.Size / 1GB), 2) free_gib = [math]::Round(([double]$_.FreeSpace / 1GB), 2) } @@ -20,7 +20,7 @@ function Get-SamsaraShares { $index = 0 $Result.data = @(Get-SmbShare -ErrorAction Stop | ForEach-Object { $index++ - $name = if ([string]$_.Name -match '^(ADMIN|IPC|[A-Z])\$$') { ([string]$_.Name).ToUpperInvariant() } else { 'Share-' + $index } + $name = if ([string]$_.Name -match '^(ADMIN|IPC|[A-Z])\$$') { ([string]$_.Name).ToUpperInvariant() } elseif (Test-SamsaraRetain 'storage_names_and_roots' ([string]$_.Name)) { [string]$_.Name } else { 'Share-' + $index } [pscustomobject]@{ type = 'SMB Share'; name = $name; path = Get-SamsaraApprovedRoot ([string]$_.Path) capacity_gib = $null; free_gib = $null; special = [bool]$_.Special } }) diff --git a/powershell/Get-ServerSystem.ps1 b/powershell/Get-ServerSystem.ps1 index e50955b..4fed566 100644 --- a/powershell/Get-ServerSystem.ps1 +++ b/powershell/Get-ServerSystem.ps1 @@ -8,7 +8,7 @@ function Get-SamsaraSystem { domain = 'Not collected'; domain_joined = $null } Get-SamsaraPrivacyConfiguration - if ($script:SamsaraPrivacy.approved_hostnames -ccontains [Environment]::MachineName) { $Result.data.hostname = [Environment]::MachineName } + if (($script:SamsaraPrivacy.approved_hostnames -ccontains [Environment]::MachineName) -or (Test-SamsaraRetain 'hostname' ([Environment]::MachineName))) { $Result.data.hostname = [Environment]::MachineName } Invoke-SamsaraRead $Result 'OS identity' { $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop $years = @{ '14393' = '2016'; '17763' = '2019'; '20348' = '2022'; '26100' = '2025' } diff --git a/powershell/Get-ServerTasks.ps1 b/powershell/Get-ServerTasks.ps1 index 5f649be..d85c637 100644 --- a/powershell/Get-ServerTasks.ps1 +++ b/powershell/Get-ServerTasks.ps1 @@ -7,8 +7,8 @@ function Get-SamsaraTasks { $routine = ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\', [StringComparison]::OrdinalIgnoreCase) -and -not ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\PowerShell\ScheduledJobs', [StringComparison]::OrdinalIgnoreCase) $state = switch ([int]$_.State) { 1 {'Disabled'} 2 {'Queued'} 3 {'Ready'} 4 {'Running'} default {'Unknown'} } - [pscustomobject]@{ name = $(if ($routine) { 'Maintenance-' + $index } else { 'Task-' + $index }) - path = $(if ($routine) { '\Microsoft\Windows\Maintenance\' } else { 'Task folder omitted' }) + [pscustomobject]@{ name = $(if (Test-SamsaraRetain 'task_names' ([string]$_.TaskName)) { [string]$_.TaskName } elseif ($routine) { 'Maintenance-' + $index } else { 'Task-' + $index }) + path = $(if (Test-SamsaraRetain 'task_names' ([string]$_.TaskPath) -Path) { [string]$_.TaskPath } elseif ($routine) { '\Microsoft\Windows\Maintenance\' } else { 'Task folder omitted' }) enabled = ([int]$_.State -in @(2,3,4)); state = $state account = 'Omitted by privacy policy'; triggers = 'Schedule details omitted; authorized scheduler review required.' } }) diff --git a/powershell/Inventory.Common.ps1 b/powershell/Inventory.Common.ps1 index b0e398a..acada31 100644 --- a/powershell/Inventory.Common.ps1 +++ b/powershell/Inventory.Common.ps1 @@ -84,10 +84,22 @@ function Get-SamsaraPrivacyConfiguration { } } +function Test-SamsaraRetain { + # Mirrors the `retain` block of privacy_policy.json; Python re-validates every value. + param([string]$Flag, [string]$Value, [switch]$Path) + Get-SamsaraPrivacyConfiguration + if ($script:SamsaraPrivacy.retain.$Flag -ne $true -or [string]::IsNullOrEmpty($Value)) { return $false } + if ($Value -match '[0-9]{6,}' -or $Value -match '(^|[\\/])\.\.([\\/]|$)') { return $false } + if ($Path) { return ($Value.Length -le 260 -and $Value -match "^([A-Za-z]:)?[\\/A-Za-z0-9 ._()+#`$,&'-]*$") } + return ($Value.Length -le 120 -and $Value -match "^[A-Za-z0-9][A-Za-z0-9 ._()+#`$:,&'@-]*$") +} + function Get-SamsaraPublicName { param([string]$Kind, [string]$Value, [int]$Index) Get-SamsaraPrivacyConfiguration $normalized = $Value.ToLowerInvariant() -replace '\.(exe|service)$', '' + $flag = switch ($Kind) { 'products' {'software_names'} 'processes' {'process_names'} default {'service_names'} } + if (Test-SamsaraRetain $flag $Value) { return $Value } if ($Kind -eq 'services' -and $script:SamsaraPrivacy.approved_service_names -ccontains $Value) { return $Value } if ($Kind -eq 'products') { if ($script:SamsaraPrivacy.approved_software_names -ccontains $Value) { return $Value } @@ -108,6 +120,7 @@ function Get-SamsaraPublicName { function Get-SamsaraApprovedRoot { param([string]$Value) Get-SamsaraPrivacyConfiguration + if (Test-SamsaraRetain 'storage_names_and_roots' $Value -Path) { return $Value } if ($Value -notmatch '^[A-Za-z]:\\' -or $Value -match '(^|[\\/])\.\.([\\/]|$)' -or $Value -match '[\x00-\x1f]') { return 'Omitted by privacy policy' } foreach ($root in $script:SamsaraPrivacy.approved_share_roots) { if ($root -match '^[A-Za-z]:\\' -and $Value.TrimEnd([char]'\').Equals(([string]$root).TrimEnd([char]'\'), [StringComparison]::OrdinalIgnoreCase)) { return $Value } diff --git a/samsara/assets/privacy_policy.json b/samsara/assets/privacy_policy.json index 224da8e..fa257c1 100644 --- a/samsara/assets/privacy_policy.json +++ b/samsara/assets/privacy_policy.json @@ -5,5 +5,14 @@ "approved_share_roots": [], "approved_service_names": [], "approved_software_names": [], - "max_session_assessments": 10 + "max_session_assessments": 10, + "retain": { + "hostname": true, + "software_names": true, + "service_names": true, + "process_names": true, + "task_names": true, + "storage_names_and_roots": true, + "private_peer_addresses": true + } } diff --git a/samsara/collectors/linux.py b/samsara/collectors/linux.py index 9220c82..292bb56 100644 --- a/samsara/collectors/linux.py +++ b/samsara/collectors/linux.py @@ -80,7 +80,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run started = time.monotonic() data = {'schema_version': '1.0', 'synthetic': False, 'collected_at': datetime.now(timezone.utc).isoformat(), - 'server': {'hostname': socket.gethostname() if privacy_policy['approved_hostnames'] else 'Host-0001', 'platform': 'linux', + 'server': {'hostname': socket.gethostname() if privacy_policy['approved_hostnames'] or privacy_policy['retain']['hostname'] else 'Host-0001', 'platform': 'linux', 'collection_source': 'Local Linux read-only collection', 'operating_system': platform.system(), 'os_build': platform.release(), 'last_boot': 'Not collected', 'ip_addresses': [], 'network_adapters': [], diff --git a/samsara/core/privacy.py b/samsara/core/privacy.py index 74934e0..99c83b4 100644 --- a/samsara/core/privacy.py +++ b/samsara/core/privacy.py @@ -34,6 +34,8 @@ TASK_STATES = {'Unknown','Disabled','Queued','Ready','Running','active/waiting', SERVICE_STATES = {'Running','Stopped','Paused','Start Pending','Stop Pending','Unknown','active/exited','inactive/dead','failed/failed'} STARTUP = {'Auto','Automatic','Manual','Disabled','enabled','disabled','static','indirect','masked','generated','alias','Unknown'} NETWORK_STATES = {'Listen','Established','Bound','Closed','TimeWait','CloseWait','SynSent','SynReceived','FinWait1','FinWait2','LastAck','Closing','DeleteTCB'} +RETAIN_KEYS = {'hostname','software_names','service_names','process_names','task_names', + 'storage_names_and_roots','private_peer_addresses'} SOURCES = {'Local Windows read-only collection','Local Linux read-only collection'} @@ -45,14 +47,17 @@ def load_privacy_policy(path=POLICY_PATH): raise ValueError() policy = json.loads(raw.decode('utf-8')) expected = {'version','report_directory','approved_hostnames','approved_share_roots', - 'approved_service_names','approved_software_names','max_session_assessments'} + 'approved_service_names','approved_software_names','max_session_assessments','retain'} if not isinstance(policy,dict) or set(policy)!=expected or policy['version']!='1.0': raise ValueError() if type(policy['max_session_assessments']) is not int or not 1<=policy['max_session_assessments']<=50: raise ValueError() + retain=policy['retain'] + if not isinstance(retain,dict) or set(retain)!=RETAIN_KEYS or any(type(v) is not bool for v in retain.values()): + raise ValueError() if not isinstance(policy['report_directory'],str) or not policy['report_directory']: raise ValueError() - for field in expected-{'version','report_directory','max_session_assessments'}: + for field in expected-{'version','report_directory','max_session_assessments','retain'}: if not isinstance(policy[field],list) or len(policy[field])>200 or any(not isinstance(v,str) or not v or len(v)>256 or any(ord(c)<32 for c in v) for v in policy[field]): raise ValueError() for root in policy['approved_share_roots']: @@ -105,7 +110,7 @@ def safe_ip(value): REMOTE_SCOPES = {'loopback','host','unspecified','private','public'} -def remote_scope(value, own, previous=None): +def remote_scope(value, own, previous=None, keep_private=False): """Classify a peer address. Only loopback, this host's own and wildcard addresses stay numeric: other peers (clients, patients, workstations) are reduced to private/public.""" if not isinstance(value,str) or not value.strip(): @@ -118,7 +123,46 @@ def remote_scope(value, own, previous=None): if ip.is_unspecified: return 'unspecified', str(ip) if ip.is_loopback: return 'loopback', str(ip) if ip in own: return 'host', str(ip) - return ('private' if ip.is_private or ip.is_link_local else 'public'), '' + if ip.is_private or ip.is_link_local: return 'private', (str(ip) if keep_private else '') + return 'public', '' + + +# --- Retained names ------------------------------------------------------------------- +# Infrastructure names (software, services, shares, mount roots) are what an assessment +# needs. A pattern filter withholds values that look like personal identifiers. This is a +# heuristic, not PHI detection: administrators can switch each category off in `retain`. +LABEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._()+#$:,&'@\-]{0,119}") +UNIT_TEMPLATE = re.compile(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_:\-]*(?:\.(?:service|timer|socket|path|mount))?') +IDENTIFIER_PATTERNS = (re.compile(r'[0-9]{6,}'), re.compile(r'\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b'), + re.compile(r'\b[0-9]{3}[-. ][0-9]{3}[-. ][0-9]{4}\b')) + + +def safe_label(value): + if not isinstance(value,str): return None + text=value.strip() + if not LABEL.fullmatch(text) or ('@' in text and not UNIT_TEMPLATE.fullmatch(text)): return None + if any(pattern.search(text) for pattern in IDENTIFIER_PATTERNS): return None + return text + + +def safe_path(value): + """Share/mount roots only: every component must pass safe_label; no traversal; depth <= 4 components.""" + if not isinstance(value,str) or not value or len(value)>260: return None + norm=value.strip().replace('\\','/') + drive=re.fullmatch(r'[A-Za-z]:(/.*)?',norm) + if drive: parts=norm[2:].split('/') + elif norm.startswith('//'): parts=norm[2:].split('/') + elif re.fullmatch(r'[A-Za-z0-9._-]+:/.*',norm): parts=[norm.split(':',1)[0]]+norm.split(':',1)[1].split('/') + elif norm.startswith('/'): parts=norm.split('/') + else: return None + parts=[part for part in parts if part] + if len(parts)>4 or any(part in ('.','..') or safe_label(part) is None for part in parts): return None + return value.strip() + + +def hostname_label(value): + text=safe_label(value) + return text if text and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}',text) else None def integer(value, maximum=2**32-1): @@ -128,6 +172,7 @@ def integer(value, maximum=2**32-1): def service_label(value, index, policy): if not isinstance(value,str): return f'Service-{index+1:04d}' if value in policy['approved_service_names']: return value + if policy['retain']['service_names'] and safe_label(value): return safe_label(value) name=normal_name(value) if name in KNOWN_SERVICES: return name family=service_family(value) @@ -162,7 +207,8 @@ def minimize_inventory(data, policy=None, *, trusted_source=None): match=re.search(r'\b(2016|2019|2022|2025)\b',str(raw.get('operating_system',''))) year=match[0] if match else None linux_name=next((label for pattern,label in ((r'\bred hat\b|\brhel\b','Red Hat Enterprise Linux'),(r'\brocky\b','Rocky Linux'),(r'\balmalinux\b','AlmaLinux'),(r'\boracle linux\b','Oracle Linux'),(r'\bcentos\b','CentOS'),(r'\blinux mint\b','Linux Mint'),(r'\bubuntu\b','Ubuntu'),(r'\bdebian\b','Debian')) if re.search(pattern,str(raw.get('operating_system','')),re.I)), 'Linux') - hostname=raw.get('hostname') if raw.get('hostname') in policy['approved_hostnames'] else 'Host-0001' + retain=policy['retain'] + hostname=raw.get('hostname') if raw.get('hostname') in policy['approved_hostnames'] else (hostname_label(raw.get('hostname')) if retain['hostname'] else None) or 'Host-0001' server={'hostname':hostname,'platform':platform, 'operating_system':'Windows Server '+year if year else 'Windows' if platform=='windows' else linux_name, 'os_version':year or numeric_version(raw.get('os_version','')), @@ -178,7 +224,8 @@ def minimize_inventory(data, policy=None, *, trusted_source=None): if source: server['collection_source']=source result={'schema_version':'1.0','synthetic':data.get('synthetic') is True,'collected_at':stamp, 'server':server,'checks':{},'inventory':{key:[] for key in ('roles','services','network','tasks','software','storage','certificates')}, - 'privacy':{'profile':'minimized-metadata-v1','identities_omitted':True,'paths':'approved-roots-only', + 'privacy':{'profile':'minimized-metadata-v1','identities_omitted':True,'paths':'share-and-mount-roots-only', + 'retained':sorted(key for key,on in retain.items() if on), 'scope':'Disclosure reduction only; not a PHI-free or compliance attestation.'}} own={ipaddress.ip_address(ip) for ip in server['ip_addresses']} for section in result['inventory']: @@ -189,26 +236,31 @@ def minimize_inventory(data, policy=None, *, trusted_source=None): name=row.get('name','') if section=='roles': normalized=str(name).lower() - result['inventory'][section].append({'name':normalized if normalized in KNOWN_ROLES else f'Component-{i+1:04d}','state':'Installed' if row.get('state')=='Installed' else 'Unknown'}) + kept=normalized if normalized in KNOWN_ROLES else (safe_label(name) or '').lower() if retain['service_names'] else '' + result['inventory'][section].append({'name':kept or f'Component-{i+1:04d}','state':'Installed' if row.get('state')=='Installed' else 'Unknown'}) elif section=='services': result['inventory'][section].append({'name':service_label(name,i,policy),'state':enum_value(row.get('state'), SERVICE_STATES), 'startup':enum_value(row.get('startup'), STARTUP),'pid':integer(row.get('pid')),'account':OMITTED}) elif section=='network': process=normal_name(row.get('process')) - scope,peer=remote_scope(row.get('remote_address'),own,row.get('remote_scope')) + scope,peer=remote_scope(row.get('remote_address'),own,row.get('remote_scope'),retain['private_peer_addresses']) result['inventory'][section].append({'state':enum_value(row.get('state'), NETWORK_STATES), 'local_address':safe_ip(row.get('local_address')),'remote_address':peer,'remote_scope':scope, 'local_port':integer(row.get('local_port'),65535),'remote_port':integer(row.get('remote_port'),65535), - 'pid':integer(row.get('pid')),'process':process if process in KNOWN_PROCESSES else f'Process-{i+1:04d}', + 'pid':integer(row.get('pid')),'process':process if process in KNOWN_PROCESSES or (retain['process_names'] and safe_label(process)) else f'Process-{i+1:04d}', 'direction':row.get('direction') if row.get('direction') in ('Inbound','Outbound','Unknown') else 'Unknown'}) elif section=='tasks': routine=task_is_maintenance(row) - result['inventory'][section].append({'name':f'Maintenance-{i+1:04d}' if routine else f'Task-{i+1:04d}', - 'path':'\\Microsoft\\Windows\\Maintenance\\' if routine else 'Task folder omitted', + kept_name=safe_label(name) if retain['task_names'] else None + kept_path=safe_path(row.get('path')) if retain['task_names'] else None + if kept_path is None and retain['task_names'] and row.get('path')=='systemd system timer': kept_path='systemd system timer' + result['inventory'][section].append({'name':kept_name or (f'Maintenance-{i+1:04d}' if routine else f'Task-{i+1:04d}'), + 'path':kept_path or ('\\Microsoft\\Windows\\Maintenance\\' if routine else 'Task folder omitted'), 'enabled':row.get('enabled') is True,'state':enum_value(row.get('state'), TASK_STATES), 'account':OMITTED,'triggers':'Schedule details omitted; verify through an authorized scheduler review.'}) elif section=='software': label=name if isinstance(name,str) and name in policy['approved_software_names'] else None + if label is None and retain['software_names']: label=safe_label(name) if label is None: label=next((product for product in PRODUCTS if isinstance(name,str) and re.match(re.escape(product)+r'(?![A-Za-z0-9])',name,re.I)),f'Software-{i+1:04d}') result['inventory'][section].append({'name':label,'version':numeric_version(row.get('version'))}) @@ -221,7 +273,10 @@ def minimize_inventory(data, policy=None, *, trusted_source=None): approved={canonical_share_root(root) for root in policy['approved_share_roots']} if canonical in approved: path=row['path'] except ValueError: pass - entry={'type':kind,'name':name.upper() if admin else f'Share-{i+1:04d}' if kind in ('SMB Share','NFS Export','Samba Share') else f'Volume-{i+1:04d}', + keep=retain['storage_names_and_roots'] + if path==OMITTED and keep and safe_path(row.get('path')): path=safe_path(row.get('path')) + shown=(safe_label(name) or safe_path(name)) if keep else None + entry={'type':kind,'name':name.upper() if admin else shown if shown else f'Share-{i+1:04d}' if kind in ('SMB Share','NFS Export','Samba Share') else f'Volume-{i+1:04d}', 'path':path,'capacity_gib':None,'free_gib':None} for key in ('capacity_gib','free_gib'): number=row.get(key) diff --git a/samsara/reporting/export.py b/samsara/reporting/export.py index 8bbf881..56c2150 100644 --- a/samsara/reporting/export.py +++ b/samsara/reporting/export.py @@ -89,7 +89,7 @@ def export_html(path, data, assessment): checks.append((name,row['status'],row.get('started_at','Not supplied'),row.get('completed_at','Not supplied'),row['message'],errors,'; '.join(row.get('permission_limitations',[])),'; '.join(row.get('limitations',[])))) content += table(['Check','Status','started_at (UTC)','completed_at (UTC)','Result','Errors','permission_limitations','Scope limitations'],checks) content += '

Recommended manual verification

' - content += '

Privacy and omitted evidence

Identifying names, accounts, task commands, certificate identities and unapproved paths are omitted. No raw inventory sidecar is generated. JSON export contains the same minimized evidence. Numeric IP addresses remain confidential infrastructure metadata. This report does not establish PHI-free operation or HIPAA compliance.

' + content += '

Privacy and omitted evidence

Accounts, task commands, certificate identities, child paths/filenames and public peer IPs are omitted; infrastructure names and share/mount roots are kept per the privacy policy `retain` switches and pass an identifier-pattern filter. No raw inventory sidecar is generated. JSON export contains the same minimized evidence. Numeric IP addresses remain confidential infrastructure metadata. This report does not establish PHI-free operation or HIPAA compliance.

' content += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()]) css = 'body{font:15px system-ui;max-width:1180px;margin:32px auto;padding:20px;color:#233044;line-height:1.55}h1,h2{color:#173c60}.muted{color:#667}.source,.card,details{background:#eef3f8;padding:16px;border-radius:7px}.table{overflow-x:auto}table{border-collapse:collapse;width:100%;margin:12px 0}th,td{border:1px solid #d9e0e8;text-align:left;vertical-align:top;padding:9px;white-space:pre-wrap;overflow-wrap:anywhere}th{background:#eef3f8}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#1263a3}' write_private(path,'Samsara'+content+'') diff --git a/samsara/ui/window.py b/samsara/ui/window.py index b224f91..4b11a1e 100644 --- a/samsara/ui/window.py +++ b/samsara/ui/window.py @@ -322,7 +322,7 @@ class MainWindow(QMainWindow): settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions')) settings.addWidget(self.label('Local Windows Server and Linux collectors are available. Windows validation on a supported host is pending. Remote collection is a separate follow-up. Saved inventories and samples work on either desktop.', muted=True)) settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True)) - settings.addWidget(self.label('Privacy defaults: identifying names and unapproved paths omitted. Task commands, certificate identities and SMB open-file details are not collected. Exports use a configured private local folder. Review docs/PRIVACY_HARDENING.md before real assessments.', muted=True)) + settings.addWidget(self.label('Privacy defaults: software, service, process, task, share and mount names plus share/mount root paths are kept unless switched off in privacy_policy.json; values that look like identifiers are withheld. Accounts, command lines, child paths, filenames, public peer IPs, certificate identities and SMB open-file details are never collected. Exports use a configured private local folder. Review docs/PRIVACY_HARDENING.md before real assessments.', muted=True)) settings.addWidget(self.label('Retirement scoring policy')) self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True) settings.addWidget(self.policy_label) diff --git a/schemas/inventory-v1.schema.json b/schemas/inventory-v1.schema.json index b848307..e20c370 100644 --- a/schemas/inventory-v1.schema.json +++ b/schemas/inventory-v1.schema.json @@ -539,11 +539,22 @@ "const": true }, "paths": { - "const": "approved-roots-only" + "enum": [ + "approved-roots-only", + "share-and-mount-roots-only" + ] }, "scope": { "type": "string", "maxLength": 8192 + }, + "retained": { + "type": "array", + "items": { + "type": "string", + "maxLength": 64 + }, + "maxItems": 20 } } } diff --git a/tests/powershell/Test-Collectors.ps1 b/tests/powershell/Test-Collectors.ps1 index 8b50313..acaf721 100644 --- a/tests/powershell/Test-Collectors.ps1 +++ b/tests/powershell/Test-Collectors.ps1 @@ -10,6 +10,9 @@ foreach ($file in $files) { } foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1' })) { . $file.FullName } $script:SamsaraIsElevated = $true +# Synthetic SECRET canaries must never serialize: run these contract tests with every `retain` switch off. +Get-SamsaraPrivacyConfiguration +foreach ($property in @($script:SamsaraPrivacy.retain.PSObject.Properties)) { $property.Value = $false } $script:SyntheticBuild = '20348' function Get-CimInstance { [CmdletBinding()] param($ClassName, $Property, $Filter, $Namespace) diff --git a/tests/test_import.py b/tests/test_import.py index 045f94f..8e5f6d8 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -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('Share-' in item['evidence'] for item in result['findings'])) + self.assertTrue(any('Configured non-administrative 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): diff --git a/tests/test_linux.py b/tests/test_linux.py index ea49721..c6bd063 100644 --- a/tests/test_linux.py +++ b/tests/test_linux.py @@ -104,7 +104,10 @@ class RegressionReviewTests(unittest.TestCase): 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) + from samsara.core.privacy import load_privacy_policy + strict = load_privacy_policy() + strict['retain'] = {key: False for key in strict['retain']} + clean = minimize_inventory(data, strict) self.assertNotEqual(clean['server']['os_build'], '١٢٣٤٥') self.assertEqual(clean['inventory']['software'][0]['name'], 'Microsoft Visual C++') diff --git a/tests/test_privacy.py b/tests/test_privacy.py index c36eac9..d903a10 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -19,6 +19,12 @@ from samsara.analyzers.assessment import assess CANARY='SYNTHETIC_PERSON_CANARY_9f4' +def strict_policy(): + policy=load_privacy_policy() + policy['retain']={key:False for key in policy['retain']} + return policy + + def fixture(name='active_sql_server'): return json.loads(Path('tests/fixtures/scoring',name+'.json').read_text()) @@ -36,14 +42,14 @@ class PrivacyTests(unittest.TestCase): 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) + clean=minimize_inventory(raw,strict_policy()) 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): + policy=strict_policy();policy['report_directory']=folder + with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.load_privacy_policy',return_value=policy), patch('samsara.core.privacy.load_privacy_policy',return_value=policy): assessment=assess(original) assessment['findings'][0]['evidence']=CANARY export_html(Path(folder)/'test.html',raw,assessment) @@ -52,7 +58,7 @@ class PrivacyTests(unittest.TestCase): 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'] + policy=strict_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') @@ -128,3 +134,41 @@ class CaptureTests(unittest.TestCase): self.assertNotIn(CANARY,str(raised.exception)) with self.assertRaises(CollectionCancelled): capture([sys.executable,'-c','print(1)'],env=env,timeout=3,cancelled=lambda:True) + + +class RetainedNameTests(unittest.TestCase): + def test_useful_names_and_roots_are_kept_but_identifier_like_values_withheld(self): + raw=fixture('active_file_server') + raw['server']['hostname']='fileserver01' + raw['inventory']['software']=[{'name':'Epic Hyperspace Client 2024','version':'1.2.3'},{'name':'Scanner 123456789','version':'1'}] + raw['inventory']['services'][-1]['name']='CustomImagingService' + raw['inventory']['storage']=[ + {'type':'SMB Share','name':'RadiologyArchive','path':'D:\\Shares\\Radiology'}, + {'type':'SMB Share','name':'Dept','path':'D:\\Shares\\a\\b\\c\\d.pdf'}, + {'type':'Remote Mount','name':'nas01:/export/imaging','path':'/mnt/imaging'}, + {'type':'SMB Share','name':'jane.doe@hospital.org','path':'D:\\Shares\\123-45-6789'}] + clean=minimize_inventory(raw) + text=json.dumps(clean) + self.assertEqual(clean['server']['hostname'],'fileserver01') + names=[r['name'] for r in clean['inventory']['software']] + self.assertIn('Epic Hyperspace Client 2024',names); self.assertNotIn('Scanner 123456789',names) + self.assertIn('CustomImagingService',[r['name'] for r in clean['inventory']['services']]) + storage=clean['inventory']['storage'] + self.assertEqual(storage[0]['name'],'RadiologyArchive'); self.assertEqual(storage[0]['path'],'D:\\Shares\\Radiology') + self.assertEqual(storage[2]['name'],'nas01:/export/imaging'); self.assertEqual(storage[2]['path'],'/mnt/imaging') + for leaked in ('d.pdf','jane.doe','123-45-6789','123456789'): self.assertNotIn(leaked,text) + self.assertEqual(minimize_inventory(clean),clean) + validate_inventory(clean) + + def test_every_category_can_be_switched_off(self): + raw=fixture('active_file_server'); raw['server']['hostname']='fileserver01' + raw['inventory']['software']=[{'name':'Epic Hyperspace Client','version':'1'}] + raw['inventory']['storage']=[{'type':'SMB Share','name':'RadiologyArchive','path':'D:\\Shares\\Radiology'}] + text=json.dumps(minimize_inventory(raw,strict_policy())) + for value in ('fileserver01','Epic','RadiologyArchive','Radiology'): self.assertNotIn(value,text) + + def test_policy_retain_block_is_validated(self): + with tempfile.TemporaryDirectory() as folder: + bad=load_privacy_policy(); bad['retain']['hostname']='yes' + path=Path(folder)/'policy.json'; path.write_text(json.dumps(bad)) + with self.assertRaises(ValueError): load_privacy_policy(path) diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 3901c0a..39dad50 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -198,7 +198,7 @@ class ScoringRegressionTests(unittest.TestCase): 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']) + self.assertEqual(clean['server']['hostname'], data['server']['hostname']) # infrastructure hostname retained by default policy def test_custom_jobs_not_misclassified_by_substrings_and_zero_points_maintenance(self): data=fixture('clean_windows')