~/infra-dev.md
INFRA_DEV

pull_request_target는 왜 배포 권한으로 PR 코드를 실행하면 안 될까

10분 읽기·2026.07.06·출처 5·00
오늘의 질문

외부 기여자가 만든 PR을 검사하면서도 base repository의 secret과 GITHUB_TOKEN 권한을 쓰는 workflow가 있다면, 우리는 무엇을 먼저 끊어야 할까?

infra-dev.md
PR Trigger Safety

pull_request_target는 왜 배포 권한으로 PR 코드를 실행하면 안 될까

1. 왜 지금 봐야 하나

CI/CD 보안 사고는 대개 “secret을 누가 훔쳤나”로 설명되지만, 실제 운영에서는 secret이 없어도 위험한 경로가 생긴다. 대표적인 예가 GitHub Actions의 pull_request_target이다. 이 이벤트는 fork PR을 다루면서도 workflow 파일은 base branch의 것을 사용하고, base repository의 GITHUB_TOKEN, secret, default-branch cache에 접근할 수 있다. 그래서 maintainer가 의도한 자동화에는 유용하지만, untrusted PR code를 잘못 checkout해서 실행하면 공격자 코드가 privileged context 안으로 들어온다.

GitHub는 2026년 6월 18일 actions/checkout v7을 공개하면서 이 흔한 “pwn request” 패턴을 기본 차단하기 시작했다. fork PR에서 pull_request_target 또는 특정 workflow_run 경로로 PR head나 merge commit을 checkout하려는 입력을 actions/checkout이 거부한다. 2026년 7월 16일부터는 현재 지원되는 major version에도 enforcement가 backport될 예정이며, actions/checkout@v4처럼 floating major tag를 쓰는 workflow는 자동으로 영향을 받는다. 반대로 SHA, minor, patch에 고정한 workflow는 직접 업그레이드해야 한다.

며칠 뒤인 2026년 6월 26일에는 untrusted trigger에 read-only Actions cache token을 발급하는 변화도 발표됐다. 예전에는 pull_request_target, issue_comment, fork PR에서 이어지는 workflow_run 같은 이벤트도 default-branch cache에 쓸 수 있어 cache poisoning 경로가 열릴 수 있었다. 이제는 외부 actor가 trigger할 수 있고 default-branch cache scope를 쓰는 경우 cache write가 제한된다.

오늘 글의 핵심은 “GitHub가 막아주니 끝”이 아니다. 플랫폼 기본값이 좋아져도, workflow 안에 남아 있는 git fetch, gh pr checkout, third-party checkout, cache save, broad token permission은 여전히 운영자가 설계해야 한다.

2. 핵심 개념: 이벤트, 코드, 권한을 분리해서 보자

pull_requestpull_request_target은 이름이 비슷하지만 보안 모델은 다르다.

  • pull_request: PR 변경사항을 검사하기 좋다. fork PR에서는 secret 접근과 token 권한이 제한된다.
  • pull_request_target: base repository context에서 돈다. label 달기, comment 쓰기, status 남기기처럼 maintainer 권한이 필요한 작업에 쓰기 좋다.

문제는 두 요구를 한 job에 섞을 때 생긴다.

zsh — 생존확인.sh
on: pull_request_target
jobs:
  test:
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

이 workflow는 “PR 코드를 테스트한다”는 목적과 “base repository 권한을 가진다”는 조건이 동시에 존재한다. fork PR 작성자가 package.json의 script, test 파일, build step, postinstall script를 바꾸면 그 코드가 base repository의 권한과 가까운 위치에서 실행될 수 있다. 이것이 pwn request의 핵심이다.

GitHub의 actions/checkout v7 변화는 위와 같은 대표 패턴을 막는다. fork PR에서 repository:가 fork repository로 해석되거나, ref:refs/pull/number/head, refs/pull/number/merge, PR head/merge commit SHA로 해석되면 checkout을 거부한다. 하지만 보호 범위가 actions/checkout의 특정 입력에 한정된다는 점도 중요하다. run 블록에서 git이나 gh CLI로 PR head를 직접 가져와 실행하거나, unrelated third-party repository를 받아 실행하는 것은 자동으로 다 막히지 않는다.

3. 최신 변화가 말해주는 운영 방향

이번 GitHub Actions 변경을 하나의 흐름으로 보면 방향이 분명하다. untrusted trigger가 trusted resource를 수정하거나 실행하게 두지 말라는 것이다.

첫째, actions/checkout v7은 privileged event에서 untrusted fork PR code checkout을 거부한다. 이는 실행 경로를 끊는 변화다.

둘째, read-only Actions cache for untrusted triggers는 cache poisoning 경로를 줄인다. untrusted workflow가 default-branch cache에 악성 dependency나 build artifact를 저장하고, 나중에 pushschedule 같은 trusted workflow가 그 cache를 복원해 secret을 가진 채 실행하는 시나리오를 막는 쪽이다. 제한이 걸릴 때 actions/cache는 warning을 남기고 job은 cache save 없이 계속 진행된다. restore는 영향받지 않는다.

셋째, workflow execution protections public preview는 trigger 자체를 policy로 다루게 한다. actor rule은 누가 workflow를 trigger할 수 있는지, event rule은 어떤 event가 허용되는지 제한한다. GitHub 설명처럼 모든 repository의 YAML을 하나씩 믿기보다 조직 ruleset으로 pull_request_target, workflow_dispatch 같은 고위험 trigger를 evaluate mode에서 먼저 관찰하고, 이후 enforce할 수 있다.

이 세 가지는 서로 다른 층이다.

zsh — 생존확인.sh
조직 정책층: 누가/어떤 event가 workflow를 시작할 수 있는가
workflow 설계층: privileged job과 untrusted code 실행 job을 분리했는가
런타임 보호층: checkout/cache/action 기본값이 위험한 경로를 차단하는가

한 층만으로는 부족하다. 예를 들어 조직 정책이 없어도 checkout v7이 대표적인 실수를 막아줄 수 있다. 하지만 run: gh pr checkout 같은 우회 경로가 있으면 workflow 설계층에서 다시 위험해진다. 반대로 workflow가 잘 설계되어도 조직 전체에 오래된 repository가 많으면 centrally enforce할 정책이 필요하다.

4. 개발자 관점 해석: “검사”와 “권한 행사”를 두 workflow로 나누기

실무에서 안전한 패턴은 간단하다. PR 코드를 실행하는 workflow와 repository 권한을 쓰는 workflow를 나눈다.

PR 코드를 실행하는 workflow

이 workflow는 pull_request에서 돈다. secret이 필요 없는 lint, unit test, type check, build 검증을 맡는다. token 권한은 read-only로 둔다.

zsh — 생존확인.sh
name: pr-check
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: npm ci
      - run: npm test

여기서는 PR code를 실행해도 base repository secret을 주지 않는다. 실패 결과는 status로 남기되, 배포 credential, package publish token, production 환경 secret은 접근하지 못하게 한다.

권한을 행사하는 workflow

label, comment, triage, artifact publish, deploy 같은 권한 작업은 base branch 코드만 실행하거나, 사람이 승인한 artifact만 소비해야 한다. pull_request_target을 써야 한다면 PR head를 checkout하지 않는 것을 기본 원칙으로 둔다.

zsh — 생존확인.sh
name: pr-label
on:
  pull_request_target:
    types: [opened, reopened, labeled]
permissions:
  contents: read
  pull-requests: write
jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v8
        with:
          script: |
            // PR metadata만 읽고 label/comment 처리

권한 workflow가 꼭 PR 결과를 참고해야 한다면, untrusted workflow가 만든 artifact를 그대로 실행하지 말고 다음을 지킨다.

  • artifact는 데이터로만 읽고 shell/script로 실행하지 않는다.
  • artifact 이름, path, PR title/body 같은 untrusted input은 shell에 직접 interpolation하지 않는다.
  • environment secret은 GitHub environment required reviewer 뒤에 둔다.
  • GITHUB_TOKEN은 job별 최소 권한으로 명시한다.

5. 내 프로젝트에 적용할 체크포인트

오늘 바로 repository에서 검색할 항목이다.

  • on: pull_request_target workflow가 있는가?
  • 그 안에서 actions/checkout이 PR head, merge ref, fork repository를 checkout하는가?
  • run: 블록에서 git fetch, git checkout, gh pr checkout, curl | sh처럼 untrusted code를 가져와 실행하는가?
  • workflow_run이 fork PR workflow 결과를 받아 privileged 작업을 이어서 수행하는가?
  • issue_comment/test, /deploy, /retest 같은 명령을 받아 workflow를 실행하는가?
  • actions/cache save가 untrusted trigger에서 default-branch cache scope로 수행되는가?
  • permissions:가 repository default에 의존하지 않고 job별 최소 권한으로 적혀 있는가?
  • pull_request_target이 정말 필요한 작업인지, pull_request + 별도 maintainer-only workflow로 나눌 수 있는지 검토했는가?
  • actions/checkout을 v7 또는 조직 정책에 맞는 지원 버전으로 업그레이드할 계획이 있는가?
  • SHA/minor/patch에 pin한 checkout action이 있다면 2026-07-16 backport를 자동으로 받지 못한다는 점을 알고 있는가?

특히 monorepo나 오픈소스 프로젝트는 issue_commentworkflow_run을 같이 봐야 한다. “외부 사용자가 댓글로 테스트 재실행” → “workflow가 PR head checkout” → “cache 또는 artifact를 trusted workflow가 재사용” 같은 간접 경로가 생길 수 있기 때문이다.

6. 오늘 10분 액션

10분 동안 완전한 보안 재설계를 할 필요는 없다. 위험한 조합만 찾아도 충분하다.

  1. repository에서 고위험 trigger를 찾는다.
zsh — 생존확인.sh
grep -R "pull_request_target\|workflow_run\|issue_comment" .github/workflows
  1. 해당 workflow에서 checkout 입력을 확인한다.
zsh — 생존확인.sh
with:
  ref: ${{ github.event.pull_request.head.sha }}
  repository: ${{ github.event.pull_request.head.repo.full_name }}

이런 패턴이 있으면 “privileged event에서 PR code checkout”으로 표시한다.

  1. run: 블록의 수동 checkout도 찾는다.
zsh — 생존확인.sh
grep -R "gh pr checkout\|refs/pull\|github.event.pull_request.head" .github/workflows
  1. permissions:를 workflow 맨 위와 job 단위에서 확인한다. 없으면 기본값에 기대고 있다는 뜻이다. 최소한 contents: read를 기본으로 두고 필요한 job만 pull-requests: write, issues: write, id-token: write를 부여한다.

  2. 팀 문서에 아래 문장을 남긴다.

pull_request_target workflow는 fork PR code를 checkout하거나 실행하지 않는다. PR code 검증은 pull_request에서 수행하고, 권한 작업은 base branch code와 최소 권한 token으로 분리한다.

오늘의 목표는 모든 workflow를 고치는 것이 아니라, “외부 actor가 trigger할 수 있는 job이 trusted resource를 수정하거나 trusted secret에 접근하는가”를 눈으로 확인하는 것이다.

7. 더 볼 자료

중복 회피 메모

로컬 content/generated와 Supabase 최근 infra_dev 글을 확인했다. 최근 글은 GitHub Actions OIDC immutable subject, Kubernetes Indexed Job 실패 예산, Vercel OIDC audience, GitHub Actions VNET failover, AI incident response guardrail, EventBridge 로깅, Lambda durable functions, GitHub Actions SaaS 장애 대응, Cloudflare Queues, PostgreSQL 관측, Docker 보안, GKE capacity buffer, OpenTelemetry Collector, Cloud Run worker backpressure를 다뤘다. 이번 글은 OIDC trust policy나 CI 장애 대응이 아니라, GitHub Actions의 privileged PR trigger인 pull_request_target, actions/checkout v7 기본 차단, untrusted trigger cache write 제한, 조직 trigger policy를 묶어 “외부 PR 코드 실행과 base repository 권한을 분리하는 운영 설계”에 집중해 중복을 피했다.

오늘 10분 액션+5%

댓글 0

최신순 ▾
한 줄 남기기

혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)