From ec5339a60540ce571712764602a818c231f20050 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Tue, 15 Sep 2026 16:12:34 +0100 Subject: [PATCH 1/7] docs: explain how CycloneDX and SPDX SBOM attestations differ 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 --- getting_started/attestations.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index bc32029..9d04289 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -306,6 +306,23 @@ Currently, we support the following types of evidence: Nothing in the SBOM is checked against the artifact. It is recorded as reported, so the attestation says what the SBOM claims, not whether the claim is true. + **What you get depends on the format.** Kosli records what the document declares, and the + two formats are filled in differently by the tools that write them. Two differences matter + if you plan to write a policy against the attestation. + + *The subject's digest is often absent from CycloneDX.* In SPDX the subject is a package and + its checksum sits on that package, which Kosli reads into `subject.sha256`. CycloneDX has a + `hashes` field for the same job, but Snyk and Syft both leave it empty and write the digest + into `version` instead, where it reads as a version string rather than a checksum. Kosli + does not infer a checksum from a version, so `subject.sha256` is typically set on an SPDX + attestation and empty on a CycloneDX one. If a policy of yours ties the SBOM to the + artifact by digest, assert the field is present rather than comparing it directly, or + attest the SBOM as SPDX. + + *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. + The CLI refuses an SBOM file larger than 9 MiB, which leaves room for the attestation itself within the 10 MB the server accepts. We are working on raising this. From 03c12db0ebae48f4daf0bbea4229c1db5d7ccd51 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Tue, 15 Sep 2026 16:39:33 +0100 Subject: [PATCH 2/7] docs: attribute the empty SBOM digest to the tool, and name the policy 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 --- getting_started/attestations.md | 34 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index 9d04289..cf62e51 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -306,22 +306,28 @@ Currently, we support the following types of evidence: Nothing in the SBOM is checked against the artifact. It is recorded as reported, so the attestation says what the SBOM claims, not whether the claim is true. - **What you get depends on the format.** Kosli records what the document declares, and the - two formats are filled in differently by the tools that write them. Two differences matter - if you plan to write a policy against the attestation. - - *The subject's digest is often absent from CycloneDX.* In SPDX the subject is a package and - its checksum sits on that package, which Kosli reads into `subject.sha256`. CycloneDX has a - `hashes` field for the same job, but Snyk and Syft both leave it empty and write the digest - into `version` instead, where it reads as a version string rather than a checksum. Kosli - does not infer a checksum from a version, so `subject.sha256` is typically set on an SPDX - attestation and empty on a CycloneDX one. If a policy of yours ties the SBOM to the - artifact by digest, assert the field is present rather than comparing it directly, or - attest the SBOM as SPDX. + **What you get depends on the tool, not just the format.** Kosli records what the document + declares, and tools fill the same fields differently. Two differences catch people out. + + *The subject's digest is often absent from CycloneDX.* Kosli reads the subject's SHA-256 + `hashes` entry into `subject.sha256`, in both formats. Syft's SPDX output fills it. Snyk's + and Syft's CycloneDX output does not: both leave `hashes` empty and write the digest into + `version`, where it reads as a version string rather than a checksum. Kosli does not infer + a checksum from a version, so the field is empty for those two. That is the tool's choice + rather than a limit of CycloneDX, so check what yours writes instead of assuming either + way. If a policy of yours ties the SBOM to the artifact by digest, assert the field is + present before comparing it, so a missing digest fails the rule instead of skipping it. *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. + format calls a package. 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. + + 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..attestations_statuses..attestation_data.document`, + and a trail-scoped one at the same path without the `artifacts_statuses.` step. The CLI refuses an SBOM file larger than 9 MiB, which leaves room for the attestation itself within the 10 MB the server accepts. We are working on raising this. From 560b7bcb015c6bfe10df4340ed18d7189cf6ea3a Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Tue, 15 Sep 2026 16:54:47 +0100 Subject: [PATCH 3/7] docs: show the SBOM policy path as a worked Rego example 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 --- getting_started/attestations.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index cf62e51..ed7da20 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -324,10 +324,24 @@ Currently, we support the following types of evidence: `kosli_Linux_arm64.rpm` in CycloneDX and two for the same file in SPDX. 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..attestations_statuses..attestation_data.document`, - and a trail-scoped one at the same path without the `artifacts_statuses.` step. + 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`: + + ```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 + } + ``` + + A trail-scoped SBOM sits at `trail.compliance_status.attestations_statuses[name]` instead. + + If you narrow the input with `kosli evaluate trail --attestations`, name the SBOM there too, + dot-qualified as `.` for an artifact-scoped one. Anything left out is absent + from the input, and a rule reading it does not match rather than failing. The CLI refuses an SBOM file larger than 9 MiB, which leaves room for the attestation itself within the 10 MB the server accepts. We are working on raising this. From 1ef31d2ba81835659b1b9f3b702b22ec692744a3 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Thu, 17 Sep 2026 16:06:11 +0100 Subject: [PATCH 4/7] docs: warn that a present SBOM subject digest may not be the artifact's 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 --- getting_started/attestations.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index ed7da20..5156461 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -307,7 +307,7 @@ Currently, we support the following types of evidence: attestation says what the SBOM claims, not whether the claim is true. **What you get depends on the tool, not just the format.** Kosli records what the document - declares, and tools fill the same fields differently. Two differences catch people out. + declares, and tools fill the same fields differently. Three differences catch people out. *The subject's digest is often absent from CycloneDX.* Kosli reads the subject's SHA-256 `hashes` entry into `subject.sha256`, in both formats. Syft's SPDX output fills it. Snyk's @@ -315,8 +315,15 @@ Currently, we support the following types of evidence: `version`, where it reads as a version string rather than a checksum. Kosli does not infer a checksum from a version, so the field is empty for those two. That is the tool's choice rather than a limit of CycloneDX, so check what yours writes instead of assuming either - way. If a policy of yours ties the SBOM to the artifact by digest, assert the field is - present before comparing it, so a missing digest fails the rule instead of skipping it. + way. + + *A digest that is present is not automatically the artifact's.* The subject identifies what + the generator scanned. Point one at a tag and it records whatever that tag resolved to on + that machine. For a multi-architecture image that is a single architecture, and it can be a + local image id rather than a registry digest. 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, where the build can fail, rather than in a policy. *Package counts are not comparable between formats.* `package_count` counts what each format calls a package. A CycloneDX component with `type: file` is skipped, while the SPDX From 7e6855c09b7fd6b02c7c3c48acebb09c66dbf3ad Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Wed, 23 Sep 2026 15:39:50 +0200 Subject: [PATCH 5/7] docs: make the SBOM policy example runnable, and stop it modelling a 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 --- getting_started/attestations.md | 20 +++++++++++++------- policy-reference/rego_policy.mdx | 2 ++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index 5156461..570beca 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -330,21 +330,27 @@ Currently, we support the following types of evidence: 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. - 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`: + 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 - sbom_describes(artifact) if { + sbom_lists_packages(artifact) if { sbom := artifact.attestations_statuses[sbom_attestation_name] - sbom.attestation_data.document.subject.sha256 == artifact.artifact_fingerprint + sbom.attestation_data.document.package_count > 0 } ``` - A trail-scoped SBOM sits at `trail.compliance_status.attestations_statuses[name]` instead. + `artifact` comes from looping over `artifacts_statuses`, as in the + [Rego examples](/policy-reference/rego_policy#examples). 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 `.` for an artifact-scoped one. Anything left out is absent diff --git a/policy-reference/rego_policy.mdx b/policy-reference/rego_policy.mdx index 8f330ab..cdfe087 100644 --- a/policy-reference/rego_policy.mdx +++ b/policy-reference/rego_policy.mdx @@ -143,6 +143,8 @@ The policy receives `input.trail`, a single trail 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. + + 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 `subject` and `package_count`. From 0f4d1b526dc0639f2eabb4fb681ce8731794af12 Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Wed, 23 Sep 2026 15:53:20 +0200 Subject: [PATCH 6/7] docs: make the Rego example a policy that actually decides something 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 --- getting_started/attestations.md | 12 ++++++++++-- policy-reference/rego_policy.mdx | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index 570beca..188af8e 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -342,14 +342,22 @@ Currently, we support the following types of evidence: 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) + } + } ``` - `artifact` comes from looping over `artifacts_statuses`, as in the - [Rego examples](/policy-reference/rego_policy#examples). A trail-scoped SBOM sits at + 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, diff --git a/policy-reference/rego_policy.mdx b/policy-reference/rego_policy.mdx index cdfe087..33567f8 100644 --- a/policy-reference/rego_policy.mdx +++ b/policy-reference/rego_policy.mdx @@ -144,7 +144,7 @@ The policy receives `input.trail`, a single trail 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. - 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 `subject` and `package_count`. + 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`. `purl` and `sha256` are often empty, because they only carry what the tool that wrote the SBOM recorded. @@ -258,7 +258,7 @@ artifact_within_threshold(artifact) if { } trail_is_compliant(trail) if { - every name, artifact in trail.compliance_status.artifacts_statuses { + every artifact in trail.compliance_status.artifacts_statuses { artifact_within_threshold(artifact) } } From fe991d86d200dcb59890ff19c680704b0eff7e1f Mon Sep 17 00:00:00 2001 From: Alex Kantor Date: Thu, 24 Sep 2026 08:51:44 +0200 Subject: [PATCH 7/7] docs: name the SPDX checksum field, not just the CycloneDX one 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 --- getting_started/attestations.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/getting_started/attestations.md b/getting_started/attestations.md index 188af8e..415e71c 100644 --- a/getting_started/attestations.md +++ b/getting_started/attestations.md @@ -309,11 +309,13 @@ Currently, we support the following types of evidence: **What you get depends on the tool, not just the format.** Kosli records what the document declares, and tools fill the same fields differently. Three differences catch people out. - *The subject's digest is often absent from CycloneDX.* Kosli reads the subject's SHA-256 - `hashes` entry into `subject.sha256`, in both formats. Syft's SPDX output fills it. Snyk's - and Syft's CycloneDX output does not: both leave `hashes` empty and write the digest into - `version`, where it reads as a version string rather than a checksum. Kosli does not infer - a checksum from a version, so the field is empty for those two. That is the tool's choice + *The subject's digest is often absent from CycloneDX.* Kosli fills `subject.sha256` from + the subject's SHA-256 checksum. Each format spells that differently: `hashes` in + CycloneDX, and `checksums` in SPDX, written `PackageChecksum` in the tag-value form. Syft's + SPDX output fills it. Snyk's and Syft's CycloneDX output does not: both leave `hashes` + empty and put the digest in `version`, where it reads as a version string rather than a + checksum. Kosli does not infer a checksum from a version, so `subject.sha256` is empty for + those two, and the digest they wrote is in `subject.version`. That is the tool's choice rather than a limit of CycloneDX, so check what yours writes instead of assuming either way.