CIS benchmarks and OpenSCAP

Scan, tailor and remediate with evidence.

Intermediate16 min · lesson 24 of 24

A benchmark turns "is this server hardened?" into a list of checks that each pass or fail. In this lesson you learn what the CIS benchmarks are and how they differ from vendor guidance, scan a Rocky Linux 10.2 server against the CIS Level 1 Server profile with OpenSCAP, read the results, apply and roll back the fix for one rule, record an exception in a tailoring file, and find out what is and is not available for Ubuntu 26.04. It ends the course, so it also says where to go next.

Whose recommendations these are

The Center for Internet Security (CIS) is a non-profit that publishes consensus hardening benchmarks for operating systems and applications. A benchmark is a numbered list of recommendations, each with a rationale, an audit procedure and a remediation, grouped into profiles: Level 1 aims to reduce attack surface without breaking normal use, Level 2 goes further for high-security environments and expects some functionality to be lost. CIS is a third party. Its benchmarks are not the operating system's defaults, and they are not Red Hat's or Canonical's own guidance (Red Hat's security hardening guide and Ubuntu's security documentation), although the vendors help write them and ship tooling for them. Other baselines, such as the DISA STIG, overlap heavily but differ in detail.

Checking hundreds of recommendations by hand is not practical, so the checks are written as SCAP content (Security Content Automation Protocol): an XCCDF checklist of rules and profiles, OVAL tests that inspect the system, and remediation snippets, bundled in one "data stream" file. OpenSCAP's oscap is the scanner that evaluates it. The SCAP Security Guide (SSG, from the ComplianceAsCode project) is the content, and on RHEL-family systems it ships as the scap-security-guide package. SSG's CIS profiles implement the CIS benchmark; CIS's own assessor tool (CIS-CAT) is a separate product.

deploy@rocky10 · Rocky Linux 10.2
$ rpm -q openscap-scanner scap-security-guide
openscap-scanner-1.4.4-1.el10_2.aarch64 scap-security-guide-0.1.82-2.el10_2.rocky.1.1.noarch
$ ls /usr/share/xml/scap/ssg/content/
ssg-rhel10-ds.xml ssg-rl10-ds.xml
$ oscap info --profiles /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml | grep -i cis
xccdf_org.ssgproject.content_profile_cis:CIS Red Hat Enterprise Linux 10 Benchmark for Level 2 - Server xccdf_org.ssgproject.content_profile_cis_server_l1:CIS Red Hat Enterprise Linux 10 Benchmark for Level 1 - Server xccdf_org.ssgproject.content_profile_cis_workstation_l1:CIS Red Hat Enterprise Linux 10 Benchmark for Level 1 - Workstation xccdf_org.ssgproject.content_profile_cis_workstation_l2:CIS Red Hat Enterprise Linux 10 Benchmark for Level 2 - Workstation

Rocky's package carries a Rocky data stream (ssg-rl10-ds.xml) next to the RHEL one; scan with the one for your distribution. The CIS profile names follow the benchmark: cis is Level 2 Server, cis_server_l1 is Level 1 Server, and the two workstation profiles are for desktops. This release of the content encodes version 1.0.1 of the CIS Red Hat Enterprise Linux 10 Benchmark; CIS renumbers and re-levels recommendations between benchmark versions, so always say which one you scanned against.

Scan

A scan only reads the system. --results saves machine-readable results (the evidence you keep and compare), --report writes an HTML report for people, and the per-rule lines on standard output are worth keeping too. On this lab server the whole Level 1 scan takes about twenty seconds.

deploy@rocky10 · Rocky Linux 10.2
$ mkdir -p ~/cis && cd ~/cis time sudo oscap xccdf eval --profile cis_server_l1 \ --results results.xml --report report.html \ /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml > scan.txt echo "oscap exit status: $?"
W: oscap: Entity name 'value' from state (id: 'oval:ssg-state_accounts_password_last_change_is_in_past_time_diff:ste:1') not found in item (id: '1119886223'). … real 0m27.289s … oscap exit status: 2
$ head -n 9 ~/cis/scan.txt
--- Starting Evaluation --- Title Install AIDE Rule xccdf_org.ssgproject.content_rule_package_aide_installed Result pass Title Build and Test AIDE Database Rule xccdf_org.ssgproject.content_rule_aide_build_database Result fail

oscap exits with 0 when every rule passes, 2 when the scan worked and at least one rule failed, and 1 on an error, so a script that runs scans must treat 2 as a normal result. The warning line comes from one OVAL test in this content release and does not stop the scan. Count the results and read the score:

deploy@rocky10 · Rocky Linux 10.2
$ grep ^Result ~/cis/scan.txt | cut -f2 | sort | uniq -c
125 fail 32 notapplicable 167 pass
$ grep -o '<score[^>]*>[^<]*' ~/cis/results.xml
<score system="urn:xccdf:scoring:default" maximum="100.000000">69.209465

notapplicable rules test something this system does not have (for example, a package that is not installed). The score is weighted by rule, so 69 is not "69 percent of rules passed". It is a trend line for this host and this profile, not a grade to compare across different profiles. The results file also records which content produced it: the SSG release and the benchmark version of the profile.

deploy@rocky10 · Rocky Linux 10.2
$ grep -m1 -oE 'version update="[^"]*">[^<]+' ~/cis/results.xml grep -A1 'Profile id="xccdf_org.ssgproject.content_profile_cis_server_l1"' ~/cis/results.xml | grep -oE 'version>[^<]+'
version update="https://github.com/ComplianceAsCode/content/releases/latest">0.1.82 version>1.0.1

Keep those two numbers with every results file. A scap-security-guide update can add, split or retune rules, and the counts and score then move with no change on the host. The failures are where the work is:

deploy@rocky10 · Rocky Linux 10.2
$ grep -B2 'fail$' ~/cis/scan.txt | grep ^Title | cut -f2 | head -n 25
Build and Test AIDE Database Configure AIDE to Verify the Audit Tools Configure Periodic Execution of AIDE Implement Custom Crypto Policy Modules for CIS Benchmark Ensure /tmp Located On Separate Partition Ensure Only Users Logged In To Real tty Can Execute Sudo - sudo use_pty Ensure Sudo Logfile Exists - sudo logfile Require Re-Authentication When Using the sudo Command Ensure Red Hat GPG Key Installed Ensure Local Login Warning Banner Is Configured Properly Ensure Remote Login Warning Banner Is Configured Properly Ensure Active Authselect Profile Includes PAM Modules Configure the Use of the pam_faillock.so Module in the /etc/pam.d/password-auth File. Configure the Use of the pam_faillock.so Module in the /etc/pam.d/system-auth File. Ensure Password History Is Enforced for the Root User …

Many of these are controls this course has already covered: AIDE (the previous lesson), faillock and password history (PAM), sudo logging and use_pty (sudo hardening), login banners, a separate /tmp partition (mounts). Some are cheap on this server, some need a change window, and some do not fit it at all. "Ensure Red Hat GPG Key Installed" fails even with the Rocky content, because Rocky signs its packages with its own key: a reminder to read each failure before acting on it. A default Rocky install fails a benchmark by design; the defaults serve many uses, and the benchmark describes one hardened one. SELinux, which you configured in the last lesson, appears too: the CIS Level 1 profile requires it not to be disabled, and the Level 2 profile requires enforcing mode.

deploy@rocky10 · Rocky Linux 10.2
$ grep -A1 -E 'content_rule_(selinux|grub2_enable_selinux)' ~/cis/scan.txt
Rule xccdf_org.ssgproject.content_rule_grub2_enable_selinux Result pass -- Rule xccdf_org.ssgproject.content_rule_selinux_not_disabled Result pass -- Rule xccdf_org.ssgproject.content_rule_selinux_policytype Result pass

Generate fixes, read them, apply one

oscap xccdf generate fix writes a bash (or Ansible) remediation for every rule that failed in a results file. Generate it, and read it before anything runs.

deploy@rocky10 · Rocky Linux 10.2
$ cd ~/cis sudo oscap xccdf generate fix --fix-type bash --result-id "" results.xml > remediate.sh grep -c "^# BEGIN fix" remediate.sh
125
$ grep -o "^# BEGIN fix ([0-9]* / [0-9]*) for '[^']*'" ~/cis/remediate.sh | grep -E 'firewalld|authselect|_removed|grub2'
# BEGIN fix (12 / 125) for 'xccdf_org.ssgproject.content_rule_accounts_password_pam_modules_in_authselect_profile' # BEGIN fix (40 / 125) for 'xccdf_org.ssgproject.content_rule_file_permissions_boot_grub2' # BEGIN fix (41 / 125) for 'xccdf_org.ssgproject.content_rule_grub2_password' # BEGIN fix (49 / 125) for 'xccdf_org.ssgproject.content_rule_service_firewalld_enabled' # BEGIN fix (50 / 125) for 'xccdf_org.ssgproject.content_rule_firewalld_loopback_traffic_trusted'
$ sed -n '/BEGIN fix.*sshd_disable_root_login/,/END fix/p' ~/cis/remediate.sh
# BEGIN fix (117 / 125) for 'xccdf_org.ssgproject.content_rule_sshd_disable_root_login' … mkdir -p /etc/ssh/sshd_config.d chmod 0700 /etc/ssh/sshd_config.d touch /etc/ssh/sshd_config.d/00-complianceascode-hardening.conf chmod 0600 /etc/ssh/sshd_config.d/00-complianceascode-hardening.conf LC_ALL=C sed -i "/^\s*PermitRootLogin\s\+/Id" "/etc/ssh/sshd_config" LC_ALL=C sed -i "/^\s*PermitRootLogin\s\+/Id" "/etc/ssh/sshd_config.d"/*.conf … printf '%s\n' "PermitRootLogin no" > "/etc/ssh/sshd_config.d/00-complianceascode-hardening.conf" …

125 fixes, one per failed rule, and some of them change how people log in. The SSH fix shows why reading matters. It writes PermitRootLogin no into /etc/ssh/sshd_config.d/00-complianceascode-hardening.conf, which sorts first and therefore wins under sshd's first-match rule (the SSH lesson's drop-in convention). It also deletes every PermitRootLogin line from sshd_config and from every other file in sshd_config.d, including files your configuration management or cloud-init owns, which will fight back on their next run. Other fixes in the same script enable and start firewalld (installed and enabled on this lab server, but stopped) and change its zones, rewrite the authselect PAM profile and set a boot-loader password, any of which can lock you out or stop the application the server exists for.

So remediate one rule at a time, after reading its fix. --rule limits a scan to one rule, and --remediate runs the fix for rules that fail. The rule here stops the host sending ICMP redirects, which only a router needs (the network sysctls lesson explains the setting).

deploy@rocky10 · Rocky Linux 10.2
$ sed -n '/BEGIN fix.*sysctl_net_ipv4_conf_all_send_redirects/,/END fix/p' ~/cis/remediate.sh
# BEGIN fix (73 / 125) for 'xccdf_org.ssgproject.content_rule_sysctl_net_ipv4_conf_all_send_redirects' … SYSCONFIG_FILE='/etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf' … /sbin/sysctl -q -n -w net.ipv4.conf.all.send_redirects="0" … printf '# Per %s: Set %s in %s\n' "${cce}" "${formatted_output}" "${SYSCONFIG_FILE}" >> "${SYSCONFIG_FILE}" printf '%s\n' "$formatted_output" >> "${SYSCONFIG_FILE}" …
$ sudo oscap xccdf eval --profile cis_server_l1 \ --rule xccdf_org.ssgproject.content_rule_sysctl_net_ipv4_conf_all_send_redirects \ /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
… Title Disable Kernel Parameter for Sending ICMP Redirects on all IPv4 Interfaces Rule xccdf_org.ssgproject.content_rule_sysctl_net_ipv4_conf_all_send_redirects Result fail
$ sudo oscap xccdf eval --profile cis_server_l1 --remediate \ --rule xccdf_org.ssgproject.content_rule_sysctl_net_ipv4_conf_all_send_redirects \ /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
… --- Starting Remediation --- … Title Disable Kernel Parameter for Sending ICMP Redirects on all IPv4 Interfaces Rule xccdf_org.ssgproject.content_rule_sysctl_net_ipv4_conf_all_send_redirects Result fixed
$ sysctl net.ipv4.conf.all.send_redirects grep -rs send_redirects /etc/sysctl.conf /etc/sysctl.d/
net.ipv4.conf.all.send_redirects = 0 /etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf:# Per : Set net.ipv4.conf.all.send_redirects = 0 in /etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf /etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf:net.ipv4.conf.all.send_redirects = 0

The fix set the running value and wrote /etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf so it persists. Verify with the system's own tools as well as the scanner: sysctl shows the live value and the file shows why it will survive a reboot. The rollback for this rule is the reverse:

deploy@rocky10 · Rocky Linux 10.2
$ sudo rm /etc/sysctl.d/net_ipv4_conf_all_send_redirects.conf sudo sysctl -w net.ipv4.conf.all.send_redirects=1
net.ipv4.conf.all.send_redirects = 1
Never run a bulk remediation on a live server unreviewed
oscap xccdf eval --remediate without --rule applies every fix in the profile, and bash remediate.sh does the same. Apply fixes in a staging copy of the server first, take a snapshot or backup of the real one, keep a second way in (a console as well as the SSH session the change might break), and change one area at a time with a rollback written down. CIS recommendations are a starting point you tailor, not a script to run.

Tailoring: exceptions with a reason

Some rules will not apply to a given server. On Rocky, "Ensure Red Hat GPG Key Installed" can never pass: Rocky's packages are signed with Rocky's own key, and that is the only key installed.

deploy@rocky10 · Rocky Linux 10.2
$ grep -B2 'fail$' ~/cis/scan.txt | grep -A1 'Title.*GPG' rpm -q gpg-pubkey --qf '%{summary}\n'
Title Ensure Red Hat GPG Key Installed Rule xccdf_org.ssgproject.content_rule_ensure_redhat_gpgkey_installed Release Engineering (Rocky Linux 10) <releng@rockylinux.org> public key

The right response is a documented exception, not a failing rule everyone learns to ignore and not an edited copy of the profile. A tailoring file is a small XCCDF document that extends a profile: it can unselect rules, select extra ones and change values such as a minimum password length. autotailor (in the openscap-utils package) writes one. On RHEL 10.2 that package pulls in about thirty packages of RPM build tooling, so many teams run it on an admin machine and copy the tailoring file to servers.

deploy@rocky10 · Rocky Linux 10.2
$ sudo dnf install -y -q openscap-utils rpm -q openscap-utils
… Installed: annobin-docs-13.02-2.el10.noarch annobin-plugin-gcc-13.02-2.el10.aarch64 … openscap-utils-1.4.4-1.el10_2.aarch64
$ cd ~/cis autotailor --title "CIS L1 server, rocky10 exceptions" \ --unselect ensure_redhat_gpgkey_installed \ --output tailoring.xml \ /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml cis_server_l1 grep -E "Profile id|title|idref" tailoring.xml
<xccdf-1.2:Profile id="xccdf_org.ssgproject.content_profile_cis_server_l1_customized" extends="xccdf_org.ssgproject.content_profile_cis_server_l1"> <xccdf-1.2:title override="true">CIS L1 server, rocky10 exceptions</xccdf-1.2:title> <xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_ensure_redhat_gpgkey_installed" selected="false"/>
deploy@rocky10 · Rocky Linux 10.2
$ cd ~/cis sudo oscap xccdf eval --tailoring-file tailoring.xml --profile cis_server_l1_customized \ --results tailored.xml /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml > tailored.txt grep ^Result tailored.txt | cut -f2 | sort | uniq -c grep -c ensure_redhat_gpgkey_installed scan.txt tailored.txt grep -A1 "rule_ensure_redhat_gpgkey_installed\"" tailored.xml | grep -o "<result>[a-z]*"
… 124 fail 32 notapplicable 167 pass scan.txt:1 tailored.txt:0 <result>notselected

The tailored profile cis_server_l1_customized is the base profile minus one rule: 124 failures instead of 125, the rule absent from the output and marked notselected in the results. The file records what was excepted but not why. Keep the reason (Rocky signs with its own key; the Rocky key is checked instead by dnf's gpgcheck=1) with the tailoring file in version control, review it when the server changes, and scan with the tailoring file from then on, so the report shows only failures someone still has to act on.

The benchmark loop
1Scan
profile + data stream, keep results
2Review failures
does each rule fit this server?
3Tailor exceptions
tailoring file + written reason
4Remediate one area
read the fix, stage, snapshot
5Re-scan and verify
scanner result + system tools
6Schedule the scan
watch for drift, not just the score
Every step leaves a file you can show an auditor.

Once tailored, a scheduled scan (a systemd timer running oscap xccdf eval weekly, keeping each results file) turns the benchmark into drift detection: a rule that passed last week and fails today means something changed on the host, whether a hurried fix during an outage or an intruder re-enabling root login. Compare rule results as well as the score, and treat exit status 1 as the error, 2 as "some rules fail". Store the SSG version with each results file (rpm -q scap-security-guide, or the version lines above), and after a content update take a new baseline scan before you compare again. On more than one server, the results need a home: archive each host's results.xml centrally, or use a compliance service that runs the same SCAP content across your hosts, so you can compare hosts as well as weeks.

The same applies to every control in this course. Each drop-in you wrote (sshd, sudoers, PAM, sysctl, modprobe, nftables, AIDE) is a file for configuration management, not a hand edit. Run its validator as the tool's check step before the file is installed (sshd -t, visudo -cf, nft -c -f, findmnt --verify), create what a file depends on before the file (the group before AllowGroups, the key list before RevokedKeys), and roll a change out to a few canary hosts before the rest. A scan then catches the host someone changed by hand.

Ubuntu 26.04

Canonical's supported CIS tooling is the Ubuntu Security Guide (USG), an Ubuntu Pro service built on the same SSG content: sudo pro enable usg, sudo apt install usg, then usg audit cis_level1_server, usg fix and usg generate-tailoring. Canonical documents it for 20.04, 22.04 and 24.04. On 26.04 it is not offered yet:

deploy@web01 · Ubuntu 26.04 LTS
$ oscap --version | head -n 1
OpenSCAP command line tool (oscap) 1.4.3
$ pro status --all | grep -E '^(SERVICE|usg)'
WARNING: this output is intended to be human readable, and subject to change. In scripts, prefer using machine readable data from the `pro api` command, or use `pro status --format json`. SERVICE AVAILABLE DESCRIPTION usg no Security compliance and audit tools

The scanner is in the archive (openscap-scanner), and the community ssg-debderived package in universe carries SSG content, but only for older releases.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo apt install -y ssg-debderived
… Installing: ssg-debderived Installing dependencies: ssg-base …
$ ls /usr/share/xml/scap/ssg/content/ | grep -- -ds.xml
ssg-ubuntu2204-ds.xml ssg-ubuntu2404-ds.xml
$ oscap info --profiles /usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml | grep -i cis
xccdf_org.ssgproject.content_profile_cis_level1_server:CIS Ubuntu Linux 24.04 LTS Benchmark for Level 1 - Server xccdf_org.ssgproject.content_profile_cis_level1_workstation:CIS Ubuntu Linux 24.04 LTS Benchmark for Level 1 - Workstation xccdf_org.ssgproject.content_profile_cis_level2_server:CIS Ubuntu Linux 24.04 LTS Benchmark for Level 2 - Server xccdf_org.ssgproject.content_profile_cis_level2_workstation:CIS Ubuntu Linux 24.04 LTS Benchmark for Level 2 - Workstation
$ mkdir -p ~/cis-ub && cd ~/cis-ub sudo oscap xccdf eval --profile cis_level1_server --results results.xml \ /usr/share/xml/scap/ssg/content/ssg-ubuntu2404-ds.xml > scan.txt echo "oscap exit status: $?" grep ^Result scan.txt | cut -f2 | sort | uniq -c
OpenSCAP Error: Unable to open file: '/usr/share/openscap/cpe/openscap-cpe-dict.xml' [./src/source/oscap_source.c:298] Failed to add default CPE to newly created CPE Session. [./src/CPE/cpe_session.c:58] oscap exit status: 0 405 notapplicable

All 405 rules of the 24.04 Level 1 profile come back notapplicable and oscap exits 0: a clean-looking result that checked nothing. The content starts with a platform check (a CPE, Common Platform Enumeration, name for the older Ubuntu 24.04 release) that this system does not match, and the 26.04 openscap package also reports its own CPE dictionary missing. The skip is the correct behaviour: settings, package names and defaults changed between releases (sudo-rs and the Rust coreutils, for a start), and a 24.04 result on a 26.04 host would be misleading even if you forced it. Until USG or SSG ships content for 26.04, apply the controls in this course, and where you must show compliance, check the CIS benchmark document for the release (from the CIS website) by hand.

Where to go next

This is the last lesson of Linux hardening. The next course, Advanced Linux security (linux-det), takes the attacker's view of the same hosts: privilege escalation paths and how to find them first, the audit pipeline and detections built on the auditd rules you wrote, osquery, eBPF and Falco, hunting, live triage and incident response. It assumes Linux essentials and this course: auditd installed with keyed rules, AppArmor or SELinux enforcing, SUID, cron and timer baselines, logs forwarded with correct time, and file integrity monitoring in place. Advanced Linux internals (linux-perf) is optional if you want the kernel and systemd mechanisms behind sysctls, cgroups and units in more depth.

Try this

On a RHEL-family lab machine with scap-security-guide installed, run the Level 1 Server scan above and keep results.xml. Pick one failing rule from the list that you understand from this course, read its fix in the generated script, and apply it with --rule and --remediate. Confirm the change with the system's own command (for a sysctl, sysctl; for a file, stat or cat), re-run the scan with --rule to see it pass, then undo the change and confirm the rule fails again. Finally, write a tailoring file that unselects one rule you cannot meet on your lab, scan with it, and check that the rule is notselected in the new results.

Takeaway

Use a CIS profile as a measured starting point, not a script: scan, tailor out what does not fit with a written reason, fix one reviewed area at a time with a rollback ready, and re-scan on a schedule so a newly failing rule tells you what changed. On Ubuntu 26.04, do not scan with content made for another release.

Quick check
01A nightly job runs oscap xccdf eval against the CIS Level 1 profile under set -e, and it keeps aborting before the results are archived, although the scan completes. Why?
Correct — Exit status 2 means the scan worked and some rules failed, which is the normal state of a real server.
Incorrect — The OVAL checks are non-interactive; the job completes the scan and then stops on the exit status.
Incorrect — The report is optional; exit status reflects the rule results, not which outputs were requested.
Incorrect — A scan without --remediate only reads the system; there is nothing half-applied.
02An auditor wants a CIS scan of your new Ubuntu 26.04 servers. A colleague installs ssg-debderived and scans with the ubuntu2404 data stream, and the report shows zero failures. What is the correct reading?
Incorrect — Nothing was checked: the rules did not pass, they were skipped as not applicable to this release.
Incorrect — ssg-debderived is a free community package; the scan ran and marked every rule notapplicable because of the release check.
Correct — The content's platform check does not match 26.04, and forcing it would be misleading anyway because defaults changed.
Incorrect — No release ships CIS settings by default; a default install fails many recommendations by design.
03A server cannot meet "Ensure /var/log located on separate partition" without a rebuild. What is the maintainable way to handle it?
Incorrect — A permanent known failure trains everyone to ignore failures, and new ones get lost among it.
Correct — The exception is explicit and reviewable, and the report shows only failures someone must act on.
Incorrect — Editing vendor content is overwritten by the next package update and hides what was changed.
Incorrect — There is no automatic fix for partitioning an existing disk; the rule has no safe remediation on a running system.

Related