Time: ~2 hours · Difficulty: Intermediate · You need: Docker (the scanner is Linux-only, so the lab runs in a container)
What OVAL is, and why you will read it rather than write it
OVAL is the Open Vulnerability and Assessment Language: an XML vocabulary for stating, precisely and machine-readably, what has to be true of a system for some claim about it to hold. "This host is affected by CVE-2023-0286" is a claim. OVAL is how a vendor writes that claim down so that any scanner can evaluate it and get the same answer.
Be clear-eyed about your relationship to it. Almost nobody authors OVAL from scratch, and this page is not going to pretend otherwise. What you will do, repeatedly, is consume it and occasionally argue with it: every major Linux distribution publishes its security advisories as OVAL, every SCAP-based compliance scan is OVAL underneath, and every DISA STIG and CIS Benchmark you run through an automated checker is delivered as OVAL bound to an XCCDF checklist.
Which means that when your vulnerability scanner and your distribution disagree about whether you are exposed, the authoritative answer is sitting in an OVAL definition, and being able to read it is the difference between escalating a real finding and burning a week patching a false positive.
The single most useful thing OVAL encodes: backports. Red Hat, SUSE, Debian, and Ubuntu routinely fix a vulnerability by patching an older package version rather than shipping the newer upstream release. The version string stays at, say, 1.1.1k forever, and only the release field moves. A scanner that compares your version against "fixed in upstream 1.1.1n" reports you as vulnerable and is wrong. The vendor's OVAL states the real fixed evr, and it is the only thing that does.
On this page
Where OVAL sits inside SCAP
OVAL is one component of the Security Content Automation Protocol, and the components divide the work cleanly:
- XCCDF is the checklist. Rules, human-readable titles and rationale, severities, and profiles that group rules into a named baseline such as a CIS Level 1 or a STIG.
- OVAL is the machinery. Each XCCDF rule points at an OVAL definition that says how to actually determine pass or fail on a live system.
- CPE names platforms, so content can say "only apply this on RHEL 9".
- CVE and CVSS supply identifiers and scoring; CCE does the same for configuration items.
- OCIL covers checks a machine cannot make, by asking a human a structured question.
- ARF is the results format, so one scan's output is consumable by another tool.
In practice these arrive bundled as a data stream: one XML file containing the XCCDF, the OVAL, and the CPE dictionary together. That is what a file named ssg-rhel9-ds.xml is.
Current versions, and who owns what
This corner of the ecosystem has moved, and stale references are everywhere.
- SCAP is specified by NIST. SCAP 1.3 is SP 800-126 Revision 3; SP 800-126 Revision 4, specifying SCAP 1.4, was finalised on 8 June 2026 and drops backward-compatibility requirements for the earlier SCAP versions.
- OVAL was created by MITRE, and the MITRE OVAL site is now explicitly in archive status. Sponsorship passed to the SCAP Compliance Checker team at the Naval Information Warfare Center, and the live home of the schemas is github.com/OVAL-Community/OVAL. The 5.12.x line is what tools implement in practice; OVAL 6.0 landed in January 2025 as the first major version in nearly twenty years.
- OpenSCAP (
oscap) is the reference open-source scanner, and ComplianceAsCode is the project that actually builds the CIS, STIG, PCI, and HIPAA content most people run.
You will still find plenty of documentation pointing at MITRE as the current owner. It is not, and a link into the archived site is a good signal that whatever you are reading has not been touched in years.
The four building blocks
OVAL's structure is where people bounce off it, because everything is joined by id reference rather than by nesting. Four kinds of element, each with a single job:
- An object says what to look at: "the RPM named openssl", "the file /etc/ssh/sshd_config", "the registry key at this path".
- A state says what would be true if the claim holds: "its evr is less than 1:3.0.7-6.el9_1".
- A test binds one object to one state and adds a check semantic: "at least one of the collected items must match this state".
- A definition carries the metadata (title, CVE references, class) and a criteria tree of AND, OR, and NOT over tests.
Read it as a sentence and the indirection stops being noise: a definition is true when its criteria are satisfied; a criterion is satisfied when its test passes; a test passes when the items collected for its object match its state.
One definition, shaped like a real vendor advisory
Element names and structure below match what Red Hat actually publishes; the ids and versions are illustrative.
<definition class="patch" id="oval:com.redhat.rhsa:def:20231234" version="1">
<metadata>
<title>RHSA-2023:1234: openssl security update (Important)</title>
<affected family="unix">
<platform>Red Hat Enterprise Linux 9</platform>
</affected>
<reference source="CVE" ref_id="CVE-2023-0286"
ref_url="https://access.redhat.com/security/cve/CVE-2023-0286"/>
</metadata>
<!-- The criteria tree. Note the AND: the platform test guards the
package test, so this definition simply does not apply on RHEL 8. -->
<criteria operator="AND">
<criterion test_ref="oval:com.redhat.rhsa:tst:20231234001"
comment="Red Hat Enterprise Linux 9 is installed"/>
<criterion test_ref="oval:com.redhat.rhsa:tst:20231234002"
comment="openssl is earlier than 1:3.0.7-6.el9_1"/>
</criteria>
</definition>Follow tst:20231234002 and you land on the test, which points at an object and a state:
<!-- TEST: check="at least one" means the test passes if any installed
package matching the object satisfies the state. -->
<rpminfo_test id="oval:com.redhat.rhsa:tst:20231234002" check="at least one"
comment="openssl is earlier than 1:3.0.7-6.el9_1" version="1">
<object object_ref="oval:com.redhat.rhsa:obj:20231234002"/>
<state state_ref="oval:com.redhat.rhsa:ste:20231234002"/>
</rpminfo_test>
<!-- OBJECT: what to collect from the system. Just a package name. -->
<rpminfo_object id="oval:com.redhat.rhsa:obj:20231234002" version="1">
<name>openssl</name>
</rpminfo_object>
<!-- STATE: the condition that means "still vulnerable".
datatype="evr_string" is the important part - see below. -->
<rpminfo_state id="oval:com.redhat.rhsa:ste:20231234002" version="1">
<evr datatype="evr_string" operation="less than">1:3.0.7-6.el9_1</evr>
</rpminfo_state>evr is epoch, version, release. datatype="evr_string" tells the scanner to compare using RPM's own version-comparison algorithm rather than string or numeric comparison, which is the only way 1.1.1k-9 correctly sorts after 1.1.1k-8 and before 1.1.1k-10.
And look at where the fix lives: -6.el9_1, the release field. The upstream version never moved. That is a backport, written down in the only place it is written down.
Walk-through
1. Get a scanner
oscap is Linux-only, so run the lab in a container regardless of your laptop.
mkdir -p ~/oval-lab && cd ~/oval-lab # -v mounts this directory into the container so reports survive exit. docker run --rm -it -v "$PWD:/work" -w /work fedora:latest bash # ...then, inside the container: dnf install -y openscap-scanner scap-security-guide bzip2 libxml2 && oscap --version
oscap --version prints the OVAL and XCCDF schema versions it supports. Read them. Content written against a newer schema than your scanner understands is a real and confusing failure mode.
2. Fetch a real vendor OVAL feed
# Red Hat publishes per-release OVAL streams; Debian, Ubuntu, and SUSE # publish equivalents. This is a large file, so expect a moment. curl -sSLO https://access.redhat.com/security/data/oval/v2/RHEL9/rhel-9.oval.xml.bz2 bunzip2 -f rhel-9.oval.xml.bz2 # How many definitions did we just download? grep -c '<definition ' rhel-9.oval.xml
3. Read one definition end to end
This is the exercise that makes the format stop feeling opaque. Pick a CVE, find its definition, then follow the id chain by hand.
# Find a definition that references a CVE you care about. xmllint --xpath \ "//*[local-name()='definition'][.//*[@ref_id='CVE-2023-0286']]/@id" \ rhel-9.oval.xml # Note local-name(): OVAL is heavily namespaced, and a plain //definition # XPath silently returns nothing. Empty output from a namespaced document # almost always means the namespace, not an absent element.
# Then walk the chain, substituting each id you find as you go: # definition -> criterion test_ref -> test -> object_ref + state_ref xmllint --xpath "//*[@id='oval:com.redhat.rhsa:tst:20231234002']" rhel-9.oval.xml
4. Run an OVAL-only evaluation
This evaluates the feed against the running system and tells you which definitions are true, meaning which advisories apply to you.
oscap oval eval --results oval-results.xml --report oval-report.html \ rhel-9.oval.xml # The container is Fedora, not RHEL 9, so expect nearly everything to come # back "false" via the platform test. That is a correct result and a useful # control: it proves the platform guard in the criteria tree is doing its job.
5. Run a real compliance scan through XCCDF
Now the other half of SCAP. The scap-security-guide package installs data streams under /usr/share/xml/scap/ssg/content/.
ls /usr/share/xml/scap/ssg/content/ # oscap info tells you what is inside a data stream, and crucially which # profiles it offers. Never guess a profile id - they are long and versioned. oscap info /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml
oscap xccdf eval \ --profile xccdf_org.ssgproject.content_profile_standard \ --results-arf arf.xml \ --report report.html \ /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml echo $? # 0 = all rules passed, 2 = at least one failed, 1 = the scan errored
Exit code 1 and exit code 2 mean completely different things, and a CI step written as oscap ... || exit 1 collapses them. 2 is "the scan worked and found problems". 1 is "the scan did not happen". Treating a scan that never ran as a scan that found nothing is the failure this whole discipline exists to prevent.
6. Read the results
# Open report.html in a browser for the human view. For the machine view, # pull the rule results straight out of the ARF: xmllint --xpath \ "//*[local-name()='rule-result'][*[local-name()='result']='fail']/@idref" \ arf.xml | tr ' ' '\n' | head -20
Every failing rule id maps back to an XCCDF rule with prose rationale, and that rule points at the OVAL definition that produced the verdict. When someone disputes a finding, that chain is the evidence.
7. Generate remediation, and read it before running it
oscap xccdf generate fix \ --profile xccdf_org.ssgproject.content_profile_standard \ --fix-type bash \ /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml > remediate.sh # --fix-type ansible emits a playbook instead, which is usually the better # artifact: it is idempotent, reviewable, and fits an existing change process.
Generated remediation is genuinely useful and genuinely dangerous. It is derived from a baseline that knows nothing about your workload, and a STIG profile applied wholesale to a running application server will disable something that application needs. Treat the output as a reviewed pull request, never as a command to pipe into a shell.
8. Scan a container image instead of a host
# openscap-utils ships oscap-podman, which mounts an image's filesystem # and evaluates against it without starting the container. dnf install -y openscap-utils oscap-podman <image-id> oval eval --report image-report.html rhel-9.oval.xml
This is where OVAL earns its keep in cloud work. The unit you are assessing is a golden image or a base layer, not a long-lived server, and answering "which advisories apply to this image" before it is deployed a thousand times is worth considerably more than answering it afterwards.
Hands-on exercises
Using the Red Hat feed you downloaded, pick any definition and follow it all the way down: definition to criterion, criterion to test, test to object and state. Write out in one plain sentence what condition the definition is checking.
Show the answer
Your sentence should come out roughly as: "On a system where the RHEL 9 platform test passes, at least one installed RPM named X has an evr less than Y."
Two structural things are worth having noticed on the way down. The platform test is nearly always ANDed at the top of the criteria tree, which is why a definition can be present in a feed and simply not apply. And the same object is frequently referenced by several tests with different states, which is how one definition covers a package that was fixed at different versions on different minor releases.
A scanner reports your RHEL 9 host as vulnerable to a CVE in openssl, citing "fixed in 3.0.8". Your host has openssl-3.0.7-6.el9_1. The vendor OVAL says the fixed evr is 1:3.0.7-6.el9_1 and the definition evaluates to false. Who is right, and what exactly did the scanner get wrong?
Show the answer
The vendor is right and you are not vulnerable. The scanner compared your version against the upstream fixed version and concluded that 3.0.7 is less than 3.0.8. Red Hat backported the fix into the 3.0.7 package and expressed it in the release field, -6.el9_1, leaving the upstream version untouched.
The scanner's mistake is a category error, not an arithmetic one: it used upstream version numbers as a proxy for patch status on a distribution that deliberately decouples those two things. This is why the evr comparison and the vendor feed both exist.
The practical move when you meet this: get the evidence rather than the argument. rpm -q --changelog openssl | grep CVE- lists the CVEs the installed package claims to fix, and it either names yours or it does not.
Take one failing rule id from your XCCDF scan and find the OVAL definition behind it. Then answer: could you have determined pass or fail by reading the XCCDF alone?
Show the answer
# The XCCDF rule carries a <check-content-ref> naming the OVAL definition id. xmllint --xpath "//*[local-name()='Rule'][@id='<rule-id>'] //*[local-name()='check-content-ref']/@name" \ /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml
No, and that is the whole division of labour. XCCDF says what should be true and why it matters, in prose aimed at a human. OVAL says how to determine whether it is true, in a form aimed at a machine. An XCCDF rule with no check reference is documentation; it will report notchecked and it will not fail a scan.
That last point is worth checking for deliberately. A profile can look like it is enforcing far more than it is, because notchecked results are easy to read past in a report that is mostly green.
A rule in the profile is wrong for your environment and you need to switch it off. Why is editing the data stream the wrong answer, and what is the right one?
Show the answer
Editing the data stream forks vendor content. The next release overwrites your change or, worse, does not, and you quietly drift onto a private baseline nobody can reproduce or audit. You also lose the signature on signed content.
The right mechanism is tailoring: a separate XCCDF tailoring file that selects and deselects rules and overrides variable values, referencing the original data stream without modifying it.
oscap xccdf eval \ --profile "$PROFILE" \ --tailoring-file my-tailoring.xml \ /usr/share/xml/scap/ssg/content/ssg-fedora-ds.xml
The tailoring file is also the artifact your auditor actually wants, because it is an explicit, reviewable, version-controlled list of every deviation from the published baseline and, if you write the remark fields, why. scap-workbench has a GUI for producing one if hand-writing XCCDF is not how you want to spend an afternoon.
Common mistakes
- Trusting an upstream version comparison over the vendor feed. Backports make naive version matching wrong in the direction that wastes the most time.
- Plain XPath against namespaced XML.
//definitionreturns nothing. Uselocal-name()or declare the namespaces. Empty output looks exactly like an absent element. - Collapsing oscap exit codes 1 and 2. "Scan failed to run" and "scan found failures" are opposite situations, and only one of them means your system needs attention.
- Ignoring
notcheckedandnotapplicable. A report can be almost entirely green because most of its rules never ran. Count results by status before quoting a pass rate. - Editing vendor data streams instead of tailoring. Un-auditable, and silently reverted on the next content update.
- Piping generated remediation into a shell. It is a proposal derived from a baseline that has never seen your workload.
- Citing MITRE as OVAL's maintainer. That site is archived. If a document you are relying on says otherwise, treat its other currency claims with the same suspicion.
Where next
- Vulnerability management for where scan output becomes a prioritised, owned queue rather than a spreadsheet.
- Container security for scanning images and base layers, which is where
oscap-podmanand tools like Trivy and Grype overlap. - GRC and compliance for the reporting side, and how a tailoring file becomes evidence.
- OPA and Rego for the same "policy as data" instinct applied to configuration you control rather than packages you consume.
- The OpenSCAP portal and ComplianceAsCode, which is where you go to see how the CIS and STIG content is actually built.
