Skip to content

docs: explain how CycloneDX and SPDX SBOM attestations differ - #420

Open
AlexKantor87 wants to merge 7 commits into
mainfrom
claude/docs-sbom-format-differences
Open

AlexKantor87 wants to merge 7 commits into
mainfrom
claude/docs-sbom-format-differences

Conversation

@AlexKantor87

Copy link
Copy Markdown
Contributor

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.sha256 is filled. CycloneDX has a hashes field for the same job. Snyk and Syft both leave it empty. They write the digest into version instead, 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_count counts 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.

document subject.sha256 package_count
Snyk CycloneDX 1.6, server image empty 1023
Syft CycloneDX 1.6, kosli_Linux_arm64.rpm empty 1
Syft SPDX 2.3, same rpm the artifact fingerprint 2

The 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

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>
@AlexKantor87
AlexKantor87 requested a review from a team as a code owner September 15, 2026 15:13
@mintlify

mintlify Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
kosli 🟢 Ready View Preview Sep 24, 2026, 6:53 AM

@claude

claude Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @AlexKantor87's task in 3m 25s —— View job


Doc review

Three findings, posted inline. No new pages, moves or deletions, so config/navigation.json and config/redirects.json are correctly untouched; all links added are root-relative and their anchors (#input-data, #params, #attestation-types) resolve to real headings — checked by hand, mint is not installed on this runner so mint broken-links did not run here.

# Where Finding
1 getting_started/attestations.md:335-367 A complete runnable Rego policy plus --params and --attestations guidance sits in a getting-started accordion. policy-reference/rego_policy.mdx:205 already has an ## Examples section holding two entries of exactly this shape.
2 getting_started/attestations.md:354-358 The policy defines allow with no violations rule, so a denial names neither the artifact nor whether the SBOM was missing or empty — on a page about rules that give no signal.
3 policy-reference/rego_policy.mdx:146 "purl and sha256 are often empty" states as a format property what the other file argues is a tool property. #attestation-types is a working anchor for the link dropped last round.

Minor, not worth a round on their own

  • every over artifacts_statuses is now spelled three ways across the site: every artifact in (rego_policy.mdx:261, changed by this PR), every _, artifact in (tutorials/evaluate_trails_with_opa.mdx:164, :229), and every artifact in again in the new block. All compile; the tutorial pair is the odd one out if you want one idiom.
  • allow if { every artifact in ... } is vacuously true for a trail with no artifacts, so an SBOM requirement passes when there is nothing to check. Shared with the existing Snyk example at rego_policy.mdx:258-262, so it is the site's idiom rather than something this PR introduced.

The three format/tool differences themselves read well, and the per-format field names (hashes / checksums / PackageChecksum) close the gap that made the "check what yours writes" instruction unusable for SPDX readers.
· claude/docs-sbom-format-differences

Comment thread getting_started/attestations.md Outdated
Comment thread getting_started/attestations.md Outdated
Comment thread getting_started/attestations.md Outdated
…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>
Comment thread getting_started/attestations.md Outdated
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>
Comment thread getting_started/attestations.md Outdated
Comment thread getting_started/attestations.md
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>
Comment thread getting_started/attestations.md Outdated
Comment thread getting_started/attestations.md
Comment thread getting_started/attestations.md Outdated
Comment thread getting_started/attestations.md Outdated
…digest check

The example didn't parse. It used `if` in a rule head with no package or
import line, so pasting it gave you an error rather than the field path
you came for.

It also compared the subject digest with the fingerprint, three lines
under a paragraph saying to do that in the pipeline and not in a policy.
Anyone skimming to the code took away the opposite of the advice. And on
a CycloneDX SBOM the digest is absent, so the rule quietly never fired.

It now reads `package_count`, which is always there. That shows where the
fields live without putting a comparison in front of people that the
prose has just talked them out of.

The reference gets the missing half: the attestations_statuses field now
says some types keep their summary under `attestation_data`, and names
the sbom one, so this page isn't the only source for that path.

verified: pulled the fenced block out of the page and ran opa check.
          Before, "rego_parse_error: package expected". After, it parses.
          opa eval against a trail whose subject.sha256 is null returns
          true, where the old block did not fire at all.
          The live API returns artifact_fingerprint on an artifacts_statuses
          entry, and sdlc-policies policy.rego:135 reads the same key with
          allow gated on it, so the field name was already right.

no mutation: an example and prose, no test.

search: grep -rn "attestation_data" over md and mdx -> two sources now,
        this page and the reference. grep -n "subject.sha256" -> only the
        prose explaining when it is missing; no code compares it.
        mint broken-links reports one break, in working_with_controls.mdx,
        pre-existing on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread getting_started/attestations.md
Comment thread policy-reference/rego_policy.mdx Outdated
Last round's block parsed but denied everything. Adding `package policy`
made it a policy file, and Kosli requires an `allow` rule. There wasn't
one, and nothing called the helper, so saving it and running it gave you
a deny with no clue why.

It now has `default allow := false` and an `allow` that loops the
artifacts. The loop shows where `artifact` comes from, so the sentence
explaining that has gone. One line added saying the attestation name
comes from `--params`, because without it the alias is undefined and no
rule using it runs.

The reference example at rego_policy.mdx:261 didn't compile either:
`every name, artifact in ...` leaves `name` unused and OPA rejects it.
Fixed, since this page now links there.

The attestation_data note also names the subject keys rather than
calling it an object, so a reader working from the reference alone can
spell `sha256` without guessing.

verified: opa check on the block pulled off the page passes. opa eval
          against a real trail shape: 1023 packages -> allow true, 0
          packages -> false, no --params -> false. The subject keys come
          from the live attestation on kosli/server artifact e49d9687:
          name, version, purl, sha256, with the last two null.

no mutation: examples and prose, no test.

search: grep -n "every name, artifact" over md and mdx -> rego_policy.mdx:261
        was the only other one. opa check over all 8 rego blocks in that
        file: the rest are fragments with no package line, deliberately.
        mint broken-links reports one break, working_with_controls.mdx,
        pre-existing on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread getting_started/attestations.md Outdated
The paragraph said Kosli reads the subject's `hashes` entry "in both
formats". `hashes` is CycloneDX's name. SPDX has no such field.

That broke the one instruction the paragraph gives. It tells you to check
what your tool writes. Do that on an SPDX document, grep for `hashes`,
find nothing, and you conclude the tool skipped the digest. Which is the
wrong conclusion this section exists to stop you reaching.

It now names both: `hashes` in CycloneDX, `checksums` in SPDX JSON,
`PackageChecksum` in tag-value. It also says where the digest went for
the CycloneDX tools that skip the checksum, which is `subject.version`.

verified: internal/sbom/sbom.go in kosli-dev/cli reads component.Hashes on
          the CycloneDX path and pkg.PackageChecksums on the SPDX path.
          The spellings come from spdx/tools-golang@v0.5.7, the library
          doing the parsing: PackageChecksums carries
          `json:"checksums,omitempty"`, and its tag-value reader has
          case "PackageChecksum".

no mutation: prose.

search: grep -rn "hashes" over md and mdx -> attestations.md:313 and :315,
        both in this paragraph. It was the only place on the site naming
        the field, so nothing else would have corrected the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +335 to +367
Only [Rego](/policy-reference/rego_policy#input-data) can read these fields. Environment
policy expressions get the artifact's name and fingerprint, nothing from inside an
attestation. Evaluation copies an attestation's own fields onto its status entry, so the
summary sits under `attestation_data`:

```rego
package policy

import rego.v1

sbom_attestation_name := data.params.sbom_attestation_name

default allow := false

sbom_lists_packages(artifact) if {
sbom := artifact.attestations_statuses[sbom_attestation_name]
sbom.attestation_data.document.package_count > 0
}

allow if {
every artifact in input.trail.compliance_status.artifacts_statuses {
sbom_lists_packages(artifact)
}
}
```

Pass the attestation name in [`--params`](/policy-reference/rego_policy#params). Without it
the alias is undefined and no rule using it runs. A trail-scoped SBOM sits at
`input.trail.compliance_status.attestations_statuses[sbom_attestation_name].attestation_data.document`.

If you narrow the input with `kosli evaluate trail --attestations`, name the SBOM there too,
dot-qualified as `<artifact>.<name>` for an artifact-scoped one. Anything left out is absent
from the input, and a rule reading it does not match rather than failing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement — this is a complete, runnable Rego policy plus --params and --attestations guidance, sitting in an accordion on a getting-started page. It belongs in the reference, which already has a section shaped exactly like it.

The ## Attestation types accordions are uniformly short pointers: JUnit is 5 lines, Snyk 5, Jira 10, SonarQube 7, each ending in "see the CLI reference for usage details and examples". After this PR the SBOM accordion runs 293-373, 80 lines, and 33 of them (335-367) are policy authoring — a package/import preamble, a fail-safe default, an every loop, a params flag and an input-narrowing flag. A reader on this page is deciding what an SBOM attestation records; they meet a policy file before they reach the 9 MiB size limit at 369.

Meanwhile policy-reference/rego_policy.mdx:205 has ## Examples holding two entries of precisely this form — ### Check pull request approvals across multiple trails and ### Check Snyk scan results on a single trail, each a whole policy with a one-paragraph lede. A ### Check an SBOM lists packages there would sit beside them, inherit the surrounding contract and safe-design rules instead of restating them (361-362 re-explains the params aliasing already at rego_policy.mdx:110-115), and be found by readers writing policies rather than only by readers who opened this accordion.

What earns its place here is the part about what the attestation contains: the three tool/format differences (309-333), the two field paths, and the sentence that environment policy expressions cannot reach them. Suggest keeping those, replacing the code block with a link to the new example, and moving 361-367 with it.

Fix this →

Comment on lines +354 to +358
allow if {
every artifact in input.trail.compliance_status.artifacts_statuses {
sbom_lists_packages(artifact)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — the policy denies without saying why, on a page whose subject is rules that give no signal.

policy-reference/rego_policy.mdx:28 calls violations "optional but recommended… Kosli displays these when allow is false", and every other whole policy on the site pairs the two: rego_policy.mdx:231 and :268, and the tutorial at tutorials/evaluate_trails_with_opa.mdx:170 and :235. This one defines only allow, so a reader who runs it against a trail where one artifact's SBOM is missing or reports package_count: 0 gets exit 1 and no message naming the artifact — and the two causes are indistinguishable, which is the distinction the section above spends three paragraphs on.

Suggested change
allow if {
every artifact in input.trail.compliance_status.artifacts_statuses {
sbom_lists_packages(artifact)
}
}
allow if {
every artifact in input.trail.compliance_status.artifacts_statuses {
sbom_lists_packages(artifact)
}
}
violations contains msg if {
some name, artifact in input.trail.compliance_status.artifacts_statuses
not sbom_lists_packages(artifact)
msg := sprintf("artifact '%v': no SBOM attestation named '%v' listing packages", [name, sbom_attestation_name])
}

Worth an opa eval before taking the wording — not sbom_lists_packages(artifact) is negation over a partial function, which behaves the way you want here but is not an idiom the other examples use.

Fix this →


<ParamField path="input.trail.compliance_status.attestations_statuses" type="object">
Map of attestation name to attestation status object. Each object contains the attestation's data, including type-specific fields enriched via `--attestations`. For example, a `pull-request` attestation includes a `pull_requests` array, each with an `approvers` array and a `url` string.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — "purl and sha256 are often empty" states as a property of SBOM attestations the thing the other half of this PR spends a paragraph arguing is a property of the writing tool.

getting_started/attestations.md:315-320 is explicit: Syft's SPDX output fills the digest, Snyk's and Syft's CycloneDX output does not, "that is the tool's choice rather than a limit of CycloneDX, so check what yours writes instead of assuming either way." A reader who only reads this reference takes "often empty" as a reason to design around the field rather than to check their generator, which is the conclusion that earlier prose was rewritten to prevent.

The link you dropped last round also has a working anchor after all — ## Attestation types at getting_started/attestations.md:259 gives #attestation-types. It lands on the accordion list, one click from the SBOM entry, which is closer than no link.

Suggested change
Some types put their summary under `attestation_data` rather than on the object itself. An `sbom` attestation keeps it at `attestation_data.document`, which holds `package_count` and a `subject` object with `name`, `version`, `purl` and `sha256`. These carry only what the tool that wrote the SBOM recorded, so `purl` and `sha256` are empty for generators that leave them out — see [attestation types](/getting_started/attestations#attestation-types) for which tools fill the digest.

Fix this →

This branch was successfully deployed

1 active deployment
staging — fe991d86 Deployed Sep 24, 2026 by mintlify[bot]
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.

1 participant