- 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>
188 lines
11 KiB
Python
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'] 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()
|