88 lines
3.4 KiB
Bash
88 lines
3.4 KiB
Bash
#!/usr/bin/env bash
|
|
# stig-deviations.sh <results-arf.xml> [output.md]
|
|
#
|
|
# Turns a post-remediation OpenSCAP result (ARF or XCCDF results XML) into a
|
|
# human-readable deviations report: every rule whose result is not "pass",
|
|
# tagged "intentional" (matches an unselected rule in the diskless tailoring) or
|
|
# "REVIEW" (a real finding to triage). Writes markdown to stdout, or to the
|
|
# second arg if given. Run in CI on the ARF pulled out of the build VM (see the
|
|
# Gitea workflow); pairs with the curated register in docs/stig-deviations.md.
|
|
set -euo pipefail
|
|
|
|
ARF=${1:?usage: stig-deviations.sh <results-arf.xml> [output.md]}
|
|
OUT=${2:-/dev/stdout}
|
|
# Tailoring file whose unselected rules count as "intentional" deviations.
|
|
TAILORING=${TAILORING:-files/stig/diskless-tailoring.xml}
|
|
|
|
[ -f "$ARF" ] || { echo "ERROR: results file not found: $ARF" >&2; exit 1; }
|
|
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 1; }
|
|
|
|
python3 - "$ARF" "$TAILORING" <<'PY' > "$OUT"
|
|
import sys, xml.etree.ElementTree as ET
|
|
from collections import Counter
|
|
|
|
arf, tailoring = sys.argv[1], sys.argv[2]
|
|
X = "{http://checklists.nist.gov/xccdf/1.2}"
|
|
|
|
root = ET.parse(arf).getroot()
|
|
|
|
# rule id -> (severity, title) from the benchmark embedded in the ARF
|
|
rules = {}
|
|
for r in root.iter(X + "Rule"):
|
|
rid = r.get("id")
|
|
t = r.find(X + "title")
|
|
rules[rid] = (r.get("severity", "unknown"),
|
|
(t.text or "").strip() if t is not None else "")
|
|
|
|
# rules we deliberately unselected (intentional deviations)
|
|
waived = set()
|
|
try:
|
|
troot = ET.parse(tailoring).getroot()
|
|
for s in troot.iter(X + "select"):
|
|
if s.get("selected", "true") == "false":
|
|
waived.add(s.get("idref"))
|
|
except (FileNotFoundError, ET.ParseError):
|
|
pass
|
|
|
|
counts = Counter()
|
|
findings = []
|
|
for rr in root.iter(X + "rule-result"):
|
|
rid = rr.get("idref")
|
|
res_el = rr.find(X + "result")
|
|
res = res_el.text if res_el is not None else "unknown"
|
|
counts[res] += 1
|
|
if res in ("fail", "error", "unknown", "notchecked"):
|
|
sev, title = rules.get(rid, ("unknown", ""))
|
|
tag = "intentional" if rid in waived else "REVIEW"
|
|
findings.append((sev, res, rid, title, tag))
|
|
|
|
sev_order = {"high": 0, "medium": 1, "low": 2, "unknown": 3}
|
|
findings.sort(key=lambda f: (sev_order.get(f[0], 9), f[2]))
|
|
|
|
short = lambda rid: rid.replace("xccdf_org.ssgproject.content_rule_", "")
|
|
review = sum(1 for f in findings if f[4] == "REVIEW")
|
|
|
|
print("# STIG deviations report (generated)\n")
|
|
print("Generated by `scripts/stig-deviations.sh` from the post-remediation scan. "
|
|
"See [stig-deviations.md](stig-deviations.md) for the curated register of "
|
|
"intentional deviations.\n")
|
|
print("## Summary\n")
|
|
for k in ("pass", "fixed", "fail", "error", "unknown", "notchecked",
|
|
"notapplicable", "notselected", "informational"):
|
|
if counts.get(k):
|
|
print(f"- **{k}**: {counts[k]}")
|
|
print(f"\n**{review} rule(s) need REVIEW** "
|
|
f"(non-pass and not a documented intentional deviation).\n")
|
|
|
|
if findings:
|
|
print("## Findings (non-pass)\n")
|
|
print("| Severity | Result | Rule | Disposition | Title |")
|
|
print("|---|---|---|---|---|")
|
|
for sev, res, rid, title, tag in findings:
|
|
print(f"| {sev} | {res} | `{short(rid)}` | {tag} | {title} |")
|
|
else:
|
|
print("No fail/error/unknown/notchecked rules — clean against the tailored profile.")
|
|
PY
|
|
|
|
[ "$OUT" = /dev/stdout ] || echo "wrote $OUT" >&2
|