fix(action): a fork's branch name must not reach the shell holding the API key (rf-7xv0, rf-v2mj) - #260
Merged
Conversation
…e API key (rf-7xv0, rf-v2mj) P0, authorized by Rome 2026-09-13. Both defects are live AT THE v1 TAG, which is what consumers pin. MERGING THIS WITHOUT MOVING v1 CHANGES NOTHING FOR ANY CONSUMER — v1 is 34850d4, 115 commits and five months behind main, so no fix that has ever landed on main has reached them. rf-7xv0 — KEY EXFILTRATION, github-action/action.yml. "branch_name": "${{ github.head_ref || github.ref_name }}", sat inside a `run:` block whose env carries RAFTER_API_KEY. The runner expands `${{ }}` into the script TEXT before bash sees it, so that value was not data — it was source code, chosen by whoever opened the pull request, and anyone can open one from a fork. Measured, not argued: with a branch name of x"; printf %s "$RAFTER_API_KEY" > "$CANARY_PATH"; echo " the canary file came back containing the key. `$( )` works too and needs no quote-breaking at all. Fixed the way GitHub documents: the values reach the script through `env:` (GH_REPOSITORY, GH_BRANCH) and never through `${{ }}` in `run:`. The body is then built with `jq -nc --arg` rather than pasted into a hand-quoted JSON string, which fixes a second bug in the same line — a branch name containing a quote or backslash produced MALFORMED JSON even with no attacker involved. Hand-quoting would have to get shell and JSON escaping both right; jq --arg gets both right by construction. rf-v2mj — AN UNREADABLE REPORT RENDERED AS A CLEAN SCAN, root action.yml. `COUNT=$(... | jq ... || echo "0")` made "no findings" and "I could not read the output" the same value, so the gate passed precisely when it could not see its input. Now the count is written only when a parse actually succeeded; otherwise the step fails and finding-count is left EMPTY rather than a fabricated 0. The text branch distinguishes grep's exit 1 (no matches — clean) from grep failing (>1). Note which file that is: the same defect class in github-action/action.yml was fixed under sable-fgk7 and this ROOT file was never touched by it. Two action.yml files; only one had been fixed. That is the measurement trap the bead warned about, and it is why the corpus is stated below. PROBES — each FAILS on the pre-fix file and passes after, each with a control so it cannot pass by doing nothing: test-trigger-injection.sh unfixed 3 failures -> fixed 0 quote-breaking injection reads the key; $( ) injection reads the key; and a FIDELITY control that a legal branch name containing a quote and a backslash still arrives intact as valid JSON, checked against a real local listener that records the body actually sent. test-root-action-counts.sh unfixed 2 failures -> fixed 0 unparseable report and truncated JSON must fail the step; CONTROLS that a genuinely clean report still passes with count 0 and that real findings are still counted as 2. Without those controls a "fail on everything" change would have passed. Writing the fidelity control honestly cost two rewrites: the first scraped the script text for the old inline-JSON shape and silently matched nothing once the fix removed that shape, and the probe did not model the step's `env:` block, so after the fix the payload was not arriving anywhere and the injection tests were passing vacuously. Both are why the probe now renders the env mapping the way the runner does. CI: both probes wired into test-github-action.yml. The workflow's path filter watched only `github-action/**`, so a change to the ROOT action.yml would not have run the probe that guards it; `action.yml` added to both the pull_request and push filters. CORPUS SEARCHED, as the bead requires. Every action file at both refs: origin/main action.yml head_ref/ref_name: 0 origin/main github-action/action.yml head_ref/ref_name: 1 v1 action.yml head_ref/ref_name: 0 v1 github-action/action.yml head_ref/ref_name: 1 plus .github/workflows/test-action.yml and test-github-action.yml, which are workflows rather than actions and carry no such interpolation. NOT FIXED HERE, reported rather than silently left. A sweep of every `${{ }}` inside a `run:` block found more of the same CLASS, none of them attacker-controlled from a fork: root action.yml interpolates inputs.scan-path, inputs.args, inputs.format and inputs.version directly into run blocks, so a consumer whose workflow passes untrusted text into those inputs has the same shape of problem one level out. github-action/action.yml also interpolates github.repository and github.event.pull_request.number, both runner-supplied and narrowly typed. Worth its own pass; not smuggled into a P0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes sable-oubg (rf-7xv0 + rf-v2mj). P0, authorized by Rome 2026-09-13.
rf-7xv0 — key exfiltration
sat inside a
run:block whoseenv:carriesRAFTER_API_KEY. The runner expands${{ }}into the script text before bash sees it, so that value was not data — it was source code, chosen by whoever opened the pull request. Anyone can open one from a fork.Measured, not argued. With a branch name of:
the canary file came back containing the key.
$( )works too, and needs no quote-breaking at all.Fixed the way GitHub documents: the values reach the script through
env:(GH_REPOSITORY,GH_BRANCH), never through${{ }}inrun:. The body is then built withjq -nc --arginstead of pasted into a hand-quoted JSON string — which fixes a second bug in the same line: a branch name containing a quote or backslash produced malformed JSON even with no attacker involved.rf-v2mj — an unreadable report rendered as a clean scan
COUNT=$(... | jq ... || echo "0")made "no findings" and "I could not read the output" the same value, so the gate passed precisely when it could not see its input. The count is now written only when a parse actually succeeded; otherwise the step fails andfinding-countis left empty rather than a fabricated0.Note which file: the same defect class in
github-action/action.ymlwas fixed under sable-fgk7, and this rootaction.ymlwas never touched by it. Two action files; only one had been fixed.Probes — each fails on the pre-fix file, each has a control
test-trigger-injection.shtest-root-action-counts.shControls matter here: the count probe asserts a genuinely clean report still passes with count
0and that real findings still count as2. Without those, a "fail on everything" change would have passed.The injection probe's fidelity control took two rewrites to become real — the first scraped the script text for the old inline-JSON shape and silently matched nothing once the fix removed it, and the probe did not model the step's
env:block, so after the fix the payload arrived nowhere and the injection tests were passing vacuously. It now renders the env mapping the way the runner does, and checks the branch name round-trips against a real local listener that records the body actually sent.CI
Both probes wired into
test-github-action.yml. The workflow's path filter watched onlygithub-action/**, so a change to the rootaction.ymlwould never have run the probe that guards it —action.ymladded to both thepull_requestandpushfilters.Corpus searched
head_ref/ref_nameinterpolationsaction.ymlgithub-action/action.ymlaction.ymlgithub-action/action.ymlPlus
.github/workflows/test-action.ymlandtest-github-action.yml, which are workflows rather than actions and carry no such interpolation.Not fixed here — reported, not silently left
A sweep of every
${{ }}inside arun:block found more of the same class, none attacker-controlled from a fork: the root action interpolatesinputs.scan-path,inputs.args,inputs.formatandinputs.versiondirectly into run blocks, so a consumer whose workflow feeds untrusted text into those inputs has the same shape of problem one level out.github-action/action.ymlalso interpolatesgithub.repositoryandgithub.event.pull_request.number, both runner-supplied and narrowly typed. Worth its own pass; not smuggled into a P0.