mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
506 lines
19 KiB
Python
506 lines
19 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
|
|
# The caller consents to Lynis running as part of the assessment; the
|
|
# interface asks the user before setting it, since a run can take a few
|
|
# minutes. Absent or false, the audit reads any stored report instead.
|
|
run_lynis = bool(data.get('run_lynis'))
|
|
|
|
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, run_lynis=run_lynis)
|
|
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/lynis-readiness', methods=['GET'])
|
|
@require_auth
|
|
def lynis_readiness():
|
|
"""Whether a security assessment would need to run Lynis.
|
|
|
|
The interface reads this before starting a run whose profile includes
|
|
the Lynis check, so it can ask the user whether to run the audit
|
|
(which takes a few minutes) or reuse a stored report. Touches no host
|
|
state beyond reading the existing report file's age.
|
|
"""
|
|
try:
|
|
import security_manager
|
|
from pathlib import Path
|
|
installed = bool(security_manager._find_lynis_cmd())
|
|
parsed = (security_manager.parse_lynis_report(enrich_current=False)
|
|
if installed else None)
|
|
complete = bool(parsed and parsed.get("is_complete"))
|
|
age_days = None
|
|
if complete:
|
|
src = next((p for p in (Path("/var/log/lynis-report.dat"),
|
|
Path("/var/log/lynis-output.log"))
|
|
if p.exists()), None)
|
|
if src:
|
|
age_days = round((time.time() - src.stat().st_mtime) / 86400, 1)
|
|
limit = audit_policy.load().threshold("lynis_report_days") if audit_policy else 30
|
|
return jsonify({"success": True, "installed": installed,
|
|
"has_report": complete, "age_days": age_days,
|
|
"stale_days": limit,
|
|
"stale": age_days is not None and age_days >= limit})
|
|
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
|