Pular para conteúdo

AUR-551 — SBOM e imagem assinados com Sigstore/Cosign

Status

Implemented in internal/supplychain (the Cosign-invoking signer, digest-ref validation), internal/config/qualitygates.go (quality_gates.supply_chain config, closed engine vocabulary, central-policy precedence already carried by ApplyCentralPolicy) and cmd/aurumcode/aur551.go (the aurumcode sign subcommand, additive to review/fix/sbom). Behavior proof: internal/supplychain/signer_test.go and cmd/aurumcode/aur551_test.go::TestAUR551*; central-policy precedence proof: internal/config/qualitygates_test.go::TestApplyCentralPolicySupplyChainPrecedence. Acceptance: tests/acceptance/AUR-551.sh.

Problem

AUR-549 generates a CycloneDX SBOM and AUR-550 uploads it to Dependency-Track, but neither card signs anything: a tampered SBOM, or an image pushed under the same digest-pinned reference by someone other than this pipeline, would be indistinguishable from the genuine artifact downstream. The corporate adoption RFC (kept out of this public repository) asks for Sigstore/Cosign signatures on both the SBOM and the artifact image, verifiable afterwards, with signing failure never silently treated as "signed".

Design

internal/supplychain.Signer shells out to an external cosign binary (never a crypto library this project links, never a key AurumCode manages) for exactly three calls: cosign sign-blob (the SBOM), cosign verify-blob (used by tests and the real proof below, never by production signing itself) and cosign sign (the image). Every failure is wrapped naming the artifact being signed/verified — internal/supplychain/signer.go's own doc comment spells out why (AC-002: "o parecer diz qual artefato ficou sem assinatura"). ValidateArtifactRef refuses any image reference that is not pinned by a full 64-hex-character sha256 digest; the same check is duplicated (not imported, to avoid a package cycle with internal/config) as validateImageDigestRef in internal/config/qualitygates.go, so a bad reference is refused both at config-load time (quality_gates.supply_chain. artifacts) and again if a caller bypasses config entirely and calls internal/supplychain directly with a CLI-supplied --image.

quality_gates.supply_chain (internal/config.SupplyChainConfig) is a pointer field on the existing QualityGatesConfig, exactly like Sast and SsorDtrack: nil means "not declared at all", and ApplyCentralPolicy (central.go) already governed it per-section before this card — the pointer and the three independent per-subsection if central.QualityGates.X != nil blocks pre-existed in central.go; this card only added the struct's fields (Engine, SignSBOM, SignArtifacts, Artifacts) and its own Validate(). Validate rejects a declared section whose engine is anything other than the literal "cosign" (including the empty string), and any artifacts entry that is not digest-pinned — both fail closed at config-load time, before any cosign call.

aurumcode sign (cmd/aurumcode/aur551.go) resolves the effective config the same way aurumcode sbom does (loadEffectiveConfig, reused from aur549.go), then:

  • exits 0 immediately, before resolving --cosign-bin or touching a file, if quality_gates.supply_chain is not declared at all (AC-003);
  • validates the section, then signs the SBOM (--sbom, falling back to quality_gates.ssor_dtrack.sbom_generator.output_file when sign_sbom is on and no --sbom was given) and/or the image(s) (--image, repeatable, falling back to quality_gates.supply_chain.artifacts when sign_artifacts is on);
  • fails unconditionally, naming the artifact, on any signing error — no gate.inconclusive softening. This is deliberately different from aurumcode sbom's own reportSBOMFailure: the card's own AC-002 names no warn mode ("falha de assinatura reprova a etapa"), so a signing failure is always a non-zero exit.

Keyless signing (no --key) relies on cosign's own ambient OIDC flow (Fulcio/Rekor); production CI never manages a private key for this. Tests and the real proof below always supply a local ephemeral key instead, so they run offline with no cosign network dependency at all.

CI wiring

.github/workflows/review.yml installs cosign via sigstore/cosign-installer, pinned by commit SHA (6f9f17788090df1f26f669e9d70d6ae9567deba6, tag v4.1.2 as of this card), with cosign-release: 'v3.1.3' — a fixed Cosign release, never latest, matching the version used for the real proof below so the pinned CI binary and the one this proof exercises are the same. The "Sign SBOM and image (Cosign, AUR-551)" step runs unconditionally right after the SBOM step and reuses aurumcode sign's own no-op decision, exactly like the SBOM step already reuses aurumcode sbom's. Signing in CI is keyless (GitHub Actions' own OIDC identity); this reusable workflow never declares a permissions: block (the same workflow_call constraint already documented for security-events: write), so a caller that wants sign_sbom/sign_artifacts must itself grant id-token: write to the job that calls this workflow. action.yml (the standalone Docker action) does not run aurumcode sign, for the same docker run-from-inside-a-container path-translation reason AUR-549 already documents for aurumcode sbom, plus the missing OIDC id-token a container-only action has no step to request.

Real proof (AC-001): cosign, pinned by digest, signing and verifying a SBOM offline

Image resolved via gh api repos/sigstore/cosign/releases/latest (tag v3.1.3, not a draft/prerelease) and pulled to resolve its digest:

$ docker pull ghcr.io/sigstore/cosign/cosign:v3.1.3
...
Digest: sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8
Status: Downloaded newer image for ghcr.io/sigstore/cosign/cosign:v3.1.3

cosign sign-blob --help was run against this exact digest first, because cosign v3 changed flag defaults from the version the card's own prose ("--tlog-upload=false") assumed: --tlog-upload is deprecated (warns, but still works) and --use-signing-config=true is now the default, which requires --bundle and a writable $HOME for a TUF client that still tries to reach the network even with a local key. The commands below were adjusted to what the pinned binary's own --help confirmed works offline.

Ephemeral key pair, generated fully offline, no network:

$ docker run --rm --network none -e COSIGN_PASSWORD= \
    -v "$SCRATCH":/w -w /w --user "$(id -u):$(id -g)" \
    ghcr.io/sigstore/cosign/cosign@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8 \
    generate-key-pair
Private key written to cosign.key
Public key written to cosign.pub

Sign the example SBOM (sbom.json, a minimal CycloneDX document), with --tlog-upload=false and --use-signing-config=false so no Rekor upload or TUF fetch is attempted — --network none on the container proves it:

$ docker run --rm --network none -e COSIGN_PASSWORD= -e HOME=/w/home \
    -v "$SCRATCH":/w -w /w --user "$(id -u):$(id -g)" \
    ghcr.io/sigstore/cosign/cosign@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8 \
    sign-blob --key cosign.key --yes --tlog-upload=false --use-signing-config=false \
    --bundle sbom.json.bundle sbom.json
Flag --tlog-upload has been deprecated, prefer using a --signing-config file with no transparency log services
Using payload from: sbom.json
Signing artifact...
Wrote bundle to file sbom.json.bundle
$ echo "EXIT:$?"
EXIT:0

Verify the bundle offline, with no tlog/SCT check (the exact offline counterpart of the card's own --insecure-ignore-tlog):

$ docker run --rm --network none -e HOME=/w/home \
    -v "$SCRATCH":/w -w /w --user "$(id -u):$(id -g)" \
    ghcr.io/sigstore/cosign/cosign@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8 \
    verify-blob --key cosign.pub --bundle sbom.json.bundle \
    --insecure-ignore-tlog --insecure-ignore-sct sbom.json
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the blob.
Verified OK
$ echo "EXIT:$?"
EXIT:0

internal/supplychain.Signer (SignBlob/VerifyBlob) builds exactly this argv shape (--key, --bundle, --tlog-upload=false --use-signing-config=false when Options.SkipTransparencyLog is set for signing, --insecure-ignore-tlog --insecure-ignore-sct for verifying) — see signer_test.go's fake-cosign tests for the argv assertions that pin this shape in code.

The downloaded image was removed after this proof (docker rmi ghcr.io/sigstore/cosign/cosign@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8), and the ephemeral key pair and scratch files used for this run were discarded (never committed) — this project manages no Cosign key.

Non-goals

Managing keys outside Cosign's own mechanism; signing third-party artifacts; keyless (OIDC) verification (the real proof above only covers the key-based path, which is what this card's own acceptance script can run with no network and no GitHub Actions identity token).

Known gap

.board/bootstrap/locks/scanners.yml has no Cosign entry (it currently locks only gitleaks/trivy/semgrep/shellcheck). That file is in this card's read_paths, not its paths, so it was not edited here; the CI pin lives instead in .github/workflows/review.yml itself (cosign-installer's commit SHA plus a fixed cosign-release). A follow-up card should add a supplychain_scanner_* entry to scanners.yml for consistency with how every other externally-pinned tool in this project is locked.