Files
Samsara/samsara/collectors/linux.py
oceans2alaska b4c0cf12ca Retain infrastructure names and share/mount roots behind policy switches
Add a `retain` block to privacy_policy.json (all on by default) so assessments
show software, services, processes, tasks, shares/mounts and private peer IPs.
Values pass an identifier-pattern filter (digit runs, SSN/phone, e-mail, charset,
length, path depth). Accounts, command lines, child paths, filenames and public
peer IPs stay omitted. Linux collector and Windows PowerShell collectors updated;
tests cover retention, withholding and all-switches-off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:03:49 -08:00

188 lines
11 KiB
Python

"""Read-only local Linux inventory. No networking, sudo, shells or configuration changes."""
import json
from pathlib import Path
import platform
import re
import shlex
import socket
import time
from datetime import datetime, timezone
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):
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'):
enabled = dict(line.split()[:2] for line in enabled_text.splitlines() if len(line.split()) >= 2)
rows = []
for line in text.splitlines():
fields = line.split(None, 4)
if len(fields) < 4 or not fields[0].endswith('.' + kind):
continue
name, loaded, active, sub = fields[:4]
if kind == 'service':
rows.append({'name': name, 'display_name': fields[4] if len(fields) > 4 else name,
'state': 'Running' if active == 'active' and sub == 'running' else active + '/' + sub,
'startup': enabled.get(name, 'Unknown'), 'account': 'Not collected'})
else:
rows.append({'name': name, 'path': 'systemd system timer',
'enabled': active == 'active' or enabled.get(name) in ('enabled', 'enabled-runtime'),
'account': 'Not collected; inspect associated service',
'triggers': 'Not collected; inspect timer calendar',
'state': active + '/' + sub})
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
def parse_connections(text):
rows = []
for line in text.splitlines():
fields = line.split(None, 5)
if len(fields) < 5:
continue
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 '')
rows.append({'state': state, 'local_address': local, 'local_port': lp,
'remote_address': remote, 'remote_port': rp,
'pid': int(process[2]) if process else None,
'process': process[1] if process else 'Unavailable to current user',
'direction': 'Unknown'})
return rows
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() if privacy_policy['approved_hostnames'] or privacy_policy['retain']['hostname'] 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': [],
'domain': 'Not collected; directory integration requires review'},
'checks': {}, 'inventory': {key: [] for key in
('roles', 'services', 'network', 'tasks', 'software', 'storage', 'certificates')}}
def check_cancel():
if cancelled():
raise CollectionCancelled('Local collection cancelled.')
if time.monotonic() - started > 60:
raise RuntimeError('Local collection exceeded its overall time limit.')
def command(args):
check_cancel()
return runner(args, cancelled=cancelled, timeout=min(8, max(1, 60 - (time.monotonic() - started))))
def check(name, operation, message, status='partial'):
check_cancel()
stage('Collecting local Linux ' + name + '…')
try:
operation()
data['checks'][name] = {'status': status, 'message': message}
except CollectionCancelled:
raise
except (OSError, ValueError, RuntimeError, KeyError, IndexError, StopIteration):
data['checks'][name] = {'status': 'failed', 'message': 'Check could not complete: missing command, restricted access, unsupported output or timeout. No synthetic replacement used.'}
def system():
release = {}
for line in Path('/etc/os-release').read_text().splitlines():
if '=' in line:
key, value = line.split('=', 1)
release[key] = ' '.join(shlex.split(value))
data['server']['operating_system'] = release.get('PRETTY_NAME', 'Linux (distribution unknown)')
data['server']['os_version'] = release.get('VERSION_ID', 'Not collected')
boot = next(line.split()[1] for line in Path('/proc/stat').read_text().splitlines() if line.startswith('btime '))
data['server']['last_boot'] = datetime.fromtimestamp(int(boot), timezone.utc).isoformat()
check('system', system, 'Local OS, kernel and boot time observed; domain membership not inspected.')
def interfaces():
adapters = json.loads(command(['ip', '-j', 'address', 'show']))
data['server']['network_adapters'] = [row['ifname'] for row in adapters]
data['server']['ip_addresses'] = [address['local'] for row in adapters for address in row.get('addr_info', [])]
check('interfaces', interfaces, 'Addresses observed from local interfaces; no name resolution or remote probes.', 'complete')
def units(kind):
listing = command(['systemctl', 'list-units', '--all', '--type=' + kind, '--no-legend', '--plain', '--no-pager'])
enabled = command(['systemctl', 'list-unit-files', '--type=' + kind, '--no-legend', '--no-pager'])
data['inventory']['services' if kind == 'service' else 'tasks'] = parse_units(listing, enabled, kind)
check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.')
def timers():
units('timer')
# 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.')
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.')
def software():
rpm = any((Path(folder) / 'rpm').is_file() for folder in ('/usr/bin', '/bin'))
args = ['rpm', '-qa', '--qf', '%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\n'] if rpm else ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\t${Version}\n']
for line in command(args).splitlines():
parts = line.split('\t')
if len(parts) != 3:
raise ValueError('Unsupported package output')
if not rpm and parts[0] != 'installed':
continue
data['inventory']['software'].append({'name': parts[0] if rpm else parts[1],
'version': parts[1] if rpm else parts[2], 'publisher': parts[2] if rpm else 'Not collected'})
check('software', software, 'RPM or dpkg database queried read-only. Containers, manually installed applications and language package environments not covered.')
def storage():
# Do not stat mounted filesystems: stale NFS or FUSE could block the worker.
for line in Path('/proc/self/mountinfo').read_text().splitlines():
before, after = line.split(' - ', 1)
fields, fs = before.split(), after.split()
unescape = lambda value: re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), value)
data['inventory']['storage'].append({'type': 'Remote Mount' if fs[0] in ('nfs', 'nfs4', 'cifs', 'smb3') else 'Mounted Filesystem',
'name': unescape(fs[1]), 'path': unescape(fields[4]), 'filesystem': fs[0],
'capacity_gib': None, 'free_gib': None})
check('storage', storage, 'Mounts visible in this process namespace observed; capacity/free space and NFS/Samba export configuration not collected. Remote filesystems were not accessed.')
for key, message in {
'roles': 'Linux workloads require service/package/configuration correlation; no Windows role equivalent inferred.',
'certificates': 'Application-specific certificate locations not inspected; no private keys read.',
'shares': 'NFS and Samba export configuration and consumers not inspected.',
'directory': 'Directory integration, external service accounts and identity dependencies not inspected.',
}.items():
data['checks'][key] = {'status': 'not_collected', 'message': message}
check_cancel()
return minimize_inventory(data, privacy_policy, trusted_source='Local Linux read-only collection')
def main():
"""Headless local collection for testing without Qt or Windows modules."""
import argparse
parser = argparse.ArgumentParser(description='Collect read-only inventory of this Linux host. No remote connection is made.')
parser.add_argument('--output', required=True, help='Local JSON destination (contains host inventory)')
args = parser.parse_args()
try:
result = collect_local_linux()
# Exclusive creation prevents accidentally replacing an existing report.
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')
if __name__ == '__main__':
main()