Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory
This commit is contained in:
1
samsara/__init__.py
Normal file
1
samsara/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
samsara/analyzers/__init__.py
Normal file
1
samsara/analyzers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
44
samsara/analyzers/assessment.py
Normal file
44
samsara/analyzers/assessment.py
Normal 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}
|
||||
34
samsara/analyzers/summary.py
Normal file
34
samsara/analyzers/summary.py
Normal 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())}
|
||||
140
samsara/assets/demo_inventory.json
Normal file
140
samsara/assets/demo_inventory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
129
samsara/assets/demo_linux_inventory.json
Normal file
129
samsara/assets/demo_linux_inventory.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
BIN
samsara/assets/samsara-logo.jpg
Normal file
BIN
samsara/assets/samsara-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
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']
|
||||
24
samsara/main.py
Normal file
24
samsara/main.py
Normal 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())
|
||||
1
samsara/models/__init__.py
Normal file
1
samsara/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
samsara/reporting/__init__.py
Normal file
1
samsara/reporting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
31
samsara/reporting/export.py
Normal file
31
samsara/reporting/export.py
Normal 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
1
samsara/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
22
samsara/ui/branding.py
Normal file
22
samsara/ui/branding.py
Normal 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
465
samsara/ui/window.py
Normal 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()
|
||||
Reference in New Issue
Block a user