mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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>
468 lines
17 KiB
Python
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
|