Add audit and reports page, and a change journal

ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-08 21:06:04 +02:00
co-authored by Claude Opus 5
parent ae75508eff
commit da8a480eff
102 changed files with 24118 additions and 1403 deletions
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Checks a journal migration without reading the whole script.
Migrating a function to the change journal must not change what the
function does — only how it writes. That is a narrow claim, and a narrow
claim can be verified mechanically, which is the point of this: reviewing
a four-thousand-line shell script by eye is how a byte-level difference
in a configuration file gets shipped.
Run it against the pre-migration version of the same file:
verify_journal_migration.py --before original.sh --after migrated.sh
The pre-migration version is whatever the repository had before the work
started, for example:
git show HEAD:scripts/post_install/customizable_post_install.sh
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
# Writes that reach the host. A heredoc into /tmp is a composition step,
# not a change, so paths under /tmp are excluded from the search.
DIRECT_WRITE = re.compile(
r"""(?x)
(?:cat|printf|echo|tee)\s*(?:<<-?\s*['"]?\w+['"]?\s*)?>{1,2}\s*["']?(?:/etc|/usr|/var|/boot|/root|\$\{?(?:config_file|sysctl_conf|conf|target))
| sed\s+-i(?!\s+[^|;&]*\s/tmp/)
| systemctl\s+(?:enable|disable)\s+--now
""")
# A heredoc body: what the function actually writes. The delimiter is
# usually followed by a redirection on the same line — `<<EOF > "$file"`
# — so everything up to the newline is skipped before the body starts.
HEREDOC = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n(.*?)^\1\s*$", re.M | re.S)
# A backup the uninstaller may restore from. The path is often a
# variable — `cp -n "$conf" "$backup_conf"` — so the copy itself is what
# is matched, not the .bak suffix.
BACKUP = re.compile(r"cp\s+(?:-n\s+)?[^\n]*(?:\.bak|backup_conf|_backup|\bbackup\b)")
# Both declaration forms bash accepts, because a file written in the
# `function name() {` style used to yield no functions at all: the
# walker saw none, the sanity check counted none, the two agreed, and
# the file passed without a single one of its bodies being read.
FUNC_START = re.compile(
r"^(?:function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*(?:\(\))?"
r"|([A-Za-z_][A-Za-z0-9_-]*)\s*\(\))\s*\{\s*$", re.M)
HEREDOC_START = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?")
def functions(source: str) -> list[tuple[str, str]]:
"""Every top-level shell function and its body, in declaration order.
A list rather than a mapping because a script may declare the same
name twice — the later definition is the one bash keeps, but both are
in the file. Keyed by name, the first body vanished and its lines
were then counted as top-level code that nothing had recorded.
Walked line by line rather than matched with a regular expression,
because these scripts embed whole files in heredocs and several of
those contain a closing brace in the first column — a systemd unit,
an awk program, a shell script being installed. A regex that ends the
function at the first such line cuts it in half, and everything after
the cut looks like top-level code that nothing is checking.
"""
found: list[tuple[str, str]] = []
lines = source.splitlines()
i, total = 0, len(lines)
while i < total:
match = FUNC_START.match(lines[i])
if not match:
i += 1
continue
name = match.group(1) or match.group(2)
body, depth, delimiter = [], 1, None
i += 1
while i < total and depth > 0:
line = lines[i]
if delimiter is not None:
# Inside a heredoc nothing counts as shell syntax. The
# closing line is usually the delimiter alone, but these
# scripts also nest heredocs inside quoted strings passed
# to `pct exec`, where the terminator carries the closing
# quote: `EOF"`. Treating only the exact form as a close
# swallows the rest of the file and silently merges every
# function after it.
stripped = line.strip()
if stripped == delimiter or (
stripped.startswith(delimiter)
and stripped[len(delimiter):].strip(" \"';)") == ""):
delimiter = None
else:
opened = HEREDOC_START.search(line)
if opened:
delimiter = opened.group(1)
elif line == "}":
depth -= 1
if depth == 0:
break
body.append(line)
i += 1
found.append((name, "\n".join(body)))
i += 1
return found
def heredocs(body: str) -> list[str]:
"""Contents written by a function, in order, ignoring the delimiters."""
return [text for _, text in HEREDOC.findall(body)]
def _without_heredocs(source: str) -> str:
"""The script with heredoc bodies removed, line count preserved.
What a script writes into a file is content, not code: a function
declared inside a heredoc belongs to the file being installed.
"""
out, delimiter = [], None
for line in source.splitlines():
if delimiter is not None:
stripped = line.strip()
if stripped == delimiter or (
stripped.startswith(delimiter)
and stripped[len(delimiter):].strip(" \"';)") == ""):
delimiter = None
out.append("")
continue
opened = HEREDOC_START.search(line)
out.append(line)
if opened:
delimiter = opened.group(1)
return "\n".join(out)
def _top_level(source: str) -> str:
"""The script with every function body removed.
Built by subtracting the bodies the walker found, so a heredoc
containing a closing brace cannot make half a function look like
top-level code.
"""
remaining = source
for _, body in functions(source):
if body:
remaining = remaining.replace(body, "", 1)
return remaining
def _heredocs_of(source: str) -> list[str]:
return [text for _, text in HEREDOC.findall(source)]
def check(before_path: Path, after_path: Path) -> int:
before = functions(before_path.read_text())
after = functions(after_path.read_text())
problems: list[str] = []
migrated: list[str] = []
# The file has to be valid shell before anything else is worth saying.
syntax = subprocess.run(["bash", "-n", str(after_path)],
capture_output=True, text=True)
if syntax.returncode != 0:
print(f"FAIL bash -n: {syntax.stderr.strip()}")
return 1
before_by_name: dict[str, list[str]] = {}
for name, body in before:
before_by_name.setdefault(name, []).append(body)
after_by_name: dict[str, list[str]] = {}
for name, body in after:
after_by_name.setdefault(name, []).append(body)
gone = sorted(set(before_by_name) - set(after_by_name))
if gone:
problems.append(f"functions removed: {', '.join(gone)}")
# A name declared more than once is a property of the script, not a
# fault in the migration. Stated so the reader knows which body the
# results below belong to, and not counted against the file.
repeated = sorted(n for n, bodies in after_by_name.items() if len(bodies) > 1)
for name in repeated:
print(f"note: {name} is declared {len(after_by_name[name])} times; "
f"each declaration is checked against its own original")
# Sanity: the walker must find every function the file declares. If
# it finds fewer, it merged some, and everything it reported about
# them is unreliable — a green result on a file it did not read.
#
# Counted with the heredocs removed, because these scripts install
# other scripts by writing them out, and a function declared inside
# one of those belongs to the installed file, not to this one.
declared = len(FUNC_START.findall(_without_heredocs(after_path.read_text())))
if declared != len(after):
problems.append(
f"parser found {len(after)} functions but the file declares "
f"{declared}; the result cannot be trusted")
# Everything above only looks inside functions. A script that acts at
# the top level — and several do — was invisible to this check, which
# is exactly where an unrecorded write would hide.
outside_before = _top_level(before_path.read_text())
outside_after = _top_level(after_path.read_text())
if "pmx_journal" in outside_after or any(
"pmx_journal_context" in body for _, body in after):
# Scanned with the heredoc bodies blanked: a script that installs
# another script writes that script's own `sed -i` lines as
# content, and the contract forbids touching what is written.
direct = [m.group(0).strip()
for m in DIRECT_WRITE.finditer(_without_heredocs(outside_after))]
if direct:
problems.append(
f"top level: {len(direct)} write(s) still reach the host directly — "
f"{direct[0][:70]}")
if _heredocs_of(outside_before) != _heredocs_of(outside_after):
problems.append("top level: the content written outside any function changed")
occurrence: dict[str, int] = {}
for name, body in after:
index = occurrence.get(name, 0)
occurrence[name] = index + 1
if "pmx_journal_context" not in body:
continue
migrated.append(name if index == 0 else f"{name} (declaration {index + 1})")
originals = before_by_name.get(name, [])
if index >= len(originals):
problems.append(f"{name}: declaration {index + 1} was not present "
f"before the migration")
continue
original = originals[index]
# 1. One context, naming the function it sits in.
contexts = re.findall(r'pmx_journal_context\s+"([^"]+)"', body)
if len(contexts) != 1:
problems.append(f"{name}: {len(contexts)} calls to pmx_journal_context, expected 1")
elif contexts[0] != name:
problems.append(f"{name}: context declares '{contexts[0]}'")
# 2. Nothing still writes to the host directly. The heredoc
# bodies are blanked first: a `sed -i` inside a script this
# function installs is that script's line, not this one's, and
# rewriting it is exactly what the contract forbids.
direct = [m.group(0).strip()
for m in DIRECT_WRITE.finditer(_without_heredocs(body))]
if direct:
problems.append(f"{name}: still writes directly — {direct[0][:70]}")
# 3. What it writes has to be what it wrote before. This is the
# check that matters: a migration that alters a configuration
# file by one byte is a behaviour change wearing a refactor.
if heredocs(original) != heredocs(body):
before_docs, after_docs = heredocs(original), heredocs(body)
if len(before_docs) != len(after_docs):
problems.append(
f"{name}: wrote {len(before_docs)} block(s) before, {len(after_docs)} now")
else:
for i, (was, now) in enumerate(zip(before_docs, after_docs)):
if was != now:
problems.append(f"{name}: content of block {i + 1} changed")
# 4. A backup the uninstaller depends on must survive.
if BACKUP.search(original) and not BACKUP.search(body):
problems.append(f"{name}: the .bak copy was removed; "
f"uninstall-tools.sh restores from it")
# 5. Registration is untouched.
if original.count("register_tool") != body.count("register_tool"):
problems.append(f"{name}: register_tool calls changed")
print(f"functions migrated: {len(migrated)}")
for name in migrated:
print(f" {name}")
if problems:
print(f"\n{len(problems)} problem(s):")
for problem in problems:
print(f" {problem}")
return 1
print("\nno problems found")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--before", required=True, type=Path)
parser.add_argument("--after", required=True, type=Path)
args = parser.parse_args()
return check(args.before, args.after)
if __name__ == "__main__":
sys.exit(main())
@@ -33,6 +33,16 @@ assert.equal(cache.getLxcAppsCached(101).sidecar.apps[0].docker_available_versio
assert.equal(requests, 0)
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
assert.match(
source,
/const independentlyUpdatedApps = registeredApps\.filter\(\s*\(a\) => a\.update_via !== "docker",\s*\)/,
'Docker-delegated apps must not render a second Updates section',
)
assert.match(
source,
/image\.update_available === false \? "text-green-500" : "text-foreground\/80"/,
'a current Docker image must show its installed version in green',
)
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const pieces = []
function walk(node) {
+113
View File
@@ -0,0 +1,113 @@
"""Flask API contracts with stubbed authentication, temporary DB, no probes."""
import importlib
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
try:
from flask import Flask
except ImportError:
Flask = None
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
import audit_store as store
@unittest.skipIf(Flask is None, "Flask runtime required")
class AuditApiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dbpatch = patch.object(store, "DB_PATH", Path(self.temp.name) / "audit.db")
self.dbpatch.start()
self.addCleanup(self.dbpatch.stop)
store._schema_ready = False
self.addCleanup(lambda: setattr(store, "_schema_ready", False))
auth = types.ModuleType("auth_manager")
auth.load_auth_config = lambda: {"enabled": True}
auth.verify_token = lambda token: "verified-operator"
middleware = types.ModuleType("jwt_middleware")
middleware.require_auth = lambda f: f
middleware.require_admin_scope = lambda f: f
with patch.dict(sys.modules, auth_manager=auth, jwt_middleware=middleware):
sys.modules.pop("flask_audit_routes", None)
self.routes = importlib.import_module("flask_audit_routes")
self.addCleanup(lambda: sys.modules.pop("flask_audit_routes", None))
self.app = Flask(__name__)
self.app.register_blueprint(self.routes.audit_bp)
self.client = self.app.test_client()
self.headers = {"Authorization": "Bearer fixture"}
self.finding = {"check_id": "guests.privileged_containers", "area": "guests",
"severity": "WARNING", "state": "warn", "raw_state": "warn",
"classification": "warning", "raw_classification": "warning",
"affected": [{"vmid": 101}], "scope": "fixture-scope"}
self.run = store.start_run("full")
store.record_findings(self.run, [self.finding])
store.finish_run(self.run, checks_total=1)
def accept(self, **extra):
return self.client.post("/api/audit/exceptions", headers=self.headers, json={
"check_id": self.finding["check_id"], "reason": "intentional lab", "run_id": self.run,
"accepted_by": "forged-author", **extra})
def test_actor_is_from_authentication_and_live_view_updates(self):
self.assertEqual(self.accept().status_code, 200)
row = self.client.get(f"/api/audit/runs/{self.run}?effective=1").json["findings"][0]
self.assertEqual(row["state"], "accepted")
self.assertEqual(row["exception"]["accepted_by"], "verified-operator")
self.assertEqual(row["classification"], "warning")
self.assertEqual(row["raw_classification"], "warning")
historical = self.client.get(f"/api/audit/runs/{self.run}").json["findings"][0]
self.assertEqual(historical["state"], "warn")
status = self.client.get("/api/audit/status").json
self.assertEqual(status["summary"], {"accepted": 1})
def test_revoke_is_immediate(self):
self.accept()
self.client.delete(f"/api/audit/exceptions/{self.finding['check_id']}", headers=self.headers)
self.assertEqual(self.client.get("/api/audit/status").json["summary"], {"warning": 1})
self.assertEqual(len(self.client.get("/api/audit/exceptions").json["history"]), 2)
def test_expiry_must_be_positive_integer(self):
for days in (0, False, -1, 1.5, True, 999999, "invalid"):
with self.subTest(days=days):
self.assertEqual(self.accept(expires_in_days=days).status_code, 400)
def test_stale_run_cannot_accept_new_results(self):
self.assertEqual(self.accept(run_id="old-run").status_code, 409)
def test_observation_cannot_be_accepted_as_a_risk(self):
finding = {**self.finding, 'classification':'observation', 'raw_classification':'observation'}
self.run = store.start_run('full')
store.record_findings(self.run, [finding])
store.finish_run(self.run, checks_total=1)
self.assertEqual(self.accept().status_code, 400)
self.assertFalse(store.active_exceptions())
def test_invalid_profile_or_area_never_starts_worker(self):
with patch.object(self.routes.threading, "Thread") as worker:
for body in ({"profile": "invented"}, {"areas": ["invented"]}, {"areas": []}, {"areas": "system"}):
self.assertEqual(self.client.post("/api/audit/run", json=body).status_code, 400)
worker.assert_not_called()
def test_run_returns_id_before_worker_finishes(self):
with patch.object(self.routes.threading, "Thread"):
response = self.client.post("/api/audit/run", json={"profile": "full"})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json["run_id"])
self.assertEqual(self.client.post("/api/audit/run", json={}).status_code, 409)
def test_audit_database_failure_does_not_prevent_monitor_startup(self):
with patch.object(store, "recover_interrupted_runs", side_effect=OSError("read-only filesystem")):
another_app = Flask("audit-startup-failure")
another_app.register_blueprint(self.routes.audit_bp)
self.assertEqual(self.client.get("/api/audit/status").status_code, 500)
self.assertEqual(self.client.post("/api/audit/run", json={}).status_code, 500)
if __name__ == "__main__":
unittest.main()
+653
View File
@@ -0,0 +1,653 @@
"""All 43 checks against a declared fixture host, plus boundary/failure cases.
No subprocesses, network connections or real host paths are consulted.
"""
import json
from pathlib import PurePosixPath
from types import SimpleNamespace
import sys
import time
import unittest
from unittest.mock import patch
from test_audit_report import Context, evaluate, HEADER
import audit_checks as engine
import audit_checks_pve as checks
import audit_policy
import audit_profiles
NOW = 1788700000
VM_CMD = ("pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json")
TASK_CMD = ("pvesh", "get", "/nodes/fixture/tasks", "--typefilter", "vzdump", "--limit", "200", "--output-format", "json")
PBS_CMD = ("pvesh", "get", "/nodes/fixture/storage/pbs/content", "--output-format", "json")
DF_CMD = ("df", "--output=target,pcent,ipcent,size,avail", "/", "/var", "/var/log", "/var/lib/vz")
FINDMNT_CMD = ("findmnt", "-rno", "TARGET,OPTIONS")
LVS_CMD = ("lvs", "--noheadings", "--units", "b", "--nosuffix", "--separator", "|", "-o",
"vg_name,lv_name,lv_size,pool_lv,lv_attr,data_percent,metadata_percent")
# Explicit identities, not just a count: swapping an old check for a new
# one must not let this contract pass accidentally.
EXPECTED = {
"backup.guest_coverage": "conformant", "backup.last_backup_age": "conformant",
"backup.retention_defined": "conformant", "backup.verification_state": "conformant",
"backup.job_results": "conformant", "storage.connected_storage": "conformant",
"storage.orphaned_volumes": "conformant", "storage.thin_pool_overprovisioning": "conformant",
"storage.zfs_arc_max": "conformant", "storage.zfs_scrub_age": "conformant",
"storage.pool_integrity": "conformant", "system.pending_reboot": "conformant",
"system.kernel_current": "conformant", "system.security_updates": "conformant",
"system.enterprise_repo_without_subscription": "observation", "system.memory_overcommit": "conformant",
"system.time_synchronisation": "conformant", "system.journal_size": "conformant",
"system.swap_configured": "conformant", "system.filesystem_capacity": "conformant",
"system.update_chain": "conformant", "system.notification_delivery": "conformant",
"guests.privileged_containers": "conformant", "guests.qemu_without_agent": "conformant",
"guests.autostart": "conformant", "guests.stuck_snapshots": "conformant",
"guests.cpu_host_type": "conformant", "guests.replication_state": "conformant",
"network.bond_members": "conformant", "network.bridge_without_ports": "conformant",
"security.host_firewall_enabled": "conformant", "security.ssh_root_login": "conformant",
"security.certificate_expiry": "conformant", "security.lynis_warnings": "conformant",
"hardware.disk_service_life": "conformant",
"backup.host_recovery": "conformant", "system.cluster_quorum": "conformant",
"hardware.disk_errors": "warning", "system.boot_loader": "conformant",
"system.failed_units": "conformant", "storage.ceph_health": "conformant",
"storage.array_integrity": "conformant", "system.ha_state": "conformant",
}
# A three-node cluster over two corosync rings, so the check has both a
# membership to compare and a link count that is not the bare minimum.
COROSYNC_CONF = """totem {
cluster_name: fixture-cluster
interface { linknumber: 0 }
}
nodelist {
node { name: fixture ring0_addr: 10.0.0.1 ring1_addr: 10.1.0.1 nodeid: 1 }
node { name: second ring0_addr: 10.0.0.2 ring1_addr: 10.1.0.2 nodeid: 2 }
node { name: third ring0_addr: 10.0.0.3 ring1_addr: 10.1.0.3 nodeid: 3 }
}
"""
PVECM_NODES = """Membership information
----------------------
Nodeid Votes Name
1 1 fixture (local)
2 1 second
3 1 third
"""
TIMERS = ("NEXT LEFT LAST PASSED UNIT ACTIVATES\n"
"Sun 2026-09-07 - - - proxmenux-backup-hostcfg-daily.timer "
"proxmenux-backup-hostcfg-daily.service\n")
class FixturePath:
def __init__(self, owner, value):
self.owner, self.value = owner, str(value)
def __str__(self): return self.value
def __truediv__(self, name): return FixturePath(self.owner, self.value.rstrip('/') + '/' + name)
@property
def name(self): return PurePosixPath(self.value).name
def exists(self): return self.value in self.owner.ctx.files or self.is_dir()
def is_file(self): return self.value in self.owner.ctx.files
def is_dir(self): return self.value in self.owner.directories
def read_text(self, **kwargs):
if self.value not in self.owner.ctx.files: raise FileNotFoundError(self.value)
return self.owner.ctx.files[self.value]
def stat(self):
if not self.exists(): raise FileNotFoundError(self.value)
return SimpleNamespace(st_mtime=self.owner.stamps.get(self.value, NOW))
def glob(self, pattern):
return [FixturePath(self.owner, p) for p in sorted(self.owner.ctx.files)
if str(PurePosixPath(p).parent) == self.value and PurePosixPath(p).match(pattern)]
def iterdir(self): return self.glob('*')
class CatalogTests(unittest.TestCase):
def setUp(self):
self.ctx = Context(lxc_configs={101: 'unprivileged: 1\nonboot: 1\nmemory: 512\n'},
qemu_configs={200: 'agent: 1\nonboot: 1\nmemory: 1024\ncpu: kvm64\n'},
storages=[{'id': 'backups', 'type': 'dir', 'content': 'backup', 'prune-backups': 'keep-last=3'},
{'id': 'pbs', 'type': 'pbs', 'content': 'backup'},
{'id': 'local', 'type': 'dir', 'content': 'images'}])
self.ctx.storage_snapshot = {'rows': [{'name': s['id'], 'node': 'fixture', 'status': 'available',
'total': 100, 'used': 20} for s in self.ctx.storages]}
self.ctx.lynis_report = {'complete': True, 'warnings': [], 'suggestions': [], 'hardening_index': 80, 'mtime': NOW}
self.ctx.monitor_snapshot = {'smart': {'sda': (NOW, {'power_on_hours': 12})}}
stamp = time.strftime('%Y_%m_%d-%H_%M_%S', time.localtime(NOW - 3600))
self.ctx.responses.update({
('pvesm', 'list', 'backups'): (0, HEADER + ''.join(
f'backups:backup/vzdump-{kind}-{vmid}-{stamp}.tar.zst zst backup 1024 {vmid}\n'
for kind, vmid in [('lxc', 101), ('qemu', 200)])),
('uname', '-r'): (0, '6.8.12-1-pve'),
('dpkg-query', '-W', '-f=${db:Status-Status} ${Package}\n'): (0, 'installed proxmox-kernel-6.8.12-1-pve-signed\n'),
('proxmox-boot-tool', 'kernel', 'list'): (0, 'Automatically selected kernels:\n6.8.12-1-pve\nPinned kernel:\n6.8.12-1-pve\n'),
('cat', '/proc/meminfo'): (0, 'MemTotal: 16777216 kB\nMemAvailable: 10000000 kB\n'),
('timedatectl', 'show', '-p', 'NTP', '-p', 'NTPSynchronized'): (0, 'NTP=yes\nNTPSynchronized=yes\n'),
('apt-get', '-s', 'upgrade'): (0, 'Reading package lists...\n0 upgraded, 0 newly installed\n'),
('openssl', 'x509', '-enddate', '-noout', '-in', '/etc/pve/local/pve-ssl.pem'): (0, 'notAfter=fixture'),
('date', '-d', 'fixture', '+%s'): (0, str(NOW + 90 * 86400)),
('sshd', '-T'): (0, 'permitrootlogin prohibit-password\npasswordauthentication yes\nkbdinteractiveauthentication no\n'),
('journalctl', '--disk-usage'): (0, 'Archived and active journals take up 1.0M in the file system.'),
('swapon', '--show=NAME,SIZE,TYPE', '--bytes', '--noheadings'): (0, '/dev/swap 1048576 partition'),
('zpool', 'list', '-H', '-o', 'name'): (0, 'tank\n'),
('zpool', 'list', '-H', '-o', 'name,health'): (0, 'tank\tONLINE\n'),
('zpool', 'status', 'tank'): (0, ' state: ONLINE\n scan: scrub repaired 0B in 1h with 0 errors on ' + time.ctime(NOW) + '\n disk ONLINE 0 0 0\n'),
('pvesh', 'get', '/nodes/fixture/replication', '--output-format', 'json'): (0, json.dumps([{'id':'101-0', 'last_sync':NOW-60, 'schedule':'daily'}])),
TASK_CMD: (0, json.dumps([{'type': 'vzdump', 'id': '101', 'status': 'OK'}])),
PBS_CMD: (0, json.dumps([self.snapshot()])),
DF_CMD: (0, 'Mounted on Use% IUse% 1K-blocks Avail\n/ 20% 10% 100 80\n/ 20% 10% 100 80\n'),
FINDMNT_CMD: (0, '/ rw,relatime\n/var/log rw,relatime\n'),
LVS_CMD: (0, 'pve|data|1000000000||twi-a-tz--|20|5\npve|vm-101-disk-0|100000000|data|Vwi-a-tz--||\n'),
('ceph', '-s', '--format', 'json'): (0, json.dumps(
{'health': {'status': 'HEALTH_OK', 'checks': {}},
'quorum_names': ['a', 'b', 'c']})),
('ha-manager', 'status'): (0,
'quorum OK\nmaster fixture (active, Mon Jan 1 00:00:00 2026)\n'
'lrm fixture (active, Mon Jan 1 00:00:00 2026)\n'
'service vm:100 (fixture, started)\n'),
('systemctl', 'list-units', '--state=failed', '--no-legend',
'--no-pager', '--plain'): (0, ''),
('systemctl', 'is-active', 'pve-cluster', 'pvedaemon',
'pveproxy', 'pvestatd'): (0, 'active\nactive\nactive\nactive\n'),
('proxmox-boot-tool', 'status'): (0,
"System currently booted with uefi\n"
"654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n"
"6550-5CBE is configured with: uefi (versions: 6.8.12-1-pve)\n"),
('pvecm', 'status'): (0, 'Quorate: Yes\nExpected votes: 3\nTotal votes: 3\n'),
('pvecm', 'nodes'): (0, PVECM_NODES),
('systemctl', 'list-timers', '--all', '--no-pager'): (0, TIMERS),
})
archive = 'hostcfg-daily-20260906_000017.tar.zst'
self.ctx.files.update({
f'/var/lib/vz/dump/{archive}': 'fixture archive',
f'/var/lib/vz/dump/{archive}.proxmenux.json': json.dumps({
'schema_version': 1, 'kind': 'scheduled', 'job_id': 'hostcfg-daily',
'hostname': 'fixture', 'archive': archive, 'archive_size': 4377756725,
'created_at': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(NOW - 3600)),
}),
})
self.ctx.files.update({
'/etc/kernel/proxmox-boot-uuids':'654E-D6BD\n6550-5CBE\n',
'/etc/pve/ceph.conf':'[global]\n',
'/etc/pve/ha/resources.cfg':'vm: 100\n',
'/proc/mdstat':('Personalities : [raid1]\n'
'md0 : active raid1 sda1[0] sdb1[1]\n'
' 976630464 blocks super 1.2 [2/2] [UU]\n'),
'/etc/pve/firewall/cluster.fw':'[OPTIONS]\nenable: 1\n',
'/etc/pve/local/pve-ssl.pem':'fixture certificate',
'/etc/corosync/corosync.conf': COROSYNC_CONF,
'/etc/systemd/journald.conf':'SystemMaxUse=1G\n',
'/etc/network/interfaces':'iface vmbr0 inet static\n bridge-ports eth0\n',
'/etc/pve/replication.cfg':'local: 101-0\n target other\n',
'/proc/spl/kstat/zfs/arcstats':'c_min 4 1048576\nc_max 4 1073741824\nsize 4 5000000\n',
'/sys/module/zfs/parameters/zfs_arc_max':'1073741824',
'/var/lib/apt/periodic/update-success-stamp':'',
'/proc/net/bonding/bond0':'Bonding Mode: active-backup\nSlave Interface: eth0\nMII Status: up\n',
})
self.directories = {'/sys/module/zfs', '/proc/net/bonding', '/etc/modprobe.d',
'/var/lib/vz/dump'}
self.stamps = {}
self.channels = {'telegram': {'enabled': True, 'configured': True}}
self.histories = {'telegram': {'history': [{'channel':'telegram','success':1,'sent_at':NOW}]}}
self.manager = SimpleNamespace(list_channels=lambda: {'channels': self.channels},
get_history=lambda **kw: self.histories[kw['channel']])
# A disk that reported something long ago and has been quiet since:
# a record exists, and nothing in it is current.
self.observations = [{'device_name': '/dev/sda', 'error_type': 'smart_error',
'severity': 'WARNING', 'occurrence_count': 2,
'first_occurrence': NOW - 90 * 86400,
'last_occurrence': NOW - 60 * 86400,
'raw_message': 'fixture'}]
self.persistence = SimpleNamespace(
get_disk_observations=lambda: self.observations)
for p in (patch.object(checks, 'Path', side_effect=lambda v: FixturePath(self, v)),
patch.object(checks.time, 'time', return_value=NOW),
patch.dict(sys.modules, {'flask_server': SimpleNamespace(
notification_manager=self.manager,
health_persistence=self.persistence)})):
p.start(); self.addCleanup(p.stop)
def snapshot(self, state='ok', **kwargs):
return {'vmid':101, 'content':'backup', 'ctime':NOW-100, 'volid':'pbs:backup/ct/101/date',
'verification':{'state':state}, **kwargs}
def result(self, fn): return evaluate(fn, self.ctx)
def test_every_one_of_the_43_checks_has_a_real_fixture(self):
self.assertEqual({c.check_id for c in engine.registered_checks()}, set(EXPECTED))
for c in engine.registered_checks():
with self.subTest(check=c.check_id):
result = self.result(c.evaluate)
self.assertIsNotNone(result, 'This fixture must exercise the check, not skip it')
self.assertEqual(result['classification'], EXPECTED[c.check_id])
def test_reboot_marker_is_evidence_even_with_no_package_named(self):
"""Something wrote the marker and did not say what.
Reporting that as unverified described the reading rather than
the host, which had plainly asked for a restart.
"""
self.ctx.files['/var/run/reboot-required'] = ''
for pkgs in ('', None):
if pkgs is None:
self.ctx.files.pop('/var/run/reboot-required.pkgs', None)
else:
self.ctx.files['/var/run/reboot-required.pkgs'] = pkgs
rows = self.result(checks._pending_reboot)['affected']
self.assertEqual([(r['reason_key'], r['classification']) for r in rows],
[('rebootMarkerWithoutPackages', 'observation')])
# A named package still describes itself.
self.ctx.files['/var/run/reboot-required.pkgs'] = 'libc6\n'
rows = self.result(checks._pending_reboot)['affected']
self.assertEqual(rows[0]['reason_key'], 'packageAwaitingRestart')
def test_essential_state_must_be_one_word_per_service(self):
"""`systemctl is-active` prints one word per unit; anything else
is prose, and zipping prose onto the names made every word of it
a critical finding."""
active = ('systemctl', 'is-active', 'pve-cluster', 'pvedaemon',
'pveproxy', 'pvestatd')
self.ctx.responses[('systemctl', 'list-units', '--state=failed',
'--no-legend', '--no-pager', '--plain')] = (0, '')
for output in ('Unit pvedaemon.service could not be found.',
'active\nactive\n', ''):
self.ctx.responses[active] = (1, output)
result = self.result(checks._failed_units)
self.assertEqual(result['classification'], 'unverified',
f'prose became a verdict: {output!r}')
self.ctx.responses[active] = (0, 'active\nactive\nactive\nactive\n')
self.assertEqual(self.result(checks._failed_units)['classification'],
'conformant')
def test_verification_ignores_guests_that_are_not_on_this_node(self):
"""A shared backup server holds every node's copies, and keeps
those of guests that no longer exist anywhere."""
# 101 is local (see the fixture); 999 belongs to somebody else.
self.ctx.responses[PBS_CMD] = (0, json.dumps([
self.snapshot(state='failed', vmid=999),
self.snapshot(state='ok', vmid=101)]))
result = self.result(checks._backup_verification)
self.assertEqual(result['classification'], 'conformant',
"another node's failed snapshot was graded here")
def test_ceph_reports_its_own_verdict_and_the_checks_behind_it(self):
"""Ceph grades its own state better than anything outside could;
what is added is putting that verdict where the host's is read."""
cmd = ('ceph', '-s', '--format', 'json')
self.ctx.responses[cmd] = (0, json.dumps({'health': {
'status': 'HEALTH_ERR', 'checks': {
'PG_DAMAGED': {'severity': 'HEALTH_ERR',
'summary': {'message': '1 pg inconsistent'}},
'OSD_NEARFULL': {'severity': 'HEALTH_WARN',
'summary': {'message': '1 osd nearfull'}}}}}))
rows = self.result(checks._ceph_health)['affected']
self.assertEqual({r['name']: r['classification'] for r in rows},
{'PG_DAMAGED': 'critical', 'OSD_NEARFULL': 'warning'})
# A warning cluster is a warning even with no check named.
self.ctx.responses[cmd] = (0, json.dumps(
{'health': {'status': 'HEALTH_WARN', 'checks': {}}}))
self.assertEqual(
self.result(checks._ceph_health)['affected'][0]['classification'], 'warning')
# The client binary ships with Proxmox; a node without a cluster
# configuration has nothing to report.
del self.ctx.files['/etc/pve/ceph.conf']
self.assertIsNone(self.result(checks._ceph_health))
def test_array_short_of_devices_is_not_an_array_that_stopped(self):
"""Both keep serving; only one has lost what it was built for."""
def grade(mdstat):
self.ctx.files['/proc/mdstat'] = mdstat
r = self.result(checks._array_integrity)
return [(a['name'], a['reason_key'], a['classification'])
for a in r.get('affected', [])] or [(r['summary_key'],)]
self.assertEqual(grade('Personalities : [raid1]\n'
'md0 : active raid1 sda1[0] sdb1[1]\n'
' 976630464 blocks super 1.2 [2/1] [U_]\n'),
[('md0', 'arrayDegraded', 'warning')])
# Rebuilding is the array doing what it should.
self.assertEqual(grade('Personalities : [raid1]\n'
'md0 : active raid1 sda1[0] sdb1[1]\n'
' 976630464 blocks super 1.2 [2/1] [U_]\n'
' [==>..] recovery = 12.0% (1/9) finish=2min\n'),
[('md0', 'arrayRebuilding', 'warning')])
self.assertEqual(grade('Personalities : [raid1]\n'
'md0 : inactive sda1[0]\n'),
[('md0', 'arrayNotActive', 'critical')])
# No array and no multipath tool is nothing to report on.
self.ctx.files['/proc/mdstat'] = 'Personalities :\nunused devices: <none>\n'
self.assertIsNone(self.result(checks._array_integrity))
def test_ha_reads_what_quorum_does_not_answer(self):
"""Quorum has its own check; this is the half it cannot answer."""
cmd = ('ha-manager', 'status')
self.ctx.responses[cmd] = (0,
'quorum OK\nmaster fixture (active, Mon Jan 1 00:00:00 2026)\n'
'lrm fixture (wait_for_agent_lock, Mon Jan 1 00:00:00 2026)\n'
'service vm:100 (fixture, error)\n')
rows = {r['name']: (r['reason_key'], r['classification'])
for r in self.result(checks._ha_state)['affected']}
self.assertEqual(rows['vm:100'], ('haServiceError', 'critical'))
self.assertEqual(rows['fixture'], ('haManagerNotReady', 'warning'))
# Nothing decides where a service runs without a master.
self.ctx.responses[cmd] = (0, 'quorum OK\nlrm fixture (idle, x)\n'
'service vm:100 (fixture, started)\n')
self.assertIn('haNoMaster',
{r['reason_key'] for r in self.result(checks._ha_state)['affected']})
# No declared resources is nothing to move.
del self.ctx.files['/etc/pve/ha/resources.cfg']
self.assertIsNone(self.result(checks._ha_state))
def test_essential_service_down_outranks_a_peripheral_unit(self):
"""A node that keeps its guests and refuses every management
operation looks healthy from every other angle."""
failed = ('systemctl', 'list-units', '--state=failed', '--no-legend',
'--no-pager', '--plain')
active = ('systemctl', 'is-active', 'pve-cluster', 'pvedaemon',
'pveproxy', 'pvestatd')
self.ctx.responses[failed] = (
1, 'smartd.service loaded failed failed Self-Monitoring daemon\n')
rows = self.result(checks._failed_units)['affected']
self.assertEqual([(r['name'], r['classification']) for r in rows],
[('smartd.service', 'warning')])
# An inactive essential service is not always a failed unit, and
# the outcome is the same, so it is asked for by name.
self.ctx.responses[failed] = (0, '')
self.ctx.responses[active] = (3, 'active\ninactive\nactive\nactive\n')
rows = self.result(checks._failed_units)['affected']
self.assertEqual([(r['name'], r['classification']) for r in rows],
[('pvedaemon', 'critical')])
def test_filesystem_critical_needs_exhaustion_not_a_high_percentage(self):
"""Ninety-one per cent is a risk; nothing left is the failure.
A fixed high percentage would not prove an interruption either,
so the critical result comes from zero bytes, no inodes, or a
mount the kernel reports read-only.
"""
def grade(df, mounts='/ rw,relatime\n'):
self.ctx.responses[DF_CMD] = (0, 'Mounted on Use% IUse% 1K-blocks Avail\n' + df)
self.ctx.responses[FINDMNT_CMD] = (0, mounts)
r = self.result(checks._filesystem_capacity)
return [(a['reason_key'], a['classification']) for a in r.get('affected', [])] \
or [(r.get('summary_key'), r['classification'])]
self.assertEqual(grade('/ 95% 10% 100 5\n'),
[('filesystemNearlyFull', 'warning')])
# Full to the last byte, whatever the rounded percentage says.
self.assertEqual(grade('/ 100% 10% 100 0\n'),
[('filesystemExhausted', 'critical')])
self.assertEqual(grade('/ 40% 100% 100 60\n'),
[('inodesExhausted', 'critical')])
# Already refusing writes, and no percentage says so.
self.assertEqual(grade('/ 40% 10% 100 60\n', '/ ro,relatime\n'),
[('filesystemReadOnly', 'critical')])
# `ro` inside another option must not be mistaken for read-only.
self.assertEqual(grade('/ 40% 10% 100 60\n', '/ rw,errors=remount-ro\n'),
[('withinLimits', 'conformant')])
def test_boot_partitions_out_of_step_are_not_redundancy(self):
"""Two partitions carrying different kernels is redundancy on paper.
The surviving disk starts something other than what this one
would, which is exactly the case the pair exists to cover. The
kernel check reads which version boots and says it does not
verify the loader's installation; this is that half.
"""
cmd = ('proxmox-boot-tool', 'status')
self.ctx.responses[cmd] = (0,
"System currently booted with uefi\n"
"654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n"
"6550-5CBE is configured with: uefi (versions: 6.7.0-1-pve)\n")
reasons = {r['reason_key'] for r in self.result(checks._boot_loader)['affected']}
self.assertIn('bootEspOutOfSync', reasons)
self.assertIn('bootEspMissingNewest', reasons)
# One partition is a working boot with a single point of failure.
self.ctx.responses[cmd] = (0,
"System currently booted with uefi\n"
"654E-D6BD is configured with: uefi (versions: 6.8.12-1-pve)\n")
single = self.result(checks._boot_loader)['affected'][0]
self.assertEqual(single['reason_key'], 'bootSingleEsp')
self.assertEqual(single['classification'], 'observation')
# A host that does not use the tool keeps its loader elsewhere.
del self.ctx.files['/etc/kernel/proxmox-boot-uuids']
self.assertIsNone(self.result(checks._boot_loader))
def test_disk_errors_are_warnings_separate_from_current_smart_health(self):
def grade(severity, days_ago):
self.observations[:] = [{'device_name': '/dev/sdh', 'error_type': 'io_error',
'severity': severity, 'occurrence_count': 284252,
'first_occurrence': NOW - 110 * 86400,
'last_occurrence': NOW - days_ago * 86400,
'raw_message': 'ata8.00: error: { IDNF }'}]
result = self.result(checks._disk_errors)
return result['affected'][0]['classification'] if result.get('affected') \
else result['classification']
# A recorded event asks for attention, but it does not override
# the separate current SMART/Proxmox health result or assert a
# present disk failure.
for severity, days in [('CRITICAL', 0), ('CRITICAL', 60),
('WARNING', 0), ('WARNING', 60)]:
self.assertEqual(grade(severity, days), 'warning',
f'{severity} {days}d was not reported as a warning')
# The observation log writes ISO strings while other Monitor
# tables write epoch seconds. Reading only one of them made an
# error happening now look like one that stopped long ago.
import datetime as _dt
iso = _dt.datetime.fromtimestamp(NOW - 3600).isoformat()
self.observations[:] = [{'device_name': '/dev/sdh', 'error_type': 'io_error',
'severity': 'critical', 'occurrence_count': 284340,
'first_occurrence': iso, 'last_occurrence': iso,
'raw_message': 'ata8.00: error: { IDNF }'}]
row = self.result(checks._disk_errors)['affected'][0]
self.assertEqual(row['classification'], 'warning')
self.assertEqual(row['reason_key'], 'diskErrorsActive')
# An empty store and a store the reader emptied look the same,
# and neither supports "no disk reported an error".
self.observations[:] = []
result = self.result(checks._disk_errors)
self.assertEqual(result['classification'], 'not_applicable')
self.assertEqual(result['summary_key'], 'noEvents')
def test_thin_pool_unknown_usage_does_not_pass(self):
for row, expected in [('vg|thin|100||twi|10|?', 'unverified'),
('vg|thin|0||twi|10|10', 'unverified'),
('vg|thin|100||twi|nan|10', 'unverified'),
('vg|thin|100||twi|95|?', 'warning')]:
self.ctx.responses[LVS_CMD] = (0, row)
result = self.result(checks._thin_overprovisioning)
self.assertEqual(result['classification'], expected)
self.assertTrue(result['incomplete'])
def test_replication_status_types_and_missing_error_text(self):
cmd = ('pvesh','get','/nodes/fixture/replication','--output-format','json')
for fields, expected in [({'disable':'0', 'fail_count':2}, 'warning'),
({'disable':'1', 'fail_count':2}, 'observation'),
({'last_sync':NOW+500}, 'unverified'),
({'disable':'unknown'}, 'unverified')]:
self.ctx.responses[cmd]=(0,json.dumps([{'id':'101-0','last_sync':NOW-100, **fields}]))
self.assertEqual(self.result(checks._replication_state)['classification'], expected)
for body in ('{}', 'null', '[3]'):
self.ctx.responses[cmd]=(0,body)
self.assertEqual(self.result(checks._replication_state)['classification'], 'unverified')
def test_old_snapshot_cpu_is_not_current_cpu(self):
self.ctx.qemu_configs={200:'name: fixture\n[snapshot]\ncpu: host\n'}
self.assertEqual(self.result(checks._cpu_host_type)['classification'], 'conformant')
def test_lynis_report_age_qualifies_the_warnings_it_came_with(self):
"""The age describes the report, not the host, so it rides with
the warnings it qualifies instead of standing as a check.
Reading "no warnings" without knowing the audit ran in June is
reading something else entirely.
"""
with patch.dict(self.ctx.lynis_report, {'mtime': NOW - 90 * 86400}):
result = self.result(checks._lynis_warnings)
self.assertEqual(result['summary_key'], 'noneStale')
self.assertEqual(result['affected'][0]['reason_key'], 'lynisReportStale')
self.assertEqual(result['affected'][0]['classification'], 'observation')
# A recent report with nothing to report is simply conformant.
self.assertEqual(self.result(checks._lynis_warnings)['classification'],
'conformant')
# An unusable date does not become an age, and does not stop the
# warnings from being reported.
for fields in ({'mtime': NOW + 86400}, {'complete': False}):
with patch.dict(self.ctx.lynis_report, fields):
result = self.result(checks._lynis_warnings)
self.assertNotIn('Stale', str(result.get('summary_key')))
def test_profiles_cover_their_declared_scope(self):
all_checks = engine.registered_checks()
for name, spec in audit_profiles.PROFILES.items():
actual = {c.check_id for c in audit_profiles.selected_checks(name, all_checks)}
expected = set(EXPECTED) if spec['areas'] is None else {
c.check_id for c in all_checks if c.area in spec['areas'] or c.check_id in spec['include']}
self.assertEqual(actual, expected, name)
def test_backup_verification_newest_not_oldest_and_each_destination(self):
"""The newest copy is what is graded, and never as critical.
The audit performs no restore, so it cannot demonstrate that
recovery is impossible; what it can say is whether anything
else verified.
"""
for older, newest, expected in [('failed','ok','conformant'), ('ok','failed','warning')]:
self.ctx.responses[PBS_CMD]=(0,json.dumps([self.snapshot(older,ctime=NOW-500), self.snapshot(newest)]))
self.assertEqual(self.result(checks._backup_verification)['classification'], expected)
self.ctx.storages.append({'id':'second','type':'pbs'})
self.ctx.responses[tuple(x.replace('/pbs/', '/second/') for x in PBS_CMD)] = (1,'unavailable')
result = self.result(checks._backup_verification)
self.assertEqual(result['classification'], 'warning')
self.assertTrue(result['incomplete'])
def test_verification_empty_missing_unknown_and_malformed(self):
for body in ('{}', 'null', 'invalid', '[3]'):
self.ctx.responses[PBS_CMD]=(0,body)
self.assertEqual(self.result(checks._backup_verification)['classification'], 'unverified')
for state, expected in [('none','observation'), ('unexpected','unverified')]:
self.ctx.responses[PBS_CMD]=(0,json.dumps([self.snapshot(state)]))
self.assertEqual(self.result(checks._backup_verification)['classification'], expected)
self.ctx.responses[PBS_CMD]=(0,'[]')
self.assertIsNone(self.result(checks._backup_verification))
def test_backup_tasks_do_not_count_unknown_status_as_success(self):
for status, expected in [('','unverified'),('running','unverified'),('job errors','warning'),('OK','conformant')]:
self.ctx.responses[TASK_CMD]=(0,json.dumps([{'type':'vzdump','status':status}]))
self.assertEqual(self.result(checks._backup_job_results)['classification'], expected)
self.ctx.responses[TASK_CMD]=(1,'offline')
self.assertEqual(self.result(checks._backup_job_results)['classification'], 'unverified')
def test_backup_task_recovers_guest_from_upid(self):
upid = 'UPID:fixture:001234:00ABCDEF:68BD1234:vzdump:106:root@pam:'
self.ctx.responses[TASK_CMD] = (0, json.dumps([{
'type': 'vzdump', 'status': 'job errors', 'upid': upid,
'starttime': NOW - 60,
}]))
result = self.result(checks._backup_job_results)
self.assertEqual(result['affected'][0]['vmid'], 106)
self.assertEqual(result['affected'][0]['upid'], upid)
def test_filesystem_partial_data_keeps_known_pressure(self):
for row, expected in [('/ 91% 10% 100 9','warning'),('/ 20% 95% 100 80','warning'),('/ 20% - 100 80','unverified')]:
self.ctx.responses[DF_CMD]=(0,'header\n'+row+'\n')
self.assertEqual(self.result(checks._filesystem_capacity)['classification'], expected)
self.ctx.responses[DF_CMD]=(1,'header\n/ 91% 10% 100 9\ndf: missing path\n')
result=self.result(checks._filesystem_capacity)
self.assertEqual(result['classification'],'warning'); self.assertTrue(result['incomplete'])
self.ctx.responses[DF_CMD]=(1,'df: failure')
self.assertEqual(self.result(checks._filesystem_capacity)['classification'],'unverified')
def test_pool_status_failure_is_not_healthy(self):
self.ctx.responses[('zpool','status','tank')]=(1,'unavailable')
self.assertEqual(self.result(checks._pool_integrity)['classification'],'unverified')
self.ctx.responses[('zpool','list','-H','-o','name,health')]=(0,'tank\tFAULTED\n')
result=self.result(checks._pool_integrity)
self.assertEqual(result['classification'],'critical'); self.assertTrue(result['incomplete'])
def test_pool_counters_are_reported_without_claiming_current_failure(self):
self.ctx.responses[('zpool','status','tank')]=(0,'state: ONLINE\n disk ONLINE 0 0 7\n')
result=self.result(checks._pool_integrity)
self.assertEqual(result['classification'],'warning')
self.assertIn('not necessarily',result['evidence'])
def test_cache_rebuild_is_not_a_repository_refresh(self):
self.ctx.files.pop('/var/lib/apt/periodic/update-success-stamp')
self.ctx.files['/var/cache/apt/pkgcache.bin']='rebuilt just now'
self.assertEqual(self.result(checks._update_chain)['classification'],'unverified')
self.ctx.files['/var/lib/apt/periodic/update-success-stamp']=''
self.stamps['/var/lib/apt/periodic/update-success-stamp']=NOW-8*86400
self.assertEqual(self.result(checks._update_chain)['classification'],'warning')
self.stamps['/var/lib/apt/periodic/update-success-stamp']=NOW+86400
self.assertEqual(self.result(checks._update_chain)['classification'],'unverified')
def test_notification_no_history_or_error_does_not_prove_delivery(self):
for payload in ({'history':[]}, {'history':[], 'error':'locked'}, {'history':[{'channel':'telegram','success':'0'}]}):
self.histories['telegram']=payload
self.assertEqual(self.result(checks._notification_delivery)['classification'],'unverified')
def test_notification_recovery_and_disabled_channel(self):
self.histories['telegram']['history'].append({'channel':'telegram','success':0,'sent_at':NOW-10})
self.channels['email']={'enabled':False,'configured':True}
self.assertEqual(self.result(checks._notification_delivery)['classification'],'conformant')
self.histories['telegram']['history'].insert(0,{'channel':'telegram','success':0,'error_message':'fixture error'})
result=self.result(checks._notification_delivery)
self.assertEqual(result['classification'],'warning')
self.assertEqual(result['affected'][0]['last_error'],'fixture error')
def test_notification_misconfiguration_and_unknown_channel_results(self):
self.channels['email']={'enabled':True,'configured':False}
self.histories['telegram']={'history':[]}
result=self.result(checks._notification_delivery)
self.assertEqual(result['classification'],'warning'); self.assertTrue(result['incomplete'])
self.channels={}
self.assertEqual(self.result(checks._notification_delivery)['classification'],'observation')
def test_certificate_just_expired_is_expired(self):
self.ctx.responses[('date','-d','fixture','+%s')]=(0,str(NOW-1))
result=self.result(checks._certificate_expiry)
self.assertEqual(result['classification'],'warning')
self.assertEqual(result['summary_key'],'expired')
def test_kernel_next_boot_is_the_pin_or_the_newest_retained(self):
"""Without a pin the boot tool starts the newest kernel it keeps.
Reading that as undetermined made the check unverifiable on every
host that never pinned one, which is most of them.
"""
cmd=('proxmox-boot-tool','kernel','list')
# Retained across both lists; the newest of them is what boots.
self.ctx.responses[cmd]=(0,'Manually selected kernels:\n6.9.0-1-pve\nAutomatically selected kernels:\n6.8.12-1-pve\n')
result = self.result(checks._kernel_current)
self.assertEqual(result['summary_key'],'newerSelected')
self.assertIn('6.9.0-1-pve', result['evidence'])
# The running kernel already being the newest retained is the
# ordinary state of a host that rebooted after its last upgrade.
self.ctx.responses[cmd]=(0,'Automatically selected kernels:\n6.8.12-1-pve\n6.7.0-1-pve\n')
self.assertEqual(self.result(checks._kernel_current)['classification'],'conformant')
# An explicit pin still wins over the retention lists.
self.ctx.responses[cmd]=(0,'Pinned kernel:\n6.8.12-1-pve\nKernel pinned on next-boot:\n6.9.0-1-pve\n')
self.assertEqual(self.result(checks._kernel_current)['summary_key'],'newerSelected')
def test_empty_ntp_and_ssh_output_do_not_prove_configuration(self):
self.ctx.responses[('timedatectl','show','-p','NTP','-p','NTPSynchronized')]=(0,'')
self.assertEqual(self.result(checks._time_sync)['classification'],'unverified')
self.ctx.responses[('sshd','-T')]=(0,'permitrootlogin yes\n')
self.assertEqual(self.result(checks._ssh_root_login)['classification'],'unverified')
def test_bond_unknown_member_not_claimed_as_link_failure(self):
self.ctx.files['/proc/net/bonding/bond0']='Slave Interface: eth0\n'
self.assertEqual(self.result(checks._bond_members)['classification'],'unverified')
self.ctx.files['/proc/net/bonding/bond0']='Slave Interface: eth0\nMII Status: down\n'
self.assertEqual(self.result(checks._bond_members)['classification'],'critical')
def test_policy_exemption_and_unstated_backups(self):
self.ctx.policy=audit_policy.Policy({'defaults':{'backup':'not_required'}})
self.assertIsNone(self.result(checks._last_backup_age))
self.ctx.policy=audit_policy.Policy()
self.ctx.vzdump_jobs=''
self.assertEqual(self.result(checks._guest_coverage)['classification'],'observation')
self.ctx.policy=audit_policy.Policy({'defaults':{'backup':'required'}})
self.assertEqual(self.result(checks._guest_coverage)['classification'],'warning')
if __name__ == '__main__': unittest.main()
+253
View File
@@ -0,0 +1,253 @@
// Render the quick-diagnosis document from the real builder, in every
// language, without a browser or an API. A short report that throws on
// click is worse than a long one that prints.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const appRequire = createRequire(path.join(app, 'package.json'));
const ts = appRequire('typescript');
function load(rel, imports = {}) {
const source = fs.readFileSync(path.join(app, rel), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
}}).outputText;
const module = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
name => imports[name] || appRequire(name), module, module.exports);
return module.exports;
}
global.window = { location: { origin: 'http://localhost:8008' } };
const shell = load('lib/report-shell.ts');
const evidence = load('lib/evidence-format.ts');
const diagrams = load('lib/report-diagrams.ts', { './report-shell': shell });
const presentation = load('lib/audit-presentation.ts', { './evidence-format': evidence });
const doc = load('lib/audit-document.ts', {
'./report-shell': shell, './report-diagrams': diagrams,
'./audit-presentation': presentation, './evidence-format': evidence,
});
const finding = (check_id, classification, area, extra = {}) => ({
check_id, classification, area, incomplete: false, summary_key: 'attention',
summary_params: { count: '3', total: '9' }, evidence: 'raw evidence',
affected: Array.from({ length: 25 }, (_, i) => ({
name: `object-${i}`, classification, reason_key: 'hostBackupStale',
})),
...extra,
});
const FINDINGS = [
finding('backup.host_recovery', 'critical', 'backup'),
finding('system.security_updates', 'warning', 'system'),
finding('guests.autostart', 'observation', 'guests'),
finding('storage.zfs_scrub_age', 'conformant', 'storage'),
{ ...finding('system.update_chain', 'unverified', 'system'), affected: [] },
];
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
const messages = JSON.parse(
fs.readFileSync(path.join(app, 'messages', locale, 'common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
const input = {
profile: 'diagnostic', findings: FINDINGS, t, locale,
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100,
status: 'partial', metadata: {} },
inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} },
};
const html = doc.buildAuditDocument(input);
// What it must contain: the findings that ask for a decision.
assert.ok(html.includes(t('audit.checks.backup.host_recovery.title')),
`${locale}: the critical finding is missing`);
assert.ok(html.includes(t('audit.checks.system.security_updates.title')),
`${locale}: the warning is missing`);
// And the blind spot it could not read.
assert.ok(html.includes(t('audit.document.diagnosticUnread')),
`${locale}: unread readings are not declared`);
// What it must not: conformant results, observations, the annex.
assert.ok(!html.includes(t('audit.checks.storage.zfs_scrub_age.title')),
`${locale}: a conformant result reached the quick diagnosis`);
assert.ok(!html.includes(t('audit.checks.guests.autostart.title')),
`${locale}: an observation reached the quick diagnosis`);
assert.ok(!html.includes('raw evidence'),
`${locale}: the technical annex reached the quick diagnosis`);
// Long tables are cut rather than printed whole.
assert.ok(html.includes('object-7') && !html.includes('object-9'),
`${locale}: affected rows are not capped at eight`);
assert.ok(html.includes(t('audit.document.diagnosticMoreRows', { count: '17' })),
`${locale}: the cut is not declared`);
assert.ok(!html.includes('undefined') && !html.includes('audit.document.'),
`${locale}: an untranslated key or an undefined value was rendered`);
}
// With nothing to decide it says so instead of printing an empty section.
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/en/common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
const clear = doc.buildAuditDocument({
profile: 'diagnostic', findings: [FINDINGS[3]], t, locale: 'en',
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} },
inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} },
});
assert.ok(clear.includes(t('audit.document.diagnosticClear')),
'a host with nothing to decide is not told so');
assert.ok(!clear.includes(t('audit.document.diagnosticActions')),
'an empty findings section was printed');
// A disk finding shows what happened, not six rows repeating that
// something did. The columns are the inventory's own, so the finding and
// the observation table read as one account of the disk.
{
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
const groups = presentation.presentFinding({
check_id: 'hardware.disk_errors', classification: 'warning',
area: 'hardware', evidence: null,
affected: [
{ name: 'sdh', type: 'io_error', severity: 'critical', count: 284364,
first_seen: '2026-05-20T23:03:39', last_seen: '2026-09-07T19:31:47',
message: 'ata8.00: error: { IDNF }', classification: 'warning',
reason_key: 'diskErrorsActive' },
{ name: 'sda', type: 'smart_error', severity: 'warning', count: 13,
first_seen: 1788000000, last_seen: 1788600000, message: 'read failed',
classification: 'warning', reason_key: 'diskWarningsActive' },
],
}, t, 'es', []);
assert.equal(groups.length, 2, 'events are not grouped by device');
assert.deepEqual(groups.map(g => g.title), ['sdh', 'sda']);
assert.deepEqual(groups[0].columns, [
t('audit.document.event'), t('audit.document.severity'),
t('audit.document.occurrences'), t('audit.document.firstSeen'),
t('audit.document.lastSeen'), t('audit.document.detail'),
], 'the finding does not use the inventory table columns');
const [type, severity, count, first, last, detail] = groups[0].rows[0].cells;
assert.equal(type, 'io_error');
assert.equal(severity, t('audit.classifications.critical'),
'the stored English severity reached a translated view');
assert.equal(count, '284364');
assert.ok(first.includes('2026') && last.includes('2026'),
'ISO timestamps were not rendered as dates');
assert.equal(detail, 'ata8.00: error: { IDNF }');
assert.equal(first, new Date(2026, 4, 20, 23, 3, 39).toLocaleString('es'),
'a local SQLite timestamp was converted as UTC');
// The other Monitor tables store epoch seconds; both forms must render.
const epochRow = groups[1].rows[0].cells;
assert.ok(epochRow[3].includes('2026') && epochRow[4].includes('2026'),
'epoch timestamps were not rendered as dates');
console.log('Disk findings: inventory columns, translated severity, both date forms.');
}
// "Could not be evaluated" describes the assessment, not the host. The
// reason is recorded against each source; it used to sit two collapsed
// panels below a line that explained nothing.
{
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
// Exactly what .55 recorded: a backup destination that refused the
// connection, which is why the age of its copies is unverified.
const line = presentation.unreadSources([
{ source: 'cmd:["pvesm", "list", "local"]', collected_at: 1788728305 },
{ source: 'cmd:["pvesm", "list", "pbs"]', collected_at: 1788728305,
error: "exit 111: pbs: error fetching datastores - 500 Can't connect to\n192.168.0.72:8007 (Connection refused)" },
], t);
assert.ok(line.startsWith(t('audit.presentation.couldNotRead')),
'the line does not say that something could not be read');
assert.ok(line.includes('pvesm list pbs'),
'the command was left in its serialised form');
assert.ok(!line.includes('cmd:['), 'the raw source key leaked into the reader\'s view');
assert.ok(line.includes('Connection refused'), 'the reason was dropped');
assert.ok(!line.includes('\n'), 'a multi-line error was not flattened');
assert.ok(!line.includes('pvesm list local'),
'a source that was read fine was listed as unreadable');
assert.equal(presentation.unreadSources([{ source: 'x', collected_at: 1 }], t), '',
'a check whose sources all worked printed an empty notice');
assert.equal(presentation.unreadSources(undefined, t), '');
console.log('Unread sources: named, flattened, only the ones that failed.');
}
// Lynis repeats a warning once per thing it applies to. Ten promiscuous
// interfaces printed as ten rows saying "NETW-3015 · —" described none
// of them; collapsed, each row carries a warning and how often it was
// raised.
{
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
const warn = (test, message, details = '') => ({
test, message, details, classification: 'observation', reason_key: 'lynisWarning' });
const finding = affected => ({ check_id: 'security.lynis_warnings',
classification: 'observation', area: 'security', evidence: null, affected });
const plain = presentation.presentFinding(finding([
warn('PKGS-7392', 'Found one or more vulnerable packages.'),
...Array.from({ length: 10 }, () => warn('NETW-3015', 'Found promiscuous interface')),
warn('MAIL-8818', 'SMTP banner discloses software'),
]), t, 'es', []);
assert.equal(plain.length, 1, 'warnings are still split into a group each');
assert.equal(plain[0].rows.length, 3, '12 warnings did not collapse to 3 rows');
assert.deepEqual(plain[0].columns, [t('audit.presentation.lynisTest'),
t('audit.presentation.lynisWarning'), t('audit.document.occurrences')],
'a detail column was printed with nothing to put in it');
const promiscuous = plain[0].rows.find(r => r.cells[0] === 'NETW-3015');
assert.equal(promiscuous.cells[2], '10', 'repetitions were not counted');
// Where Lynis names what it found, the names are kept and joined.
const named = presentation.presentFinding(finding([
warn('NETW-3015', 'Found promiscuous interface', 'ens4f0'),
warn('NETW-3015', 'Found promiscuous interface', 'eno1'),
]), t, 'es', []);
assert.equal(named[0].columns.length, 4, 'the detail column is missing');
assert.equal(named[0].rows[0].cells[3], 'ens4f0, eno1');
console.log('Lynis warnings: one row per warning, repetitions counted, names kept.');
}
// The inventory profile is the other short document: structure and
// configuration, with nothing assessed. An assessment summary counting
// nothing and a findings section listing nothing are two empty frames
// around the only thing its reader opened it for.
const structure = doc.buildAuditDocument({
profile: 'inventory', findings: [], t, locale: 'en',
run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} },
inventory: { sections: {
identity: { node: 'fixture', pve_version: '9.2.4' },
cluster: { member: false },
hardware: { cpu_model: 'Xeon', memory_total: 1, disks: [],
memory_modules: [], controllers: [] },
network: { bridges: {}, adapters: [] },
}, unavailable: {} },
});
assert.ok(structure.includes(t('audit.document.structureTitle')),
'the structure report is still titled as an audit');
assert.ok(!structure.includes(t('audit.document.executiveSummary')),
'an assessment summary counting nothing was printed');
assert.ok(!structure.includes(t('audit.document.findings')),
'a findings section listing nothing was printed');
assert.ok(!structure.includes(t('audit.presentation.annex')),
'the technical annex was printed with no evidence to carry');
assert.ok(structure.includes(t('audit.document.scope')),
'the structure report does not say what it covers');
console.log('Structure report: no assessment frames, own title, scope kept.');
console.log('Quick diagnosis: eight languages, only what needs deciding, capped tables, declared blind spots and cuts.');
+122
View File
@@ -0,0 +1,122 @@
// Component-state regression tests with isolated hooks and a mocked API.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const app = path.resolve(__dirname, '../AppImage');
const ts = require(path.join(app, 'node_modules/typescript'));
const messages = require(path.join(app, 'messages/en/common.json'));
const t = (key, params = {}) => key.split('.').reduce((v, k) => v?.[k], messages)
.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
let cursor = 0, states = [], effects = [], initialized = false, submitted, rejectSave = false;
const snapshot = { guests: {}, storages: {}, thresholds: {},
defaults: { backup: 'required', autostart: 'not_required', storage_role: 'essential', recovery_objective_hours: 48 } };
const hooks = {
useState(initial) { const i = cursor++; if (!(i in states)) states[i] = initial;
return [states[i], v => { states[i] = typeof v === 'function' ? v(states[i]) : v; }]; },
useMemo: fn => fn(), useCallback: fn => fn,
useEffect(fn) { if (!initialized) effects.push(fn); },
};
const jsx = (type, props) => ({ type, props: props || {} });
const api = async (url, options) => {
if (options) {
submitted = JSON.parse(options.body);
if (rejectSave) throw Object.assign(new Error('conflict'), { status: 409 });
return { success: true, summary: { revision: 'second' } };
}
if (url.includes('inventory')) return { inventory: { sections: {
guests: [{ vmid: 100, name: 'fixture', type: 'lxc' }], storages: [{ id: 'pbs', type: 'pbs' }],
} } };
return { success: true, policy: snapshot, summary: { revision: 'first' }, vocabulary: {
expectations: ['required', 'not_required', 'unspecified'], roles: ['essential', 'optional', 'unspecified'],
thresholds: { storage_usage_percent: 90 },
} };
};
const mod = { exports: {} };
const source = fs.readFileSync(path.join(app, 'components/audit-policy.tsx'), 'utf8');
const js = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
} }).outputText;
new Function('require', 'module', 'exports', js)(name => {
if (name === 'react') return hooks;
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
if (name.endsWith('api-config')) return { fetchApi: api };
if (name.endsWith('provider')) return { useT: () => t };
return new Proxy({}, { get: (_, name) => String(name) });
}, mod, mod.exports);
function render() { cursor = 0; const tree = mod.exports.AuditPolicy(); initialized = true; return tree; }
function nodes(tree, type) {
if (!tree || typeof tree !== 'object') return [];
if (Array.isArray(tree)) return tree.flatMap(x => nodes(x, type));
if (typeof tree.type === 'function') return nodes(tree.type(tree.props), type);
return [...(tree.type === type ? [tree] : []), ...nodes(tree.props?.children, type)];
}
const change = (node, value) => node.props.onChange({ target: { value } });
// The dropdowns are the shared Select, which reports a value rather than
// an event. Unresolved imports come back as their own name, so the
// element type is the component's name.
const choose = (node, value) => node.props.onValueChange(value);
const tick = () => new Promise(resolve => setImmediate(resolve));
(async () => {
render(); effects.forEach(fn => fn()); await tick();
// The form is locked until the reader says they are editing it.
let tree = render();
assert.equal(nodes(tree, 'fieldset')[0].props.disabled, true,
'The declaration is editable before anyone asked to edit it');
const editButton = nodes(tree, 'button').find(
b => JSON.stringify(b.props.children).includes(messages.actions.edit));
assert(editButton, 'No edit button to unlock the declaration');
// A disabled fieldset disables every control it holds, so the button that
// leaves that state cannot live inside it.
assert(!nodes(nodes(tree, 'fieldset')[0], 'button').includes(editButton),
'The edit button sits inside the fieldset it unlocks, so it is never clickable');
// The dropdown governs its own opening, so the fieldset does not reach it.
assert(nodes(tree, 'Select').every(sel => sel.props.disabled === true),
'A locked declaration still opens its dropdowns');
editButton.props.onClick(); tree = render();
assert.equal(nodes(tree, 'fieldset')[0].props.disabled, false);
assert(nodes(tree, 'Select').every(sel => sel.props.disabled === false),
'Editing does not unlock the dropdowns');
let select = nodes(tree, 'Select');
assert.equal(select[0].props.value, 'inherit');
const inherited = (value) => messages.audit.policy.inherit.replace('{value}', value);
assert.equal(nodes(select[0], 'SelectItem')[0].props.children, inherited('Required'));
assert.equal(nodes(select[2], 'SelectItem')[0].props.children, inherited('Essential'));
choose(select[0], 'unspecified'); choose(select[2], 'unspecified');
tree = render();
assert.equal(nodes(tree, 'Select')[0].props.value, 'unspecified');
nodes(tree, 'form')[0].props.onSubmit({ preventDefault() {} }); await tick();
assert.equal(submitted.expected_revision, 'first');
assert.equal(submitted.guests['100'].backup, 'unspecified');
assert.equal(submitted.storages.pbs.role, 'unspecified');
tree = render(); choose(nodes(tree, 'Select')[0], 'inherit');
tree = render();
const inputs = nodes(tree, 'input');
assert.equal(inputs[0].props.placeholder, '48');
assert.equal(inputs[1].props.max, 100);
change(inputs[1], '-1'); tree = render();
assert.equal(nodes(tree, 'input')[1].props.value, -1, 'Invalid value is not silently cleared');
change(nodes(tree, 'input')[1], ''); tree = render();
rejectSave = true;
nodes(tree, 'form')[0].props.onSubmit({ preventDefault() {} }); await tick(); tree = render();
assert.equal(submitted.expected_revision, 'second');
assert.equal(submitted.guests['100'], undefined);
assert.equal(nodes(tree, 'fieldset')[0].props.disabled, true);
assert(JSON.stringify(tree).includes(messages.audit.policy.conflict));
assert(JSON.stringify(tree).includes(messages.audit.policy.reload));
// With nothing declared site-wide there is no value to name, and the
// explicit option that would say the same thing is not offered twice.
Object.assign(snapshot.defaults, { backup: undefined, autostart: undefined,
storage_role: undefined });
cursor = 0; states = []; effects = []; initialized = false;
render(); effects.forEach(fn => fn()); await tick(); tree = render();
select = nodes(tree, 'Select');
const first = nodes(select[0], 'SelectItem');
assert.equal(first[0].props.children, messages.audit.policy.inheritUnset,
'The default option names a value nobody declared');
assert(!first.slice(1).some(i => i.props.value === 'unspecified'),
'The dropdown offers the same outcome twice');
console.log('Policy UI: inheritance, explicit unspecified, numeric constraints, revision and conflict tests passed');
})().catch(error => { console.error(error); process.exitCode = 1; });
+126
View File
@@ -0,0 +1,126 @@
"""Policy validation and atomic updates. All writes stay in temporary directories."""
import concurrent.futures
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
import audit_policy as policy
class AuditPolicyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "policy.json"
def save(self, raw, **kwargs):
return policy.save(raw, self.path, **kwargs)
def test_missing_is_not_declared(self):
value = policy.load(self.path)
self.assertFalse(value.declared)
self.assertIsNone(value.error)
self.assertEqual(value.revision, "missing")
def test_inheritance_and_explicit_unspecified_round_trip(self):
value = self.save({"defaults": {"backup": "required", "autostart": "required",
"storage_role": "essential", "recovery_objective_hours": 48},
"guests": {"100": {"backup": "unspecified", "autostart": "not_required"}},
"storages": {"local": {"role": "unspecified"}}})
self.assertEqual(value.backup_required(100), "unspecified")
self.assertEqual(value.backup_required(101), "required")
self.assertEqual(value.autostart_required(100), "not_required")
self.assertEqual(value.storage_role("local"), "unspecified")
self.assertEqual(value.storage_role("pbs"), "essential")
self.assertEqual(value.recovery_objective_hours(100), 48)
def test_invalid_numbers_rejected_without_changing_saved_policy(self):
self.save({"thresholds": {"storage_usage_percent": 90}})
before = self.path.read_bytes()
for value in (True, False, 0, -1, float("nan"), float("inf"), -float("inf"), "12", 10**400):
for raw in ({"thresholds": {"storage_usage_percent": value}},
{"guests": {"100": {"recovery_objective_hours": value}}},
{"defaults": {"recovery_objective_hours": value}}):
with self.subTest(raw=raw), self.assertRaises(ValueError):
self.save(raw)
self.assertEqual(before, self.path.read_bytes())
def test_percentage_bounds_and_positive_fractional_values(self):
with self.assertRaises(ValueError):
self.save({"thresholds": {"storage_usage_percent": 101}})
value = self.save({"thresholds": {"storage_usage_percent": 100, "thin_overprovision_ratio": 2.5},
"guests": {"100": {"recovery_objective_hours": 0.5}}})
self.assertEqual(value.recovery_objective_hours(100), 0.5)
self.assertEqual(value.threshold("thin_overprovision_ratio"), 2.5)
def test_invalid_sections_and_defaults_rejected(self):
for name in ("guests", "storages", "thresholds", "defaults"):
for value in ([], False, "", None):
with self.subTest(name=name, value=value), self.assertRaises(ValueError):
self.save({name: value})
for name in ("backup", "autostart", "storage_role"):
with self.assertRaises(ValueError):
self.save({"defaults": {name: "invalid"}})
def test_manual_invalid_file_is_visible_and_not_overwritten(self):
self.path.write_text('{"thresholds":{"storage_usage_percent":Infinity}}')
value = policy.load(self.path)
self.assertTrue(value.error)
with self.assertRaises(ValueError):
self.save({}, expected_revision=value.revision)
def test_stale_editor_is_rejected(self):
first = self.save({})
second = self.save({"defaults": {"backup": "required"}}, expected_revision=first.revision)
with self.assertRaises(policy.PolicyConflict):
self.save({}, expected_revision=first.revision)
self.assertEqual(policy.load(self.path).revision, second.revision)
def test_deleted_file_is_also_a_conflict(self):
first = self.save({})
self.path.unlink()
with self.assertRaises(policy.PolicyConflict):
self.save({}, expected_revision=first.revision)
def test_concurrent_editors_only_one_can_save(self):
revision = self.save({}).revision
def write(i):
try:
self.save({"guests": {str(i): {"backup": "required"}}}, expected_revision=revision)
return "saved"
except policy.PolicyConflict:
return "conflict"
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(write, range(100, 108)))
self.assertEqual(results.count("saved"), 1)
self.assertEqual(results.count("conflict"), 7)
self.assertEqual(len(json.loads(self.path.read_text())["guests"]), 1)
self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), [])
def test_failed_replace_preserves_original_and_cleans_temp(self):
self.save({})
before = self.path.read_bytes()
with patch.object(Path, "replace", side_effect=OSError("fixture failure")):
with self.assertRaises(OSError):
self.save({"defaults": {"backup": "required"}})
self.assertEqual(before, self.path.read_bytes())
self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), [])
def test_private_permissions_and_same_mtime_changes(self):
first = self.save({})
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
stamp = self.path.stat().st_mtime_ns
self.path.write_text('{"defaults":{"backup":"required"}}')
os.utime(self.path, ns=(stamp, stamp))
fresh = policy.load(self.path)
self.assertNotEqual(first.revision, fresh.revision)
self.assertEqual(fresh.backup_required(100), "required")
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
"""Policy endpoint contracts; authentication and storage are isolated fixtures."""
import importlib
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
from flask import Flask
import audit_policy as policy
import audit_store as store
class PolicyApiTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.path = Path(temp.name) / "policy.json"
original_load, original_save = policy.load, policy.save
for patcher in (
patch.object(policy, "load", side_effect=lambda *args: original_load(self.path)),
patch.object(policy, "save", side_effect=lambda raw, **kw: original_save(raw, self.path, **kw)),
patch.object(store, "DB_PATH", Path(temp.name) / "audit.db"),
patch.object(store, "_schema_ready", False),
):
patcher.start(); self.addCleanup(patcher.stop)
auth = types.ModuleType("auth_manager")
auth.load_auth_config = lambda: {"enabled": True}
auth.verify_token = lambda token: "fixture"
middleware = types.ModuleType("jwt_middleware")
middleware.require_auth = lambda f: f
middleware.require_admin_scope = lambda f: f
with patch.dict(sys.modules, auth_manager=auth, jwt_middleware=middleware):
sys.modules.pop("flask_audit_routes", None)
routes = importlib.import_module("flask_audit_routes")
self.addCleanup(lambda: sys.modules.pop("flask_audit_routes", None))
app = Flask(__name__)
app.register_blueprint(routes.audit_bp)
self.client = app.test_client()
def test_revision_and_conflict_contract(self):
first = self.client.get("/api/audit/policy").json
self.assertEqual(first["summary"]["revision"], "missing")
self.assertEqual(self.client.put("/api/audit/policy", json={}).status_code, 428)
payload = {"expected_revision": "missing", "defaults": {"backup": "required"}}
response = self.client.put("/api/audit/policy", json=payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.client.put("/api/audit/policy", json=payload).status_code, 409)
self.assertEqual(self.client.get("/api/audit/policy").json["policy"]["defaults"]["backup"], "required")
def test_validation_error_is_not_silent_success(self):
bad = {"expected_revision": "missing", "thresholds": {"storage_usage_percent": True}}
self.assertEqual(self.client.put("/api/audit/policy", json=bad).status_code, 400)
self.assertFalse(self.path.exists())
def test_invalid_file_does_not_open_empty_editor(self):
self.path.write_text("invalid json")
self.assertEqual(self.client.get("/api/audit/policy").status_code, 422)
if __name__ == "__main__":
unittest.main()
+159
View File
@@ -0,0 +1,159 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {createRequire} = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const appRequire = createRequire(path.join(app,'package.json'));
const ts = appRequire('typescript');
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = {exports:{}}; cache.set(file,mod);
const js = ts.transpileModule(fs.readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText;
new Function('require','module','exports',js)(name => {
if (!name.startsWith('.')) return appRequire(name);
const base=path.resolve(path.dirname(file),name);
return load(fs.existsSync(base+'.ts') ? base+'.ts' : base+'.tsx');
},mod,mod.exports);
return mod.exports;
}
function translate(locale) {
const messages=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json')));
return (key,params={}) => {
const text=key.split('.').reduce((o,k)=>o?.[k],messages);
return typeof text==='string' ? text.replace(/\{(\w+)\}/g,(m,k)=>params[k] ?? m) : key;
};
}
global.window={location:{origin:'http://localhost'}};
const presentation=load(path.join(app,'lib/audit-presentation.ts'));
const {buildAuditDocument}=load(path.join(app,'lib/audit-document.ts'));
const {storageDiagram}=load(path.join(app,'lib/report-diagrams.ts'));
const base=(id,classification,affected=[])=>({check_id:id,area:id.split('.')[0],severity:'INFO',classification,summary_key:null,summary_params:{},affected,evidence:null});
const coverage=base('backup.guest_coverage','observation',[
...[109,111,112,114,9510].map(vmid=>({vmid,classification:'observation',reason_key:'noJobSelectsGuest'})),
...['sata0','sata1','sata2','sata3','scsi1'].map(volume=>({vmid:106,volume,reason_key:'dataExcludedFromBackup',classification:'observation'})),
{vmid:110,volume:'scsi1',reason_key:'dataExcludedFromBackup',classification:'observation'}]);
const lynis=base('security.lynis_warnings','observation',[...['enp3s0','tap106i0','tap105i0'].map(details=>({test:'NETW-3015',message:'Found promiscuous interface',details,solution:'Do not show this advice',classification:'observation'}))]);
const age=base('backup.last_backup_age','warning',[{vmid:101,storage:'PBS-Cloud',classification:'warning',reason_key:'olderThanSchedule'},{vmid:109,storage:'any',classification:'observation',reason_key:'noStoredBackupUnscheduled'}]);
age.evidence=JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252}]);
for(const locale of ['en','es','de','fr','it','pt','sk','sv']) {
const t=translate(locale);
assert(!t('audit.checks.backup.last_backup_age.rationale').includes('ProxMenux'));
assert(!t('audit.presentation.limitReference').includes('ProxMenux'));
const groups=presentation.presentFinding(coverage,t,locale);
assert.equal(groups[0].rows.length,5); assert.equal(groups[1].rows.length,2);
assert(presentation.affectedDescription(coverage,t).includes('6'));
assert(!presentation.affectedDescription(coverage,t).includes('11'));
const lxcExcluded=base('backup.guest_coverage','observation',[{vmid:120,name:'container',type:'lxc',volume:'mp0',reason_key:'dataExcludedFromBackup',classification:'observation'}]);
assert.equal(presentation.presentFinding(lxcExcluded,t,locale)[0].rows[0].cells[0],'container · LXC 120');
const unavailable=base('backup.last_backup_age','unverified',[{vmid:120,storage:'offline',classification:'unverified',reason_key:'destinationUnavailable'}]);
const unavailableText=JSON.stringify(presentation.presentFinding(unavailable,t,locale));
assert(!unavailableText.includes(t('audit.presentation.notFound')));
assert(unavailableText.includes(t('audit.classifications.unverified')));
const text=JSON.stringify(presentation.presentFinding(lynis,t,locale));
assert(!text.includes('Do not show this advice'));
assert(text.includes('tap106i0'));
assert.equal(presentation.presentFinding(lynis,t,locale).length,1);
assert.equal(presentation.presentFinding(age,t,locale).length,2);
assert.notEqual(presentation.auditDuration(259.1,locale),presentation.auditDuration(252,locale));
for(const [policy,labelKey] of [['schedule and grace','limitSchedule'],['declared recovery objective','limitDeclared'],['fallback; no recovery objective declared and schedule not read','limitReference']]) {
const data={...age,affected:[age.affected[0]],evidence:JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252,age_policy:policy}])};
const group=presentation.presentFinding(data,t,locale)[0];
assert(group.columns.includes(t('audit.presentation.backupAge')));
assert(group.columns.includes(t('audit.presentation.backupLimit')));
assert(!group.columns.includes(t('audit.presentation.ageLimit')));
assert(group.rows[0].cells.includes(presentation.auditDuration(259.1,locale)));
assert(group.rows[0].cells.some(cell=>cell.includes(t('audit.presentation.'+labelKey)) && cell.includes(presentation.auditDuration(252,locale))));
}
const legacyText=JSON.stringify(presentation.presentFinding(age,t,locale));
assert(!legacyText.includes(t('audit.presentation.limitSchedule')));
const implicitDestination={...age,affected:[{vmid:112,storage:'local',classification:'warning',reason_key:'olderThanFallback'}],evidence:JSON.stringify([{vmid:112,expected_storage:'any visible destination (no explicit target)',storage:'local',last_backup:1787763643,age_hours:800,max_age_hours:720,age_policy:'fallback; no recovery objective declared and schedule not read'}])};
const implicitText=JSON.stringify(presentation.presentFinding(implicitDestination,t,locale));
assert(implicitText.includes(presentation.auditDuration(720,locale)),
`${locale}: a backup without an explicit job destination lost its limit`);
const failedRuns=base('backup.job_results','warning',[
{vmid:106,status:'job errors',when:1787760000,upid:'UPID:first',classification:'warning',reason_key:'backupRunFailed'},
{vmid:106,status:'job errors',when:1787763600,upid:'UPID:last',classification:'warning',reason_key:'backupRunFailed'},
{vmid:110,status:'storage unavailable',when:1787767200,upid:'UPID:other',classification:'warning',reason_key:'backupRunFailed'},
]);
const failedGroups=presentation.presentFinding(failedRuns,t,locale);
assert.equal(failedGroups.length,1);
assert.equal(failedGroups[0].rows.length,2,
`${locale}: repeated backup failures were not grouped`);
assert.equal(failedGroups[0].rows.find(row=>row.cells[0].includes('106')).cells[1],'2');
assert(failedGroups[0].rows.some(row=>row.cells.includes('UPID:last')),
`${locale}: the latest backup task reference was not retained`);
const connected={...base('storage.connected_storage','conformant'),evidence:JSON.stringify({storages:[
{storage:'store-fixture',type:'pbs',status:'active',dependencies:[{vmid:101}],jobs:['backup-1'],capacity_known:true,used_percent:42.5},
],scope:'PVE-side observations only'})};
const connectedGroups=presentation.presentFinding(connected,t,locale);
assert.equal(connectedGroups.length,1);
assert.deepEqual(connectedGroups[0].columns,[t('audit.document.storage'),t('audit.document.type'),
t('audit.document.state'),t('audit.presentation.capacity'),t('audit.presentation.fact')]);
assert(connectedGroups[0].rows[0].cells.some(cell=>cell.includes('42')),
`${locale}: connected storage capacity was not presented`);
const thin={...base('storage.thin_pool_overprovisioning','warning',[
{pool:'pve/data',metric:'metadata',classification:'warning',reason_key:'thinMetadataPressure'},
]),evidence:JSON.stringify([{pool:'pve/data',allocated_bytes:214748364800,
pool_bytes:107374182400,allocation_percent:200,data_percent:81.2,metadata_percent:92.4}])};
const thinGroups=presentation.presentFinding(thin,t,locale);
assert.equal(thinGroups.length,1);
assert.deepEqual(thinGroups[0].columns,[t('audit.presentation.resource'),
t('audit.presentation.capacity'),t('audit.presentation.data'),
t('audit.presentation.metadata'),t('audit.presentation.fact')]);
assert(thinGroups[0].rows[0].cells[1].includes('GiB'),
`${locale}: thin-pool byte values were not made readable`);
const passing={...base('system.time_synchronisation','conformant'),evidence:'NTP: yes\nNTPSynchronized: yes'};
const input={profile:'full',run:null,findings:[coverage,age,lynis,passing,base('system.security_updates','unverified')],inventory:null,t,locale};
const html=buildAuditDocument(input);
const header=html.split('<div class="exec-box">')[1].split('<div class="audit-counters">')[0];
assert(header.includes('<strong>4/5</strong>'));
assert(header.includes('stroke-dasharray="80 100"'));
assert(header.includes(t('audit.presentation.verified')));
assert(!header.includes('health-ring'));
assert(!header.includes('health-lbl'));
assert(!header.includes('15 de 26'));
assert(header.includes('audit-result-heading'));
assert(header.includes('stroke="currentColor"'));
const checkedHtml=html.split('id="verified-checks"')[1].split('id="unverified-checks"')[0];
assert.equal((checkedHtml.match(/href="#finding-/g)||[]).length,4);
assert(checkedHtml.includes(t('audit.presentation.verifiedChecks')+' · 4'));
assert(checkedHtml.includes(t('audit.checks.system.time_synchronisation.title')));
assert(!checkedHtml.includes('href="#finding-system.security_updates"'));
assert(html.indexOf('id="verified-checks"') < html.indexOf(t('audit.presentation.overview')));
assert(!html.includes('audit.document.area'));
for(const [findings,expected] of [
[[base('x','critical'),base('y','observation'),base('z','not_applicable')],'2/2'],
[[{...base('x','warning'),incomplete:true,decision:'accepted'},base('y','conformant')],'1/2'],
[[{...base('x','unverified'),decision:'accepted'}],'0/1'],
[[base('x','not_applicable')],'—'],
[[],'—']]) {
const doc=buildAuditDocument({...input,findings});
assert(doc.includes(`<strong>${expected}</strong>`));
const verifiedSection=doc.split('id="verified-checks"')[1]?.split('</table>')[0] || '';
const expectedCount=expected==='—'?0:Number(expected.split('/')[0]);
assert.equal((verifiedSection.match(/href="#finding-/g)||[]).length,expectedCount);
if(expected==='—') assert(doc.includes('stroke-dasharray="0 100"'));
}
assert(!html.includes('health-icon" style="font-size:26px'));
assert(html.includes(t('audit.presentation.incomplete')));
assert(!html.includes('{count}'));
assert(!html.includes('audit.presentation.'));
const main=html.split('id="evidence-')[0];
assert(!main.includes('noJobSelectsGuest'));
assert(!main.includes('Do not show this advice'));
assert(html.includes(t('audit.presentation.evidenceObserved')),
`${locale}: conformant findings have no visible evidence`);
assert(!html.includes('id="evidence-system.time_synchronisation"'),
`${locale}: conformant raw evidence still bloats the appendix`);
const structuredPassing=buildAuditDocument({...input,findings:[connected]});
assert.equal((structuredPassing.match(/store-fixture/g)||[]).length,1,
`${locale}: structured conformant evidence was printed twice`);
const dangerous={...coverage,affected:[{vmid:109,name:'<img src=x onerror=alert(1)>',classification:'observation'}]};
assert(!buildAuditDocument({...input,findings:[dangerous]}).includes('<img src=x'));
}
const diagram=storageDiagram([{vmid:1,name:'one',disks:[{storage:'local'},{storage:'local'}],backups:[]}],{guests:'Guests',storage:'Storage',backup:'Jobs',unprotected:'No job'});
assert(!diagram.includes('>2</text>'));
console.log('Audit presentation: eight languages, truthful counts, calendar age, Lynis grouping, no advice, escaping and incomplete results passed.');
module.exports={load,translate,base,coverage,age,lynis,buildAuditDocument};
+75
View File
@@ -0,0 +1,75 @@
"""Pure regression tests: no imports that probe the host, no production writes."""
import ast
import re
import unittest
from types import SimpleNamespace
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "AppImage/scripts"
def functions(file, names):
tree = ast.parse((SCRIPTS / file).read_text())
wanted = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
namespace = {"re": re, "_WEEKDAYS": {day: i for i, day in enumerate(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])},
"_SHORTHAND": {"daily": 86400, "weekly": 604800}}
exec(compile(ast.Module(body=wanted, type_ignores=[]), file, "exec"), namespace)
return namespace
class AuditPresentationTests(unittest.TestCase):
def test_enterprise_configuration_is_not_conformance(self):
tree = ast.parse((SCRIPTS / "audit_checks_pve.py").read_text())
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_enterprise_repo")
node.decorator_list = []
ns = {"re": re, **{f"CLASS_{s.upper()}": s for s in ("observation", "conformant", "warning", "unverified")}}
exec(compile(ast.Module(body=[node], type_ignores=[]), "enterprise", "exec"), ns)
check = ns["_enterprise_repo"]
for source in ({}, {"pve.list": "# deb https://enterprise.proxmox.com/debian/pve stable pve-enterprise"},
{"pve.sources": "URIs: https://enterprise.proxmox.com/debian/pve\nEnabled: no\n"}):
ctx = SimpleNamespace(apt_sources=source, run=lambda *_: self.fail("Disabled repository must not query subscription"))
self.assertEqual(check(ctx)["classification"], "observation")
for source in ({"pve.list": "deb https://enterprise.proxmox.com/debian/pve stable pve-enterprise"},
{"pve.sources": "URIs: https://enterprise.proxmox.com/debian/pve\nEnabled: yes\n"}):
for rc, status, expected in [(0,"active","observation"), (0,"new","observation"),
(0,"notfound","warning"), (0,"invalid","warning"),
(0,"expired","warning"), (0,"suspended","warning"),
(1,"active","unverified"), (0,"","unverified"),
(0,"unexpected","unverified")]:
with self.subTest(source=source, rc=rc, status=status):
ctx = SimpleNamespace(apt_sources=source, run=lambda *_: (rc, f"status: {status}"))
self.assertEqual(check(ctx)["classification"], expected)
def test_lynis_current_message_and_details_are_distinct(self):
parse = functions("security_manager.py", {"_parse_lynis_warning"})["_parse_lynis_warning"]
row = parse("NETW-3015|Found promiscuous interface|tap106i0|text:upstream text|")
self.assertEqual(row["description"], "Found promiscuous interface")
self.assertEqual(row["details"], "tap106i0")
self.assertEqual(row["severity"], "")
self.assertEqual(parse("PKGS-7392|Actual warning|-|-|")["description"], "Actual warning")
self.assertEqual(parse("PKGS-7392|Actual warning|-|-|")["details"], "")
self.assertEqual(parse("OLD-0001|H|Legacy warning|legacy solution")["description"], "Legacy warning")
self.assertIsNone(parse("broken"))
def test_audit_does_not_surface_upstream_solutions(self):
entry = functions("audit_checks_pve.py", {"_lynis_entry"})["_lynis_entry"]
row = entry({"test_id": "NETW-3015", "description": "Found promiscuous interface", "details": "tap1", "solution": "DO SOMETHING"})
self.assertNotIn("solution", row)
self.assertEqual(row["details"], "tap1")
def test_longest_gap_respects_each_scheduled_instant(self):
names = {"_weekday_set", "_longest_gap", "_schedule_interval", "_schedule_age_limit"}
ns = functions("audit_checks_pve.py", names)
interval = ns["_schedule_interval"]
for schedule, hours in [("sun 07:00", 168), ("sun 01:00,13:00", 156),
("01:00,02:00", 23), ("01:00,01:00", 24),
("mon..fri 07:00", 72), ("mon,wed 01:00", 120)]:
with self.subTest(schedule=schedule):
self.assertEqual(interval(schedule), hours * 3600)
self.assertIsNone(interval("01:00:99"))
self.assertIsNone(interval("mon..fri */2:00"))
self.assertEqual(ns["_schedule_age_limit"]("sun 07:00"), 252 * 3600)
if __name__ == "__main__":
unittest.main()
+585
View File
@@ -0,0 +1,585 @@
"""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()
+81
View File
@@ -0,0 +1,81 @@
// Render the real summary JSX with fixture state, without API calls or effects.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const app = path.resolve(__dirname, '../AppImage');
const appRequire = createRequire(path.join(app, 'package.json'));
const React = appRequire('react');
const { renderToStaticMarkup } = appRequire('react-dom/server');
const ts = appRequire('typescript');
const {load} = require('./test_audit_presentation.cjs');
const source = fs.readFileSync(path.join(app, 'components/audit-report.tsx'), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020,
}}).outputText;
function render(locale, findings, status = 'partial') {
const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages', locale, 'common.json')));
const t = (key, values = {}) => {
let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key;
for (const [key, value] of Object.entries(values)) text = text.replaceAll(`{${key}}`, value);
return text;
};
const summary = findings.reduce((o, f) => ({...o, [f.classification]: (o[f.classification] || 0) + 1}), {});
// Positional: one entry per useState in audit-report.tsx, in order.
const states = ['assessment', false, {status, finished_at: Date.now()/1000}, findings, summary,
'all', new Set(findings.map(f=>f.check_id)), null, false, null, '', '', false,
{completed: 0, total: 36},'full',[],false];
let index = 0;
const ui = tag => ({children, ...props}) => React.createElement(tag, props, children);
const imports = {
react: {...React, useState: () => [states[index++], () => {}], useEffect: () => {},
useMemo: cb => cb(), useCallback: cb => cb},
'./ui/card': {Card: ui('section'), CardContent: ui('div'), CardHeader: ui('header'), CardTitle: ui('h2')},
'./ui/button': {Button: ui('button')}, './ui/badge': {Badge: ui('span')},
'./ui/dialog': {Dialog: () => null},
'../lib/api-config': {fetchApi: () => {throw Error('unexpected API call')}},
'../lib/i18n/provider': {useT: () => t, useI18n:()=>({language:locale})},
'./audit-inventory': {AuditInventory:()=>null},
'./audit-policy': {AuditPolicy:()=>null},
'./audit-changes': {AuditChanges:()=>null},
'./audit-comparison': {AuditComparison:()=>null},
'./ui/label': {Label: ui('label')},
'./ui/select': {Select: ui('div'), SelectContent: ui('div'), SelectItem: ui('option'),
SelectTrigger: ui('div'), SelectValue: ui('span')},
'../lib/audit-document': {},
'./audit-evidence': load(path.join(app,'components/audit-evidence.tsx')),
'./audit-finding-data': load(path.join(app,'components/audit-finding-data.tsx')),
'../lib/audit-presentation': load(path.join(app,'lib/audit-presentation.ts')),
};
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled)(name => imports[name] || appRequire(name), module, module.exports);
return {html: renderToStaticMarkup(React.createElement(module.exports.AuditReport)), t};
}
// renderToStaticMarkup escapes text, so a translation containing an
// apostrophe never matches its raw form. Compare against what React
// actually writes.
const esc = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;');
const finding = (check_id, state, severity, incomplete = false) => ({
check_id, state, severity, classification: {fail:'critical',warn:'warning',unknown:'unverified'}[state] || state,
incomplete, area: check_id.split('.')[0], affected: [], evidence:null, summary_key: null,
});
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
const fixtures = [finding('backup.guest_coverage', 'fail', 'CRITICAL'),
finding('system.pending_reboot', 'warn', 'WARNING'),
finding('hardware.disk_service_life', 'unknown', 'INFO'),
finding('security.lynis_warnings', 'unknown', 'WARNING')];
const {html, t} = render(locale, fixtures);
assert(html.includes(`aria-label="${t('audit.results')}"`));
assert(html.includes(esc(t('audit.unverifiedChecks', {checks: [
t('audit.checks.hardware.disk_service_life.title'), t('audit.checks.security.lynis_warnings.title'),
].join(' · ')}))));
assert.equal((html.match(/h-6 gap-1.5 whitespace-nowrap px-2.5 py-0 text-xs/g) || []).length, 3);
assert(html.includes('flex max-w-full flex-wrap items-center gap-2'));
assert(!render(locale, [], 'complete').html.includes('role="alert"'));
assert(!render(locale, []).html.includes(`aria-label="${t('audit.severityGroup')}"`));
const partial = render(locale, [finding('backup.guest_coverage', 'warn', 'CRITICAL', true)]);
assert(partial.html.includes(esc(t('audit.unverifiedChecks',
{checks: t('audit.checks.backup.guest_coverage.title')}))));
}
console.log('Audit summary: eight locales, uniform counters, labelled groups and partial/complete states passed.');