Skip to content

ci: attach SBOM from a least-privilege upload job (LAB-983) - #64

Draft
27Bslash6 wants to merge 3 commits into
mainfrom
lab-983-least-privilege-sbom-upload
Draft

ci: attach SBOM from a least-privilege upload job (LAB-983)#64
27Bslash6 wants to merge 3 commits into
mainfrom
lab-983-least-privilege-sbom-upload

Conversation

@27Bslash6

Copy link
Copy Markdown
Contributor

Closes LAB-983 — draft: needs a policy decision before this can merge. See "The wall" below.

What was broken

The sbom job in security.yml had no permissions: block, so it inherited the repository's read-only default token. Every release: published run died at the asset upload with Resource not accessible by integration, and 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; a release-write token must not share a runner with untrusted code. cachekit-py already rejected exactly this shape.

What this does

  • Splits the job across a permissions boundary. sbom generates at permissions: contents: read with persist-credentials: false; the SBOM crosses as a workflow artifact; sbom-upload holds the only contents: write token and runs nothing but first-party actions.
  • Drops the archived action. upload-release-asset was archived by GitHub on 2021-03-03. Replaced with actions/github-scriptgh is not an option, the cachekit ARC runner has no gh CLI (LAB-899).
  • Deletes three redundant steps. The runner image bakes Rust stable and cargo-sbom, and this job compiles nothing, so cargo install / rust-toolchain / actions/cache all went. Generation now takes 19s.
  • Fails closed on 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 obvious form does not — a failed command substitution leaves count empty, [ "" -lt 1 ] errors rather than compares, and the step exits 0. Unit-tested against 8 malformed inputs.
  • Switches to cyclone_dx_json_1_6, matching the format release.yml attests.
  • Adds a release_tag dispatch input so the path is testable without cutting a release, and excludes Kani from a backfill dispatch so it does not drag the ARC pool along.

The wall

Live verification found the real blocker behind the permissions bug. Immutable releases are enabled on this repository, and GitHub applies immutability at publish. release: published therefore only ever fires against an already-sealed release:

HttpError: Cannot upload assets to an immutable release.

Run 30359945625Generate 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 have immutability off.

So no amount of fixing security.yml can attach an SBOM asset, and v0.4.0 cannot be backfilled — a sealed release refuses assets permanently. This needs a decision, not a retry:

  1. Disable immutable releases for this repo (Settings → Code and automation → Releases). This PR then works as-is and v0.4.0 can be backfilled — but it trades a real tamper-resistance guarantee for an unsigned convenience copy of an SBOM that is already attested.
  2. Keep immutability, attach assets pre-publish — create the release as a draft, upload, then publish. That lives in release.yml, which LAB-983 puts out of scope. The two jobs here are reusable unchanged.
  3. Keep immutability, drop the asset ambition — delete these two jobs. The SBOM is already generated and attested via actions/attest-sbom in release.yml, which is the cryptographically verifiable artifact. ← my recommendation

Pending that call, the workflow now checks immutability before generating and fails with the choice spelled out, rather than burning a generation run and dying in 48KB of octokit output:

Release cachekit-core-v0.4.0 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.

Review

Expert panel (bug-hunter-supreme, security-specialist, code-craftsman, catchphrase-agent) at high stakes. Three findings applied:

  • Concurrency group could destroy an asset. Backfill dispatches shared a group with cancel-in-progress: true; since upload deletes a pre-existing asset before re-uploading, a cancel in that window would leave a release with its SBOM deleted and never replaced — worse than the bug being fixed. release_tag is now part of the key.
  • Tag validated too late. cargo sbom would execute third-party build scripts for any ref a dispatch named before anything checked the tag was a real release. Resolution moved ahead of the checkout, while the workspace is still empty. Also resolves the release id there and passes it downstream, closing the window where a repointed tag could put this SBOM on a different release.
  • A comment cited a stale claim in release.yml (its persistent-CARGO_HOME rationale predates that job's move to ubuntu-latest). Reworded. release.yml is out of scope so its own stale comment is left alone.

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.

Filed separately: the credential-less job and token-holding jobs share a persistent cargo volume on the ARC pool, so build-script code could poison it for a later privileged job. Pre-existing across the estate and not made worse here, but the job-level permissions: split alone does not isolate a shared filesystem.

Docs

SECURITY.md gains a Software Bill of Materials section documenting the attested SBOM, which is real and verifiable today, plus an explicit statement that releases carry no SBOM asset and why. An earlier revision of this branch promised the asset; the panel flagged it as unverifiable and this run proved it impossible.

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

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 81ff0dec-525e-4efe-9dfa-085e08279027

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lab-983-least-privilege-sbom-upload

Comment @coderabbitai help to get the list of available commands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant