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

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