PR 에 반응하는 트리거가 둘인데, 권한이 정반대다.
pull_request | pull_request_target | |
|---|---|---|
| 어느 코드의 워크플로가 도나 | PR 브랜치의 워크플로 | 베이스 브랜치의 워크플로 |
| 토큰 권한 | 읽기로 제한 | 쓰기 가능 |
| 포크 PR 에서 시크릿 | 접근 불가 | 접근 가능 |
왜 이런 것이 있나
pull_request 만 있으면 외부 기여자의 PR 에서 아무것도 못 한다 — 라벨을 붙이거나, 커버리지를 코멘트로 남기거나, 검토자를 지정하는 일까지 막힌다. 그런 작업을 하라고 만든 것이 pull_request_target 이다.
핵심은 베이스 브랜치의 워크플로가 돈다는 점이다. 공격자가 PR 로 워크플로 파일을 고쳐도 그것은 실행되지 않는다. 그래서 시크릿을 줘도 안전하다 — PR 코드를 실행하지 않는 한.
여기가 무너지는 지점이다
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ✗ PR 코드를 가져온다
- run: npm ci && npm test # ✗ 그 코드를 실행한다
npm ci 하나로 끝난다. package.json 의 설치 훅에 공격자가 원하는 명령을 넣어 두면, 시크릿과 쓰기 토큰을 가진 채로 그것이 실행된다. 테스트 코드, 빌드 스크립트, 설정 파일 — PR 이 고칠 수 있는 모든 것이 실행 경로다.
이 조합은 워낙 흔해서 별도의 이름이 붙어 있을 정도이고, 실제 유출 사고가 반복해서 나왔다.
안전하게 쓰는 법
- PR 코드를 체크아웃하지 않는다 — 메타데이터(제목·라벨·작성자)만 다룬다
- 코드를 봐야 한다면 권한 없는 잡에서 검사하고, 그 결과만 별도 워크플로가 받아 처리한다
- 시크릿이 필요 없는 일이면 그냥
pull_request를 쓴다
판단 기준은 하나다 — PR 이 통제하는 것이 실행되는가. 실행된다면 시크릿이 있어서는 안 된다.
검사와 권한을 분리하는 패턴
PR 코드를 정말 실행해야 하는데 결과를 코멘트로 남겨야 한다면, 두 워크플로로 쪼갠다.
① pull_request 로 실행 — 시크릿 없음, 읽기 권한
PR 코드를 빌드·테스트하고 결과를 아티팩트로 저장
② workflow_run 으로 실행 — 베이스 브랜치 워크플로, 권한 있음
①의 아티팩트만 받아 코멘트를 남긴다
신뢰할 수 없는 코드를 실행하는 곳에는 권한이 없고, 권한이 있는 곳에서는 코드를 실행하지 않는다. 두 성질을 한 잡에 섞지 않는 것이 원칙이고, 아티팩트가 그 사이를 잇는 통로다.
②에서도 방심하면 안 된다. ①이 만든 아티팩트 내용은 여전히 PR 이 정한 것이므로, 그 값을 셸에 그대로 넣으면 인젝션이 된다. 경계를 넘어온 값은 계속 데이터로 다룬다.
이미 쓰고 있다면 무엇을 보나
grep -rn "pull_request_target" .github/workflows/
찾았다면 그 워크플로에서 딱 두 가지를 확인한다 — checkout 이 PR 쪽을 가리키는가, 그리고 PR 이 고칠 수 있는 파일이 실행되는가(설치 훅·빌드 스크립트·테스트). 둘 다 아니라면 안전하다.
면접 함정
- ❌ "
pull_request_target은 그냥 더 강력한 버전" → 신뢰 모델이 다르다. 코드 실행과 함께 쓰면 안 된다. - ❌ "우리 저장소는 비공개라 괜찮다" → 포크가 없더라도 권한 경계가 흐려진다.
- ❌ "테스트만 돌리는 건 안전" → 테스트 코드도 PR 이 고칠 수 있다.