Skip to content

Signing attestations

A statement from scout check --output attestation is a claim about a server, made to be handed to a gateway, a registry or an auditor that was not there when the run happened. Two questions have to be answered before anyone acts on it, and they are answered by different tools:

Question Answered by
Who made this statement, and has it changed since? the signature: cosign verify-blob
What does it say, and is that acceptable? the contents: scout verify

scout does not sign and does not check signatures (ADR 0010). A Sigstore verifier is a certificate chain, a transparency log with inclusion proofs and a timestamp authority; written by hand it would be the largest and least reviewed part of the binary, and every gateway that would verify a scout statement already runs cosign. The statement is a file, so it is signed as one.

In GitHub Actions, without a key

The signing identity is the workflow itself. Nothing is stored and nothing expires.

permissions:
  contents: read
  id-token: write   # the OIDC token cosign exchanges for a certificate

jobs:
  attest:
    runs-on: ubuntu-latest
    steps:
      - uses: sigstore/cosign-installer@v3
      - run: |
          scout check https://mcp.example.com/mcp --token-env MCP_TOKEN \
            --output attestation > attestation.json || test $? -eq 2
          cosign sign-blob --yes --bundle attestation.sigstore.json attestation.json
        env:
          MCP_TOKEN: ${{ secrets.MCP_TOKEN }}

|| test $? -eq 2 keeps a run that found failures: a statement that says "this server fails two checks" is exactly as worth signing as one that says it passes. Exit 1 — the run could not be made — still stops the job.

Keyless signing writes an entry to Sigstore's public transparency log. The entry holds the certificate, which names the repository and workflow, and the SHA-256 of the statement file. It does not hold the statement, so the endpoint and the verdicts stay wherever you put attestation.json.

To keep credentials and signing apart, run the diagnostic in one job and sign in another that never sees the server — the split Reports describes.

Verifying

Signature first, then contents. The second step is only worth running on a statement that passed the first.

cosign verify-blob attestation.json \
  --bundle attestation.sigstore.json \
  --certificate-identity-regexp '^https://github.com/ORG/REPO/\.github/workflows/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

scout verify attestation.json --endpoint https://mcp.example.com/mcp --max-fail 0

Pin the identity as tightly as your process allows: a workflow file and a ref (…/attest.yml@refs/heads/main) rather than the whole repository, if only that workflow should be able to vouch for a server.

A statement changed after signing fails the first command. A statement signed by the right workflow and about the wrong server fails the second, through --endpoint, which recomputes the subject digest rather than comparing names.

With a key, offline

Where there is no OIDC identity, or nothing may leave the network, sign with a key and no transparency log. cosign 3 takes the services it uses from a signing configuration, so one that lists none is how to be sure nothing is uploaded:

cat > local-signing-config.json <<'EOF'
{"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json",
 "caUrls":[],"oidcUrls":[],"rekorTlogUrls":[],"tsaUrls":[],
 "rekorTlogConfig":{"selector":"ANY"},"tsaConfig":{"selector":"ANY"}}
EOF

cosign sign-blob --yes --key cosign.key \
  --signing-config local-signing-config.json \
  --bundle attestation.sigstore.json attestation.json

cosign verify-blob attestation.json --key cosign.pub \
  --bundle attestation.sigstore.json --insecure-ignore-tlog

--insecure-ignore-tlog is what it says: without a log, nobody but the key holder can see that a signature was made, and a compromised key signs silently. It is the right trade inside a closed network, and the wrong one for a statement published outside it.

Not cosign attest-blob, not gh attestation

Both wrap a predicate in a new in-toto statement whose subject is a file's digest. A scout statement is already a complete in-toto statement, and its subject is the server — a digest of the transport and endpoint — not a file. Wrapping it would nest one statement inside another and replace the server with the digest of a JSON file, and gh attestation verify, which recomputes the subject from a file or an image, has nothing to recompute it from. Sign the statement as a blob.