212 lines
8.4 KiB
Python
212 lines
8.4 KiB
Python
"""
|
|
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
|