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 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 19:03:49 -08:00
parent 610a3bb447
commit b4c0cf12ca
16 changed files with 189 additions and 29 deletions

View File

@@ -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 `public`, because client IPs on patient-facing servers can be identifiers. Local
listener addresses and the host's own IPs are still retained. 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 ## Configure approved metadata and report storage
Deployment administrators review and protect these bundled files against untrusted Deployment administrators review and protect these bundled files against untrusted

View File

@@ -4,7 +4,7 @@ function Get-SamsaraStorage {
$index = 0 $index = 0
$Result.data = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -Property DeviceID,Size,FreeSpace,FileSystem -ErrorAction Stop | ForEach-Object { $Result.data = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -Property DeviceID,Size,FreeSpace,FileSystem -ErrorAction Stop | ForEach-Object {
$index++ $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) + '\') path = Get-SamsaraApprovedRoot (([string]$_.DeviceID) + '\')
filesystem = [string]$_.FileSystem; capacity_gib = [math]::Round(([double]$_.Size / 1GB), 2) filesystem = [string]$_.FileSystem; capacity_gib = [math]::Round(([double]$_.Size / 1GB), 2)
free_gib = [math]::Round(([double]$_.FreeSpace / 1GB), 2) } free_gib = [math]::Round(([double]$_.FreeSpace / 1GB), 2) }
@@ -20,7 +20,7 @@ function Get-SamsaraShares {
$index = 0 $index = 0
$Result.data = @(Get-SmbShare -ErrorAction Stop | ForEach-Object { $Result.data = @(Get-SmbShare -ErrorAction Stop | ForEach-Object {
$index++ $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) [pscustomobject]@{ type = 'SMB Share'; name = $name; path = Get-SamsaraApprovedRoot ([string]$_.Path)
capacity_gib = $null; free_gib = $null; special = [bool]$_.Special } capacity_gib = $null; free_gib = $null; special = [bool]$_.Special }
}) })

View File

@@ -8,7 +8,7 @@ function Get-SamsaraSystem {
domain = 'Not collected'; domain_joined = $null domain = 'Not collected'; domain_joined = $null
} }
Get-SamsaraPrivacyConfiguration 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' { Invoke-SamsaraRead $Result 'OS identity' {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop
$years = @{ '14393' = '2016'; '17763' = '2019'; '20348' = '2022'; '26100' = '2025' } $years = @{ '14393' = '2016'; '17763' = '2019'; '20348' = '2022'; '26100' = '2025' }

View File

@@ -7,8 +7,8 @@ function Get-SamsaraTasks {
$routine = ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\', [StringComparison]::OrdinalIgnoreCase) -and $routine = ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\', [StringComparison]::OrdinalIgnoreCase) -and
-not ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\PowerShell\ScheduledJobs', [StringComparison]::OrdinalIgnoreCase) -not ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\PowerShell\ScheduledJobs', [StringComparison]::OrdinalIgnoreCase)
$state = switch ([int]$_.State) { 1 {'Disabled'} 2 {'Queued'} 3 {'Ready'} 4 {'Running'} default {'Unknown'} } $state = switch ([int]$_.State) { 1 {'Disabled'} 2 {'Queued'} 3 {'Ready'} 4 {'Running'} default {'Unknown'} }
[pscustomobject]@{ name = $(if ($routine) { 'Maintenance-' + $index } else { 'Task-' + $index }) [pscustomobject]@{ name = $(if (Test-SamsaraRetain 'task_names' ([string]$_.TaskName)) { [string]$_.TaskName } elseif ($routine) { 'Maintenance-' + $index } else { 'Task-' + $index })
path = $(if ($routine) { '\Microsoft\Windows\Maintenance\' } else { 'Task folder omitted' }) 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 enabled = ([int]$_.State -in @(2,3,4)); state = $state
account = 'Omitted by privacy policy'; triggers = 'Schedule details omitted; authorized scheduler review required.' } account = 'Omitted by privacy policy'; triggers = 'Schedule details omitted; authorized scheduler review required.' }
}) })

View File

@@ -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 { function Get-SamsaraPublicName {
param([string]$Kind, [string]$Value, [int]$Index) param([string]$Kind, [string]$Value, [int]$Index)
Get-SamsaraPrivacyConfiguration Get-SamsaraPrivacyConfiguration
$normalized = $Value.ToLowerInvariant() -replace '\.(exe|service)$', '' $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 'services' -and $script:SamsaraPrivacy.approved_service_names -ccontains $Value) { return $Value }
if ($Kind -eq 'products') { if ($Kind -eq 'products') {
if ($script:SamsaraPrivacy.approved_software_names -ccontains $Value) { return $Value } if ($script:SamsaraPrivacy.approved_software_names -ccontains $Value) { return $Value }
@@ -108,6 +120,7 @@ function Get-SamsaraPublicName {
function Get-SamsaraApprovedRoot { function Get-SamsaraApprovedRoot {
param([string]$Value) param([string]$Value)
Get-SamsaraPrivacyConfiguration 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' } 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) { 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 } if ($root -match '^[A-Za-z]:\\' -and $Value.TrimEnd([char]'\').Equals(([string]$root).TrimEnd([char]'\'), [StringComparison]::OrdinalIgnoreCase)) { return $Value }

View File

@@ -5,5 +5,14 @@
"approved_share_roots": [], "approved_share_roots": [],
"approved_service_names": [], "approved_service_names": [],
"approved_software_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
}
} }

View File

@@ -80,7 +80,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run
started = time.monotonic() started = time.monotonic()
data = {'schema_version': '1.0', 'synthetic': False, data = {'schema_version': '1.0', 'synthetic': False,
'collected_at': datetime.now(timezone.utc).isoformat(), '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', 'collection_source': 'Local Linux read-only collection',
'operating_system': platform.system(), 'os_build': platform.release(), 'operating_system': platform.system(), 'os_build': platform.release(),
'last_boot': 'Not collected', 'ip_addresses': [], 'network_adapters': [], 'last_boot': 'Not collected', 'ip_addresses': [], 'network_adapters': [],

View File

@@ -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'} 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'} 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'} 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'} SOURCES = {'Local Windows read-only collection','Local Linux read-only collection'}
@@ -45,14 +47,17 @@ def load_privacy_policy(path=POLICY_PATH):
raise ValueError() raise ValueError()
policy = json.loads(raw.decode('utf-8')) policy = json.loads(raw.decode('utf-8'))
expected = {'version','report_directory','approved_hostnames','approved_share_roots', 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': if not isinstance(policy,dict) or set(policy)!=expected or policy['version']!='1.0':
raise ValueError() raise ValueError()
if type(policy['max_session_assessments']) is not int or not 1<=policy['max_session_assessments']<=50: if type(policy['max_session_assessments']) is not int or not 1<=policy['max_session_assessments']<=50:
raise ValueError() 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']: if not isinstance(policy['report_directory'],str) or not policy['report_directory']:
raise ValueError() 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]): 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() raise ValueError()
for root in policy['approved_share_roots']: for root in policy['approved_share_roots']:
@@ -105,7 +110,7 @@ def safe_ip(value):
REMOTE_SCOPES = {'loopback','host','unspecified','private','public'} 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 """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.""" numeric: other peers (clients, patients, workstations) are reduced to private/public."""
if not isinstance(value,str) or not value.strip(): 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_unspecified: return 'unspecified', str(ip)
if ip.is_loopback: return 'loopback', str(ip) if ip.is_loopback: return 'loopback', str(ip)
if ip in own: return 'host', 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): def integer(value, maximum=2**32-1):
@@ -128,6 +172,7 @@ def integer(value, maximum=2**32-1):
def service_label(value, index, policy): def service_label(value, index, policy):
if not isinstance(value,str): return f'Service-{index+1:04d}' if not isinstance(value,str): return f'Service-{index+1:04d}'
if value in policy['approved_service_names']: return value 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) name=normal_name(value)
if name in KNOWN_SERVICES: return name if name in KNOWN_SERVICES: return name
family=service_family(value) 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',''))) match=re.search(r'\b(2016|2019|2022|2025)\b',str(raw.get('operating_system','')))
year=match[0] if match else None 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') 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, server={'hostname':hostname,'platform':platform,
'operating_system':'Windows Server '+year if year else 'Windows' if platform=='windows' else linux_name, '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','')), '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 if source: server['collection_source']=source
result={'schema_version':'1.0','synthetic':data.get('synthetic') is True,'collected_at':stamp, 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')}, '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.'}} 'scope':'Disclosure reduction only; not a PHI-free or compliance attestation.'}}
own={ipaddress.ip_address(ip) for ip in server['ip_addresses']} own={ipaddress.ip_address(ip) for ip in server['ip_addresses']}
for section in result['inventory']: for section in result['inventory']:
@@ -189,26 +236,31 @@ def minimize_inventory(data, policy=None, *, trusted_source=None):
name=row.get('name','') name=row.get('name','')
if section=='roles': if section=='roles':
normalized=str(name).lower() 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': elif section=='services':
result['inventory'][section].append({'name':service_label(name,i,policy),'state':enum_value(row.get('state'), SERVICE_STATES), 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}) 'startup':enum_value(row.get('startup'), STARTUP),'pid':integer(row.get('pid')),'account':OMITTED})
elif section=='network': elif section=='network':
process=normal_name(row.get('process')) 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), 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_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), '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'}) 'direction':row.get('direction') if row.get('direction') in ('Inbound','Outbound','Unknown') else 'Unknown'})
elif section=='tasks': elif section=='tasks':
routine=task_is_maintenance(row) routine=task_is_maintenance(row)
result['inventory'][section].append({'name':f'Maintenance-{i+1:04d}' if routine else f'Task-{i+1:04d}', kept_name=safe_label(name) if retain['task_names'] else None
'path':'\\Microsoft\\Windows\\Maintenance\\' if routine else 'Task folder omitted', 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), '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.'}) 'account':OMITTED,'triggers':'Schedule details omitted; verify through an authorized scheduler review.'})
elif section=='software': elif section=='software':
label=name if isinstance(name,str) and name in policy['approved_software_names'] else None 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: 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}') 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'))}) 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']} approved={canonical_share_root(root) for root in policy['approved_share_roots']}
if canonical in approved: path=row['path'] if canonical in approved: path=row['path']
except ValueError: pass 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} 'path':path,'capacity_gib':None,'free_gib':None}
for key in ('capacity_gib','free_gib'): for key in ('capacity_gib','free_gib'):
number=row.get(key) number=row.get(key)

View File

@@ -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',[])))) 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 += table(['Check','Status','started_at (UTC)','completed_at (UTC)','Result','Errors','permission_limitations','Scope limitations'],checks)
content += '<h2>Recommended manual verification</h2><ul>'+''.join('<li>'+escape(step)+'</li>' for step in MANUAL_STEPS)+'</ul>' content += '<h2>Recommended manual verification</h2><ul>'+''.join('<li>'+escape(step)+'</li>' for step in MANUAL_STEPS)+'</ul>'
content += '<h2>Privacy and omitted evidence</h2><p>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.</p>' content += '<h2>Privacy and omitted evidence</h2><p>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.</p>'
content += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()]) 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}' 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,'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>') write_private(path,'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>')

View File

@@ -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('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('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('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')) settings.addWidget(self.label('Retirement scoring policy'))
self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True) self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True)
settings.addWidget(self.policy_label) settings.addWidget(self.policy_label)

View File

@@ -539,11 +539,22 @@
"const": true "const": true
}, },
"paths": { "paths": {
"const": "approved-roots-only" "enum": [
"approved-roots-only",
"share-and-mount-roots-only"
]
}, },
"scope": { "scope": {
"type": "string", "type": "string",
"maxLength": 8192 "maxLength": 8192
},
"retained": {
"type": "array",
"items": {
"type": "string",
"maxLength": 64
},
"maxItems": 20
} }
} }
} }

View File

@@ -10,6 +10,9 @@ foreach ($file in $files) {
} }
foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1' })) { . $file.FullName } foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1' })) { . $file.FullName }
$script:SamsaraIsElevated = $true $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' $script:SyntheticBuild = '20348'
function Get-CimInstance { function Get-CimInstance {
[CmdletBinding()] param($ClassName, $Property, $Filter, $Namespace) [CmdletBinding()] param($ClassName, $Property, $Filter, $Namespace)

View File

@@ -23,7 +23,7 @@ class ImportTests(unittest.TestCase):
data = self.load(collect_demo(LINUX_DEMO_HOST)) data = self.load(collect_demo(LINUX_DEMO_HOST))
self.assertEqual(data['server']['platform'], 'linux') self.assertEqual(data['server']['platform'], 'linux')
result = assess(data) 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'])) self.assertTrue(any('certificates:' in item['evidence'] for item in result['findings']))
def test_report_wrapper_recomputed_and_sensitive_fields_removed(self): def test_report_wrapper_recomputed_and_sensitive_fields_removed(self):

View File

@@ -104,7 +104,10 @@ class RegressionReviewTests(unittest.TestCase):
data = json.loads(Path('samsara/assets/demo_inventory.json').read_text()) data = json.loads(Path('samsara/assets/demo_inventory.json').read_text())
data['server']['os_build'] = '١٢٣٤٥' data['server']['os_build'] = '١٢٣٤٥'
data['inventory']['software'] = [{'name': 'Microsoft Visual C++ 2015-2022 Redistributable (x64)', 'version': '14.38.33135'}] 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.assertNotEqual(clean['server']['os_build'], '١٢٣٤٥')
self.assertEqual(clean['inventory']['software'][0]['name'], 'Microsoft Visual C++') self.assertEqual(clean['inventory']['software'][0]['name'], 'Microsoft Visual C++')

View File

@@ -19,6 +19,12 @@ from samsara.analyzers.assessment import assess
CANARY='SYNTHETIC_PERSON_CANARY_9f4' 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'): def fixture(name='active_sql_server'):
return json.loads(Path('tests/fixtures/scoring',name+'.json').read_text()) 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['checks'][CANARY]={'status':'failed','message':CANARY}
raw['privacy']={'profile':'minimized-metadata-v1'} # A marker cannot bypass projection. raw['privacy']={'profile':'minimized-metadata-v1'} # A marker cannot bypass projection.
snapshot=copy.deepcopy(raw) snapshot=copy.deepcopy(raw)
clean=minimize_inventory(raw) clean=minimize_inventory(raw,strict_policy())
self.assertEqual(raw,snapshot) self.assertEqual(raw,snapshot)
self.assertNotIn(CANARY,json.dumps(clean)) self.assertNotIn(CANARY,json.dumps(clean))
validate_inventory(clean) validate_inventory(clean)
self.assertEqual(assess(clean)['score'],70) self.assertEqual(assess(clean)['score'],70)
with tempfile.TemporaryDirectory() as folder: with tempfile.TemporaryDirectory() as folder:
policy=load_privacy_policy();policy['report_directory']=folder 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): 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=assess(original)
assessment['findings'][0]['evidence']=CANARY assessment['findings'][0]['evidence']=CANARY
export_html(Path(folder)/'test.html',raw,assessment) export_html(Path(folder)/'test.html',raw,assessment)
@@ -52,7 +58,7 @@ class PrivacyTests(unittest.TestCase):
self.assertEqual(len(list(Path(folder).iterdir())),2) self.assertEqual(len(list(Path(folder).iterdir())),2)
def test_exact_roots_only_no_descendant_or_network_approval(self): 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']] 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) clean=minimize_inventory(raw,policy)
self.assertEqual(clean['inventory']['storage'][0]['path'],'D:\\Approved') self.assertEqual(clean['inventory']['storage'][0]['path'],'D:\\Approved')
@@ -128,3 +134,41 @@ class CaptureTests(unittest.TestCase):
self.assertNotIn(CANARY,str(raised.exception)) self.assertNotIn(CANARY,str(raised.exception))
with self.assertRaises(CollectionCancelled): with self.assertRaises(CollectionCancelled):
capture([sys.executable,'-c','print(1)'],env=env,timeout=3,cancelled=lambda:True) 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)

View File

@@ -198,7 +198,7 @@ class ScoringRegressionTests(unittest.TestCase):
export_json(output,data,assess(data)) export_json(output,data,assess(data))
clean=load_inventory(output) clean=load_inventory(output)
self.assertEqual(len(clean['inventory']['services']),86) 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): def test_custom_jobs_not_misclassified_by_substrings_and_zero_points_maintenance(self):
data=fixture('clean_windows') data=fixture('clean_windows')