Privacy hardening, remote-peer IP masking and review fixes
- Add privacy projection (core/privacy.py), private report storage, bounded
process capture; omit identities, free text and unapproved paths
- Mask remote peer IPs: only loopback/host/wildcard stay numeric, others are
reduced to remote_scope private/public (schema + scoring updated)
- Map ss TCP states to canonical names; ASCII-only numeric fields; fix
"Microsoft Visual C++" product matching (Python and PowerShell)
- Merge 7fed3b9: -Diagnostic switch, serialization exit code 3, PS7 module
path, UI error detail; keep hardened task/certificate collectors
- Add audit docs, privacy docs and regression tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,8 @@ def classify_dependencies(data):
|
||||
state = str(row.get('state','')).lower()
|
||||
proc = normal_name(row.get('process',''))
|
||||
remote,local = address(row.get('remote_address')), address(row.get('local_address'))
|
||||
has_remote = remote is not None or row.get('remote_scope') in ('private','public')
|
||||
peer = row.get('remote_address') or (f"{row['remote_scope']} peer (address withheld)" if row.get('remote_scope') else '')
|
||||
matching = []
|
||||
if state in ('established','estab') and local is not None and known_pid(row):
|
||||
wildcard = ipaddress.ip_address('0.0.0.0' if local.version==4 else '::')
|
||||
@@ -207,7 +209,7 @@ def classify_dependencies(data):
|
||||
basis = 'Direction supplied by inventory source; not independently verified.'
|
||||
elif state in ('established','estab') and matching:
|
||||
direction,basis = 'Inbound (inferred)','Local address/port and nonzero PID match a listener. Client initiation is inferred, not observed.'
|
||||
elif state in ('established','estab') and remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152:
|
||||
elif state in ('established','estab') and has_remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152:
|
||||
direction,basis = 'Outbound (inferred)','Ephemeral local port to a non-ephemeral remote port without an attributed listener; heuristic only.'
|
||||
family = row_family(row)
|
||||
classification, rationale = UNKNOWN, 'No attributed application workload. No score from the connection alone.'
|
||||
@@ -219,12 +221,12 @@ def classify_dependencies(data):
|
||||
if direction == 'Listener':
|
||||
evidence(family,'attributed_application_listener',f"{row.get('process','unknown')} PID {row.get('pid','unknown')} listening at {row.get('local_address')}:{row.get('local_port')}; a listener alone is potential exposure.",ref)
|
||||
classification,rationale = POTENTIAL,'Listener attributed to a known application; grouped with service evidence.'
|
||||
elif state in ('established','estab') and direction.startswith('Inbound') and matching and remote and not remote.is_unspecified:
|
||||
elif state in ('established','estab') and direction.startswith('Inbound') and matching and has_remote and not (remote and remote.is_unspecified):
|
||||
if family == 'file-sharing':
|
||||
evidence(family,'observed_smb_transport',f"File-sharing transport observed on local port {row.get('local_port')}; specific share/export use is unverified without correlated open files.",ref)
|
||||
classification,rationale = POTENTIAL,'File-sharing transport observed; no named business share attributed.'
|
||||
else:
|
||||
evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True)
|
||||
evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {peer}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True)
|
||||
classification,rationale = CONFIRMED,'Observed transport activity on an attributed application listener; direction inference is recorded separately.'
|
||||
else:
|
||||
note('Other application traffic',UNKNOWN,f"{direction}: {row.get('process','unknown')}; not scored as an inbound consumer. {basis}",ref)
|
||||
@@ -237,7 +239,7 @@ def classify_dependencies(data):
|
||||
classification,rationale=STANDARD,'Baseline OS/management endpoint; no application workload attributed.'
|
||||
note('OS networking',STANDARD,rationale,ref)
|
||||
else:
|
||||
note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}. {basis}",ref)
|
||||
note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {peer}:{row.get('remote_port')}. {basis}",ref)
|
||||
network_observations.append({'inventory_index':index,'direction':direction,'direction_basis':basis,
|
||||
'classification':classification,'workload':family or '', 'rationale':rationale})
|
||||
return groups, aggregates, network_observations, task_observations
|
||||
|
||||
@@ -41,6 +41,7 @@ def load_policy(source=None):
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError('Invalid scoring policy. Use the bundled v2 policy structure, integer scores 0–100, ordered thresholds and positive coverage weights.')
|
||||
policy['name']='Deployment scoring policy'
|
||||
return policy
|
||||
|
||||
|
||||
|
||||
379
samsara/assets/privacy_catalogue.json
Normal file
379
samsara/assets/privacy_catalogue.json
Normal file
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"services": [
|
||||
"acpid",
|
||||
"apache2",
|
||||
"auditd",
|
||||
"bfe",
|
||||
"bind9",
|
||||
"bits",
|
||||
"brokerinfrastructure",
|
||||
"cbdhsvc",
|
||||
"cdpsusersvc",
|
||||
"cdpsvc",
|
||||
"certsvc",
|
||||
"chronyd",
|
||||
"clipsvc",
|
||||
"containerd",
|
||||
"coremessagingregistrar",
|
||||
"cron",
|
||||
"crond",
|
||||
"cryptsvc",
|
||||
"dbus",
|
||||
"dcomlaunch",
|
||||
"defragsvc",
|
||||
"deviceassociationservice",
|
||||
"dhcp",
|
||||
"dhcpserver",
|
||||
"diagnosticshub.standardcollector.service",
|
||||
"diagtrack",
|
||||
"dns",
|
||||
"dnscache",
|
||||
"docker",
|
||||
"dps",
|
||||
"dusmsvc",
|
||||
"eventlog",
|
||||
"eventsystem",
|
||||
"fdphost",
|
||||
"fdrespub",
|
||||
"firewalld",
|
||||
"fontcache",
|
||||
"gpsvc",
|
||||
"haproxy",
|
||||
"hidserv",
|
||||
"httpd",
|
||||
"hvhost",
|
||||
"iphlpsvc",
|
||||
"irqbalance",
|
||||
"isc-dhcp-server",
|
||||
"kea-dhcp4",
|
||||
"keyiso",
|
||||
"ktmrm",
|
||||
"lanmanserver",
|
||||
"lanmanworkstation",
|
||||
"lfsvc",
|
||||
"licensemanager",
|
||||
"lmhosts",
|
||||
"lsm",
|
||||
"mariadb",
|
||||
"minecraft",
|
||||
"minecraft-server",
|
||||
"mpssvc",
|
||||
"msdtc",
|
||||
"mssqlserver",
|
||||
"multipathd",
|
||||
"mysql",
|
||||
"mysqld",
|
||||
"named",
|
||||
"netlogon",
|
||||
"netman",
|
||||
"netprofm",
|
||||
"nfs-server",
|
||||
"nginx",
|
||||
"nlasvc",
|
||||
"nsi",
|
||||
"ntds",
|
||||
"pcasvc",
|
||||
"plugplay",
|
||||
"podman",
|
||||
"policyagent",
|
||||
"polkit",
|
||||
"postgresql",
|
||||
"power",
|
||||
"profsvc",
|
||||
"rasman",
|
||||
"redis",
|
||||
"redis-server",
|
||||
"remoteaccess",
|
||||
"remoteregistry",
|
||||
"rpceptmapper",
|
||||
"rpcss",
|
||||
"rsyslog",
|
||||
"rustserver",
|
||||
"samss",
|
||||
"schedule",
|
||||
"seclogon",
|
||||
"securityhealthservice",
|
||||
"sens",
|
||||
"sense",
|
||||
"sessionenv",
|
||||
"shellhwdetection",
|
||||
"smb",
|
||||
"smbd",
|
||||
"spooler",
|
||||
"sppsvc",
|
||||
"sqlserveragent",
|
||||
"sqlwriter",
|
||||
"ssdpsrv",
|
||||
"ssh",
|
||||
"sshd",
|
||||
"stisvc",
|
||||
"storsvc",
|
||||
"swprv",
|
||||
"sysmain",
|
||||
"systemeventsbroker",
|
||||
"termservice",
|
||||
"themes",
|
||||
"timebrokersvc",
|
||||
"tomcat",
|
||||
"trustedinstaller",
|
||||
"tuned",
|
||||
"ualsvc",
|
||||
"udisks2",
|
||||
"uhssvc",
|
||||
"umrdpservice",
|
||||
"usosvc",
|
||||
"valheim",
|
||||
"vds",
|
||||
"vmicheartbeat",
|
||||
"vmickvpexchange",
|
||||
"vmicshutdown",
|
||||
"vmictimesync",
|
||||
"vmicvss",
|
||||
"vss",
|
||||
"w32time",
|
||||
"w3logsvc",
|
||||
"w3svc",
|
||||
"was",
|
||||
"wdiservicehost",
|
||||
"wdisystemhost",
|
||||
"wdnissvc",
|
||||
"wecsvc",
|
||||
"wersvc",
|
||||
"windefend",
|
||||
"winhttpautoproxysvc",
|
||||
"winmgmt",
|
||||
"winrm",
|
||||
"wlidsvc",
|
||||
"wpnservice",
|
||||
"wsearch",
|
||||
"wuauserv"
|
||||
],
|
||||
"processes": [
|
||||
"apache2",
|
||||
"arkascendedserver",
|
||||
"bedrock_server",
|
||||
"brave",
|
||||
"chrome",
|
||||
"containerd",
|
||||
"curl",
|
||||
"dhcpd",
|
||||
"dockerd",
|
||||
"firefox",
|
||||
"haproxy",
|
||||
"hlds",
|
||||
"httpd",
|
||||
"idle",
|
||||
"iexplore",
|
||||
"iisexpress",
|
||||
"kea-dhcp4",
|
||||
"lsass",
|
||||
"mariadbd",
|
||||
"msedge",
|
||||
"msmpeng",
|
||||
"mysqld",
|
||||
"named",
|
||||
"nfsd",
|
||||
"nginx",
|
||||
"opera",
|
||||
"palserver",
|
||||
"podman",
|
||||
"postgres",
|
||||
"postmaster",
|
||||
"redis-server",
|
||||
"rustdedicated",
|
||||
"services",
|
||||
"shootergameserver",
|
||||
"smbd",
|
||||
"smss",
|
||||
"spoolsv",
|
||||
"sqlservr",
|
||||
"srcds",
|
||||
"srcds_linux",
|
||||
"sshd",
|
||||
"steam",
|
||||
"steamservice",
|
||||
"steamwebhelper",
|
||||
"svchost",
|
||||
"system",
|
||||
"tomcat",
|
||||
"valheim_server",
|
||||
"w3wp",
|
||||
"wget",
|
||||
"wininit",
|
||||
"winlogon",
|
||||
"wmiprvse",
|
||||
"wsmprovhost"
|
||||
],
|
||||
"roles": [
|
||||
"ad-domain-services",
|
||||
"adcs-cert-authority",
|
||||
"dhcp",
|
||||
"dns",
|
||||
"failover-clustering",
|
||||
"file-services",
|
||||
"fs-fileserver",
|
||||
"hyper-v",
|
||||
"net-framework-45-aspnet",
|
||||
"net-framework-45-core",
|
||||
"net-framework-45-features",
|
||||
"net-framework-core",
|
||||
"net-framework-features",
|
||||
"powershell",
|
||||
"powershell-ise",
|
||||
"powershell-v2",
|
||||
"rds-rd-server",
|
||||
"rsat",
|
||||
"storage-services",
|
||||
"telnet-client",
|
||||
"web-server",
|
||||
"windows-defender",
|
||||
"windows-defender-features",
|
||||
"windows-search",
|
||||
"wow64-support"
|
||||
],
|
||||
"products": [
|
||||
"Microsoft SQL Server",
|
||||
"PostgreSQL",
|
||||
"MariaDB",
|
||||
"MySQL",
|
||||
"nginx",
|
||||
"Apache HTTP Server",
|
||||
"Redis",
|
||||
"Docker",
|
||||
"Microsoft .NET",
|
||||
"Microsoft Visual C++",
|
||||
"Microsoft Edge",
|
||||
"Google Chrome",
|
||||
"Mozilla Firefox",
|
||||
"Steam",
|
||||
"Valheim Dedicated Server",
|
||||
"Minecraft Server"
|
||||
],
|
||||
"families": {
|
||||
"directory": {
|
||||
"names": [
|
||||
"ntds"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "ntds"
|
||||
},
|
||||
"sql": {
|
||||
"names": [
|
||||
"mssqlserver",
|
||||
"sqlserveragent"
|
||||
],
|
||||
"prefixes": [
|
||||
"mssql$",
|
||||
"sqlagent$"
|
||||
],
|
||||
"representative": "mssqlserver"
|
||||
},
|
||||
"iis": {
|
||||
"names": [
|
||||
"w3svc"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "w3svc"
|
||||
},
|
||||
"web": {
|
||||
"names": [
|
||||
"nginx",
|
||||
"apache2",
|
||||
"httpd",
|
||||
"haproxy",
|
||||
"tomcat"
|
||||
],
|
||||
"prefixes": [
|
||||
"tomcat"
|
||||
],
|
||||
"representative": "nginx"
|
||||
},
|
||||
"postgres": {
|
||||
"names": [
|
||||
"postgresql"
|
||||
],
|
||||
"prefixes": [
|
||||
"postgresql-"
|
||||
],
|
||||
"representative": "postgresql"
|
||||
},
|
||||
"mysql": {
|
||||
"names": [
|
||||
"mysql",
|
||||
"mysqld",
|
||||
"mariadb"
|
||||
],
|
||||
"prefixes": [
|
||||
"mysql",
|
||||
"mariadb"
|
||||
],
|
||||
"representative": "mysql"
|
||||
},
|
||||
"redis": {
|
||||
"names": [
|
||||
"redis",
|
||||
"redis-server"
|
||||
],
|
||||
"prefixes": [
|
||||
"redis-"
|
||||
],
|
||||
"representative": "redis"
|
||||
},
|
||||
"containers": {
|
||||
"names": [
|
||||
"docker",
|
||||
"containerd",
|
||||
"podman"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "docker"
|
||||
},
|
||||
"dns": {
|
||||
"names": [
|
||||
"dns",
|
||||
"named",
|
||||
"bind9"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "dns"
|
||||
},
|
||||
"dhcp": {
|
||||
"names": [
|
||||
"dhcpserver",
|
||||
"isc-dhcp-server",
|
||||
"kea-dhcp4"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "dhcpserver"
|
||||
},
|
||||
"ca": {
|
||||
"names": [
|
||||
"certsvc"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "certsvc"
|
||||
},
|
||||
"file-sharing": {
|
||||
"names": [
|
||||
"smbd",
|
||||
"smb",
|
||||
"nfs-server"
|
||||
],
|
||||
"prefixes": [],
|
||||
"representative": "smbd"
|
||||
},
|
||||
"game-server": {
|
||||
"names": [
|
||||
"minecraft",
|
||||
"minecraft-server",
|
||||
"valheim",
|
||||
"rustserver"
|
||||
],
|
||||
"prefixes": [
|
||||
"minecraft@",
|
||||
"valheim-server"
|
||||
],
|
||||
"representative": "minecraft"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
samsara/assets/privacy_policy.json
Normal file
9
samsara/assets/privacy_policy.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"report_directory": "reports",
|
||||
"approved_hostnames": [],
|
||||
"approved_share_roots": [],
|
||||
"approved_service_names": [],
|
||||
"approved_software_names": [],
|
||||
"max_session_assessments": 10
|
||||
}
|
||||
@@ -1,53 +1,31 @@
|
||||
"""Offline intake shared by Windows and Linux desktops. No code is executed."""
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from samsara.models.inventory import validate_inventory, restrict_inventory_fields
|
||||
from samsara.core.privacy import minimize_inventory
|
||||
from samsara.core.storage import require_local_path
|
||||
from samsara.models.inventory import validate_inventory
|
||||
|
||||
MAX_IMPORT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def load_inventory(path):
|
||||
with Path(path).open('rb') as source:
|
||||
local = require_local_path(path)
|
||||
flags=os.O_RDONLY|getattr(os,'O_NOFOLLOW',0)|getattr(os,'O_NONBLOCK',0)|getattr(os,'O_BINARY',0)
|
||||
with os.fdopen(os.open(local,flags),'rb') as source:
|
||||
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||
raise ValueError('Only regular local inventory files may be imported.')
|
||||
raw = source.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
raise ValueError('Inventory exceeds the 10 MiB import limit.')
|
||||
try:
|
||||
data = json.loads(raw.decode('utf-8-sig'))
|
||||
except (ValueError, UnicodeError):
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
raise ValueError('The selected file is not valid UTF-8 inventory JSON.') from None
|
||||
# A report wrapper is accepted, but its precomputed assessment is never trusted.
|
||||
if isinstance(data, dict) and 'inventory_result' in data:
|
||||
data = data['inventory_result']
|
||||
validate_inventory(data)
|
||||
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
|
||||
# contain command lines or credentials from external collection tools.
|
||||
safe_errors = {
|
||||
'access_denied': 'Current account could not read this scope.',
|
||||
'command_unavailable': 'Required command was unavailable.',
|
||||
'timeout': 'Section time limit exceeded.',
|
||||
'overall_timeout': 'Overall collection time limit exceeded.',
|
||||
'output_limit': 'Collection output limit exceeded.',
|
||||
'powershell_failed': 'PowerShell could not execute the collector.',
|
||||
'invalid_output': 'Invalid section output.',
|
||||
'collection_failed': 'A local query failed.'
|
||||
}
|
||||
checks = {}
|
||||
for name, check in data['checks'].items():
|
||||
clean = {'status': check['status'], 'message':
|
||||
'Imported check status. Source assertions are unverified; raw diagnostics excluded.'}
|
||||
for field in ('started_at', 'completed_at'):
|
||||
if field in check:
|
||||
clean[field] = check[field]
|
||||
if 'errors' in check:
|
||||
clean['errors'] = []
|
||||
for error in check['errors']:
|
||||
code = error['code'] if error['code'] in safe_errors else 'collection_failed'
|
||||
clean['errors'].append({'code': code, 'scope': 'Imported check', 'message': safe_errors[code]})
|
||||
if 'permission_limitations' in check:
|
||||
clean['permission_limitations'] = ['Collector reported limited read access; details excluded.'] if check['permission_limitations'] else []
|
||||
if 'limitations' in check:
|
||||
clean['limitations'] = ['Collector reported scope limitations; consult the inventory owner.'] if check['limitations'] else []
|
||||
checks[name] = clean
|
||||
data['checks'] = checks
|
||||
return restrict_inventory_fields(data)
|
||||
return validate_inventory(minimize_inventory(data))
|
||||
|
||||
@@ -1,56 +1,26 @@
|
||||
"""Read-only local Linux inventory. No networking, sudo, shells or configuration changes."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from samsara.collectors.linux_schedules import read_system_cron, enrich_timers
|
||||
|
||||
|
||||
class CollectionCancelled(Exception):
|
||||
pass
|
||||
from samsara.core.process import CollectionCancelled, capture
|
||||
from samsara.core.privacy import minimize_inventory, load_privacy_policy
|
||||
|
||||
|
||||
def run_command(arguments, cancelled=lambda: False, timeout=8):
|
||||
"""Execute only fixed collector commands from system directories, with bounded output."""
|
||||
executable = next((str(Path(folder) / arguments[0]) for folder in
|
||||
('/usr/bin', '/usr/sbin', '/bin', '/sbin')
|
||||
if (Path(folder) / arguments[0]).is_file()), None)
|
||||
if executable is None:
|
||||
raise RuntimeError('Required inventory command is unavailable.')
|
||||
environment = {'PATH': '/usr/bin:/usr/sbin:/bin:/sbin', 'LC_ALL': 'C',
|
||||
'LANG': 'C', 'SYSTEMD_PAGER': '', 'SYSTEMD_COLORS': '0'}
|
||||
with tempfile.TemporaryFile() as output:
|
||||
process = subprocess.Popen([executable, *arguments[1:]], stdout=output,
|
||||
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
|
||||
env=environment, shell=False)
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if cancelled():
|
||||
raise CollectionCancelled('Local collection cancelled.')
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError('Inventory command timed out.')
|
||||
if os.fstat(output.fileno()).st_size > 10 * 1024 * 1024:
|
||||
raise RuntimeError('Inventory command exceeded its output limit.')
|
||||
time.sleep(.05)
|
||||
if process.returncode:
|
||||
raise RuntimeError('Inventory command unavailable or access denied.')
|
||||
output.seek(0)
|
||||
raw = output.read(10 * 1024 * 1024 + 1)
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise RuntimeError('Inventory command exceeded its output limit.')
|
||||
return raw.decode('utf-8', errors='replace')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
executable = next((str(Path(folder)/arguments[0]) for folder in
|
||||
('/usr/bin','/usr/sbin','/bin','/sbin') if (Path(folder)/arguments[0]).is_file()), None)
|
||||
if executable is None: raise RuntimeError('Required inventory command unavailable.')
|
||||
environment = {'PATH':'/usr/bin:/usr/sbin:/bin:/sbin','LC_ALL':'C','LANG':'C',
|
||||
'SYSTEMD_PAGER':'','SYSTEMD_COLORS':'0'}
|
||||
return capture([executable,*arguments[1:]], env=environment, timeout=timeout,
|
||||
cancelled=cancelled).decode('utf-8',errors='replace')
|
||||
|
||||
|
||||
def parse_units(text, enabled_text, kind='service'):
|
||||
@@ -74,6 +44,12 @@ def parse_units(text, enabled_text, kind='service'):
|
||||
return rows
|
||||
|
||||
|
||||
# ss(8) spellings mapped to the canonical names shared with the Windows collector.
|
||||
TCP_STATES = {'LISTEN': 'Listen', 'ESTAB': 'Established', 'TIME-WAIT': 'TimeWait', 'CLOSE-WAIT': 'CloseWait',
|
||||
'SYN-SENT': 'SynSent', 'SYN-RECV': 'SynReceived', 'FIN-WAIT-1': 'FinWait1',
|
||||
'FIN-WAIT-2': 'FinWait2', 'LAST-ACK': 'LastAck', 'CLOSING': 'Closing'}
|
||||
|
||||
|
||||
def endpoint(value):
|
||||
address, port = value.rsplit(':', 1)
|
||||
return address.strip('[]'), int(port) if port.isdigit() else 0
|
||||
@@ -85,7 +61,7 @@ def parse_connections(text):
|
||||
fields = line.split(None, 5)
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
state = {'LISTEN': 'Listen', 'ESTAB': 'Established'}.get(fields[0], fields[0])
|
||||
state = TCP_STATES.get(fields[0], fields[0])
|
||||
local, lp = endpoint(fields[3])
|
||||
remote, rp = endpoint(fields[4])
|
||||
process = re.search(r'\("([^"\n]+)",pid=(\d+)', fields[5] if len(fields) > 5 else '')
|
||||
@@ -100,10 +76,11 @@ def parse_connections(text):
|
||||
def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, runner=run_command):
|
||||
if platform.system() != 'Linux':
|
||||
raise ValueError('Local Linux collection requires a Linux execution host.')
|
||||
privacy_policy = load_privacy_policy()
|
||||
started = time.monotonic()
|
||||
data = {'schema_version': '1.0', 'synthetic': False,
|
||||
'collected_at': datetime.now(timezone.utc).isoformat(),
|
||||
'server': {'hostname': socket.gethostname(), 'platform': 'linux',
|
||||
'server': {'hostname': socket.gethostname() if privacy_policy['approved_hostnames'] 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': [],
|
||||
@@ -151,14 +128,9 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run
|
||||
check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.')
|
||||
def timers():
|
||||
units('timer')
|
||||
enrich_timers(data['inventory']['tasks'], command)
|
||||
# Detailed unit properties may contain identifiers; omit by default.
|
||||
check('tasks', timers, 'Loaded system timers and available calendar/next-run metadata observed; unloaded/user timers and application schedulers require review.')
|
||||
def cron():
|
||||
rows, incomplete = read_system_cron(cancelled)
|
||||
data['inventory']['tasks'].extend(rows)
|
||||
if incomplete:
|
||||
raise RuntimeError('Some system cron files inaccessible')
|
||||
check('cron', cron, 'Readable /etc/crontab and /etc/cron.d entries observed. Arguments omitted. User crontabs, anacron rules and periodic-script contents not inspected; timezone and actual execution unverified.')
|
||||
data['checks']['cron'] = {'status':'not_collected','message':'Privacy policy: cron commands and script files are not read. Verify schedules separately.'}
|
||||
def network():
|
||||
data['inventory']['network'] = parse_connections(command(['ss', '-H', '-n', '-t', '-a', '-p']))
|
||||
check('network', network, 'TCP snapshot only. Process visibility is limited by permissions; direction, historical activity, UDP and other namespaces not verified.')
|
||||
@@ -192,7 +164,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run
|
||||
}.items():
|
||||
data['checks'][key] = {'status': 'not_collected', 'message': message}
|
||||
check_cancel()
|
||||
return data
|
||||
return minimize_inventory(data, privacy_policy, trusted_source='Local Linux read-only collection')
|
||||
|
||||
|
||||
def main():
|
||||
@@ -204,8 +176,8 @@ def main():
|
||||
try:
|
||||
result = collect_local_linux()
|
||||
# Exclusive creation prevents accidentally replacing an existing report.
|
||||
with open(args.output, 'x', encoding='utf-8') as destination:
|
||||
json.dump(result, destination, indent=2)
|
||||
from samsara.core.storage import write_private
|
||||
write_private(args.output, json.dumps(result, indent=2))
|
||||
print('Local inventory saved. Review per-check statuses before making retirement decisions.')
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
parser.exit(1, 'Collection/export failed. Check Linux platform, destination permissions and whether the destination already exists.\n')
|
||||
|
||||
@@ -8,13 +8,13 @@ from pathlib import Path
|
||||
import platform
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
from samsara.collectors.linux import CollectionCancelled
|
||||
from samsara.core.process import CollectionCancelled, ProcessFailure, capture
|
||||
from samsara.core.privacy import minimize_inventory, load_privacy_policy, safe_time
|
||||
from samsara.core.detection import check_profile
|
||||
from samsara.models.inventory import inventory_schema, validate_inventory, restrict_inventory_fields
|
||||
from samsara.models.inventory import inventory_schema, validate_inventory
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / 'powershell' / 'Get-ServerInventory.ps1'
|
||||
SECTIONS = ('system', 'roles', 'services', 'network', 'tasks', 'software', 'storage', 'shares', 'certificates')
|
||||
@@ -56,44 +56,19 @@ def run_section(section, executable, cancelled=lambda: False, timeout=45, diagno
|
||||
args = [str(executable), '-NoLogo', '-NoProfile', '-NonInteractive', '-File', str(SCRIPT), '-Section', section]
|
||||
if diagnostic:
|
||||
args.append('-Diagnostic')
|
||||
environment = os.environ.copy()
|
||||
# Only inbox modules are needed. Do not autoload user-supplied modules or profiles.
|
||||
environment = {key: os.environ[key] for key in ('SystemRoot','WINDIR','TEMP','TMP','COMSPEC','SystemDrive') if key in os.environ}
|
||||
environment['PSModulePath'] = str(Path(executable).parent / 'Modules')
|
||||
with tempfile.TemporaryFile() as output:
|
||||
try:
|
||||
process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=output,
|
||||
stderr=subprocess.DEVNULL, shell=False, env=environment,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
except OSError:
|
||||
raise WindowsCollectionError('powershell_failed', 'Windows PowerShell could not start. Check executable permissions and local policy.') from None
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if cancelled():
|
||||
raise CollectionCancelled()
|
||||
if time.monotonic() >= deadline:
|
||||
raise WindowsCollectionError('timeout', 'The local section exceeded its time limit.')
|
||||
if os.fstat(output.fileno()).st_size > MAX_OUTPUT:
|
||||
raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.')
|
||||
time.sleep(.05)
|
||||
if cancelled():
|
||||
raise CollectionCancelled()
|
||||
if process.returncode:
|
||||
if process.returncode == 3:
|
||||
raise WindowsCollectionError('serialization_failed', 'PowerShell could not serialize inventory metadata; no successful evidence was substituted.')
|
||||
raise WindowsCollectionError('powershell_failed', 'The trusted script could not run. Check script signing policy, module availability and read permissions; raw diagnostics are excluded.')
|
||||
output.seek(0)
|
||||
raw = output.read(MAX_OUTPUT + 1)
|
||||
if len(raw) > MAX_OUTPUT:
|
||||
raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.')
|
||||
try:
|
||||
return json.loads(raw.decode('utf-8-sig'))
|
||||
except (UnicodeError, ValueError, RecursionError):
|
||||
raise WindowsCollectionError('invalid_output', 'PowerShell did not return a valid UTF-8 JSON section.') from None
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
try:
|
||||
raw = capture(args, env=environment, timeout=timeout, cancelled=cancelled,
|
||||
limit=MAX_OUTPUT, creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
|
||||
except ProcessFailure as exc:
|
||||
if exc.returncode == 3:
|
||||
raise WindowsCollectionError('serialization_failed', 'PowerShell could not serialize inventory metadata; no successful evidence was substituted.') from None
|
||||
raise WindowsCollectionError(exc.code, str(exc)) from None
|
||||
try:
|
||||
return json.loads(raw.decode('utf-8-sig'))
|
||||
except (UnicodeError, ValueError, RecursionError):
|
||||
raise WindowsCollectionError('invalid_output','PowerShell did not return a valid JSON section.') from None
|
||||
|
||||
|
||||
def validate_section(record, section):
|
||||
@@ -108,7 +83,8 @@ def validate_section(record, section):
|
||||
section_schema = {'type': 'object', 'required': ['section','status','message','started_at','completed_at','errors','permission_limitations','limitations','data'], 'properties': properties}
|
||||
if not Draft202012Validator(section_schema, format_checker=FormatChecker()).is_valid(record):
|
||||
raise WindowsCollectionError('invalid_output', 'PowerShell returned a section that violates the inventory contract.')
|
||||
if record['completed_at'] < record['started_at']:
|
||||
start, end = safe_time(record['started_at']), safe_time(record['completed_at'])
|
||||
if start is None or end is None or end < start:
|
||||
# Both native timestamps use the same UTC format. This is not a trust check.
|
||||
raise WindowsCollectionError('invalid_output', 'PowerShell returned inconsistent section timestamps.')
|
||||
return record
|
||||
@@ -136,6 +112,7 @@ def collect_local_windows(stage=lambda message: None, cancelled=lambda: False, p
|
||||
raise ValueError('Local Windows collection requires an authorized Windows Server execution host.')
|
||||
if profile != 'auto' and not profile.startswith('windows:'):
|
||||
raise ValueError('Select Auto-detect or a Windows Server profile for this execution host.')
|
||||
load_privacy_policy()
|
||||
executable = powershell_executable()
|
||||
runner = runner or run_section
|
||||
started = time.monotonic()
|
||||
@@ -179,7 +156,7 @@ def collect_local_windows(stage=lambda message: None, cancelled=lambda: False, p
|
||||
raise ValueError('Windows identity collection failed: ' + str(exc)) from None
|
||||
if cancelled():
|
||||
raise CollectionCancelled()
|
||||
return validate_inventory(restrict_inventory_fields(result, local_source='Local Windows read-only collection'))
|
||||
return validate_inventory(minimize_inventory(result, trusted_source='Local Windows read-only collection'))
|
||||
|
||||
|
||||
def main():
|
||||
@@ -190,8 +167,8 @@ def main():
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
data = collect_local_windows(profile=args.profile, diagnostic=args.diagnostic)
|
||||
with open(args.output, 'x', encoding='utf-8') as output:
|
||||
json.dump(data, output, indent=2)
|
||||
from samsara.core.storage import write_private
|
||||
write_private(args.output, json.dumps(data, indent=2))
|
||||
print('Windows inventory saved. Review all failed/partial checks and permission limitations before retirement decisions.')
|
||||
except (OSError, ValueError, WindowsCollectionError):
|
||||
parser.exit(1, 'Windows inventory was not saved. Check supported local Server OS, Python architecture, script policy, permissions and a new writable output filename.\n')
|
||||
|
||||
@@ -46,4 +46,4 @@ def check_profile(detected, profile):
|
||||
raise ValueError('Unknown operating system profile.')
|
||||
family, version = profile.split(':')
|
||||
if detected['family'] != family or (version != 'any' and detected['version'] != version):
|
||||
raise ValueError(f"Detected {detected['name']}, which does not match the selected profile. Select Auto-detect to assess this computer.")
|
||||
raise ValueError('Detected operating system does not match the selected profile. Select Auto-detect to assess this computer.')
|
||||
|
||||
259
samsara/core/privacy.py
Normal file
259
samsara/core/privacy.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Minimize identifying metadata before UI, analysis and export.
|
||||
|
||||
This is a disclosure-reduction boundary, not PHI detection or HIPAA certification.
|
||||
Legacy inputs and provider-returned metadata can enter memory before projection.
|
||||
Only deployment-reviewed exact allowlists can authorize otherwise omitted names.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path, PureWindowsPath, PurePosixPath
|
||||
import re
|
||||
|
||||
from samsara.analyzers.dependencies import (STANDARD_SERVICES, STANDARD_PROCESS, CLIENT_PROCESS,
|
||||
FAMILIES, ROLE_FAMILIES, service_family, normal_name)
|
||||
|
||||
POLICY_PATH = Path(__file__).resolve().parents[1] / 'assets' / 'privacy_policy.json'
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
OMITTED = 'Omitted by privacy policy'
|
||||
SECTIONS = {'system','interfaces','roles','services','network','tasks','cron','software','storage',
|
||||
'shares','share_activity','certificates','directory','ad','privacy'}
|
||||
SCORES = {'complete','partial','failed','not_collected'}
|
||||
ERROR_CODES = {'access_denied','command_unavailable','timeout','overall_timeout','output_limit',
|
||||
'powershell_failed','invalid_output','serialization_failed','collection_failed','cleanup_failed'}
|
||||
KNOWN_ROLES = set(ROLE_FAMILIES) | {'net-framework-core','net-framework-45-core','net-framework-45-aspnet',
|
||||
'net-framework-features','net-framework-45-features','powershell','powershell-v2','powershell-ise',
|
||||
'windows-defender','windows-defender-features','storage-services','file-services','fs-fileserver',
|
||||
'wow64-support','rsat','telnet-client','windows-search'}
|
||||
KNOWN_SERVICES = STANDARD_SERVICES | {name for _,names,_,_ in FAMILIES.values() for name in names}
|
||||
KNOWN_PROCESSES = STANDARD_PROCESS | CLIENT_PROCESS | {name for _,_,_,names in FAMILIES.values() for name in names}
|
||||
PRODUCTS = ('Microsoft SQL Server','PostgreSQL','MariaDB','MySQL','nginx','Apache HTTP Server',
|
||||
'Redis','Docker','Microsoft .NET','Microsoft Visual C++','Microsoft Edge','Google Chrome',
|
||||
'Mozilla Firefox','Steam','Valheim Dedicated Server','Minecraft Server')
|
||||
TASK_STATES = {'Unknown','Disabled','Queued','Ready','Running','active/waiting','active/running','inactive/dead'}
|
||||
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'}
|
||||
SOURCES = {'Local Windows read-only collection','Local Linux read-only collection'}
|
||||
|
||||
|
||||
def load_privacy_policy(path=POLICY_PATH):
|
||||
try:
|
||||
with Path(path).open('rb') as source:
|
||||
raw = source.read(65537)
|
||||
if len(raw)>65536:
|
||||
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'}
|
||||
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()
|
||||
if not isinstance(policy['report_directory'],str) or not policy['report_directory']:
|
||||
raise ValueError()
|
||||
for field in expected-{'version','report_directory','max_session_assessments'}:
|
||||
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']:
|
||||
canonical_share_root(root) # Exact roots only; never a descendant allowlist.
|
||||
return policy
|
||||
except (OSError, UnicodeError, ValueError, TypeError, RecursionError):
|
||||
raise ValueError('Privacy policy is unavailable or invalid. Collection/export is blocked; contact the deployment administrator.') from None
|
||||
|
||||
|
||||
def canonical_share_root(value):
|
||||
if not isinstance(value,str) or not value or any(ord(c)<32 for c in value):
|
||||
raise ValueError('Invalid approved share root.')
|
||||
if value.startswith(('\\\\','//')) or '..' in value.replace('\\','/').split('/'):
|
||||
raise ValueError('Only explicit local share roots may be approved.')
|
||||
windows = PureWindowsPath(value)
|
||||
if windows.drive:
|
||||
if not re.fullmatch(r'[A-Za-z]:',windows.drive) or not windows.is_absolute():
|
||||
raise ValueError('Invalid approved local root.')
|
||||
return str(windows).rstrip('\\').casefold()
|
||||
posix = PurePosixPath(value)
|
||||
if not posix.is_absolute():
|
||||
raise ValueError('Approved share roots must be absolute.')
|
||||
return str(posix).rstrip('/') or '/'
|
||||
|
||||
|
||||
def safe_time(value):
|
||||
if not isinstance(value,str) or not re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|[+-][0-9]{2}:[0-9]{2})',value):
|
||||
return None
|
||||
try:
|
||||
parsed=datetime.fromisoformat(value.replace('Z','+00:00'))
|
||||
return parsed.astimezone(timezone.utc).isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def numeric_version(value):
|
||||
if not isinstance(value,str): return 'Not collected'
|
||||
match=re.match(r'^[0-9]{1,5}(?:\.[0-9]{1,5}){0,3}(?![0-9.])',value)
|
||||
return match[0] if match else 'Not collected'
|
||||
|
||||
|
||||
def safe_ip(value):
|
||||
if not isinstance(value,str): return ''
|
||||
try:
|
||||
return str(ipaddress.ip_address(value.split('%')[0].strip('[]')))
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
REMOTE_SCOPES = {'loopback','host','unspecified','private','public'}
|
||||
|
||||
|
||||
def remote_scope(value, own, previous=None):
|
||||
"""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():
|
||||
return (previous if previous in ('private','public') else ''), ''
|
||||
try:
|
||||
ip=ipaddress.ip_address(value.split('%')[0].strip().strip('[]'))
|
||||
except ValueError:
|
||||
return '', ''
|
||||
if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped
|
||||
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'), ''
|
||||
|
||||
|
||||
def integer(value, maximum=2**32-1):
|
||||
return value if type(value) is int and 0<=value<=maximum else 0
|
||||
|
||||
|
||||
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
|
||||
name=normal_name(value)
|
||||
if name in KNOWN_SERVICES: return name
|
||||
family=service_family(value)
|
||||
if family:
|
||||
# Preserve family recognition without retaining instance/customer suffixes.
|
||||
representative=FAMILIES[family][1][0]
|
||||
return representative
|
||||
if re.fullmatch(r'(?:vmic|systemd-)[a-z0-9_.-]+',name):
|
||||
return f'OS service-{index+1:04d}'
|
||||
return f'Service-{index+1:04d}'
|
||||
|
||||
|
||||
def task_is_maintenance(row):
|
||||
path=str(row.get('path','')).replace('/','\\').lower()
|
||||
return path.startswith('\\microsoft\\windows\\') and not path.startswith('\\microsoft\\windows\\powershell\\scheduledjobs')
|
||||
|
||||
|
||||
def enum_value(value, choices, default='Unknown'):
|
||||
return value if isinstance(value,str) and value in choices else default
|
||||
|
||||
|
||||
def minimize_inventory(data, policy=None, *, trusted_source=None):
|
||||
policy=load_privacy_policy() if policy is None else policy
|
||||
if not isinstance(data,dict) or not isinstance(data.get('server'),dict) or not isinstance(data.get('inventory'),dict) or not isinstance(data.get('checks'),dict):
|
||||
raise ValueError('Invalid inventory container.')
|
||||
stamp=safe_time(data.get('collected_at'))
|
||||
if not stamp: raise ValueError('Invalid collection timestamp.')
|
||||
raw=data['server']; platform=raw.get('platform')
|
||||
if platform not in ('windows','linux'): raise ValueError('Unsupported inventory platform.')
|
||||
year=raw.get('os_version') if raw.get('os_version') in ('2016','2019','2022','2025') else None
|
||||
if platform=='windows' and year is 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'
|
||||
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','')),
|
||||
'os_build':numeric_version(raw.get('os_build','')),
|
||||
'last_boot':safe_time(raw.get('last_boot')) or 'Not collected',
|
||||
'domain':'Domain identity omitted',
|
||||
'ip_addresses':[ip for value in raw.get('ip_addresses',[]) if (ip:=safe_ip(value))],
|
||||
'network_adapters':[f'Adapter-{i+1:04d}' for i,_ in enumerate(raw.get('network_adapters',[]))]}
|
||||
if isinstance(raw.get('os_build'),str) and re.fullmatch(r'[0-9]{1,6}',raw['os_build']): server['os_build']=raw['os_build']
|
||||
if raw.get('product_type') in (1,2,3) and type(raw['product_type']) is int: server['product_type']=raw['product_type']
|
||||
if type(raw.get('domain_joined')) is bool: server['domain_joined']=raw['domain_joined']
|
||||
source=trusted_source if trusted_source in SOURCES else 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',
|
||||
'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']:
|
||||
rows=data['inventory'].get(section,[])
|
||||
if not isinstance(rows,list) or len(rows)>20000: raise ValueError('Inventory section exceeds its permitted structure or row limit.')
|
||||
for i,row in enumerate(rows):
|
||||
if not isinstance(row,dict): raise ValueError('Inventory row must be an object.')
|
||||
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'})
|
||||
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'))
|
||||
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}',
|
||||
'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',
|
||||
'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:
|
||||
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'))})
|
||||
elif section=='storage':
|
||||
kind=row.get('type') if row.get('type') in ('Volume','SMB Share','NFS Export','Samba Share','Remote Mount','Mounted Filesystem') else 'Volume'
|
||||
admin=isinstance(name,str) and re.fullmatch(r'(?:ADMIN|IPC|[A-Z])\$',name,re.I)
|
||||
path=OMITTED
|
||||
try:
|
||||
canonical=canonical_share_root(row.get('path'))
|
||||
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}',
|
||||
'path':path,'capacity_gib':None,'free_gib':None}
|
||||
for key in ('capacity_gib','free_gib'):
|
||||
number=row.get(key)
|
||||
if type(number) in (int,float) and 0<=number<=10**12: entry[key]=number
|
||||
if row.get('filesystem') in ('NTFS','ReFS','FAT32','exFAT','ext4','xfs','btrfs','tmpfs','overlay'): entry['filesystem']=row['filesystem']
|
||||
if type(row.get('special')) is bool: entry['special']=row['special']
|
||||
# Historical aggregate observations can be retained without paths/identities.
|
||||
if row.get('activity_source') in ('Get-SmbOpenFile (share-root-correlated)','Get-SmbOpenFile (administrative alias ambiguous)'):
|
||||
entry['activity_source']=row['activity_source']
|
||||
for key in ('observed_open_files','observed_client_count'): entry[key]=integer(row.get(key))
|
||||
observed=safe_time(row.get('activity_observed_at'))
|
||||
if observed: entry['activity_observed_at']=observed
|
||||
result['inventory'][section].append(entry)
|
||||
elif section=='certificates':
|
||||
entry={'subject':f'Certificate-{i+1:04d}','issuer':OMITTED}
|
||||
if safe_time(row.get('expires')): entry['expires']=safe_time(row['expires'])
|
||||
if row.get('store') in ('My','Root','CA','TrustedPeople','TrustedPublisher','AuthRoot','Disallowed'): entry['store']=row['store']
|
||||
result['inventory'][section].append(entry)
|
||||
for name,check in data['checks'].items():
|
||||
if name not in SECTIONS: continue
|
||||
if not isinstance(check,dict): raise ValueError('Invalid collection check.')
|
||||
status=enum_value(check.get('status'), SCORES, 'failed')
|
||||
clean={'status':status,'message':{'complete':'Documented query scope completed.','partial':'Scope incomplete; manual review required.','failed':'Collection check failed; raw diagnostics omitted.','not_collected':'Scope not collected; review required.'}[status],
|
||||
'errors':[],'permission_limitations':[],'limitations':['Identifying text is minimized; metadata may have entered source-process memory before projection.']}
|
||||
for field in ('started_at','completed_at'):
|
||||
if safe_time(check.get(field)): clean[field]=safe_time(check[field])
|
||||
for error in (check.get('errors',[]) if isinstance(check.get('errors',[]),list) else [])[:30]:
|
||||
code=error.get('code') if isinstance(error,dict) else 'collection_failed'
|
||||
if not isinstance(code,str) or code not in ERROR_CODES: code='collection_failed'
|
||||
clean['errors'].append({'code':code,'scope':name,'message':'Collection limitation; raw diagnostics omitted.'})
|
||||
if check.get('permission_limitations'): clean['permission_limitations']=['Current token may have incomplete read access.']
|
||||
result['checks'][name]=clean
|
||||
result['checks']['privacy']={'status':'partial','message':'Identifying fields omitted. Source metadata may enter memory; this mode is not a PHI-free attestation.',
|
||||
'errors':[],'permission_limitations':[],'limitations':['Names/accounts/path detail are reduced. Numeric IP addresses remain infrastructure-confidential data. Legacy imports are processed before projection.']}
|
||||
return result
|
||||
62
samsara/core/process.py
Normal file
62
samsara/core/process.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Bounded child output in memory; never spool inventory to temporary files."""
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class CollectionCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessFailure(RuntimeError):
|
||||
def __init__(self, code, returncode=None):
|
||||
self.code = code
|
||||
self.returncode = returncode
|
||||
super().__init__('Inventory subprocess failed (' + code + '); diagnostics omitted.')
|
||||
|
||||
|
||||
def capture(arguments, *, env, timeout, cancelled=lambda: False, limit=10*1024*1024, creationflags=0):
|
||||
if cancelled():
|
||||
raise CollectionCancelled()
|
||||
try:
|
||||
child = subprocess.Popen(arguments, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, shell=False, env=env, creationflags=creationflags)
|
||||
except OSError:
|
||||
raise ProcessFailure('command_unavailable') from None
|
||||
buffer = bytearray()
|
||||
exceeded = threading.Event()
|
||||
read_failed = threading.Event()
|
||||
def drain():
|
||||
try:
|
||||
while chunk := child.stdout.read(65536):
|
||||
if len(buffer)+len(chunk)>limit:
|
||||
exceeded.set()
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
except (OSError, ValueError):
|
||||
read_failed.set()
|
||||
reader = threading.Thread(target=drain, daemon=True)
|
||||
reader.start()
|
||||
deadline = time.monotonic()+timeout
|
||||
try:
|
||||
while child.poll() is None:
|
||||
if cancelled(): raise CollectionCancelled()
|
||||
if exceeded.is_set(): raise ProcessFailure('output_limit')
|
||||
if time.monotonic()>=deadline: raise ProcessFailure('timeout')
|
||||
time.sleep(.02)
|
||||
reader.join(timeout=1)
|
||||
if reader.is_alive(): raise ProcessFailure('cleanup_failed')
|
||||
if cancelled(): raise CollectionCancelled()
|
||||
if exceeded.is_set(): raise ProcessFailure('output_limit')
|
||||
if read_failed.is_set() or child.returncode: raise ProcessFailure('collection_failed', child.returncode)
|
||||
return bytes(buffer)
|
||||
finally:
|
||||
if child.poll() is None: child.kill()
|
||||
try:
|
||||
child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ProcessFailure('cleanup_failed') from None
|
||||
# Fixed collectors must not spawn descendants holding stdout open.
|
||||
# Do not block on closing a buffered stream owned by a stalled reader.
|
||||
reader.join(timeout=.2)
|
||||
if not reader.is_alive(): child.stdout.close()
|
||||
118
samsara/core/storage.py
Normal file
118
samsara/core/storage.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Exclusive private report files in a deployment-configured local directory.
|
||||
|
||||
Network-mounted storage detection is best effort; enterprise egress controls remain
|
||||
required. Windows roots must be provisioned with a protected, restricted DACL.
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
|
||||
from samsara.core.privacy import load_privacy_policy, PROJECT_ROOT
|
||||
|
||||
|
||||
def require_local_path(path):
|
||||
value = os.fspath(path)
|
||||
if not value or value.startswith(('\\\\','//')) or '://' in value:
|
||||
raise ValueError('Network and URI paths are not permitted.')
|
||||
candidate = Path(os.path.abspath(value))
|
||||
if os.name == 'nt':
|
||||
kernel = ctypes.WinDLL('kernel32',use_last_error=True,winmode=0x800)
|
||||
kernel.GetDriveTypeW.argtypes=[ctypes.c_wchar_p]
|
||||
if kernel.GetDriveTypeW(candidate.anchor) != 3:
|
||||
raise ValueError('A fixed local drive is required.')
|
||||
else:
|
||||
# Inspect mount metadata only; do not probe remote mounts.
|
||||
for line in Path('/proc/self/mountinfo').read_text().splitlines():
|
||||
before,after=line.split(' - ',1)
|
||||
mount=re.sub(r'\\([0-7]{3})',lambda m: chr(int(m[1],8)),before.split()[4])
|
||||
fs=after.split()[0]
|
||||
if (fs.startswith('fuse.') or fs in {'nfs','nfs4','cifs','smb3','9p','ceph','afs','glusterfs','davfs','fuse.sshfs','fuse.rclone'}) and (candidate==Path(mount) or Path(mount) in candidate.parents):
|
||||
raise ValueError('Network-backed paths are not permitted.')
|
||||
for parent in reversed([candidate,*candidate.parents]):
|
||||
try:
|
||||
info = parent.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(info.st_mode) or getattr(info,'st_file_attributes',0)&0x400:
|
||||
raise ValueError('Symbolic links and reparse points are not permitted.')
|
||||
return candidate
|
||||
|
||||
|
||||
def _verify_windows_acl(path, *, protected=True):
|
||||
"""Fail closed: protected DACL; allow ACEs only owner, SYSTEM, Administrators.
|
||||
|
||||
No ACL changes, elevation or shell commands. Native Windows validation pending.
|
||||
"""
|
||||
from ctypes import wintypes as w
|
||||
adv=ctypes.WinDLL('advapi32',use_last_error=True,winmode=0x800); kernel=ctypes.WinDLL('kernel32',use_last_error=True,winmode=0x800)
|
||||
ptr=ctypes.c_void_p
|
||||
adv.GetNamedSecurityInfoW.argtypes=[w.LPWSTR,w.DWORD,w.DWORD,ctypes.POINTER(ptr),ptr,ctypes.POINTER(ptr),ptr,ctypes.POINTER(ptr)]
|
||||
adv.GetNamedSecurityInfoW.restype=w.DWORD
|
||||
adv.GetSecurityDescriptorControl.argtypes=[ptr,ctypes.POINTER(w.WORD),ctypes.POINTER(w.DWORD)]
|
||||
adv.ConvertSidToStringSidW.argtypes=[ptr,ctypes.POINTER(w.LPWSTR)]
|
||||
adv.GetAce.argtypes=[ptr,w.DWORD,ctypes.POINTER(ptr)]
|
||||
kernel.LocalFree.argtypes=[ptr];kernel.LocalFree.restype=ptr
|
||||
owner=ptr();acl=ptr();descriptor=ptr()
|
||||
result=adv.GetNamedSecurityInfoW(str(path),1,5,ctypes.byref(owner),None,ctypes.byref(acl),None,ctypes.byref(descriptor))
|
||||
if result: raise ValueError('Cannot verify report-directory security.')
|
||||
def sid_text(sid):
|
||||
value=w.LPWSTR()
|
||||
if not adv.ConvertSidToStringSidW(sid,ctypes.byref(value)): raise ValueError('Invalid report ACL.')
|
||||
try:return value.value
|
||||
finally:kernel.LocalFree(ctypes.cast(value,ptr))
|
||||
try:
|
||||
control=w.WORD();revision=w.DWORD()
|
||||
if not adv.GetSecurityDescriptorControl(descriptor,ctypes.byref(control),ctypes.byref(revision)) or (protected and not(control.value & 0x1000)) or not acl.value:
|
||||
raise ValueError('Report directory requires a protected DACL.')
|
||||
permitted={sid_text(owner),'S-1-5-18','S-1-5-32-544'}
|
||||
# ACL header: revision byte, reserved byte, size WORD, ACE count WORD.
|
||||
count=ctypes.c_ushort.from_address(acl.value+4).value
|
||||
if count==0: raise ValueError('Report directory has no usable access grants.')
|
||||
for index in range(count):
|
||||
ace=ptr()
|
||||
if not adv.GetAce(acl,index,ctypes.byref(ace)): raise ValueError('Cannot verify report ACL.')
|
||||
kind=ctypes.c_ubyte.from_address(ace.value).value
|
||||
if kind!=0 or sid_text(ptr(ace.value+8)) not in permitted:
|
||||
raise ValueError('Report directory grants access outside owner, SYSTEM and Administrators.')
|
||||
finally:kernel.LocalFree(descriptor)
|
||||
|
||||
|
||||
def report_directory():
|
||||
configured=Path(load_privacy_policy()['report_directory'])
|
||||
root=require_local_path(configured if configured.is_absolute() else PROJECT_ROOT/configured)
|
||||
if os.name=='nt':
|
||||
if not root.is_dir():
|
||||
raise ValueError('An administrator must provision the approved private report directory first.')
|
||||
_verify_windows_acl(root)
|
||||
else:
|
||||
root.mkdir(mode=0o700,exist_ok=True)
|
||||
info=root.stat()
|
||||
if info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)&0o077:
|
||||
raise ValueError('Report directory must be owned by the current user with mode 0700.')
|
||||
return root
|
||||
|
||||
|
||||
def write_private(path, payload):
|
||||
destination=require_local_path(path)
|
||||
root=report_directory()
|
||||
if destination.parent!=root or destination.suffix.lower() not in ('.json','.html'):
|
||||
raise ValueError('Save reports directly inside the configured private report directory.')
|
||||
flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0)
|
||||
if os.name=='nt':
|
||||
# Child inherits the previously verified restricted directory ACL.
|
||||
fd=os.open(destination,flags|getattr(os,'O_BINARY',0),0o600)
|
||||
else:
|
||||
directory_fd=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
||||
try:
|
||||
info=os.fstat(directory_fd)
|
||||
if info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)&0o077:
|
||||
raise ValueError('Report directory permissions changed.')
|
||||
fd=os.open(destination.name,flags,0o600,dir_fd=directory_fd)
|
||||
finally:os.close(directory_fd)
|
||||
with os.fdopen(fd,'wb') as output:
|
||||
if os.name=='nt':
|
||||
_verify_windows_acl(destination, protected=False)
|
||||
output.write(payload if isinstance(payload,bytes) else payload.encode('utf-8'))
|
||||
return destination
|
||||
@@ -13,6 +13,18 @@ def inventory_schema():
|
||||
|
||||
|
||||
def validate_inventory(data):
|
||||
# Validate depth and timestamps without optional third-party format plugins.
|
||||
from samsara.core.privacy import safe_time
|
||||
def visit(value, depth=0):
|
||||
if depth>20: raise ValueError('Inventory nesting exceeds its limit.')
|
||||
if isinstance(value,dict):
|
||||
for key,item in value.items():
|
||||
if key in ('collected_at','started_at','completed_at','activity_observed_at') and safe_time(item) is None:
|
||||
raise ValueError('Inventory timestamp is invalid.')
|
||||
visit(item,depth+1)
|
||||
elif isinstance(value,list):
|
||||
for item in value: visit(item,depth+1)
|
||||
visit(data)
|
||||
validator = Draft202012Validator(inventory_schema(), format_checker=FormatChecker())
|
||||
if not validator.is_valid(data):
|
||||
raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.')
|
||||
@@ -32,10 +44,6 @@ SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_b
|
||||
|
||||
|
||||
def restrict_inventory_fields(data, local_source=None):
|
||||
"""Keep only metadata fields; never serialize raw CIM/task/service objects."""
|
||||
data["server"] = {key: value for key, value in data["server"].items() if key in SERVER_FIELDS}
|
||||
if local_source:
|
||||
data["server"]["collection_source"] = local_source
|
||||
data["inventory"] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]}
|
||||
for row in rows] for section, rows in data["inventory"].items()}
|
||||
return data
|
||||
"""Compatibility entry point: all callers now receive the privacy projection."""
|
||||
from samsara.core.privacy import minimize_inventory
|
||||
return minimize_inventory(data, trusted_source=local_source)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Actionable assessment report, with full inventory preserved as a separate JSON artifact."""
|
||||
"""Actionable reports from privacy-minimized evidence, without raw sidecars."""
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from samsara.core.privacy import minimize_inventory
|
||||
from samsara.core.storage import write_private
|
||||
from samsara.analyzers.assessment import assess
|
||||
from samsara.analyzers.summary import summarize
|
||||
|
||||
MANUAL_STEPS = ['Confirm application and business owners and obtain administrator retirement approval.',
|
||||
@@ -12,21 +14,30 @@ MANUAL_STEPS = ['Confirm application and business owners and obtain administrato
|
||||
'Verify custom tasks and share consumers; resolve incomplete checks and confirm rollback/retention requirements.']
|
||||
|
||||
|
||||
def private_assessment(data, assessment):
|
||||
# Never trust a marker or cached finding to authorize raw data disclosure.
|
||||
source=data.get('server',{}).get('collection_source')
|
||||
clean=minimize_inventory(data, trusted_source=source)
|
||||
policy=assessment.get('scoring',{}).get('policy') if isinstance(assessment,dict) else None
|
||||
if policy is not None:
|
||||
import copy
|
||||
policy=copy.deepcopy(policy)
|
||||
policy['name']='Deployment scoring policy'
|
||||
return clean, assess(clean, policy=policy)
|
||||
|
||||
|
||||
def export_json(path, data, assessment):
|
||||
Path(path).write_text(json.dumps({'inventory_result':data,'assessment':assessment},indent=2),encoding='utf-8')
|
||||
data, assessment=private_assessment(data, assessment)
|
||||
write_private(path,json.dumps({'inventory_result':data,'assessment':assessment},indent=2))
|
||||
|
||||
|
||||
def inventory_artifact(path, data):
|
||||
payload = (json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8')
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
destination = Path(path).with_name(Path(path).stem + '.inventory-' + digest[:12] + '.json')
|
||||
try:
|
||||
with destination.open('xb') as output:
|
||||
output.write(payload)
|
||||
except FileExistsError:
|
||||
if destination.read_bytes() != payload:
|
||||
raise FileExistsError('Existing inventory artifact differs; choose a new report filename.') from None
|
||||
return destination, digest
|
||||
"""Explicit minimized inventory export; never writes a raw sidecar."""
|
||||
data=minimize_inventory(data)
|
||||
payload=(json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8')
|
||||
destination=Path(path).with_suffix('.inventory.json')
|
||||
write_private(destination,payload)
|
||||
return destination, hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def table(headers, rows):
|
||||
@@ -38,7 +49,7 @@ def table(headers, rows):
|
||||
|
||||
def export_html(path, data, assessment):
|
||||
escape = lambda value: html.escape(str(value))
|
||||
artifact, digest = inventory_artifact(path,data)
|
||||
data, assessment = private_assessment(data, assessment)
|
||||
source = 'SYNTHETIC DEMO — no real collection performed.' if data['synthetic'] else 'IMPORTED INVENTORY — source assertions not independently verified.'
|
||||
for platform in ('Linux','Windows'):
|
||||
if not data['synthetic'] and data['server'].get('collection_source') == f'Local {platform} read-only collection':
|
||||
@@ -78,9 +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>Complete raw inventory</h2><p>Every collected inventory row is preserved separately, including routine services and administrative shares. The analyzer has not changed the collected evidence.</p>'
|
||||
content += '<p><a href="'+escape(quote(artifact.name))+'">Download / open complete inventory JSON</a></p><p>Inventory SHA-256: '+digest+'</p>'
|
||||
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 += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()])
|
||||
content += '<p>JSON assessment export also preserves all raw inventory, classifications, evidence references and policy. Keep the inventory file alongside this HTML when sharing or archiving.</p>'
|
||||
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}'
|
||||
Path(path).write_text('<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>',encoding='utf-8')
|
||||
write_private(path,'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>')
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from samsara.core.privacy import minimize_inventory, load_privacy_policy
|
||||
from samsara.core.storage import report_directory
|
||||
from uuid import uuid4
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
|
||||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
|
||||
@@ -30,7 +33,7 @@ class CollectionJob(QThread):
|
||||
if self.source == 'local':
|
||||
self.stage.emit('Detecting operating system…')
|
||||
detected = detect_local_os()
|
||||
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
|
||||
self.stage.emit('Operating system detected. Collecting minimized inventory…')
|
||||
if detected['platform'] == 'windows':
|
||||
data = collect_local_windows(profile=self.profile, stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||||
else:
|
||||
@@ -45,6 +48,7 @@ class CollectionJob(QThread):
|
||||
if self.isInterruptionRequested():
|
||||
raise CollectionCancelled()
|
||||
self.stage.emit('Preparing assessment report…')
|
||||
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
|
||||
self.result.emit(data)
|
||||
except CollectionCancelled:
|
||||
self.failed.emit('Cancelled. Your previous assessment has been kept.')
|
||||
@@ -298,6 +302,9 @@ class MainWindow(QMainWindow):
|
||||
self.history_list.itemActivated.connect(self.open_history)
|
||||
self.history_list.itemClicked.connect(self.open_history)
|
||||
history.addWidget(self.history_list)
|
||||
self.clear_history_button = QPushButton('Clear session assessments')
|
||||
self.clear_history_button.clicked.connect(self.clear_history)
|
||||
history.addWidget(self.clear_history_button)
|
||||
self.history_empty = self.label('No assessments yet.', muted=True)
|
||||
history.addWidget(self.history_empty)
|
||||
reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.')
|
||||
@@ -315,6 +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('Retirement scoring policy'))
|
||||
self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True)
|
||||
settings.addWidget(self.policy_label)
|
||||
@@ -376,6 +384,7 @@ class MainWindow(QMainWindow):
|
||||
self.job.start()
|
||||
|
||||
def set_busy(self, busy):
|
||||
self.clear_history_button.setEnabled(not busy)
|
||||
self.policy_button.setEnabled(not busy)
|
||||
self.reset_policy_button.setEnabled(not busy)
|
||||
self.analyze_button.setEnabled(not busy)
|
||||
@@ -399,11 +408,20 @@ class MainWindow(QMainWindow):
|
||||
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
|
||||
|
||||
def collection_done(self, data):
|
||||
try:
|
||||
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
|
||||
except ValueError:
|
||||
self.status.setText('Privacy validation failed; assessment withheld.')
|
||||
return
|
||||
assessment = assess(data, policy=self.active_policy)
|
||||
self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'],
|
||||
'source': self.source_label(data), 'collected_at': data['collected_at']})
|
||||
self.records.append((data, assessment))
|
||||
self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}")
|
||||
while len(self.records)>load_privacy_policy()['max_session_assessments']:
|
||||
self.records.pop(0)
|
||||
self.history.pop(0)
|
||||
self.history_list.takeItem(0)
|
||||
self.history_list.addItem(f"{data['collected_at']} • {data['server']['hostname']} • {assessment['level'].title()} risk")
|
||||
self.history_empty.hide()
|
||||
self.display_assessment(data, assessment)
|
||||
self.nav.setCurrentRow(0)
|
||||
@@ -428,7 +446,7 @@ class MainWindow(QMainWindow):
|
||||
self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) +
|
||||
(f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') +
|
||||
'\nSchedules indicate configured work; execution is not confirmed.' if jobs else
|
||||
'No nonroutine enabled schedules identified. Full schedules and missing scheduler coverage are in Tasks and Collection checks.')
|
||||
'No nonroutine enabled schedules identified. Schedule detail is omitted; review Tasks and Collection checks before concluding no work exists.')
|
||||
self.quick_save.setEnabled(True)
|
||||
self.result_tabs.setCurrentIndex(0)
|
||||
# Discard tables from the previous assessment. Populate detailed tabs on demand.
|
||||
@@ -443,6 +461,26 @@ class MainWindow(QMainWindow):
|
||||
for button in self.report_buttons:
|
||||
button.setEnabled(True)
|
||||
|
||||
def clear_history(self):
|
||||
self.history.clear()
|
||||
self.records.clear()
|
||||
self.history_list.clear()
|
||||
self.history_empty.show()
|
||||
self.data = self.assessment = None
|
||||
self.result_title.setText('No assessment loaded')
|
||||
self.banner.clear()
|
||||
for label in (self.retirement_score,self.coverage_note,self.os_type,self.os_version,self.important_software,self.scheduled_jobs):
|
||||
label.clear()
|
||||
for area in self.detail_areas.values():
|
||||
while area.count():
|
||||
item=area.takeAt(0)
|
||||
if item.widget(): item.widget().deleteLater()
|
||||
area.addWidget(self.label('No assessment loaded.',muted=True))
|
||||
self.report_label.setText('Analyze a computer or open an inventory first.')
|
||||
for button in self.report_buttons: button.setEnabled(False)
|
||||
self.quick_save.setEnabled(False)
|
||||
self.status.setText('Session assessment references cleared. This is not secure memory erasure; exported files remain.')
|
||||
|
||||
def open_history(self, item):
|
||||
index = self.history_list.row(item)
|
||||
if 0 <= index < len(self.records):
|
||||
@@ -488,16 +526,21 @@ class MainWindow(QMainWindow):
|
||||
def save_report(self, kind):
|
||||
if self.data is None:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})')
|
||||
try:
|
||||
suggested = str(report_directory() / f'samsara-{uuid4().hex[:12]}.{kind}')
|
||||
except (OSError, ValueError):
|
||||
QMessageBox.warning(self, 'Save blocked', 'Provision the private report directory described in docs/PRIVACY_HARDENING.md.')
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(self, 'Save minimized assessment', suggested, f'{kind.upper()} (*.{kind})')
|
||||
if not path:
|
||||
return
|
||||
if not path.lower().endswith('.' + kind):
|
||||
path += '.' + kind
|
||||
try:
|
||||
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
|
||||
QMessageBox.information(self, 'Report saved', 'HTML report and separate full inventory JSON saved locally.' if kind == 'html' else 'Assessment and full inventory saved as JSON.')
|
||||
except OSError:
|
||||
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
|
||||
QMessageBox.information(self, 'Report saved', 'Minimized assessment saved. No raw inventory sidecar was created.')
|
||||
except (OSError, ValueError):
|
||||
QMessageBox.warning(self, 'Save failed', 'Use a new filename directly in the approved private report directory. Check its permissions.')
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.job is not None and self.job.isRunning():
|
||||
|
||||
Reference in New Issue
Block a user