CIS_Benchmarks/cis_auditor/benchmarks/base.py
2026-06-18 20:47:15 +03:00

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