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:
2026-11-04 18:50:24 -08:00
parent 7fed3b9ab9
commit 610a3bb447
51 changed files with 2719 additions and 604 deletions

View File

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

View File

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

View 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"
}
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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