mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-16 19:56:47 +00:00
586 lines
31 KiB
Python
586 lines
31 KiB
Python
"""Audit regression fixtures. No host probes, daemon or production database."""
|
|||
|
|
import json
|
||
|
|
import copy
|
||
|
|
import sqlite3
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
|
||
|
|
import audit_checks as engine
|
||
|
|
import audit_checks_pve as checks
|
||
|
|
import audit_store as store
|
||
|
|
import audit_policy
|
||
|
|
|
||
|
|
|
||
|
|
def evaluate(fn, ctx):
|
||
|
|
result = fn(ctx)
|
||
|
|
if result is not None:
|
||
|
|
check = next(c for c in engine.registered_checks() if c.evaluate == fn)
|
||
|
|
result = {**result, "classification": engine._classification_of(result, check)}
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
HEADER = "Volid Format Type Size VMID\n"
|
||
|
|
|
||
|
|
|
||
|
|
class Context:
|
||
|
|
node = "fixture"
|
||
|
|
lxc_configs = {101: "hostname: one\nunprivileged: 1\n", 102: "hostname: two\nunprivileged: 1\n"}
|
||
|
|
qemu_configs = {}
|
||
|
|
cluster_configs = {}
|
||
|
|
vzdump_jobs = "vzdump: daily\n all 1\n schedule daily\n storage backups\n"
|
||
|
|
pve_user_cfg = ""
|
||
|
|
storages = [{"id": "backups", "type": "dir", "content": "backup"}]
|
||
|
|
apt_sources = {}
|
||
|
|
monitor_snapshot = {}
|
||
|
|
storage_snapshot = {"rows": [], "source": "fixture", "collected_at": 123, "units": "bytes"}
|
||
|
|
|
||
|
|
def __init__(self, **values):
|
||
|
|
for key, value in type(self).__dict__.items():
|
||
|
|
if not key.startswith("_") and not callable(value):
|
||
|
|
setattr(self, key, copy.deepcopy(value))
|
||
|
|
self.policy = audit_policy.Policy()
|
||
|
|
self.responses = {}
|
||
|
|
self.files = {}
|
||
|
|
self.__dict__.update(values)
|
||
|
|
|
||
|
|
def run(self, argv, **kwargs):
|
||
|
|
if tuple(argv) in self.responses:
|
||
|
|
return self.responses[tuple(argv)]
|
||
|
|
if argv[:2] == ["pvesm", "list"]:
|
||
|
|
return (0, HEADER)
|
||
|
|
if argv == ["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"]:
|
||
|
|
return (0, "[]")
|
||
|
|
raise AssertionError(f"Unmocked probe: {argv}")
|
||
|
|
|
||
|
|
def read(self, path, **kwargs):
|
||
|
|
return self.files.get(str(path), "")
|
||
|
|
|
||
|
|
|
||
|
|
class CheckTests(unittest.TestCase):
|
||
|
|
def backup(self, vmid=101, age=0):
|
||
|
|
stamp = time.strftime("%Y_%m_%d-%H_%M_%S", time.localtime(time.time() - age))
|
||
|
|
return f"backups:backup/vzdump-lxc-{vmid}-{stamp}.tar.zst zst backup 1024 {vmid}\n"
|
||
|
|
|
||
|
|
def test_missing_second_backup_is_not_pass(self):
|
||
|
|
ctx = Context(responses={("pvesm", "list", "backups"): (0, HEADER + self.backup())})
|
||
|
|
result = evaluate(checks._last_backup_age, ctx)
|
||
|
|
self.assertEqual(result["classification"], "warning")
|
||
|
|
self.assertEqual(result["affected"][0]["vmid"], 102)
|
||
|
|
|
||
|
|
def test_no_backups_is_not_not_applicable(self):
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, Context())["classification"], "warning")
|
||
|
|
|
||
|
|
def test_no_storage_is_missing_backup(self):
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, Context(storages=[]))["classification"], "warning")
|
||
|
|
|
||
|
|
def test_unreadable_destination_is_unknown_not_missing(self):
|
||
|
|
ctx = Context(responses={("pvesm", "list", "backups"): (1, "offline")})
|
||
|
|
result = evaluate(checks._last_backup_age, ctx)
|
||
|
|
self.assertEqual(result["classification"], "unverified")
|
||
|
|
self.assertEqual(result.get("affected", []), [])
|
||
|
|
|
||
|
|
def test_bad_inventory_is_unknown(self):
|
||
|
|
ctx = Context(responses={("pvesm", "list", "backups"): (0, "unexpected")})
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "unverified")
|
||
|
|
|
||
|
|
def test_mixed_storage_host_archives_and_isos_not_guest_copies(self):
|
||
|
|
ctx = Context(lxc_configs={101: ""}, responses={
|
||
|
|
("pvesm", "list", "backups"): (0, HEADER + self.backup() +
|
||
|
|
"backups:iso/install.iso iso iso 100\n" +
|
||
|
|
"backups:backup/hostcfg-daily-20260905_000000.tar.zst tar.zst backup 200\n")})
|
||
|
|
result = evaluate(checks._last_backup_age, ctx)
|
||
|
|
self.assertEqual(result["classification"], "conformant")
|
||
|
|
self.assertFalse(result["incomplete"])
|
||
|
|
self.assertIn("not counted", result["evidence"])
|
||
|
|
|
||
|
|
def test_backup_without_vmid_column_can_use_vzdump_identity(self):
|
||
|
|
ctx = Context(lxc_configs={101: ""}, responses={
|
||
|
|
("pvesm", "list", "backups"): (0, HEADER + self.backup().rsplit(" ", 1)[0] + "\n")})
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_daily_job_older_than_two_days_is_stale(self):
|
||
|
|
ctx = Context(lxc_configs={101: ""}, responses={
|
||
|
|
("pvesm", "list", "backups"): (0, HEADER + self.backup(age=2 * 86400))})
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "warning")
|
||
|
|
|
||
|
|
def test_unsupported_schedule_is_explicit(self):
|
||
|
|
self.assertIsNone(checks._schedule_age_limit("mon..fri */2:00"))
|
||
|
|
self.assertIsNone(checks._schedule_age_limit("99:99"))
|
||
|
|
|
||
|
|
def test_job_on_other_node_does_not_cover_local_guest(self):
|
||
|
|
ctx = Context(vzdump_jobs="vzdump: remote\n all 1\n node other\n")
|
||
|
|
self.assertEqual(evaluate(checks._guest_coverage, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_disabled_destination_is_not_probed(self):
|
||
|
|
ctx = Context(storages=[{"id": "disabled", "type": "dir", "content": "backup", "disable": "1"}])
|
||
|
|
self.assertIsNone(evaluate(checks._destination_reachable, ctx))
|
||
|
|
|
||
|
|
def test_all_storage_types_and_dependencies(self):
|
||
|
|
storages = [{"id": t, "type": t, "content": "images"} for t in
|
||
|
|
("dir", "zfspool", "lvmthin", "nfs", "cifs", "iscsi", "pbs")]
|
||
|
|
ctx = Context(storages=storages, lxc_configs={101: "rootfs: nfs:101/disk.raw\n"},
|
||
|
|
qemu_configs={200: "scsi0: iscsi:volume\n[old]\nscsi1: cifs:old\n"},
|
||
|
|
storage_snapshot={"rows": [{"name": s["id"], "node": "fixture",
|
||
|
|
"status": "available", "total": 100, "used": 20} for s in storages]})
|
||
|
|
ctx.run = lambda argv, **kw: (0, "[]") if argv == ["pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json"] else self.fail("storage check must not probe remote storage")
|
||
|
|
result = evaluate(checks._destination_reachable, ctx)
|
||
|
|
self.assertEqual(result["classification"], "conformant")
|
||
|
|
self.assertEqual(result["summary_params"]["total"], 7)
|
||
|
|
rows = {r["storage"]: r for r in result["observations"]}
|
||
|
|
self.assertEqual(rows["nfs"]["dependencies"][0]["vmid"], 101)
|
||
|
|
self.assertEqual(rows["iscsi"]["dependencies"][0]["vmid"], 200)
|
||
|
|
self.assertEqual(rows["cifs"]["dependencies"], [])
|
||
|
|
|
||
|
|
def test_storage_missing_metadata_is_unknown(self):
|
||
|
|
self.assertEqual(evaluate(checks._destination_reachable, Context())["classification"], "unverified")
|
||
|
|
|
||
|
|
def test_cached_missing_resource_is_not_confirmed_outage(self):
|
||
|
|
ctx = Context(storage_snapshot={"rows": [{"name": "backups", "node": "fixture",
|
||
|
|
"status": "error", "status_detail": "not_found"}]})
|
||
|
|
self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "unverified")
|
||
|
|
|
||
|
|
def test_pbs_unknown_capacity_not_full_or_failed(self):
|
||
|
|
ctx = Context(storage_snapshot={"rows": [{"name": "backups", "node": "fixture",
|
||
|
|
"status": "namespace_restricted", "total": 0, "used": 0}]})
|
||
|
|
result = evaluate(checks._destination_reachable, ctx)
|
||
|
|
self.assertEqual(result["classification"], "conformant")
|
||
|
|
self.assertFalse(result["observations"][0]["capacity_known"])
|
||
|
|
|
||
|
|
def test_unavailable_and_full_network_storage(self):
|
||
|
|
ctx = Context(storages=[{"id": "nas", "type": "nfs", "content": "images"}],
|
||
|
|
storage_snapshot={"rows": [{"name": "nas", "node": "fixture",
|
||
|
|
"status": "available", "total": 100, "used": 95}]})
|
||
|
|
self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "warning")
|
||
|
|
ctx.storage_snapshot["rows"][0]["status"] = "unavailable"
|
||
|
|
self.assertEqual(evaluate(checks._destination_reachable, ctx)["classification"], "warning")
|
||
|
|
|
||
|
|
def test_storage_credentials_not_in_evidence(self):
|
||
|
|
ctx = Context(storages=[{"id": "nas", "type": "cifs", "password": "SECRET",
|
||
|
|
"username": "PRIVATE", "content": "images"}])
|
||
|
|
result = evaluate(checks._destination_reachable, ctx)
|
||
|
|
self.assertNotIn("SECRET", result["evidence"])
|
||
|
|
self.assertNotIn("PRIVATE", result["evidence"])
|
||
|
|
|
||
|
|
def test_other_node_storage_never_assessed(self):
|
||
|
|
ctx = Context(storages=[{"id": "remote", "type": "pbs", "nodes": "other"}])
|
||
|
|
self.assertIsNone(evaluate(checks._destination_reachable, ctx))
|
||
|
|
|
||
|
|
def two_destinations(self):
|
||
|
|
return Context(lxc_configs={101: ""}, storages=[
|
||
|
|
{"id": name, "type": "pbs", "content": "backup"} for name in ("backups", "second")],
|
||
|
|
vzdump_jobs="vzdump: first\n all 1\n storage backups\n schedule daily\nvzdump: second\n all 1\n storage second\n schedule weekly\n",
|
||
|
|
responses={("pvesm", "list", "backups"): (0, HEADER + self.backup())})
|
||
|
|
|
||
|
|
def test_recent_pbs_cannot_mask_missing_second_destination(self):
|
||
|
|
result = evaluate(checks._last_backup_age, self.two_destinations())
|
||
|
|
self.assertEqual(result["classification"], "warning")
|
||
|
|
self.assertEqual(result["affected"][0]["storage"], "second")
|
||
|
|
self.assertEqual(result["summary_params"]["total"], 2)
|
||
|
|
|
||
|
|
def test_second_pbs_failure_does_not_claim_missing_copy(self):
|
||
|
|
ctx = self.two_destinations()
|
||
|
|
ctx.responses[("pvesm", "list", "second")] = (1, "offline")
|
||
|
|
result = evaluate(checks._last_backup_age, ctx)
|
||
|
|
self.assertEqual(result["classification"], "unverified")
|
||
|
|
self.assertEqual(result.get("affected", []), [])
|
||
|
|
|
||
|
|
def test_each_destination_has_own_schedule(self):
|
||
|
|
ctx = self.two_destinations()
|
||
|
|
ctx.responses[("pvesm", "list", "second")] = (0, HEADER + self.backup(age=3 * 86400))
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["classification"], "conformant")
|
||
|
|
ctx.responses[("pvesm", "list", "second")] = (0, HEADER + self.backup(age=11 * 86400))
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["affected"][0]["storage"], "second")
|
||
|
|
|
||
|
|
def test_missing_target_configuration_is_reported(self):
|
||
|
|
ctx = self.two_destinations()
|
||
|
|
ctx.storages = ctx.storages[:1]
|
||
|
|
self.assertEqual(evaluate(checks._last_backup_age, ctx)["affected"][0]["storage"], "second")
|
||
|
|
|
||
|
|
def test_failure_elsewhere_does_not_hide_missing_expected_copy(self):
|
||
|
|
ctx = self.two_destinations()
|
||
|
|
ctx.responses[("pvesm", "list", "backups")] = (1, "offline")
|
||
|
|
result = evaluate(checks._last_backup_age, ctx)
|
||
|
|
self.assertEqual(result["classification"], "warning")
|
||
|
|
self.assertTrue(result["incomplete"])
|
||
|
|
self.assertEqual(result["affected"][0]["storage"], "second")
|
||
|
|
|
||
|
|
def test_templates_are_not_missing_backups(self):
|
||
|
|
ctx = Context(lxc_configs={101: "template: 1\n"})
|
||
|
|
self.assertIsNone(evaluate(checks._guest_coverage, ctx))
|
||
|
|
self.assertIsNone(evaluate(checks._last_backup_age, ctx))
|
||
|
|
|
||
|
|
def test_bind_mount_and_backup_zero_are_reported(self):
|
||
|
|
ctx = Context(lxc_configs={101: "rootfs: local:vm-101-disk-0\nmp0: /data,mp=/data,backup=1\nmp1: local:vm-101-disk-1,mp=/x,backup=0\n"})
|
||
|
|
result = evaluate(checks._guest_coverage, ctx)
|
||
|
|
self.assertEqual(result["summary_key"], "excludedData")
|
||
|
|
self.assertEqual(len(result["affected"]), 2)
|
||
|
|
|
||
|
|
def test_old_snapshot_cannot_override_current_privileged_state(self):
|
||
|
|
ctx = Context(lxc_configs={101: "unprivileged: 0\n[old]\nunprivileged: 1\n"})
|
||
|
|
self.assertEqual(evaluate(checks._privileged_containers, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_agent_options_order(self):
|
||
|
|
ctx = Context(qemu_configs={101: "agent: fstrim_cloned_disks=1,enabled=1\n"})
|
||
|
|
self.assertEqual(evaluate(checks._qemu_without_agent, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_disabled_deb822_is_not_enterprise_enabled(self):
|
||
|
|
ctx = Context(apt_sources={"pve.sources": "Types: deb\nURIs: https://enterprise.proxmox.com/debian/pve\nEnabled: no\n"})
|
||
|
|
self.assertEqual(evaluate(checks._enterprise_repo, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_orphan_inventory_failure_cannot_pass(self):
|
||
|
|
ctx = Context(storages=[{"id": "local", "type": "dir", "content": "images"}],
|
||
|
|
responses={("pvesm", "list", "local"): (1, "offline")})
|
||
|
|
self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "unverified")
|
||
|
|
|
||
|
|
def test_unreferenced_volume_with_existing_vmid_is_candidate(self):
|
||
|
|
ctx = Context(storages=[{"id": "local", "type": "dir", "content": "images"}],
|
||
|
|
responses={("pvesm", "list", "local"): (0, HEADER + "local:101/vm-101-disk-9.raw raw images 1 101\n")})
|
||
|
|
self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_unused_snapshot_and_template_base_are_protected(self):
|
||
|
|
ctx = Context(lxc_configs={}, qemu_configs={101: "unused0: local:vm-101-disk-0\n[old]\nscsi0: local:vm-101-disk-1\n"},
|
||
|
|
storages=[{"id": "local", "type": "lvmthin", "content": "images"}], responses={
|
||
|
|
("pvesm", "list", "local"): (0, HEADER +
|
||
|
|
"local:vm-101-disk-0 raw images 1 101\nlocal:vm-101-disk-1 raw images 1 101\nlocal:base-999-disk-0 raw images 1 999\n")})
|
||
|
|
self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_orphan_on_guestless_host(self):
|
||
|
|
ctx = Context(lxc_configs={}, storages=[{"id": "local", "type": "dir", "content": "images"}],
|
||
|
|
responses={("pvesm", "list", "local"): (0, HEADER + "local:101/vm-101-disk-0.raw raw images 1 101\n")})
|
||
|
|
self.assertEqual(evaluate(checks._orphaned_volumes, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def scrub_context(self, scan):
|
||
|
|
ctx = Context(responses={("zpool", "list", "-H", "-o", "name"): (0, "tank\n"),
|
||
|
|
("zpool", "status", "tank"): (0, " scan: " + scan)})
|
||
|
|
return ctx
|
||
|
|
|
||
|
|
@patch.object(Path, "exists", return_value=True)
|
||
|
|
def test_resilver_is_not_scrub(self, _):
|
||
|
|
ctx = self.scrub_context("resilvered 1G in 1h on " + time.ctime())
|
||
|
|
self.assertEqual(evaluate(checks._zfs_scrub_age, ctx)["classification"], "unverified")
|
||
|
|
|
||
|
|
@patch.object(Path, "exists", return_value=True)
|
||
|
|
def test_one_never_scrubbed_pool_is_not_hidden(self, _):
|
||
|
|
ctx = self.scrub_context("scrub repaired 0B in 1h with 0 errors on " + time.ctime())
|
||
|
|
ctx.responses[("zpool", "list", "-H", "-o", "name")] = (0, "tank\nother\n")
|
||
|
|
ctx.responses[("zpool", "status", "other")] = (0, "scan: none requested")
|
||
|
|
self.assertEqual(evaluate(checks._zfs_scrub_age, ctx)["classification"], "warning")
|
||
|
|
|
||
|
|
def test_storage_inherited_retention(self):
|
||
|
|
ctx = Context(storages=[{"id": "backups", "type": "dir", "content": "backup", "prune-backups": "keep-last=7"}])
|
||
|
|
self.assertEqual(evaluate(checks._retention_defined, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_pbs_remote_retention_is_not_declared_absent(self):
|
||
|
|
ctx = Context(storages=[{"id": "backups", "type": "pbs", "content": "backup"}])
|
||
|
|
self.assertEqual(evaluate(checks._retention_defined, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_firewall_default_host_enable(self):
|
||
|
|
ctx = Context(files={"/etc/pve/firewall/cluster.fw": "[OPTIONS]\nenable: 1\n"})
|
||
|
|
self.assertEqual(evaluate(checks._host_firewall, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_firewall_other_section_not_an_enable_option(self):
|
||
|
|
ctx = Context(files={"/etc/pve/firewall/cluster.fw": "[RULES]\nenable: 1\n"})
|
||
|
|
self.assertEqual(evaluate(checks._host_firewall, ctx)["classification"], "observation")
|
||
|
|
|
||
|
|
def test_no_smart_cache_does_not_start_smartctl(self):
|
||
|
|
ctx = Context()
|
||
|
|
ctx.run = lambda *a, **kw: self.fail("must not run any disk command")
|
||
|
|
self.assertEqual(evaluate(checks._disk_service_life, ctx)["classification"], "unverified")
|
||
|
|
|
||
|
|
def test_ha_managed_guest_does_not_need_onboot(self):
|
||
|
|
ctx = Context(lxc_configs={101: "onboot: 0\n"}, files={"/etc/pve/ha/resources.cfg": "ct: 101\n state started\n"})
|
||
|
|
self.assertEqual(evaluate(checks._autostart, ctx)["classification"], "conformant")
|
||
|
|
|
||
|
|
def test_legacy_jobs_parse_without_execution(self):
|
||
|
|
jobs = checks._parse_vzdump_jobs("0 2 * * * root /usr/sbin/vzdump 101 102 --storage backups --all 0\n")
|
||
|
|
self.assertEqual(jobs[0]["vmid"], "101 102")
|
||
|
|
self.assertEqual(jobs[0]["storage"], "backups")
|
||
|
|
|
||
|
|
def test_another_job_type_ends_vzdump_section(self):
|
||
|
|
jobs = checks._parse_vzdump_jobs("vzdump: a\n all 1\nother: b\n enabled 0\n")
|
||
|
|
self.assertNotIn("enabled", jobs[0])
|
||
|
|
|
||
|
|
|
||
|
|
class StoreTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory()
|
||
|
|
self.db_patch = patch.object(store, "DB_PATH", Path(self.temp.name) / "audit.db")
|
||
|
|
self.db_patch.start()
|
||
|
|
store._schema_ready = False
|
||
|
|
self.addCleanup(self.temp.cleanup)
|
||
|
|
self.addCleanup(self.db_patch.stop)
|
||
|
|
self.addCleanup(lambda: setattr(store, "_schema_ready", False))
|
||
|
|
|
||
|
|
def finding(self, objects=None, state="warn"):
|
||
|
|
f = {"check_id": "guests.test", "area": "guests", "severity": "WARNING", "state": state,
|
||
|
|
"raw_state": state, "classification": store.classification_of(state, "WARNING"),
|
||
|
|
"raw_classification": store.classification_of(state, "WARNING"), "affected": objects or [{"vmid": 101}], "check_version": 2, "host": "fixture"}
|
||
|
|
f["scope"] = store.finding_scope(f)
|
||
|
|
return f
|
||
|
|
|
||
|
|
def recorded(self, f):
|
||
|
|
run = store.start_run("full")
|
||
|
|
store.record_findings(run, [f])
|
||
|
|
store.finish_run(run, checks_total=1)
|
||
|
|
return run
|
||
|
|
|
||
|
|
def test_accept_and_revoke_immediate_without_mutating_history(self):
|
||
|
|
f = self.finding()
|
||
|
|
run = self.recorded(f)
|
||
|
|
store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"])
|
||
|
|
self.assertEqual(store.effective_findings(run)[0]["state"], "accepted")
|
||
|
|
self.assertEqual(store.get_findings(run)[0]["state"], "warn")
|
||
|
|
store.revoke_risk(f["check_id"], "operator")
|
||
|
|
self.assertEqual(store.effective_findings(run)[0]["state"], "warn")
|
||
|
|
self.assertEqual(len(store.exception_history()), 2)
|
||
|
|
|
||
|
|
def test_acceptance_does_not_extend_to_new_guest(self):
|
||
|
|
f = self.finding()
|
||
|
|
self.recorded(f)
|
||
|
|
store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"])
|
||
|
|
newer = self.recorded(self.finding([{"vmid": 101}, {"vmid": 102}]))
|
||
|
|
self.assertEqual(store.effective_findings(newer)[0]["state"], "warn")
|
||
|
|
|
||
|
|
def test_expiry_is_effective_without_new_scan(self):
|
||
|
|
f = self.finding()
|
||
|
|
run = self.recorded(f)
|
||
|
|
expiry = int(time.time()) + 60
|
||
|
|
store.accept_risk(f["check_id"], "lab", "operator", expiry, scope=f["scope"])
|
||
|
|
with patch.object(store.time, "time", return_value=expiry + 1):
|
||
|
|
self.assertEqual(store.effective_findings(run)[0]["state"], "warn")
|
||
|
|
|
||
|
|
def test_accepted_snapshot_survives_revocation(self):
|
||
|
|
f = self.finding()
|
||
|
|
store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"])
|
||
|
|
f.update(decision=store.DECISION_ACCEPTED, exception=store.active_exceptions()[f["check_id"]])
|
||
|
|
run = self.recorded(f)
|
||
|
|
store.revoke_risk(f["check_id"])
|
||
|
|
self.assertEqual(store.get_findings(run)[0]["exception"]["reason"], "lab")
|
||
|
|
self.assertEqual(store.effective_findings(run)[0]["state"], "warn")
|
||
|
|
|
||
|
|
def test_scope_ignores_age_but_not_rule_or_severity(self):
|
||
|
|
a = self.finding([{"vmid": 101, "days": 40}])
|
||
|
|
b = self.finding([{"vmid": 101, "days": 41}])
|
||
|
|
self.assertEqual(a["scope"], b["scope"])
|
||
|
|
b["check_version"] = 3
|
||
|
|
self.assertNotEqual(a["scope"], store.finding_scope(b))
|
||
|
|
|
||
|
|
def test_new_failure_visible_instead_of_old_complete_run(self):
|
||
|
|
self.recorded(self.finding())
|
||
|
|
run = store.start_run("full")
|
||
|
|
store.finish_run(run, checks_total=0, error="interrupted")
|
||
|
|
self.assertEqual(store.latest_run()["run_id"], run)
|
||
|
|
|
||
|
|
def test_no_false_resolved_when_collection_failed(self):
|
||
|
|
before = self.recorded(self.finding())
|
||
|
|
after = self.recorded(self.finding(state="unknown"))
|
||
|
|
result = engine.compare_runs(before, after)
|
||
|
|
self.assertEqual(result["resolved"], [])
|
||
|
|
self.assertEqual(len(result["unverified"]), 1)
|
||
|
|
|
||
|
|
def test_acceptance_is_not_resolution_and_class_changes_are_visible(self):
|
||
|
|
before = self.recorded(self.finding())
|
||
|
|
accepted = self.finding()
|
||
|
|
accepted['decision'] = store.DECISION_ACCEPTED
|
||
|
|
after = self.recorded(accepted)
|
||
|
|
result = engine.compare_runs(before, after)
|
||
|
|
self.assertEqual(len(result['accepted']), 1)
|
||
|
|
self.assertFalse(result['resolved'])
|
||
|
|
critical = self.finding()
|
||
|
|
critical.update(classification='critical', raw_classification='critical')
|
||
|
|
changed = self.recorded(critical)
|
||
|
|
self.assertEqual(len(engine.compare_runs(before, changed)['new']), 1)
|
||
|
|
self.assertEqual(len(engine.compare_runs(changed, before)['new']), 1)
|
||
|
|
|
||
|
|
def test_malformed_result_does_not_abort_remaining_checks(self):
|
||
|
|
for bad in (False, [], {'affected': [None]}, {'affected': 'invalid'}):
|
||
|
|
with self.subTest(result=bad), patch.object(engine.AuditContext, 'metadata', return_value={}), patch.object(
|
||
|
|
engine, 'registered_checks', return_value=[
|
||
|
|
engine.Check('guests.bad', 'guests', 'WARNING', lambda ctx: bad),
|
||
|
|
engine.Check('guests.good', 'guests', 'WARNING', lambda ctx: {'classification':'conformant'})]):
|
||
|
|
run = engine.run_assessment()
|
||
|
|
results = {f['check_id']: f['classification'] for f in store.get_findings(run)}
|
||
|
|
self.assertEqual(results, {'guests.bad':'unverified', 'guests.good':'conformant'})
|
||
|
|
self.assertEqual(store.get_run(run)['status'], 'partial')
|
||
|
|
|
||
|
|
def test_interrupted_run_marked_failed(self):
|
||
|
|
run = store.start_run("full")
|
||
|
|
store.recover_interrupted_runs()
|
||
|
|
self.assertEqual(store.get_run(run)["status"], "failed")
|
||
|
|
|
||
|
|
def test_secrets_are_redacted_before_persistence(self):
|
||
|
|
f = self.finding()
|
||
|
|
f["evidence"] = "https://alice:secret@example.com/x?token=abc\nAuthorization: Bearer xyz"
|
||
|
|
run = self.recorded(f)
|
||
|
|
evidence = store.get_findings(run)[0]["evidence"]
|
||
|
|
self.assertNotIn("alice", evidence)
|
||
|
|
self.assertNotIn("abc", evidence)
|
||
|
|
self.assertNotIn("xyz", evidence)
|
||
|
|
|
||
|
|
def test_raising_check_is_unknown_and_run_partial(self):
|
||
|
|
def bad(ctx):
|
||
|
|
raise OSError("fixture source offline")
|
||
|
|
with patch.object(engine.AuditContext, "metadata", return_value={}), patch.object(engine, "registered_checks", return_value=[
|
||
|
|
engine.Check("guests.test", "guests", "WARNING", bad)]):
|
||
|
|
run = engine.run_assessment()
|
||
|
|
self.assertEqual(store.get_findings(run)[0]["state"], "unknown")
|
||
|
|
self.assertEqual(store.get_run(run)["status"], "partial")
|
||
|
|
|
||
|
|
def test_failed_command_cannot_become_pass(self):
|
||
|
|
def bad(ctx):
|
||
|
|
ctx.run(["fixture-command"])
|
||
|
|
return {"state": "pass"}
|
||
|
|
with patch.object(engine.AuditContext, "metadata", return_value={}), patch.object(engine.subprocess, "run", return_value=SimpleNamespace(
|
||
|
|
returncode=1, stdout="", stderr="failed")), patch.object(engine, "registered_checks", return_value=[
|
||
|
|
engine.Check("guests.test", "guests", "WARNING", bad)]):
|
||
|
|
run = engine.run_assessment()
|
||
|
|
self.assertEqual(store.get_findings(run)[0]["state"], "unknown")
|
||
|
|
|
||
|
|
def test_v1_migration_preserves_legacy_decisions_without_reusing_scope(self):
|
||
|
|
connection = sqlite3.connect(store.DB_PATH)
|
||
|
|
connection.executescript("""
|
||
|
|
CREATE TABLE audit_runs (run_id TEXT PRIMARY KEY, profile TEXT, started_at INTEGER,
|
||
|
|
finished_at INTEGER, status TEXT, error TEXT, is_baseline INTEGER DEFAULT 0,
|
||
|
|
checks_total INTEGER DEFAULT 0, schema_version INTEGER DEFAULT 1);
|
||
|
|
CREATE TABLE audit_findings (id INTEGER PRIMARY KEY, run_id TEXT, check_id TEXT,
|
||
|
|
area TEXT, severity TEXT, state TEXT, summary_key TEXT, summary_params TEXT,
|
||
|
|
affected TEXT, evidence TEXT, remediable_by TEXT);
|
||
|
|
CREATE TABLE audit_exceptions (check_id TEXT PRIMARY KEY, reason TEXT,
|
||
|
|
accepted_by TEXT, accepted_at INTEGER, expires_at INTEGER);
|
||
|
|
INSERT INTO audit_runs (run_id, profile, started_at, status) VALUES ('old', 'full', 1, 'complete');
|
||
|
|
INSERT INTO audit_findings (run_id, check_id, area, severity, state)
|
||
|
|
VALUES ('old', 'guests.test', 'guests', 'WARNING', 'accepted');
|
||
|
|
INSERT INTO audit_exceptions VALUES ('guests.test', 'original reason', 'original author', 1, NULL);
|
||
|
|
""")
|
||
|
|
connection.commit()
|
||
|
|
connection.close()
|
||
|
|
store.init_db()
|
||
|
|
self.assertEqual(store.get_findings("old")[0]["state"], "accepted")
|
||
|
|
self.assertEqual(store.effective_findings("old")[0]["state"], "unknown")
|
||
|
|
event = store.exception_history()[0]
|
||
|
|
self.assertEqual(json.loads(event["decision"])["reason"], "original reason")
|
||
|
|
self.assertEqual(event["action"], "legacy-unscoped")
|
||
|
|
|
||
|
|
def test_baseline_and_running_run_survive_retention(self):
|
||
|
|
baseline = self.recorded(self.finding())
|
||
|
|
store.set_baseline(baseline)
|
||
|
|
running = store.start_run("full")
|
||
|
|
store.prune_runs(keep=0)
|
||
|
|
self.assertIsNotNone(store.get_run(baseline))
|
||
|
|
self.assertIsNotNone(store.get_run(running))
|
||
|
|
|
||
|
|
def test_incomplete_warning_cannot_be_accepted(self):
|
||
|
|
f = self.finding()
|
||
|
|
f["incomplete"] = True
|
||
|
|
run = self.recorded(f)
|
||
|
|
store.accept_risk(f["check_id"], "lab", "operator", scope=f["scope"])
|
||
|
|
self.assertEqual(store.effective_findings(run)[0]["state"], "warn")
|
||
|
|
|
||
|
|
|
||
|
|
class EngineTests(unittest.TestCase):
|
||
|
|
def test_incomplete_and_invalid_classification_never_become_conformant(self):
|
||
|
|
check = engine.Check('guests.test', 'guests', 'WARNING', lambda ctx: None)
|
||
|
|
for result, expected in [
|
||
|
|
({'classification':'conformant', 'incomplete':True}, 'unverified'),
|
||
|
|
({'classification':'observation', 'affected':[{'classification':'unverified'}]}, 'unverified'),
|
||
|
|
({'classification':'invented'}, 'unverified'),
|
||
|
|
({'incomplete':True, 'affected':[{'classification':'warning'}]}, 'warning'),
|
||
|
|
({'classification':'unverified', 'affected':[{'classification':'critical'}]}, 'critical')]:
|
||
|
|
self.assertEqual(engine._classification_of(result, check), expected)
|
||
|
|
|
||
|
|
def test_cached_derived_source_keeps_transitive_failures(self):
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
with patch.object(engine.subprocess, 'run', return_value=SimpleNamespace(returncode=1, stdout='', stderr='offline')) as probe:
|
||
|
|
def collect():
|
||
|
|
ctx.run(['fixture'])
|
||
|
|
return {}
|
||
|
|
ctx.begin_check()
|
||
|
|
ctx._once('derived', lambda: ctx._once('inner', collect))
|
||
|
|
ctx.begin_check()
|
||
|
|
ctx._once('derived', lambda: self.fail('source must be reused'))
|
||
|
|
self.assertIn('inner', ctx._sources_used)
|
||
|
|
self.assertTrue(ctx._sources_used & ctx._errors.keys())
|
||
|
|
self.assertEqual(probe.call_count, 1)
|
||
|
|
|
||
|
|
def test_storage_snapshot_reuses_cache_without_probe(self):
|
||
|
|
server = SimpleNamespace(_proxmox_storage_cache={"time": time.time(),
|
||
|
|
"data": {"storage": [{"name": "nas"}]}})
|
||
|
|
with patch.dict(sys.modules, {"flask_server": server}), patch.object(engine.subprocess, "run") as probe:
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
self.assertEqual(ctx.storage_snapshot["source"], "Monitor storage cache")
|
||
|
|
ctx.storage_snapshot["rows"][0]["name"] = "modified copy"
|
||
|
|
self.assertEqual(server._proxmox_storage_cache["data"]["storage"][0]["name"], "nas")
|
||
|
|
probe.assert_not_called()
|
||
|
|
|
||
|
|
def test_expired_storage_snapshot_reads_metadata_once(self):
|
||
|
|
server = SimpleNamespace(_proxmox_storage_cache={"time": 1, "data": {"storage": []}})
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
rows = [{"node": ctx.node, "storage": "nas", "status": "available"},
|
||
|
|
{"node": "another", "storage": "hidden", "status": "available"}]
|
||
|
|
with patch.dict(sys.modules, {"flask_server": server}), patch.object(engine.subprocess, "run",
|
||
|
|
return_value=SimpleNamespace(returncode=0, stdout=json.dumps(rows), stderr="")) as probe:
|
||
|
|
self.assertEqual(len(ctx.storage_snapshot["rows"]), 1)
|
||
|
|
self.assertEqual(ctx.storage_snapshot["rows"][0]["name"], "nas")
|
||
|
|
self.assertEqual(probe.call_count, 1)
|
||
|
|
self.assertIn("/cluster/resources", probe.call_args[0][0])
|
||
|
|
|
||
|
|
def test_failed_storage_metadata_records_unknown_source(self):
|
||
|
|
with patch.dict(sys.modules, {"flask_server": SimpleNamespace()}), patch.object(
|
||
|
|
engine.subprocess, "run", return_value=SimpleNamespace(returncode=1, stdout="", stderr="offline")):
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
self.assertEqual(ctx.storage_snapshot, {})
|
||
|
|
self.assertIn("storage_snapshot", ctx._errors)
|
||
|
|
|
||
|
|
def test_shared_failed_source_remains_unknown_for_each_consumer(self):
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
with patch.object(engine.subprocess, "run", return_value=SimpleNamespace(
|
||
|
|
returncode=1, stdout="", stderr="offline")) as command:
|
||
|
|
ctx.begin_check()
|
||
|
|
ctx.run(["fixture"])
|
||
|
|
ctx.begin_check()
|
||
|
|
ctx.run(["fixture"])
|
||
|
|
self.assertTrue(ctx._sources_used & ctx._errors.keys())
|
||
|
|
self.assertEqual(command.call_count, 1)
|
||
|
|
|
||
|
|
def test_command_deadline_prevents_next_probe(self):
|
||
|
|
ctx = engine.AuditContext()
|
||
|
|
ctx._check_deadline = time.monotonic() - 1
|
||
|
|
with patch.object(engine.subprocess, "run") as command:
|
||
|
|
rc, _ = ctx.run(["fixture-probe"])
|
||
|
|
command.assert_not_called()
|
||
|
|
self.assertEqual(rc, -1)
|
||
|
|
|
||
|
|
def test_invalid_scope_rejected_before_collection(self):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
engine.run_assessment(only_areas={"invented"})
|
||
|
|
|
||
|
|
def test_catalog_has_43_distinct_checks(self):
|
||
|
|
registered = engine.registered_checks()
|
||
|
|
self.assertEqual(len(registered), 43)
|
||
|
|
self.assertEqual(len({c.check_id for c in registered}), 43)
|
||
|
|
for c in registered:
|
||
|
|
self.assertTrue(c.check_id.startswith(c.area + "."))
|
||
|
|
|
||
|
|
def test_locales_have_new_states_and_matching_placeholders(self):
|
||
|
|
root = Path(__file__).resolve().parents[1] / "AppImage/messages"
|
||
|
|
for locale in ("en", "es", "de", "fr", "it", "pt", "sk", "sv"):
|
||
|
|
audit = json.loads((root / locale / "common.json").read_text())["audit"]
|
||
|
|
self.assertEqual({area+'.'+name for area, names in audit['checks'].items() for name in names},
|
||
|
|
{check.check_id for check in engine.registered_checks()})
|
||
|
|
self.assertIn("unknown", audit["states"])
|
||
|
|
self.assertIn("{completed}", audit["progress"])
|
||
|
|
self.assertEqual(set(audit["areas"]), set(engine.AREAS) | {"all"})
|
||
|
|
connected = audit["checks"]["storage"]["connected_storage"]
|
||
|
|
self.assertIn("{total}", connected["summary"]["available"])
|
||
|
|
self.assertIn("{count}", connected["summary"]["attention"])
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|