Files
ProxMenux/AppImage/scripts/audit_store.py
T

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()