fix: clarify audit side effects and boot-check scope

This commit is contained in:
martino
2026-09-17 18:23:56 +02:00
parent dede876f0b
commit 60f71c0c8a
6 changed files with 177 additions and 14 deletions
+4 -4
View File
@@ -5081,7 +5081,7 @@
"evidenceObserved": "Observed evidence", "evidenceObserved": "Observed evidence",
"evidenceExcerpt": "Compact view. The complete source evidence remains stored with this assessment.", "evidenceExcerpt": "Compact view. The complete source evidence remains stored with this assessment.",
"annexScope": "Complete source evidence for results that require attention, record an observation or could not be verified.", "annexScope": "Complete source evidence for results that require attention, record an observation or could not be verified.",
"readOnlyScope": "The assessment does not change configuration. Read commands and, when needed, Lynis may generate logs or reports.", "readOnlyScope": "The assessment inspects host settings and health. It can write reports and logs; boot status checks can temporarily mount EFI system partitions.",
"capacity": "Capacity", "capacity": "Capacity",
"used": "Used", "used": "Used",
"free": "Free", "free": "Free",
@@ -5182,7 +5182,7 @@
"neverRun": "This host has not been assessed yet.", "neverRun": "This host has not been assessed yet.",
"lastRun": "Last assessed on {when}", "lastRun": "Last assessed on {when}",
"stale": "{days} days ago", "stale": "{days} days ago",
"readOnlyNotice": "The assessment only reads the host. It makes no changes.", "readOnlyNotice": "The assessment inspects host settings and health. It can write reports and logs; boot status checks can temporarily mount EFI system partitions.",
"unverifiedChecks": "Not taken: {checks}. Each says in its own evidence what it could not read.", "unverifiedChecks": "Not taken: {checks}. Each says in its own evidence what it could not read.",
"noFindings": "No findings match the current filter.", "noFindings": "No findings match the current filter.",
"affectedCount": "{count} affected", "affectedCount": "{count} affected",
@@ -5403,7 +5403,7 @@
}, },
"boot_loader": { "boot_loader": {
"title": "Boot loader", "title": "Boot loader",
"rationale": "The EFI system partitions proxmox-boot-tool reports and the kernels each one carries. No partition is mounted and no boot is attempted.", "rationale": "The EFI system partitions proxmox-boot-tool reports and the kernels each one carries. proxmox-boot-tool status can temporarily mount EFI system partitions; no boot is attempted.",
"summary": { "summary": {
"synchronised": "The {total} boot partitions carry the same kernels", "synchronised": "The {total} boot partitions carry the same kernels",
"attention": "{count} of {total} boot partitions need review", "attention": "{count} of {total} boot partitions need review",
@@ -5891,7 +5891,7 @@
"scope": "Scope of this report", "scope": "Scope of this report",
"scopeText": "This document reports the {profile} profile on the node named in the header, at the moment of the run.", "scopeText": "This document reports the {profile} profile on the node named in the header, at the moment of the run.",
"scopeLocal": "It covers this node only. Guests on other nodes and their configuration are outside it.", "scopeLocal": "It covers this node only. Guests on other nodes and their configuration are outside it.",
"scopeReadOnly": "Every check reads configuration and state that already exists; none modifies the host.", "scopeReadOnly": "The assessment inspects host settings and health. It can write reports and logs; boot status checks can temporarily mount EFI system partitions.",
"scopeMoment": "It describes the state at the time of the run, not a period of time.", "scopeMoment": "It describes the state at the time of the run, not a period of time.",
"notRead": "Sources that could not be read:", "notRead": "Sources that could not be read:",
"uplink": "Uplink", "uplink": "Uplink",
+4 -3
View File
@@ -1,8 +1,9 @@
"""Check registry and evaluation engine for Audit & Report. """Check registry and evaluation engine for Audit & Report.
A check declares an identifier, an area and the severity its failure A check declares an identifier, an area and the severity its failure
carries, and returns the outcome of one evaluation. Checks never modify carries, and returns the outcome of one evaluation. Assessments inspect
the host: an assessment reads, it does not act. host settings and health. They can write reports and logs; boot status
checks can temporarily mount EFI system partitions.
Identifiers are ``<area>.<slug>`` and are frozen once published. Rewording Identifiers are ``<area>.<slug>`` and are frozen once published. Rewording
a title never changes the identifier, because the accepted-risk register a title never changes the identifier, because the accepted-risk register
@@ -185,7 +186,7 @@ class AuditContext:
return self._cache[key] return self._cache[key]
def run(self, cmd: list[str], timeout: int = 10, allowed_codes=(0,)) -> tuple[int, str]: def run(self, cmd: list[str], timeout: int = 10, allowed_codes=(0,)) -> tuple[int, str]:
"""Run a read-only command, returning exit code and output.""" """Run an inspection command, returning exit code and output."""
key = "cmd:" + json.dumps(cmd) key = "cmd:" + json.dumps(cmd)
self.source(key) self.source(key)
if key in self._cache: if key in self._cache:
+3 -3
View File
@@ -2785,8 +2785,7 @@ def _update_chain(ctx):
inherits the age of that picture. inherits the age of that picture.
Whether each repository can still be reached is not tested: finding Whether each repository can still be reached is not tested: finding
out means refreshing the indexes, and an assessment that only reads out means refreshing the indexes, which this check does not do.
does not do that.
""" """
# pkgcache.bin is rebuilt from files already on disk, so its date # pkgcache.bin is rebuilt from files already on disk, so its date
# says nothing about contacting a repository. These three do, in # says nothing about contacting a repository. These three do, in
@@ -3392,7 +3391,8 @@ def _boot_loader(ctx):
evidence += ("\nEach partition is an EFI system partition Proxmox keeps in " evidence += ("\nEach partition is an EFI system partition Proxmox keeps in "
"step so the host survives losing any one boot disk. The " "step so the host survives losing any one boot disk. The "
"kernel each would start is read from the tool's own report; " "kernel each would start is read from the tool's own report; "
"no partition is mounted and no boot is attempted.") "proxmox-boot-tool status can temporarily mount EFI system partitions; "
"no boot is attempted.")
affected = [] affected = []
for message in problems: for message in problems:
+5 -4
View File
@@ -4,10 +4,11 @@
ProxMenux Audit Routes ProxMenux Audit Routes
Flask blueprint for the Audit & Report assessment engine. Flask blueprint for the Audit & Report assessment engine.
An assessment reads the host and records findings; it never modifies An assessment inspects host settings and health and records findings.
anything. The run endpoint is therefore the only POST that does real It can write reports and logs; boot status checks can temporarily mount
work, and it is deliberately serialised: two concurrent assessments would EFI system partitions. Assessment runs are deliberately serialised: two
compete for the same collectors without producing a better answer. concurrent assessments would compete for the same collectors without
producing a better answer.
""" """
import threading import threading
+66
View File
@@ -0,0 +1,66 @@
// Node 20+: use the same TypeScript compiler harness as the document tests.
// No browser, React rendering, API or host-management imports.
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 read = rel => fs.readFileSync(path.join(app, rel), 'utf8');
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 compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
// Evaluate only local repository modules compiled above, never external input.
new Function('require', 'module', 'exports', compiled)(name => {
if (!name.startsWith('.')) return appRequire(name);
return load(path.resolve(path.dirname(file), name + '.ts'));
}, mod, mod.exports);
return mod.exports;
}
const messages = JSON.parse(read('messages/en/common.json'));
const used = [];
const t = (key, values = {}) => {
used.push(key);
let text = key.split('.').reduce((o, k) => o?.[k], messages);
assert.equal(typeof text, 'string', key);
for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v);
return text;
};
const contract = 'The assessment inspects host settings and health. It can write reports and logs; boot status checks can temporarily mount EFI system partitions.';
global.window = { location: { origin: 'http://localhost' } };
const { buildAuditDocument } = load(path.join(app, 'lib/audit-document.ts'));
const html = buildAuditDocument({
t, locale: 'en', profile: 'full', run: null, findings: [],
inventory: { sections: {}, unavailable: {} },
});
assert.ok(used.includes('audit.presentation.readOnlyScope'), 'document did not look up its scope contract');
assert.ok(html.includes(contract), 'real document omitted the safety contract');
// Check the JSX-to-catalog binding structurally, not quote style, line breaks,
// class names, or paragraph formatting. This is not a React visibility test.
const report = ts.createSourceFile('audit-report.tsx', read('components/audit-report.tsx'),
ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const noticeKeys = [];
function visit(node) {
if (ts.isJsxExpression(node) && node.expression && ts.isCallExpression(node.expression)) {
const call = node.expression;
const key = call.arguments[0];
if (ts.isIdentifier(call.expression) && call.expression.text === 't' &&
key && ts.isStringLiteral(key) && key.text === 'audit.readOnlyNotice') {
noticeKeys.push(key.text);
}
}
ts.forEachChild(node, visit);
}
visit(report);
assert.ok(noticeKeys.length > 0, 'assessment notice consumer not found');
for (const key of noticeKeys) assert.equal(t(key), contract);
assert.equal(t('audit.document.scopeReadOnly'), contract);
console.log('PASS: real full document safety contract, JSX notice binding, legacy scope agreement');
+95
View File
@@ -0,0 +1,95 @@
"""English safety contract at isolated producer seams; no host-module imports.
Run: python3 -m unittest discover -s tests -p test_audit_safety_wording.py -v
The external boot-tool mount behaviour is documented in the review evidence;
these fixtures exercise ProxMenux's actual command selection and evidence text.
"""
import ast
import json
from pathlib import Path
import re
from types import SimpleNamespace
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCOPE = (
"The assessment inspects host settings and health. It can write reports and "
"logs; boot status checks can temporarily mount EFI system partitions."
)
BOOT_EFFECT = (
"proxmox-boot-tool status can temporarily mount EFI system partitions; "
"no boot is attempted."
)
def boot_check(present, output):
"""Extract only the check and its two pure helpers, with all I/O replaced."""
source = ROOT / "AppImage/scripts/audit_checks_pve.py"
tree = ast.parse(source.read_text())
names = {"_boot_loader", "_version_key", "_unverified"}
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names]
assert {n.name for n in nodes} == names
for node in nodes:
node.decorator_list = []
commands = []
def path(value):
assert value == "/etc/kernel/proxmox-boot-uuids", value
return SimpleNamespace(exists=lambda: present)
def run(argv, **kwargs):
commands.append((argv, kwargs))
if argv == ["proxmox-boot-tool", "status"]:
return 0, output
if argv == ["uname", "-r"]:
return 0, "6.8.12-1-pve"
raise AssertionError(f"Unmocked command: {argv}")
env = {"Path": path, "re": re, "json": json,
"CLASS_CONFORMANT": "conformant", "CLASS_WARNING": "warning",
"CLASS_OBSERVATION": "observation", "CLASS_UNVERIFIED": "unverified"}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), "exec"), env)
return env["_boot_loader"](SimpleNamespace(run=run)), commands
class SafetyWording(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.audit = json.loads((ROOT / "AppImage/messages/en/common.json").read_text())["audit"]
def test_boot_evidence_and_rationale_disclose_command_side_effect(self):
for partitions in (1, 2):
with self.subTest(partitions=partitions):
output = "\n".join(
f"ABCD-000{i} is configured with: uefi (versions: 6.8.12-1-pve)"
for i in range(partitions))
result, commands = boot_check(True, output)
self.assertEqual(commands, [
(["proxmox-boot-tool", "status"], {"timeout": 25, "allowed_codes": (0, 1)}),
(["uname", "-r"], {}),
])
self.assertTrue(result["evidence"].endswith(BOOT_EFFECT), result["evidence"])
self.assertTrue(self.audit["checks"]["system"]["boot_loader"]["rationale"].endswith(BOOT_EFFECT))
self.assertEqual(result["summary_params"]["total"], str(partitions))
def test_absent_boot_configuration_does_not_invoke_tool(self):
result, commands = boot_check(False, "")
self.assertIsNone(result)
self.assertEqual(commands, [])
def test_unreadable_boot_status_does_not_claim_success(self):
result, commands = boot_check(True, "E: no configured partitions")
self.assertEqual(result["classification"], "unverified")
self.assertEqual(len(commands), 1)
def test_all_english_scope_surfaces_share_bounded_contract(self):
# The document currently consumes presentation.readOnlyScope;
# document.scopeReadOnly is retained for existing consumers.
for text in (self.audit["readOnlyNotice"],
self.audit["presentation"]["readOnlyScope"],
self.audit["document"]["scopeReadOnly"]):
self.assertEqual(text, SCOPE)
if __name__ == "__main__":
unittest.main()