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
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

View File

@@ -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 }
})

View File

@@ -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' }

View File

@@ -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.' }
})

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 {
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 }

View File

@@ -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
}
}

View File

@@ -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': [],

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'}
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)

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',[]))))
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>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()])
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>')

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('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)

View File

@@ -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
}
}
}

View File

@@ -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)

View File

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

View File

@@ -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++')

View File

@@ -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)

View File

@@ -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')