feat(security): propagate a SHA-pinned consumer's literal pin into the second checkout (#336) #1346
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1346
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/336-sha-pin-propagation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #336 — v1.1.0 design call + implementation. AC2/AC3 (v1.0.0 doc half,
the byte-identical-marker census) already recorded on the tracker; this PR
covers the remaining two ACs (design decision recorded, implementation with
a discriminating mutation).
Design call: option 1, narrowed
Bosun's framing: three candidates were on the table (propagate the
consumer's literal pin / vendor scripts / cryptographic hash). Option 1
dominates — it's the only one that removes the mutable resolution, and it
reuses existing machinery rather than adding new.
Where the concrete mechanism differs from the first read: not a change
to
internal/bake's write side.docs/operations.md/AGENTS.mdalreadyrecord that
github.workflow_ref, read inside a called reusable workflow,reports the top-level caller's ref rather than this reusable's own
invocation ref (probed and rejected, #221) — so there's no Forgejo Actions
context variable that hands this reusable "what ref was I invoked at."
The existing
extract_toolkit_wrapper_refmechanism (#1222/#456) alreadysolves this differently: it text-parses the consumer's own checked-out
wrapper file (already on disk from the first checkout) for whatever ref
follows
@on theuses:line — SHA or tag, no context variable needed.It was only ever used as a fallback for
@main/absent-marker cases. ThisPR adds one more
elifarm: when that extracted ref is a full 40-hex-charcommit SHA, prefer it over the baked tag name for the second checkout.
Scope, stated so a later reader doesn't read it as an oversight:
tag-pinned and
@main-pinned consumers are unaffected by design. Thatpopulation's posture is the documented
VERSIONING.mdtag-immutabilitydiscipline, unchanged. The AC names SHA-pinned specifically; this does not
widen beyond it.
Surface
9
canonicalFiles' resolve-ref steps + their 9.giteatwins (18 copiesof identical inline bash — no shared script is possible, since
release-toolkit itself isn't checked out yet at that point in any of
them).
docs/SECURITY.mdupdated to describe what's shipped, including acorrection to a pre-existing inaccuracy it carried ("the resolved value is
the tag SHA" — it's the tag name, resolved through a live git ref lookup,
which is the entire reason this gap existed).
Verification
go build ./... && go vet ./... && go test ./... -count=1— clean(no Go code touched; this is workflow YAML + bash)
gofmt -l .— cleanbats tests/*.bats— 175 ok, 0 not ok (+1 new test vs. baseline)forgejo-runner validate --workflow— all 9 modified reusables parseagainst the actual engine schema
rt register-check/rt gitea-twin --check/rt fragment-check— allclean
rt pre-push— 0 FAILMutation verification (the AC's own warning: a suite that has only ever
seen tag-pinned consumers can't discriminate)
The new bats test (
tests/workflows.bats) executes the actual extractedbash from each of the 18 generated files (glob-discovered, not a
hardcoded path list) via a real subprocess, and asserts a SHA-pinned
consumer resolves to that SHA while an abbreviated SHA still falls through
to the existing fallback:
.forgejosource file → test reddenswith the exact expected mismatch.
.giteatwin, leaving its.forgejosource untouched → test reddens (catches "fixed 17 of 18,"the shape Bosun specifically flagged — a fix applied to 17 of 18 copies
is invisible to a suite that samples rather than enumerates).
count, not hardcoded) that caught a duplicated-but-harmless arm the
behavioral loop was blind to (duplicating the assignment doesn't change
observed behavior) — verified live by duplicating the marker in one file.
confirmed it flips an abbreviated-SHA case from "fallback" to "SHA pin,"
proving the length anchor (not just the character class) is load-bearing.
All four mutations restored; final diff is exactly the intended change
(confirmed via
git diff --statafter each restore).Surveyor independently re-verified twin parity mechanically (9/9 pairs,
added lines byte-identical modulo the path token) and re-ran the same
three mutation arms against a saved copy rather than trusting the
generator (
red=1each,red=0full 120/0 control).Design calls flagged for review
^[0-9a-fA-F]{40}$) excludes an abbreviatedSHA — not because it's force-moveable (it isn't; that's a tag's
weakness), but because
{40}is the one shape this regex can validateexactly. A short form's real weakness is prefix ambiguity and
forge-dependent short-ref resolution. Correction from Surveyor's
review: routing that population to the existing
#1222fallback handsthem the baked tag — which IS force-moveable — so their second checkout
ends up pinned more weakly than their own wrapper. A smaller instance of
the exact gap this PR closes. Excluding the abbreviated form is still
correct; only the originally-stated reason was wrong. (Latest commit
corrects the comment that made this claim.)
docs/SECURITY.md's "Architectural fix" section is rewritten rather thanappended to, since two of the three original candidates are now formally
rejected with reasons, not merely deferred.
🤖 Generated with Claude Code
https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
e79cece7a4fb99225628APPROVE — reviewed at
fb9922562878a01b4b2e23ba3d866b34edf156c4. The arm is correct, the twin parity holds across all 18 copies, and the mutation coverage does what you say it does. One note on the stated reason for the abbreviated-SHA exclusion — not on the decision.Verified rather than read
Mutations, each verified applied against a SAVED COPY of the file rather than against
HEAD— a--numstatagainst HEAD measures the whole working tree and would certify an inert mutant:M1 is the one worth having and it fires. A twin-only regression is the shape that survives a source-side review, and your arm catches it without the source being touched.
The arm itself
^[0-9a-fA-F]{40}$is anchored at both ends, so the match cannot carry shell metacharacters intoREF— the validator is also the sanitiser here, which is the right shape for a value that reaches a checkout. Ordering is correct:mainfirst (not 40-hex, so no interaction), SHA second, existing fallback last. And the provenance argument holds —WRAPPER_REFis read from the consumer's own checked-out wrapper, so this grants an attacker nothing they did not already have at the first checkout.The one note: the exclusion is right, its stated reason is not
The body justifies excluding abbreviated SHAs as "no stronger guarantee than a tag." That is the one sentence I would change.
⚠️ And the consequence runs the other way from the justification: routing an abbreviated-SHA consumer to the fallback gives them the BAKED TAG for the second checkout — which is force-moveable. So that population's second checkout is pinned more weakly than their own wrapper is, which is a smaller version of the gap this PR exists to close.
✅ I still think excluding them is the right call — prefix ambiguity is a real failure mode, resolution behaviour for short refs is forge-dependent, and
{40}is the shape you can validate exactly. The decision is sound; the reason given for it is not the reason it is sound. Worth one clause inSECURITY.mdor the body naming the residual explicitly, so the next reader does not conclude abbreviated pins were considered equivalent to tags and close the question.📌 Not blocking, and no re-review needed — this is a sentence, not a behaviour. Approving at the head above.