Skip to content

Commit 54995bf

Browse files
committed
ci: close panel-found fail-open and injection in attestation-check (LAB-984)
Expert-panel review found four real defects in the first cut. 1. The 404 branch was still a fail-open. getLatestRelease returns the latest NON-draft, NON-prerelease release, so a repo whose releases are all drafts or prereleases 404s as well — and the old branch skipped, gated every verification off and exited 0. Flipping v0.4.0 to prerelease would have resurrected the exact bug this file was rewritten to remove. A 404 now consults listReleases and only a genuinely empty list may skip; drafts/prereleases present is a hard failure. 2. The release tag was interpolated straight into two `run:` shells via template position while every other value went through `env:`. Git refnames legally contain `$`, backticks and `(`, so a crafted tag would execute in a job holding GITHUB_TOKEN. Both steps now pass TAG via env, matching the convention pr-title-lint.yml and release.yml already document. The tag regex is also tightened from `.+` / `.*` to explicit character classes, since those groups build both a URL and a file path. 3. `--signer-workflow` alone did not pin what it appeared to. Verified: it is an unanchored prefix match on the certificate SAN — a truncated `.../release` with no `.yml` passes — so it pinned neither the ref nor the full filename, and an actor with push access could mint provenance from a modified release.yml on any branch. Both verifies now add `--source-ref refs/heads/main`, which is the claim actually worth making. Confirmed to pass on the real artifact and to fail with a clear error on a wrong ref. 4. Failure-issue dedupe keyed on a title prefix, so an open issue for one tag silenced a genuine new failure on the next. It is now a per-tag HTML marker matched on the body, so retitling or relabelling cannot break it. Also: added a never-cancel concurrency group (siblings all have one; two overlapping runs could both file an issue), and cut ~22 lines of post-mortem narration from the comments — that content lives in the commit history, where it cannot rot into a lie. Three factual errors in those comments are fixed: the claim that every `gh` call in the old file hit the same `|| echo ""` (only the first ever ran), the SPDX/CycloneDX fallback order (it tried SPDX first), and the asserted TTY mechanism behind gh's silence (replaced with the observation that a redirected passing verify prints nothing at all, which is the part that actually matters). Rejected, with reasons: splitting the job to drop `issues: write` from the verify shell (Actions has no step-level permissions, and the injection vector that made it reachable is now closed); asserting crates.io max_stable_version equals the verified tag (real coverage gap, but a different assertion than "verify the latest release" and it races the publish window — follow-up); and the 60-day scheduled-workflow auto-disable risk (real, out of scope here).
1 parent fe5dca3 commit 54995bf

1 file changed

Lines changed: 81 additions & 70 deletions

File tree

‎.github/workflows/attestation-check.yml‎

Lines changed: 81 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,28 @@ on:
55
- cron: '23 3 * * 1' # Weekly Monday 3:23am UTC
66
workflow_dispatch:
77

8+
# Never cancel: a dispatch landing during the Monday cron would otherwise leave two
9+
# runs racing to file the same failure issue.
10+
concurrency:
11+
group: ${{ github.workflow }}
12+
cancel-in-progress: false
13+
814
jobs:
915
verify:
1016
name: Verify Latest Release Attestations
11-
# GitHub-hosted, NOT the self-hosted `cachekit` runner. Two independently
12-
# verified reasons, either of which alone makes this job incapable of
13-
# verifying anything on ARC (LAB-984):
14-
# 1. The ARC pods have no `gh` CLI (LAB-899). `gh attestation verify` is the
15-
# only tool that performs Sigstore bundle verification — there is no
16-
# github-script equivalent, and `gh attestation list` (which the previous
17-
# version of this file called) is not a real subcommand at all. Every `gh`
18-
# invocation here exited 127 into a `2>/dev/null || echo ""` and the job
19-
# reported green while skipping its own checks for weeks.
20-
# 2. The ARC pods have unreliable DNS/egress to Sigstore (Fulcio/Rekor) —
21-
# the same reason release.yml's publish job is already on ubuntu-latest.
22-
# This job runs weekly and is free on public repos.
17+
# ubuntu-latest, NOT the self-hosted `cachekit` runner: the ARC pods have no `gh`
18+
# binary (LAB-899) and unreliable Sigstore (Fulcio/Rekor) egress — the same reason
19+
# release.yml's publish job is hosted. `gh attestation verify` is the only tool
20+
# that does Sigstore bundle verification, so moving this job back to ARC silently
21+
# re-breaks it (LAB-984: every `gh` call exited 127 into `|| echo ""`, and the job
22+
# reported green while verifying nothing for weeks).
2323
runs-on: ubuntu-latest
2424
permissions:
2525
contents: read
2626
issues: write
2727
steps:
28-
# github-script, NOT `gh`: confines `gh` to the single thing only it can do
29-
# (Sigstore verification, below) and keeps API calls on the SHA-pinned action
30-
# this repo already standardised on (release.yml:44).
31-
#
32-
# getLatestRelease is deliberate: it 404s when — and only when — the repo has
33-
# zero published releases, so "nothing to verify" is unreachable via an API
34-
# error. Any non-404 rethrows and fails the job. No error swallowing.
28+
# github-script, not `gh`, for API calls — the convention release.yml documents
29+
# on its "Assign release PR" step. `gh` is confined to Sigstore verification below.
3530
- name: Resolve latest release
3631
id: release
3732
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -45,17 +40,36 @@ jobs:
4540
});
4641
tagName = data.tag_name;
4742
} catch (error) {
48-
if (error.status === 404) {
49-
core.info('Repository has no published releases — nothing to verify.');
50-
core.setOutput('skip', 'true');
43+
if (error.status !== 404) {
44+
throw error;
45+
}
46+
// A 404 here does NOT mean "no releases". getLatestRelease returns the
47+
// latest non-draft, non-prerelease release, so a repo whose releases are
48+
// all drafts or prereleases 404s too. Skipping on a bare 404 would be a
49+
// silent green — the exact fail-open this file was rewritten to remove.
50+
// Only a genuinely empty release list may skip.
51+
const { data: releases } = await github.rest.repos.listReleases({
52+
owner: context.repo.owner,
53+
repo: context.repo.repo,
54+
per_page: 1,
55+
});
56+
if (releases.length > 0) {
57+
core.setFailed(
58+
'No published full release, but the repository has draft/prerelease releases. ' +
59+
'Nothing verifiable — refusing to report green.',
60+
);
5161
return;
5262
}
53-
throw error;
63+
core.info('Repository has no releases at all — nothing to verify.');
64+
core.setOutput('skip', 'true');
65+
return;
5466
}
5567
// release-please tags this crate `<package-name>-v<semver>`
5668
// (release-please-config.json: package-name cachekit-core, release-type rust).
57-
// An unparseable tag is a hard failure, never a silent skip.
58-
const match = /^(?<crate>.+)-v(?<version>\d+\.\d+\.\d+.*)$/.exec(tagName);
69+
// The character classes are deliberately tight: these values build a URL and
70+
// a file path, so `/` and shell metacharacters must not survive parsing.
71+
const match = /^(?<crate>[A-Za-z0-9_-]+)-v(?<version>\d+\.\d+\.\d+[0-9A-Za-z.+-]*)$/
72+
.exec(tagName);
5973
if (!match) {
6074
core.setFailed(`Cannot derive crate name and version from release tag '${tagName}'`);
6175
return;
@@ -66,18 +80,13 @@ jobs:
6680
core.setOutput('version', match.groups.version);
6781
core.info(`Latest release ${tagName} -> crate ${match.groups.crate} version ${match.groups.version}`);
6882
69-
# The attested artifact is the `.crate` that release.yml hands to
70-
# attest-build-provenance and attest-sbom (`target/package/*.crate`) — the
71-
# identical file `cargo publish` then uploads to crates.io. GitHub *release
72-
# assets* are deliberately not consulted: this repo publishes none (v0.4.0 has
73-
# an empty asset list) and they were never the attestation subject. crates.io
74-
# is the canonical copy and is byte-identical by digest, which is what
75-
# attestation lookup keys on.
83+
# The attestation subject is the `.crate` release.yml hands to
84+
# attest-build-provenance / attest-sbom (`target/package/*.crate`) — the identical
85+
# file `cargo publish` uploads. crates.io holds that byte-identical copy, and this
86+
# repo publishes no GitHub release assets, so crates.io is the artifact source.
7687
#
77-
# `curl -f`, no `|| true`: a tagged release whose crate never reached crates.io
78-
# is a genuine supply-chain gap — publish failing after the tag landed is
79-
# precisely what release.yml's manual re-publish escape hatch exists for — so
80-
# it must fail this job rather than degrade to a skip.
88+
# `curl -f` with no `|| true`: a tagged release whose crate never reached crates.io
89+
# is a real supply-chain gap, so it must fail the job rather than degrade to a skip.
8190
- name: Download attested crate from crates.io
8291
if: steps.release.outputs.skip != 'true'
8392
id: artifact
@@ -87,9 +96,8 @@ jobs:
8796
run: |
8897
mkdir -p release-assets
8998
ARTIFACT="release-assets/${CRATE}-${VERSION}.crate"
90-
# -A is mandatory, not politeness: crates.io's API answers 403 to a generic
91-
# user agent (verified — plain `curl/8.x` gets 403 on this exact URL), and
92-
# the crates.io crawler policy asks callers to identify themselves.
99+
# -A is mandatory, not politeness: crates.io answers 403 to a generic user
100+
# agent (verified — plain `curl/8.x` gets 403 on this exact URL).
93101
curl -fsSL --retry 3 --retry-connrefused \
94102
-A "cachekit-core-attestation-check (+https://github.com/${GITHUB_REPOSITORY})" \
95103
"https://crates.io/api/v1/crates/${CRATE}/${VERSION}/download" \
@@ -98,90 +106,93 @@ jobs:
98106
echo "path=$ARTIFACT" >> "$GITHUB_OUTPUT"
99107
echo "Downloaded $ARTIFACT ($(wc -c < "$ARTIFACT") bytes, sha256 $(sha256sum "$ARTIFACT" | cut -d' ' -f1))"
100108
101-
# `--signer-workflow` pins WHICH workflow was allowed to sign. Without it,
102-
# `--repo` alone accepts an attestation produced by any workflow in the repo,
103-
# so a compromised workflow could mint one that passes.
109+
# Identity pinning takes BOTH flags. `--signer-workflow` is an unanchored prefix
110+
# match on the certificate SAN (verified: a truncated `.../release` with no `.yml`
111+
# also passes), so on its own it pins neither the ref nor the full filename — an
112+
# actor with push access could mint provenance from a modified release.yml on any
113+
# branch and this check would bless it. `--source-ref` is what asserts "built by
114+
# the release pipeline on main", which is the only claim worth making here.
104115
#
105-
# `--format json --jq` is not cosmetic: gh prints its "Verification succeeded"
106-
# banner only to a TTY, so on a runner a passing verify is otherwise completely
107-
# silent — indistinguishable in the log from the skip this job used to do.
108-
# The jq line puts the verified digest, predicate type and signer URI in the log.
116+
# `--format json --jq` is the evidence trail, not decoration: verified by
117+
# redirecting output, a passing `gh attestation verify` prints NOTHING and exits 0,
118+
# which in a log is indistinguishable from the skip this job used to do. The jq
119+
# line names the digest that was actually verified.
109120
- name: Verify provenance attestation
110121
if: steps.release.outputs.skip != 'true'
111122
env:
112123
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
113124
ARTIFACT: ${{ steps.artifact.outputs.path }}
125+
TAG: ${{ steps.release.outputs.tag }}
114126
run: |
115-
echo "Verifying SLSA provenance for ${{ steps.release.outputs.tag }}"
127+
echo "Verifying SLSA provenance for $TAG"
116128
gh attestation verify "$ARTIFACT" \
117129
--repo "$GITHUB_REPOSITORY" \
118130
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
131+
--source-ref refs/heads/main \
119132
--predicate-type https://slsa.dev/provenance/v1 \
120133
--format json \
121134
--jq '.[] | "verified provenance: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
122135
123-
# Predicate type is `https://cyclonedx.org/bom` — unversioned. That is what
124-
# actions/attest-sbom emits for the CycloneDX 1.6 document release.yml generates
125-
# (`cargo sbom --output-format cyclone_dx_json_1_6`). The previous version of
126-
# this file looked for `https://cyclonedx.org/bom/v1.6` with an
127-
# `|| <spdx.dev/Document/v2.3>` fallback; both strings match nothing here, so
128-
# that check could not have passed even with an artifact in hand. There is no
129-
# SPDX attestation to fall back to, so there is no fallback — one predicate,
130-
# verified or red.
136+
# Predicate type is the UNVERSIONED `https://cyclonedx.org/bom` — that is what
137+
# actions/attest-sbom emits for the CycloneDX 1.6 document release.yml generates.
138+
# Do not "fix" this to `.../bom/v1.6`: nothing matches that, nor SPDX. There is no
139+
# second SBOM predicate to fall back to, so there is no fallback — verified or red.
131140
- name: Verify SBOM attestation
132141
if: steps.release.outputs.skip != 'true'
133142
env:
134143
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
135144
ARTIFACT: ${{ steps.artifact.outputs.path }}
145+
TAG: ${{ steps.release.outputs.tag }}
136146
run: |
137-
echo "Verifying CycloneDX SBOM attestation for ${{ steps.release.outputs.tag }}"
147+
echo "Verifying CycloneDX SBOM attestation for $TAG"
138148
gh attestation verify "$ARTIFACT" \
139149
--repo "$GITHUB_REPOSITORY" \
140150
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \
151+
--source-ref refs/heads/main \
141152
--predicate-type https://cyclonedx.org/bom \
142153
--format json \
143154
--jq '.[] | "verified SBOM: digest=\(.verificationResult.statement.subject[0].digest.sha256) predicate=\(.verificationResult.statement.predicateType) signer=\(.verificationResult.signature.certificate.buildSignerURI)"'
144155
145-
# Deduped: this job is weekly, so an unfixed failure would otherwise file a
146-
# fresh issue every Monday. If a tracking issue is already open, log and stop.
147-
# Scans the first 100 open `bug` issues — ample for this repo; if that is ever
148-
# exceeded the worst case is a duplicate issue, not a missed alert.
149-
# github-script rather than `gh issue create` so a failure to raise the alert
150-
# is itself a hard failure instead of a shell exit status nobody reads.
156+
# Weekly job: without dedupe one unfixed break files a fresh issue every Monday,
157+
# and a muted `bug` label is itself a fail-open alert channel. The marker is
158+
# per-tag, so a NEW tag's failure still alerts instead of hiding behind the open
159+
# issue for the old one. Matched on body, not title or label, so a human
160+
# retitling or relabelling the issue cannot break dedupe. The 100-issue page is
161+
# ample here; worst case is a duplicate issue, never a missed alert.
151162
- name: Open issue on failure
152163
if: failure()
153164
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
154165
env:
155166
TAG: ${{ steps.release.outputs.tag }}
156167
with:
157168
script: |
158-
const TITLE_PREFIX = 'Attestation verification failed';
159-
const tag = process.env.TAG || '(tag unresolved)';
169+
const tag = process.env.TAG || 'unresolved-release';
170+
const marker = `<!-- attestation-check:${tag} -->`;
160171
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
161172
const { data: open } = await github.rest.issues.listForRepo({
162173
owner: context.repo.owner,
163174
repo: context.repo.repo,
164175
state: 'open',
165-
labels: 'bug',
166176
per_page: 100,
167177
});
168-
const existing = open.find((i) => !i.pull_request && i.title.startsWith(TITLE_PREFIX));
178+
const existing = open.find((i) => !i.pull_request && (i.body || '').includes(marker));
169179
if (existing) {
170-
core.info(`Tracking issue #${existing.number} is already open; not filing a duplicate. Run: ${runUrl}`);
180+
core.info(`Issue #${existing.number} already tracks ${tag}; not filing a duplicate. Run: ${runUrl}`);
171181
return;
172182
}
173183
const { data: created } = await github.rest.issues.create({
174184
owner: context.repo.owner,
175185
repo: context.repo.repo,
176-
title: `${TITLE_PREFIX} for ${tag}`,
186+
title: `Attestation verification failed for ${tag}`,
177187
body: [
188+
marker,
178189
`The weekly attestation health check failed for \`${tag}\`.`,
179190
'',
180191
`Run: ${runUrl}`,
181192
'',
182193
'Check that release.yml produced valid SLSA provenance and CycloneDX SBOM',
183-
'attestations for the published `.crate`, and that the crate actually',
184-
'reached crates.io for this tag.',
194+
'attestations for the published `.crate`, that it ran on `main`, and that',
195+
'the crate actually reached crates.io for this tag.',
185196
].join('\n'),
186197
labels: ['bug'],
187198
});

0 commit comments

Comments
 (0)