왜 태그 참조가 위험한가
대부분의 워크플로는 uses: actions/checkout@v4처럼 태그나 브랜치로 액션을 참조한다. 그런데 Git 태그는 이동 가능한 포인터일 뿐이다. 액션 저장소가 탈취되거나, 메인테이너 계정이 뚫리거나, 악의적 기여자가 태그를 재작성하면 v4가 가리키는 커밋이 조용히 바뀐다. 워크플로 파일은 그대로인데 실행되는 코드만 달라지는 것이다.
이건 이론이 아니다. 2025년 tj-actions/changed-files 사건처럼 널리 쓰이는 액션의 태그가 변조되어 러너 메모리에서 시크릿을 덤프하는 코드가 삽입된 사례가 실제로 발생했다. CI 러너는 GITHUB_TOKEN, 배포 크리덴셜, 클라우드 OIDC 토큰에 접근하므로 액션 하나가 곧 조직 전체의 공급망 진입점이 된다.
해결책: 전체 커밋 SHA로 고정
방어의 핵심은 이동 가능한 태그 대신 불변(immutable)한 전체 40자 커밋 SHA로 액션을 고정하는 것이다. SHA는 커밋 내용의 해시이므로 위조가 사실상 불가능하고, 한 번 고정하면 그 커밋의 코드만 실행된다. 태그가 재작성돼도 우리 워크플로는 영향받지 않는다.
# 위험: 태그는 언제든 다른 커밋을 가리킬 수 있음
- uses: actions/checkout@v4
# 안전: 40자 SHA로 고정, 주석으로 사람이 읽을 버전 명시
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.2.2
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
뒤에 붙인 # v4.2.2 주석이 중요하다. SHA만 있으면 사람이 어떤 버전인지 알 수 없어 업데이트 판단이 어렵다. Dependabot도 이 주석을 읽어 버전을 추적한다.
참조 방식 비교
| 참조 방식 | 예시 | 불변성 | 권장 |
|---|---|---|---|
| 브랜치 | @main | 없음 | 금지 |
| 이동 태그 | @v4 | 없음 | 비권장 |
| 정확한 태그 | @v4.2.2 | 약함(재작성 가능) | 불충분 |
| 전체 SHA | @08eba0b... | 강함 | 권장 |
짧은 SHA(@08eba0b)는 쓰지 않는다. 짧은 해시는 충돌 여지가 있고 GitHub이 확장 처리하므로, 반드시 전체 40자를 쓴다.
기존 태그를 SHA로 변환하기
워크플로가 많으면 수동 변환은 비현실적이다. 태그가 가리키는 SHA를 API로 조회해 일괄 치환하는 스크립트를 쓴다.
#!/usr/bin/env bash
# 특정 owner/repo@tag 의 전체 커밋 SHA 조회
resolve() {
local repo="$1" tag="$2"
gh api "repos/${repo}/git/refs/tags/${tag}" \
--jq '.object.sha' 2>/dev/null \
|| gh api "repos/${repo}/commits/${tag}" --jq '.sha'
}
# 예: actions/checkout v4.2.2 의 SHA 확인
resolve "actions/checkout" "v4.2.2"
# -> 08eba0b27e820071cde6df949e0beb9ba4906955
조직 차원에서는 pin-github-action이나 ratchet 같은 도구로 리포 전체를 자동 고정하고, 주석 버전까지 붙여준다. 신규 도입 시 한 번 돌려 베이스라인을 잡는 것을 권한다.
고정 상태를 CI에서 강제하기
한 번 고정해도 누군가 새 워크플로에 태그를 넣으면 다시 뚫린다. 그래서 PR 단계에서 SHA 미고정을 검사해 병합을 막아야 한다.
import re, sys, pathlib
# uses: owner/repo@ref 에서 40자 hex SHA가 아니면 위반
USES = re.compile(r'uses:\s*([\w.-]+/[\w.-]+)@(\S+)')
SHA40 = re.compile(r'^[0-9a-f]{40}$')
violations = []
for f in pathlib.Path(".github/workflows").glob("*.y*ml"):
for n, line in enumerate(f.read_text().splitlines(), 1):
m = USES.search(line)
if not m:
continue
action, ref = m.group(1), m.group(2)
# 같은 리포의 로컬 액션(./)이나 docker:// 는 제외
if action.startswith(".") or not SHA40.match(ref):
violations.append(f"{f}:{n} {action}@{ref}")
if violations:
print("SHA 미고정 액션 발견:")
print("\n".join(violations))
sys.exit(1)
이 검사를 별도 워크플로로 두고 필수 체크로 지정하면, 태그 참조가 담긴 PR은 자동으로 실패한다.
업데이트와 운영상 주의점
SHA 고정의 단점은 명확하다. 보안 패치가 나와도 우리가 SHA를 바꾸지 않으면 옛 코드가 계속 돈다. 즉 고정은 자동 업데이트 위험을 수동 업데이트 부담으로 바꾸는 트레이드오프다. 이 부담은 Dependabot으로 상쇄한다.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Dependabot은 SHA로 고정된 액션도 인식해 새 릴리스가 나오면 SHA와 버전 주석을 함께 갱신하는 PR을 열어준다. 사람이 릴리스 노트를 확인하고 병합하므로, 자동성과 검토 사이의 균형이 맞는다.
남은 빈틈
SHA 고정은 만능이 아니다. 첫째, 액션이 내부에서 다른 액션을 태그로 호출하면 그 전이 의존성은 여전히 노출된다. 둘째, 액션이 런타임에 외부 스크립트나 컨테이너 이미지를 curl로 받아오면 SHA 고정을 우회한다. 그래서 최소 권한 permissions: 설정, GITHUB_TOKEN 스코프 축소, 신뢰할 수 있는 액션만 쓰는 허용 목록 정책을 함께 적용해야 한다. 해시 고정은 공급망 방어의 필수 기반이지만, 그 위에 권한 최소화가 얹혀야 실제 방어선이 완성된다.