- 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>
96 lines
8.4 KiB
Python
96 lines
8.4 KiB
Python
"""Actionable reports from privacy-minimized evidence, without raw sidecars."""
|
|
import hashlib
|
|
import html
|
|
import json
|
|
from pathlib import Path
|
|
from samsara.core.privacy import minimize_inventory
|
|
from samsara.core.storage import write_private
|
|
from samsara.analyzers.assessment import assess
|
|
from samsara.analyzers.summary import summarize
|
|
|
|
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 private_assessment(data, assessment):
|
|
# Never trust a marker or cached finding to authorize raw data disclosure.
|
|
source=data.get('server',{}).get('collection_source')
|
|
clean=minimize_inventory(data, trusted_source=source)
|
|
policy=assessment.get('scoring',{}).get('policy') if isinstance(assessment,dict) else None
|
|
if policy is not None:
|
|
import copy
|
|
policy=copy.deepcopy(policy)
|
|
policy['name']='Deployment scoring policy'
|
|
return clean, assess(clean, policy=policy)
|
|
|
|
|
|
def export_json(path, data, assessment):
|
|
data, assessment=private_assessment(data, assessment)
|
|
write_private(path,json.dumps({'inventory_result':data,'assessment':assessment},indent=2))
|
|
|
|
|
|
def inventory_artifact(path, data):
|
|
"""Explicit minimized inventory export; never writes a raw sidecar."""
|
|
data=minimize_inventory(data)
|
|
payload=(json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8')
|
|
destination=Path(path).with_suffix('.inventory.json')
|
|
write_private(destination,payload)
|
|
return destination, hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
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))
|
|
data, assessment = private_assessment(data, assessment)
|
|
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>Privacy and omitted evidence</h2><p>Identifying names, accounts, task commands, certificate identities and unapproved paths are omitted. No raw inventory sidecar is generated. JSON export contains the same minimized evidence. Numeric IP addresses remain confidential infrastructure metadata. This report does not establish PHI-free operation or HIPAA compliance.</p>'
|
|
content += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()])
|
|
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}'
|
|
write_private(path,'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Samsara</title><style>'+css+'</style></head><body>'+content+'</body></html>')
|