Check the standalone OVAL checks against their copies in the datastream - #163
Conversation
3995e64 to
4254a0c
Compare
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>
|
Ported to Go — thanks, agreed the Python affinity with The comparison now lives in Behaviour is unchanged and I checked equivalence against the real content: seven files compared, the same five 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 |
4254a0c to
2a9f11a
Compare
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>
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>
Why
The same OVAL definitions live in two places, and nothing compared them.
validate_checksandvalidate_xmleach check one copy against the schema inisolation, so a change applied to one and not the other passes CI. That is how
openssh-sftp-servercame to be banned by the standaloneRemoteAccessServicescheck 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:
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>readingCustomin every standalone file andCISin fiveembedded blocks, which changes no verdict. Failing on it would block adoption
behind a content change.
--strict-metadatapromotes 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:
locatable — just
0 only in the file, 0 only in the datastream;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;
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-playgroundassembles the datastream when a new SRG versionlands; its
create_extended_component()reads each standalone file and embeds itverbatim. 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.xmlis 72lines against our 273, and its
CertificateAuditTest.xmlstill 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 evaland have no input for regeneration:oval:org.stub:def:1and CertificateAudit. The missing
CertificateAuditTest.xmlhas to be derivedfrom the datastream rather than copied from the generator's stale version, so it
is left for a follow-up.
Usage
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
validateaggregate,which already fails on the known-broken
validate_xml; CI invokes it directly.Verification
flat-list implementation.
make validate_mirrorsgreen on the real content —checked 7files, 0errors, the 5 expected metadata warnings.
actionlintissue count unchanged frommain;zizmorno findings;oscap ds sds-validaterc=0.Adds a
.gitignore(the repo had none) for Python bytecode, plus the scan-outputdirectories the README already described as ignored.
🤖 Generated with Claude Code