Files
ProxMenux/AppImage/scripts/flask_audit_routes.py
T
MacRimiandClaude Opus 5 da8a480eff Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:06:04 +02:00

468 lines
17 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ProxMenux Audit Routes
Flask blueprint for the Audit & Report assessment engine.
An assessment reads the host and records findings; it never modifies
anything. The run endpoint is therefore the only POST that does real
work, and it is deliberately serialised: two concurrent assessments would
compete for the same collectors without producing a better answer.
"""
import threading
import time
from flask import Blueprint, jsonify, request
from jwt_middleware import require_auth, require_admin_scope
from auth_manager import verify_token, load_auth_config
audit_bp = Blueprint('audit', __name__)
try:
import audit_store
import audit_checks
import audit_checks_pve # noqa: F401 — importing registers the checks
import audit_inventory
import audit_profiles
import audit_policy
import changes_journal
except ImportError:
audit_store = None
audit_checks = None
audit_inventory = None
audit_profiles = None
audit_policy = None
changes_journal = None
# One assessment at a time. The flag is also what the interface polls to
# know a run is still in progress.
_run_lock = threading.Lock()
_running: dict = {'active': False, 'run_id': None, 'started_at': 0}
_startup_error = None
def _actor():
config = load_auth_config()
if not config.get('enabled') or config.get('declined'):
return 'local-admin (authentication disabled)'
parts = request.headers.get('Authorization', '').split()
return verify_token(parts[1]) if len(parts) == 2 else 'unknown'
def _progress(run_id, completed, total, check_id):
_running.update(run_id=run_id, completed=completed, total=total, check_id=check_id)
@audit_bp.record_once
def _on_register(state):
global _startup_error
if audit_store:
try:
audit_store.recover_interrupted_runs()
except Exception as exc:
# An audit DB problem must never prevent the Monitor starting.
_startup_error = str(exc)
print(f"[audit] persistence unavailable: {exc}")
def _unavailable():
return jsonify({
"success": False,
"message": "Audit engine not available",
}), 500
@audit_bp.route('/api/audit/checks', methods=['GET'])
@require_auth
def list_checks():
"""Catalogue of registered checks, independent of any run."""
if not audit_checks:
return _unavailable()
try:
return jsonify({
"success": True,
"areas": list(audit_checks.AREAS),
"checks": [
{
"check_id": c.check_id,
"area": c.area,
"severity": c.severity,
}
for c in audit_checks.registered_checks()
],
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/status', methods=['GET'])
@require_auth
def status():
"""Latest run, whether an assessment is in progress, and the baseline."""
if not audit_store or _startup_error:
return _unavailable()
try:
latest = audit_store.latest_run()
summary = {}
if latest:
for f in audit_store.effective_findings(latest['run_id']):
# An accepted finding is counted as a decision, not as the
# problem it still technically is, so the counters and the
# list a reader sees agree with each other.
key = (f.get('decision') or f['classification'])
summary[key] = summary.get(key, 0) + 1
return jsonify({
"success": True,
"running": _running['active'],
"progress": {k: _running.get(k) for k in ('run_id', 'completed', 'total', 'check_id')},
"latest": latest,
"summary": summary,
"baseline": audit_store.get_baseline(),
"exceptions": len(audit_store.active_exceptions()),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/run', methods=['POST'])
@require_admin_scope
def run():
"""Start an assessment in the background.
The response returns immediately with the run identifier; the
interface polls ``/api/audit/status``. A full assessment is short but
runs against a production host, so it must not hold an HTTP worker.
"""
if not audit_checks or _startup_error:
return _unavailable()
data = request.get_json(silent=True) or {}
profile = str(data.get('profile') or 'full')
areas = data.get('areas')
if (not audit_profiles.is_known(profile) or (areas is not None and
(not isinstance(areas, list) or not areas or
any(not isinstance(a, str) or a not in audit_checks.AREAS for a in areas)))):
return jsonify(success=False, message="Unsupported audit profile or areas"), 400
only = set(areas) if areas is not None else None
with _run_lock:
if _running['active']:
return jsonify({
"success": False,
"message": "An assessment is already running",
"run_id": _running['run_id'],
}), 409
run_id = audit_store.start_run(profile)
_running.update({'active': True, 'run_id': run_id,
'started_at': time.time(), 'completed': 0, 'total': 0, 'check_id': None})
def worker():
try:
audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress)
audit_store.prune_runs()
except Exception as e:
audit_store.finish_run(run_id, checks_total=_running.get('completed', 0), error=str(e))
print(f"[audit] assessment failed: {e}")
finally:
_running['active'] = False
try:
threading.Thread(target=worker, daemon=True, name='audit-run').start()
except Exception as e:
_running['active'] = False
audit_store.finish_run(run_id, checks_total=0, error=str(e))
return jsonify(success=False, message="Unable to start assessment"), 500
return jsonify({"success": True, "started": True, "run_id": run_id})
@audit_bp.route('/api/audit/runs', methods=['GET'])
@require_auth
def runs():
if not audit_store:
return _unavailable()
try:
limit = min(int(request.args.get('limit', 20)), 100)
return jsonify({"success": True, "runs": audit_store.list_runs(limit)})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/runs/<run_id>', methods=['GET'])
@require_auth
def run_detail(run_id):
"""Findings of one run, with the accepted-risk record attached.
Accepted findings are returned like any other so the interface can
show them muted rather than dropping them: hiding an accepted risk
turns the register into a way of forgetting decisions.
"""
if not audit_store:
return _unavailable()
try:
run = audit_store.get_run(run_id)
if not run:
return jsonify({"success": False, "message": "Run not found"}), 404
# History is immutable by default. The live view explicitly asks
# for current decisions, so acceptance/revocation needs no scan.
findings = (audit_store.effective_findings(run_id) if request.args.get('effective') == '1'
else audit_store.get_findings(run_id))
return jsonify({"success": True, "run": run, "findings": findings})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/compare', methods=['GET'])
@require_auth
def compare():
"""Difference between two runs, defaulting the base to the baseline."""
if not audit_store:
return _unavailable()
try:
other = request.args.get('to')
base = request.args.get('from')
if not base:
baseline = audit_store.get_baseline()
base = baseline['run_id'] if baseline else None
if not other:
latest = audit_store.latest_run()
other = latest['run_id'] if latest else None
if not base or not other:
return jsonify({
"success": False,
"reason": "insufficient_runs",
"message": "Two runs are required to compare",
}), 400
return jsonify({
"success": True,
"from": base,
"to": other,
**audit_checks.compare_runs(base, other),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/baseline', methods=['POST'])
@require_admin_scope
def set_baseline():
if not audit_store:
return _unavailable()
try:
data = request.get_json(silent=True) or {}
run_id = data.get('run_id')
if not run_id or not audit_store.get_run(run_id):
return jsonify({"success": False, "message": "Run not found"}), 404
audit_store.set_baseline(run_id)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions', methods=['GET'])
@require_auth
def list_exceptions():
if not audit_store:
return _unavailable()
try:
return jsonify({
"success": True,
"exceptions": audit_store.all_exceptions(),
"history": audit_store.exception_history(),
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions', methods=['POST'])
@require_admin_scope
def accept_exception():
"""Record a finding as a deliberate decision.
The reason is mandatory. An acceptance without one cannot be
distinguished later from having silenced the check, which is the
outcome this register exists to prevent.
"""
if not audit_store:
return _unavailable()
try:
data = request.get_json(silent=True) or {}
check_id = (data.get('check_id') or '').strip()
reason = (data.get('reason') or '').strip()
if not check_id:
return jsonify({"success": False,
"message": "check_id is required"}), 400
if not reason:
return jsonify({"success": False,
"message": "A reason is required"}), 400
latest = audit_store.latest_run()
if not latest or data.get('run_id') != latest['run_id']:
return jsonify(success=False, message="Reload the latest assessment before accepting a risk"), 409
finding = next((f for f in audit_store.get_findings(latest['run_id']) if f['check_id'] == check_id), None)
if (not finding or finding.get('raw_classification') not in audit_store.CLASS_PROBLEMS or
finding.get('incomplete') or not finding.get('scope')):
return jsonify(success=False, message="This finding cannot be accepted"), 400
expires_at = None
days = data.get('expires_in_days')
if days is not None:
try:
if isinstance(days, bool) or int(days) != float(days) or not 1 <= int(days) <= 3650:
raise ValueError("invalid expiry")
expires_at = int(time.time()) + int(days) * 86400
except (TypeError, ValueError):
return jsonify({"success": False,
"message": "Invalid expiry"}), 400
audit_store.accept_risk(
check_id, reason,
accepted_by=_actor(),
expires_at=expires_at,
scope=finding['scope'],
)
return jsonify({"success": True})
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/exceptions/<path:check_id>', methods=['DELETE'])
@require_admin_scope
def revoke_exception(check_id):
if not audit_store:
return _unavailable()
try:
removed = audit_store.revoke_risk(check_id, _actor())
if not removed:
return jsonify({"success": False,
"message": "Exception not found"}), 404
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/inventory', methods=['GET'])
@require_auth
def inventory():
"""Structural inventory of the node.
Composed from collectors the Monitor already runs; the assessment and
the inventory answer different questions and neither depends on the
other, so this endpoint does not require a run to exist.
"""
if not audit_inventory:
return _unavailable()
try:
profile = request.args.get('profile') or audit_profiles.DEFAULT_PROFILE
if not audit_profiles.is_known(profile):
return jsonify(success=False, message="Unsupported report profile"), 400
ctx = audit_checks.AuditContext()
ctx.begin_check()
inventory = audit_inventory.collect(ctx, sections=audit_profiles.sections(profile))
return jsonify({"success": True, "profile": profile, "inventory": inventory})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/profiles', methods=['GET'])
@require_auth
def profiles():
"""Report profiles this build offers, without touching the host."""
if not audit_profiles:
return _unavailable()
try:
return jsonify({"success": True, "default": audit_profiles.DEFAULT_PROFILE,
"profiles": audit_profiles.describe()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/policy', methods=['GET'])
@require_auth
def policy():
"""The declaration, and what a declaration can say.
The vocabulary travels with the declaration so the interface offers
exactly the expectations and thresholds this build understands,
rather than a list written twice and drifting apart.
"""
if not audit_policy:
return _unavailable()
try:
current = audit_policy.load()
if current.error:
return jsonify(success=False, message=current.error), 422
return jsonify({
"success": True,
"policy": {
"guests": current._guests,
"storages": current._storages,
"defaults": current._defaults,
"thresholds": current._thresholds,
},
"summary": current.describe(),
"vocabulary": {
"expectations": list(audit_policy._EXPECTATIONS),
"roles": list(audit_policy._ROLES),
"thresholds": audit_policy.DEFAULT_THRESHOLDS,
},
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@audit_bp.route('/api/audit/policy', methods=['PUT'])
@require_admin_scope
def save_policy():
"""Replace the declaration.
Validation is the store's, not this endpoint's: a declaration that
cannot be understood is refused with the reason rather than written
and reinterpreted later.
"""
if not audit_policy:
return _unavailable()
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify(success=False, message="A policy object is required"), 400
revision = payload.get("expected_revision")
if not isinstance(revision, str) or not revision:
return jsonify(success=False, message="A policy revision is required"), 428
try:
saved = audit_policy.save(payload, expected_revision=revision)
except audit_policy.PolicyConflict as e:
return jsonify(success=False, message=str(e)), 409
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": True, "summary": saved.describe()})
@audit_bp.route('/api/audit/changes', methods=['GET'])
@require_auth
def changes():
"""What ProxMenux changed on this host, and what was there before.
The diff of each configuration change travels with it: a function may
run to hundreds of lines and alter two values, and it is the two
values the reader is owed.
"""
if not changes_journal:
return _unavailable()
try:
limit = min(int(request.args.get('limit', 200)), 1000)
entries = changes_journal.changes(
limit=limit,
offset=int(request.args.get('offset', 0)),
function=request.args.get('function', ''),
klass=request.args.get('class', ''),
)
for entry in entries:
entry["diff"] = changes_journal.diff_of(entry)
return jsonify({"success": True, "changes": entries,
"summary": changes_journal.summary()})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500