Privacy hardening, remote-peer IP masking and review fixes

- Add privacy projection (core/privacy.py), private report storage, bounded
  process capture; omit identities, free text and unapproved paths
- Mask remote peer IPs: only loopback/host/wildcard stay numeric, others are
  reduced to remote_scope private/public (schema + scoring updated)
- Map ss TCP states to canonical names; ASCII-only numeric fields; fix
  "Microsoft Visual C++" product matching (Python and PowerShell)
- Merge 7fed3b9: -Diagnostic switch, serialization exit code 3, PS7 module
  path, UI error detail; keep hardened task/certificate collectors
- Add audit docs, privacy docs and regression tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 18:50:24 -08:00
parent 7fed3b9ab9
commit 610a3bb447
51 changed files with 2719 additions and 604 deletions

View File

@@ -1,3 +1,6 @@
from samsara.core.privacy import minimize_inventory, load_privacy_policy
from samsara.core.storage import report_directory
from uuid import uuid4
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
@@ -30,7 +33,7 @@ class CollectionJob(QThread):
if self.source == 'local':
self.stage.emit('Detecting operating system…')
detected = detect_local_os()
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
self.stage.emit('Operating system detected. Collecting minimized inventory…')
if detected['platform'] == 'windows':
data = collect_local_windows(profile=self.profile, stage=self.stage.emit, cancelled=self.isInterruptionRequested)
else:
@@ -45,6 +48,7 @@ class CollectionJob(QThread):
if self.isInterruptionRequested():
raise CollectionCancelled()
self.stage.emit('Preparing assessment report…')
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
self.result.emit(data)
except CollectionCancelled:
self.failed.emit('Cancelled. Your previous assessment has been kept.')
@@ -298,6 +302,9 @@ class MainWindow(QMainWindow):
self.history_list.itemActivated.connect(self.open_history)
self.history_list.itemClicked.connect(self.open_history)
history.addWidget(self.history_list)
self.clear_history_button = QPushButton('Clear session assessments')
self.clear_history_button.clicked.connect(self.clear_history)
history.addWidget(self.clear_history_button)
self.history_empty = self.label('No assessments yet.', muted=True)
history.addWidget(self.history_empty)
reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.')
@@ -315,6 +322,7 @@ 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('Privacy defaults: identifying names and unapproved paths omitted. Task commands, certificate identities and SMB open-file details are not collected. Exports use a configured private local folder. Review docs/PRIVACY_HARDENING.md before real assessments.', 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)
@@ -376,6 +384,7 @@ class MainWindow(QMainWindow):
self.job.start()
def set_busy(self, busy):
self.clear_history_button.setEnabled(not busy)
self.policy_button.setEnabled(not busy)
self.reset_policy_button.setEnabled(not busy)
self.analyze_button.setEnabled(not busy)
@@ -399,11 +408,20 @@ class MainWindow(QMainWindow):
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
def collection_done(self, data):
try:
data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source'))
except ValueError:
self.status.setText('Privacy validation failed; assessment withheld.')
return
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))
self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}")
while len(self.records)>load_privacy_policy()['max_session_assessments']:
self.records.pop(0)
self.history.pop(0)
self.history_list.takeItem(0)
self.history_list.addItem(f"{data['collected_at']} • {data['server']['hostname']} • {assessment['level'].title()} risk")
self.history_empty.hide()
self.display_assessment(data, assessment)
self.nav.setCurrentRow(0)
@@ -428,7 +446,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 nonroutine enabled schedules identified. Full schedules and missing scheduler coverage are in Tasks and Collection checks.')
'No nonroutine enabled schedules identified. Schedule detail is omitted; review Tasks and Collection checks before concluding no work exists.')
self.quick_save.setEnabled(True)
self.result_tabs.setCurrentIndex(0)
# Discard tables from the previous assessment. Populate detailed tabs on demand.
@@ -443,6 +461,26 @@ class MainWindow(QMainWindow):
for button in self.report_buttons:
button.setEnabled(True)
def clear_history(self):
self.history.clear()
self.records.clear()
self.history_list.clear()
self.history_empty.show()
self.data = self.assessment = None
self.result_title.setText('No assessment loaded')
self.banner.clear()
for label in (self.retirement_score,self.coverage_note,self.os_type,self.os_version,self.important_software,self.scheduled_jobs):
label.clear()
for area in self.detail_areas.values():
while area.count():
item=area.takeAt(0)
if item.widget(): item.widget().deleteLater()
area.addWidget(self.label('No assessment loaded.',muted=True))
self.report_label.setText('Analyze a computer or open an inventory first.')
for button in self.report_buttons: button.setEnabled(False)
self.quick_save.setEnabled(False)
self.status.setText('Session assessment references cleared. This is not secure memory erasure; exported files remain.')
def open_history(self, item):
index = self.history_list.row(item)
if 0 <= index < len(self.records):
@@ -488,16 +526,21 @@ class MainWindow(QMainWindow):
def save_report(self, kind):
if self.data is None:
return
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})')
try:
suggested = str(report_directory() / f'samsara-{uuid4().hex[:12]}.{kind}')
except (OSError, ValueError):
QMessageBox.warning(self, 'Save blocked', 'Provision the private report directory described in docs/PRIVACY_HARDENING.md.')
return
path, _ = QFileDialog.getSaveFileName(self, 'Save minimized assessment', suggested, f'{kind.upper()} (*.{kind})')
if not path:
return
if not path.lower().endswith('.' + kind):
path += '.' + kind
try:
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
QMessageBox.information(self, 'Report saved', '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.')
QMessageBox.information(self, 'Report saved', 'Minimized assessment saved. No raw inventory sidecar was created.')
except (OSError, ValueError):
QMessageBox.warning(self, 'Save failed', 'Use a new filename directly in the approved private report directory. Check its permissions.')
def closeEvent(self, event):
if self.job is not None and self.job.isRunning():