CoursesLinux hardeningCIS benchmarks with OpenSCAP

CIS benchmarks with OpenSCAP

Scan, remediate, and track drift.

Intermediate12 min · lesson 16 of 16

A home inspector walking through a house does not eyeball the wiring and offer an opinion. They carry a printed checklist: smoke detector in every bedroom, ground-fault outlets near the sink, a handrail on any stair with four or more steps. Each item is specific, and each one either passes or fails. Hardening a Linux server works best the same way. Instead of arguing about whether a box is "locked down," you check it against a written list of exact settings and get back a pass or fail for every one.

That written list is the CIS Benchmark (CIS is the Center for Internet Security, a non-profit that publishes free, consensus-built hardening guides for operating systems, databases, and cloud platforms). Every subject this course covers on its own is in that list: the Secure Shell (SSH) daemon config, PAM (Pluggable Authentication Modules, the stack that decides how a login is authenticated), sysctl kernel tunables, SELinux (Security-Enhanced Linux, the mandatory access control system built into the kernel), the audit daemon, mount options. Each one is a numbered control with a test and a fix. You could work through hundreds of them by hand. Or you point a scanner at the host and let it grade every control in one pass. OpenSCAP is that scanner, and it is the standard open-source one.

~/secopslog — bash
$ sudo dnf install -y openscap-scanner scap-security-guide
Dependencies resolved. ================================================================================ Package Arch Version Repository Size ================================================================================ Installing: openscap-scanner x86_64 1.3.10-1.el9 appstream 1.2 M scap-security-guide noarch 0.1.72-1.el9 appstream 13 M Installing dependencies: openscap x86_64 1.3.10-1.el9 appstream 3.6 M Transaction Summary ================================================================================ Install 3 Packages Complete!

This lesson uses the Red Hat family (Red Hat Enterprise Linux, or RHEL, and its community rebuilds Rocky and Alma), where the packages are openscap-scanner and scap-security-guide and the content sits in /usr/share/xml/scap/ssg/content/. On Debian and Ubuntu the scanner ships in the libopenscap8 package and the CIS content in ssg-debderived, with the datastream under /usr/share/scap-security-guide/ (for example, ssg-ubuntu2204-ds.xml). Every oscap command from here on is identical across both.

What the scanner reads under the hood

OpenSCAP knows nothing about SSH or firewalls on its own. It is a generic engine that reads security content and runs it. That content follows SCAP (Security Content Automation Protocol, a family of standards for writing security checks that any tool can run). Two layers inside a SCAP bundle are worth naming. The checklist itself is written in XCCDF (Extensible Configuration Checklist Description Format), the human-readable list of controls, their severities, and how the total is scored, like the inspector's checklist. The actual test behind each control is written in OVAL (Open Vulnerability and Assessment Language), the machine-readable logic that goes and reads the real file or queries the running system to decide pass or fail, like the inspector's method for testing each item. The whole thing ships as one file called a datastream (the -ds.xml file), which is the inspector's binder holding the checklist and every test together.

One benchmark holds far more controls than any single host should apply, so the content groups them into profiles. Think of a profile as the edition of the checklist an inspector pulls for one kind of building: a named selection of rules, plus the exact values each rule checks for. CIS ships its controls in two levels. Level 1 is the safe baseline, settings that harden the box with little risk of breaking normal use. Level 2 is defense-in-depth for high-security environments, and some of its rules will turn off features you actually need. For a general server, start at Level 1 and treat Level 2 as a deliberate, tested upgrade. You can list every profile the content offers before you scan anything:

~/secopslog — bash
$ oscap info /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
Document type: Source Data Stream Imported: 2025-02-14T09:12:33 Version: 1.3 Checklists: Ref-Id: scap_org.open-scap_cref_ssg-rhel9-xccdf.xml Profiles: Title: CIS Red Hat Enterprise Linux 9 Benchmark for Level 2 - Server Id: xccdf_org.ssgproject.content_profile_cis Title: CIS Red Hat Enterprise Linux 9 Benchmark for Level 1 - Server Id: xccdf_org.ssgproject.content_profile_cis_server_l1 Title: CIS Red Hat Enterprise Linux 9 Benchmark for Level 1 - Workstation Id: xccdf_org.ssgproject.content_profile_cis_workstation_l1 Title: CIS Red Hat Enterprise Linux 9 Benchmark for Level 2 - Workstation Id: xccdf_org.ssgproject.content_profile_cis_workstation_l2 Title: ANSSI-BP-028 (enhanced) Id: xccdf_org.ssgproject.content_profile_anssi_bp28_enhanced Referenced check files: ssg-rhel9-oval.xml system: http://oval.mitre.org/XMLSchema/oval-definitions-5
The benchmark loop
1Get the content
install the scanner and the CIS datastream
2Scan
grade every control, save results + report
3Review failures
which rules, why, do they apply here
4Remediate
generate the fix, read it, apply in staging first
5Re-scan
confirm the score actually moved
6Schedule
run weekly, watch the score for drift

Run the first scan

Pick a profile and point oscap at the datastream. The --results file is the machine-readable record you keep and compare later; the --report file is a self-contained HTML page a human can read, with every control, its pass or fail, its severity, and the remediation text for anything that failed. The scan reads the live system, so run it with sudo.

~/secopslog — bash
$ sudo oscap xccdf eval \ --profile xccdf_org.ssgproject.content_profile_cis_server_l1 \ --results scan.xml \ --report report.html \ /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
Title Ensure SELinux State is Enforcing Rule xccdf_org.ssgproject.content_rule_selinux_state Ident CCE-90841-9 Result pass Title Ensure SSH Root Login is Disabled Rule xccdf_org.ssgproject.content_rule_sshd_disable_root_login Ident CCE-90775-9 Result fail Title Ensure firewalld is Active and Enabled Rule xccdf_org.ssgproject.content_rule_service_firewalld_enabled Ident CCE-82999-3 Result fail Title Ensure the audit Package is Installed Rule xccdf_org.ssgproject.content_rule_package_audit_installed Ident CCE-90201-6 Result pass ...

The command streams a pass or fail for every rule to your terminal, but the overall grade lives in the results and the report. OpenSCAP scores the run from 0 to 100, weighted by rule. You do not have to open the HTML to read it; the number sits in the results XML, and one xmllint query pulls it out for logging or a trend graph. (CCE in that output is the Common Configuration Enumeration identifier, a stable catalog number for each control, handy when you cross-reference the benchmark PDF.)

~/secopslog — bash
$ xmllint --xpath 'string(//*[local-name()="score"])' scan.xml
71.851852

Fix what failed, then prove it

OpenSCAP does not only find problems, it can write the fix. Point generate fix at your results file and it emits a remediation for every rule that failed, either as a Bash script or as an Ansible playbook (Ansible is a config-management tool that applies the same changes across a whole fleet). The empty --result-id "" tells it to use the single set of results in the file, so you get fixes for what actually failed on this host, not the entire benchmark.

~/secopslog — bash
$ # Bash remediation for ONLY the rules that failed this scan sudo oscap xccdf generate fix \ --fix-type bash \ --result-id "" \ --output remediate.sh \ scan.xml # Prefer config management? Emit an Ansible playbook instead sudo oscap xccdf generate fix \ --fix-type ansible \ --result-id "" \ --output remediate.yml \ scan.xml
remediate.sh
# Remediation of rule 'xccdf_org.ssgproject.content_rule_sshd_disable_root_login'
if [ -e "/etc/ssh/sshd_config" ]; then
LC_ALL=C sed -i "/^\s*PermitRootLogin\s\+/Id" "/etc/ssh/sshd_config"
fi
printf '%s\n' "PermitRootLogin no" >> "/etc/ssh/sshd_config"
# Remediation of rule 'xccdf_org.ssgproject.content_rule_service_firewalld_enabled'
SYSTEMCTL_EXEC='/usr/bin/systemctl'
"$SYSTEMCTL_EXEC" unmask 'firewalld.service'
"$SYSTEMCTL_EXEC" start 'firewalld.service'
"$SYSTEMCTL_EXEC" enable 'firewalld.service'

Read the script, then run it, then scan again. That last step is the one people skip, and it is the only proof the fix took. Watch the exit code while you are at it: oscap returns 0 when every rule passes, 2 when the scan ran fine but some rules failed, and 1 for a real error like a missing datastream. In automation, exit code 2 is the normal case and must not abort the run, which is why the wrapper script further down catches it on purpose.

~/secopslog — bash
$ less remediate.sh # read every line before you run it sudo bash remediate.sh sudo oscap xccdf eval \ --profile xccdf_org.ssgproject.content_profile_cis_server_l1 \ --results scan-after.xml --report report-after.html \ /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml xmllint --xpath 'string(//*[local-name()="score"])' scan-after.xml
96.296295
Remediation can lock you out of your own box
The generated script does exactly what the benchmark says, with no idea what your host is for. Enabling firewalld with a default-deny zone can cut the SSH session you are running it over. Disabling a kernel module or a "legacy" service can break the application the server exists to run. Read remediate.sh line by line, apply it first on a staging host or a snapshot, and keep a second way in (a console or an out-of-band management card) before you run it on anything you care about. The re-scan proves the settings changed; only your own testing proves the service still works.

Turn one scan into a trend

A host is hardened on the day you scan it, and it starts drifting the next morning. Someone opens a port for a demo. A package upgrade ships a new default. An on-call engineer sets PermitRootLogin yes at 3 a.m. to clear an outage and never sets it back. This is also what an intruder does once they land on a box: they turn root SSH login back on for easy re-entry, stop firewalld so their tools can call out, disable the audit daemon so their steps are not recorded. Every one of those is a CIS control. A scan you run on a schedule turns each of them into a specific failed rule and a lower score, which means your compliance baseline doubles as tampering detection. Wire it up with a small wrapper script and a systemd timer. Think of the timer as an alarm clock for the machine: it wakes at a set time, runs one job, and goes back to sleep. (systemd, the thing that provides these timers, is the service manager that starts and supervises background programs on modern Linux.)

/usr/local/sbin/oscap-scan.sh
#!/usr/bin/env bash
set -euo pipefail
DS=/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
PROFILE=xccdf_org.ssgproject.content_profile_cis_server_l1
OUT=/var/log/oscap
STAMP=$(date +%F)
mkdir -p "$OUT"
# oscap exits 2 when some rules fail (expected), 1 on a real error.
rc=0
oscap xccdf eval \
--profile "$PROFILE" \
--results "$OUT/results-$STAMP.xml" \
--report "$OUT/report-$STAMP.html" \
"$DS" || rc=$?
if [ "$rc" -eq 1 ]; then
logger -t oscap-scan "scan ERROR (exit 1)"; exit 1
fi
score=$(xmllint --xpath 'string(//*[local-name()="score"])' "$OUT/results-$STAMP.xml")
logger -t oscap-scan "CIS L1 score $STAMP: $score"
/etc/systemd/system/oscap-scan.service
[Unit]
Description=Weekly CIS Level 1 scan (OpenSCAP)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
Nice=19
IOSchedulingClass=idle
ExecStart=/usr/local/sbin/oscap-scan.sh
/etc/systemd/system/oscap-scan.timer
[Unit]
Description=Run the CIS scan every Monday morning
[Timer]
OnCalendar=Mon *-*-* 03:00:00
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target
~/secopslog — bash
$ sudo systemctl daemon-reload sudo systemctl enable --now oscap-scan.timer systemctl list-timers oscap-scan.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES Mon 2026-07-20 03:14:00 UTC 2 days 8h - - oscap-scan.timer oscap-scan.service 1 timers listed.

Now the score line in the system journal becomes a time series: 96, 96, 96, then one Monday it reads 88 with two new failures logged beside it. That is your signal. You open that week's report.html, see exactly which controls flipped from pass to fail, and go find out who changed them and whether it was allowed. The report is a to-do list for hardening, and also the fastest way to answer "what changed on this host, and when" after the fact.

One more thing about that score. A perfect CIS pass means the host matches a known configuration standard, and that is all it means. It says nothing about an unpatched application, a weak network boundary, a leaked credential, or an attacker already logged in. Benchmarks check configuration, not the code you deploy or the traffic you allow. Treat CIS as the floor every host has to clear and as a tripwire for drift, then layer patching, least privilege, monitoring, and response on top.

Quick check
01A server scored 96% against CIS Level 1 last week. Today's scheduled scan reports 88%, and the two newly failing rules are "SSH Root Login is Disabled" and "firewalld is Active". What is the most useful read on this?
Incorrect — A content update can shift scores, but two specific security controls flipping to fail points to a config change on the host, not new content.
Correct — A falling score with named controls newly failing is exactly what drift or tampering looks like, and it is a lead to investigate.
Incorrect — Re-running does not change a genuinely failing setting; forcing the old number would only hide a real change.
Incorrect — Drift is a lead to investigate, not proof of intrusion; wiping first destroys the evidence you need to understand what happened.
02The scheduled scan wrapper deliberately catches oscap exit code 2 instead of letting set -e abort the script. Why?
Incorrect — a missing datastream is a genuine error, which oscap reports as exit 1.
Correct — the lesson notes 0 = all pass, 2 = ran fine with failures, 1 = real error, so exit 2 must not abort automation.
Incorrect — exit 2 is exactly the case where rules are still failing, not a sign everything passed.
Incorrect — a bad profile ID is an error condition (exit 1), not the exit-2 'some rules failed' result.
03generate fix produced remediate.sh, which enables firewalld and disables a service tagged 'legacy'. You are about to run it over the very SSH session connected to a remote production box. What is the safe move?
Incorrect — the re-scan only proves the settings changed; the lesson stresses that only your own testing proves the service still works.
Incorrect — mass-applying an untested fix multiplies the blast radius of a lockout or a broken application.
Correct — the lesson warns enabling firewalld can cut your SSH session and disabling a service can break the app, so stage it and keep out-of-band access.
Incorrect — the generated fix follows the benchmark blindly with no idea what the host is for, and can lock you out or break the app.

Two habits make this pay off. Commit each dated results file and report into a git repository, so you keep a timestamped record of every host's state for the next audit and a clean diff whenever something moves. And send the score line your wrapper logs to wherever your other alerts land, so a drop from 96 to 88 pages a human the morning it happens, not at the next quarterly review.

Try this

Work through “Turn one scan into a trend” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: remediation can lock you out of your own box. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related