Tech_News

이슈 제목 한 줄로 Jira가 털릴 뻔했다 — Copilot Autofix가 되살린 GitHub Actions 스크립트 인젝션

TeEm0 2026. 8. 19. 09:00
728x90

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.* }}는 전부 원격 코드 실행 후보다. 그리고 그 러너에 붙어 있는 시크릿의 권한이 곧 사고의 폭발 반경이다.

  1. .github/workflows/ 전체를 grep + actionlint로 훑고, run:/script: 안의 표현식은 전부 env: 바인딩으로 이동.
  2. JSON을 만드는 곳은 문자열 결합 금지, jq --arg/--argjson으로 통일.
  3. issues, issue_comment, pull_request_target 트리거 워크플로우 목록을 뽑아 각각 "외부인이 트리거 가능한가 / 시크릿이 필요한가"를 표로 정리. 필요 없는 시크릿은 잡 단위로 분리해 격리.
  4. 워크플로우마다 permissions:를 명시(기본은 contents: read). 조직 설정에서 GITHUB_TOKEN 기본 권한을 read-only로.
  5. 장기 API 토큰은 최소 권한 서비스 계정으로. 클라우드 접근은 OIDC 페더레이션으로 단명 크리덴셜 전환. Jira처럼 OIDC가 안 되는 SaaS는 프로젝트 스코프 제한 + 로테이션 주기 단축.
  6. CI 로그에서 syntax error: unexpected end of file 같은 셸 파싱 에러가 뜨는 잡은 "입력 이상"이 아니라 "인젝션 표면"으로 분류해 조사.

가장 무서운 숫자는 5일이다. 취약 코드가 라이브된 지 닷새 만에 자율 에이전트가 찾아내 익스플로잇까지 끝냈다. 분기별 보안 점검, 반기 토큰 로테이션 같은 사이클은 이 속도를 못 따라간다. 워크플로우 변경은 머지 전에 자동으로 막고, 시크릿은 짧게 가져가는 것 말고는 답이 없다.

참고 자료

728x90