From 4f3edcdfc1ca43966219347c425ff50d334cdca0 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 28 Jul 2026 22:31:01 +1000 Subject: [PATCH 1/3] 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/3] 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/3] 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.