205 lines
11 KiB
Python
205 lines
11 KiB
Python
"""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
|
|
|
|
|
|
class CollectionCancelled(Exception):
|
|
pass
|
|
|
|
|
|
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()
|
|
|
|
|
|
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
|
|
|
|
|
|
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 = {'LISTEN': 'Listen', 'ESTAB': 'Established'}.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 Linux Mint or a RHEL-based Linux execution host.')
|
|
started = time.monotonic()
|
|
data = {'schema_version': '1.0', 'synthetic': False,
|
|
'collected_at': datetime.now(timezone.utc).isoformat(),
|
|
'server': {'hostname': socket.gethostname(), '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)')
|
|
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.')
|
|
check('tasks', lambda: units('timer'), 'Loaded system timers observed; unloaded timers, schedules, cron/anacron, user timers and application schedulers require manual review.')
|
|
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 data
|
|
|
|
|
|
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.
|
|
with open(args.output, 'x', encoding='utf-8') as destination:
|
|
json.dump(result, destination, 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()
|