Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory
This commit is contained in:
1
samsara/collectors/__init__.py
Normal file
1
samsara/collectors/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
samsara/collectors/demo.py
Normal file
13
samsara/collectors/demo.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from samsara.core.validation import validate_hostname
|
||||
|
||||
DEMO_HOST = "demo-web-01.example.test"
|
||||
LINUX_DEMO_HOST = "demo-rhel-01.example.test"
|
||||
DEMO_FILES = {DEMO_HOST: "demo_inventory.json", LINUX_DEMO_HOST: "demo_linux_inventory.json"}
|
||||
|
||||
def collect_demo(hostname):
|
||||
hostname = validate_hostname(hostname)
|
||||
if hostname not in DEMO_FILES:
|
||||
raise ValueError("Demo mode supports " + " or ".join(DEMO_FILES) + ". No real host was contacted.")
|
||||
return json.loads((Path(__file__).resolve().parents[1] / "assets" / DEMO_FILES[hostname]).read_text())
|
||||
47
samsara/collectors/importer.py
Normal file
47
samsara/collectors/importer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Offline intake shared by Windows and Linux desktops. No code is executed."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
MAX_IMPORT_BYTES = 10 * 1024 * 1024
|
||||
SCHEMA = Path(__file__).resolve().parents[2] / 'schemas' / 'inventory-v1.schema.json'
|
||||
# Inventory fields allowed through the report boundary. Command arguments, environment
|
||||
# variables and private key material are deliberately excluded.
|
||||
ALLOWED = {
|
||||
'roles': {'name', 'state'},
|
||||
'services': {'name', 'display_name', 'state', 'startup', 'account'},
|
||||
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
|
||||
'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit'},
|
||||
'software': {'name', 'publisher', 'version', 'installation_date'},
|
||||
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib'},
|
||||
'certificates': {'subject', 'issuer', 'expires', 'thumbprint'},
|
||||
}
|
||||
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
|
||||
|
||||
|
||||
def load_inventory(path):
|
||||
with Path(path).open('rb') as source:
|
||||
raw = source.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
raise ValueError('Inventory exceeds the 10 MiB import limit.')
|
||||
try:
|
||||
data = json.loads(raw.decode('utf-8-sig'))
|
||||
except (ValueError, UnicodeError):
|
||||
raise ValueError('The selected file is not valid UTF-8 inventory JSON.') from None
|
||||
# A report wrapper is accepted, but its precomputed assessment is never trusted.
|
||||
if isinstance(data, dict) and 'inventory_result' in data:
|
||||
data = data['inventory_result']
|
||||
schema = json.loads(SCHEMA.read_text(encoding='utf-8'))
|
||||
errors = list(Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors(data))
|
||||
if errors:
|
||||
raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.')
|
||||
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
|
||||
# contain command lines or credentials from external collection tools.
|
||||
data['checks'] = {name: {'status': check['status'], 'message':
|
||||
'Imported check status. Consult collector diagnostics separately; details excluded to limit secret exposure.'}
|
||||
for name, check in data['checks'].items()}
|
||||
data['server'] = {key: value for key, value in data['server'].items() if key in SERVER_FIELDS}
|
||||
data['inventory'] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]}
|
||||
for row in rows] for section, rows in data['inventory'].items()}
|
||||
return data
|
||||
215
samsara/collectors/linux.py
Normal file
215
samsara/collectors/linux.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 a 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)')
|
||||
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')
|
||||
enrich_timers(data['inventory']['tasks'], command)
|
||||
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.')
|
||||
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()
|
||||
95
samsara/collectors/linux_schedules.py
Normal file
95
samsara/collectors/linux_schedules.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Read scheduler metadata only; never run jobs or report their arguments."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
|
||||
SAFE_PATH = re.compile(r'^[A-Za-z0-9_./+-]+$')
|
||||
|
||||
|
||||
def cron_rows(text, source, system_file=False):
|
||||
rows = []
|
||||
for number, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith('#') or re.match(r'^[A-Za-z_][A-Za-z0-9_]*\s*=', line):
|
||||
continue
|
||||
parts = line.split(None, 1 if line.startswith('@') else 5)
|
||||
count = 1 if line.startswith('@') else 5
|
||||
if count == 1 and parts[0] not in {'@reboot', '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'}:
|
||||
continue
|
||||
if len(parts) <= count:
|
||||
continue
|
||||
schedule, command = ' '.join(parts[:count]), parts[count]
|
||||
account = 'Current user'
|
||||
if system_file:
|
||||
user_command = command.split(None, 1)
|
||||
if len(user_command) != 2:
|
||||
continue
|
||||
account, command = user_command
|
||||
# A safe executable/script filename is enough to identify the workload.
|
||||
# Arguments, embedded scripts and environment assignments never leave here.
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
except ValueError:
|
||||
tokens = []
|
||||
executable = tokens[0] if tokens and SAFE_PATH.fullmatch(tokens[0]) else '[command omitted]'
|
||||
script = executable
|
||||
if Path(executable).name.startswith(('python', 'perl', 'ruby', 'bash', 'sh')) and len(tokens) > 1:
|
||||
if SAFE_PATH.fullmatch(tokens[1]) and not tokens[1].startswith('-'):
|
||||
script = tokens[1]
|
||||
rows.append({'name': f'{Path(script).name} ({source}:{number})', 'path': source,
|
||||
'enabled': True, 'account': account, 'executable': executable,
|
||||
'triggers': schedule + ' (cron; timezone not resolved)',
|
||||
'state': 'Configured schedule; execution unverified'})
|
||||
return rows
|
||||
|
||||
|
||||
def read_system_cron(cancelled=lambda: False):
|
||||
paths = [Path('/etc/crontab')]
|
||||
directory = Path('/etc/cron.d')
|
||||
if directory.exists():
|
||||
paths.extend(sorted(directory.iterdir()))
|
||||
rows, incomplete = [], False
|
||||
incomplete = len(paths) > 256
|
||||
for path in paths[:256]:
|
||||
if cancelled():
|
||||
from samsara.collectors.linux import CollectionCancelled
|
||||
raise CollectionCancelled()
|
||||
if not path.exists():
|
||||
continue
|
||||
# Avoid following symlinks or special files. Bound every file read.
|
||||
if path.is_symlink() or not path.is_file():
|
||||
incomplete = True
|
||||
continue
|
||||
if path.name.startswith('.') or path.name.endswith(('~', '.bak', '.dpkg-old', '.rpmnew', '.rpmsave')):
|
||||
continue
|
||||
try:
|
||||
with path.open('r', encoding='utf-8', errors='replace') as source:
|
||||
text = source.read(65537)
|
||||
if len(text) > 65536:
|
||||
incomplete = True
|
||||
continue
|
||||
rows.extend(cron_rows(text, str(path), system_file=True))
|
||||
except OSError:
|
||||
incomplete = True
|
||||
return rows, incomplete
|
||||
|
||||
|
||||
def enrich_timers(rows, command):
|
||||
names = [row['name'] for row in rows if re.fullmatch(r'[A-Za-z0-9_.@:-]+\.timer', row['name'])]
|
||||
if not names:
|
||||
return
|
||||
output = command(['systemctl', 'show', '--property=Id', '--property=TimersCalendar',
|
||||
'--property=NextElapseUSecRealtime', '--property=Triggers', '--no-pager', '--', *names[:200]])
|
||||
details = {}
|
||||
for block in output.strip().split('\n\n'):
|
||||
properties = dict(line.split('=', 1) for line in block.splitlines() if '=' in line)
|
||||
if 'Id' in properties:
|
||||
details[properties['Id']] = properties
|
||||
for row in rows:
|
||||
properties = details.get(row['name'], {})
|
||||
schedule = properties.get('TimersCalendar', '')
|
||||
next_run = properties.get('NextElapseUSecRealtime', '')
|
||||
if schedule or next_run:
|
||||
row['triggers'] = '; '.join(filter(None, [schedule, 'Next: ' + next_run if next_run else '']))
|
||||
if properties.get('Triggers'):
|
||||
row['associated_unit'] = properties['Triggers']
|
||||
Reference in New Issue
Block a user