GitHub Actions 보안 실전: tj-actions 사건에서 배운 3가지 핵심 방어

2025년 3월, GitHub Advisory 하나가 제 보안 인식의 경계를 새로 그었습니다. tj-actions/changed-files, 제가 3년 동안 써 오던 이 Action이 CVE-2025-30066으로 표시된 것입니다. 23,000개가 넘는 저장소가 하룻밤 사이 Secrets 유출 위험에 노출되었습니다.
더 섬뜩했던 것은 공격 방식이었습니다. 공격자는 먼저 한 프로젝트의 PAT를 훔친 뒤 tj-actions의 버전 태그를 변조했습니다. 원래 안전한 코드를 가리키던 태그가 악성 스크립트를 향하도록 바뀐 것입니다. 그리고 Secrets, 즉 AWS 키, 데이터베이스 비밀번호, API Token이 조용히 공개 로그로 흘러 나갔습니다.
그때 저는 꽤 충격을 받았습니다. 직접 설정한 CI/CD 흐름이 공격자의 발판이 될 수 있다는 사실 때문이었습니다. 급히 모든 저장소를 뒤져 Action 버전 참조, GITHUB_TOKEN 권한, 감사 로그 설정을 확인했습니다. 하루 종일 손을 본 뒤에야, 이미 오래전부터 놓치고 있던 세부 사항이 많다는 것을 알게 되었습니다.
이 글은 그때의 “식은땀 나는 순간” 이후 정리한 방어 체크리스트입니다. 공급망 공격의 전형적인 방식, Secrets 관리의 올바른 자세, GITHUB_TOKEN 권한 제어의 세부 사항, 그리고 GitHub 2026 보안 로드맵에서 미리 준비할 만한 새 기능을 함께 살펴보겠습니다.
핵심은 이것입니다. 여기서 말하는 설정들은 새 기능 출시를 기다리지 않아도 지금 바로 적용할 수 있습니다.
tj-actions 사건으로 보는 CI/CD 공급망 공격
공격은 어떻게 일어났나
먼저 tj-actions/changed-files라는 Action부터 보겠습니다. GitHub에서 꽤 인기 있는 Action으로, PR에서 어떤 파일이 변경되었는지 감지하는 데 사용됩니다. 많은 CI/CD 흐름에서 쓰이고, 제 프로젝트 몇 개도 증분 배포 판단에 이 Action을 의존하고 있었습니다.
공격 흐름은 대략 이렇습니다.
공격자는 먼저 reviewdog/action-setup 프로젝트에서 PAT, 즉 개인 액세스 토큰을 훔쳤습니다. 이 PAT에는 저장소 쓰기 권한이 있었습니다. 토큰을 손에 넣은 뒤 공격자는 그것을 사용해 tj-actions 저장소의 버전 태그를 변조했습니다. 안전한 코드를 가리키던 v45 태그가 악성 로직이 심어진 새 커밋을 조용히 가리키도록 바꾼 것입니다.
아직 uses: tj-actions/changed-files@v45를 사용하던 프로젝트들은 아무것도 모른 채 악성 코드를 내려받았습니다. 악성 스크립트가 하는 일은 단순했습니다. CI/CD 환경 안의 Secrets를 전부 로그에 출력하는 것이었습니다. 로그가 공개 상태라면 Secrets는 그대로 유출됩니다.
GitHub Advisory 보고서에 따르면 23,000개가 넘는 저장소가 영향을 받았습니다. 이후 Coinbase 보안팀은 이번 공격이 70,000명이 넘는 고객 데이터에도 영향을 주었다고 공개했습니다.
내가 밟았던 함정: 태그 참조 vs SHA 고정
제 저장소를 점검하면서 많은 곳에서 태그 참조를 쓰고 있다는 것을 발견했습니다.
# 잘못된 예: 변경 가능한 태그 사용
- name: Check changed files
uses: tj-actions/changed-files@v45
태그는 살아 움직입니다. 저장소 유지보수자, 또는 쓰기 권한을 획득한 공격자는 언제든 태그가 새 커밋을 가리키도록 바꿀 수 있습니다. 나는 v45를 참조한다고 생각하지만 실제로 실행되는 코드는 변조된 악성 코드일 수 있습니다.
올바른 방법은 전체 SHA로 고정하는 것입니다.
# 올바른 방법: 전체 SHA 사용
- name: Check changed files
uses: tj-actions/changed-files@6cbf527e7a7b6d61c4e7f25e5ce5f7b7c8f3c72a
SHA는 고정되어 있습니다. 직접 참조를 업데이트하지 않는 한 실행되는 코드는 그 한 조각 그대로이며, 영원히 바뀌지 않습니다.
당시 저는 수정하면서 스스로에게 화가 났습니다. 이건 명백히 기본 보안 상식인데, 왜 계속 신경 쓰지 않았을까 싶었습니다.
서드파티 Action의 신뢰 비용
tj-actions 사건은 또 다른 문제도 드러냈습니다. 우리는 서드파티 Action을 너무 쉽게 신뢰합니다.
아무 Action이나 stars 수가 좀 많고 사용하는 사람이 많으면 곧바로 프로덕션 CI/CD 흐름에 넣어 버립니다. 하지만 유지보수자의 보안 의식이 어떤지, 그들의 PAT가 탈취되지 않을지 누가 알 수 있을까요?
GitHub 2026 보안 로드맵에는 한 가지 해법이 언급되어 있습니다. 워크플로 수준 의존성 잠금입니다. package-lock.json과 비슷하게 모든 Action의 SHA를 하나의 잠금 파일에 고정하는 방식입니다. 아직 이 기능은 출시되지 않았지만, 지금도 수동으로 같은 원칙을 적용할 수 있습니다. 모든 Action 참조를 SHA로 바꾸고 정기적으로 감사하면 됩니다.
또 다른 권장 사항은 서드파티 Action의 수를 줄이는 것입니다. 공식 Action으로 해결할 수 있다면 서드파티를 쓰지 않는 편이 좋습니다. 예를 들어 파일 변경 감지는 사실 GitHub 공식 actions/checkout과 shell 스크립트를 조합해 구현할 수 있으므로, 반드시 tj-actions에 의존할 필요는 없습니다.
Secrets 관리의 3가지 계층과 고급 실전
GitHub Secrets의 3단계 저장
GitHub 내장 Secrets는 세 가지 계층으로 나뉩니다. 조직 수준, 저장소 수준, 환경 수준입니다.
조직 수준 Secrets는 여러 저장소에서 공유할 수 있어 AWS 키나 클라우드 서비스 Token 같은 공통 자격 증명에 적합합니다. 저장소 수준 Secrets는 현재 저장소에서만 사용할 수 있으므로 프로젝트 전용 데이터베이스 비밀번호에 적합합니다. 환경 수준 Secrets는 더 세밀하며, 환경 보호 규칙과 함께 사용할 수 있습니다. 예를 들어 PR 승인이 통과되어야 프로덕션 환경의 Secrets에 접근하게 만들 수 있습니다.
저장 방식 측면에서 GitHub는 libsodium sealed box로 암호화합니다. Secrets는 기록한 뒤 다시 읽을 수 없고, 워크플로 실행 중 secrets 컨텍스트를 통해서만 접근할 수 있습니다.
steps:
- name: Deploy to AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
흔한 실수는 Secrets를 shell 명령 안에 직접 보간하는 것입니다.
# 위험: Secrets가 로그에 출력될 수 있음
- name: Configure AWS
run: aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
Secrets 값에 특수 문자가 있거나 명령 실행이 실패하면 로그에 Secrets가 노출될 수 있습니다. 올바른 방법은 환경 변수로 전달하는 것입니다.
# 안전: 환경 변수로 전달
- name: Configure AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID"
동적 마스킹: ::add-mask::
일부 민감 데이터는 미리 저장된 Secrets가 아니라 실행 중 생성됩니다. 예를 들어 어떤 스크립트가 출력하는 임시 Token이 그렇습니다. 이럴 때는 ::add-mask::로 동적으로 가릴 수 있습니다.
- name: Generate temporary token
run: |
token=$(generate-token.sh)
echo "::add-mask::$token"
echo "TOKEN=$token" >> $GITHUB_ENV
마스킹된 값은 로그에서 ***로 표시됩니다. 하지만 주의할 점이 있습니다. 마스킹은 값이 출력되기 전에 일어나야 합니다. 먼저 출력하고 나중에 마스킹하면 로그에는 여전히 노출됩니다.
고급 방안: HashiCorp Vault OIDC 통합
프로젝트의 보안 요구가 높다면 GitHub 내장 Secrets만으로는 부족할 수 있습니다. 장기 자격 증명이 GitHub에 저장되어 있으면 저장소가 침해되는 순간 Secrets 전체가 위험해집니다.
더 나은 방안은 HashiCorp Vault와 OIDC(OpenID Connect)를 조합해 자격 증명 없는 접근을 구현하는 것입니다. 원리는 GitHub Actions가 Vault에 “내가 누구인지”를 증명하고, Vault가 검증한 뒤 단기 Token을 발급하는 방식입니다. 이렇게 하면 GitHub에는 장기 자격 증명을 전혀 저장하지 않습니다.
설정 절차는 대략 다음과 같습니다.
1단계: Vault에 OIDC 역할 설정
Vault는 GitHub의 OIDC 제공자를 신뢰해야 합니다. 어떤 저장소가 어떤 Secrets를 가져갈 수 있는지 지정하는 역할을 설정합니다.
resource "vault_jwt_auth_backend_role" "github_actions" {
backend = "jwt"
role_name = "github-actions-role"
bound_audiences = ["https://github.com/your-org"]
user_claim = "repository"
role_type = "jwt"
token_policies = ["ci-policy"]
token_ttl = "1h"
}
2단계: GitHub Actions에서 vault-action 사용
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Import Secrets from Vault
uses: hashicorp/[email protected]
id: vault
with:
url: https://vault.example.com:8200
role: github-actions-role
method: jwt
secrets: |
secret/data/ci/aws accessKey | AWS_ACCESS_KEY_ID ;
secret/data/ci/aws secretKey | AWS_SECRET_ACCESS_KEY
- name: Deploy with AWS credentials
run: |
echo "Accessing AWS with Vault-provided credentials"
aws s3 ls
이 흐름에서 Vault-action은 GitHub가 제공하는 OIDC Token을 사용해 Vault에 자동 인증합니다. Vault는 검증 후 Secrets를 반환하고 환경 변수로 주입합니다. 1시간 뒤 Token은 자동 만료되며, 다음 실행 때 다시 가져옵니다.
Azure Key Vault도 비슷한 OIDC 통합을 지원합니다. 설정 방식도 거의 비슷하며, azure/login Action과 Azure Key Vault Secrets를 함께 사용합니다.
GITHUB_TOKEN 권한 제어 실전
GITHUB_TOKEN이란 무엇인가
GitHub Actions 워크플로가 실행될 때마다 자동으로 GITHUB_TOKEN 하나를 받습니다. 이것은 현재 저장소를 조작하기 위한 임시 OAuth Token입니다. 예를 들어 Release 생성, 코드 푸시, PR 댓글 작성에 사용됩니다.
문제는 GITHUB_TOKEN의 기본 권한이 너무 크다는 점입니다.
이전 버전의 GitHub Actions에서는 GITHUB_TOKEN의 읽기/쓰기 권한이 거의 모두 열려 있었습니다. 워크플로는 저장소 내용을 마음대로 수정하고, 브랜치를 만들고, 코드를 푸시할 수 있었습니다. 공격자가 어떤 워크플로를 악용하면, 예를 들어 PR로 트리거된 악성 스크립트를 통해, GITHUB_TOKEN은 곧바로 공격자의 무기가 됩니다.
permissions 키의 전체 설정
GitHub는 2021년 4월 permissions 키를 도입해 GITHUB_TOKEN의 권한 범위를 정확히 제어할 수 있게 했습니다.
기본 문법은 다음과 같습니다.
permissions:
actions: read|write|none # Actions 관리
contents: read|write|none # 저장소 콘텐츠
issues: read|write|none # Issue 작업
packages: read|write|none # GitHub Packages
pull-requests: read|write|none # PR 작업
security-events: read|write|none # 보안 이벤트 보고
deployments: read|write|none # 배포 상태
statuses: read|write|none # Commit 상태
중요한 메커니즘이 하나 있습니다. 워크플로에 permissions 키를 쓰는 순간, 명시하지 않은 모든 권한은 자동으로 none이 됩니다. 이것이 바로 “최소 권한”의 보안 경계입니다.
워크플로 수준 vs 작업 수준 권한
permissions는 두 계층에 쓸 수 있습니다. 워크플로 수준, 즉 전역 설정과 작업 수준, 즉 로컬 설정입니다.
워크플로 수준 권한은 모든 작업에 적용됩니다.
name: CI Pipeline
permissions:
contents: read # 모든 작업은 기본적으로 저장소 콘텐츠를 읽기만 함
issues: write # 모든 작업은 Issue를 쓸 수 있음
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "Lint with read-only access"
test:
runs-on: ubuntu-latest
steps:
- run: echo "Test with read-only access"
작업 수준 권한은 워크플로 설정을 덮어쓸 수 있습니다.
name: Release Pipeline
permissions:
contents: read # 기본은 읽기 전용
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # 읽기 전용 상속
steps:
- run: echo "Build needs read only"
release:
runs-on: ubuntu-latest
permissions:
contents: write # Release 생성을 위해 쓰기 권한으로 덮어씀
packages: write # Packages에 게시
steps:
- name: Create Release
uses: actions/create-release@v1
실제 사례를 하나 들겠습니다. 제 프로젝트 중 하나의 Release 워크플로에서는 build 작업은 코드를 읽기만 하면 되고, release 작업은 Release를 만들고 Docker 이미지를 푸시해야 합니다. 작업 수준 권한으로 격리하면 build 작업이 공격받더라도 저장소 콘텐츠를 쓸 수 없습니다.
저장소 수준 권한 모드
워크플로 설정 외에도 GitHub 저장소 설정에는 두 가지 권한 모드가 있습니다.
- Permissive(완화): GITHUB_TOKEN이 기본적으로 모든 읽기/쓰기 권한을 가집니다. 워크플로에서
permissions를 지정하지 않았을 때 적용됩니다. - Restricted(제한): GITHUB_TOKEN은 기본적으로 contents와 packages 읽기 권한만 가집니다. 쓰기 권한을 얻으려면 워크플로에서 추가로 선언해야 합니다.
모든 저장소를 Restricted 모드로 설정하는 것을 권장합니다. 저장소 Settings > Actions > General에서 “Workflow permissions”를 찾아 “Read repository contents and packages permissions”를 선택하세요.
이렇게 하면 워크플로에서 permissions 작성을 잊어도 과도한 권한이 생기지 않습니다. 보안 경계가 저장소 수준에서 이미 잠깁니다.
감사 로그와 컴플라이언스 점검
워크플로 실행 이벤트의 감사 로그 편입
2021년 2월부터 GitHub는 Actions 워크플로 실행 이벤트를 조직 감사 로그에 포함했습니다. 이제 누가 어떤 워크플로를 트리거했는지, 어떤 권한을 사용했는지, 어떤 Secrets에 접근했는지 추적할 수 있습니다.
감사 로그 위치는 조직 Settings > Security > Audit log입니다.
주요 필드는 다음과 같습니다.
action: 이벤트 유형. 예:workflow_run.create,workflow_run.completeactor: 트리거한 주체. 사용자명, App, 또는github-actions[bot]일 수 있음repo: 저장소 경로token_scopes: 사용된 Token 권한 범위request_id: 로그 연계를 추적하기 위한 요청 ID
실제 활용 예는 비정상 워크플로 실행을 발견했을 때 감사 로그로 원인을 추적하는 것입니다. 예를 들어 어떤 Secrets가 비정상적으로 접근되었다면 workflow_run 이벤트를 조회해 트리거한 주체를 찾을 수 있습니다.
기업 감사 API
조직이 GitHub Enterprise를 사용한다면 Audit Log API로 모든 작업을 조회할 수 있습니다.
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://api.github.com/enterprises/YOUR_ENTERPRISE/audit-log?phrase=workflow_run"
반환되는 JSON에는 상세한 이벤트 기록이 포함됩니다. 이를 SIEM 시스템, 예를 들어 Splunk나 Datadog으로 내보내 지속적으로 모니터링할 수 있습니다.
컴플라이언스 요구 사항 간단 정리
프로젝트가 SOC 2 또는 ISO 27001 컴플라이언스를 충족해야 한다면 CI/CD 보안은 필수 항목입니다. 감사 로그는 가장 직접적인 컴플라이언스 증거입니다. CI/CD 활동을 추적하고, 이상 징후를 발견하며, 사건에 대응할 능력이 있음을 증명하기 때문입니다.
추천 설정은 감사 로그를 외부 시스템으로 내보내고 핵심 이벤트에 대한 알림 규칙을 설정하는 것입니다. 예를 들면 다음과 같습니다.
- 워크플로 실행 실패율 급증
- Secrets 비정상 접근, 예를 들어 짧은 시간 안에 대량 읽기
- 낯선 IP에서 워크플로 실행
GitHub 2026 보안 로드맵 미리보기
GitHub는 2026년 3월 Actions 보안 로드맵을 발표했고, 6가지 주요 새 기능을 계획했습니다. 일부는 이미 출시되었고, 일부는 아직 개발 중입니다.
워크플로 수준 의존성 잠금
이는 tj-actions 유형 공격에 대한 공식 해결책입니다. package-lock.json과 비슷하게 모든 Action 참조를 SHA에 잠급니다. 워크플로에는 여전히 uses: tj-actions/changed-files@v45라고 쓰지만, 잠금 파일에는 해당 SHA가 기록됩니다. 유지보수자가 Action을 업데이트해도 잠금 파일은 자동으로 바뀌지 않습니다. 직접 검토하고 업데이트해야 합니다.
이 기능은 아직 출시되지 않았지만, 지금도 수동으로 SHA 고정을 적용할 수 있습니다.
Layer 7 네이티브 아웃바운드 방화벽
CI/CD 흐름의 외부 네트워크 접근을 네이티브로 제어하는 기능입니다. 예를 들어 워크플로가 당신의 AWS API에만 접근하고 임의의 외부 인터넷에는 접근하지 못하게 제한할 수 있습니다.
현재 이를 구현하려면 자체 호스팅 Runner와 사용자 정의 네트워크 정책이 필요합니다. 2026년에 출시되면 GitHub 클라우드 Runner에서도 아웃바운드 방화벽을 설정할 수 있게 됩니다.
Scoped Secrets
더 세밀한 Secrets 범위 제어입니다. 예를 들어 특정 Secrets를 특정 브랜치나 특정 작업에서만 접근 가능하게 만들 수 있습니다. 현재 Secrets의 범위는 저장소 수준 또는 환경 수준이라 입자가 충분히 세밀하지 않습니다.
정책 기반 실행 제어
신뢰 경계, 승인, 증명 게이트를 정의하는 기능입니다. 예를 들어 fork에서 온 모든 PR은 사람이 승인해야만 워크플로를 트리거할 수 있게 할 수 있습니다. 또는 워크플로가 보안 스캔을 통과해야만 실행되도록 요구할 수 있습니다.
환경 보호 규칙과 비슷하지만, 더 세밀하고 로직도 더 복잡합니다.
Actions Data Stream
CI/CD 활동에 대한 실시간 가시성입니다. 감사 로그와 비슷하지만 사후 조회가 아니라 실시간 푸시입니다. SIEM 시스템에 연결해 실시간 모니터링을 할 수 있습니다.
OIDC 사용자 정의 속성 선언
클라우드 제공자 인증을 강화하는 기능입니다. OIDC Token 안에 저장소 태그나 브랜치 정보 같은 더 많은 사용자 정의 속성을 담을 수 있습니다. Vault나 AWS는 이러한 속성을 기반으로 더 세밀한 권한 판단을 할 수 있습니다.
결론
tj-actions 사건 이후 제가 배운 핵심 교훈은 세 가지입니다.
첫째, SHA 고정입니다. 서드파티 Action을 태그로 참조하지 말고 전체 SHA를 사용하세요. 이는 23,000개 저장소가 대가를 치르고 얻은 경험입니다.
둘째, 최소 권한입니다. GITHUB_TOKEN의 기본 권한은 너무 큽니다. permissions 키로 제한하고 저장소를 Restricted 모드로 설정하세요.
셋째, 감사 로그입니다. Actions 이벤트는 이미 감사 대상에 포함되어 있습니다. 비정상 실행을 정기적으로 확인하고 모니터링 시스템으로 내보내세요.
이 세 가지는 지금 바로 설정할 수 있습니다. GitHub 2026 새 기능 출시를 기다릴 필요도 없고, 새 도구로 갈아탈 필요도 없습니다. 저장소를 열고 Action 참조, 권한 설정, 감사 로그를 점검하세요. 기본 방어는 30분이면 끝낼 수 있습니다.
결국 CI/CD 보안은 대단히 어려운 기술이라기보다 세부 습관에 가깝습니다. SHA 고정이 하나 늘고, 과도한 권한이 하나 줄어들 때마다 위험도는 조금씩 낮아집니다. tj-actions 사건이 알려준 것은 분명합니다. 공격자에게 꼭 고도의 수법이 필요한 것은 아닙니다. 우리가 작은 세부 사항 하나를 놓치면 충분합니다.
GitHub Actions 보안 방어 설정 절차
SHA 고정부터 권한 제어까지, CI/CD 보안 강화를 위한 세 가지 핵심 단계를 완료합니다.
⏱️ Estimated time: 30 min
- 1
Step 1: Action 참조를 SHA로 고정
모든 워크플로 파일을 점검해 `uses: action@tag`를 `uses: action@full-sha`로 바꾸고 태그 변조를 방지합니다. `pinact` 같은 도구로 기존 참조를 일괄 변환할 수 있습니다. - 2
Step 2: permissions 키 설정
워크플로 또는 작업 수준에 permissions 키를 추가하고 필요한 권한만 부여합니다. 예를 들어 읽기 전용 작업에는 `permissions: contents: read`, Release 생성에는 `permissions: contents: write`를 사용합니다. 저장소를 Restricted 모드로 설정해 기본 방어선을 둡니다. - 3
Step 3: 감사 로그 모니터링 활성화
조직 Settings > Security > Audit log에서 Actions 워크플로 실행 이벤트를 확인합니다. 워크플로 실패율 급증, Secrets 비정상 접근, 낯선 IP의 워크플로 실행 같은 핵심 이벤트에 알림을 설정합니다. 로그를 SIEM 시스템으로 내보내 지속적으로 모니터링합니다.
FAQ
왜 태그 참조가 아니라 SHA 고정을 사용해야 하나요?
GITHUB_TOKEN 권한은 어떻게 제어하나요?
워크플로가 공격받았는지 어떻게 감지하나요?
OIDC + Vault는 내장 Secrets와 비교해 어떤 장점이 있나요?
서드파티 Action을 사용해도 되나요?
3분 읽기 · 게시일: 2026년 5월 16일 · 수정일: 2026년 10월 21일



댓글
GitHub로 로그인하여 댓글을 남기세요