284 lines
9.3 KiB
Python
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
|