Automating CIS and STIG hardening with OpenSCAP
Hardening a Linux system to a published baseline — a CIS Benchmark or a DISA STIG — is one of those tasks that is conceptually simple and operationally miserable. The benchmark is a few hundred rules. Applying them by hand is a few hundred opportunities to make a typo, skip a control, or set something that quietly breaks the machine three weeks later. The good news is that the whole cycle is automatable, and the tooling is open source.
This is a practical walk through doing it with OpenSCAP and the SCAP Security Guide.
The two pieces: a scanner and content
OpenSCAP is the scanner. It does not contain any policy of its own — it is an engine that evaluates a system against a policy expressed in SCAP format and, optionally, applies fixes.
The policy comes from the SCAP Security Guide (SSG), an open-source project that maintains hardening content for most common operating systems. On a Debian or Ubuntu system:
apt-get install openscap-scanner ssg-debderived
On RHEL, AlmaLinux, or Rocky:
dnf install openscap-scanner scap-security-guide
The content lands under /usr/share/xml/scap/ssg/content/, one datastream file per OS version — for example ssg-ubuntu2404-ds.xml or ssg-rhel9-ds.xml. The content is OS-specific and version-specific, and this is the first place people go wrong: evaluating an Ubuntu 24.04 host against Ubuntu 22.04 content produces a score, and the score is meaningless.
Step 1: see what profiles the content offers
A single datastream carries several profiles — CIS Level 1, CIS Level 2, STIG, and others. List them:
oscap info /usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml
You are looking for profile IDs like xccdf_org.ssgproject.content_profile_cis_level1_server or xccdf_org.ssgproject.content_profile_stig.
Step 2: evaluate, before you change anything
Run a scan and keep both the machine-readable results and a human-readable report:
oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis_level1_server \
--results scan-results.xml \
--report scan-report.html \
/usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml
oscap exits non-zero if any rule fails, which is worth knowing before you wire it into CI — a passing scan is exit 0, and anything else is not automatically an error condition you should treat as a broken build. The HTML report is the artifact to actually read: every rule, its result, its rationale, and the remediation.
Step 3: remediate
There are two honest ways to apply the fixes, and a dishonest one.
The direct route is to let OpenSCAP remediate in place:
oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis_level1_server \
--remediate \
--results scan-results.xml \
/usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml
The better route, for anything you will build repeatably, is to generate a remediation role rather than mutating a live host. OpenSCAP can emit the fixes as a Bash script or an Ansible playbook:
oscap xccdf generate fix \
--profile xccdf_org.ssgproject.content_profile_cis_level1_server \
--fix-type ansible \
/usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml \
> remediate.yml
Now the hardening is a reviewable, version-controlled artifact you apply during a build, instead of a one-time act performed on a running machine that nobody can reproduce later.
The dishonest route is to remediate, see a rule still failing, and add it to a waiver list without understanding why. Which brings us to the part the tooling cannot do for you.
The part that is not automatic: exceptions
Some rules will fail remediation, and some should fail. A STIG rule requiring a graphical login banner is irrelevant on a headless server. A control mandating a package be absent conflicts with an application that needs it. The benchmark cannot know your environment, so it errs toward strict.
The right response is a documented exception: a record of which rule you are not applying, and why. “We accept this finding because the control does not apply to a headless build, signed off by X on Y” is defensible to an auditor. Silently dropping the rule from your scan so the score looks better is exactly the kind of thing an audit exists to catch. Handle exceptions as data you keep, not as edits you make to the content.
Step 4: re-scan, and gate on the score
After remediation, scan again — against the same profile, into a fresh report — and treat the score as a build gate. If the hardened image scores below your threshold, the build should fail. An image that ships below its compliance bar and reports the shortfall “later” is worse than a build that never completed, because it looks finished.
Where this gets hard at scale
Everything above works cleanly for one image. The difficulty is that this is not a one-time job:
- The SSG content is updated as benchmarks are revised, so last quarter’s remediation is not this quarter’s.
- Each OS and version needs its own content and its own profile, so a fleet spanning Ubuntu, RHEL, and Windows is running several of these pipelines, not one.
- The scan, the remediation, the exceptions, and the resulting evidence all have to be kept together, per build, or you cannot answer “what was the compliance state of the image running in production last March?”
That maintenance is the actual work, and it is the part Primcoat automates: it runs exactly this OpenSCAP cycle — evaluate, remediate against the matching SSG content, re-score, gate — on every build, records the exceptions as structured data, and keeps the compliance report alongside the SBOM and the signature for the image that was actually produced. The oscap commands above are worth knowing regardless; whether you want to run them on a schedule forever is a separate question.
Machine-readable:/blog/automating-cis-stig-hardening-openscap.md