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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user