Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

GitHub Actions의 pull_request_target과 workflow_run 이벤트

GitHub Actions에서 워크플로우가 트리거되는 조건을 걸 수 있다. 예를 들어 GitHub Actions의 워크플로우에서 다음과 같이 on을 지정하면 해당 저장소에서 pushpull_request 이벤트가 발생했을 때 워크플로우가 시작된다.

on: [push, pull_request]

보통 이 두 이벤트 혹은 push 만을 사용하지만, 협업이 많은 저장소에서는 Pull Request에서 약간의 문제가 있다.

풀 리퀘스트로 트리거 된 워크플로우의 제약

풀 리퀘스트를 만드는 방법에는 2가지가 있다고 할 수 있다.

  1. 저장소의 푸시 권한이 있는 사람이 해당 저장소의 원격 브랜치에서 풀 리퀘스트를 보낸다.
  2. 저장소를 포크한 사람이 포크한 저장소의 브랜치에서 업스트림 저장소로 풀 리퀘스트를 보낸다.

1은 상관이 없는데 2의 방법으로 포크 된 저장소에서 보낸 풀 리퀘스트로 시작된 GitHub Actions에는 다음과 같은 제약이 있다. 여기서 업스트림 저장소라고 부른 것은 포크 된 저장소가 가리키는 원 저장소를 의미한다. GitHub에서 github.com/spring-projects/spring-frameworkgithub.com/outsideris/spring-framework로 포크했을 때 github.com/spring-projects/spring-framework를 의미한다.

  • 업스트림 저장소에 쓰기 권한이 없다.
  • 업스트림 저장소의 시크릿을 읽을 수 있는 권한이 없다.

이 제약 때문에 GitHub 액션을 사용할 때 포크 된 저장소에서 올라온 풀 리퀘스트에서는 다음과 같은 동작을 할 수 없다. 그래서 포크 된 저장소에서 풀 리퀘스트를 많이 받는 오픈소스의 경우 워크플로우에 문제가 생길 수 있다.

  • Actions의 동작으로 풀 리퀘스트에 댓글을 남기려는 경우 할 수 없다.(쓰기 권한 없음)
  • Actions의 동작으로 ESLint의 결과를 코드에 남기려는 경우 댓글을 남길 수 없다.(쓰기 권한 없음)
  • CoverallsCodecov를 연동할 때 시크릿에 저장된 엑세스 키를 사용해야 하므로 연동할 수 없다.(시크릿 접근 불가)
  • BrowserStack이나 Sauce Labs 같은 서비스로 브라우저 테스트를 할 때 시크릿에 저장된 엑세스 키를 사용해야 하므로 연동할 수 없다.(시크릿 접근 불가)

처음 포크 된 저장소의 풀 리퀘스트를 받을 때 헷갈릴 수도 있고 불편해 보일 수 있다. 이런 정책이 없다면 악의적인 의도를 가진 사람이 시크릿은 훔쳐 가거나 저장소에 스팸 댓글 같은 것을 남길 수 있다.

추가로 저장소의 Settings에서 Actions 메뉴에 들어가면 액션에 대한 권한을 설정할 수 있어서 원하는 경우 더 강력한 보안을 적용할 수 있다.

GitHub private 저장소의 Fork pull request 워크플로우 설정

메뉴를 보여주려고 "Allow select actions"를 선택해두었지만, 기본값은 "Allow all actions"다. 위 설명에 나온 대로 기본은 모든 액션을 사용 가능하게 두는 것이지만(읽기 전용에 시크릿에 접근 못 할 뿐이지 액션은 풀 리퀘스트에서 정의해서 사용할 수 있다.) 아예 사용하지 못하게 하거나 저장소 내에서 정의된 것만 실행되게 지정할 수 있다. "Allow select actions"를 선택하면 특정 GitHub 액션만 지정해서 실행되도록 할 수도 있다. 보안이 걱정된다면 이런 부분까지 설정해서 쓰면 더 좋을 것이다.

사용자 삽입 이미지

Private 저장소에는 Fork pull request에 대한 추가 설정을 할 수 있다. Public과 달리 Private 저장소는 풀 리퀘스트를 열 수 있는 사람을 지정할 수 있으므로 여기서 쓰기 권한과 시크릿 접근 권한을 부여할지 말지를 지정할 수 있다. 회사 내에서 포크 모델을 사용하는 경우 악의적인 공격자가 없으니 편하게 사용할 수 있다.

pull_request_target

앞에서 얘기한 fork pull request의 제한을 해결하기 위해 2020년 8월 pull_request_target 이벤트가 추가되었다. 보안 걱정 없이 위 문제를 모두 해결하는 것은 아니고 일부 상황에서 보완해서 사용할 수 있다. 내가 참여하고 있는 오픈소스에서 fork pull request가 시크릿이 없어서 깨지는 문제 때문에 찾아보기 시작했는데 정확한 동작 방식을 이해해야 pull_request_target을 보안 걱정 없이 잘 사용할 수 있다. 참고로 잘못 사용하면 보안에 아주 취약하다.

처음에 이 이벤트를 봤을 때 잘 이해가 되지 않았는데 pull_request_target 문서를 보면 아래와 같이 설명하고 있다.(물론 위험할 수 있으니 조심하라는 얘기도 강조되어 있다.)

This event runs in the context of the base of the pull request, rather than in the merge commit as the pull_request event does. This prevents executing unsafe workflow code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow.

정리하면 pull_request 이벤트가 PR로 올라온 커밋에서 실행되는 반면 pull_request_target은 풀 리퀘스트에 의해서 실행되는 이벤트이지만 풀 리퀘스트가 머지 대상(target)으로 지정한 base를 기준으로 실행된다. 즉, main 브랜치를 base로 올라온 풀 리퀘스트라면 main 브랜치를 기준으로 GitHub Actions가 실행되므로 공격자가 악의적인 코드를 실행할 수가 없다. 대신 base를 기준으로 실행되므로 저장소에 쓰기 권한과 시크릿 접근 권한이 있다.

동작 테스트를 위한 GitHub Actions 워크플로우 정의

동작을 명확하게 이해하기 위해 다음 두 개의 워크플로우 pr.yaml, pr-target.yaml을 정의했다.

# pr.yaml
name: pull-request
on: 
  pull_request

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
      - run: npm test
        env:
          MY_SECRET: '${{secrets.MY_SECRET}}'
# pr-target.yaml
name: pull-request-target
on:
  pull_request_target

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
      - run: npm test
        env:
          MY_SECRET: '${{secrets.MY_SECRET}}'

편의상 필요는 없지만 Node.js를 사용했다. 간단히 설명하면 npm test를 실행하면 현재 실행되는 컨텍스트를 확실히 알 수 있도록 다음과 같이 출력되도록 했고 시크릿이 접근 가능한지 확인하려고 시크릿을 추가한 뒤 출력되도록 했다.

This is main branch.
***

main 브랜치에서 위처럼 출력될 것이다. ***secrets.MY_SECRET이 출력된 부분으로 GitHub Actions가 알아서 마스킹 처리를 해주지만 출력되었다는 건 접근 가능하다는 의미가 된다.

같은 저장소의 브랜치에서 올린 풀 리퀘스트

같은 저장소에서 브랜치로 풀 리퀘스트를 올렸다면 해당 저장소에 푸시 권한이 있는 사람이므로 당연히 쓰기 권한과 시크릿 접근 권한을 가질 수 있다. 코드에서 This is main branch.라고 출력되는 부분만 This is pr-test branch.로 수정해서 커밋한 후 풀 리퀘스트를 올린다.

pull_request 이벤트는 다음과 같이 출력된다.

This is pr-test branch.
***

pull_request_target 이벤트는 다음과 같이 출력된다.

This is main branch.
***

(당연히) 둘 다 시크릿 접근 권한은 있지만 pull_request_target에서는 풀 리퀘스트에서 수정한 메시지가 아니라 main 브랜치의 메시지가 출력되었다. 당연히 풀 리퀘스트가 아니라 main 브랜치에서 실행되었기 때문이다.

포크한 저장소의 브랜치에서 올린 풀 리퀘스트

이번엔 포크한 저장소에서 풀 리퀘스트를 올리는데 이 풀 리퀘스트는 업스트림 저장소의 쓰기 권한과 시크릿 접근 권한이 없다. 이전 예제처럼 출력되는 메시지를 This is forked-pr-test branch from forked repo.로 수정했다.

pull_request 이벤트는 다음과 같이 출력된다.

This is forked-pr-test branch from forked repo.

pull_request_target 이벤트는 다음과 같이 출력된다.

This is main branch.
***

pull_request 이벤트에서는 변경된 메시지가 출력되었지만, 시크릿에 접근하지 못해서 시크릿이 출력되지 않았고 pull_request_target 이벤트에서는 변경된 메시지가 아닌 main 브랜치의 메시지가 출력되면서 시크릿이 출력되었다.

이 정도 봤으면 pull_request_target이 어떻게 동작하는지는 이해했지만 어떻게 활용할지는 애매할 수 있다.

pull_request_target 안전하게 활용하기

GitHub Security LabKeeping your GitHub Actions and workflows secure: Preventing pwn requests라는 글을 통해 pull_request_target을 안전하게 사용하는 방법을 잘 설명하고 있다.

on:
  pull_request_target

jobs:
  build:
    name: Build and test
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
      with:
        ref: ${{ github.event.pull_request.head.sha }}

pull_request_target 이벤트에서도 위처럼 actions/checkout에서 명시적으로 풀 리퀘스트의 Git sha로 지정하면 base가 아니라 풀 리퀘스트의 커밋 위에서 저장소의 쓰기 권한과 시크릿 접근 권한을 줄 수 있다. 편해 보이긴 하지만 아주 위험한 방법이므로 절대 사용하면 안 된다. 이렇게 하면 권한 없는 사용자에게 저장소의 권한을 내어 준 것이나 다름없다. 보안 취약점을 열어주었으므로 다양한 방법으로 저장소에 악의적인 행위를 할 수 있고 AWS나 GCP 같은 타 서비스의 엑세스 키 같은 것을 뺏길 수도 있다. 다시 한번 강조하면 위와 같은 식으로는 사용하면 안 된다.

pull_request_target은 신뢰할 수 없는 코드 위에서 뭔가 실행하거나 빌드하면 안 되고 다음과 같이 안전하다고 생각하는 예시에서만 pull_request_target을 사용하라고 Security Lab은 권장하고 있다.

  • 코드를 리포맷하고 커밋한다.
  • 베이스와 헤드 레파지토리를 체크아웃하고 diff를 생성한다.
  • 체크아웃한 소스에서 grep을 실행한다.

그리고 쓰기 권한과 시크릿이 필요 없다면 pull_request_target 이벤트를 사용하면 안 되고 그냥 pull_request 이벤트를 사용하라고 하고 있다.

Security Lab에서는 추가적인 활용 방법도 안내하고 있다.(코드는 해당 글에서 가져왔다.)

on:
  pull_request_target:
    types: [labeled]

jobs:
  build:
    name: Build and test
    runs-on: ubuntu-latest
    if: contains(github.event.pull_request.labels.*.name, 'safe to test')

pull_request_target 이벤트를 사용하지만, 라벨이 추가되었을 때라는 조건을 걸고 해당 라벨이 지정한 라벨인 safe to test인지 확인한 뒤에 코드를 실행한다. 이는 저장소 소유자가 풀 리퀘스트를 리뷰하고 악의적인 변경사항이 없다고 확인한 뒤에 라벨을 붙이면 실행되는 방식이다. 라벨은 저장소 소유자만 추가할 수 있으므로 그냥 pull_request_target을 사용하는 것보다는 안전하지만 코드 리뷰를 꼭 해야 한다는 불편함이 있고 사람이 하는 일이므로 악의적인 코드를 못 보고 실행할 가능성도 있으니 여전히 위험 요소가 있다고 할 수 있다.

workflow_run 이벤트

pull_request_target 이벤트가 추가될 때 workflow_run 이벤트도 같이 추가되었다.

This event occurs when a workflow run is requested or completed, and allows you to execute a workflow based on the finished result of another workflow. A workflow run is triggered regardless of the result of the previous workflow.

이 이벤트는 GitHub Actions의 다른 워크플로우가 실행이 끝난 후(성공이든 실패든) 실행되는 이벤트이다. 다시 Security Lab의 예제를 참고해 보자.

name: Receive PR

on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Save PR number
        run: |
          mkdir -p ./pr
          echo ${{ github.event.number }} > ./pr/NR
      - uses: actions/upload-artifact@v2
        with:
          name: pr
          path: pr/

pull_request 이벤트의 워크플로우에서는 자식의 작업을 한 뒤에 필요한 데이터를 아티팩트로 만들어서 업로드 한다. 이어질 workflow_run에서는 PR에 대한 정보를 전혀 알지 못하므로 PR 번호를 파일로 만들어서 저장했다.

name: Comment on the pull request

on:
  workflow_run:
    workflows: ["Receive PR"]
    types:
      - completed

jobs:
  upload:
    runs-on: ubuntu-latest
    if: >
      ${{ github.event.workflow_run.event == 'pull_request' &&
      github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: 'Download artifact'
        uses: actions/github-script@v3.1.0
        with:
          script: |
            ...
      - run: unzip pr.zip

      - name: 'Comment on PR'
        uses: actions/github-script@v3
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

이 예제는 풀 리퀘스트에 댓글을 다는 워크플로우가 github-script로 긴 코드가 있는데 여기서는 중요하지 않아서 제거했다.
"Receive PR" 워크플로우가 완료되면 workflow_run을 통해서 이 워크플로우("Comment on the pull request")가 실행되는데 여기서 성공한 경우만 실행되도록 조건을 걸어주고 아티팩트를 받아온 뒤에 해당 풀 리퀘스트에 댓글을 단다.

여기서 workflow_run은 해당 저장소의 쓰기 권한과 시크릿 접근 권한이 있다. 이 글에서 계속 강조하지만 신중하게 조심히 사용해야 한다.

아티팩트도 신뢰할 수 없는 풀 리퀘스트에서 생성된 것이므로 완전히 안전하다고 믿을 수 없다. 위처럼 필요한 (안전한) 정보만 아티팩트로 넘기거나 커버리지를 보고하기 위해 커버리지 측정 결과 파일만 아티팩트로 넘겨서 안전하다고 확신한 상태에서 workflow_run을 사용해야 한다. 풀 리퀘스트의 코드에 기반한 빌드 파일도 기본적으로 신뢰할 수 없다고 생각해야 한다.



pull_request_target, workflow_run을 잘 이용하면 기존에 ``pull_request`에서는 할 수 없던 워크플로우를 만들 수 있다.(원래 이런 걸 위해서 스케줄 잡을 돌리고 그랬다) 사실 이걸 자세히 봤던 이유는 오픈소스 프로젝트에서 Sauce Labs를 이용한 브라우저 테스트가 포크 된 저장소의 풀 리퀘스트에서는 동작하지 않았기 때문이다.(Sauce Labs의 엑세스 키를 알지 못하므로 접속되지 않는다) 이 부분을 해결해 줄 수 있을까 해서 보기 시작한 것이었는데 브라우저 테스트는 풀 리퀘스트의 코드 위에서 실행할 수밖에 없으므로 이 두 이벤트를 이용해도 안전하게 실행할 수 있는 자동화된 방법은 없어 보인다. 그래도 덕분에 추가된 줄도 몰랐던 새 이벤트를 알게 되었다.

2021/03/30 03:30 2021/03/30 03:30