Replace inventory-counting risk with bounded workload scoring and separate confidence

This commit is contained in:
2026-10-30 12:34:39 -07:00
parent f65ade9fff
commit 57e716b710
26 changed files with 10351 additions and 87 deletions

View File

@@ -1,44 +1,78 @@
def assess(data):
inventory = data["inventory"]
"""Bounded workload risk, distinct from inventory coverage and uncertainty."""
from collections import Counter
from samsara.analyzers.dependencies import classify_dependencies, CONFIRMED, POTENTIAL, STANDARD, UNKNOWN
from samsara.analyzers.policy import load_policy, policy_id, risk_level
REVIEW = 'Historical usage validation, application-owner verification and administrator approval are required. No active connections does not prove a server is unused.'
def assess(data, policy=None):
policy = load_policy(policy)
groups, aggregates, network, tasks = classify_dependencies(data)
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}
breakdown = []
for group_id, group in sorted(groups.items()):
rule = max(sorted(group['rules']), key=lambda name: policy['scores'][name])
score = policy['scores'][rule]
references = sorted(group['evidence'])
selected_refs = [ref for ref in references if group['evidence_rules'][ref] == rule]
visible_refs = (selected_refs[:3] + [ref for ref in references if ref not in selected_refs])[:6]
examples = [group['evidence'][ref] for ref in visible_refs]
if len(references)>6:
examples.append(f'{len(references)-6} additional supporting inventory observations; see evidence_refs in JSON.')
classification = CONFIRMED if group['confirmed'] else POTENTIAL
reason = f"Rule {rule} assigns {score}/100. Strongest rule within workload {group_id}; corroborating services/ports/connections are not added."
findings.append({'id':group_id, 'category':group['title'], 'classification':classification,
'evidence':'\n'.join(examples), 'evidence_refs':references,
'severity':risk_level(score,policy), 'points':score, 'scoring_reason':reason})
breakdown.append({'workload':group_id,'title':group['title'],'classification':classification,
'selected_rule':rule,'candidate_rules':sorted(group['rules']),'selected_evidence_refs':selected_refs,
'score':score,'evidence_count':len(references),'reason':reason})
for (category,classification), observations in sorted(aggregates.items()):
examples = [text for _,text in observations[:3]]
findings.append({'id':'context:'+category,'category':category,'classification':classification,
'evidence':f'{len(observations)} observations. ' + ' '.join(examples),
'evidence_refs':[ref for ref,_ in observations], 'severity':'informational','points':0,
'scoring_reason':'Context/uncertainty only; no evidence-based workload score.'})
checks = data.get('checks',{})
incomplete = []
earned, possible = 0.0, sum(policy['coverage_weights'].values())
for name,weight in policy['coverage_weights'].items():
check = checks.get(name,{})
status = check.get('status','not_collected')
if status == 'complete' and (check.get('errors') or check.get('permission_limitations')):
status = 'partial'
earned += weight * (1 if status=='complete' else policy['partial_check_credit'] if status=='partial' else 0)
for name in sorted(set(checks) | set(policy['coverage_weights'])):
check = checks.get(name, {'status':'not_collected','message':'No status supplied for this assessment scope.'})
if check['status'] != 'complete' or check.get('permission_limitations') or check.get('errors'):
incomplete.append({'check':name,'status':check['status'],'message':check['message']})
if incomplete:
findings.append({'id':'coverage','category':'Missing evidence','classification':UNKNOWN,
'evidence':'\n'.join(f"{row['check']}: {row['status']} — {row['message']}" for row in incomplete),
'evidence_refs':['checks.'+row['check'] for row in incomplete],
'severity':'informational','points':0,
'scoring_reason':'Collection gaps reduce confidence and trigger review; they do not establish an active workload.'})
findings.append({'id':'observation-limits','category':'Observation limits','classification':UNKNOWN,
'evidence':REVIEW,'evidence_refs':[], 'severity':'informational','points':0,
'scoring_reason':'General observation limits never add retirement risk points.'})
score = max((row['score'] for row in breakdown), default=0)
for row in breakdown:
row['determines_final_score'] = row['score']==score and score>0
for finding in findings:
finding['determines_final_score'] = finding['points']==score and score>0
coverage_percent = round(100*earned/possible)
unknown_services = len(aggregates.get(('Unclassified services',UNKNOWN),[]))
unknown_directions = sum(row['direction']=='Unknown' for row in network)
confidence = 'low' if coverage_percent<60 else 'moderate' if coverage_percent<90 or incomplete or unknown_services or unknown_directions else 'high'
return {'model_version':'2.0','score':score,'level':risk_level(score,policy),'findings':findings,
'scoring':{'formula':'Maximum grouped workload score (0–100); never a sum of inventory observations.',
'policy_id':policy_id(policy),'policy':policy,'breakdown':breakdown,
'explanation':'No scored workload evidence was identified. This is not retirement clearance.' if not score else f"Final {score}/100 is the highest workload score; other workloads and supporting observations are not added."},
'coverage':{'percent':coverage_percent,'incomplete_checks':incomplete,
'method':'Weighted complete checks; partial checks receive policy-defined credit. Missing checks receive zero. Scope limitations still apply.'},
'confidence':{'level':confidence,'unclassified_services':unknown_services,'unknown_connection_directions':unknown_directions,
'scope':'Confidence concerns collected/classified evidence only, not historical inactivity or retirement readiness.'},
'network_observations':network,'task_observations':tasks,
'classification_counts':dict(Counter(row['classification'] for row in findings)),
'review_required':True,'manual_verification':REVIEW}

View File

@@ -0,0 +1,243 @@
"""Correlate workload evidence without altering collected inventory.
A TCP snapshot cannot prove connection initiation. Listener/PID inference is
explicitly labeled; a confirmed finding describes observed application transport
activity, not verified business ownership or permission to retire.
"""
from collections import defaultdict
import ipaddress
import re
CONFIRMED = 'Confirmed workload dependency'
POTENTIAL = 'Potential workload dependency'
STANDARD = 'Standard operating system activity'
UNKNOWN = 'Unknown / insufficient evidence'
FAMILIES = {
'directory': ('Directory services', ('ntds',), (), ()),
'sql': ('SQL Server', ('mssqlserver','sqlserveragent'), ('mssql$','sqlagent$'), ('sqlservr',)),
'iis': ('IIS application hosting', ('w3svc',), (), ('w3wp','iisexpress')),
'web': ('Web application hosting', ('nginx','apache2','httpd','haproxy','tomcat'), ('tomcat',), ('nginx','apache2','httpd','haproxy','tomcat')),
'postgres': ('PostgreSQL', ('postgresql',), ('postgresql-',), ('postgres','postmaster')),
'mysql': ('MySQL / MariaDB', ('mysql','mysqld','mariadb'), ('mysql','mariadb'), ('mysqld','mariadbd')),
'redis': ('Redis', ('redis','redis-server'), ('redis-',), ('redis-server',)),
'containers': ('Container workloads', ('docker','containerd','podman'), (), ('dockerd','containerd','podman')),
'dns': ('DNS server', ('dns','named','bind9'), (), ('named',)),
'dhcp': ('DHCP server', ('dhcpserver','isc-dhcp-server','kea-dhcp4'), (), ('dhcpd','kea-dhcp4')),
'ca': ('Certificate authority', ('certsvc',), (), ()),
'file-sharing': ('Business file shares', ('smbd','smb','nfs-server'), (), ('smbd','nfsd')),
'game-server': ('Dedicated game server', ('minecraft','minecraft-server','valheim','rustserver'), ('minecraft@','valheim-server'), ('srcds','srcds_linux','hlds','valheim_server','rustdedicated','arkascendedserver','shootergameserver','palserver','bedrock_server')),
}
ROLE_FAMILIES = {'web-server':'iis','dns':'dns','dhcp':'dhcp','adcs-cert-authority':'ca',
'ad-domain-services':'directory','hyper-v':'virtualization','failover-clustering':'clustering',
'rds-rd-server':'remote-desktop'}
TITLES = {key: value[0] for key, value in FAMILIES.items()}
TITLES.update({'directory':'Directory services','virtualization':'Virtualization','clustering':'Failover clustering',
'remote-desktop':'Remote desktop workloads','scheduled-workloads':'Custom scheduled workloads'})
STANDARD_SERVICES = set('''bfe bits brokerinfrastructure cbdhsvc cdpsvc cdpsusersvc clipsvc coremessagingregistrar cryptsvc
dcomlaunch defragsvc deviceassociationservice dhcp diagnosticshub.standardcollector.service diagtrack dnscache dps
dusmsvc eventlog eventsystem fdphost fdrespub fontcache gpsvc hidserv hvhost iphlpsvc keyiso ktmrm lanmanserver
lanmanworkstation lfsvc licensemanager lmhosts lsm mpssvc msdtc netlogon netman netprofm nlasvc nsi pcasvc
plugplay policyagent power profsvc rasman remoteaccess remoteregistry rpcss rpceptmapper samss schedule seclogon
securityhealthservice sens sense sessionenv shellhwdetection spooler sppsvc ssdpSrv stisvc storSvc swprv sysmain
systemeventsbroker termservice themes timebrokersvc trustedinstaller ualsvc uhssvc umrdpservice usosvc vds vmicheartbeat
vmickvpexchange vmicshutdown vmictimesync vmicvss vss w32time w3logsvc was wdiservicehost wdisystemhost wdnisSvc
wecsvc wersvc winDefend winhttpautoproxysvc winmgmt winrm wlidsvc wpnservice wsearch wuauserv
sqlwriter acpid auditd chronyd crond cron dbus firewalld irqbalance multipathd polkit rsyslog ssh sshd tuned udisks2'''.lower().split())
STANDARD_PROCESS = {'system','idle','svchost','lsass','services','wininit','winlogon','smss','spoolsv','msmpeng','wmiprvse','wsmprovhost','sshd'}
CLIENT_PROCESS = {'steam','steamservice','steamwebhelper','chrome','msedge','firefox','iexplore','brave','opera','curl','wget'}
MANAGEMENT_PORTS = {22,135,137,138,139,445,3389,5357,5985,5986}
STANDARD_TASK_HINTS = ('apt','dpkg','logrotate','man-db','fstrim','updatedb','systemd-','dnf-','yum-','fwupd','packagekit','run-parts','anacron')
def normal_name(value):
return str(value or '').replace('\\','/').rsplit('/',1)[-1].lower().removesuffix('.exe').removesuffix('.service')
def service_family(value):
name = normal_name(value)
for key, (_, names, prefixes, _) in FAMILIES.items():
if name in names or any(name.startswith(prefix) for prefix in prefixes):
return key
return None
def process_family(value):
name = normal_name(value)
return next((key for key, (_, _, _, names) in FAMILIES.items() if name in names), None)
def is_standard_service(name):
original = str(name).lower()
name = normal_name(name)
return original in STANDARD_SERVICES or name in STANDARD_SERVICES or name.startswith(('systemd-','vmic','onesyncsvc_','cbdhsvc_','cdpusersvc_','wpnuserservice_'))
def administrative_share(row):
return row.get('type') in ('SMB Share','Samba Share') and bool(re.fullmatch(r'(?:admin|ipc|[a-z])\$', str(row.get('name','')), re.I))
def address(value):
try:
result = ipaddress.ip_address(str(value).strip('[]').split('%')[0])
return result.ipv4_mapped if isinstance(result, ipaddress.IPv6Address) and result.ipv4_mapped else result
except ValueError:
return None
def known_pid(row):
value = row.get('pid')
return value if type(value) is int and value > 0 else None
def classify_dependencies(data):
inventory = data['inventory']
groups = {}
aggregates = defaultdict(list)
network_observations, task_observations = [], []
def evidence(group, rule, text, ref, confirmed=False):
entry = groups.setdefault(group, {'id':group, 'title':TITLES.get(group,group), 'rules':set(),
'evidence':{}, 'evidence_rules':{}, 'confirmed':False})
entry['rules'].add(rule)
entry['evidence'][ref] = text
entry['evidence_rules'][ref] = rule
entry['confirmed'] |= confirmed
def note(category, classification, text, ref):
aggregates[(category,classification)].append((ref,text))
services = inventory.get('services',[])
pid_families = defaultdict(set)
running_families = set()
for index, row in enumerate(services):
ref = f'services[{index}]'
family = service_family(row['name'])
running = str(row.get('state','')).lower() == 'running'
if family and running:
running_families.add(family)
if known_pid(row):
pid_families[known_pid(row)].add(family)
evidence(family,'running_application_service',f"Running service {row['name']}; account {row.get('account','not collected')}. Ownership/use requires review.",ref)
elif family:
note('Historical workload hints',UNKNOWN,f"{row['name']} is {row.get('state','unknown')}; no active service evidence.",ref)
elif is_standard_service(row['name']):
note('OS services',STANDARD,f"{row['name']} ({row.get('state','unknown')}) is a baseline service, not hosted workload evidence.",ref)
elif normal_name(row['name']) in CLIENT_PROCESS or 'steam client' in str(row.get('display_name','')).lower():
note('Client activity',UNKNOWN,f"{row['name']} is a client component, not evidence of a dedicated server.",ref)
else:
note('Unclassified services',UNKNOWN,f"{row['name']}: no workload rule; review service ownership.",ref)
for index, row in enumerate(inventory.get('roles',[])):
name = row['name'].lower()
ref = f'roles[{index}]'
family = ROLE_FAMILIES.get(name)
if family and str(row.get('state','Installed')).lower() in ('installed','enabled'):
evidence(family,'installed_workload_role',f"Installed role {row['name']}; role installation alone does not prove configured or active workloads.",ref)
else:
standard = name.startswith(('net-framework','powershell','windows-defender','defender','storage-services','file-services','fs-fileserver','wow64','telnet-client','rsat','windows-search'))
note('Installed components',STANDARD if standard else UNKNOWN,f"{row['name']}: installation is not application-use evidence.",ref)
if data['server'].get('platform') == 'windows' and data['server'].get('product_type') == 2:
evidence('directory','verified_domain_controller','CIM ProductType=2 identifies this host as a domain controller. Validate AD replication, FSMO, DNS, authentication and retirement procedures.','server.product_type',True)
business_shares = []
for index,row in enumerate(inventory.get('storage',[])):
ref = f'storage[{index}]'
if administrative_share(row):
note('Administrative shares',STANDARD,f"{row['name']} is a default administrative share; retained in inventory with no workload score.",ref)
elif row.get('type') in ('SMB Share','Samba Share','NFS Export'):
business_shares.append(row)
evidence('file-sharing','configured_business_share',f"Configured non-administrative share {row['name']}: {row['path']}; consumers/use not established.",ref)
if row.get('activity_source') == 'Get-SmbOpenFile (share-root-correlated)' and type(row.get('observed_open_files')) is int and row['observed_open_files'] > 0:
evidence('file-sharing','observed_business_share_clients',f"{row['name']}: {row['observed_open_files']} open files share-root-correlated by Get-SmbOpenFile at {row.get('activity_observed_at','an unspecified time')}. Current SMB use observed; owner approval still required.",ref+':client_activity',True)
elif row.get('type') == 'Remote Mount':
note('Remote storage consumers',UNKNOWN,f"{row['name']} mounted at {row['path']}; outbound storage use is not evidence of inbound consumers.",ref)
for index,row in enumerate(inventory.get('software',[])):
name = row['name']
if any(term in name.lower() for term in ('server','sql','nginx','apache','minecraft','valheim','rust','steam')):
note('Installed software hints',UNKNOWN,f"Installed {name}; installation alone does not prove an active hosted workload.",f'software[{index}]')
for index,row in enumerate(inventory.get('tasks',[])):
ref = f'tasks[{index}]'
path,name = str(row.get('path','')).lower(), str(row['name']).lower()
enabled = bool(row.get('enabled')) or str(row.get('state','')).lower() == 'running'
family = service_family(name) or process_family(row.get('executables',row.get('executable','')))
task_name = normal_name(name.split(' (',1)[0])
maintenance = any(task_name == hint.rstrip('-') or task_name.startswith((hint.rstrip('-')+'-',hint.rstrip('-')+'.')) for hint in STANDARD_TASK_HINTS)
windows_path = path.replace('/','\\')
os_namespace = windows_path.startswith('\\microsoft\\windows\\') and not windows_path.startswith('\\microsoft\\windows\\powershell\\scheduledjobs')
standard = not family and (os_namespace or maintenance)
classification = STANDARD if standard else POTENTIAL if enabled else UNKNOWN
if enabled and not standard:
group = family or 'scheduled-workloads'
evidence(group,'configured_custom_schedule',f"Enabled task {row['name']} at {row.get('path','unknown path')}; schedule: {row.get('triggers','not collected')}. Configuration does not confirm execution.",ref)
else:
note('OS scheduled maintenance' if standard else 'Disabled schedules',classification,f"{row['name']}: routine maintenance or disabled schedule; no workload points.",ref)
task_observations.append({'inventory_index':index,'classification':classification,'enabled':enabled})
rows = inventory.get('network',[])
listener_index = {}
own_addresses = {address(value) for value in data['server'].get('ip_addresses',[])} - {None}
for index,row in enumerate(rows):
local = address(row.get('local_address'))
if local and not local.is_unspecified:
own_addresses.add(local)
if str(row.get('state','')).lower() in ('listen','listening'):
if local is not None and known_pid(row):
listener_index.setdefault((row.get('local_port'),known_pid(row),local),index)
def row_family(row):
family = process_family(row.get('process',''))
choices = pid_families.get(known_pid(row),set())
if family:
return family
return next(iter(choices)) if len(choices)==1 else None
for index,row in enumerate(rows):
ref = f'network[{index}]'
state = str(row.get('state','')).lower()
proc = normal_name(row.get('process',''))
remote,local = address(row.get('remote_address')), address(row.get('local_address'))
matching = []
if state in ('established','estab') and local is not None and known_pid(row):
wildcard = ipaddress.ip_address('0.0.0.0' if local.version==4 else '::')
for endpoint in {local,wildcard}:
match = listener_index.get((row.get('local_port'),known_pid(row),endpoint))
if match is not None:
matching.append(match)
matching.sort()
direction, basis = 'Unknown', 'No reliable direction evidence; a TCP snapshot does not record the initiating handshake.'
if state in ('listen','listening'):
direction,basis = 'Listener','Local listening endpoint; not proof of a consumer.'
elif remote and (remote.is_loopback or remote in own_addresses):
direction,basis = 'Loopback / same host','Remote address is loopback or belongs to this host; no external client inferred.'
elif str(row.get('direction','')).lower() in ('inbound','outbound'):
direction = str(row['direction']).title() + ' (reported)'
basis = 'Direction supplied by inventory source; not independently verified.'
elif state in ('established','estab') and matching:
direction,basis = 'Inbound (inferred)','Local address/port and nonzero PID match a listener. Client initiation is inferred, not observed.'
elif state in ('established','estab') and remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152:
direction,basis = 'Outbound (inferred)','Ephemeral local port to a non-ephemeral remote port without an attributed listener; heuristic only.'
family = row_family(row)
classification, rationale = UNKNOWN, 'No attributed application workload. No score from the connection alone.'
if proc in CLIENT_PROCESS:
family = None
rationale = 'Known client process (including browsers/Steam); not a dedicated server workload.'
note('Client activity',UNKNOWN,rationale,ref)
elif family:
if direction == 'Listener':
evidence(family,'attributed_application_listener',f"{row.get('process','unknown')} PID {row.get('pid','unknown')} listening at {row.get('local_address')}:{row.get('local_port')}; a listener alone is potential exposure.",ref)
classification,rationale = POTENTIAL,'Listener attributed to a known application; grouped with service evidence.'
elif state in ('established','estab') and direction.startswith('Inbound') and matching and remote and not remote.is_unspecified:
if family == 'file-sharing':
evidence(family,'observed_smb_transport',f"File-sharing transport observed on local port {row.get('local_port')}; specific share/export use is unverified without correlated open files.",ref)
classification,rationale = POTENTIAL,'File-sharing transport observed; no named business share attributed.'
else:
evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True)
classification,rationale = CONFIRMED,'Observed transport activity on an attributed application listener; direction inference is recorded separately.'
else:
note('Other application traffic',UNKNOWN,f"{direction}: {row.get('process','unknown')}; not scored as an inbound consumer. {basis}",ref)
elif business_shares and row.get('local_port') == 445 and (direction == 'Listener' or direction.startswith('Inbound')) and proc in ('system','smbd','unknown','unavailable or process exited'):
family='file-sharing'
if state in ('established','estab') and matching:
evidence(family,'observed_smb_transport',f"SMB TCP activity observed on local port 445 with matching listener PID {row.get('pid')}; the accessed share is unknown and could be administrative. Do not equate this with business-share use.",ref)
classification,rationale = POTENTIAL,'SMB transport plus configured business shares; accessed share unverified without open-file evidence.'
elif proc in STANDARD_PROCESS and (row.get('local_port') in MANAGEMENT_PORTS or (type(row.get('local_port')) is int and row['local_port']>=49152)):
classification,rationale=STANDARD,'Baseline OS/management endpoint; no application workload attributed.'
note('OS networking',STANDARD,rationale,ref)
else:
note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}. {basis}",ref)
network_observations.append({'inventory_index':index,'direction':direction,'direction_basis':basis,
'classification':classification,'workload':family or '', 'rationale':rationale})
return groups, aggregates, network_observations, task_observations

View File

@@ -0,0 +1,55 @@
"""Data-only scoring policy. No executable expressions or arbitrary regex rules."""
import copy
import hashlib
import json
from pathlib import Path
DEFAULT_POLICY = Path(__file__).resolve().parents[1] / 'assets' / 'scoring_policy.json'
RULES = {'installed_workload_role', 'running_application_service', 'attributed_application_listener',
'observed_application_clients', 'configured_business_share', 'observed_smb_transport',
'observed_business_share_clients', 'configured_custom_schedule', 'verified_domain_controller'}
def load_policy(source=None):
if source is not None and not isinstance(source,(dict,str,Path)):
raise ValueError('A scoring policy must be a JSON object or file path.')
if isinstance(source, dict):
policy = copy.deepcopy(source)
else:
path = Path(source) if source is not None else DEFAULT_POLICY
try:
if path.stat().st_size > 65536:
raise ValueError('Scoring policy exceeds 64 KiB.')
policy = json.loads(path.read_text(encoding='utf-8'))
except (OSError, UnicodeError, json.JSONDecodeError, RecursionError):
raise ValueError('Unable to read a valid UTF-8 scoring policy JSON file.') from None
try:
limits, weights = policy['thresholds'], policy['coverage_weights']
valid = (
set(policy) == {'model_version','name','scores','thresholds','coverage_weights','partial_check_credit'}
and policy['model_version'] == '2.0' and isinstance(policy['name'],str) and 0<len(policy['name'])<=120
and set(policy['scores']) == RULES
and all(type(value) is int and 0<=value<=100 for value in policy['scores'].values())
and set(limits) == {'low','medium','high','critical'}
and all(type(value) is int for value in limits.values())
and 1 == limits['low'] < limits['medium'] < limits['high'] < limits['critical'] <= 100
and set(weights) == {'system','roles','services','network','tasks','software','storage','shares','certificates'}
and all(type(weight) is int and 1<=weight<=10 for weight in weights.values())
and type(policy['partial_check_credit']) in (int,float) and 0<=policy['partial_check_credit']<1
)
except (KeyError,TypeError,AttributeError):
valid = False
if not valid:
raise ValueError('Invalid scoring policy. Use the bundled v2 policy structure, integer scores 0–100, ordered thresholds and positive coverage weights.')
return policy
def policy_id(policy):
return hashlib.sha256(json.dumps(policy, sort_keys=True).encode('utf-8')).hexdigest()
def risk_level(score, policy):
for name in ('critical','high','medium','low'):
if score >= policy['thresholds'][name]:
return name
return 'informational'

View File

@@ -1,8 +1,9 @@
"""Compact, evidence-based presentation of the full assessment."""
import re
from samsara.analyzers.dependencies import service_family
# 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)
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|dedicated server|minecraft|valheim|rustdedicated)', re.I)
def summarize(data, assessment):
@@ -20,15 +21,17 @@ def summarize(data, assessment):
'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:
if row.get('state') == 'Running' and service_family(row['name']) is not None 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':
task_classes = {row['inventory_index']: row['classification'] for row in assessment.get('task_observations', [])}
for index, row in enumerate(inventory.get('tasks', [])):
if (row.get('enabled') or row.get('state') == 'Running') and task_classes.get(index) != 'Standard operating system activity':
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())}
'incomplete': len(assessment['coverage']['incomplete_checks']),
'coverage_percent': assessment['coverage']['percent'], 'confidence': assessment['confidence']['level']}

View File

@@ -0,0 +1,18 @@
{
"model_version": "2.0",
"name": "Conservative retirement workload evidence",
"scores": {
"installed_workload_role": 15,
"running_application_service": 35,
"attributed_application_listener": 45,
"observed_application_clients": 70,
"configured_business_share": 20,
"observed_smb_transport": 40,
"observed_business_share_clients": 80,
"configured_custom_schedule": 20,
"verified_domain_controller": 95
},
"thresholds": {"low": 1, "medium": 25, "high": 60, "critical": 90},
"coverage_weights": {"system": 2, "roles": 1, "services": 2, "network": 2, "tasks": 2, "software": 1, "storage": 1, "shares": 1, "certificates": 1},
"partial_check_credit": 0.5
}

View File

@@ -25,7 +25,7 @@ ALLOWED = {
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit', 'executables'},
'software': {'name', 'publisher', 'version', 'installation_date', 'registry_view'},
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib', 'filesystem', 'special'},
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib', 'filesystem', 'special', 'observed_open_files', 'observed_client_count', 'activity_source', 'activity_observed_at'},
'certificates': {'subject', 'issuer', 'expires', 'thumbprint', 'store'},
}
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'kernel_version', 'product_type', 'domain_joined', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}

View File

@@ -1,33 +1,86 @@
"""Actionable assessment report, with full inventory preserved as a separate JSON artifact."""
import hashlib
import html
import json
from pathlib import Path
from urllib.parse import quote
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."]
MANUAL_STEPS = ['Confirm application and business owners and obtain administrator retirement approval.',
'Validate historical usage across a representative business cycle, including infrequent and offline jobs.',
'Review DNS, load balancers, monitoring, backups, firewall logs and service-account consumers.',
'Verify custom tasks and share consumers; resolve incomplete checks and confirm rollback/retention requirements.']
def export_json(path, data, assessment):
Path(path).write_text(json.dumps({"inventory_result": data, "assessment": assessment}, indent=2), encoding="utf-8")
Path(path).write_text(json.dumps({'inventory_result':data,'assessment':assessment},indent=2),encoding='utf-8')
def inventory_artifact(path, data):
payload = (json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8')
digest = hashlib.sha256(payload).hexdigest()
destination = Path(path).with_name(Path(path).stem + '.inventory-' + digest[:12] + '.json')
try:
with destination.open('xb') as output:
output.write(payload)
except FileExistsError:
if destination.read_bytes() != payload:
raise FileExistsError('Existing inventory artifact differs; choose a new report filename.') from None
return destination, digest
def table(headers, rows):
escape = lambda value: html.escape(str(value))
if not rows:
return '<p class="muted">None identified in collected evidence. Review missing evidence before drawing conclusions.</p>'
return '<div class="table"><table><thead><tr>' + ''.join('<th>'+escape(h)+'</th>' for h in headers) + '</tr></thead><tbody>' + ''.join('<tr>'+''.join('<td>'+escape(v)+'</td>' for v in row)+'</tr>' for row in rows) + '</tbody></table></div>'
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>'
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Windows read-only collection':
content = '<h1>Samsara assessment</h1><p>LOCAL WINDOWS 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")
artifact, digest = inventory_artifact(path,data)
source = 'SYNTHETIC DEMO — no real collection performed.' if data['synthetic'] else 'IMPORTED INVENTORY — source assertions not independently verified.'
for platform in ('Linux','Windows'):
if not data['synthetic'] and data['server'].get('collection_source') == f'Local {platform} read-only collection':
source = f'LOCAL {platform.upper()} INVENTORY — read-only snapshot of the execution host.'
summary = summarize(data,assessment)
scoring = assessment['scoring']
content = '<h1>Samsara assessment</h1><p class="source">'+escape(source)+'</p>'
content += '<h2>Summary</h2>'
content += '<p><strong>'+escape(data['server']['hostname'])+'</strong> · '+escape(data['collected_at'])+'</p>'
content += f"<div class='card'><strong>Retirement risk estimate: {escape(assessment['level']).title()} · {assessment['score']}/100</strong><p>{escape(scoring['explanation'])}</p></div>"
content += f"<p>Evidence coverage: <strong>{summary['coverage_percent']}%</strong> · Evidence confidence: <strong>{escape(summary['confidence'])}</strong> · {summary['incomplete']} checks need review.</p>"
content += '<p>Coverage and uncertainty are separate from risk. A zero or low score is not retirement clearance. Historical usage validation and administrator approval are always required.</p>'
content += f"<p>OS type: {escape(summary['os_type'])}<br>Version: {escape(summary['version'])}</p>"
actionable = [row for row in assessment['findings'] if row['points']>0]
content += '<h2>Actionable workload findings</h2>'
content += table(['Workload','Classification','Workload score','Evidence','Why this affects scoring'],[(row['category'],row['classification'],row['points'],row['evidence'],row['scoring_reason']) for row in actionable])
content += '<h2>How the score was calculated</h2><p>'+escape(scoring['formula'])+'</p>'
content += table(['Workload','Selected rule','Score','Determines final score','Evidence observations'],[(row['title'],row['selected_rule'],row['score'],'Yes (co-leading)' if row['determines_final_score'] else 'No; below the maximum',row['evidence_count']) for row in scoring['breakdown']])
content += '<details><summary>Policy weights, thresholds and audit identity</summary><p>Policy SHA-256: '+escape(scoring['policy_id'])+'</p><pre>'+escape(json.dumps(scoring['policy'],indent=2))+'</pre></details>'
content += '<h2>Important software</h2><p>Inventory name hints only; installed software does not establish active use.</p>'
content += table(['Software','Version','Evidence'],[(r['name'],r['version'],r['evidence']) for r in summary['software'][:12]])
content += '<h2>Scheduled jobs and scripts</h2><p>Nonroutine enabled schedules; configuration does not confirm execution. All tasks remain in the inventory.</p>'
content += table(['Job','Schedule','State'],[(r['name'],r['schedule'],r['status']) for r in summary['jobs'][:12]])
context = [r for r in assessment['findings'] if r['points']==0 and r['id'] not in ('coverage','observation-limits')]
content += '<h2>Unscored context and review items</h2><p>Routine services, administrative shares, client traffic and unknown observations do not receive workload points.</p>'
content += table(['Category','Classification','Context'],[(r['category'],r['classification'],r['evidence']) for r in context])
directions = {}
for row in assessment['network_observations']:
directions[row['direction']] = directions.get(row['direction'],0)+1
content += '<h2>Network interpretation</h2><p>Direction inferred from listener/PID or ephemeral-port patterns is a heuristic, not a captured initiating handshake. Confirmed workload findings describe attributed observed transport activity, not verified business ownership.</p>'
content += table(['Direction / activity','Observations'],list(sorted(directions.items())))
content += '<h2>Collection checks / missing evidence</h2><p>Failed and partial checks reduce confidence; they never add workload risk points.</p>'
checks = []
for name in sorted(set(data['checks']) | set(assessment['scoring']['policy']['coverage_weights'])):
row = data['checks'].get(name,{'status':'not_collected','message':'No status supplied.'})
errors = '; '.join(f"{e['code']}: {e['message']}" for e in row.get('errors',[]))
checks.append((name,row['status'],row.get('started_at','Not supplied'),row.get('completed_at','Not supplied'),row['message'],errors,'; '.join(row.get('permission_limitations',[])),'; '.join(row.get('limitations',[]))))
content += table(['Check','Status','started_at (UTC)','completed_at (UTC)','Result','Errors','permission_limitations','Scope limitations'],checks)
content += '<h2>Recommended manual verification</h2><ul>'+''.join('<li>'+escape(step)+'</li>' for step in MANUAL_STEPS)+'</ul>'
content += '<h2>Complete raw inventory</h2><p>Every collected inventory row is preserved separately, including routine services and administrative shares. The analyzer has not changed the collected evidence.</p>'
content += '<p><a href="'+escape(quote(artifact.name))+'">Download / open complete inventory JSON</a></p><p>Inventory SHA-256: '+digest+'</p>'
content += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()])
content += '<p>JSON assessment export also preserves all raw inventory, classifications, evidence references and policy. Keep the inventory file alongside this HTML when sharing or archiving.</p>'
css = 'body{font:15px system-ui;max-width:1180px;margin:32px auto;padding:20px;color:#233044;line-height:1.55}h1,h2{color:#173c60}.muted{color:#667}.source,.card,details{background:#eef3f8;padding:16px;border-radius:7px}.table{overflow-x:auto}table{border-collapse:collapse;width:100%;margin:12px 0}th,td{border:1px solid #d9e0e8;text-align:left;vertical-align:top;padding:9px;white-space:pre-wrap;overflow-wrap:anywhere}th{background:#eef3f8}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#1263a3}'
Path(path).write_text('<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>',encoding='utf-8')

View File

@@ -12,6 +12,7 @@ from samsara.collectors.windows import collect_local_windows
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.analyzers.policy import load_policy, policy_id
from samsara.reporting.export import export_html, export_json
@@ -101,6 +102,8 @@ class MainWindow(QMainWindow):
self.assessment = None
self.history = []
self.records = []
self.scoring_policy = load_policy()
self.active_policy = self.scoring_policy
self.setStyleSheet('''
QWidget { background:#131b27; color:#e5ecf5; font-size:14px; }
QLabel#title { font-size:27px; font-weight:600; }
@@ -254,7 +257,7 @@ class MainWindow(QMainWindow):
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))
score_layout.addWidget(self.label('Higher scores mean more workload concerns. Historical usage validation and administrator approval are always required.', muted=True))
self.coverage_note = self.label('', muted=True)
score_layout.addWidget(self.coverage_note)
summary_layout.addWidget(score_card)
@@ -312,8 +315,32 @@ class MainWindow(QMainWindow):
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 Windows Server and Linux collectors are available. Windows validation on a supported host is pending. Remote collection is a separate follow-up. 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.addWidget(self.label('Retirement scoring policy'))
self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True)
settings.addWidget(self.policy_label)
self.policy_button = QPushButton('Load scoring policy JSON…')
self.policy_button.clicked.connect(self.choose_policy)
settings.addWidget(self.policy_button)
self.reset_policy_button = QPushButton('Restore default scoring policy')
self.reset_policy_button.clicked.connect(self.reset_policy)
settings.addWidget(self.reset_policy_button)
settings.addWidget(self.label('Changes apply to new assessments. History keeps the original score and policy. Scores use the strongest workload, never an inventory count.', muted=True))
settings.addStretch()
def choose_policy(self):
path, _ = QFileDialog.getOpenFileName(self, 'Load scoring policy', '', 'Scoring policy (*.json)')
if not path:
return
try:
self.scoring_policy = load_policy(path)
self.policy_label.setText(self.scoring_policy['name'] + ' · ' + policy_id(self.scoring_policy)[:12])
except ValueError as exc:
QMessageBox.warning(self, 'Invalid scoring policy', str(exc))
def reset_policy(self):
self.scoring_policy = load_policy()
self.policy_label.setText('Default policy · ' + policy_id(self.scoring_policy)[:12])
def source_changed(self):
source = self.source.currentData()
self.file_row.setVisible(source == 'file')
@@ -338,6 +365,7 @@ class MainWindow(QMainWindow):
if source == 'file' and not self.inventory_path.text().strip():
self.status.setText('Choose an inventory file before analyzing.')
return
self.active_policy = load_policy(self.scoring_policy)
self.set_busy(True)
self.status.setText('Starting assessment…')
self.job = CollectionJob(source, self.target.currentData(), self.inventory_path.text().strip(), self)
@@ -348,6 +376,8 @@ class MainWindow(QMainWindow):
self.job.start()
def set_busy(self, busy):
self.policy_button.setEnabled(not busy)
self.reset_policy_button.setEnabled(not busy)
self.analyze_button.setEnabled(not busy)
self.source.setEnabled(not busy)
self.target.setEnabled(not busy and self.source.currentData() == 'local')
@@ -369,7 +399,7 @@ class MainWindow(QMainWindow):
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
def collection_done(self, data):
assessment = assess(data)
assessment = assess(data, policy=self.active_policy)
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))
@@ -377,7 +407,7 @@ class MainWindow(QMainWindow):
self.history_empty.hide()
self.display_assessment(data, assessment)
self.nav.setCurrentRow(0)
incomplete = sum(check['status'] != 'complete' for check in data['checks'].values())
incomplete = len(assessment['coverage']['incomplete_checks'])
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):
@@ -386,7 +416,7 @@ class MainWindow(QMainWindow):
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.coverage_note.setText(f"Evidence coverage: {summary['coverage_percent']}% · Confidence: {summary['confidence'].title()} · {summary['incomplete']} checks need review. Coverage gaps do not add risk points.")
self.os_type.setText('OS type: ' + summary['os_type'])
self.os_version.setText('Version: ' + summary['version'])
software = summary['software']
@@ -398,7 +428,7 @@ class MainWindow(QMainWindow):
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.')
'No nonroutine enabled schedules identified. Full schedules and missing scheduler coverage are in Tasks and Collection checks.')
self.quick_save.setEnabled(True)
self.result_tabs.setCurrentIndex(0)
# Discard tables from the previous assessment. Populate detailed tabs on demand.
@@ -432,9 +462,17 @@ class MainWindow(QMainWindow):
elif key == 'checks':
rows = [{'check': name, **value} for name, value in self.data['checks'].items()]
elif key in ('risk', 'dependencies'):
rows = self.assessment['findings']
findings = self.assessment['findings']
if key == 'dependencies':
findings = [row for row in findings if row['classification'] in ('Confirmed workload dependency','Potential workload dependency')]
rows = [{name: row[name] for name in ('category','classification','severity','points','determines_final_score','evidence','scoring_reason')} for row in findings]
else:
rows = self.data['inventory'][key]
if key == 'risk':
area.addWidget(self.label(self.assessment['scoring']['formula'] + '\n' + self.assessment['scoring']['explanation'] + '\nPolicy: ' + self.assessment['scoring']['policy']['name'] + ' · ' + self.assessment['scoring']['policy_id'][:12], muted=True))
if key == 'network':
observations = self.assessment['network_observations']
rows = [{**row, 'assessed_direction': observations[index]['direction'], 'direction_basis': observations[index]['direction_basis'], 'activity_classification': observations[index]['classification']} for index,row in enumerate(rows)]
if key in self.data['checks']:
check = self.data['checks'][key]
area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True))
@@ -455,7 +493,7 @@ class MainWindow(QMainWindow):
path += '.' + kind
try:
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
QMessageBox.information(self, 'Report saved', 'HTML report and separate full inventory JSON saved locally.' if kind == 'html' else 'Assessment and full inventory saved as JSON.')
except OSError:
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')