""" Base classes shared by all benchmarks. BenchmarkInfo — metadata + profile/check registry for one benchmark CheckDefinition — a single check: its audit script, pass condition, remediation and a run() method that handles all evaluation logic Pass conditions ─────────────── empty_output Most common. Command output empty → PASS, any output → FAIL. Used by: grep -v checks (mount options, kernel params), most "find bad things" style commands. non_empty_output Output present → PASS, empty → FAIL. Used by: "verify X is installed/enabled" checks where the command echoes a confirmation string. contains_pass Script explicitly prints "PASS" or "FAIL" in its output. Used by: embedded CIS bash scripts with l_output/l_output2 variables that evaluate multiple sub-conditions. manual Requires human judgment. Script is still run and output is collected, but status is always MANUAL so the reviewer just reads the data without re-running. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional from ..core.models import CheckResult, AuditReport, PASS, FAIL, MANUAL, ERROR from ..core.executor import LocalExecutor, SSHExecutor Executor = LocalExecutor | SSHExecutor # ── CheckDefinition ─────────────────────────────────────────────────────────── @dataclass class CheckDefinition: """ A single benchmark check in data-driven form. The run() method handles all pass/fail logic so callers never need to inspect audit text or implement evaluation themselves. """ check_id: str title: str profiles: List[str] assessment_status: str # 'Automated' | 'Manual' audit_script: str # bash command or script to execute pass_condition: str # 'empty_output' | 'non_empty_output' | 'contains_pass' | 'manual' expected_description: str # human-readable expected state remediation: str default_value: str = "" notes: str = "" def run(self, executor: Executor) -> CheckResult: """Execute this check and return a CheckResult.""" if not self.audit_script.strip() or self.pass_condition == "manual": out, _ = executor.run(self.audit_script) if self.audit_script.strip() else ("", 0) return CheckResult( check_id=self.check_id, title=self.title, profiles=self.profiles, status=MANUAL, actual_value=out.strip() or "(manual check — no automatable command)", expected_value=self.expected_description, commands_run=[self.audit_script] if self.audit_script.strip() else [], remediation=self.remediation, notes=self.notes, ) # ── run the script ──────────────────────────────────────────────────── # Multi-line bash scripts need to be run via bash explicitly script = self.audit_script.strip() if "\n" in script or script.startswith("#!/"): # Use heredoc so both local and SSH executors handle it correctly cmd = f"bash <<'__SCRIPT_EOF__'\n{script}\n__SCRIPT_EOF__" else: cmd = script try: out, exit_code = executor.run(cmd) except Exception as exc: return CheckResult( check_id=self.check_id, title=self.title, profiles=self.profiles, status=ERROR, actual_value=f"Execution error: {exc}", expected_value=self.expected_description, commands_run=[script], remediation=self.remediation, ) out = out.strip() # ── evaluate pass condition ─────────────────────────────────────────── if self.pass_condition == "empty_output": status = PASS if not out else FAIL actual = "(no issues found — compliant)" if not out else out elif self.pass_condition == "non_empty_output": status = PASS if out else FAIL actual = out if out else "(no output — expected confirmation of compliance)" elif self.pass_condition == "contains_pass": # CIS bash scripts print lines like " - PASS: ..." or " - FAIL: ..." has_pass = bool(re.search(r"\bPASS\b", out, re.IGNORECASE)) has_fail = bool(re.search(r"\bFAIL\b", out, re.IGNORECASE)) if has_pass and not has_fail: status = PASS elif has_fail: status = FAIL else: # Script didn't emit PASS/FAIL — fall back to empty=PASS heuristic status = PASS if not out else FAIL actual = out or "(no output)" else: status = MANUAL actual = out or "(no output)" return CheckResult( check_id=self.check_id, title=self.title, profiles=self.profiles, status=status, actual_value=actual, expected_value=self.expected_description, commands_run=[script], remediation=self.remediation, notes=self.notes, ) # ── BenchmarkInfo ───────────────────────────────────────────────────────────── @dataclass class BenchmarkInfo: """ Describes one benchmark (e.g. NGINX, Debian 13). profiles ordered list of profile names checks_by_profile profile → [check_id, ...] check_registry check_id → CheckDefinition (for data-driven benchmarks) OR check_id → Callable[[Executor], CheckResult] (for hand-coded) """ name: str version: str profiles: List[str] checks_by_profile: Dict[str, List[str]] check_registry: Dict[str, CheckDefinition | Callable] def run_profile( self, executor: Executor, profile: str, check_ids: Optional[List[str]] = None, verbose: bool = False, ) -> AuditReport: """Run all (or a subset of) checks for *profile*.""" if profile not in self.checks_by_profile: raise ValueError( f"Unknown profile '{profile}' for benchmark '{self.name}'. " f"Valid: {self.profiles}" ) ids = check_ids or self.checks_by_profile[profile] report = AuditReport(target=executor.name, profile=f"{self.name} — {profile}") total = len(ids) for i, cid in enumerate(ids, 1): if verbose: print(f" [{i:>3}/{total}] {cid:<12} …", end="\r", flush=True) entry = self.check_registry.get(cid) if entry is None: report.results.append(CheckResult( check_id=cid, title=f"Check {cid}", profiles=[profile], status=ERROR, actual_value="No implementation found for this check ID", expected_value="N/A", commands_run=[], remediation="", notes="Missing from check_registry", )) continue try: if callable(entry) and not isinstance(entry, CheckDefinition): # hand-coded function: check_X_Y_Z(executor) -> CheckResult result = entry(executor) else: # data-driven CheckDefinition result = entry.run(executor) except Exception as exc: result = CheckResult( check_id=cid, title=f"Check {cid}", profiles=[profile], status=ERROR, actual_value=f"Unexpected error: {exc}", expected_value="N/A", commands_run=[], remediation="", notes=str(exc), ) report.results.append(result) if verbose: print(" " * 60, end="\r") return report