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