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

@@ -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.')