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

284 lines
9.3 KiB
Python

"""
Parse the CIS Debian Linux 13 Benchmark XLSX and produce CheckDefinition objects.
Key insight
-----------
327 of 343 checks are "Automated" and the benchmark already embeds a runnable
bash script or command in the Audit Procedure column. Two patterns appear:
A. CIS bash script (~100 checks)
Starts with #!/usr/bin/env bash, contains l_output/l_output2 variables,
and explicitly prints " - PASS: ..." or " - FAIL: ...".
-> pass_condition = 'contains_pass'
B. Simple shell command (~227 checks)
One-liners like:
findmnt -kn /tmp | grep -v nodev
dpkg-query -s autofs && echo "autofs is installed"
Pass condition is inferred from surrounding prose (see _infer_pass_condition).
-> pass_condition = 'empty_output' | 'non_empty_output'
C. Manual checks (16 checks)
Commands are extracted for reviewer convenience but status is always MANUAL.
-> pass_condition = 'manual'
"""
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Tuple
import openpyxl
from ..base import CheckDefinition
# ── script extraction ─────────────────────────────────────────────────────────
_CODE_BLOCK_RE = re.compile(r"```(?:bash|shell|sh|)?\n?(.*?)```", re.DOTALL)
def _is_command_block(block: str) -> bool:
"""Return True if the code block looks like a command/script rather than example output."""
stripped = block.strip()
if not stripped:
return False
if stripped.startswith("#!/usr/bin/env bash") or stripped.startswith("#!/bin/bash"):
return True
if "l_output" in stripped:
return True
if stripped.startswith("#"):
if re.match(r"^#\s*(File:|Mode:|Access:|Owner:|Group:)", stripped):
return False
return True
if re.match(
r"^(find|stat|grep|awk|sed|cat|ls|dpkg|apt|systemctl|sysctl|"
r"findmnt|mount|chkconfig|service|ps|ss|netstat|ip|"
r"passwd|getent|useradd|usermod|chage|last|who|w|"
r"ufw|iptables|nft|firewall-cmd|modprobe|lsmod|"
r"openssl|ssh|curl|wget|auditctl|ausearch|aureport)",
stripped,
):
return True
return False
def _extract_scripts(audit_text: str) -> List[str]:
"""Extract all command/script code blocks from audit procedure markdown."""
blocks = _CODE_BLOCK_RE.findall(audit_text)
return [b.strip() for b in blocks if _is_command_block(b.strip())]
def _looks_like_example_output(line: str) -> bool:
"""Return True if a line looks like CIS example output rather than a command."""
s = line.strip()
if not s:
return False
return bool(re.match(
r"^("
r"[A-Z][a-z]+:.*|"
r"\d{4}-\d{2}-\d{2}.*|"
r"(enabled|disabled|generated|active|inactive|masked|running)\s*$|"
r"[a-z0-9._/-]+\s+\d{3,4}\s*$|"
r"-----BEGIN|"
r"[a-z]+\s+is\s+(installed|enabled|active|running)\s*$|"
r"-+\s*(OR|AND)\s*-+|"
r"nothing\s+is\s+returned\s*$|"
r"\(no output\)\s*$"
r")",
s,
re.IGNORECASE,
))
def _clean_simple_command(raw: str) -> str:
"""
Strip leading '# ' comment prefix and remove trailing example-output lines.
CIS XLSX often puts expected output in the same fenced block as the command,
separated by a blank line. Strategy: find the last line that is a real shell
command (not example output, not blank) and discard everything after it.
"""
lines = raw.strip().splitlines()
cleaned = [re.sub(r"^#\s*", "", line) for line in lines]
last_cmd_idx = -1
for i, line in enumerate(cleaned):
if line.strip() and not _looks_like_example_output(line):
last_cmd_idx = i
if last_cmd_idx == -1:
return raw
result_lines = cleaned[: last_cmd_idx + 1]
non_empty = [l for l in result_lines if l.strip()]
if not non_empty:
return raw
if len(non_empty) == 1:
return non_empty[0]
return "\n".join(non_empty)
# ── pass condition inference ──────────────────────────────────────────────────
_EMPTY_PASS_PHRASES = [
"nothing should be returned",
"no output",
"should return nothing",
"output should be empty",
"nothing is returned",
"should produce no output",
"should not return",
"if nothing",
"if no output",
"empty string",
"no files",
"no results",
]
_NON_EMPTY_PASS_PHRASES = [
"is installed",
"is enabled",
"is active",
"verify.*installed",
"confirm.*installed",
"should be installed",
"should return.*installed",
"output.*installed",
"output.*enabled",
"output.*active",
]
def _infer_pass_condition(audit_text: str, script: str) -> str:
"""
Determine pass condition for a check.
Returns one of: 'contains_pass', 'empty_output', 'non_empty_output', 'manual'
"""
is_bash = "#!/usr/bin/env bash" in script or "#!/bin/bash" in script
has_pass_fail_output = bool(re.search(r'"[^"]*\bPASS\b|[^"]*\bFAIL\b', script))
if is_bash and ("l_output" in script or has_pass_fail_output):
return "contains_pass"
text_lower = audit_text.lower()
for phrase in _EMPTY_PASS_PHRASES:
if phrase in text_lower:
return "empty_output"
for phrase in _NON_EMPTY_PASS_PHRASES:
if re.search(phrase, text_lower):
if re.search(r"(not installed|not enabled|not active|should not be|"
r"should be removed|should not be installed)", text_lower):
return "empty_output"
return "non_empty_output"
if re.search(r"findmnt.*grep.*-v", script):
return "empty_output"
if re.search(r"sysctl.*grep", script) or re.search(r"grep.*sysctl", script):
return "empty_output"
if re.search(r"grep\s+-[a-zA-Z]*v", script):
return "empty_output"
if re.match(r"stat\s+", script) and "|" not in script:
return "manual"
return "empty_output"
def _extract_expected_description(audit_text: str) -> str:
"""Pull a short expected-state description from the audit procedure text."""
m = re.search(
r"(?:example output|expected output|verify.*?:)(.*?)(?:```|\n\n|\Z)",
audit_text,
re.IGNORECASE | re.DOTALL,
)
if m:
snippet = m.group(1).strip()[:200]
if snippet:
return snippet
first = re.split(r"\.\s|\n", audit_text.strip())[0]
return first[:200] if first else "See audit procedure"
# ── main parser ───────────────────────────────────────────────────────────────
def load_benchmark(xlsx_path: str | Path) -> Tuple[
Dict[str, CheckDefinition],
Dict[str, List[str]],
List[str],
]:
"""
Parse the Debian benchmark XLSX and return:
- check_registry: {check_id: CheckDefinition}
- checks_by_profile: {profile: [check_ids]}
- profiles: ordered list of profile names
"""
wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True)
ws = wb["Combined Profiles"]
check_registry: Dict[str, CheckDefinition] = {}
checks_by_profile: Dict[str, List[str]] = defaultdict(list)
profile_order: List[str] = []
for row in ws.iter_rows(min_row=2, values_only=True):
check_id = row[1]
profile = row[2]
title = row[3]
status = row[4]
remediation = str(row[8]) if row[8] else ""
audit_text = str(row[9]) if row[9] else ""
default_val = str(row[25]) if len(row) > 25 and row[25] else ""
if not check_id or not profile:
continue
if profile not in profile_order:
profile_order.append(profile)
checks_by_profile[profile].append(check_id)
if check_id in check_registry:
if profile not in check_registry[check_id].profiles:
check_registry[check_id].profiles.append(profile)
continue
# extract audit script
scripts = _extract_scripts(audit_text)
bash_scripts = [s for s in scripts if "l_output" in s or s.startswith("#!/")]
simple_cmds = [s for s in scripts if s not in bash_scripts]
if bash_scripts:
primary_script = bash_scripts[0]
elif simple_cmds:
primary_script = _clean_simple_command(simple_cmds[0])
else:
primary_script = ""
# pass condition
if status == "Manual":
pass_cond = "manual"
elif not primary_script:
pass_cond = "manual"
else:
pass_cond = _infer_pass_condition(audit_text, primary_script)
expected = _extract_expected_description(audit_text)
check_registry[check_id] = CheckDefinition(
check_id=check_id,
title=title or f"Check {check_id}",
profiles=[profile],
assessment_status=status or "Manual",
audit_script=primary_script,
pass_condition=pass_cond,
expected_description=expected,
remediation=remediation,
default_value=default_val,
)
wb.close()
return check_registry, dict(checks_by_profile), profile_order