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

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