Focus the Audit & Report tab, scope API tokens

This commit is contained in:
MacRimi
2026-09-12 00:22:14 +02:00
parent f12ca3ed27
commit 0b64549230
25 changed files with 683 additions and 146 deletions
+38 -8
View File
@@ -111,7 +111,7 @@ def registered_checks() -> list[Check]:
class AuditContext:
"""Lazily collects each source once and shares it across checks."""
def __init__(self):
def __init__(self, run_lynis: bool = False):
self._cache: dict[str, Any] = {}
self._source_info = {}
self._dependencies = {}
@@ -119,6 +119,10 @@ class AuditContext:
self._errors = {}
self._check_deadline = float("inf")
self._run_deadline = time.monotonic() + RUN_TIMEOUT
# Whether this assessment may launch Lynis. The user grants it in
# the run dialog; without it the audit reads a stored report and
# never starts one, so a run is fast and predictable.
self._run_lynis_allowed = run_lynis
def begin_check(self, budget: int = CHECK_TIMEOUT):
self._sources_used = set()
@@ -332,14 +336,39 @@ class AuditContext:
user launched from that page is waited on rather than duplicated.
"""
def load():
from security_manager import parse_lynis_report
from security_manager import parse_lynis_report, _find_lynis_cmd
parsed = parse_lynis_report(enrich_current=False)
ran, run_error = False, None
if parsed is None or not parsed.get("is_complete"):
produced, ran, run_error = self._run_lynis()
if produced is not None:
parsed = produced
# Launch Lynis only when the assessment was granted permission
# (the user chose "with Lynis"). Then run it if there is no
# usable report, or refresh a stored one that is past the
# staleness threshold, since running is precisely what the
# user consented to.
if self._run_lynis_allowed:
need_run = parsed is None or not parsed.get("is_complete")
if not need_run:
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 = (time.time() - src.stat().st_mtime) / 86400
need_run = age_days >= self.policy.threshold("lynis_report_days")
if need_run:
produced, ran, run_error = self._run_lynis()
if produced is not None:
parsed = produced
if parsed is None:
# No stored report and none produced. Where Lynis is
# installed the check should say it was not run rather than
# that it does not apply, so the reader knows a reading is
# available on request.
if not self._run_lynis_allowed and _find_lynis_cmd():
return {"mtime": 0, "source": "", "version": None,
"warnings": [], "suggestions": [],
"hardening_index": None, "complete": False,
"produced_here": False,
"run_error": "Lynis is installed but was not run "
"for this assessment."}
return None
source = next((p for p in (Path("/var/log/lynis-report.dat"),
Path("/var/log/lynis-output.log")) if p.exists()), None)
@@ -534,7 +563,8 @@ def _classification_of(result: dict, check: "Check") -> str:
def run_assessment(profile: str = "full",
only_areas: Optional[set[str]] = None, *, run_id=None, progress=None) -> str:
only_areas: Optional[set[str]] = None, *, run_id=None,
progress=None, run_lynis: bool = False) -> str:
"""Evaluate every registered check and persist the result.
A check that raises is recorded as unverified with the error kept
@@ -551,7 +581,7 @@ def run_assessment(profile: str = "full",
checks = audit_profiles.selected_checks(profile, registered_checks())
if only_areas is not None:
checks = [c for c in checks if c.area in only_areas]
ctx = AuditContext()
ctx = AuditContext(run_lynis=run_lynis)
exceptions = audit_store.active_exceptions()
metadata = ctx.metadata(checks)
if run_id is None:
+4 -1
View File
@@ -3080,7 +3080,10 @@ def _host_recovery(ctx):
"summary_key": "scheduledOnly",
"summary_params": {"count": str(len(scheduled))},
"evidence": evidence}
return {"classification": CLASS_WARNING, "summary_key": "noHostBackup",
# A host-configuration backup is a ProxMenux feature the operator
# may simply not have set up; its absence is not a fault of the
# host. Reported as an observation, not a warning.
return {"classification": CLASS_OBSERVATION, "summary_key": "noHostBackup",
"evidence": evidence}
affected = []
+4
View File
@@ -578,6 +578,10 @@ def list_api_tokens():
entry = {
"id": t.get("id"),
"name": t.get("name", "API Token"),
# Tokens issued before scope existed carry no claim and verify
# as full_admin, so the list reflects that rather than the
# read-only default a new token gets.
"scope": t.get("scope", "full_admin"),
"token_prefix": t.get("token_prefix", "***"),
"created_at": t.get("created_at"),
"expires_at": t.get("expires_at"),
+39 -1
View File
@@ -145,6 +145,10 @@ def run():
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']:
@@ -159,7 +163,8 @@ def run():
def worker():
try:
audit_checks.run_assessment(profile, only_areas=only, run_id=run_id, progress=_progress)
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))
@@ -378,6 +383,39 @@ def profiles():
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():