Skip to content

Check the standalone OVAL checks against their copies in the datastream - #163

Merged
stevebeattie merged 1 commit into
chainguard-dev:mainfrom
stevebeattie:mirror-consistency-check
Aug 25, 2026
Merged

Check the standalone OVAL checks against their copies in the datastream#163
stevebeattie merged 1 commit into
chainguard-dev:mainfrom
stevebeattie:mirror-consistency-check

Conversation

@stevebeattie

@stevebeattie stevebeattie commented Aug 24, 2026

Copy link
Copy Markdown
Member

Why

The same OVAL definitions live in two places, and nothing compared them.
validate_checks and validate_xml each check one copy against the schema in
isolation, so a change applied to one and not the other passes CI. That is how
openssh-sftp-server came to be banned by the standalone RemoteAccessServices
check while the shipped datastream ignored it — the bug fixed in #162.

This adds the check that would have caught it. Reverting #162's datastream
change makes it fail, naming the entity and printing both patterns:

::error::RemoteAccessServicesTest.xml: functional content differs (1 differing)
    oval:org.RemoteAccessServices:obj:6 differs:
        standalone : .../pattern|...|^P:(openssh|openssh-server|openssh-sftp-server|...
        datastream : .../pattern|...|^P:(openssh|openssh-server|dropbear|...

What it compares, and why the split

Functional content — criteria, tests, objects, states, variables — is an
error: the two copies would evaluate the same image differently.

Metadata is a warning. The copies disagree today on a
<reference source> reading Custom in every standalone file and CIS in five
embedded blocks, which changes no verdict. Failing on it would block adoption
behind a content change. --strict-metadata promotes it once that is settled.

Comparison is per entity, not a flat node list

Worth flagging for review, because the obvious approach is wrong and was what I
wrote first. Flattening both copies into one ordered list of nodes fails three
ways:

  • a difference that is a permutation or a count change reports nothing
    locatable — just 0 only in the file, 0 only in the datastream;
  • listing two tests in a different order reads as functional drift, although it
    is schema-valid, changes no verdict, and is something the generator is free to
    do — a false positive on the exact axis this check gates;
  • a flat list loses which parent a child belongs to, so a relocated element is
    caught only as a side effect of ordering rather than by comparing structure.

Keying on OVAL id and canonicalising each entity recursively (so a child's path
from its entity root is part of its identity) fixes all three. Duplicate entity
ids are reported rather than one silently displacing the other.

The tests pin those properties directly, since none of the three defects is
detectable from outcome-only assertions: sibling reordering, a namespace-prefix
difference, an element duplicated inside an entity, an entity present in only
one copy, an element relocated between entities, and that a finding names its
entity and shows both values — including for a count-only difference, the case
that previously printed nothing. Each was confirmed to fail against the
flat-list implementation rather than merely passing against this one.

The generator is why this matters beyond tidiness

chainguard-dev/oscap-playground assembles the datastream when a new SRG version
lands; its create_extended_component() reads each standalone file and embeds it
verbatim. So the standalone files are the source and the datastream is a
build artifact. Between SRG bumps the datastream is hand-edited and the
generator never runs.

Keeping this green is therefore what makes the next regeneration safe: if the
datastream already matches what the in-repo sources produce, rebuilding cannot
silently revert anything. Worth knowing separately: the generator's own input
copies are currently well behind this repo — its DetectOpenSslTest.xml is 72
lines against our 273, and its CertificateAuditTest.xml still carries a literal
<hash> — so regenerating today would revert a great deal. Out of scope here.

Also reported, not fixed

Definitions present only in the datastream, which therefore cannot be evaluated
with oscap oval eval and have no input for regeneration: oval:org.stub:def:1
and CertificateAudit. The missing CertificateAuditTest.xml has to be derived
from the datastream rather than copied from the generator's stale version, so it
is left for a follow-up.

Usage

make validate_mirrors            # the check
make validate_mirrors_selftest   # its own unit tests

Both run on PRs via the offline workflow — pure Python, repo-local, no registry
or network, so no new allowed endpoints. Not added to the validate aggregate,
which already fails on the known-broken validate_xml; CI invokes it directly.

Verification

  • Unit tests: 6 methods, 10 table cases. Green here; failing against the
    flat-list implementation.
  • make validate_mirrors green on the real content — checked 7 files, 0
    errors, the 5 expected metadata warnings.
  • actionlint issue count unchanged from main; zizmor no findings;
    oscap ds sds-validate rc=0.
  • No datastream or fixture changes in this PR; it is tooling only.

Adds a .gitignore (the repo had none) for Python bytecode, plus the scan-output
directories the README already described as ignored.

🤖 Generated with Claude Code

@stevebeattie
stevebeattie force-pushed the mirror-consistency-check branch from 3995e64 to 4254a0c Compare August 24, 2026 18:31
@stevebeattie
stevebeattie requested a review from egibs August 24, 2026 18:44
Comment thread tests/mirrors/check_mirrors.py Outdated
The same OVAL definitions live in two places and nothing compared them.
validate_checks and validate_xml each check one copy against the schema in
isolation, so a change applied to one and not the other passes CI — which is
how openssh-sftp-server came to be banned by the standalone
RemoteAccessServices check while the shipped datastream ignored it (chainguard-dev#162).

Add tests/oscap-offline/internal/mirrors, with the repository-wide assertion
as TestRepositoryMirrorsMatch. `make validate_mirrors` runs that package, and
`make test-offline` covers it too since that runs the whole module. Reverting
chainguard-dev#162's datastream change fails the test, naming the entity and printing both
values.

Differences are split by consequence rather than treated alike. Functional
content — criteria, tests, objects, states, variables — fails, because the two
copies would evaluate the same image differently. Descriptive content is
logged: the copies disagree today on a <reference source> reading "Custom" in
every standalone file and "CIS" in five embedded blocks, which changes no
verdict, and failing on it would mean disabling the check rather than fixing
content.

Comparison is per entity, keyed by OVAL id, with each entity canonicalised
recursively so a child's path from its entity root is part of its identity.
That choice matters: flattening both copies into one ordered list of nodes,
which is the obvious approach, is wrong three ways. A difference that is a
permutation or a count change reports nothing locatable. Listing two tests in
a different order reads as functional drift, although it is schema-valid,
changes no verdict, and is something the generator is free to do — a false
positive on the very axis being gated, and the kind that gets a check
disabled. And a flat list loses which parent a child belongs to, so a
relocated element is caught only as a side effect of ordering rather than by
comparing structure. Keying on id fixes all three.

Namespace declarations are excluded from the comparison. The two copies write
namespaces differently here — the standalone files redeclare the
independent-component namespace as the default on each element, the datastream
binds it to a prefix — and Go surfaces declarations as ordinary attributes, so
comparing attributes naively would report every independent element as drift.
A test covers that shape specifically, since it is where this would otherwise
break silently.

The tests pin the properties the design rests on rather than only its
outcomes: sibling reordering, a namespace written by prefix rather than
default, an element duplicated inside an entity, an entity present in only one
copy, an element relocated between entities, a malformed datastream, and that
a finding names its entity and shows both values — including for a count-only
difference.

Definitions present only in the datastream are reported: they cannot be
evaluated with `oscap oval eval` and regeneration has no input for them.
Two are, oval:org.stub:def:1 and CertificateAudit, the latter addressed in
chainguard-dev#164.

Why this matters beyond tidiness: chainguard-dev/oscap-playground assembles
the datastream when a new SRG version lands, and its
create_extended_component() reads each standalone file and embeds it verbatim.
The standalone files are the source; the datastream is a build artifact.
Between SRG bumps it is hand-edited and the generator never runs. Keeping this
green is what makes the next regeneration safe — a datastream that already
matches its sources cannot be silently reverted by rebuilding it.

Not added to the `validate` aggregate, which already fails on the known-broken
validate_xml. Adds a .gitignore (the repo had none) for the scan-output
directories the README already described as ignored, and formats
internal/scan/fixtures_test.go, which I left unformatted in chainguard-dev#162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stevebeattie

Copy link
Copy Markdown
Member Author

Ported to Go — thanks, agreed the Python affinity with oscap-playground wasn't worth it given that tooling isn't in use.

The comparison now lives in tests/oscap-offline/internal/mirrors, with the repository-wide assertion as TestRepositoryMirrorsMatch. make validate_mirrors runs that package, and make test-offline covers it too since that runs the whole module. No Python left in the repo.

Behaviour is unchanged and I checked equivalence against the real content: seven files compared, the same five <reference source> differences logged as non-fatal, the same unmirrored definitions listed, and reverting #162's pattern still fails the test naming oval:org.RemoteAccessServices:obj:6.

One thing the port had to get right that the Python didn't: Go surfaces namespace declarations as ordinary attributes. The two copies write namespaces differently here — the standalone files redeclare the independent-component namespace as the default on each element, the datastream binds it to a prefix — so comparing attributes naively would have reported every independent element as drift. Declarations are excluded and there's a test for exactly that shape, since it's where this port would otherwise have broken silently.

Two of my test expectations were wrong and got corrected while porting: an element relocated between entities yields two findings, not one (a new entity appears and an existing one loses a child — a reader wants both), and a datastream containing no OVAL at all is now an error rather than a per-file "no counterpart" finding, since the latter points at the wrong problem.

Also included: a one-line gofmt fix to internal/scan/fixtures_test.go, which I left unformatted in #162. That surfaced something worth its own change — there is currently no gofmt or go vet gate anywhere, which is why it slipped through. Following up separately.

@stevebeattie
stevebeattie force-pushed the mirror-consistency-check branch from 4254a0c to 2a9f11a Compare August 25, 2026 23:24
@stevebeattie
stevebeattie merged commit 888183c into chainguard-dev:main Aug 25, 2026
6 checks passed
stevebeattie added a commit to stevebeattie/chainguard-dev-stigs that referenced this pull request Aug 25, 2026
CertificateAudit was the only OVAL component the datastream references that
had no standalone file. Two consequences: it was the one check that could not
be evaluated on its own with `oscap oval eval`, and regeneration had no input
for it — chainguard-dev/oscap-playground assembles the datastream by reading
each standalone file and embedding it verbatim, and its component mapping
expects 'CertificateAuditTest.xml' by that exact name. The datastream has
been naming a file that did not exist, in both its component catalog and the
rule's check-content-ref.

Derive it from the datastream rather than copying the generator's own version,
which is badly stale: 2 tests against the current 13, a hardcoded <hash>
literal from before the pinned value was removed, and no sidecar variable, no
kaniko criteria and no Java truststore. Copying that would have regressed the
rule to its 2025 shape.

Derivation lifts the <oval_definitions> element out of the certaudit
extended-component verbatim and dedents it, so the standalone file and the
embedded copy are identical by construction rather than by transcription. That
matters because the two are only kept in step by hand: the mirror check
introduced in chainguard-dev#163 now compares this pair like the other seven, and reports
zero differences for it.

Verified: xmllint well-formed; `oscap oval validate` rc=0; `make
validate_checks` now validates 8 files and passes; `oscap oval eval` runs the
definition standalone, which is the capability that was missing. Against
chainguard-dev#163's comparator the count goes from 7 files to 8 with no new errors or
warnings, and CertificateAudit drops out of the "present only in the
datastream" list, leaving only oval:org.stub:def:1.

No datastream change: this adds the missing source file to match what is
already there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stevebeattie
stevebeattie deleted the mirror-consistency-check branch August 26, 2026 18:24
stevebeattie added a commit that referenced this pull request Aug 28, 2026
Neither was checked anywhere. CI ran `go test` only, which applies no
formatting check and enables just a subset of vet, so an unformatted file
reached main unnoticed — the stanza comment added in #162, fixed in passing
in #163. A gate that would have caught it costs one step.

`make lint-go` checks gofmt and runs vet in full over tests/oscap-offline,
and the offline workflow runs it. Verified against both failure modes with
deliberately broken files: unformatted code fails naming the file, and a
formatted file with a vet problem (a Printf verb mismatch, which is in the
subset `go test` already covers, chosen so the two layers are visibly
distinct) fails too. Clean on main as it stands.

gofmt -l is checked by its output rather than its exit status, and that is
worth stating in the recipe because it is the trap that hides this class of
problem: `gofmt -l` exits 0 whether or not it lists anything, so the natural
`gofmt -l . && ...` succeeds on unformatted input. Getting this wrong is what
made the original violation invisible, and it also cost me a wrong conclusion
while diagnosing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants