Files
diskless-workstation/scripts/stig-deviations.sh
T
2026-06-18 10:00:41 +00:00

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