docs: explain how CycloneDX and SPDX SBOM attestations differ - #420
AlexKantor87 wants to merge 4 commits into
Conversation
The page describes the subject and the package count as though both come back the same way whatever the format. They do not, and a reader following this to write a policy meets the difference as a rule that never matches. In SPDX the subject is a package and its checksum sits on it, so subject.sha256 is filled. CycloneDX has a hashes field for the same job, but Snyk and Syft both leave it empty and put the digest in version. Kosli does not infer a checksum from a version, so the field is empty there. package_count counts what each format calls a package. CycloneDX skips entries describing a file and SPDX does not, so one artifact reports a different count in each format. verified: ran internal/sbom.ProcessSBOMFile from cli origin/main over three real documents -- Snyk CycloneDX 1.6 from a server build (sha256 null, count 1023, matching the live attestation), and Syft CycloneDX 1.6 (sha256 null, count 1) and Syft SPDX 2.3 (sha256 = the artifact fingerprint, count 2) for the same rpm from the v2.41.0 release. Read subjectFromComponent, subjectFromSPDX and packageCount for why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
|
Claude finished @AlexKantor87's task in 3m 49s —— View job Doc review
Four inline findings on the Rego block and its paths (lines 333-347), plus one placement finding below.
|
| *Package counts are not comparable between formats.* `package_count` counts what each | ||
| format calls a package. CycloneDX entries describing a file are not counted; the SPDX | ||
| equivalents are. The same artifact can report a different count in each format. |
There was a problem hiding this comment.
Suggestion — "CycloneDX entries describing a file are not counted; the SPDX equivalents are" is too abstract to act on: a reader cannot map "the SPDX equivalents" onto anything they can look for in their own document, so they can't predict a count or explain one to an auditor.
The measurements in the PR description do this work and aren't on the page. Naming the mechanic (a CycloneDX component with type: file is skipped) and giving the measured pair — the same kosli_Linux_arm64.rpm reporting 1 from Syft's CycloneDX output and 2 from its SPDX output — turns a caveat into something the reader can check against their own SBOM.
There was a problem hiding this comment.
Taken. The abstraction was hiding the one thing a reader can check.
The paragraph now names the mechanic and gives the measured pair. A CycloneDX component with type: file is skipped, while the SPDX package describing that same file is counted. Syft reports one package for kosli_Linux_arm64.rpm in CycloneDX and two for the same file in SPDX.
Kept as prose, no table, as suggested.
verified: ran internal/sbom.ProcessSBOMFile from cli origin/main over the two SBOMs the
v2.41.0 release produced for that rpm. CycloneDX 1.6 gives package_count 1,
SPDX 2.3 gives 2. Read packageCount, which skips ComponentTypeFile and recurses
into nested components, against the SPDX path which is len(doc.Packages) with
no filter.
no mutation: this reply adds no test. The claim is a pair of parser runs over two real
documents, and the numbers above are that output.
search: grep -nE "package_count|type: file" getting_started/attestations.md
:321 and :322-323, this paragraph only. No other page states a package count rule,
per the site-wide grep in the first thread.
…y path
Three review findings, all taken.
The takeaway generalised to the format after the prose had correctly blamed
the tools. A reader using a generator that fills CycloneDX hashes would have
read the field as untrustworthy. It now says Kosli reads the hashes entry in
both formats, that Snyk and Syft leave it empty, and that this is a tool
choice.
The page told readers the differences matter for a policy without saying
which kind can reach the fields. Environment policy expressions expose only
the flow and the artifact name and fingerprint, so only Rego can. The
artifact-scoped path is given, with the trail-scoped variant named.
The package count rule named no mechanic a reader could look for. It now
says a CycloneDX component with type file is skipped, and gives the measured
pair for one rpm.
verified: ran internal/sbom.ProcessSBOMFile from cli origin/main over four
documents. Live Snyk CycloneDX 1.6, subject.sha256 null. The same
document with a SHA-256 added to metadata.component.hashes,
populated. Syft CycloneDX 1.6 and SPDX 2.3 for kosli_Linux_arm64.rpm,
package_count 1 and 2. Resolved the documented Rego path with opa
eval against the live attestation shape.
mutation: drop the added hashes entry -> subject.sha256 returns null, so the
populated result comes from hashes and not the version beside it.
search: grep -rnE "subject\.sha256|package_count" over all md and mdx finds
only this file. mint broken-links reports one broken link, in
tutorials/working_with_controls.mdx, present on main and untouched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| Of the two policy mechanisms, only [Rego](/policy-reference/rego_policy#input-data) can read | ||
| these fields; environment policy expressions expose the artifact's name and fingerprint but | ||
| not attestation content. An artifact-scoped SBOM is reachable at | ||
| `input.trail.compliance_status.artifacts_statuses.<artifact>.attestations_statuses.<name>.attestation_data.document`, | ||
| and a trail-scoped one at the same path without the `artifacts_statuses.<artifact>` step. |
There was a problem hiding this comment.
Improvement — the new path disagrees with every other Rego example on the site, and omits the prerequisite that makes it populated.
1. The attestation_data.document step appears nowhere else. rego_policy.mdx:145 describes the attestations_statuses value as the object that "contains the attestation's data, including type-specific fields enriched via --attestations" — fields directly on the status object, no wrapper. Both its own example (rego_policy.mdx:252) and the tutorial (tutorials/evaluate_trails_with_opa.mdx:157, :221) read snyk := artifact.attestations_statuses[...] then snyk.processed_snyk_results.results. A site-wide grep finds attestation_data only on this line. So either this page adds a step the reader's rule will trip over, or the other three places are describing a shape SBOM doesn't follow — and a reader has no way to tell which. Given that a wrong path here produces a rule that silently never matches, it's worth settling from kosli evaluate trail --show-input --output json on a real trail carrying an SBOM attestation rather than from an input object assembled by hand, since building the shape by hand assumes the answer.
2. --attestations filters by name. The flag "limits which attestations are enriched with full detail" (tutorials/evaluate_trails_with_opa.mdx:393, client_reference/kosli_evaluate_trail.md:30), and the tutorial recommends narrowing with it for speed. A reader already passing --attestations dashboard.snyk-container-scan gets an undefined lookup for the SBOM — the same silent non-match this section exists to prevent. One clause naming the flag closes it.
Two smaller things in the same sentence: the path is dot notation with <artifact>/<name> placeholders, while the site's examples use bracket lookup with a params-aliased name — which rego_policy.mdx makes a design rule, so a missing param fails the rule rather than proceeding. And the path stops at .document, leaving the reader to join it to the two fields the section is about (.document.subject.sha256, .document.package_count).
There was a problem hiding this comment.
Point 2 and the two smaller ones taken. Point 1 I checked and it does not hold.
attestation_data is not a step this page invented. Evaluation merges each attestation record onto its status entry: rehydrateAttestationMap copies every top-level key of the detail record across, unless the entry already has that key. pull_requests, processed_snyk_results and html_url are top-level fields of an attestation record. So is attestation_data. The other three places and this one read top-level keys of the same merged object, so there is nothing to settle.
Production confirms it resolves. kosli-dev/server evaluates SDLC-CTRL-0004 with artifact.attestations_statuses[name].attestation_data.content. On the trail this PR takes its numbers from, the live decision records allow: true and violations: null. A non-resolving path empties lock_content, which empties lock_pins, which fires the "records no exact (==) version pins" rule. allow would be false. It is not.
I could not run --show-input, having no CLI token here.
Point 2 is now on the page, dot-qualified for artifact scope. The example also moved to bracket lookup with a params-aliased name, and reaches the two fields.
verified: read internal/evaluate/transform.go rehydrateAttestationMap for the merge rule.
The CLI golden check in cmd/kosli/evaluateTrail_test.go asserts
input.trail.compliance_status.artifacts_statuses.cli.attestations_statuses.art-att.html_url,
which is the artifacts_statuses step. Ran opa check on the snippet now in the
page, then opa eval three ways.
mutation: set subject.sha256 to null -> allow undefined, the silent non-match. Remove the
attestation from attestations_statuses, as --attestations would -> allow
undefined again. Populate the digest -> allow true. So both warnings on the
page describe the behaviour the snippet has.
search: grep -rn "attestation_data" over the kosli-dev/server control policies finds
SDLC-CTRL-0004 only. The other three read status, is_compliant, has_audit_package
and pull_requests directly off the entry. Both idioms are live and green in one
pipeline, which is what the merge rule predicts.
The path was prose with placeholders, stopped at the document object, and
said nothing about input filtering. A reader narrowing the input with
--attestations and leaving the SBOM out gets a rule that never matches,
which is the failure this section exists to prevent.
It is now a snippet in the site's own idiom: bracket lookup with a
params-aliased attestation name, reaching subject.sha256 and comparing it
to the artifact fingerprint. The trail-scoped path is named beside it. A
closing line says to include the SBOM in --attestations, dot-qualified for
artifact scope, because anything left out is absent from the input.
The attestation_data step stands. Evaluation copies every top-level key of
an attestation record onto its status entry, so pull_requests and
attestation_data sit at the same level, and the site's other examples and
this one are both reading top-level keys.
verified: read internal/evaluate/transform.go rehydrateAttestationMap for
the merge rule. cmd/kosli/evaluateTrail_test.go asserts
input.trail.compliance_status.artifacts_statuses.cli.attestations_statuses.art-att.html_url,
confirming the artifacts_statuses step. kosli-dev/server's
SDLC-CTRL-0004 reads attestation_data.content and its live
decision on this trail is allow true, violations null, which an
unresolved path would have made false. opa check passes on the
snippet.
mutation: subject.sha256 null -> allow undefined. Attestation removed from
attestations_statuses -> allow undefined. Digest populated ->
allow true. Both warnings match the snippet's behaviour.
search: grep -rn attestation_data over the server control policies finds
SDLC-CTRL-0004 only; the other three read fields directly off the
entry. mint broken-links reports one broken link, in
tutorials/working_with_controls.mdx, present on main and untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
||
| sbom_describes(artifact) if { | ||
| sbom := artifact.attestations_statuses[sbom_attestation_name] | ||
| sbom.attestation_data.document.subject.sha256 == artifact.artifact_fingerprint |
There was a problem hiding this comment.
Improvement — artifact.artifact_fingerprint is the one field in this snippet that nothing on the site backs up, and it is the field the comparison turns on.
rego_policy.mdx:148-150documents anartifacts_statusesentry as having "its ownattestations_statusesmap" and names no other field on it, so the fingerprint key is undocumented there.- The only in-repo occurrences of
artifact_fingerprintare in the trail events array (client_reference/kosli_get_trail.md:83,:404) — a different object from an artifact status entry, so it is not evidence for this path. - The nearest documented spelling is the environment-policy one,
artifact.fingerprint(policy-reference/environment_policy.mdx:144).
If the key on a status entry is fingerprint (or absent, with the name-keyed map being the only artifact identity in the input), the == compares against undefined, sbom_describes never fires, and the reader gets the silent non-match this whole section was written to prevent — from the example meant to show them the way. Worth pinning with kosli evaluate trail --show-input --output json | jq '.input.trail.compliance_status.artifacts_statuses[] | keys' on a real trail rather than a hand-built input, and then documenting the confirmed key in rego_policy.mdx's ## Input data so the example has a second source.
| ```rego | ||
| sbom_attestation_name := data.params.sbom_attestation_name | ||
|
|
||
| sbom_describes(artifact) if { | ||
| sbom := artifact.attestations_statuses[sbom_attestation_name] | ||
| sbom.attestation_data.document.subject.sha256 == artifact.artifact_fingerprint | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Improvement — the snippet does not do the thing the paragraph above it advises, and it can't be run as written.
Line 318 tells the reader to "assert the field is present before comparing it, so a missing digest fails the rule instead of skipping it". The snippet compares straight away. On a Snyk or Syft CycloneDX SBOM — the case the section is about — subject.sha256 is absent, the == is undefined, and the body does not fire. Under a positive allow that fails safe; inside a violations rule it is the silent skip rego_policy.mdx:84 warns about. Since the snippet shows neither call site, the reader can't tell which they get. A presence check makes the advice visible in the code:
sbom_describes(artifact) if {
sbom := artifact.attestations_statuses[sbom_attestation_name]
digest := sbom.attestation_data.document.subject.sha256
digest != ""
digest == artifact.artifact_fingerprint
}Two smaller things in the same block: if in a rule head needs import rego.v1, which both examples in rego_policy.mdx (:212, :245) and the tutorial carry but this fragment doesn't mention; and artifact arrives unbound, where the site's idiom shows the every name, artifact in trail.compliance_status.artifacts_statuses that produces it (rego_policy.mdx:258-262). Either add that line or say the fragment plugs into the Snyk example's shape.
Line 340 then writes the trail-scoped path as attestations_statuses[name], using name where the snippet aliases sbom_attestation_name, and dropping the input. prefix the reference uses throughout.
The page said to assert the digest field is present before comparing it.
Present is not the same as correct, and a reader following that advice
gets a comparison that looks sound and is not.
The subject identifies what the generator scanned. Pointed at a tag rather
than a digest, it records whatever that tag resolved to on that machine.
For a multi-architecture image that is one architecture, and it can be a
local image id that is not a registry digest at all.
The page now says to compare the two only where the pipeline pointed the
generator at the exact artifact, and to check it in the build otherwise.
verified: kosli-public flow cli, artifact
ece2be992ca243c8f601e42e019974df05f773a310265c328f66d0c01f3350e2,
attestation container-sbom, spdx-2.3 from syft. Its
subject.sha256 is 02eb3b331a64..., which returns 404 from the
ghcr manifest API for that repo. The attested index holds amd64
0a222bfc... and arm64 d7f84a11..., whose config digests are
4e3e6f6b... and 5bcab99e.... None is the reported subject. The
subject purl carries arch=amd64, and the tag resolves to the
attested index, so the tag had not moved.
no mutation: prose only, no test claim.
search: grep -nE "present|populat|assert|compare|trust"
getting_started/attestations.md finds only the new paragraph and
line 272, which is the pull-request --assert flag and not this
class. No other advice on the page treats a populated field as
trustworthy. mint broken-links still reports only the pre-existing
tutorials/working_with_controls.mdx break.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| sbom_describes(artifact) if { | ||
| sbom := artifact.attestations_statuses[sbom_attestation_name] | ||
| sbom.attestation_data.document.subject.sha256 == artifact.artifact_fingerprint | ||
| } |
There was a problem hiding this comment.
Improvement — the only code on the page does the one thing the paragraph three lines above tells the reader not to do, and it has no guard for the case the section opens with.
Lines 324-326: "Kosli does not check the subject against the artifact, so a digest that is present can still belong to something else. Compare the two only where your pipeline pointed the generator at the exact artifact it attests. Otherwise check it in the pipeline… rather than in a policy." The snippet is then a policy-side digest comparison, presented without restating that precondition. A reader who skims to the code block — which is what a reader looking for a path does — takes away the pattern the prose just cautioned against, and a multi-arch image gives them a rule that denies a correctly built artifact.
Separately, on a Snyk or Syft CycloneDX SBOM — the case the section exists to warn about — subject.sha256 is absent, so the == is undefined and sbom_describes does not fire. Under a positive allow that fails safe; inside a violations rule it is the silent skip policy-reference/rego_policy.mdx:84 warns about. The snippet shows neither call site, so the reader cannot tell which they get. The earlier draft carried "assert the field is present before comparing it"; commit 1ef31d2 removed that sentence, and the snippet never grew the check, so nothing on the page now covers it.
| sbom_describes(artifact) if { | |
| sbom := artifact.attestations_statuses[sbom_attestation_name] | |
| sbom.attestation_data.document.subject.sha256 == artifact.artifact_fingerprint | |
| } | |
| sbom_describes(artifact) if { | |
| sbom := artifact.attestations_statuses[sbom_attestation_name] | |
| digest := sbom.attestation_data.document.subject.sha256 | |
| digest != "" | |
| digest == artifact.artifact_fingerprint | |
| } |
Worth a clause above the block saying the comparison holds only under the precondition in the paragraph above — or making the example read package_count, which is always present, so the code demonstrates reaching the fields without also modelling the comparison the prose discourages.
| not attestation content. Evaluation copies an attestation's own fields onto its status | ||
| entry, so the summary sits under `attestation_data`: | ||
|
|
||
| ```rego |
There was a problem hiding this comment.
Improvement — the snippet will not parse or run as written, and it is the only Rego block on the site that omits the preamble.
ifin a rule head requiresimport rego.v1under OPA before 1.0. Every other Rego block on the site carriespackage policy+import rego.v1—policy-reference/rego_policy.mdx:212,:244,tutorials/evaluate_trails_with_opa.mdx:37,:152,:213. This one has neither, so a reader who pastes it gets a parse error rather than the field path they came for.artifactarrives unbound andsbom_describeshas no call site. The site's idiom shows where it comes from:every name, artifact in trail.compliance_status.artifacts_statuses(rego_policy.mdx:258-262).
Either show the two preamble lines and the every binding, or say in one clause that the fragment plugs into the Snyk example's shape at /policy-reference/rego_policy#examples — the latter keeps the accordion short and gives the reader a runnable whole to drop it into.
| Of the two policy mechanisms, only [Rego](/policy-reference/rego_policy#input-data) can read | ||
| these fields. Environment policy expressions expose the artifact's name and fingerprint, | ||
| not attestation content. Evaluation copies an attestation's own fields onto its status | ||
| entry, so the summary sits under `attestation_data`: |
There was a problem hiding this comment.
Improvement — this page is now the site's only source for the attestation_data.document wrapper, and the reference it links to describes a different shape.
policy-reference/rego_policy.mdx:145 says an attestations_statuses value "contains the attestation's data, including type-specific fields enriched via --attestations" and illustrates it with pull_requests sitting directly on the object; its own example reads snyk.processed_snyk_results.results (:252) with no wrapper. attestation_data and document appear in no other .md/.mdx on the site. A reader who follows the #input-data link this sentence gives them, then writes sbom.subject.sha256 or sbom.document.subject.sha256 off the reference's description, gets the silent non-match this section was written to prevent.
Since the path itself has been verified, the cheap durable fix is on the other side: extend the attestations_statuses ParamField in rego_policy.mdx's ## Input data to note that some attestation types nest their summary under attestation_data (SBOM under attestation_data.document, with subject.sha256 and package_count on it), while others expose their fields directly. Then this accordion's one-sentence claim has a reference source behind it instead of standing alone.
| } | ||
| ``` | ||
|
|
||
| A trail-scoped SBOM sits at `trail.compliance_status.attestations_statuses[name]` instead. |
There was a problem hiding this comment.
Suggestion — the trail-scoped path stops short of the fields, and swaps the key name the snippet just established.
The snippet aliases the attestation name as sbom_attestation_name (deliberately, per rego_policy.mdx's params rule); this line writes [name], an identifier that is undefined in the surrounding example. And it ends at the status entry, leaving the reader to append the .attestation_data.document step themselves — the exact step that has no second source on the site, so it is the one they are least able to reconstruct.
| A trail-scoped SBOM sits at `trail.compliance_status.attestations_statuses[name]` instead. | |
| A trail-scoped SBOM sits at `trail.compliance_status.attestations_statuses[sbom_attestation_name].attestation_data.document` instead. |
The SBOM section describes the subject and the package count as though both come back the same way whatever the format. They do not.
A reader who follows this page to write a policy meets the difference as a rule that silently never matches. That is the worst way to find out.
The two differences
The subject's digest is usually missing from CycloneDX. In SPDX the subject is a package and its checksum sits on that package. So
subject.sha256is filled. CycloneDX has ahashesfield for the same job. Snyk and Syft both leave it empty. They write the digest intoversioninstead, where it reads as a version string. Kosli does not infer a checksum from a version, so the field stays empty.Package counts are not comparable.
package_countcounts what each format calls a package. CycloneDX entries describing a file are skipped. The SPDX equivalents are not.Measured
Ran the CLI parser over three real documents rather than reasoning from the schemas.
subject.sha256package_countkosli_Linux_arm64.rpmThe first reproduces the live attestation on the server flow exactly. The last two are the same artifact from the v2.41.0 release. That pair is where the count difference shows up.
One file, prose only. No new pages and no new links, so navigation is unaffected.
🤖 Generated with Claude Code