Skip to content

SBOM attestation, part 2 of 2: kosli attest sbom #1162

Description

@AlexKantor87

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 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.

Dependencies

github.com/CycloneDX/cyclonedx-go
github.com/spdx/tools-golang

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.

What to extract

type Subject struct {
    Name    string `json:"name"`
    Version string `json:"version"`
    Purl    string `json:"purl"`
    Sha256  string `json:"sha256"`
}
type Document struct {
    CreatedAt    string   `json:"created_at"`
    Tools        []string `json:"tools"`
    Subject      *Subject `json:"subject"`
    PackageCount int      `json:"package_count"`
}

Sent as attestation_data alongside 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 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.
  • Annotations: map[string]string{betaCLIAnnotation: ""}.
  • 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.
  • RequireFlags(cmd, []string{"flow", "trail", "name", "sbom-file"}).

Where the SBOM work goes in run()

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
  • Not released until kosli-dev/server#6839 is deployed to production

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions