Skip to content

fix(action): a fork's branch name must not reach the shell holding the API key (rf-7xv0, rf-v2mj) - #260

Merged
Rome-1 merged 1 commit into
mainfrom
fix/sable-oubg-v1-key-exfil
Sep 17, 2026
Merged

Rome-1 merged 1 commit into
mainfrom
fix/sable-oubg-v1-key-exfil

Conversation

@Rome-1

@Rome-1 Rome-1 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Closes sable-oubg (rf-7xv0 + rf-v2mj). P0, authorized by Rome 2026-09-13.

⚠️ Merging this without moving v1 changes nothing for any consumer

Both defects are live at the v1 tag, which is what consumers pin. v1 is 34850d46 — 115 commits and five months behind main. No fix that has ever landed on main has reached a consumer pinning v1. The fix and the tag move are one unit of work; this PR is only the first half.

Per the bead, I have not moved the tag. Flagging for review so the mayor can take the tag move to Rome.

rf-7xv0 — key exfiltration

"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. 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), never through ${{ }} in run:. The body is then built with jq -nc --arg instead 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 and finding-count is left empty rather than a fabricated 0.

Note which file: the same defect class in github-action/action.yml was fixed under sable-fgk7, and this root action.yml was never touched by it. Two action files; only one had been fixed.

Probes — each fails on the pre-fix file, each has a control

probe unfixed fixed
test-trigger-injection.sh 3 failures 0
test-root-action-counts.sh 2 failures 0

Controls matter here: the count probe asserts a genuinely clean report still passes with count 0 and that real findings still count as 2. 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 only github-action/**, so a change to the root action.yml would never have run the probe that guards it — action.yml added to both the pull_request and push filters.

Corpus searched

ref file head_ref/ref_name interpolations
main action.yml 0
main github-action/action.yml 1
v1 action.yml 0
v1 github-action/action.yml 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, not silently left

A sweep of every ${{ }} inside a run: block found more of the same class, none attacker-controlled from a fork: the root action interpolates inputs.scan-path, inputs.args, inputs.format and inputs.version directly 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.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.

…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.
@Rome-1
Rome-1 merged commit 19795f5 into main Sep 17, 2026
33 checks passed
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