Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory

This commit is contained in:
2026-10-30 10:25:55 -07:00
parent 365f97942a
commit 967b2bd46a
37 changed files with 985 additions and 541 deletions

1
samsara/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,44 @@
def assess(data):
inventory = data["inventory"]
findings = []
def add(category, classification, evidence, severity, points):
findings.append(dict(category=category, classification=classification, evidence=evidence,
severity=severity, points=points))
for row in inventory.get("roles", []):
add("Installed role", "Potential dependency", row["name"], "medium", 10)
linux_apps = ('nginx', 'apache2', 'httpd', 'mysql', 'mysqld', 'mariadb', 'postgresql',
'redis', 'docker', 'containerd', 'smbd', 'smb', 'nfs-server', 'named', 'haproxy', 'tomcat')
unclassified = []
for row in inventory.get("services", []):
if row.get("state") == "Running":
if data['server'].get('platform') == 'linux' and not any(
row['name'].split('@')[0].split('.')[0] == app or row['name'].startswith(app + '-')
for app in linux_apps
):
unclassified.append(row['name'])
continue
add("Active service", "Potential dependency", f"{row['name']} running as {row.get('account', 'unknown')}", "medium", 10)
if unclassified:
add("Unclassified services", "Unknown or insufficient evidence",
f"{len(unclassified)} running Linux services lack an application rule. Administrator review required; examples: " + ', '.join(unclassified[:8]), "low", 5)
for row in inventory.get("network", []):
if row.get("state") == "Established":
add("Network activity", "Confirmed observed activity", f"{row['local_address']}:{row['local_port']} ↔ {row['remote_address']}:{row['remote_port']}; direction unverified", "high", 20)
elif row.get("state") == "Listen":
add("Listening port", "Potential dependency", f"TCP {row['local_port']} ({row.get('process', 'unknown')})", "medium", 5)
for row in inventory.get("tasks", []):
if row.get("enabled"):
add("Scheduled workload", "Potential dependency", f"{row['name']}; account {row.get('account', 'unknown')}", "medium", 10)
for row in inventory.get("storage", []):
if row.get("type") in ("SMB Share", "NFS Export", "Samba Share"):
add("Shared storage", "Potential dependency", f"{row['name']}: {row['path']}", "high", 15)
for row in inventory.get("storage", []):
if row.get("type") == "Remote Mount":
add("Remote storage consumer", "Potential dependency", f"{row['name']} mounted at {row['path']}; access and use unverified", "medium", 10)
for name, check in data["checks"].items():
if check["status"] != "complete":
add("Missing evidence", "Unknown or insufficient evidence", f"{name}: {check['message']}", "medium", 10)
add("Observation limits", "Unknown or insufficient evidence", "Point-in-time inventory cannot establish infrequent, offline, historical or external dependencies. No active connections does not prove a server is unused.", "low", 5)
score = min(100, sum(f["points"] for f in findings))
level = "critical" if score >= 90 else "high" if score >= 60 else "medium" if score >= 30 else "low" if score else "informational"
return {"score": score, "level": level, "findings": findings}

View File

@@ -0,0 +1,34 @@
"""Compact, evidence-based presentation of the full assessment."""
import re
# Name hints for server workloads, not a comprehensive application classifier.
WORKLOAD = re.compile(r'(?:nginx|apache|httpd|iis|w3svc|sql|postgres|mariadb|mysql|redis|mongo|oracle|tomcat|haproxy|docker|containerd|podman|samba|smbd|nfs|bind|named|rabbitmq|kafka|jenkins|backup|reporting)', re.I)
def summarize(data, assessment):
server, inventory = data['server'], data['inventory']
os_name = str(server.get('operating_system') or 'Not collected')
version = server.get('os_version')
if not version:
match = re.search(r'\b(?:20\d{2}|\d+(?:\.\d+)+)\b', os_name)
version = match[0] if match else 'Not collected'
version = str(version)
important = []
for row in inventory.get('software', []):
if WORKLOAD.search(row['name']) and not row['name'].lower().startswith('lib') and not row['name'].lower().endswith(('-dev', '-devel', '-doc')):
important.append({'name': row['name'], 'version': row.get('version', 'Not collected'),
'evidence': 'Installed software name matches a server-workload hint; use unverified'})
names = {row['name'].lower().split(':')[0] for row in important}
for row in inventory.get('services', []):
if row.get('state') == 'Running' and WORKLOAD.search(row['name']) and row['name'].lower().removesuffix('.service') not in names:
important.append({'name': row['name'], 'version': 'Not collected', 'evidence': 'Running service; application ownership requires review'})
jobs = []
for row in inventory.get('tasks', []):
if row.get('enabled') or row.get('state') == 'Running':
jobs.append({'name': row['name'], 'schedule': row.get('triggers', 'Schedule not collected'),
'status': row.get('state', 'Enabled schedule; execution unverified'),
'account': row.get('account', 'Not collected')})
return {'os_type': os_name, 'version': version,
'risk_score': assessment['score'], 'risk_level': assessment['level'],
'software': important, 'jobs': jobs,
'incomplete': sum(check['status'] != 'complete' for check in data['checks'].values())}

View File

@@ -0,0 +1,140 @@
{
"schema_version": "1.0",
"synthetic": true,
"collected_at": "2026-10-01T12:00:00Z",
"server": {
"hostname": "demo-web-01.example.test",
"operating_system": "Windows Server 2022 Standard",
"os_build": "20348",
"last_boot": "2026-09-25T06:30:00Z",
"ip_addresses": [
"192.0.2.20"
],
"network_adapters": [
"Synthetic Ethernet adapter"
],
"domain": "example.test",
"platform": "windows"
},
"checks": {
"roles": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"services": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"network": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"tasks": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"software": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"storage": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"certificates": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"ad": {
"status": "not_collected",
"message": "Optional Active Directory enrichment is not implemented."
}
},
"inventory": {
"roles": [
{
"name": "Web-Server",
"state": "Installed"
},
{
"name": "File-Services",
"state": "Installed"
}
],
"services": [
{
"name": "W3SVC",
"display_name": "World Wide Web Publishing Service",
"state": "Running",
"startup": "Automatic",
"account": "LocalSystem",
"executable": "C:\\Windows\\System32\\svchost.exe"
}
],
"network": [
{
"state": "Listen",
"local_address": "0.0.0.0",
"local_port": 443,
"remote_address": "",
"remote_port": 0,
"pid": 4,
"process": "System",
"direction": "Unknown"
},
{
"state": "Established",
"local_address": "192.0.2.20",
"local_port": 443,
"remote_address": "192.0.2.45",
"remote_port": 52104,
"pid": 4,
"process": "System",
"direction": "Unknown"
}
],
"tasks": [
{
"name": "Nightly export",
"path": "\\Operations\\",
"enabled": true,
"account": "EXAMPLE\\svc_export",
"executable": "C:\\Operations\\export.exe",
"arguments": "[omitted: potentially sensitive]",
"triggers": "Daily 02:00"
}
],
"software": [
{
"name": "Example Reporting Service",
"publisher": "Example Software",
"version": "4.2",
"installation_date": "2024-06-12"
}
],
"storage": [
{
"type": "Volume",
"name": "C:",
"path": "C:\\",
"capacity_gib": 160,
"free_gib": 72
},
{
"type": "SMB Share",
"name": "Reports",
"path": "D:\\Reports",
"capacity_gib": null,
"free_gib": null
}
],
"certificates": [
{
"subject": "CN=demo-web-01.example.test",
"issuer": "CN=Example Test CA",
"expires": "2027-06-01T00:00:00Z",
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567"
}
]
}
}

View File

@@ -0,0 +1,129 @@
{
"schema_version": "1.0",
"synthetic": true,
"collected_at": "2026-10-01T12:00:00Z",
"server": {
"hostname": "demo-rhel-01.example.test",
"platform": "linux",
"operating_system": "Red Hat Enterprise Linux 9.4 (synthetic)",
"os_build": "5.14.0 (synthetic)",
"last_boot": "2026-09-25T06:30:00Z",
"ip_addresses": [
"192.0.2.30"
],
"network_adapters": [
"ens192 (synthetic)"
],
"domain": "Unknown; directory integration not collected"
},
"checks": {
"roles": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"services": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"network": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"tasks": {
"status": "partial",
"message": "Synthetic system timer only; user timers, crontabs and application schedulers require verification."
},
"software": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"storage": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"certificates": {
"status": "not_collected",
"message": "Linux certificate locations are application-specific; no universal machine certificate store."
},
"ad": {
"status": "not_collected",
"message": "Directory integration and external identity dependencies not collected."
}
},
"inventory": {
"roles": [
{
"name": "Web server (inferred from installed nginx package)",
"state": "Potential workload; verify configuration"
}
],
"services": [
{
"name": "nginx.service",
"display_name": "Nginx web server",
"state": "Running",
"startup": "enabled",
"account": "nginx",
"executable": "/usr/sbin/nginx"
}
],
"network": [
{
"state": "Listen",
"local_address": "0.0.0.0",
"local_port": 443,
"remote_address": "",
"remote_port": 0,
"pid": 1234,
"process": "nginx",
"direction": "Unknown"
},
{
"state": "Established",
"local_address": "192.0.2.30",
"local_port": 443,
"remote_address": "192.0.2.45",
"remote_port": 52104,
"pid": 1234,
"process": "nginx",
"direction": "Unknown"
}
],
"tasks": [
{
"name": "reports.timer",
"path": "systemd system timer",
"enabled": true,
"account": "Unknown; service unit not inspected",
"executable": "Unknown",
"arguments": "[not collected]",
"triggers": "Daily 02:00 (synthetic)"
}
],
"software": [
{
"name": "nginx",
"publisher": "Red Hat (synthetic)",
"version": "1.20.1 (synthetic)",
"installation_date": "Unknown"
}
],
"storage": [
{
"type": "Volume",
"name": "/dev/mapper/rhel-root",
"path": "/",
"capacity_gib": 80,
"free_gib": 32
},
{
"type": "NFS Export",
"name": "reports",
"path": "/srv/reports",
"capacity_gib": null,
"free_gib": null
}
],
"certificates": []
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1 @@

View 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())

View 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
View 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()

View 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']

24
samsara/main.py Normal file
View File

@@ -0,0 +1,24 @@
import sys
from samsara.core.startup import desktop_error
def main():
error = desktop_error()
if error:
print(error, file=sys.stderr)
return 1
from samsara.ui.branding import configure_taskbar_identity, application_icon
configure_taskbar_identity()
from PySide6.QtWidgets import QApplication
from samsara.ui.window import MainWindow
app = QApplication(sys.argv)
app.setApplicationName('Samsara')
app.setDesktopFileName('samsara')
app.setWindowIcon(application_icon())
window = MainWindow()
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,31 @@
import html
import json
from pathlib import Path
from samsara.analyzers.summary import summarize
MANUAL_STEPS = ["Confirm application and business owners and obtain retirement approval.",
"Review historical monitoring, DNS, load balancers, backups and firewall logs.",
"Validate service accounts, scheduled workloads and SMB/NFS/Samba consumers with owners.",
"Resolve failed or missing checks and verify rollback and retention requirements."]
def export_json(path, data, assessment):
Path(path).write_text(json.dumps({"inventory_result": data, "assessment": assessment}, indent=2), encoding="utf-8")
def export_html(path, data, assessment):
escape = lambda value: html.escape(str(value))
content = "<h1>Samsara assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Samsara assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
content = '<h1>Samsara assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
summary = summarize(data, assessment)
content += f"<h2>Summary</h2><p>Retirement risk estimate: {escape(summary['risk_level'])} ({summary['risk_score']}/100). Higher scores mean more retirement concerns.</p>"
content += f"<p>OS type: {escape(summary['os_type'])}<br>Version: {escape(summary['version'])}<br>Incomplete or missing checks: {summary['incomplete']}</p>"
content += '<h3>Important software</h3><p>Workload name hints and application services; use unverified.</p><ul>'
content += ''.join(f"<li>{escape(row['name'])} — {escape(row['version'])}</li>" for row in summary['software']) or '<li>No workload name matches observed; review full inventory and missing checks.</li>'
content += '</ul><h3>Scheduled jobs and scripts</h3><p>Configured schedules do not confirm execution.</p><ul>'
content += ''.join(f"<li>{escape(row['name'])} — {escape(row['schedule'])}</li>" for row in summary['jobs']) or '<li>No enabled schedules observed; scheduler coverage may be incomplete.</li>'
content += '</ul>'
for title, value in [("Server overview", data["server"]), ("Collection timestamp", data["collected_at"]), ("Inventory", data["inventory"]), ("Dependencies and risk findings", assessment["findings"]), ("Collection checks / missing evidence", data["checks"])]:
content += f"<h2>{title}</h2><pre>{escape(json.dumps(value, indent=2))}</pre>"
content += "<h2>Recommended manual verification</h2><ul>" + "".join(f"<li>{escape(s)}</li>" for s in MANUAL_STEPS) + "</ul>"
Path(path).write_text('<!doctype html><html lang="en"><meta charset="utf-8"><title>Samsara</title><style>body{font:16px system-ui;max-width:1100px;margin:40px auto;padding:20px}pre{white-space:pre-wrap;background:#eee;padding:16px}</style>' + content + '</html>', encoding="utf-8")

1
samsara/ui/__init__.py Normal file
View File

@@ -0,0 +1 @@

22
samsara/ui/branding.py Normal file
View File

@@ -0,0 +1,22 @@
"""Application identity and bundled logo shared by the desktop entry points."""
from pathlib import Path
import sys
APP_ID = 'com.doorstodurin.Samsara'
LOGO_PATH = Path(__file__).resolve().parents[1] / 'assets' / 'samsara-logo.jpg'
def configure_taskbar_identity():
"""Set the Windows process identity before creating any Qt windows."""
if sys.platform == 'win32':
import ctypes
try:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
except (AttributeError, OSError):
# A shell integration failure must not prevent assessments.
pass
def application_icon():
from PySide6.QtGui import QIcon
return QIcon(str(LOGO_PATH))

465
samsara/ui/window.py Normal file
View File

@@ -0,0 +1,465 @@
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
QPushButton, QListWidget, QStackedWidget, QLineEdit, QTableView,
QFileDialog, QMessageBox, QComboBox, QHeaderView, QFrame, QProgressBar, QScrollArea, QSizePolicy, QLayout, QTabWidget)
from samsara.ui.branding import application_icon
from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
from samsara.collectors.importer import load_inventory
from samsara.collectors.linux import collect_local_linux, CollectionCancelled
from samsara.core.detection import detect_local_os, check_profile, PROFILES
from samsara.analyzers.assessment import assess
from samsara.analyzers.summary import summarize
from samsara.reporting.export import export_html, export_json
class CollectionJob(QThread):
stage = Signal(str)
result = Signal(dict)
failed = Signal(str)
def __init__(self, source, profile='auto', inventory_path='', parent=None):
super().__init__(parent)
self.source, self.profile, self.inventory_path = source, profile, inventory_path
def run(self):
try:
if self.source == 'local':
self.stage.emit('Detecting operating system…')
detected = detect_local_os()
if detected['platform'] != 'linux':
raise ValueError('Windows live collection is not available yet. Open a saved inventory or a sample assessment.')
check_profile(detected, self.profile)
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
elif self.source == 'file':
self.stage.emit('Reading saved inventory…')
data = load_inventory(self.inventory_path)
else:
self.stage.emit('Loading sample assessment…')
data = collect_demo(DEMO_HOST if self.source == 'windows_demo' else LINUX_DEMO_HOST)
if self.isInterruptionRequested():
raise CollectionCancelled()
self.stage.emit('Preparing assessment report…')
self.result.emit(data)
except CollectionCancelled:
self.failed.emit('Cancelled. Your previous assessment has been kept.')
except ValueError as exc:
self.failed.emit(str(exc))
except Exception:
self.failed.emit('Unable to complete assessment. Check file access and collection availability; previous results have been kept.')
class SearchTable(QWidget):
def __init__(self, rows):
super().__init__()
layout = QVBoxLayout(self)
self.setMinimumHeight(230)
search = QLineEdit(placeholderText='Search all columns…')
layout.addWidget(search)
view = QTableView()
model = QStandardItemModel(self)
columns = list(dict.fromkeys(key for row in rows for key in row))
model.setHorizontalHeaderLabels([key.replace('_', ' ').title() for key in columns])
for row in rows:
items = []
for key in columns:
value = row.get(key)
item = QStandardItem()
item.setData('' if value is None else value if isinstance(value, (str, int, float)) else str(value), Qt.ItemDataRole.DisplayRole)
item.setEditable(False)
items.append(item)
model.appendRow(items)
proxy = QSortFilterProxyModel(self)
proxy.setSourceModel(model)
proxy.setFilterKeyColumn(-1)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
search.textChanged.connect(proxy.setFilterFixedString)
view.setModel(proxy)
view.setSortingEnabled(True)
view.setAlternatingRowColors(True)
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
view.horizontalHeader().setStretchLastSection(True)
view.resizeColumnsToContents()
layout.addWidget(view)
if not rows:
layout.addWidget(QLabel('No records in this inventory. This is not proof of absence.'))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle('Samsara')
self.setWindowIcon(application_icon())
self.resize(1180, 820)
self.setMinimumSize(800, 620)
self.job = None
self.data = None
self.assessment = None
self.history = []
self.records = []
self.setStyleSheet('''
QWidget { background:#131b27; color:#e5ecf5; font-size:14px; }
QLabel#title { font-size:27px; font-weight:600; }
QLabel#muted { color:#a4b3c7; }
QLabel#summary { font-size:17px; font-weight:600; }
QFrame#card { background:#1b2839; border:1px solid #304158; border-radius:8px; }
QFrame#card QLabel { background:transparent; }
QPushButton { background:#283b52; border:1px solid #40546c; border-radius:5px; padding:9px 16px; }
QPushButton#primary { background:#287ac0; border:0; font-weight:600; }
QPushButton:hover { background:#346087; } QPushButton:disabled { color:#7e8c9e; background:#253143; }
QLineEdit,QComboBox { background:#1e2c3e; border:1px solid #465b75; border-radius:5px; padding:9px; min-height:20px; }
QListWidget { background:#172231; border:0; outline:0; }
QListWidget::item { padding:14px 12px; margin:3px; border-radius:5px; }
QListWidget::item:selected { background:#254b70; }
QTabBar::tab { background:#1b2839; padding:10px 12px; border-bottom:2px solid transparent; }
QTabBar::tab:selected { border-bottom:2px solid #469fe4; background:#24384f; }
QHeaderView::section { background:#273a51; padding:9px; border:0; }
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; border:1px solid #304057; }
QProgressBar { max-height:4px; border:0; } QProgressBar::chunk { background:#398ed1; }
''')
central = QWidget()
self.setCentralWidget(central)
outer = QHBoxLayout(central)
outer.setContentsMargins(0, 0, 0, 0)
sidebar = QVBoxLayout()
sidebar.setContentsMargins(12, 22, 12, 16)
brand_row = QHBoxLayout()
brand_row.setSpacing(8)
self.logo = QLabel()
self.logo.setFixedSize(40, 40)
self.logo.setPixmap(application_icon().pixmap(40, 40))
self.logo.setToolTip('Samsara')
self.logo.setAccessibleName('Samsara logo')
brand_row.addWidget(self.logo)
brand = QLabel('Samsara')
brand.setObjectName('summary')
brand_row.addWidget(brand)
sidebar.addLayout(brand_row)
sidebar.addWidget(self.label('Retirement assessments', muted=True))
self.nav = QListWidget()
self.nav.addItems(['Summary', 'Analyze', 'History', 'Reports', 'Settings'])
self.nav.setFixedWidth(170)
sidebar.addWidget(self.nav)
outer.addLayout(sidebar)
self.pages = QStackedWidget()
outer.addWidget(self.pages, 1)
self.build_pages()
self.nav.currentRowChanged.connect(lambda index: self.pages.setCurrentIndex({0: 1, 1: 0, 2: 2, 3: 3, 4: 4}[index]))
self.nav.setCurrentRow(1)
self.source_changed()
def label(self, text, muted=False):
label = QLabel(text)
label.setWordWrap(True)
label.setTextFormat(Qt.TextFormat.PlainText)
if muted:
label.setObjectName('muted')
return label
def page(self, title, subtitle):
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(24, 22, 24, 20)
layout.setSpacing(14)
heading = self.label(title)
heading.setObjectName('title')
layout.addWidget(heading)
layout.addWidget(self.label(subtitle, muted=True))
layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setWidget(widget)
self.pages.addWidget(scroll)
return layout
def build_pages(self):
analyze = self.page('Analyze', 'Choose a source, then analyze its retirement dependencies and risks.')
card = QFrame()
card.setObjectName('card')
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
form = QVBoxLayout(card)
form.setContentsMargins(18, 16, 18, 16)
form.setSpacing(10)
row = QHBoxLayout()
source_col = QVBoxLayout()
source_col.addWidget(self.label('Assessment source'))
self.source = QComboBox()
for label, value in [('This computer', 'local'), ('Saved inventory file', 'file'),
('Windows Server sample', 'windows_demo'), ('RHEL sample', 'linux_demo')]:
self.source.addItem(label, value)
self.source.currentIndexChanged.connect(self.source_changed)
source_col.addWidget(self.source)
row.addLayout(source_col, 1)
os_col = QVBoxLayout()
os_col.addWidget(self.label('Operating system'))
self.target = QComboBox()
for label, value in PROFILES:
self.target.addItem(label, value)
os_col.addWidget(self.target)
row.addLayout(os_col, 1)
form.addLayout(row)
self.file_row = QWidget()
file_layout = QHBoxLayout(self.file_row)
file_layout.setContentsMargins(0, 0, 0, 0)
self.inventory_path = QLineEdit(placeholderText='Choose an inventory JSON file…')
file_layout.addWidget(self.inventory_path, 1)
self.browse = QPushButton('Browse…')
self.browse.clicked.connect(self.browse_inventory)
file_layout.addWidget(self.browse)
form.addWidget(self.file_row)
self.source_hint = self.label('', muted=True)
form.addWidget(self.source_hint)
actions = QHBoxLayout()
self.analyze_button = QPushButton('Analyze')
self.analyze_button.setObjectName('primary')
self.analyze_button.clicked.connect(self.start_collection)
actions.addWidget(self.analyze_button)
self.cancel_button = QPushButton('Cancel')
self.cancel_button.clicked.connect(self.cancel_collection)
self.cancel_button.setVisible(False)
actions.addWidget(self.cancel_button)
actions.addStretch()
form.addLayout(actions)
analyze.addWidget(card)
self.status = self.label('Ready to analyze.', muted=True)
analyze.addWidget(self.status)
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.hide()
analyze.addWidget(self.progress)
analyze.addStretch()
summary_page = self.page('Assessment', 'Start with the summary, then explore the evidence in each tab.')
self.result_title = self.label('No assessment yet')
self.result_title.setObjectName('summary')
self.banner = self.label('Choose Analyze to collect or open inventory.', muted=True)
summary_page.addWidget(self.result_title)
summary_page.addWidget(self.banner)
self.result_tabs = QTabWidget()
self.result_tabs.setUsesScrollButtons(True)
self.result_tabs.setDocumentMode(True)
summary_page.addWidget(self.result_tabs, 1)
self.summary_widget = QWidget()
summary_layout = QVBoxLayout(self.summary_widget)
summary_layout.setContentsMargins(12, 16, 12, 16)
summary_layout.setSpacing(16)
score_card = QFrame()
score_card.setObjectName('card')
score_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
score_layout = QVBoxLayout(score_card)
self.retirement_score = self.label('Retirement risk estimate: —')
self.retirement_score.setObjectName('summary')
score_layout.addWidget(self.retirement_score)
score_layout.addWidget(self.label('Higher scores mean more retirement concerns. Heuristic only; administrator review is required.', muted=True))
self.coverage_note = self.label('', muted=True)
score_layout.addWidget(self.coverage_note)
summary_layout.addWidget(score_card)
self.os_type = self.label('OS type: —')
self.os_version = self.label('Version: —')
summary_layout.addWidget(self.os_type)
summary_layout.addWidget(self.os_version)
software_heading = self.label('Important software')
software_heading.setObjectName('summary')
summary_layout.addWidget(software_heading)
self.important_software = self.label('Run an assessment to see detected server software.')
summary_layout.addWidget(self.important_software)
summary_layout.addWidget(self.label('Workload name matches and application services; see Software for the full inventory.', muted=True))
jobs_heading = self.label('Scheduled jobs & scripts')
jobs_heading.setObjectName('summary')
summary_layout.addWidget(jobs_heading)
self.scheduled_jobs = self.label('Run an assessment to see enabled schedules.')
summary_layout.addWidget(self.scheduled_jobs)
summary_layout.addStretch()
self.quick_save = QPushButton('Save HTML report')
self.quick_save.setEnabled(False)
self.quick_save.clicked.connect(lambda: self.save_report('html'))
summary_layout.addWidget(self.quick_save)
self.result_tabs.addTab(self.summary_widget, 'Summary')
self.detail_areas = {}
for title, key in [('Networking', 'network'), ('Software', 'software'), ('Tasks', 'tasks'),
('Dependencies', 'dependencies'), ('Services', 'services'), ('Storage & shares', 'storage'),
('Roles', 'roles'), ('Certificates', 'certificates'), ('Risk findings', 'risk'),
('Collection checks', 'checks'), ('System', 'overview')]:
tab = QWidget()
area = QVBoxLayout(tab)
area.addWidget(self.label('No assessment loaded.', muted=True))
self.detail_areas[key] = area
self.result_tabs.addTab(tab, title)
self.result_tabs.currentChanged.connect(self.show_details)
history = self.page('History', 'Assessments from this session. Select a record to open it.')
self.history_list = QListWidget()
self.history_list.itemActivated.connect(self.open_history)
self.history_list.itemClicked.connect(self.open_history)
history.addWidget(self.history_list)
self.history_empty = self.label('No assessments yet.', muted=True)
history.addWidget(self.history_empty)
reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.')
self.report_label = self.label('Analyze a computer or open an inventory first.', muted=True)
reports.addWidget(self.report_label)
self.report_buttons = []
for label, kind in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
button = QPushButton(label)
button.clicked.connect(lambda checked=False, fmt=kind: self.save_report(fmt))
button.setEnabled(False)
self.report_buttons.append(button)
reports.addWidget(button)
reports.addStretch()
settings = self.page('Settings', 'Collection availability and assessment behavior.')
settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions'))
settings.addWidget(self.label('Local Linux collection is available. Live Windows and remote collection are not available yet. Saved inventories and samples work on either desktop.', muted=True))
settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True))
settings.addStretch()
def source_changed(self):
source = self.source.currentData()
self.file_row.setVisible(source == 'file')
self.target.setEnabled(source == 'local' and self.job is None)
if source != 'local':
self.target.setCurrentIndex(0)
hints = {'local': 'Auto-detect identifies this computer’s OS and runs its available collector. No remote connection is made.',
'file': 'The operating system is read from the saved inventory. No connection is made.',
'windows_demo': 'Synthetic Windows Server 2022 example. No server is contacted.',
'linux_demo': 'Synthetic RHEL 9 example. No server is contacted.'}
self.source_hint.setText(hints[source])
def browse_inventory(self):
path, _ = QFileDialog.getOpenFileName(self, 'Open inventory', '', 'Inventory JSON (*.json)')
if path:
self.inventory_path.setText(path)
def start_collection(self):
if self.job is not None:
return
source = self.source.currentData()
if source == 'file' and not self.inventory_path.text().strip():
self.status.setText('Choose an inventory file before analyzing.')
return
self.set_busy(True)
self.status.setText('Starting assessment…')
self.job = CollectionJob(source, self.target.currentData(), self.inventory_path.text().strip(), self)
self.job.stage.connect(self.status.setText)
self.job.result.connect(self.collection_done)
self.job.failed.connect(self.status.setText)
self.job.finished.connect(self.job_finished)
self.job.start()
def set_busy(self, busy):
self.analyze_button.setEnabled(not busy)
self.source.setEnabled(not busy)
self.target.setEnabled(not busy and self.source.currentData() == 'local')
self.browse.setEnabled(not busy)
self.inventory_path.setEnabled(not busy)
self.cancel_button.setVisible(busy)
self.cancel_button.setEnabled(busy)
self.progress.setVisible(busy)
def cancel_collection(self):
if self.job is not None:
self.job.requestInterruption()
self.cancel_button.setEnabled(False)
self.status.setText('Cancelling…')
def source_label(self, data):
if data['synthetic']:
return 'Synthetic sample'
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
def collection_done(self, data):
assessment = assess(data)
self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'],
'source': self.source_label(data), 'collected_at': data['collected_at']})
self.records.append((data, assessment))
self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}")
self.history_empty.hide()
self.display_assessment(data, assessment)
self.nav.setCurrentRow(0)
incomplete = sum(check['status'] != 'complete' for check in data['checks'].values())
self.status.setText(f'Assessment report ready. {incomplete} checks need review. Open the Summary and detail tabs to review the report.')
def display_assessment(self, data, assessment):
self.data, self.assessment = data, assessment
summary = summarize(data, assessment)
self.result_title.setText(data['server']['hostname'])
self.banner.setText(self.source_label(data) + ' • ' + data['collected_at'])
self.retirement_score.setText(f"Retirement risk estimate: {summary['risk_score']}/100 · {summary['risk_level'].title()}")
self.coverage_note.setText(f"{summary['incomplete']} checks incomplete or missing. This estimate does not establish retirement readiness.")
self.os_type.setText('OS type: ' + summary['os_type'])
self.os_version.setText('Version: ' + summary['version'])
software = summary['software']
self.important_software.setText('\n'.join(f"• {row['name']} — {row['version']}" for row in software[:6]) +
(f"\n+ {len(software) - 6} more matches; see Software." if len(software) > 6 else '') if software else
'No server-software name hints identified in collected evidence. Review the full Software and Services tabs.')
self.important_software.setToolTip('Name-based workload hints or running application services; not a complete application inventory and not proof of use.')
jobs = summary['jobs']
self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) +
(f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') +
'\nSchedules indicate configured work; execution is not confirmed.' if jobs else
'No enabled schedules observed in collected evidence. Check Tasks and Collection checks for missing scheduler coverage.')
self.quick_save.setEnabled(True)
self.result_tabs.setCurrentIndex(0)
# Discard tables from the previous assessment. Populate detailed tabs on demand.
for area in self.detail_areas.values():
while area.count():
widget = area.takeAt(0).widget()
if widget:
widget.hide()
widget.deleteLater()
self.show_details()
self.report_label.setText('Current assessment: ' + data['server']['hostname'] + '\n' + self.source_label(data))
for button in self.report_buttons:
button.setEnabled(True)
def open_history(self, item):
index = self.history_list.row(item)
if 0 <= index < len(self.records):
self.display_assessment(*self.records[index])
self.status.setText('Opened assessment from session history.')
self.nav.setCurrentRow(0)
def show_details(self):
if self.data is None or self.result_tabs.currentIndex() == 0:
return
key = list(self.detail_areas)[self.result_tabs.currentIndex() - 1]
area = self.detail_areas[key]
if area.count():
return
if key == 'overview':
rows = [{'property': name.replace('_', ' ').title(), 'value': str(value)} for name, value in self.data['server'].items()]
elif key == 'checks':
rows = [{'check': name, **value} for name, value in self.data['checks'].items()]
elif key in ('risk', 'dependencies'):
rows = self.assessment['findings']
else:
rows = self.data['inventory'][key]
if key in self.data['checks']:
check = self.data['checks'][key]
area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True))
area.addWidget(SearchTable(rows))
def job_finished(self):
self.job.deleteLater()
self.job = None
self.set_busy(False)
def save_report(self, kind):
if self.data is None:
return
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})')
if not path:
return
if not path.lower().endswith('.' + kind):
path += '.' + kind
try:
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
except OSError:
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
def closeEvent(self, event):
if self.job is not None and self.job.isRunning():
self.status.setText('Cancel the running assessment before closing.')
event.ignore()
else:
event.accept()