From 4f3edcdfc1ca43966219347c425ff50d334cdca0 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 28 Jul 2026 22:31:01 +1000 Subject: [PATCH 1/5] ci: attach SBOM from a least-privilege upload job (LAB-983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Generate SBOM job had no permissions: block, so it inherited the repo default of read-only and every release: published run died on "Resource not accessible by integration" at the asset upload. cachekit-core-v0.4.0 shipped with zero release assets. Adding contents: write to that job would have been the wrong fix: it runs cargo sbom, which compiles and executes third-party build scripts and proc-macros, and a release-write token must not share a runner with untrusted code. Split instead, matching cachekit-py's sbom job — generation stays credential-less at contents: read, the SBOM crosses as a workflow artifact, and a separate sbom-upload job holds the only write token while running nothing but first-party actions. Also in this path: - Replace actions/upload-release-asset (archived by GitHub 2021-03-03, and running Node 20 under a forced Node 24 runtime) with actions/github-script. gh is not an option: the cachekit ARC runner has no gh CLI (LAB-899). - Delete the cargo install cargo-sbom, rust-toolchain and cache steps. The runner image bakes Rust stable and cargo-sbom, the plain install exits 101 against the runner's persistent CARGO_HOME, and this job compiles nothing. - Guard against an empty SBOM. An empty-but-valid CycloneDX document looks like a verified supply chain and isn't. The guard also fails closed on an unparseable file, which the naive form does not: a failed command substitution leaves count empty, [ "" -lt 1 ] errors instead of comparing, and the step exits 0. - Switch to cyclone_dx_json_1_6 so the asset matches the format release.yml attests, and so the guard can count .components. - Add a release_tag dispatch input. Without it this path could only ever be exercised by cutting a release, which is how v0.4.0 stayed broken. It also backfills v0.4.0. An SBOM dispatch no longer drags Kani onto the ARC pool. - Check out the released tag rather than main: an SBOM describing the wrong tree is worse than a missing one. --- .github/workflows/security.yml | 180 +++++++++++++++++++++++++++------ SECURITY.md | 22 ++++ 2 files changed, 172 insertions(+), 30 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 314848e..b84a174 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -29,6 +29,13 @@ on: description: "Seconds per target for an on-demand deep fuzz (default 8h)" type: string default: "28800" + release_tag: + # Verification + backfill route for the SBOM jobs. Without it the SBOM + # path could only ever be exercised by cutting a release, which is how + # v0.4.0 shipped with zero assets and stayed that way (LAB-983). + description: "Generate and attach an SBOM to this existing release tag (e.g. cachekit-core-v0.4.0)" + type: string + default: "" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -243,7 +250,10 @@ jobs: kani: name: Kani Formal Verification runs-on: cachekit - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + # An SBOM backfill dispatch (release_tag set) is not a verification request — + # it must not drag a full Kani run onto the shared ARC pool. A plain dispatch + # still runs Kani on demand, which is what that route is for. + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') permissions: contents: read # least-privilege: the job only checks out and verifies steps: @@ -316,44 +326,154 @@ jobs: - name: Run cargo vet run: cargo vet + # SBOM generation and SBOM publishing are deliberately two jobs. `cargo sbom` + # resolves the dependency graph, which compiles and executes third-party build + # scripts and proc-macros on this runner; the release-write token must never sit + # on a runner alongside untrusted code. So generation is credential-less, the + # SBOM crosses to `sbom-upload` as an artifact, and only that job — which runs + # no third-party code at all — holds `contents: write`. Same split, and the same + # reasoning, as cachekit-py's sbom job. Do not collapse these back into one. sbom: name: Generate SBOM runs-on: cachekit - if: github.event_name == 'release' + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + permissions: + contents: read + outputs: + tag: ${{ steps.target.outputs.tag }} steps: + - name: Resolve target release tag + id: target + env: + # Via env, never inline in the script body: a dispatch input is + # caller-supplied and inline ${{ }} is a shell-injection vector. + TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + if [ -z "$TAG" ]; then + echo "::error::No release tag resolved (release event gave none and release_tag input was empty)" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master with: - toolchain: "1.85" - - - name: Cache Rust dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-sbom-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-sbom- - ${{ runner.os }}-cargo- - - - name: Install cargo-sbom - run: cargo install cargo-sbom --locked + # SBOM the code that was actually released, not whatever main holds now. + # On a backfill dispatch those are different commits, and an SBOM that + # describes the wrong tree is worse than a missing one. + ref: ${{ steps.target.outputs.tag }} + persist-credentials: false - name: Generate SBOM - run: cargo sbom > cachekit-core-sbom.json + # No cargo install, no rust-toolchain, no cargo cache: the cachekit runner + # image bakes Rust stable AND cargo-sbom (cachekit-io/runner Dockerfile), + # and a plain `cargo install cargo-sbom` exits 101 against the runner's + # persistent CARGO_HOME anyway (see release.yml). This step compiles + # nothing, so there is no build cache worth restoring. + # cyclone_dx_json_1_6 matches the SBOM release.yml attests, so the asset + # published here and the attested document are the same format. + run: cargo sbom --output-format cyclone_dx_json_1_6 > cachekit-core-sbom.json + + - name: Verify SBOM is not empty + # Fail closed. An empty-but-valid CycloneDX document is worse than no + # asset at all: it looks like a verified supply chain and isn't. python3 + # is on the runner image's PATH; jq and gh are not — do not reach for them. + run: | + # The `||` is load-bearing: without it a parse failure leaves count + # empty, `[ "" -lt 1 ]` errors instead of comparing, the if takes the + # false branch and the step exits 0 — publishing the broken SBOM. A + # guard that can't tell "zero components" from "couldn't read it" is + # not a guard. Same fail-open shape as the old Kani `|| echo`. + count=$(python3 -c 'import json; print(len(json.load(open("cachekit-core-sbom.json")).get("components") or []))') || { + echo "::error::could not parse cachekit-core-sbom.json as a CycloneDX document" + exit 1 + } + echo "SBOM components: $count" + # ponytail: a floor of 1 only catches a wholly-unresolved graph. Raise it + # to a real minimum if a partially-resolved SBOM ever slips through. + if [ "$count" -lt 1 ]; then + echo "::error::SBOM lists $count components — refusing to publish an empty SBOM" + exit 1 + fi - - name: Upload SBOM as release asset - uses: actions/upload-release-asset@e8f9f06c4b078e705bd2ea027f0926603fc9b4d5 # v1 + - name: Upload SBOM as workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sbom + path: cachekit-core-sbom.json + if-no-files-found: error + # Artifacts are immutable per run, so a "re-run all jobs" would otherwise + # 409 on the name from the first attempt and never reach the upload job. + overwrite: true + + sbom-upload: + name: Attach SBOM to Release + needs: sbom + runs-on: cachekit + # The only write token in this workflow, held by the only job that runs no + # third-party code — download-artifact and github-script are both first-party. + permissions: + contents: write + steps: + - name: Download SBOM artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: sbom + + - name: Attach SBOM to release + # github-script, NOT `gh`: the self-hosted cachekit runner has no gh CLI + # (LAB-899, same reason release.yml uses github-script). It replaces the + # upload-release-asset action GitHub archived on 2021-03-03, which was + # additionally failing here because the old single job inherited the repo's + # read-only default token: "Resource not accessible by integration" on + # every release (LAB-983). + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.sbom.outputs.tag }} + ASSET_NAME: cachekit-core-sbom.json with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: ./cachekit-core-sbom.json - asset_name: cachekit-core-sbom.json - asset_content_type: application/json + script: | + const fs = require('fs'); + const name = process.env.ASSET_NAME; + const { owner, repo } = context.repo; + + // Resolve by tag for both routes — a release event and a backfill + // dispatch then share one code path instead of branching on + // github.event.release. + const { data: release } = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: process.env.RELEASE_TAG, + }); + + // POST .../releases/{id}/assets returns 422 when an asset of this + // filename already exists, so re-running over a previous attempt has + // to delete first. Idempotent by construction, rather than by + // swallowing a 422 we would then have to tell apart from a real one. + const existing = (await github.paginate(github.rest.repos.listReleaseAssets, { + owner, + repo, + release_id: release.id, + })).find((asset) => asset.name === name); + if (existing) { + await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: existing.id }); + core.info(`Replacing existing ${name} (asset ${existing.id})`); + } + + // Read as a utf8 string: octokit JSON.stringify's plain objects and + // arrays only, so a string body is sent verbatim. uploadReleaseAsset + // carries baseUrl: uploads.github.com in its own endpoint defaults, so + // it lands on the upload host without any override here. + const data = fs.readFileSync(name, 'utf8'); + const { data: asset } = await github.rest.repos.uploadReleaseAsset({ + owner, + repo, + release_id: release.id, + name, + data, + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(data), + }, + }); + core.info(`Attached ${asset.name} (${asset.size} bytes) to ${release.tag_name}`); diff --git a/SECURITY.md b/SECURITY.md index 9d07ef6..d2473bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,6 +69,28 @@ cargo deny check advisories See `deny.toml` for the full security policy. +### Software Bill of Materials + +Releases from `cachekit-core-v0.4.0` onward carry a CycloneDX 1.6 SBOM as a +release asset named `cachekit-core-sbom.json`, generated by `cargo-sbom` from +the released tag. Earlier releases predate this and have no SBOM asset. + +```bash +gh release download cachekit-core-v0.4.0 --repo cachekit-io/cachekit-core \ + --pattern cachekit-core-sbom.json +``` + +Separately, the publish workflow attests an SBOM of the packaged crate via +[`actions/attest-sbom`](https://github.com/actions/attest-sbom). That attestation +is the cryptographically verifiable artifact — the release asset above is a +convenience copy, generated by the same tool in the same format but not itself +signed: + +```bash +gh attestation verify --repo cachekit-io/cachekit-core \ + --predicate-type https://cyclonedx.org/bom/v1.6 +``` + ## Vulnerability Disclosure History No vulnerabilities have been disclosed yet. From 47f389ba6523721b8db461aba30327d00a04b95a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 28 Jul 2026 22:39:57 +1000 Subject: [PATCH 2/5] ci: harden the SBOM split after expert-panel review (LAB-983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the panel: - The workflow concurrency group did not distinguish backfill dispatches, so a second dispatch (or a scheduled run on the same ref) could cancel one mid-flight. Because sbom-upload deletes a pre-existing asset before re-uploading, a cancel landing in that window would leave a release with its SBOM deleted and never replaced — worse than the missing asset this fixes, and hidden behind a "cancelled" status. release_tag is now part of the key. Uses github.event.inputs rather than inputs: the group is evaluated on every event and the inputs context is only documented for dispatch/reusable calls. - The target tag was not checked against a real release until the upload job, so `cargo sbom` would compile and execute third-party build scripts for any ref a dispatch named, and a typo cost a full generation run before failing. Resolution moved ahead of the checkout, while the workspace is still empty. - The comment justifying the deleted install steps cited release.yml's persistent-CARGO_HOME rationale, which is itself stale there — that job moved to ubuntu-latest. Reworded to stand on the runner image instead. release.yml is out of scope for this ticket, so its stale comment is left alone. Resolving the release id in the generate job and passing it to the upload job also drops a redundant getReleaseByTag and closes the window where a repointed tag could put this SBOM on a different release than it was generated from. Rejected: extending the backfill exclusion to deep-fuzz. run_deep_fuzz is already an explicit opt-in, and silently ignoring an input the operator ticked is worse than honouring it. The kani exclusion is not the same case — kani runs on any dispatch with no opt-in at all. --- .github/workflows/security.yml | 76 ++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b84a174..951bd5a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -38,7 +38,14 @@ on: default: "" concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # release_tag is part of the key so SBOM backfills never cancel each other or a + # scheduled/push run on the same ref. sbom-upload deletes a pre-existing asset + # before re-uploading it; a cancel landing in that window would leave the + # release with its SBOM deleted and never replaced — worse than the missing + # asset this workflow exists to fix, and easy to miss behind a "cancelled" run. + # github.event.inputs, not inputs: this is evaluated on every event, and the + # `inputs` context is only documented as available to dispatch/reusable calls. + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.release_tag || 'default' }} cancel-in-progress: true env: @@ -341,19 +348,36 @@ jobs: contents: read outputs: tag: ${{ steps.target.outputs.tag }} + release_id: ${{ steps.target.outputs.release_id }} steps: - - name: Resolve target release tag + # Resolve and validate BEFORE the checkout below, while the workspace is + # still empty. `cargo sbom` compiles and runs third-party build scripts, and + # it must never do that for a ref that is not a published release — a + # dispatch can name any ref. This also turns a typo into a 404 in seconds + # instead of a wasted multi-minute generation run that only fails later, in + # the other job. + - name: Resolve target release id: target + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - # Via env, never inline in the script body: a dispatch input is - # caller-supplied and inline ${{ }} is a shell-injection vector. - TAG: ${{ github.event.release.tag_name || inputs.release_tag }} - run: | - if [ -z "$TAG" ]; then - echo "::error::No release tag resolved (release event gave none and release_tag input was empty)" - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Via env, never inline ${{ }} in the script body: a dispatch input is + # caller-supplied. + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + with: + script: | + const tag = process.env.RELEASE_TAG; + if (!tag) { + core.setFailed('No release tag resolved: the release event carried none and release_tag was empty'); + return; + } + // Throws on 404 — fails closed if the tag has no published release. + const { data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); + core.setOutput('tag', tag); + // The upload job attaches by id instead of re-resolving the tag, so a + // tag repointed between the two jobs cannot land this SBOM on a + // different release than the one it was generated from. + core.setOutput('release_id', release.id); + core.info(`Target: ${tag} (release ${release.id})`); - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -366,10 +390,12 @@ jobs: - name: Generate SBOM # No cargo install, no rust-toolchain, no cargo cache: the cachekit runner - # image bakes Rust stable AND cargo-sbom (cachekit-io/runner Dockerfile), - # and a plain `cargo install cargo-sbom` exits 101 against the runner's - # persistent CARGO_HOME anyway (see release.yml). This step compiles - # nothing, so there is no build cache worth restoring. + # image bakes Rust stable AND cargo-sbom (cachekit-io/runner Dockerfile, + # which verifies `cargo sbom --help` at build time), and that image's own + # guidance is that workflows on this runner should not re-install them. A + # plain `cargo install cargo-sbom` would also exit 101 whenever the binary + # is already present. This step compiles nothing, so there is no build + # cache worth restoring either. # cyclone_dx_json_1_6 matches the SBOM release.yml attests, so the asset # published here and the attested document are the same format. run: cargo sbom --output-format cyclone_dx_json_1_6 > cachekit-core-sbom.json @@ -429,23 +455,19 @@ jobs: # every release (LAB-983). uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: + # Resolved and validated by the sbom job, before any third-party code + # ran. Attaching by id rather than re-resolving the tag here keeps the + # SBOM bound to the release it was actually generated from. + RELEASE_ID: ${{ needs.sbom.outputs.release_id }} RELEASE_TAG: ${{ needs.sbom.outputs.tag }} ASSET_NAME: cachekit-core-sbom.json with: script: | const fs = require('fs'); const name = process.env.ASSET_NAME; + const release_id = Number(process.env.RELEASE_ID); const { owner, repo } = context.repo; - // Resolve by tag for both routes — a release event and a backfill - // dispatch then share one code path instead of branching on - // github.event.release. - const { data: release } = await github.rest.repos.getReleaseByTag({ - owner, - repo, - tag: process.env.RELEASE_TAG, - }); - // POST .../releases/{id}/assets returns 422 when an asset of this // filename already exists, so re-running over a previous attempt has // to delete first. Idempotent by construction, rather than by @@ -453,7 +475,7 @@ jobs: const existing = (await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, - release_id: release.id, + release_id, })).find((asset) => asset.name === name); if (existing) { await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: existing.id }); @@ -468,7 +490,7 @@ jobs: const { data: asset } = await github.rest.repos.uploadReleaseAsset({ owner, repo, - release_id: release.id, + release_id, name, data, headers: { @@ -476,4 +498,4 @@ jobs: 'content-length': Buffer.byteLength(data), }, }); - core.info(`Attached ${asset.name} (${asset.size} bytes) to ${release.tag_name}`); + core.info(`Attached ${asset.name} (${asset.size} bytes) to ${process.env.RELEASE_TAG}`); From bf1add7a5ae0d00410aabc12e343532e2a628c7f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 28 Jul 2026 22:52:23 +1000 Subject: [PATCH 3/5] ci: fail fast when the target release is immutable (LAB-983) Live verification of the split found the wall behind the permissions bug: immutable releases are enabled on this repository, and GitHub applies immutability at publish. `release: published` therefore fires only ever against a sealed release, and the upload returns "Cannot upload assets to an immutable release". Verified in run 30359945625 - Generate SBOM green in 19s, Attach SBOM to Release red on that error. Every release in this repo reports immutable: true and none has ever carried an asset; cachekit-rs is the same, cachekit-py and cachekit-ts are not. That is a policy decision, not a retry: publishing an SBOM asset needs either immutable releases turned off for this repo, or releases created as drafts with assets attached before publish (which lives in release.yml, out of scope here). So the target release is now checked before generation runs, and the job fails with that choice spelled out instead of burning a generation run and dying in 48KB of octokit output. SECURITY.md documents what the repo actually ships: the attested SBOM, which is real and verifiable today, plus an explicit statement that releases carry no SBOM asset and why. The earlier wording in this branch promised an asset that cannot exist - an unverifiable claim in a security document is a trust bug, and the expert panel flagged it before this run confirmed it. --- .github/workflows/security.yml | 17 +++++++++++++++++ SECURITY.md | 22 ++++++++-------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 951bd5a..34d8224 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -372,6 +372,23 @@ jobs: } // Throws on 404 — fails closed if the tag has no published release. const { data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); + + // Immutable releases are enabled on this repo, and immutability is + // applied at publish: a published release permanently refuses new + // assets ("Cannot upload assets to an immutable release"). Fail here + // rather than spending a generation run on an SBOM that provably + // cannot be attached. See LAB-983 — this needs a decision, not a + // retry: either turn the setting off (Settings > Code and automation + // > Releases), or create releases as drafts, upload, then publish. + if (release.immutable) { + core.setFailed( + `Release ${tag} is immutable, so assets cannot be attached after publish. ` + + `Publishing an SBOM asset requires either disabling immutable releases for ` + + `this repository, or attaching assets while the release is still a draft.`, + ); + return; + } + core.setOutput('tag', tag); // The upload job attaches by id instead of re-resolving the tag, so a // tag repointed between the two jobs cannot land this SBOM on a diff --git a/SECURITY.md b/SECURITY.md index d2473bc..e39ef3e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -71,26 +71,20 @@ See `deny.toml` for the full security policy. ### Software Bill of Materials -Releases from `cachekit-core-v0.4.0` onward carry a CycloneDX 1.6 SBOM as a -release asset named `cachekit-core-sbom.json`, generated by `cargo-sbom` from -the released tag. Earlier releases predate this and have no SBOM asset. - -```bash -gh release download cachekit-core-v0.4.0 --repo cachekit-io/cachekit-core \ - --pattern cachekit-core-sbom.json -``` - -Separately, the publish workflow attests an SBOM of the packaged crate via -[`actions/attest-sbom`](https://github.com/actions/attest-sbom). That attestation -is the cryptographically verifiable artifact — the release asset above is a -convenience copy, generated by the same tool in the same format but not itself -signed: +A CycloneDX 1.6 SBOM is generated by `cargo-sbom` during publish and attested +against the packaged crate via +[`actions/attest-sbom`](https://github.com/actions/attest-sbom). The attestation +is the verifiable artifact — verify it against a crate you have downloaded: ```bash gh attestation verify --repo cachekit-io/cachekit-core \ --predicate-type https://cyclonedx.org/bom/v1.6 ``` +GitHub releases for this repository carry no SBOM file as a downloadable asset. +Immutable releases are enabled here, which seals a release's assets at publish +time, so an SBOM cannot be attached after the fact. Use the attestation above. + ## Vulnerability Disclosure History No vulnerabilities have been disclosed yet. From 7e9b2d4f8bd5b116f73af79f11dd2fbf32011972 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 13:44:49 +1000 Subject: [PATCH 4/5] ci: drop the SBOM release-asset job, keep the attestation (LAB-983) Immutable releases stay enabled on this repository, so the SBOM release asset cannot exist: GitHub seals a release's assets at publish, and the upload returns "Cannot upload assets to an immutable release". Verified live in run 30359945625. Every release since v0.1.0 has zero assets - this job has never once succeeded. So the job goes, rather than being rewritten to fail more elegantly. The SBOM is already generated by cargo-sbom and attested against the packaged crate via actions/attest-sbom in release.yml. That attestation is the cryptographically verifiable artifact; a plain release asset would have been an unsigned weaker copy of the same document, which is not worth trading tamper-resistance for. The `release:` trigger goes with it. `sbom` was the only job gated on it, so leaving it would produce an empty run on every release. The reason both were removed is recorded where the trigger used to be, including what re-adding the asset would actually require (draft release, attach, then publish), so the next person does not rediscover the wall. This also removes the archived actions/upload-release-asset (unmaintained since 2021-03-03) from the repository. SECURITY.md documents what ships: the attested SBOM and how to verify it, plus an explicit statement that releases carry no SBOM asset and why. A security document that promises a downloadable SBOM nobody can download is a trust bug. --- .github/workflows/security.yml | 217 ++------------------------------- 1 file changed, 12 insertions(+), 205 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 34d8224..ef5fe46 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -14,8 +14,16 @@ on: # regressions promptly; deep fuzz is for finding bugs, not gating merges. # Off-minute (:07) avoids the cron pile-up that GitHub schedules at :00. - cron: '7 11 * * 6' - release: - types: [published] + # Deliberately no `release:` trigger. It existed only for an SBOM job that + # attached a release asset, and that can never work here: immutable releases + # are enabled on this repo and GitHub seals a release's assets at publish, so + # the upload always failed with "Cannot upload assets to an immutable release" + # — every release since v0.1.0 has zero assets (LAB-983). The SBOM is generated + # and attested in release.yml via actions/attest-sbom; that attestation is the + # verifiable artifact, and a plain release asset would be an unsigned weaker + # copy of it. Re-adding this trigger without a job gated on it only produces an + # empty run per release; re-adding the asset upload needs the release created as + # a draft, assets attached, then published. # On-demand: lets the schedule-only jobs (e.g. Kani) be run and verified # without waiting for the weekly cron. A plain dispatch does NOT trigger the # heavy deep-fuzz matrix — set run_deep_fuzz=true to opt into that. @@ -29,23 +37,9 @@ on: description: "Seconds per target for an on-demand deep fuzz (default 8h)" type: string default: "28800" - release_tag: - # Verification + backfill route for the SBOM jobs. Without it the SBOM - # path could only ever be exercised by cutting a release, which is how - # v0.4.0 shipped with zero assets and stayed that way (LAB-983). - description: "Generate and attach an SBOM to this existing release tag (e.g. cachekit-core-v0.4.0)" - type: string - default: "" concurrency: - # release_tag is part of the key so SBOM backfills never cancel each other or a - # scheduled/push run on the same ref. sbom-upload deletes a pre-existing asset - # before re-uploading it; a cancel landing in that window would leave the - # release with its SBOM deleted and never replaced — worse than the missing - # asset this workflow exists to fix, and easy to miss behind a "cancelled" run. - # github.event.inputs, not inputs: this is evaluated on every event, and the - # `inputs` context is only documented as available to dispatch/reusable calls. - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.release_tag || 'default' }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: @@ -257,10 +251,7 @@ jobs: kani: name: Kani Formal Verification runs-on: cachekit - # An SBOM backfill dispatch (release_tag set) is not a verification request — - # it must not drag a full Kani run onto the shared ARC pool. A plain dispatch - # still runs Kani on demand, which is what that route is for. - if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' permissions: contents: read # least-privilege: the job only checks out and verifies steps: @@ -332,187 +323,3 @@ jobs: - name: Run cargo vet run: cargo vet - - # SBOM generation and SBOM publishing are deliberately two jobs. `cargo sbom` - # resolves the dependency graph, which compiles and executes third-party build - # scripts and proc-macros on this runner; the release-write token must never sit - # on a runner alongside untrusted code. So generation is credential-less, the - # SBOM crosses to `sbom-upload` as an artifact, and only that job — which runs - # no third-party code at all — holds `contents: write`. Same split, and the same - # reasoning, as cachekit-py's sbom job. Do not collapse these back into one. - sbom: - name: Generate SBOM - runs-on: cachekit - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') - permissions: - contents: read - outputs: - tag: ${{ steps.target.outputs.tag }} - release_id: ${{ steps.target.outputs.release_id }} - steps: - # Resolve and validate BEFORE the checkout below, while the workspace is - # still empty. `cargo sbom` compiles and runs third-party build scripts, and - # it must never do that for a ref that is not a published release — a - # dispatch can name any ref. This also turns a typo into a 404 in seconds - # instead of a wasted multi-minute generation run that only fails later, in - # the other job. - - name: Resolve target release - id: target - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - # Via env, never inline ${{ }} in the script body: a dispatch input is - # caller-supplied. - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} - with: - script: | - const tag = process.env.RELEASE_TAG; - if (!tag) { - core.setFailed('No release tag resolved: the release event carried none and release_tag was empty'); - return; - } - // Throws on 404 — fails closed if the tag has no published release. - const { data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); - - // Immutable releases are enabled on this repo, and immutability is - // applied at publish: a published release permanently refuses new - // assets ("Cannot upload assets to an immutable release"). Fail here - // rather than spending a generation run on an SBOM that provably - // cannot be attached. See LAB-983 — this needs a decision, not a - // retry: either turn the setting off (Settings > Code and automation - // > Releases), or create releases as drafts, upload, then publish. - if (release.immutable) { - core.setFailed( - `Release ${tag} is immutable, so assets cannot be attached after publish. ` + - `Publishing an SBOM asset requires either disabling immutable releases for ` + - `this repository, or attaching assets while the release is still a draft.`, - ); - return; - } - - core.setOutput('tag', tag); - // The upload job attaches by id instead of re-resolving the tag, so a - // tag repointed between the two jobs cannot land this SBOM on a - // different release than the one it was generated from. - core.setOutput('release_id', release.id); - core.info(`Target: ${tag} (release ${release.id})`); - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - # SBOM the code that was actually released, not whatever main holds now. - # On a backfill dispatch those are different commits, and an SBOM that - # describes the wrong tree is worse than a missing one. - ref: ${{ steps.target.outputs.tag }} - persist-credentials: false - - - name: Generate SBOM - # No cargo install, no rust-toolchain, no cargo cache: the cachekit runner - # image bakes Rust stable AND cargo-sbom (cachekit-io/runner Dockerfile, - # which verifies `cargo sbom --help` at build time), and that image's own - # guidance is that workflows on this runner should not re-install them. A - # plain `cargo install cargo-sbom` would also exit 101 whenever the binary - # is already present. This step compiles nothing, so there is no build - # cache worth restoring either. - # cyclone_dx_json_1_6 matches the SBOM release.yml attests, so the asset - # published here and the attested document are the same format. - run: cargo sbom --output-format cyclone_dx_json_1_6 > cachekit-core-sbom.json - - - name: Verify SBOM is not empty - # Fail closed. An empty-but-valid CycloneDX document is worse than no - # asset at all: it looks like a verified supply chain and isn't. python3 - # is on the runner image's PATH; jq and gh are not — do not reach for them. - run: | - # The `||` is load-bearing: without it a parse failure leaves count - # empty, `[ "" -lt 1 ]` errors instead of comparing, the if takes the - # false branch and the step exits 0 — publishing the broken SBOM. A - # guard that can't tell "zero components" from "couldn't read it" is - # not a guard. Same fail-open shape as the old Kani `|| echo`. - count=$(python3 -c 'import json; print(len(json.load(open("cachekit-core-sbom.json")).get("components") or []))') || { - echo "::error::could not parse cachekit-core-sbom.json as a CycloneDX document" - exit 1 - } - echo "SBOM components: $count" - # ponytail: a floor of 1 only catches a wholly-unresolved graph. Raise it - # to a real minimum if a partially-resolved SBOM ever slips through. - if [ "$count" -lt 1 ]; then - echo "::error::SBOM lists $count components — refusing to publish an empty SBOM" - exit 1 - fi - - - name: Upload SBOM as workflow artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: sbom - path: cachekit-core-sbom.json - if-no-files-found: error - # Artifacts are immutable per run, so a "re-run all jobs" would otherwise - # 409 on the name from the first attempt and never reach the upload job. - overwrite: true - - sbom-upload: - name: Attach SBOM to Release - needs: sbom - runs-on: cachekit - # The only write token in this workflow, held by the only job that runs no - # third-party code — download-artifact and github-script are both first-party. - permissions: - contents: write - steps: - - name: Download SBOM artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: sbom - - - name: Attach SBOM to release - # github-script, NOT `gh`: the self-hosted cachekit runner has no gh CLI - # (LAB-899, same reason release.yml uses github-script). It replaces the - # upload-release-asset action GitHub archived on 2021-03-03, which was - # additionally failing here because the old single job inherited the repo's - # read-only default token: "Resource not accessible by integration" on - # every release (LAB-983). - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - # Resolved and validated by the sbom job, before any third-party code - # ran. Attaching by id rather than re-resolving the tag here keeps the - # SBOM bound to the release it was actually generated from. - RELEASE_ID: ${{ needs.sbom.outputs.release_id }} - RELEASE_TAG: ${{ needs.sbom.outputs.tag }} - ASSET_NAME: cachekit-core-sbom.json - with: - script: | - const fs = require('fs'); - const name = process.env.ASSET_NAME; - const release_id = Number(process.env.RELEASE_ID); - const { owner, repo } = context.repo; - - // POST .../releases/{id}/assets returns 422 when an asset of this - // filename already exists, so re-running over a previous attempt has - // to delete first. Idempotent by construction, rather than by - // swallowing a 422 we would then have to tell apart from a real one. - const existing = (await github.paginate(github.rest.repos.listReleaseAssets, { - owner, - repo, - release_id, - })).find((asset) => asset.name === name); - if (existing) { - await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: existing.id }); - core.info(`Replacing existing ${name} (asset ${existing.id})`); - } - - // Read as a utf8 string: octokit JSON.stringify's plain objects and - // arrays only, so a string body is sent verbatim. uploadReleaseAsset - // carries baseUrl: uploads.github.com in its own endpoint defaults, so - // it lands on the upload host without any override here. - const data = fs.readFileSync(name, 'utf8'); - const { data: asset } = await github.rest.repos.uploadReleaseAsset({ - owner, - repo, - release_id, - name, - data, - headers: { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(data), - }, - }); - core.info(`Attached ${asset.name} (${asset.size} bytes) to ${process.env.RELEASE_TAG}`); From c2b0d491ffc9411eccc0fd8ba0b514bc9eeabe54 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 5 Aug 2026 13:53:40 +1000 Subject: [PATCH 5/5] docs: fix the SBOM attestation verify command (LAB-983) The predicate type was wrong. actions/attest-sbom records CycloneDX as `https://cyclonedx.org/bom` with no version suffix - the spec version lives in the document's own specVersion field. Only SPDX versions its predicate type. The documented command therefore 404'd for every consumer who ran it: HTTP 404: .../attestations/sha256:6aba15...?predicate_type=...%2Fbom%2Fv1.6 Verified end to end against the real published artifact rather than from reasoning: downloaded cachekit-core-0.4.0.crate from static.crates.io, confirmed the recorded attestations for its sha256 are `https://cyclonedx.org/bom` and `https://slsa.dev/provenance/v1` with subject cachekit-core-0.4.0.crate, and confirmed the corrected `gh attestation verify` exits 0 with a signature from release.yml@refs/heads/main. The wrong form errors, the corrected form passes. The example is now concrete and copy-pasteable - it names the actual crate and includes the download - because a placeholder like left the reader guessing which artifact to verify. Also notes that provenance is attested separately under the SLSA predicate. The wrong string was copied from attestation-check.yml, which carries the same bug in both its file-based and list-based SBOM checks. That file is LAB-984's fail-open territory and out of scope here; noted on that ticket. --- .github/workflows/security.yml | 5 ++--- SECURITY.md | 13 ++++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ef5fe46..f79815c 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -21,9 +21,8 @@ on: # — every release since v0.1.0 has zero assets (LAB-983). The SBOM is generated # and attested in release.yml via actions/attest-sbom; that attestation is the # verifiable artifact, and a plain release asset would be an unsigned weaker - # copy of it. Re-adding this trigger without a job gated on it only produces an - # empty run per release; re-adding the asset upload needs the release created as - # a draft, assets attached, then published. + # copy of it. Re-adding this alone only makes empty runs; the asset upload would + # additionally need the release created as a draft, attached, then published. # On-demand: lets the schedule-only jobs (e.g. Kani) be run and verified # without waiting for the weekly cron. A plain dispatch does NOT trigger the # heavy deep-fuzz matrix — set run_deep_fuzz=true to opt into that. diff --git a/SECURITY.md b/SECURITY.md index e39ef3e..48dcd83 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,13 +74,20 @@ See `deny.toml` for the full security policy. A CycloneDX 1.6 SBOM is generated by `cargo-sbom` during publish and attested against the packaged crate via [`actions/attest-sbom`](https://github.com/actions/attest-sbom). The attestation -is the verifiable artifact — verify it against a crate you have downloaded: +is the verifiable artifact — verify it against the crate as published: ```bash -gh attestation verify --repo cachekit-io/cachekit-core \ - --predicate-type https://cyclonedx.org/bom/v1.6 +# Download the published crate, then verify the SBOM attestation against it. +curl -sSLO https://static.crates.io/crates/cachekit-core/cachekit-core-0.4.0.crate +gh attestation verify cachekit-core-0.4.0.crate --repo cachekit-io/cachekit-core \ + --predicate-type https://cyclonedx.org/bom ``` +Note the predicate type carries no version suffix: `actions/attest-sbom` records +CycloneDX as `https://cyclonedx.org/bom` regardless of spec version (the version +lives in the document's own `specVersion`). Provenance is attested separately +under `https://slsa.dev/provenance/v1` against the same subject. + GitHub releases for this repository carry no SBOM file as a downloadable asset. Immutable releases are enabled here, which seals a release's assets at publish time, so an SBOM cannot be attached after the fact. Use the attestation above.