Purpose of this page: define a traceable workflow for hardening Rocky Linux 10 VMs from a central Ansible control node according to CIS Server Level 1, rebooting them, scanning them, and later checking them for drift.
The approach is intentionally split into two layers:
/usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
xccdf_org.ssgproject.content_profile_cis_server_l1
A control node gives you:
Important: the generated OpenSCAP remediation should not be treated as permanently maintained code. It is an intermediate artifact generated from the current SCAP content. Stable decisions belong in your own playbooks and variables.
Recommended project structure:
rocky10-cis-l1-hardening/
├── README.md
├── docs/
│ ├── runbook.md
│ ├── current-status.md
│ ├── wiki-rocky10-cis-l1-control-node.md
│ └── wiki-rocky10-cis-l1-control-node_en.md
├── inventory/
│ ├── rocky10-cis.example.ini
│ └── rocky10-cis.ini # local, not necessarily committed
├── playbooks/
│ ├── 00-bootstrap-openscap.yml
│ ├── 20-apply-ssh-supplement.yml
│ └── 30-post-scan.yml
├── generated/
│ └── <hostname>/
│ └── remediate-cis-server-l1.yml
├── reports/
│ └── <hostname>/
│ ├── pre-hardening-report.html
│ ├── pre-hardening-results.xml
│ ├── post-hardening-report.html
│ ├── post-hardening-results.xml
│ └── post-validation-summary.txt
└── compliance-state/
└── <hostname>/
├── exceptions.yml
├── last-scan-summary.yml
├── failed-rules.txt
└── notes.md
Recommendation:
playbooks/, docs/, and example inventories.generated/ only when needed, because it is generated from SCAP content.reports/ and compliance-state/ selectively if you want Git diffs for drift and historical findings.Example:
[rocky10_cis]
vm01.example.ch
vm02.example.ch
[rocky10_cis:vars]
ansible_user=rockyadmin
ansible_become=true
ansible_become_method=sudo
ansible_python_interpreter=auto_silent
Before hardening, the following must be true:
sudo.wheel.Test connectivity:
cd /home/hermes/work/rocky10-cis-l1-hardening
ansible -i inventory/rocky10-cis.ini rocky10_cis -m ping
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'id'
ansible -i inventory/rocky10-cis.ini rocky10_cis -m setup -a 'filter=ansible_distribution*'
Expected result:
ping: pongbecome returns uid=0(root)Goal:
Architecture note: operationally this is a remote scan. The control node starts everything via SSH/Ansible. No Ansible is installed on the target VM; the target only needs the OpenSCAP packages for scanning and generating the remediation.
Command:
ansible-playbook -i inventory/rocky10-cis.ini playbooks/00-bootstrap-openscap.yml --diff
Typical tasks on the target system:
openscap-scannerscap-security-guideauditfirewalldchronypolicycoreutils-python-utilsNot installed:
ansible / ansible-core on the target VM. Remediation is started from the control node. The target VM only needs SSH, Python for Ansible modules, and the OpenSCAP tools for scanning/generation.
Enable services:
auditdfirewalldchronydVerify datastream:
test -f /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
--results /tmp/pre-hardening-results.xml \
--report /tmp/pre-hardening-report.html \
/usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
oscap xccdf generate fix \
--fix-type ansible \
--profile xccdf_org.ssgproject.content_profile_cis_server_l1 \
--output /tmp/remediate-cis-server-l1.yml \
/usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml
Not every CIS rule should or can be applied automatically on every system. These decisions should not be manual changes on the VM. They belong in versioned site policy inside the project.
Example compliance-state/global-exceptions.yml:
cis_exceptions:
- rule_id: grub2_password
decision: excluded
reason: No GRUB password on these virtualized servers; console access is controlled through hypervisor/RBAC.
scope: all_rocky10_vms
owner: platform
- rule_id: package_aide_installed
decision: excluded
reason: AIDE is not used; file integrity monitoring is covered by a central EDR/FIM solution.
scope: all_rocky10_vms
owner: security
- rule_id: aide_periodic_cron_checking
decision: excluded
reason: Depends on AIDE; see exception package_aide_installed.
scope: all_rocky10_vms
owner: security
Important:
Typical global candidates:
systemd-journal-upload when no central journal upload URL existsReview the generated playbook first:
less generated/<hostname>/remediate-cis-server-l1.yml
Review points:
PermitRootLogin no set, and is that intended?AllowGroups rules, and is the Ansible user in that group?Then apply it:
ansible-playbook -i inventory/rocky10-cis.ini generated/<hostname>/remediate-cis-server-l1.yml --diff
Reboot afterwards:
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m reboot -a 'reboot_timeout=900'
After the reboot, first verify only reachability and baseline services:
ansible -i inventory/rocky10-cis.ini rocky10_cis -m ping
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'id'
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'sshd -t'
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'systemctl is-active firewalld auditd chronyd'
If SSH no longer works:
sshd_config, drop-ins, firewalld/nftables, and user groupsAllowGroups wheel against the effective group membership of the admin userAfter the first generated remediation, a custom supplement is usually needed. This supplement should be stable and intentionally maintained.
Example for SSH:
ansible-playbook -i inventory/rocky10-cis.ini playbooks/20-apply-ssh-supplement.yml --diff
Typical supplement tasks:
sshd -tsystemd-journal-upload when no upload URL is configuredExample local exception:
- name: Disable systemd-journal-upload when no upload URL is configured
ansible.builtin.systemd:
name: systemd-journal-upload.service
enabled: false
state: stopped
masked: true
This exception is not a trick; it is an architecture decision: either configure central journal upload infrastructure or intentionally disable and document the service.
Run the post-scan and fetch reports:
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
Expected artifacts:
reports/<hostname>/post-hardening-report.html
reports/<hostname>/post-hardening-results.xml
reports/<hostname>/post-validation-summary.txt
Important additional checks:
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'getenforce'
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'sshd -t'
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'systemctl --failed --no-legend'
A robust production hardening workflow is:
Why twice?
Example sequence:
# 1. Bootstrap and pre-scan
ansible-playbook -i inventory/rocky10-cis.ini playbooks/00-bootstrap-openscap.yml --diff
# 2. First remediation
ansible-playbook -i inventory/rocky10-cis.ini generated/<hostname>/remediate-cis-server-l1.yml --diff
# 3. First reboot
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m reboot -a 'reboot_timeout=900'
# 4. Supplement / post-hardening
ansible-playbook -i inventory/rocky10-cis.ini playbooks/20-apply-ssh-supplement.yml --diff
# 5. Optional second generated remediation, if desired
ansible-playbook -i inventory/rocky10-cis.ini generated/<hostname>/remediate-cis-server-l1.yml --diff
# 6. Second reboot
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m reboot -a 'reboot_timeout=900'
# 7. Final scan
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
Goal: later you want to see whether a VM has new rule violations or whether the compliance state has changed.
Each scan should therefore produce a normalized, diffable summary. HTML reports are useful for reading, but bad for Git diffs. XML is complete, but noisy. YAML/text files can also diff on every run if they contain timestamps, changing order, or other volatile values. Therefore, store a small, deterministically generated text/YAML summary in addition to the raw reports.
Recommended files per host:
compliance-state/<hostname>/last-scan-summary.yml
compliance-state/<hostname>/failed-rules.txt
compliance-state/<hostname>/notchecked-rules.txt
compliance-state/<hostname>/exceptions-applied.yml
compliance-state/<hostname>/notes.md
Example failed-rules.txt:
xccdf_org.ssgproject.content_rule_grub2_password
xccdf_org.ssgproject.content_rule_package_aide_installed
xccdf_org.ssgproject.content_rule_aide_periodic_cron_checking
Example last-scan-summary.yml:
host: vm01.example.ch
os: Rocky Linux 10
profile: xccdf_org.ssgproject.content_profile_cis_server_l1
scan_time: 2026-06-02T18:00:00+02:00
score: 87.5
result_counts:
pass: 210
fail: 12
notchecked: 4
notapplicable: 33
exceptions_applied:
- grub2_password
- package_aide_installed
new_unexpected_failures: 0
Workflow:
# Run scan
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
# Check normalized summary
git diff -- compliance-state reports
# If the state is intentionally accepted
git add compliance-state/<hostname>/ reports/<hostname>/post-validation-summary.txt
git commit -m "Update CIS L1 compliance state for <hostname>"
This lets you see later:
Not everything is suitable for Git. Raw data and non-deterministically generated files can diff even when there is no new compliance violation in practice.
Do not use these as the primary Git diff source:
Recommendation:
rule_id and remove duplicates.Example future helper task:
python3 scripts/extract-oscap-summary.py \
reports/<hostname>/post-hardening-results.xml \
compliance-state/<hostname>/last-scan-summary.yml \
compliance-state/<hostname>/failed-rules.txt
After initial hardening, the same control node can usually run scan-only checks:
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
Afterwards:
git diff -- compliance-state/<hostname>/ reports/<hostname>/post-validation-summary.txt
Interpretation:
failed-rules.txt: new violation or changed SCAP content.Checking and remediating are not the same thing.
Recommended model:
Why not always remediate automatically?
Useful workflow when drift appears:
For existing hardened VMs, this is usually enough:
cd /home/hermes/work/rocky10-cis-l1-hardening
ansible -i inventory/rocky10-cis.ini rocky10_cis -m ping
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
git diff -- compliance-state reports
If the diff contains only expected changes:
git add compliance-state reports/*/post-validation-summary.txt
git commit -m "Update Rocky 10 CIS L1 compliance state"
If new unexpected violations appear:
# Analyze first, then remediate deliberately
ansible-playbook -i inventory/rocky10-cis.ini playbooks/20-apply-ssh-supplement.yml --diff
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
git diff -- compliance-state reports
Custom playbooks should contain everything that is site policy or where OpenSCAP lacks enough context:
Do not permanently maintain these manually inside the generated playbook:
If a generated playbook has a known generator bug, you can patch it automatically after generation. That patch logic should live in a custom script or playbook, not as manual edits to the file.
| Risk | Safeguard |
|---|---|
| SSH lockout | non-root admin user in allowed group, console/snapshot, sshd -t, check firewalld SSH |
| Root login disabled | intended; ensure admin user with sudo first |
| PAM/password policy blocks user | check before remediation, keep a test login open |
| firewalld/nftables blocks access | allow SSH service permanently and at runtime, console fallback |
| GRUB/FIPS/mount rules not fixable post-install | document as installation requirement or exception |
| AIDE causes operational overhead | enable intentionally or document a global exception |
| Automatic remediation affects services | run remediation only after review/change window |
inventory/: defines host groups.playbooks/00-bootstrap-openscap.yml: installs scanner, runs pre-scan, generates remediation.generated/<hostname>/: contains generated remediation per host.playbooks/20-apply-ssh-supplement.yml: stable site hardening after generated remediation.playbooks/30-post-scan.yml: scans and fetches reports.compliance-state/: normalized, diffable state files.docs/: runbook, wiki page, status, and architecture decisions.cd /home/hermes/work/rocky10-cis-l1-hardening
# 0. Check inventory
ansible -i inventory/rocky10-cis.ini rocky10_cis -m ping
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m command -a 'id'
# 1. Bootstrap + pre-scan + generate remediation
ansible-playbook -i inventory/rocky10-cis.ini playbooks/00-bootstrap-openscap.yml --diff
# 2. Review generated remediation
less generated/<hostname>/remediate-cis-server-l1.yml
# 3. Initial hardening
ansible-playbook -i inventory/rocky10-cis.ini generated/<hostname>/remediate-cis-server-l1.yml --diff
# 4. Reboot
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m reboot -a 'reboot_timeout=900'
# 5. Post-hardening / apply site policy
ansible-playbook -i inventory/rocky10-cis.ini playbooks/20-apply-ssh-supplement.yml --diff
# 6. Optional second reboot
ansible -i inventory/rocky10-cis.ini rocky10_cis -b -m reboot -a 'reboot_timeout=900'
# 7. Final scan and state capture
ansible-playbook -i inventory/rocky10-cis.ini playbooks/30-post-scan.yml --diff
# 8. Review diff
git diff -- reports compliance-state
For this workflow, a system is not considered clean just because OpenSCAP reports 100 percent. It is clean when:
compliance-state/global-exceptions.yml as a real file.scripts/extract-oscap-summary.py to normalize XML into YAML/text.playbooks/30-post-scan.yml so it generates compliance-state/<hostname>/failed-rules.txt.playbooks/40-remediate-drift.yml, which only runs approved post-hardening actions..gitignore policy for large HTML/XML reports.