CI/CD 파이프라인을 몇 년 굴려본 사람이라면 "워크플로우 YAML은 코드가 아니라 설정"이라는 착각을 한 번쯤 해봤을 거다. 이번 Wiz의 Snowflake 사례는 그 착각이 얼마나 비싼지 보여준다. GitHub 이슈 제목 한 줄로 Actions 러너에서 임의 명령이 실행됐고, 거기서 새어 나간 토큰으로 Snowflake 사내 Jira에 읽기 접근이 됐다.
1. 무슨 일이 있었나: 이슈 제목 → 러너 셸 → Jira 토큰
Wiz의 자율 에이전트(Red Agent)가 snowflakedb/snowflake-connector-net 리포의 jira_issue.yml 워크플로우에서 스크립트 인젝션을 찾아냈다. 타임라인이 이 사건의 핵심이다.
- 2026-06-18: PR #1218이 머지되며 취약 패턴이 라이브. 스쿼시 커밋의 co-author에 "Copilot Autofix powered by AI"가 찍혀 있다.
- 2026-06-23: 5일 만에 Red Agent가 발견·익스플로잇·HackerOne 리포트. Snowflake는 당일 패치.
- 2026-06-24: Jira 토큰 폐기·로테이션. 감사 로그상 노출 기간 중 접근한 건 Wiz의 IP뿐이었다고 확인.
문제의 변경은 이랬다. 원래 있던 안전한 패턴을 지우고 직접 보간으로 바꿨다.
- env:
- ISSUE_TITLE: ${{ github.event.issue.title }}
- run: jq -n --arg title "$ISSUE_TITLE" ...
+ run: TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
추가로 "보안 게이트"처럼 생긴 조건도 무력했다. if: (github.event_name == 'issues' || github.event.pull_request.user.login != 'whitesource-for-github-com[bot]') — issues 이벤트에서는 github.event.pull_request가 항상 null이라 뒤쪽 비교는 언제나 참이 된다. 게이트가 아니라 열린 문이었다.
탈취된 토큰은 qa@snowflake.net 계정으로 붙었고, 엔지니어링·보안 컴플라이언스·버그바운티 트래킹 프로젝트까지 읽을 수 있었다. 워크플로우 하나의 시크릿이 조직 전체 이슈 트래커로 이어진 셈이다.
2. 원리: 치환은 셸보다 먼저 일어난다
많은 사람이 ${{ }}를 "셸 변수 같은 것"으로 오해한다. 아니다. GitHub Actions의 표현식은 러너가 스크립트 파일을 만들기 전에 템플릿 단계에서 문자열로 치환된다. 즉 sed로 이스케이프한다는 발상 자체가 순서상 성립하지 않는다. sed는 이미 만들어진 스크립트가 실행될 때 돌아가는데, 그때는 공격자 문자열이 이미 셸 소스코드의 일부다.
로컬에서 그대로 재현해보자. 템플릿 치환을 sed로 흉내 낸다.
$ cat > vuln.tmpl <<'EOF'
TITLE=$(echo '__TITLE__' | sed 's/"/\\"/g')
echo "title is $TITLE"
EOF
$ TITLE_INPUT="'; id; echo '"
$ sed "s|__TITLE__|$TITLE_INPUT|" vuln.tmpl > vuln.sh
$ cat vuln.sh
TITLE=$(echo ''; id; echo '' | sed 's/"/\\"/g')
echo "title is $TITLE"
$ bash vuln.sh
uid=1000(runner) gid=1000(runner) groups=1000(runner)
title is
id가 그냥 실행된다. 러너 위에서는 이 자리에 curl이 들어가고, 프로세스 환경에 로드된 $JIRA_API_TOKEN이 base64로 인코딩돼 외부 도메인으로 나간다. 원문에 실린 페이로드도 정확히 그 구조다.
안전한 패턴은 하나뿐이다. 신뢰할 수 없는 값을 셸 소스코드가 아니라 데이터 채널(환경변수)로 넘기는 것.
- name: Create Jira issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }} # 여기서만 치환, 값은 env로
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
payload=$(jq -n --arg t "$ISSUE_TITLE" --arg b "$ISSUE_BODY" \
'{fields:{summary:$t, description:$b}}')
curl -sS -X POST "$JIRA_BASE_URL/rest/api/2/issue" \
-u "$JIRA_USER_EMAIL:$JIRA_API_TOKEN" \
-H 'Content-Type: application/json' --data "$payload"
env:로 바인딩하면 값은 러너의 환경변수 테이블에 들어가고 셸 파서를 거치지 않는다. JSON을 만들 때 문자열 붙이기 대신 jq --arg를 쓰는 것도 같은 이유다. 따옴표·개행·백슬래시를 jq가 알아서 이스케이프해준다. 참고로 "$ISSUE_TITLE"처럼 큰따옴표로 감싸는 것까지 해야 완성이다. 벗겨두면 워드 스플리팅으로 또 샌다.
3. 실무 관점: 시크릿은 왜 거기 있었나, 그리고 흔한 함정
이 사건의 진짜 아픈 지점은 "인젝션이 됐다"가 아니라 "인젝션된 러너에 프로덕션 Jira 토큰이 있었다"다. 트리거 이벤트별 권한 모델을 정리하면 이렇다.
pull_request(포크 PR): 베이스 리포의 시크릿에 접근 불가,GITHUB_TOKEN도 읽기 전용. 상대적으로 안전.issues,issue_comment,pull_request_target: 기본 브랜치의 워크플로우 정의가 리포 시크릿과 함께 실행된다. 이슈를 여는 건 누구나 할 수 있으니, 인증 없는 외부인이 시크릿 컨텍스트를 건드릴 수 있는 표면이 열린다.
특히 pull_request_target에서 actions/checkout으로 PR의 head.sha를 체크아웃한 뒤 빌드 스크립트를 돌리는 패턴은 사실상 "남의 코드를 내 시크릿과 함께 실행"이다. 라벨 기반 승인 없이 이 조합을 쓰고 있다면 오늘 당장 확인하는 걸 권한다.
흔한 함정 ①: 에러가 났으니 안전하다는 착각
원문에서 인상적인 대목. Red Agent의 첫 페이로드는 #로 뒷부분을 주석 처리하려다 TITLE=$(의 닫는 괄호까지 먹어버려 실패했다. 러너 로그에는 이런 게 찍힌다.
/home/runner/work/_temp/8f2c1a3e-....sh: line 3: unexpected EOF while looking for matching `)'
/home/runner/work/_temp/8f2c1a3e-....sh: line 5: syntax error: unexpected end of file
##[error]Process completed with exit code 2.
실무에서 이 로그를 보면 대부분 "이상한 제목 때문에 잡 깨졌네, 이슈 제목 좀 정상적으로 써주세요"로 끝난다. 하지만 이건 인젝션이 막힌 신호가 아니라 인젝션이 성립한다는 신호다. 공격자 입력이 셸 파서에 도달했다는 증거니까. Red Agent는 이 에러를 읽고 페이로드를 ' ; curl ... ; echo ' 형태로 바꿔 바로 성공했다. 사람 개입 없이. CI 로그에서 syntax error: unexpected end of file이 뜨는 잡이 있다면 인젝션 관점으로 한 번 더 봐야 한다.
흔한 함정 ②: env로 옮겼는데 여전히 새는 경우
흔한 반쪽짜리 수정들이다.
# 나쁨: env로 옮겼지만 다시 표현식으로 씀
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "${{ env.ISSUE_TITLE }}" # 결국 템플릿 치환 → 동일 취약
# 나쁨: 문자열 결합으로 JSON 조립
run: curl -d "{\"summary\":\"$ISSUE_TITLE\"}" ... # 따옴표 하나에 JSON 붕괴
# 나쁨: actions/github-script 안의 백틱 템플릿
script: console.log(`${{ github.event.issue.title }}`) # JS 컨텍스트 인젝션
규칙은 단순하다. ${{ }}는 env:, with: 블록에서만 쓰고 run:/script: 본문에는 등장시키지 않는다.
정적 탐지 붙이기
사람 리뷰에 기대지 말고 파이프라인에 넣자. 먼저 조직 리포에서 후보를 훑는 grep 한 줄.
$ grep -rnE '\$\{\{ *github\.event\.(issue|pull_request|comment|review)\.' \
.github/workflows/
.github/workflows/jira_issue.yml:31: TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)
.github/workflows/pr-label.yml:18: echo "branch=${{ github.event.pull_request.head.ref }}"
그 다음 actionlint로 정식 검사. 이 도구는 신뢰할 수 없는 입력이 인라인 스크립트에 들어간 걸 별도 룰로 잡아준다.
$ actionlint .github/workflows/jira_issue.yml
.github/workflows/jira_issue.yml:31:24: "github.event.issue.title" is potentially untrusted.
avoid using it directly in inline scripts. instead, pass it through an environment
variable. see https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
for more details [expression]
|
31| TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
(출력 포맷은 버전에 따라 다를 수 있으니 실제 실행 결과 기준으로 확인하자.) GitHub Actions 전용 정적 분석 도구로 zizmor도 있고, pull_request_target + 체크아웃 조합 같은 패턴을 함께 잡아준다. 어느 쪽이든 PR 체크로 걸어서 병합 차단까지 연결해야 의미가 있다. 리포트만 쌓이는 스캐너는 이번 케이스를 못 막는다.
Copilot Autofix를 어떻게 다뤄야 하나
원문 업데이트 노트를 보면, Copilot이 코드를 작성했는지는 불분명하고 최소한 머지된 PR을 확인하고 "이상 없음"으로 통과시킨 co-author였다. 여기서 배울 건 "AI가 나쁘다"가 아니라 두 가지다.
첫째, AI 도구는 왜 이 패턴을 썼는지에 대한 히스토리 컨텍스트가 없다. env: + jq --arg는 인젝션을 막으려고 일부러 선택한 구조인데, 그 의도가 코드에 주석으로 남아 있지 않으면 "더 간결한" 문자열 보간으로 되돌아갈 수 있다. 보안 목적의 구조에는 이유를 주석으로 박아두고, CODEOWNERS로 .github/workflows/**를 보안/플랫폼 팀 필수 리뷰로 묶는 게 현실적인 방어다.
둘째, AI가 리뷰했다는 사실이 게이트를 대체하면 안 된다. AI 리뷰는 사람 리뷰와 같은 등급이지 정적 분석·정책 검사 위의 등급이 아니다. AI가 생성했든 사람이 짰든 동일한 스캐너를 통과해야 한다.
4. 정리: 오늘 확인할 체크리스트
한 줄 요약 — run: 블록 안의 ${{ github.event.* }}는 전부 원격 코드 실행 후보다. 그리고 그 러너에 붙어 있는 시크릿의 권한이 곧 사고의 폭발 반경이다.
.github/workflows/전체를 grep + actionlint로 훑고,run:/script:안의 표현식은 전부env:바인딩으로 이동.- JSON을 만드는 곳은 문자열 결합 금지,
jq --arg/--argjson으로 통일. issues,issue_comment,pull_request_target트리거 워크플로우 목록을 뽑아 각각 "외부인이 트리거 가능한가 / 시크릿이 필요한가"를 표로 정리. 필요 없는 시크릿은 잡 단위로 분리해 격리.- 워크플로우마다
permissions:를 명시(기본은contents: read). 조직 설정에서GITHUB_TOKEN기본 권한을 read-only로. - 장기 API 토큰은 최소 권한 서비스 계정으로. 클라우드 접근은 OIDC 페더레이션으로 단명 크리덴셜 전환. Jira처럼 OIDC가 안 되는 SaaS는 프로젝트 스코프 제한 + 로테이션 주기 단축.
- CI 로그에서
syntax error: unexpected end of file같은 셸 파싱 에러가 뜨는 잡은 "입력 이상"이 아니라 "인젝션 표면"으로 분류해 조사.
가장 무서운 숫자는 5일이다. 취약 코드가 라이브된 지 닷새 만에 자율 에이전트가 찾아내 익스플로잇까지 끝냈다. 분기별 보안 점검, 반기 토큰 로테이션 같은 사이클은 이 속도를 못 따라간다. 워크플로우 변경은 머지 전에 자동으로 막고, 시크릿은 짧게 가져가는 것 말고는 답이 없다.
참고 자료
- Wiz Blog — Wiz Red Agent Finds Its Way Into Snowflake's Internal Jira Through a Flaw in a GitHub Copilot–Assisted PR (원문)
- GitHub Docs — Security hardening for GitHub Actions (스크립트 인젝션 섹션)
- GitHub Docs — Automatic token authentication (GITHUB_TOKEN 권한)
- rhysd/actionlint — GitHub Actions 워크플로우 정적 분석기
- zizmor — GitHub Actions 보안 전용 스태틱 분석 도구
- GitHub Security Lab — Keeping your GitHub Actions and workflows secure: Untrusted input
'Tech_News' 카테고리의 다른 글
| 에이전트 루프로 커널 232배 빠르게: 인프라 엔지니어가 본 "오토리서치"의 실체 (0) | 2026.08.17 |
|---|---|
| 넓게 만들고 좁게 출시하기: AI 시대에 PR 경계를 언제 정할 것인가 (0) | 2026.08.16 |
| macOS VM에서 llama.cpp가 16배 느렸던 이유: Metal supportsFamily 응답 하나 (0) | 2026.08.13 |
| 로컬에서 24시간 도는 에이전트, Muse Glimmer 30B는 현실적인가 (0) | 2026.08.12 |
| Restate 단일 바이너리 vs Temporal 클러스터: 2AM에 뭘 붙잡고 있을지로 고르는 Durable Execution (0) | 2026.08.11 |
