Skip to main content

Plumber Score

The signed score push

A score anyone can verify

On GitHub Actions, the Plumber CLI signs the score it publishes. A signed score brings:

  • Proof of origin. Anyone can check that a published score came from your repository’s own workflow, at a given commit, in a given run. Not from a laptop, not from a fork.
  • Nothing to manage. No signing key to create, store or rotate, on your side or on Plumber’s. The job’s GitHub identity is the key.
  • A public record. Each signed push is recorded in Sigstore’s transparency log. The CLI prints the log index, and you can look the entry up at any time.
  • Verified organization pages. For an official organization, only signed pushes from the locked workflow count toward the organization’s verified grade and attestation.

Pushes that are not signed keep working exactly as before.

When the push is signed

The CLI signs when all three hold:

  • the run publishes a score (score-push: true, or --score-push);
  • the destination is score.getplumber.io. A self-hosted service that verifies bundles opts in with PLUMBER_SCORE_SIGN=true;
  • the job runs on GitHub Actions with permissions: id-token: write.

Otherwise the push goes out unsigned. GitLab pipelines and local runs publish unsigned. Without score-push, the CLI makes no request at all.

What happens

Sequence diagram of the signed score push: the CLI builds the report, mints two GitHub OIDC tokens, fetches the Sigstore trusted root, obtains a Fulcio certificate, signs the report, records the signature in Rekor, and posts the report with the bundle to the score service
  1. The report. The CLI builds the report it publishes, with three provenance keys: cliVersion, configFileSha256 (the sha256 of your committed .plumber.yaml) and ci (runId, runAttempt, runUrl).

  2. Token A. A GitHub OIDC token for the score service. It authenticates the push, as before.

  3. Token B. A GitHub OIDC token for Sigstore. GitHub fills in the repository, owner, ref, commit, workflow, trigger and runner. The job cannot edit it.

  4. A one-time key pair, generated in memory and discarded once the bundle exists.

  5. The trusted root. The Sigstore public keys, fetched through TUF and cached.

  6. The certificate. Fulcio checks token B and issues a certificate, valid about ten minutes, carrying the job identity.

  7. The signature, over the exact bytes of the report, with the one-time key.

  8. The log entry. Rekor records the hash, the signature and the certificate, and returns the log index, a signed timestamp and an inclusion proof.

  9. The bundle. Certificate, log entry and signature, in one Sigstore bundle.

  10. The push. The report is posted with the bundle in the Plumber-Sigstore-Bundle header. The CLI prints:

    ✓ Plumber Score published: https://score.getplumber.io/github.com/ORG/REPO
    ✓ report signed (Rekor index 3648963887)
  11. The service verifies the bundle offline, reads the identity from the certificate and, for an official organization, checks the workflow, the configuration and the coverage before counting the result.

Time budget and failure

Signing never fails the run. The whole of steps 4 to 9 is capped at 60 seconds: 15 seconds per Fulcio or TUF request, 30 seconds and two retries for the Rekor upload.

Any failure prints one warning and the push goes out unsigned:

⚠️ report not signed (sign the report: Post "https://rekor.sigstore.dev/api/v1/log/entries": giving up after 1 attempt(s): context deadline exceeded); pushed without a Sigstore bundle (the run is not failed)

For an official organization, that run is stored as unverified and the organization page keeps the last verified result until the next signed run.

What the signature proves

It proves where the bytes came from: this workflow, at this commit, on this GitHub-hosted runner, in this run attempt, produced exactly this report.

  • The identity comes from GitHub’s token, which the job can request but not edit.
  • The signature covers the pushed bytes: one changed byte fails it.
  • The log entry is public and time-stamped: a bundle without it is refused.

It does not prove that the report is a correct analysis. A job that tampers with the CLI signs its tampered output with a valid identity. That is why an official organization must run the locked workflow: a fixed set of pinned actions, one Plumber step at a released version, hosted runners only.

The log entry is public

The Rekor entry names the repository, the workflow path, the commit and the sha256 of the report. It never contains the report itself.

Check a run yourself

Set PLUMBER_SCORE_PAYLOAD_OUT=<path> before the run: the CLI writes the exact published bytes there. Then compare the hash the log recorded with the hash of that file, using the index the CLI printed:

Terminal window
idx=3648963887
curl -sSf "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=$idx" \
| python3 -c 'import json,base64,sys; e=json.load(sys.stdin); u=next(iter(e)); print(json.loads(base64.b64decode(e[u]["body"]))["spec"]["data"]["hash"]["value"])'
sha256sum payload.json

The two values are equal. rekor-cli get --log-index $idx prints the same entry with the certificate, whose extensions name the repository, the commit and the workflow that signed it.

Reference

ItemValue
Request headerPlumber-Sigstore-Bundle, the Sigstore bundle, base64-encoded, at most 64 KiB
Report keyscliVersion, configFileSha256, ci.runId, ci.runAttempt, ci.runUrl
Signed forscore.getplumber.io, or any endpoint when PLUMBER_SCORE_SIGN=true
WhereGitHub Actions with permissions: id-token: write; GitLab and local runs publish unsigned
Identity providerGitHub Actions OIDC
Certificate authorityhttps://fulcio.sigstore.dev, 15 s per request, one retry
Transparency loghttps://rekor.sigstore.dev, 30 s per request, two retries
Overall cap60 s for the whole signing, then the push goes out unsigned
Exact pushed bytesPLUMBER_SCORE_PAYLOAD_OUT=<path>
On failureOne warning, unsigned push, the run never fails