You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Build order. Can be built in parallel with the server ticket, but must not be released until the server side is deployed to production. A released kosli attest sbom pointed at a server with no sbom type gets a 404 for every customer who tries it, and the CLI has no feature flag to hide behind. Mark the command beta as a second layer.
Two decisions that shape this
No compression. The SBOM is uploaded as-is. This keeps the checksum we record equal to the checksum of the file the customer handed us, so they can verify it by hand, and it removes a whole slice of work. The consequence is that the API's 10 MB request limit becomes a hard ceiling on the raw file, which is enough for the case that prompted this (a 5.5 MB CycloneDX file) and for typical language-ecosystem SBOMs, but not for large container-image SBOMs. Lifting it is kosli-dev/server#6536, not this ticket.
Exactly one file per attestation.--sbom-file and --attachments are mutually exclusive. This is not tidiness: getPathOfEvidenceFileToUpload (cmd/kosli/cli_utils.go:632) uploads a single file as-is but tars and gzips two or more, so allowing a second attachment would silently compress the SBOM and break the checksum we just recorded against it. The repo already has MuXRequiredFlags (cmd/kosli/cli_utils.go:308) for exactly this.
The server validates too. Raised by Sami: customers call the API directly, so every check here is bypassable. The server requires format and original_fingerprint inside attestation_data, with a closed format list and a 64-hex fingerprint, and rejects an SBOM attestation with no attachment. What the CLI sends has to satisfy that schema; the checks below are a usability layer that gives a clear local error instead of a server 400.
Before you start
Invoke the new-command skill (/new-command). Its attest archetype (references/archetype-attest.md) gives the payload and options struct shapes, the six PreRunE calls in order, the flag wiring, the URL construction and the verification steps. Let it scaffold, then add the SBOM-specific work. Do not hand-copy attestDecision.go.
Every commit on this repo starts a Claude review relay. Unlike the server repo, each individual commit triggers a review. So run the full local gate — build, lint, full suite, self-review — before the first push, and squash work-in-progress rather than pushing a commit per slice. Do not push a string of fixups.
This repo's CLAUDE.md mandates a strict red-green-refactor loop with the test list in TODO.md at the repo root. TODO.md is gitignored, so it is a local working file that never appears in the PR.
Commits follow Conventional Commits. The server repo explicitly forbids them. Do not carry the habit over.
Running things:make build, make lint (it mutates your tree: go fmt and go mod tidy), make test_setup, make test_integration_single TARGET=AttestSbomCommandTestSuite.
Slice 1: the SBOM reader
New package internal/sbom/, modelled structurally on internal/snyk/, which wraps a third-party parser behind ProcessSnykResultFile(path) (*SnykData, error) with fixtures beside the code.
Between them they cover CycloneDX JSON and XML, and SPDX JSON and tag-value. Both Apache-2.0, so the Snyk licence gate should pass without a .snyk entry. Run make deps and commit the go.mod/go.sum diff — the PR reviewer runs go mod tidy and git diff --exit-code on both.
Measured: three modules reach the binary (the two libraries plus one conversion helper they share), and it grows about 2.7 MB. Every schema-validation and YAML dependency in the module graph is test-only and does not ship.
What it must do
Detect the format, confirm the file identifies itself as that format, and extract a small normalised summary. Neither library validates against the official schema, and schema conformance is not in this slice. The bar is: does it parse, and does it say what it is.
Five traps, each a working bug during the spike
Write a test for each.
1. CycloneDX identifies itself differently in JSON and XML.bomFormat and specVersion are JSON-only, explicitly excluded from the XML mapping (xml:"-"). An XML SBOM carries its identity in the root element name and its namespace, and the namespace is where the spec version lives (http://cyclonedx.org/schema/bom/1.6). One identity check rejects every valid XML file. Write two.
2. The obvious CycloneDX version check silently never fires. The spec version is an enum starting at 1, so absent is zero, but rendering it as text gives "SpecVersion(0)" rather than an empty string. Comparing against "" passes for a document with no version at all. Compare against the numeric zero.
3. cyclonedx-go accepts anything. Feeding it {"hello":"world"} returns no error and an empty document. The explicit check on bomFormat and the spec version is the only thing catching a non-SBOM. The SPDX library rejects input with no version field, but do not rely on the difference.
4. The SPDX readers rewrite the version. Every document is converted up to the newest internal model on read, so a 2.2 file reports itself as 2.3 after parsing. Taking the version from the parsed document sends a format that does not match the file — and the server enforces a closed enum, so this matters. Read the declared version from the raw bytes, which you are doing anyway to detect the format.
5. Snippet-after-package breaks SPDX tag-value.received unknown tag SnippetSPDXID in Package section. Fails on SPDX's own official 2.2 example, and reproduces at 2.3 by inserting a snippet into the official 2.3 example, so it is not version specific. Snippets are rare in generated SBOMs, so not a blocker, but it needs a clear error rather than a stack trace, and the gap needs documenting. Consider raising it upstream.
Confirmed working: both SPDX readers normalise the older documentDescribes field into modern relationships, so one code path covers 2.2 and 2.3. Proven with a 2.2 document using only that field with relationships stripped.
Every field inside document is nullable and present with an explicit null when the SBOM does not carry it. format and original_fingerprint are required by the server, so never null.
Tools. CycloneDX gives a structured name and version, but the layout changed in spec 1.5, so there are two shapes; the library exposes both, read both and merge. SPDX gives a single string like syft-1.50.0 where name and version are joined by convention, not rule. Record both as plain strings.
package_count has a trap. Syft's CycloneDX output lists every individual file as a component. Filter out components of type file. Verified: 1,200 real packages alongside 54,000 file entries gives 1,200, not 55,200. SPDX keeps packages and files in separate lists and is already honest.
Subject is often null on SPDX, legitimately — both official examples describe two packages. Record null rather than guessing.
subject.sha256 is recorded, not checked against --fingerprint. That comparison is a later slice.
Fixtures
The libraries ship official sample documents you can copy rather than hand-write:
CycloneDX JSON and XML: the testdata/ directory of cyclonedx-go (valid-bom.json, valid-bom.xml, and valid-metadata-tool-deprecated.json for the pre-1.5 tools layout)
SPDX JSON and tag-value, 2.2 and 2.3: examples/sample-docs/ in tools-golang
Add negative fixtures too: a non-SBOM JSON file, a truncated file, a binary file, a pre-gzipped file.
Memory and speed
Measured on a 40 MB syft-shaped document: parsed in 0.29 seconds using 459 MB, roughly eleven times the file size. At a 10 MB ceiling the realistic case is smaller, but worth a line in the docs.
Slice 2: the command
cmd/kosli/attestSbom.go, scaffolded by the skill.
Payload embeds *CommonAttestationPayload, plus TypeName string set to "sbom" in the factory, plus AttestationData holding format, original_fingerprint and the document block.
Options embeds *CommonAttestationOptions and adds sbomFilePath string. The file path is an option, not a payload field.
URL slug is "system", not "sbom". The type is distinguished by TypeName in the body.
Register in cmd/kosli/attest.go's AddCommand block. That is the only wiring step.
Flag description constants go in the const block in cmd/kosli/root.go, near attestationDecisionControlFlag. Prefix convention: no prefix for unconditionally required, [optional], [conditional], [defaulted].
--sbom-file needs a free shorthand if you want one. -F -g -o -n -f -T -u -t -x -D are taken.
PreRunE additions
Beyond the archetype's six standard calls:
MuXRequiredFlags(cmd, []string{"sbom-file", "attachments"}, false) — one file per SBOM attestation.
Between o.CommonAttestationOptions.run(...) and prepareAttestationForm(...). attestSnyk.go:178-185 is the precedent: parse the file into the payload, then append the path to o.attachments.
Order matters. CommonAttestationOptions.run() populates the attestation name, target artifacts, fingerprint, commit info, user data, external URLs and annotations on the shared payload.
Because the file is uploaded as-is and is the only attachment, getPathOfEvidenceFileToUpload returns the path unchanged with cleanupNeeded=false. There is no temporary file to remove.
Fail early on size
At a 10 MB raw ceiling this is a routine outcome, not an edge case. Check the file size before building the request and fail with the actual size and the limit, rather than letting the server return a bare 413 with no useful message. Note the 10 MB covers the whole request body, so the JSON payload counts against it too; leave headroom rather than comparing against exactly 10 MB.
Reject pre-compressed files
We need to read the file to validate it and extract its metadata, and we are not decompressing on the customer's behalf. Check for the gzip signature and fail with a message asking for the raw SBOM.
Annotations
Set sbom_format (e.g. cyclonedx-1.6) and sbom_sha256 (the file's checksum), and error if the customer passes either key via --annotate. Annotation keys allow [A-Za-z0-9_] only, enforced by processAnnotations (cmd/kosli/attestation.go:187-194). Both values also go into attestation_data, where the server's schema can enforce them; the annotations are the display surface.
The filename that reaches the server
createMultipartRequestBody sends filepath.Base(path), so the customer's own filename arrives intact. Nothing to do, but worth knowing it is what appears in the trail.
Tests
cmd/kosli/attestSbom_test.go, a testify suite modelled on attestDecision_test.go: suite struct embedding suite.Suite, SetupTest() resetting global and creating flow/trail fixtures, a table of cmdTestCase structs with golden strings, runTestCmd(suite.T(), tests), and a TestAttestSbomCommandTestSuite entrypoint.
Fixtures go in cmd/kosli/testdata/ for command-level tests, and beside the parser in internal/sbom/ for parser-level tests, following internal/snyk/.
Tests run against a live local server on http://localhost:8001 (make test_setup). Note --repo-root ../.. in the default arguments, and that make test_integration sets FAKE_CI_ENV — running go test directly without those gives mismatched goldens.
The local server must have the sbom type, which the published image will not until the server work ships. make test_setup reads the image tag from /tmp/server-image.txt and only calls hack/get-server-image.sh when that file is absent, so write a locally built server image tag into it.
Cover at minimum: each supported format parses; an SPDX 2.2 file is recorded as spdx-2.2 and not spdx-2.3; a non-SBOM file fails clearly; a truncated file fails clearly; a pre-gzipped file is rejected; a file over the ceiling fails locally with its size; --sbom-file with --attachments is rejected; a reserved annotation key is rejected; and the file-component filter gives the honest package count.
Definition of Done
internal/sbom/ parses CycloneDX JSON and XML, SPDX JSON and tag-value, with a test per trap above
attestation_data carries format and original_fingerprint in the shape the server's schema requires
One file per attestation, enforced and tested
Over-size files fail locally with the actual size, before the request
Pre-compressed input rejected with a clear message
Two annotations set, reserved keys rejected
kosli attest sbom --help renders, including the beta banner
go build ./..., make lint, and the integration suite all pass
go mod tidy leaves go.mod/go.sum unchanged
Full local gate run before the first push, and work-in-progress squashed, given every commit triggers a review
Part 2 of kosli-dev/server#6836. The server half is kosli-dev/server#6839.
Build order. Can be built in parallel with the server ticket, but must not be released until the server side is deployed to production. A released
kosli attest sbompointed at a server with nosbomtype gets a 404 for every customer who tries it, and the CLI has no feature flag to hide behind. Mark the command beta as a second layer.Two decisions that shape this
No compression. The SBOM is uploaded as-is. This keeps the checksum we record equal to the checksum of the file the customer handed us, so they can verify it by hand, and it removes a whole slice of work. The consequence is that the API's 10 MB request limit becomes a hard ceiling on the raw file, which is enough for the case that prompted this (a 5.5 MB CycloneDX file) and for typical language-ecosystem SBOMs, but not for large container-image SBOMs. Lifting it is kosli-dev/server#6536, not this ticket.
Exactly one file per attestation.
--sbom-fileand--attachmentsare mutually exclusive. This is not tidiness:getPathOfEvidenceFileToUpload(cmd/kosli/cli_utils.go:632) uploads a single file as-is but tars and gzips two or more, so allowing a second attachment would silently compress the SBOM and break the checksum we just recorded against it. The repo already hasMuXRequiredFlags(cmd/kosli/cli_utils.go:308) for exactly this.The server validates too. Raised by Sami: customers call the API directly, so every check here is bypassable. The server requires
formatandoriginal_fingerprintinsideattestation_data, with a closed format list and a 64-hex fingerprint, and rejects an SBOM attestation with no attachment. What the CLI sends has to satisfy that schema; the checks below are a usability layer that gives a clear local error instead of a server 400.Before you start
Invoke the
new-commandskill (/new-command). Its attest archetype (references/archetype-attest.md) gives the payload and options struct shapes, the six PreRunE calls in order, the flag wiring, the URL construction and the verification steps. Let it scaffold, then add the SBOM-specific work. Do not hand-copyattestDecision.go.Every commit on this repo starts a Claude review relay. Unlike the server repo, each individual commit triggers a review. So run the full local gate — build, lint, full suite, self-review — before the first push, and squash work-in-progress rather than pushing a commit per slice. Do not push a string of fixups.
This repo's CLAUDE.md mandates a strict red-green-refactor loop with the test list in
TODO.mdat the repo root.TODO.mdis gitignored, so it is a local working file that never appears in the PR.Commits follow Conventional Commits. The server repo explicitly forbids them. Do not carry the habit over.
Running things:
make build,make lint(it mutates your tree:go fmtandgo mod tidy),make test_setup,make test_integration_single TARGET=AttestSbomCommandTestSuite.Slice 1: the SBOM reader
New package
internal/sbom/, modelled structurally oninternal/snyk/, which wraps a third-party parser behindProcessSnykResultFile(path) (*SnykData, error)with fixtures beside the code.Dependencies
Between them they cover CycloneDX JSON and XML, and SPDX JSON and tag-value. Both Apache-2.0, so the Snyk licence gate should pass without a
.snykentry. Runmake depsand commit thego.mod/go.sumdiff — the PR reviewer runsgo mod tidyandgit diff --exit-codeon both.Measured: three modules reach the binary (the two libraries plus one conversion helper they share), and it grows about 2.7 MB. Every schema-validation and YAML dependency in the module graph is test-only and does not ship.
What it must do
Detect the format, confirm the file identifies itself as that format, and extract a small normalised summary. Neither library validates against the official schema, and schema conformance is not in this slice. The bar is: does it parse, and does it say what it is.
Five traps, each a working bug during the spike
Write a test for each.
1. CycloneDX identifies itself differently in JSON and XML.
bomFormatandspecVersionare JSON-only, explicitly excluded from the XML mapping (xml:"-"). An XML SBOM carries its identity in the root element name and its namespace, and the namespace is where the spec version lives (http://cyclonedx.org/schema/bom/1.6). One identity check rejects every valid XML file. Write two.2. The obvious CycloneDX version check silently never fires. The spec version is an enum starting at 1, so absent is zero, but rendering it as text gives
"SpecVersion(0)"rather than an empty string. Comparing against""passes for a document with no version at all. Compare against the numeric zero.3.
cyclonedx-goaccepts anything. Feeding it{"hello":"world"}returns no error and an empty document. The explicit check onbomFormatand the spec version is the only thing catching a non-SBOM. The SPDX library rejects input with no version field, but do not rely on the difference.4. The SPDX readers rewrite the version. Every document is converted up to the newest internal model on read, so a 2.2 file reports itself as 2.3 after parsing. Taking the version from the parsed document sends a
formatthat does not match the file — and the server enforces a closed enum, so this matters. Read the declared version from the raw bytes, which you are doing anyway to detect the format.5. Snippet-after-package breaks SPDX tag-value.
received unknown tag SnippetSPDXID in Package section. Fails on SPDX's own official 2.2 example, and reproduces at 2.3 by inserting a snippet into the official 2.3 example, so it is not version specific. Snippets are rare in generated SBOMs, so not a blocker, but it needs a clear error rather than a stack trace, and the gap needs documenting. Consider raising it upstream.Confirmed working: both SPDX readers normalise the older
documentDescribesfield into modern relationships, so one code path covers 2.2 and 2.3. Proven with a 2.2 document using only that field with relationships stripped.What to extract
Sent as
attestation_dataalongside the two schema-validated top-level fields:{ "format": "cyclonedx-1.6", "original_fingerprint": "9c2e…", "document": { "created_at": "…", "tools": ["syft 1.50.0"], "subject": {…}, "package_count": 1137 } }Every field inside
documentis nullable and present with an explicit null when the SBOM does not carry it.formatandoriginal_fingerprintare required by the server, so never null.syft-1.50.0where name and version are joined by convention, not rule. Record both as plain strings.package_counthas a trap. Syft's CycloneDX output lists every individual file as a component. Filter out components of typefile. Verified: 1,200 real packages alongside 54,000 file entries gives 1,200, not 55,200. SPDX keeps packages and files in separate lists and is already honest.subject.sha256is recorded, not checked against--fingerprint. That comparison is a later slice.Fixtures
The libraries ship official sample documents you can copy rather than hand-write:
testdata/directory ofcyclonedx-go(valid-bom.json,valid-bom.xml, andvalid-metadata-tool-deprecated.jsonfor the pre-1.5 tools layout)examples/sample-docs/intools-golangAdd negative fixtures too: a non-SBOM JSON file, a truncated file, a binary file, a pre-gzipped file.
Memory and speed
Measured on a 40 MB syft-shaped document: parsed in 0.29 seconds using 459 MB, roughly eleven times the file size. At a 10 MB ceiling the realistic case is smaller, but worth a line in the docs.
Slice 2: the command
cmd/kosli/attestSbom.go, scaffolded by the skill.*CommonAttestationPayload, plusTypeName stringset to"sbom"in the factory, plusAttestationDataholdingformat,original_fingerprintand thedocumentblock.*CommonAttestationOptionsand addssbomFilePath string. The file path is an option, not a payload field."system", not"sbom". The type is distinguished byTypeNamein the body.Annotations: map[string]string{betaCLIAnnotation: ""}.cmd/kosli/attest.go'sAddCommandblock. That is the only wiring step.constblock incmd/kosli/root.go, nearattestationDecisionControlFlag. Prefix convention: no prefix for unconditionally required,[optional],[conditional],[defaulted].--sbom-fileneeds a free shorthand if you want one.-F -g -o -n -f -T -u -t -x -Dare taken.PreRunE additions
Beyond the archetype's six standard calls:
MuXRequiredFlags(cmd, []string{"sbom-file", "attachments"}, false)— one file per SBOM attestation.RequireFlags(cmd, []string{"flow", "trail", "name", "sbom-file"}).Where the SBOM work goes in
run()Between
o.CommonAttestationOptions.run(...)andprepareAttestationForm(...).attestSnyk.go:178-185is the precedent: parse the file into the payload, then append the path too.attachments.Order matters.
CommonAttestationOptions.run()populates the attestation name, target artifacts, fingerprint, commit info, user data, external URLs and annotations on the shared payload.Because the file is uploaded as-is and is the only attachment,
getPathOfEvidenceFileToUploadreturns the path unchanged withcleanupNeeded=false. There is no temporary file to remove.Fail early on size
At a 10 MB raw ceiling this is a routine outcome, not an edge case. Check the file size before building the request and fail with the actual size and the limit, rather than letting the server return a bare 413 with no useful message. Note the 10 MB covers the whole request body, so the JSON payload counts against it too; leave headroom rather than comparing against exactly 10 MB.
Reject pre-compressed files
We need to read the file to validate it and extract its metadata, and we are not decompressing on the customer's behalf. Check for the gzip signature and fail with a message asking for the raw SBOM.
Annotations
Set
sbom_format(e.g.cyclonedx-1.6) andsbom_sha256(the file's checksum), and error if the customer passes either key via--annotate. Annotation keys allow[A-Za-z0-9_]only, enforced byprocessAnnotations(cmd/kosli/attestation.go:187-194). Both values also go intoattestation_data, where the server's schema can enforce them; the annotations are the display surface.The filename that reaches the server
createMultipartRequestBodysendsfilepath.Base(path), so the customer's own filename arrives intact. Nothing to do, but worth knowing it is what appears in the trail.Tests
cmd/kosli/attestSbom_test.go, a testify suite modelled onattestDecision_test.go: suite struct embeddingsuite.Suite,SetupTest()resettingglobaland creating flow/trail fixtures, a table ofcmdTestCasestructs with golden strings,runTestCmd(suite.T(), tests), and aTestAttestSbomCommandTestSuiteentrypoint.Fixtures go in
cmd/kosli/testdata/for command-level tests, and beside the parser ininternal/sbom/for parser-level tests, followinginternal/snyk/.Tests run against a live local server on
http://localhost:8001(make test_setup). Note--repo-root ../..in the default arguments, and thatmake test_integrationsetsFAKE_CI_ENV— runninggo testdirectly without those gives mismatched goldens.The local server must have the
sbomtype, which the published image will not until the server work ships.make test_setupreads the image tag from/tmp/server-image.txtand only callshack/get-server-image.shwhen that file is absent, so write a locally built server image tag into it.Cover at minimum: each supported format parses; an SPDX 2.2 file is recorded as
spdx-2.2and notspdx-2.3; a non-SBOM file fails clearly; a truncated file fails clearly; a pre-gzipped file is rejected; a file over the ceiling fails locally with its size;--sbom-filewith--attachmentsis rejected; a reserved annotation key is rejected; and the file-component filter gives the honest package count.Definition of Done
internal/sbom/parses CycloneDX JSON and XML, SPDX JSON and tag-value, with a test per trap aboveattestation_datacarriesformatandoriginal_fingerprintin the shape the server's schema requireskosli attest sbom --helprenders, including the beta bannergo build ./...,make lint, and the integration suite all passgo mod tidyleavesgo.mod/go.sumunchanged