actions/checkout v7 pull_request_target 실패 해결: fork PR 보안 차단 확인하기
actions/checkout v7 pull_request_target 실패 해결을 검색하게 된 로그가 Refusing to check out fork pull request code로 시작한다면, 버전 결함보다 보안 차단을 먼저 의심해야 한다. v7은 권한이 있는 pull_request_target에서 fork PR의 저장소·ref·SHA를 명시해 가져오는 패턴을 기본 거부한다. 해결의 기준은 job을 다시 통과시키는 데 있지 않다. 특권 컨텍스트에서 공격자가 바꾼 파일을 실행하지 않도록 워크플로를 나눠야 한다.
이 글은 2026년 8월 16일, Linux x86_64와 actions/checkout v7.0.1 공개 소스를 대상으로 한 controlled comparison을 바탕으로 한다. Node.js 24.19.0에서 관련 테스트 28개를 실행해 차단 입력과 허용 입력을 비교했지만, 실제 fork PR이나 GitHub-hosted runner의 토큰·secret·cache 동작을 재현한 end-to-end 시험은 아니다.
20초 핵심 요약
- 무엇:
pull_request_target에서 fork 저장소, PR ref, head·merge SHA를 checkout하면 v7이 기본 거부한다. - 왜: 차단만 우회한 뒤 fork의 빌드·테스트를 실행하면 write token, secret, runner까지 공격자 코드에 노출될 수 있다.
- 어떻게: 오류 입력을 분류한 뒤 triage는 base checkout을 유지하고, PR 코드 테스트는 비특권
pull_requestjob으로 옮긴다.
오류가 보이면 event와 checkout 입력을 함께 본다
문제는 “v7이 fork PR을 지원하지 않는다”가 아니다. pull_request_target은 base 저장소의 workflow 문맥에서 실행되며, 아무 ref도 덮어쓰지 않은 기본 checkout은 base의 기본 브랜치를 받는다. 이 상태로 라벨·댓글처럼 PR 메타데이터만 처리하는 작업은 이번 보호 때문에 막히지 않는다.
차단은 특권 이벤트, fork 여부, checkout 입력이 함께 맞을 때 발생한다. 오류 로그를 찾았다면 아래 항목을 위에서부터 확인한다.
| 확인 항목 | 차단을 의심할 값 | 판정 |
|---|---|---|
| event | pull_request_target |
특권 컨텍스트인지 확인한다 |
| PR 저장소 | head repository ID가 base와 다름 | fork PR인지 확인한다 |
with.repository |
${{ github.event.pull_request.head.repo.full_name }} |
fork 저장소를 직접 지정했다 |
with.ref |
head SHA, merge SHA | fork PR commit을 직접 지정했다 |
with.ref |
refs/pull/<n>/head 또는 refs/pull/<n>/merge |
PR 가상 ref를 직접 지정했다 |
| 다음 단계 | install, build, test, local action, 설정 로더 | 가져온 파일을 실행·해석하는지 확인한다 |
same-repository PR과 일반 pull_request는 이 변경의 직접 차단 대상이 아니다. workflow_run은 원래 workflow event가 pull_request로 시작한 경우 같은 guard의 대상이 된다. 오류가 없다는 사실만으로 안전하다고 볼 수도 없다. 수동 git fetch, gh pr checkout, 무관한 제3 저장소 checkout은 이 guard의 범위 밖이기 때문이다.

동일한 fork payload에서 무엇이 거부되고 허용됐는가
공개 v7.0.1 fixture에 같은 fork pull_request_target payload를 넣고 입력만 바꿔 비교했다. 실행 환경은 시스템 기본 Node.js 18.19.1이 아니라 저장소가 요구하는 Node.js 24.19.0이었다.
$ npx --yes node@24 --version
v24.19.0
$ npx --yes node@24 --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand __test__/unsafe-pr-checkout-helper.test.ts __test__/input-helper.test.ts
PASS __test__/input-helper.test.ts
unsafe PR checkout guard
✓ allows the default self-checkout on a fork pull_request_target
✓ refuses an explicit fork repository on pull_request_target
PASS __test__/unsafe-pr-checkout-helper.test.ts
✓ allows pull_request_target default checkout (base branch)
✓ allows same-repo pull_request_target checkout of PR head
✓ refuses pull_request_target fork PR head SHA checkout
✓ refuses pull_request_target fork PR merge_commit_sha checkout
✓ refuses pull_request_target fork PR ref pattern (head)
✓ refuses pull_request_target fork PR ref pattern (merge)
✓ refuses pull_request_target when repository points at the fork
✓ allows pull_request_target checkout of an unrelated third-party repo
✓ allows pull_request_target fork PR checkout when opted in
Tests: 28 passed, 28 total
[exit status: 0]
이 결과는 공식 변경 공지의 세 차단 조건과 일치한다. fork의 head·merge SHA, refs/pull/.../head|merge, fork repository는 거부됐다. 반대로 기본 base checkout과 same-repository PR은 통과했다. allow-unsafe-pr-checkout: true도 guard를 통과했지만, 이는 안전성이 좋아진 결과가 아니라 보호를 명시적으로 끈 대조군이다. 무관한 제3 저장소가 통과한 결과 역시 안전 보증이 아니라 이 guard의 범위 밖이라는 뜻이다.
재현 과정에서 Node.js 18로 먼저 실행한 테스트는 checkout guard에 도달하지 못했다.
$ npm test -- --runInBand __test__/unsafe-pr-checkout-helper.test.ts __test__/input-helper.test.ts
npm WARN EBADENGINE package: '[email protected]'
npm WARN EBADENGINE required: { node: '>=24' }
npm WARN EBADENGINE current: { node: 'v18.19.1', npm: '9.2.0' }
Error: Jest: Failed to parse the TypeScript config file .../jest.config.ts
SyntaxError: Unexpected token 'export'
[exit status: 1]
이 실패는 사용자의 GitHub Actions runner가 checkout v7을 실행할 수 없다는 증거가 아니다. 공개 저장소의 소스 테스트 환경이 Node.js 24를 요구하는데 로컬 테스트를 Node.js 18로 실행한 런타임 불일치다. 제품의 보안 오류와 재현 도구의 오류를 분리해야 진단이 엉뚱한 버전 문제로 흐르지 않는다.
라벨·댓글 job은 fork ref를 지우는 것이 가장 작은 수정이다
PR 코드를 실행할 필요가 없는 triage workflow라면 repository와 ref override를 제거한다. 기본 checkout이 base 브랜치를 받게 두고 필요한 권한만 남긴다.
on:
pull_request_target:
permissions:
contents: read
pull-requests: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# base default branch만 checkout한다.
# 이후 fork의 빌드·테스트·dependency를 실행하지 않는다.
checkout 단계가 초록색이 됐다고 수정이 끝난 것은 아니다. checkout된 내용이 base 코드인지, 이후 단계가 fork의 Makefile, package.json, 테스트, local action이나 설정 파일을 읽어 실행하지 않는지 함께 확인해야 한다.
persist-credentials: false는 이 문제의 대체 해결책이 아니다. checkout 뒤 credential을 저장하는 방식과, 특권 job이 신뢰하지 않은 파일을 실행하는 위험은 서로 다른 문제다. 마찬가지로 checkout을 수동 fetch로 바꾸면 v7 guard만 피할 뿐 이후 실행 위험은 남는다.
PR 코드를 테스트해야 하면 비특권 workflow로 분리한다
fork의 변경 코드를 빌드하거나 테스트해야 하고 base secret이나 write 권한이 필요 없다면 trigger를 pull_request로 옮긴다.
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm test
이 구조에서는 PR 코드 테스트와 특권 메타데이터 처리를 한 job에 섞지 않는다. test는 pull_request에서 제한된 권한으로 실행하고, triage는 pull_request_target에서 base 코드만 사용한다. 두 workflow를 운영해야 하는 부담은 생기지만, 공격자가 바꾼 파일과 base의 권한이 같은 실행 문맥에서 만나는 것을 피할 수 있다.
평가지표도 “실행 성공률” 하나로 잡으면 안 된다. fork 입력은 정확히 거부되는지, base checkout과 same-repository PR은 불필요하게 막히지 않는지, fork 코드를 실행하는 job에 base secret과 write token이 없는지, 수동 fetch 같은 우회 경로가 남지 않았는지를 함께 봐야 한다. false positive는 CI 지연을 만들지만 false negative는 secret 탈취, write token 악용, cache poisoning, runner 또는 공급망 침해로 이어질 수 있다.
allow-unsafe-pr-checkout은 마지막 예외이지 복구 버튼이 아니다
allow-unsafe-pr-checkout: true는 v7의 판정을 끈다. 따라서 오류가 사라졌다는 결과만으로 채택하면 안 된다. checkout한 fork 결과물을 코드가 아닌 데이터로만 다루고, 뒤의 action·hook·dependency·설정 로더까지 공격자 제어 파일을 실행하지 않는다는 검토가 끝난 경우에만 제한적으로 고려할 수 있다.
최소 권한과 격리된 runner도 필요하지만, 그것만으로 fork 코드 실행이 안전해지는 것은 아니다. path를 별도로 지정하거나 persist-credentials: false를 추가해도 파일을 해석하는 후속 도구가 있다면 같은 위험이 남는다. 이 조건을 증명하기 어렵다면 예외 플래그를 보류하고 workflow를 분리하는 편이 맞다.
수정 후에는 새 fork 이벤트로 네 가지를 확인한다
변경한 YAML은 fork에서 새 PR을 만들거나 synchronize 이벤트를 발생시켜 확인한다.
- triage workflow가 base 기본 브랜치를 checkout하고 필요한 라벨·댓글 작업만 성공하는가.
- test workflow가
pull_request에서 실행되고 fork에 base secret이 전달되지 않는가. - 전체 YAML에
github.event.pull_request.head.sha,refs/pull/..., fork repository, 수동 fetch 뒤 실행하는 패턴이 남지 않았는가. permissions가 job이 실제로 수행할 작업의 최소 범위이며, self-hosted runner라면 별도 격리·일회성 사용 조건을 만족하는가.
임시로 opt-out을 넣었다면 되돌리기는 명확하다. allow-unsafe-pr-checkout: true를 제거하고 head/ref/repository override를 지운 뒤, test와 triage를 각각 pull_request와 pull_request_target으로 다시 분리한다. 특권 workflow가 열려 있던 기간의 PR은 오래된 workflow 문맥이 남을 가능성을 고려해 닫기나 rebase가 필요한지도 검토한다.
v7.0.1과 backport 범위를 혼동하지 않는다
GitHub의 최초 공지는 2026년 6월 18일에 나왔고, 7월 15일 편집자 주에서 지원 구버전의 backport 강제 적용일이 7월 20일로 조정됐다. v1은 대상이 아니다. floating major tag는 변경을 받을 수 있지만 특정 SHA·minor·patch 고정은 직접 업그레이드해야 한다.
v7.0.1에는 기본 self-checkout일 때 unsafe PR 검사를 건너뛰는 보정이 포함됐다. 따라서 최초 v7.0.0 동작만 보고 현재 @v7 전체를 일반화하지 말고 실제 resolved SHA를 확인해야 한다. 이 글은 v7.0.1 tag의 입력 판정 로직을 비교했으며, GitHub-hosted runner의 backport tag 동작과 GHES 적용 버전·일정은 확인하지 않았다.
이번 오류를 해결하는 기준은 guard를 없애는 것이 아니라 특권과 fork 코드가 만나는 경로를 없애는 것이다. 라벨·댓글만 필요하면 base checkout을 유지하고, 코드 테스트가 필요하면 pull_request로 옮긴다. opt-out 없이 이 두 조건을 만족할 수 없는 workflow라면, 다시 초록색으로 만드는 것보다 권한과 실행 경로를 먼저 재설계해야 한다.
워크플로를 수정하기 전에 GitHub의 Securely using pull_request_target 지침으로 fork 코드 실행 여부와 권한을 다시 확인한다.