Repository navigation
Harden pr-merge's run-cache restore in the token-holding jobs #9455
Copy link
Copy link
Labels
status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.A maintainer confirmed the item is real and in scope; ready to be worked.
Description
Problem
Two jobs of .github/workflows/pr-merge.yml restore the pr-merge-run-cache-* actions/cache entry by bare prefix (restore-keys: pr-merge-run-cache-) after they hold the lanes App merge token:
- the
mergejob ("Restore the verdict reads the last run kept"); - the
enable-auto-mergejob, on its scheduled and hand-started runs, added by fix(pr-merge): require a pr-verify pass before a lane pull request merges natively #9450 for parity.
Any job in the default branch's cache scope can write a newer key under that prefix. carry-run-cache.mjs already filters the restored JSON, so a poisoned entry's contents can only make the gate refuse; they cannot forge a pass.
Two risks are not covered:
- Extraction path (UNVERIFIED). The security review of fix(pr-merge): require a pr-verify pass before a lane pull request merges natively #9450 raised this concern from published cache-poisoning research (Cacheract): actions/cache may extract a crafted archive with entries outside the declared
path. If it does, a poisoned entry could overwrite files under.base/(for examplecarry-run-cache.mjsorenable-auto-merge.mjs) before they run with the lane token andid-token: write. This has not been verified foractions/cache/restorev6.1.0. - Who can write the key. It has not been checked which default-branch jobs could be made to run attacker-influenced code that writes that key.
Proposal
Pick one, after verifying the extraction behaviour:
- Verify the archive. Restore into an empty directory in a step or job that holds no token. List the archive entries, refuse any path outside the single expected file, and only then copy the file into place.
- Move to the artifact-read pattern. Use the pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses for the head-ref map: an artifact uploaded only by a scheduled or hand-started run of main's pr-merge.yml, found through that run's record. That removes the shared cache scope from the trust path.
Either change should land in both jobs together.
Done when
- The extraction behaviour of the pinned cache restore is verified, and the source is cited.
- Neither token-holding job extracts a cache archive a non-pr-merge job could write, or the archive's paths are checked first.
carry-run-cache.mjs's content filter is kept.
Refs: #9450
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.A maintainer confirmed the item is real and in scope; ready to be worked.
Type
Fields
No fields configured for Task.
Activity
github-actions commentedon Oct 23, 2026
Proposed work class: none
Thanks for the report. Automated triage handed this issue to a maintainer.
Automated triage. A maintainer reviews it.
kyle-sexton commentedon Oct 23, 2026
The claude-security-review lane on #9450 (#9450 (comment)) suggests an integrity check on the carried run cache: an HMAC, or a check that a main-branch
pr-mergerun saved it. It also suggests reading the cache back through the artifact path. Today the carried sections can only count a FAIL (carry-run-cache.mjs:9-11;Reader._run_statereads a PASS's run fresh), so this protects the refuse direction and the extraction path, not the pass direction.kyle-sexton commentedon Oct 26, 2026
Owner question (security), not a decision.
Owner question (security): The body's two unverified risks were checked this session. The extraction risk is verified; the writer chain holds up to one link not read (item 4). Rated HIGH: a chain ends in arbitrary code in a job that holds the lanes App merge token and
id-token: write. This record decides nothing else.Evidence:
path.actions/cache/restore@55cc8345…is v6.1.0 and depends on"@actions/cache": "^6.1.0"(https://raw.githubusercontent.com/actions/cache/55cc8345863c7cc4c66a329aec7e433d2d1c52a9/package.json). Its bundleddist/restore-only/index.js:94080-94082extracts with-xf <archive> -P -C <GITHUB_WORKSPACE>(:94128-94131), and:94413-94418lists the archive only in debug mode, then extracts it with no entry filter. With-P, an archive entry keeps its own path, so an entry such as.base/.github/scripts/pr-merge/carry-run-cache.mjslands in the checkout, not underpath..github/workflows/pr-merge.yml, themergejob holdsid-token: write(:456), restores by bare prefixrestore-keys: pr-merge-run-cache-(:756-760), and the next step runsnode .base/.github/scripts/pr-merge/carry-run-cache.mjs(:768). Theenable-auto-mergejob does the same (:1444,:1553-1565). Both restores come after the lane token step (:605,:1522). The file's own comment already says "Any job in the default branch's cache scope can write that prefix" (:746).push,workflow_dispatch,repository_dispatch,delete,registry_package,page_buildandschedule, and thatcache-mode"controls the cache access granted to the job's token" (https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, read 2026-10-26).pr-unblock.ymlruns onworkflow_dispatch(:35) and callspr-run-activity-read.yml(:318and five more call sites), whose run job runs PR head code (pr-run-activity-read.yml:15-16) on a runner the file calls hostile (:216).pr-refine.ymldoes the same (:25,:126,:200). No job in those files setscache-mode; the only one on main iswork-resolve-issue.yml:132(cache-mode: read).The carried JSON's content filter (
carry-run-cache.mjs) does not cover this: a crafted archive replaces the script before it runs.Options, each a
.github/workflows/**edit (.github/actions/resolve-config/patch-rules.json:64refusesgithub-workflowsfor lane patches, so a host session builds it):cache-mode: none(documented on the page above) on every job that runs PR head code, so none can write the default-branch scope. Narrows the writers but keeps the prefix restore.kyle-sexton commentedon Oct 26, 2026
Released for re-triage under the owner's 2026-10-26 rule: a person is involved only for "1. A change in original intent/vision 2. Something comes up during implementation ... that alters intent/vision or the primary design 3. If there is a HIGH/CRITICAL security issue". None applies here, so the hold label came off and intake triage runs again now.
To hold this issue again, add
needs-human(orstatus: needs-decision) and name which of the three applies.melodic-automation-lanes commentedon Oct 26, 2026
The triage verdict left this issue at
status: needs-decision:Size:lineComplexity:lineUncertainty:lineFlags:linekyle-sexton commentedon Oct 26, 2026
Decision recorded. The owner answered in the Refinement session (2026-10-26), choosing option A for this HIGH finding: "A: artifacts (Recommended)" (carry the run data the way #9416 does, so neither token-holding job restores a cache).
This is a HIGH security finding (rated in the evidence below). Decided: option A, the hardening option, which removes the cause and loosens nothing.
Evidence:
path.actions/cache/restore@55cc8345…is v6.1.0 and depends on"@actions/cache": "^6.1.0"(https://raw.githubusercontent.com/actions/cache/55cc8345863c7cc4c66a329aec7e433d2d1c52a9/package.json). Its bundleddist/restore-only/index.js:94080-94082extracts with-xf <archive> -P -C <GITHUB_WORKSPACE>(:94128-94131), and:94413-94418lists the archive only in debug mode, then extracts it with no entry filter. With-P, an archive entry keeps its own path, so an entry such as.base/.github/scripts/pr-merge/carry-run-cache.mjslands in the checkout, not underpath..github/workflows/pr-merge.yml, themergejob holdsid-token: write(:456), restores by bare prefixrestore-keys: pr-merge-run-cache-(:756-760), and the next step runsnode .base/.github/scripts/pr-merge/carry-run-cache.mjs(:768). Theenable-auto-mergejob does the same (:1444,:1553-1565). Both restores come after the lane token step (:605,:1522). The file's own comment already says "Any job in the default branch's cache scope can write that prefix" (:746).push,workflow_dispatch,repository_dispatch,delete,registry_package,page_buildandschedule, and thatcache-mode"controls the cache access granted to the job's token" (https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, read 2026-10-26).pr-unblock.ymlruns onworkflow_dispatch(:35) and callspr-run-activity-read.yml(:318and five more call sites), whose run job runs PR head code (pr-run-activity-read.yml:15-16) on a runner the file calls hostile (:216).pr-refine.ymldoes the same (:25,:126,:200). No job in those files setscache-mode; the only one on main iswork-resolve-issue.yml:132(cache-mode: read).The carried JSON's content filter (
carry-run-cache.mjs) does not cover this: a crafted archive replaces the script before it runs.Options, each a
.github/workflows/**edit (.github/actions/resolve-config/patch-rules.json:64refusesgithub-workflowsfor lane patches, so a host session builds it):cache-mode: none(documented on the page above) on every job that runs PR head code, so none can write the default-branch scope. Narrows the writers but keeps the prefix restore.Decided: A. It removes the shared cache scope from the trust path of both token-holding jobs and reuses the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 already uses (DRY). C may be added as a second layer; it is not a substitute.
Build: host session
Acceptance criteria:
mergenor theenable-auto-mergejob in.github/workflows/pr-merge.ymlrunsactions/cache/restore; the carried run data reaches them through the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses.id-token: writeor a lanes App token restores a cache by a barerestore-keysprefix.carry-run-cache.mjsreads only the carried file, from a path no extracted archive can write.pr-merge.ymladds a cache restore.fix(pr-merge): read the queue's carried verdict reads from pr-merge's…
fix(pr-merge): refuse a trusted run while a tag named main exists
perf(pr-merge): read the tag named main and main's tip once per process