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:
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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.' }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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': [],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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++')
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user