Skip to content

feat(score): the score push is signed keyless, the report names its CLI version, config file hash and run - #584

Open
thomasboni wants to merge 5 commits into
mainfrom
feat/signed-score-push
Open

thomasboni wants to merge 5 commits into
mainfrom
feat/signed-score-push

Conversation

@thomasboni

@thomasboni thomasboni commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What changes

  • The JSON report names its provenance. Three new keys: cliVersion (the build's version, the Go module pseudo-version for an unstamped build from a clone), configFileSha256 (the sha256 of the .plumber.yaml bytes the run used, empty when the built-in default applies) and ci (on GitHub Actions: runId, runAttempt, runUrl).
  • A keyless Sigstore signer for the score push on GitHub Actions. plumber analyze --score-push signs the exact bytes it posts: a Fulcio certificate for the job's OIDC identity and a Rekor transparency log entry.
  • The push carries the bundle. The Sigstore bundle travels in the Plumber-Sigstore-Bundle request header. A signing failure only warns (report not signed (...); pushed without a Sigstore bundle) and the push goes on unsigned.
  • A stderr line. After ✓ Plumber Score published: ..., a signed push prints ✓ report signed (Rekor index N).
  • PLUMBER_SCORE_PAYLOAD_OUT=<path> writes the exact pushed bytes, so a reader can hash them against the Rekor entry.
  • Signing is time-bounded. The Sigstore TUF trusted-root fetch uses a fetcher whose HTTP client carries the 15s push timeout (the go-tuf default client has none), and the whole signing (id-token mint, TUF fetch, Fulcio, Rekor) is capped at 60s by the caller, which returns at the deadline whatever the inner legs do, so a slow Sigstore costs a warning, never a hung run.
  • Signing is limited to the hosted endpoint. The push is signed only when it goes to https://score.getplumber.io, or when PLUMBER_SCORE_SIGN=true names a self-hosted score service that verifies the bundle. Anywhere else the signer is never built: no warning, no id-token mint, no TUF fetch, no Rekor entry, and the push is the same request as before.
  • Dependency: github.com/sigstore/sigstore-go v1.3.0, pinned under the current Go floor (go 1.26.0 stays; v1.4.0 requires Go 1.27). Binary size: 51,064,598 to 61,790,749 bytes, a delta of 10,726,151 bytes (about 10.2 MiB). Two indirect bumps keep the scanners clean: sigstore/rekor v1.5.4 (its PGP support moved to ProtonMail/go-crypto, which drops golang.org/x/crypto/openpgp, GO-2026-5932; v1.5.5 would need Go 1.27) and go-jose/v4 v4.1.6 (GHSA-mgcc-cww5-fp2q and two Mediums).
  • Docs: the README section "Signing on GitHub Actions" documents the signed push, the header, PLUMBER_SCORE_PAYLOAD_OUT and PLUMBER_SCORE_SIGN; docs/GITHUB_ISSUES.md documents the provenance keys.

Why

The hosted score service is getting an organization trust page: a public, organization-level attestation of Plumber scores that holds without any server-side re-run, following the OpenSSF Scorecard model. The organization's own Actions run signs its result keyless; the service verifies the Rekor entry and the Fulcio certificate, which bind the exact result bytes to the job's repository, ref, commit and workflow, instead of trusting the pushed body. The provenance keys tell a reader which CLI version, which configuration file and which run produced a score.

Behaviour unchanged

  • Standalone outside CI: no push, nothing signed (the existing --score-push skipped note).
  • GitLab score push: nothing is signed, the push is the same request as before.
  • Platform mode (--platform): nothing is signed, the platform push is unchanged (nil bundle).

The CHANGELOG is generated by semantic-release and was not edited.

Verification

  • make lint (0 issues), make deadcode (only the pre-existing configuration/schema.go ConfigSchemas finding), go vet ./..., go test ./... -count=1 and govulncheck ./... all pass on the head; grype --only-fixed finds nothing in the built binary.
  • The first GitHub run and the GitLab run below built the branch at 5e6eae2; the GitHub signed push was re-run at the final head 3c3097c with a committed .plumber.yaml (see "Final review fixes").
  • GitHub standalone, signed push: run https://github.com/getplumber/plumber-lab-github/actions/runs/41149439727 on the private lab repository, building this branch at 5e6eae2. The job printed ✓ Plumber Score published: https://score.getplumber.io/github.com/getplumber/plumber-lab-github and ✓ report signed (Rekor index 3606093578). Then sha256 of the PLUMBER_SCORE_PAYLOAD_OUT bytes = the Rekor entry's hashedrekord hash = 5664b065dc8c4e2f74e40b0807dbd216de38269a60ccbf58cb460edd392977a9. The report carried cliVersion 0.0.0-20261031001606-5e6eae2316f6, an empty configFileSha256 (no .plumber.yaml in the lab) and the ci block with run id 41149439727.
  • Certificate identity read from the Rekor entry (the values the hosted verifier reads):
    • SAN URI: https://github.com/getplumber/plumber-lab-github/.github/workflows/signed-score-push-e2e.yml@refs/heads/trust-e2e
    • Issuer (1.3.6.1.4.1.57264.1.8): https://token.actions.githubusercontent.com
    • Repository (1.12): https://github.com/getplumber/plumber-lab-github
    • Ref (1.14): refs/heads/trust-e2e
    • Commit (1.13): 613c43bd4b5ccf516b9532cd48ace1666fbeba43
  • GitLab platform mode, no regression: this branch's linux/amd64 build (sha256 0b60cc82...abb9, checked in the job) ran through the Plumber component in platform mode on a self-managed GitLab: pipeline 6591, job 8514 succeeded. The log shows ✓ Results pushed to the platform followed by the platform's verdict (A 92 / 100), and no report signed or not signed line.

Final review fixes

  1. Signing limited to the hosted endpoint (PLUMBER_SCORE_SIGN=true opts a self-hosted service in); a push elsewhere never builds the signer and sends no header. Tests: TestMaybePushScore_SelfHostedEndpointNeverSigns, TestMaybePushScore_SelfHostedOptInSigns, TestScoreSigningWanted, TestSignScorePayload_HostedEndpointSigns.
  2. Unit tests never reach the public Sigstore: TestMain defaults the signer seam to noSigner. The cmd package passes with only loopback networking (unshare -rn, lo up), and a full go test ./... no longer recreates ~/.sigstore/root.
  3. 64 KiB header guard: a bundle whose base64 form exceeds 64 KiB is dropped with one warning (Sigstore bundle too large (...), pushed without it) and the push goes on. Test: TestMaybePushScore_OversizeBundleDroppedWithWarning (70 KiB stub bundle).
  4. scoreSignTimeout is an overall cap: Sign runs in a goroutine and the caller selects on the context, so it returns at the deadline even when an inner leg ignores the context. Test: TestSignScorePayload_TimeoutIsAnOverallCap (a signer that honors the context and one that does not, 50ms deadline).
  5. e2e re-run at the final head with a committed .plumber.yaml: run https://github.com/getplumber/plumber-lab-github/actions/runs/41154951511 (CLI 0.0.0-20261031011525-3c3097ce3244) printed ✓ Plumber Score published: ... and ✓ report signed (Rekor index 3607100154); the PLUMBER_SCORE_PAYLOAD_OUT sha256 equals the Rekor hash, 45f3e2fefa20b0fc1d93fc6d5930018b2d3afed1ebfb3d819edc72ba1f0058c2; the report's configFileSha256 dcf95764c96c4cbb171243cd402310e1c715774b1ef986539e6524f9b7d256ee equals sha256sum .plumber.yaml in the lab commit. The attempt just before it (run 41154708555) met a slow public Rekor (a plain GET took 15.3s at the time): it printed report not signed (... rekor.sigstore.dev ... context deadline exceeded); pushed without a Sigstore bundle and the score was still published, the warning path working as designed.

🤖 Generated with Claude Code

https://claude.ai/code/session_0155zD4xr5A8b2CUxqdKMB3U

Comment thread cmd/score_sign.go Resolved
Comment thread cmd/report_provenance.go Resolved
Comment thread cmd/report_provenance.go Resolved
Comment thread cmd/report_provenance.go Resolved
@thomasboni
thomasboni force-pushed the feat/signed-score-push branch from 5e6eae2 to bc92e41 Compare October 31, 2026 00:40
Comment thread cmd/report_provenance_test.go Resolved
thomasboni and others added 2 commits October 31, 2026 00:49
… JSON report

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155zD4xr5A8b2CUxqdKMB3U
@thomasboni
thomasboni force-pushed the feat/signed-score-push branch from bc92e41 to 64fbc06 Compare October 31, 2026 00:49
Comment thread cmd/score_push_test.go Resolved
@thomasboni
thomasboni force-pushed the feat/signed-score-push branch from 64fbc06 to a92ea46 Compare October 31, 2026 00:57
thomasboni and others added 3 commits October 31, 2026 01:14
…ilure only warns

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155zD4xr5A8b2CUxqdKMB3U
The bytes the CLI signs and posts are the publish payload
(buildPublishPayload, which drops analyzedCiConfig), not the --output
file, so nothing on disk let a user or an e2e verify the Rekor entry
against what was pushed. When PLUMBER_SCORE_PAYLOAD_OUT names a
non-empty path, maybePushScore now writes the payload verbatim there
(0o600, overwriting) right before the POST, whether or not signing
succeeded. A write error is one scoreWarn line and never stops the
push. Env only, no flag: this is a verification aid, not a CI input.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155zD4xr5A8b2CUxqdKMB3U
…CORE_PAYLOAD_OUT

Document the GitHub Actions signing path (Sigstore id-token, Fulcio
certificate, Rekor log entry), the Plumber-Sigstore-Bundle request
header (ignored by a self-hosted score service that does not know
it), the printed Rekor index line, and PLUMBER_SCORE_PAYLOAD_OUT for
verifying a push against its Rekor entry. Document cliVersion,
configFileSha256 and ci in the JSON output schema reference. The
link to the official organizations page of the docs site is not
written yet, per the monorepo spec; refer to the page without a URL.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155zD4xr5A8b2CUxqdKMB3U
@thomasboni
thomasboni force-pushed the feat/signed-score-push branch from a92ea46 to 3c3097c Compare October 31, 2026 01:18
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