#!/usr/bin/env bash # stig-deviations.sh [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 [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]}") # A scan where NOTHING was actually evaluated (no pass/fixed/fail — only # notapplicable/notselected) is NOT clean: the datastream/CPE didn't match the # host, so no rule was checked and no hardening was verified. Flag it loudly # instead of letting "0 REVIEW" read as success. evaluated = counts.get("pass", 0) + counts.get("fixed", 0) + counts.get("fail", 0) if evaluated == 0: print("\n> ⚠️ **INVALID SCAN — 0 rules evaluated.** Every rule came back " "`notapplicable`/`notselected`, which means the SSG datastream/CPE did " "not match this host (e.g. the RHEL9 datastream run on Rocky). **No " "hardening was assessed** — this report is NOT a clean result. Use the " "distro-matching datastream (`ssg-rl9-ds.xml` on Rocky) and rebuild.\n") else: 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} |") elif evaluated > 0: print("No fail/error/unknown/notchecked rules — clean against the tailored profile.") # Non-zero exit on an invalid (non-evaluating) scan so callers can gate on it. sys.exit(2 if evaluated == 0 else 0) PY [ "$OUT" = /dev/stdout ] || echo "wrote $OUT" >&2