Skip to content

fix(security): confirm a Rekor 409 by verifying it, sign before the rollout, publish release artifacts only after the attestation gate - #390

Open
Cre-eD wants to merge 6 commits into
mainfrom
fix/rekor-conflict-and-attestation-gate
Open

fix(security): confirm a Rekor 409 by verifying it, sign before the rollout, publish release artifacts only after the attestation gate#390
Cre-eD wants to merge 6 commits into
mainfrom
fix/rekor-conflict-and-attestation-gate

Conversation

@Cre-eD

@Cre-eD Cre-eD commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Four related supply-chain fixes. The first two break deploys today; the third
is why nobody notices when they do; the fourth is that "nothing is published"
was not true for images.

A review pass over the first three reproduced two of its findings
empirically. Everything it raised is addressed here; the mechanism in
section 1 changed substantially as a result. See Review round 2 at the end
for the finding-by-finding list.

1. A Rekor 409 on an already-attested image now succeeds

Redeploying an unchanged image digest regenerates a byte-identical SBOM or
provenance predicate. The transparency log already holds that exact entry, so
it answers 409 and the deploy fails on an artifact that is already in the state
the deploy wanted:

cosign attest failed: signing bundle: [POST /api/v1/log/entries][409]
createLogEntryConflict {"code":409,"message":"an equivalent entry already
exists in the transparency log with UUID ..."}
Error: failed to attach SBOM: cosign attest failed: exit status 1

Retrying cannot clear it. Under key-based signing the body is deterministic, so
every attempt reproduces the same conflict and the loop only delays the failure.

RunCosignWithRetry already refused to read a 409 as success, and its reasoning
was right: cosign uploads to Rekor before it pushes to the registry, so a tlog
entry is not proof the artifact was attached. Rather than drop that guarantee,
this closes the gap. On a conflict the loop asks the registry directly, with
cosign verify --key/--certificate-identity-regexp for a signature and
cosign verify-attestation --type <type> ... for an attestation. Artifact
present and verifying means the run is already done, so it returns success
immediately without burning the rest of the retry budget. Otherwise the
previous retry-then-report behaviour is untouched.

Verify, not download. The first draft of this probe used cosign download signature / cosign download attestation. Two problems, both raised in review
and one reproduced against a local registry with cosign v3.0.2:

  • cosign v3 defaults to the new bundle format and writes nothing to the legacy
    sha256-<digest>.sig tag. On a signed image, cosign download signature
    returns no signatures associated while cosign verify --key succeeds. The
    download probe is therefore a permanent false negative on current cosign, so
    every conflict would run to exhaustion and fail. Combined with fix 2 below,
    which makes signing a hard predecessor of the Deployment, the exact case this
    PR exists to fix would have degraded from "rolls out, run is red" to "does not
    roll out at all".
  • download answers presence, not identity. Any entry under the legacy tag
    counted, so a signature made under a rotated key, or an attestation of the
    same predicate family written by an unrelated workflow, read as proof.
    security.Signing.Verify is opt-in, so nothing downstream re-checked.

verify reads both formats and checks the identity, so a confirmation now means
the artifact is present and ours. The probe's exit code is the whole signal:
cosign prints its verification banner to stderr and nothing to stdout, so the
earlier "clean exit and non-empty stdout" rule was itself a way never to
confirm.

The confirmation stays fail-closed. A probe that errors, times out, or cannot be
built at all leaves the caller on retry-then-report, so it can never convert a
genuine signing failure into a green deploy. It is only built when the config
can name an identity to verify against; without one the caller keeps the plain
retry path rather than accept an artifact it cannot attribute. The predicate
type carries through from the attest invocation, otherwise an existing SBOM
attestation would stand in for a missing provenance one.

The probe is bounded at 30s rather than inheriting the sign/attest budget.
Reusing it made the worst case per artifact 5 x (timeout + timeout) plus
backoff, roughly 20 minutes for the SBOM attacher against 6 minutes before
confirmation existed, and both attachers run per image. It also no longer
inherits SIGSTORE_ID_TOKEN: it mints no certificate, so its environment is
filtered to registry-relevant variables.

Proving the probe actually fires. A fake cosign cannot certify this: the
fake decides what the probe means, which is exactly why the v3 breakage was
invisible to a green suite. So the suite now has an integration test that starts
a real registry:2, pushes an image, signs it for real, and drives the
confirmation path with the genuine cosign binary, plus the negative half over an
unsigned image. The synthetic part is only the 409 itself, because reaching a
real one would mean uploading to a public transparency log. On the run used to
develop this, that test logged:

cosign v3.0.2
legacy `cosign download signature` on a signed image:
  err=exit status 1 stderr="... no signatures associated"

while the verify probe confirmed. The fake was hardened to match: it validates
the probe argv the way real cosign does (identity flag required, --type
required for verify-attestation, image reference must be the one under test),
answers download the way cosign v3 does, and models probe exit status and
stdout separately so exit-0-empty, auth failure and false-negative are all
expressible. Its probe branch also moved below the delay scripting, so probes
cost simulated time like every other invocation.

Sibling symptom. Concurrent deploys of one digest also hit

signing bundle: Post "https://rekor.sigstore.dev/api/v1/log/entries": ...
giving up after 2 attempt(s)

That is cosign exhausting its own HTTP retries against the log, not a conflict.
It used to fall straight through to the fail-fast branch and break the deploy on
the first hiccup. It is now a second retryable class, matched only on a
log-entries POST so an unrelated give-up from a registry or from Fulcio still
fails fast.

The first draft matched the two halves with (?s) and a 400-character gap,
which only required them to appear near each other in the same stream. Review
confirmed all three of these matched: a log-entries POST returning 201 Created
followed by a registry unauthorized, giving up after 3 attempt(s), the same
followed by a Fulcio signingCert give-up, and the same followed by an OIDC
token give-up. None of them falsely succeeded, but a permanent registry-auth
failure was retried five times and the operator was pointed at the
transparency-log runbook. The gap now excludes quotes and newlines, so the match
stays inside one message; every competing give-up names its own quoted URL
first. All three are regression tests.

Retry budget. With two retryable classes the bound goes from 3 attempts to
5. The jittered full-backoff schedule is unchanged (1s base doubling to an 8s
cap), so the worst case grows from about 3s to about 15s of waiting. That only
applies to the genuinely-contended path now: a conflict on an artifact that is
already attached short-circuits on the first attempt and never sleeps at all.

2. Signing no longer runs after the workload has rolled out

BuildAndPushImage models sign, verify, SBOM attestation and provenance
attestation as resources hanging off the image push and returns their fan-in on
ImageOut.AddOpts. The documented contract is that the workload update depends
on them. The ECS path implements it (aws/ecs_fargate.go folds img.AddOpts
into its options). The Kubernetes path did not: BuildAndPushImages copied
AddOpts onto ContainerImage and nothing read it again, so
DeploySimpleContainer created the Deployment with no dependency edge to any
security operation.

Pulumi was therefore free to update the workload first. Observed ordering in a
failing run: rollout completed at 06:42:04, signing failed at 06:42:26, then

Image Signing         SKIPPED
Provenance Generation SKIPPED
Report Uploads        SKIPPED
error: update failed

Worst of both outcomes: an unsigned, unattested image with no SBOM is already
serving traffic, and CI is red so the next deploy looks like the problem rather
than the symptom.

This is the full fix, not a guard. Folding the per-image options into the
options DeploySimpleContainer builds makes signing a precondition of the
rollout, the same way the ECS path already worked. Images SC did not build, and
stacks with security disabled, contribute no options and deploy exactly as
before. Nothing about the security resources themselves changed.

The regression test drives DeploySimpleContainer under Pulumi mocks with a
stand-in gate resource on AddOpts and asserts the registered Deployment lists
it as a dependency. Reverting the one-line wiring fails it with the gate URN
absent from the dependency list.

3. The attestation chain now blocks a release

In push.yaml every step of the chain is continue-on-error: true and the
collector only emits ::warning title=Attestation incomplete::.... A release
can therefore publish a tarball with no SBOM, no signature and no provenance on
a fully green workflow. docs/SECURITY.md tells consumers the opposite: "Every
release produces signed, attested artifacts". Nothing downstream closes the gap
either, since verify-attestations runs after publish and its own header says
it surfaces the regression "without blocking the release that already shipped".

Which path I took, and why. The soft-fail is scoped in the comments as a
14-day bake-in tracked in a HARDENING.md Phase 2 plan. I looked for evidence
that this is still a live decision and found the opposite:

  • HARDENING.md does not exist in the repository and never has
    (git log --diff-filter=A -- '**HARDENING.md' is empty)
  • no open issue tracks flipping it
  • the soft-fail comments landed on 2026-05-21 at the latest, so a 14-day window
    closed roughly three months ago

So this enforces rather than hiding behind a flag defaulting to today's
behaviour. The three collectors in push.yaml now exit 1: the per-platform
tarball chain, the per-image chain, and the sidecar count assertion guarding the
bundle actually uploaded to the CDN (it runs before publish, so a gap there
blocks the upload).

What the chain does is unchanged. Every sub-step deliberately stays soft, so one
failure does not mask the others and the gate reports the whole picture at once
instead of aborting at the first missing piece.

Break glass, with an expiry. ATTESTATION_SOFT_FAIL degrades all three back
to warnings for the duration of a Sigstore or Rekor outage. It is unset by
default, so the enforcing path is what runs.

Review pointed out that the first draft was an unbounded, unattributed and
invisible bypass: vars.* also resolves ORGANIZATION variables, so one set on
the org silently disabled the gate in every repository that reads it, nothing
printed which scope supplied it, there was no expiry or recorded reason, and a
break-glass release was byte-indistinguishable from a clean one at the consumer
end. So the value is now an ISO-8601 UTC expiry date (YYYY-MM-DD) and:

  • it stops applying on that date whether or not anyone remembers to remove it
  • anything that is not a date, including the historical true, is rejected with
    an ::error and does not bypass
  • every gate echoes the value on every run, pass or fail, so a stale value shows
    up in green runs
  • a bypassed run writes attestation-breakglass=<date> to the job summary
  • an expired value logs a Break-glass expired warning before blocking

docs/SECURITY.md documents the format, the organization-scope caveat and who
may set it.

Sidecar pairing. The bundle assertion compared totals per sidecar kind
against the tarball count, so it passed a bundle in which one tarball carried
two of a kind and another none, which is exactly the shape a half-failed matrix
leg produces. It now pairs each tarball with its own four sidecars, and an empty
sidecar counts as missing.

branch-preview.yaml and build-staging.yml stay warn-only and gain a comment
saying why: neither is a release, no published contract promises their artifacts
carry a complete chain, and blocking them would stall iteration on an upstream
hiccup.

Matrix values in the rewritten gate scripts are read through env rather than
interpolated into the shell.

4. Release image tags are published only after the gate

Fix 3 made the gate block, but for images it blocked too late. The build step
ran docker/build-push-action with push: true and tags: containing both
<img>:latest and :<version>, so the release tags were live before
Sign + attest and before the gate. A failing gate only failed the job and
skipped docker-finalize, which left a mutable :latest pointing at an
unsigned, un-SBOM'd image while dist.simple-container.com still served the
previous version. The tarball path already gates before upload, so this was an
oversight rather than a design choice, and docs/SECURITY.md was left asserting
in writing something that was false for images.

The image now builds to a throwaway :ci-<run>-<attempt> tag, because cosign
signs by digest and needs the manifest in the registry before it can sign
anything. The release tags are applied by a promote step after the gate, and the
throwaway tag is deleted afterwards, including when the gate blocked, since that
is precisely when an unsigned image would otherwise stay reachable under a
predictable name.

Promotion has to preserve the digest, and the obvious tool does not. The
natural shape is docker buildx imagetools create -t <tag> <repo>@<digest>.
Measured against a local registry, given a single platform-specific manifest it
wraps it in a new index: source sha256:dd54c6... came back as
sha256:45b9e3.... The tag would then resolve to a digest that was never
signed, and cosign verify <repo>:latest would fail for every consumer
following the documented verification path. Promotion instead re-PUTs the signed
manifest byte for byte under each release tag, which is what crane tag does
and what the registry v2 API is for, needing nothing on the runner beyond curl
and jq. The step then compares the registry's Docker-Content-Digest against
the signed digest and fails the release if they ever diverge, so a promotion
that stops preserving the digest cannot ship silently.

With this, "the release build fails and nothing is published" is literally true
on both paths, and docs/SECURITY.md says how.

Validation

  • go build ./... clean, go vet ./... clean
  • go test ./...: 48 packages, all pass, 0 failures. The pkg/api/git
    environment failure noted in the first round no longer reproduces (this run
    used a worktree outside /tmp).
  • the integration test in pkg/security/signing was run for real against a
    local registry:2 with cosign v3.0.2: both halves pass, and it is the source
    of the download-versus-verify evidence quoted in section 1. Build-tagged
    integration and skipped under -short, so it does not run in normal CI.
  • every workflow touched YAML-parses; actionlint reports the same finding set
    before and after (6 runner-label for the self-hosted runners, 1 pre-existing
    stack-name expression warning)
  • the registry-API promotion and the break-glass date logic were both executed
    against a local registry / shell before being written into the workflow: the
    promotion preserves the digest across two tags, and true, a malformed value,
    an expired date, today's date and a future date all behave as documented
  • gofmt -l clean on every file in the diff
  • no deploys run, nothing pushed to a registry other than a throwaway local one,
    nothing written to a transparency log

Review round 2

Every finding from that review, and what happened to it.

Finding Status
P0 image tags public before the gate can block them fixed, section 4
P0 idempotency fix does not fire on cosign v3, and the rollout gate turns that into a blocked deploy fixed, section 1 (verify probe)
P1 presence vs identity in the probe fixed by the same change; the probe now checks identity
P1 rekorGiveUpRe over-matches fixed; three confirmed cases added as want: false
P1 probe reuses the full signing timeout fixed; capped at 30s, with a test
P1 ATTESTATION_SOFT_FAIL unbounded and invisible fixed; ISO-8601 expiry, echoed every run, written to the job summary, org-scope documented
P1 the cosign fake cannot catch a broken probe fixed; argv validation, ProbeExit/ProbeStdout, probe below the delay, plus the real-registry integration test
P2 confirmed conflict returns an empty RekorEntry while reporting success fixed; SignResult.Confirmed plus an explicit log line
P2 probe inherits SIGSTORE_ID_TOKEN fixed; environment filtered to an allowlist
P2 sidecar assertion compares totals, not pairs fixed; per-tarball iteration, empty counts as missing
P2 containsURN accepts a suffix fixed; equality
P2 ecs_fargate.go missing the nil guard imageSecurityOpts has fixed
docs drift: SECURITY.md "nothing is published", preview/staging "mirrors push.yaml", preview same-guarantees contradiction, verify-attestations header all four fixed

Two things worth flagging to a human:

  • Found by the new integration test: GenerateKeyPair never honoured its
    outputDir. Without --output-key-prefix, cosign writes the pair into the
    process working directory, so every caller passing a directory failed on the
    chmod that follows, after dropping an encrypted private key into the source
    tree. Fixed, and both filenames are now git-ignored as a second line of
    defence. pkg/security/signing/e2e_test.go has been calling it that way.
  • Out of scope, reported not fixed: the review found pre-existing
    private-organisation references on main of this public repository,
    including a real cloud account id in a test fixture. They are untouched here
    and need their own change plus an owner decision.

Note on #383

#383 is red only because its base predates the go directive move to 1.26.6. A
rebase clears it. Not touched here.

Cre-eD added 3 commits August 23, 2026 00:53
…ot a failed deploy

Redeploying an unchanged image digest regenerates a byte-identical SBOM or
provenance predicate. The transparency log already holds that exact entry, so
it answers 409 createLogEntryConflict, cosign exits non-zero and the deploy
fails on an artifact that is already in the state the deploy wanted:

  cosign attest failed: signing bundle: [POST /api/v1/log/entries][409]
  createLogEntryConflict {"code":409,"message":"an equivalent entry already
  exists in the transparency log with UUID ..."}
  Error: failed to attach SBOM: cosign attest failed: exit status 1

Retrying does not help here. Under key-based signing the body is deterministic,
so every attempt reproduces the same conflict and the loop only delays the
failure.

The existing code deliberately refused to read a 409 as success, on the sound
grounds that cosign uploads to Rekor before it pushes to the registry, so a
tlog entry is not proof the artifact was attached. That reasoning is kept and
the gap is closed instead of ignored: on a conflict the retry loop now asks the
registry directly, with `cosign download signature` or `cosign download
attestation --predicate-type <type>`. Both are read-only, need no verification
key, and work for keyless and key-based signing alike. If the artifact is
there, the run is already done and returns success without burning the rest of
the retry budget. If it is not, the previous retry-then-report behaviour is
unchanged.

The confirmation is fail-closed: only a clean exit AND non-empty output count
as present, so a probe that errors, times out or prints nothing can never turn
a genuine signing failure into a green deploy. The type is carried over from
the attest invocation, otherwise an existing SBOM attestation would stand in
for a missing provenance one.

Also handles the sibling symptom seen when several deploys race on one digest:

  signing bundle: Post "https://rekor.sigstore.dev/api/v1/log/entries": ...
  giving up after 2 attempt(s)

That is cosign exhausting its own HTTP retries against the log, not a conflict,
and it used to fall straight through to the fail-fast branch and break the
deploy on the first hiccup. It is now a second retryable class, matched only on
a log-entries POST so an unrelated give-up from a registry or from Fulcio still
fails fast. With two retryable classes the bound goes from 3 attempts to 5;
the jittered backoff schedule is unchanged.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
… workload updated

BuildAndPushImage models sign, verify, SBOM attestation and provenance
attestation as resources hanging off the image push and returns their fan-in on
ImageOut.AddOpts. The documented contract is that the workload update depends
on them. The ECS path implements that. The Kubernetes path did not:
BuildAndPushImages copied AddOpts onto ContainerImage and nothing ever read it
again, so DeploySimpleContainer created the Deployment with no dependency edge
to any security operation.

Pulumi was therefore free to update the workload first. Observed ordering in a
failing run: the rollout completed at 06:42:04 and signing failed at 06:42:26,
after which the run reported

  Image Signing        SKIPPED
  Provenance Generation SKIPPED
  Report Uploads       SKIPPED
  error: update failed

The practical result is the worst of both: an unsigned, unattested image with
no SBOM is already serving traffic, and the pipeline is red so the next deploy
looks like the problem rather than the symptom.

Fold the per-image options into the resource options DeploySimpleContainer
builds, so signing becomes a precondition of the rollout in the Kubernetes path
too. Images SC did not build, and stacks with security disabled, contribute no
options and deploy exactly as before.

The regression test drives DeploySimpleContainer under Pulumi mocks with a
stand-in gate resource on AddOpts and asserts the registered Deployment lists
it as a dependency. Reverting the one-line wiring fails it with the gate URN
absent from the dependency list.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
The whole attestation chain in push.yaml is continue-on-error and the step that
collects the outcomes only emits

  ::warning title=Attestation incomplete::...

so a release can publish a tarball with no SBOM, no signature and no provenance
on a fully green workflow. SECURITY.md tells consumers the opposite: "Every
release produces signed, attested artifacts". Nothing downstream closes the
gap either. verify-attestations runs after publish and its own header says it
surfaces the regression "without blocking the release that already shipped".

The soft-fail was scoped as a 14-day bake-in. The comments point at a
HARDENING.md Phase 2 plan that is not in the repository and never has been,
there is no open issue tracking the flip, and the bake-in window closed months
ago. Treating it as expired rather than as a live decision.

Turn the three collectors in push.yaml into gates that exit 1: the per-platform
tarball chain, the per-image chain, and the sidecar count assertion that guards
the bundle actually uploaded to the CDN. What the chain does is unchanged.
Every sub-step stays soft on purpose, so one failure does not hide the others
and the gate can report the whole picture at once instead of aborting at the
first missing piece.

Break glass: the repository variable ATTESTATION_SOFT_FAIL=true degrades all
three back to warnings for the duration of a Sigstore or Rekor outage. It is
unset by default, so the enforcing path is the one that runs.

branch-preview.yaml and build-staging.yml stay warn-only and gain a comment
saying why: neither is a release, no published contract promises their
artifacts carry a complete chain, and blocking them would stall iteration on an
upstream hiccup.

Matrix values in the rewritten gate scripts are read through env instead of
being interpolated into the shell.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Semgrep Scan Results

Repository: api | Commit: bbae0ad

Check Status Details
⚠️ Semgrep Warning 2 warning(s), 6 total

Scanned at 2026-08-23 11:25 UTC

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Security Scan Results

Repository: api | Commit: bbae0ad

Check Status Details
✅ Secret Scan Pass No secrets detected
✅ Dependencies (Trivy) Pass 1 total (no critical/high)
✅ Dependencies (Grype) Pass 1 total (no critical/high)
📦 SBOM Generated 523 components (CycloneDX)

Scanned at 2026-08-23 11:26 UTC

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

📊 Statement coverage

Measured on the documented included set (see docs/TESTING.md → Coverage scope). Observe-only — no regression gate is enforced yet.

Scope This PR main baseline Δ
Included set (Gold-tier denominator) 90.5% 90.5% +0.0 pp
Full set (whole repo, transparency) 29.6% 29.1% +0.5 pp

Baseline: main @ fccdc83

Cre-eD added 3 commits August 23, 2026 15:24
…n download

The idempotency fix earlier in this branch never fires on current cosign.
cosign v3 defaults to the new bundle format and writes nothing to the legacy
sha256-<digest>.sig tag, so `cosign download signature` answers "no signatures
associated" and `cosign download attestation --predicate-type ...` answers
"found no attestations" for an image that `cosign verify` and
`cosign verify-attestation` both accept. Reproduced against a local registry
with cosign v3.0.2, same image, same run.

The consequence was worse than the bug being fixed. Every conflict ran the
retry loop to exhaustion and the sign or attest step failed; with the rollout
now gated on the sign/attest fan-in, a redeploy of an unchanged digest stopped
rolling out at all instead of rolling out with a red run.

Probe with verify instead. That also closes presence versus identity: the old
probe accepted any entry under the legacy tag, so a signature made under a
rotated key, or an attestation of the same predicate family written by an
unrelated workflow, read as proof. verify checks the identity, and the probe's
exit code is the whole signal, because cosign prints its banner to stderr and
nothing to stdout.

A probe is built only when the config can name an identity to verify against.
Without one the caller keeps the retry-then-report path rather than accept an
artifact it cannot attribute. Config.CreateSigner now plumbs the certificate
identity into KeylessSigner so the path is live in production keyless runs.

Also here:

- Bound the probe at 30s instead of reusing the sign/attest timeout. Worst case
  per artifact was 5 x (timeout + timeout) plus backoff, roughly 20 minutes for
  the SBOM attacher against 6 minutes before confirmation existed, and both
  attachers run per image.
- Filter the probe environment to registry-relevant variables. It mints no
  certificate, so it had no business holding SIGSTORE_ID_TOKEN.
- Tighten rekorGiveUpRe. (?s) with a 400-character gap only required the two
  halves to appear near each other in one stream, so a log-entries POST that
  returned 201 followed by a registry, Fulcio or OIDC give-up was classified as
  a transparency-log hiccup, retried five times, and sent the operator to the
  wrong runbook. All three cases are now regression tests.
- Report a confirmed conflict on SignResult.Confirmed and say in the log that
  no transparency-log entry URL exists, instead of returning a result that
  claims success with an empty RekorEntry.
- Teach the cosign fake to validate the probe argv the way real cosign does,
  answer `download` the way cosign v3 does, and model probe exit status and
  stdout separately. Answering on $1 alone is why the breakage above was
  invisible to the suite. The probe branch now sits below the delay scripting,
  so probes cost simulated time like every other invocation.
- Add an integration test that pushes to a real local registry, signs for real,
  and drives the confirmation path with genuine cosign, including the negative
  half over an unsigned image. It records what the legacy download path returns
  on the installed version. Build-tagged and skipped under -short.
- Fix GenerateKeyPair, found by that test: without --output-key-prefix cosign
  writes the pair into the process working directory rather than outputDir, so
  every caller passing a directory failed on the chmod that follows, after
  dropping an encrypted private key into the source tree. Ignore those two
  filenames as a second line of defence.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
docker/build-push-action pushed :latest and :<version> with push: true, so both
were live before Sign + attest and before the attestation gate. A failing gate
only failed the job and skipped docker-finalize, which left a mutable :latest
pointing at an unsigned, un-SBOM'd image while the dist site still served the
previous version. The tarball path already gates before upload, and SECURITY.md
already told consumers "the release build fails and nothing is published",
which was false for images.

Build to a throwaway :ci-<run>-<attempt> tag, because cosign signs by digest
and needs the manifest in the registry first, then promote the release tags
after the gate and delete the throwaway tag.

Promotion re-PUTs the signed manifest byte for byte, which is what `crane tag`
does. `docker buildx imagetools create` is not usable here: given a single
platform-specific manifest it wraps it in a NEW index, so the tag resolves to
an index digest that was never signed and `cosign verify <repo>:latest` fails.
Measured against a local registry, a source manifest came back under a
different digest. The step compares the registry's Docker-Content-Digest with
the signed digest and fails the release if they ever diverge, so a promotion
that stops preserving the digest cannot ship silently.

ATTESTATION_SOFT_FAIL now carries an expiry. It was an unbounded, unattributed
and invisible bypass: vars.* also resolves organization variables, so one set
on the org silently disabled the gate in every repository that read it, nothing
printed which scope supplied it, and a break-glass release was
byte-indistinguishable from a clean one. The value must now be an ISO-8601 UTC
date, anything else including the historical 'true' is rejected and does not
bypass, every gate prints the value on every run whether it passes or fails,
and a bypassed run writes attestation-breakglass=<date> to the job summary.

The sidecar assertion pairs each tarball with its own sidecars instead of
comparing totals. Totals passed a bundle in which one tarball carried two of a
kind and another none, which is exactly what a half-failed matrix leg produces.
An empty sidecar now counts as missing.

Documentation caught up with the workflow. SECURITY.md describes the promote
step and the new break-glass contract. The preview and staging comments no
longer claim to mirror push.yaml or to offer the same guarantees as a release,
because push.yaml has neither a bake-in nor a warn-only aggregator any more,
and verify-attestations no longer describes itself as the gate that a release
gets past.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
The Kubernetes path's imageSecurityOpts skips nil entries and the ECS export
loop further down guards for them, but the ECS options loop did not, so the two
loops disagreed about whether ref.Images can hold a nil and one of them would
panic if it ever does.

Also drop the suffix arm from containsURN in the Kubernetes gate test. want is
always a full URN, so any URN merely ending in the wanted one satisfied the
assertion and it was weaker than it reads.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@Cre-eD Cre-eD changed the title fix(security): treat a Rekor 409 as already-attested, sign before the rollout, block releases with an incomplete attestation chain fix(security): confirm a Rekor 409 by verifying it, sign before the rollout, publish release artifacts only after the attestation gate Aug 23, 2026
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