Sign published images with cosign - #8
Closed
tomj12k wants to merge 2 commits into
Closed
Conversation
Nothing this organization publishes was signed, so nothing downstream could tell an image this workflow built from one someone else pushed to the same tag. initiative_infra's PLAN.md records that signing has to start in the app repos' publish workflows before Kyverno can verify anything, which makes the admission policy blocked on this rather than the other way round. Keyless. cosign exchanges the job's OIDC token for a short-lived certificate from Fulcio and records the signature in the public Rekor log, so no private key exists to hold, rotate or lose — which matters here, because the alternative would be another long-lived secret in a repository whose threat model already notes two of those. The certificate carries the workflow identity, so a verifier can require that THIS repository's release workflow produced the image rather than merely someone with push access to the registry. That is the property a signature is for. Signed by digest rather than tag: a tag moves, a digest is what was built, and one signature covers every tag pointing at it. Verified: the workflow parses, the job carries id-token: write, the build step exposes its digest, the generated shell passes bash -n with the Actions expressions stubbed, and every action reference including the new one is pinned to a commit SHA. Traced from T64 in the estate threat model, work item WI-12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1
Two points from adversarial review, both small.
A stray blank line sat between `- uses: docker/build-push-action@…` and
`id: build`. It parses correctly — blank lines are legal inside a YAML block
mapping, and `id: build` does land on the build step — but a reader has to
verify that rather than see it. Removed.
The image name came from `cut -d: -f1`, which returns "localhost" for any
registry carrying a port. Not true of ghcr.io, so nothing was broken; it is
just a trap for whoever points this at a different registry. Now uses
${tag%:*}, which strips the tag and leaves a port alone:
ghcr.io/morelitea/initiative-github:1.2.3 -> ghcr.io/morelitea/initiative-github
localhost:5000/foo:1.0 -> localhost:5000/foo
The workflow parses, the step passes `bash -n` with Actions expressions
stubbed, and permissions are contents/packages/id-token: write as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1
Contributor
Author
|
Production-readiness review completed with no defect found. The publish job signs the digest-qualified image using keyless cosign after pushing, and the workflow identity permissions are scoped to the job. The remaining operational trade-off is transparency-log/OIDC availability: signing failure correctly blocks publication evidence rather than silently producing an unsigned release. |
Contributor
Author
|
Superseded — reopened with the finding details moved to the private security repository. This repository is public. Tracked as T64. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Threat: T64 (
Morelitea/security, Boundary 11). Severity Medium. NIST SR-4, SR-11, SI-7.Work item: WI-12.
Why
Nothing this organization publishes is signed, so nothing downstream can tell an image this workflow built from one someone else pushed to the same tag.
initiative_infra'sPLAN.mdrecords that signing has to start in the app repos' publish workflows before Kyverno can verify anything. The admission policy is blocked on this, not the reverse — which is why this repository is a reasonable place to start.Keyless, deliberately
cosign exchanges the job's OIDC token for a short-lived certificate from Fulcio and records the signature in the public Rekor log.
No private key exists to hold, rotate or lose. That matters here specifically: the alternative is another long-lived secret in a repository whose threat model already notes the GitHub App private key and the encryption key living as plain environment variables. Adding a third would be a poor trade for a supply-chain control.
The certificate carries the workflow identity, so a verifier can require that this repository's release workflow produced the image — rather than merely someone with push access to the registry. That is the property a signature is actually for.
Signed by digest
A tag moves; a digest is what was built. One signature covers every tag pointing at it, so
latestand the semver tags all verify without signing three times.Verifying one, once this ships
Verification
Workflow parses; the job carries
id-token: write; the build step exposes its digest; the generated shell passesbash -nwith the Actions expressions stubbed; and every action reference including the new one is pinned to a commit SHA.The signing itself runs only on a tag push, so it is unexercised until the next release. That is the honest limit — the first release is the test.
A note from building this
My first attempt branched from a stale local
mainand reported eight unpinned actions, which looked like #6 having been reverted. It had not —origin/maincarries all eight pins. Rebuilt on the real tip. Worth mentioning only because "the pinning disappeared" would have been an alarming thing to report and would have been wrong.🤖 Generated with Claude Code
https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1