mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""Persistence layer for Audit & Report.
|
|
|
|
Holds assessment runs, their findings, the accepted-risk register and the
|
|
designated baseline.
|
|
|
|
The store lives in its own database rather than alongside health and
|
|
notification state. An assessment writes every finding of a run in one
|
|
burst and its retention pass deletes whole runs; sharing a file with the
|
|
notification dispatcher — which opens ``BEGIN IMMEDIATE`` transactions on
|
|
every delivered event — would make those two paths contend for the same
|
|
write lock.
|
|
|
|
Findings persist i18n keys, never rendered text. A report exported today
|
|
may be read in a different language than the one active when the
|
|
assessment ran, and the printed document renders from the key at
|
|
presentation time. Evidence is the exception: it is raw command output
|
|
and is stored verbatim.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
DB_PATH = Path("/usr/local/share/proxmenux/audit.db")
|
|
|
|
# Result of a check within one run. Severity is what the check declares
|
|
# for a failure; state is what actually happened this time.
|
|
STATE_FAIL = "fail"
|
|
STATE_WARN = "warn"
|
|
STATE_PASS = "pass"
|
|
STATE_NOT_APPLICABLE = "not_applicable"
|
|
STATE_ACCEPTED = "accepted"
|
|
|
|
RUN_RUNNING = "running"
|
|
RUN_COMPLETE = "complete"
|
|
RUN_FAILED = "failed"
|
|
|
|
_schema_lock = threading.Lock()
|
|
_schema_ready = False
|
|
|
|
|
|
def _connect() -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
|
|
def init_db() -> None:
|
|
"""Create the schema. Safe to call repeatedly."""
|
|
global _schema_ready
|
|
with _schema_lock:
|
|
if _schema_ready:
|
|
return
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = _connect()
|
|
try:
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS audit_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
profile TEXT NOT NULL,
|
|
started_at INTEGER NOT NULL,
|
|
finished_at INTEGER,
|
|
status TEXT NOT NULL,
|
|
error TEXT,
|
|
is_baseline INTEGER NOT NULL DEFAULT 0,
|
|
checks_total INTEGER NOT NULL DEFAULT 0,
|
|
schema_version INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
|
|
-- summary_key names a translation entry and summary_params
|
|
-- carries its placeholders. Storing a rendered sentence
|
|
-- instead would freeze a finding in whichever language was
|
|
-- active when the assessment ran, and a report exported
|
|
-- today may well be read in another one.
|
|
CREATE TABLE IF NOT EXISTS audit_findings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id TEXT NOT NULL,
|
|
check_id TEXT NOT NULL,
|
|
area TEXT NOT NULL,
|
|
severity TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
summary_key TEXT,
|
|
summary_params TEXT,
|
|
affected TEXT,
|
|
evidence TEXT,
|
|
remediable_by TEXT,
|
|
FOREIGN KEY (run_id) REFERENCES audit_runs(run_id)
|
|
ON DELETE CASCADE
|
|
);
|
|
|
|
-- Accepted risks outlive the run that surfaced them, so they
|
|
-- are keyed by check rather than by finding. expires_at NULL
|
|
-- means the acceptance does not lapse on its own.
|
|
CREATE TABLE IF NOT EXISTS audit_exceptions (
|
|
check_id TEXT PRIMARY KEY,
|
|
reason TEXT NOT NULL,
|
|
accepted_by TEXT NOT NULL,
|
|
accepted_at INTEGER NOT NULL,
|
|
expires_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_findings_run
|
|
ON audit_findings(run_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_findings_check
|
|
ON audit_findings(check_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_runs_started
|
|
ON audit_runs(started_at);
|
|
""")
|
|
conn.commit()
|
|
_schema_ready = True
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def start_run(profile: str) -> str:
|
|
"""Open a run and return its identifier."""
|
|
init_db()
|
|
run_id = uuid.uuid4().hex[:16]
|
|
conn = _connect()
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO audit_runs (run_id, profile, started_at, status) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(run_id, profile, int(time.time()), RUN_RUNNING),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return run_id
|
|
|
|
|
|
def finish_run(run_id: str, *, checks_total: int,
|
|
error: Optional[str] = None) -> None:
|
|
"""Close a run, marking it failed when an error is supplied."""
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.execute(
|
|
"UPDATE audit_runs SET finished_at = ?, status = ?, error = ?, "
|
|
"checks_total = ? WHERE run_id = ?",
|
|
(int(time.time()), RUN_FAILED if error else RUN_COMPLETE,
|
|
error, checks_total, run_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_run(run_id: str) -> Optional[dict[str, Any]]:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM audit_runs WHERE run_id = ?", (run_id,)
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def list_runs(limit: int = 20) -> list[dict[str, Any]]:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM audit_runs ORDER BY started_at DESC LIMIT ?",
|
|
(limit,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def latest_run(status: str = RUN_COMPLETE) -> Optional[dict[str, Any]]:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM audit_runs WHERE status = ? "
|
|
"ORDER BY started_at DESC LIMIT 1",
|
|
(status,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Findings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def record_findings(run_id: str, findings: list[dict[str, Any]]) -> int:
|
|
"""Write a run's findings in a single transaction.
|
|
|
|
``affected`` is stored as JSON so a check that covers several objects
|
|
keeps the per-object detail without emitting one finding per object.
|
|
"""
|
|
init_db()
|
|
if not findings:
|
|
return 0
|
|
rows = [
|
|
(
|
|
run_id,
|
|
f["check_id"],
|
|
f["area"],
|
|
f["severity"],
|
|
f["state"],
|
|
f.get("summary_key"),
|
|
json.dumps(f.get("summary_params") or {}, ensure_ascii=False),
|
|
json.dumps(f.get("affected") or [], ensure_ascii=False),
|
|
f.get("evidence"),
|
|
f.get("remediable_by"),
|
|
)
|
|
for f in findings
|
|
]
|
|
conn = _connect()
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.executemany(
|
|
"INSERT INTO audit_findings (run_id, check_id, area, severity, "
|
|
"state, summary_key, summary_params, affected, evidence, "
|
|
"remediable_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return len(rows)
|
|
|
|
|
|
def get_findings(run_id: str) -> list[dict[str, Any]]:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM audit_findings WHERE run_id = ? ORDER BY id",
|
|
(run_id,),
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
item = dict(r)
|
|
try:
|
|
item["affected"] = json.loads(item.get("affected") or "[]")
|
|
except (TypeError, ValueError):
|
|
item["affected"] = []
|
|
try:
|
|
item["summary_params"] = json.loads(
|
|
item.get("summary_params") or "{}")
|
|
except (TypeError, ValueError):
|
|
item["summary_params"] = {}
|
|
out.append(item)
|
|
return out
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]:
|
|
"""Return how one check resolved across recent runs."""
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT f.state, f.severity, r.run_id, r.started_at "
|
|
"FROM audit_findings f JOIN audit_runs r ON r.run_id = f.run_id "
|
|
"WHERE f.check_id = ? ORDER BY r.started_at DESC LIMIT ?",
|
|
(check_id, limit),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Accepted risks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def accept_risk(check_id: str, reason: str, accepted_by: str,
|
|
expires_at: Optional[int] = None) -> None:
|
|
"""Record a deliberate decision to leave a finding unresolved.
|
|
|
|
A reason is mandatory: an acceptance without one is indistinguishable
|
|
from having silenced the check, which is what this register exists to
|
|
prevent.
|
|
"""
|
|
if not (reason or "").strip():
|
|
raise ValueError("an accepted risk requires a reason")
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO audit_exceptions "
|
|
"(check_id, reason, accepted_by, accepted_at, expires_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(check_id, reason.strip(), accepted_by, int(time.time()),
|
|
expires_at),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def revoke_risk(check_id: str) -> bool:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
cur = conn.execute(
|
|
"DELETE FROM audit_exceptions WHERE check_id = ?", (check_id,)
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def active_exceptions() -> dict[str, dict[str, Any]]:
|
|
"""Return accepted risks that have not lapsed, keyed by check.
|
|
|
|
Lapsed entries are left on disk so the decision remains auditable;
|
|
they simply stop suppressing the finding.
|
|
"""
|
|
init_db()
|
|
now = int(time.time())
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM audit_exceptions "
|
|
"WHERE expires_at IS NULL OR expires_at > ?",
|
|
(now,),
|
|
).fetchall()
|
|
return {r["check_id"]: dict(r) for r in rows}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def all_exceptions() -> list[dict[str, Any]]:
|
|
init_db()
|
|
now = int(time.time())
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT * FROM audit_exceptions ORDER BY accepted_at DESC"
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
item = dict(r)
|
|
item["lapsed"] = bool(
|
|
item["expires_at"] is not None and item["expires_at"] <= now
|
|
)
|
|
out.append(item)
|
|
return out
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Baseline and retention
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def set_baseline(run_id: str) -> None:
|
|
"""Designate a run as the reference to compare later runs against."""
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.execute("UPDATE audit_runs SET is_baseline = 0")
|
|
conn.execute(
|
|
"UPDATE audit_runs SET is_baseline = 1 WHERE run_id = ?", (run_id,)
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_baseline() -> Optional[dict[str, Any]]:
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT * FROM audit_runs WHERE is_baseline = 1 LIMIT 1"
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def prune_runs(keep: int = 30) -> int:
|
|
"""Drop the oldest runs beyond ``keep``.
|
|
|
|
The baseline is never pruned: it is the reference every comparison is
|
|
measured against and losing it silently would break that comparison
|
|
long after the run that produced it was forgotten.
|
|
"""
|
|
init_db()
|
|
conn = _connect()
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
cur = conn.execute(
|
|
"DELETE FROM audit_runs WHERE is_baseline = 0 AND run_id NOT IN ("
|
|
" SELECT run_id FROM audit_runs "
|
|
" WHERE is_baseline = 0 ORDER BY started_at DESC LIMIT ?"
|
|
")",
|
|
(keep,),
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount
|
|
finally:
|
|
conn.close()
|