Replace inventory-counting risk with bounded workload scoring and separate confidence
This commit is contained in:
@@ -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}
|
||||
|
||||
243
samsara/analyzers/dependencies.py
Normal file
243
samsara/analyzers/dependencies.py
Normal 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
|
||||
55
samsara/analyzers/policy.py
Normal file
55
samsara/analyzers/policy.py
Normal 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'
|
||||
@@ -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']}
|
||||
|
||||
18
samsara/assets/scoring_policy.json
Normal file
18
samsara/assets/scoring_policy.json
Normal 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
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user