2026년 CI/CD 공급망 공격 3가지 유형과 GitLab 방어 전략

2026년 CI/CD 파이프라인을 겨냥한 공급망 공격에는 공통점이 있습니다. 애플리케이션 코드가 아니라 파이프라인을 노려 크리덴셜을 빼냈다는 점입니다.
2월 말, hackerbot-claw라는 자동화 봇이 Trivy 저장소의 pull_request_target 워크플로 오설정을 악용해 서비스 계정의 개인 액세스 토큰을 유출시켰습니다. 공격자는 이 토큰으로 저장소를 장악해 비공개로 전환하고 릴리즈 대부분을 삭제했죠.
Aqua Security가 대응에 나섰지만 크리덴셜 회전이 완전하지 않았습니다. 3월 19일 공격자는 살아남은 크리덴셜로 aquasecurity/trivy-action의 버전 태그 대부분을 악성 커밋으로 옮겼습니다. 0.35.0을 제외한 나머지 전부가 오염됐고, 이 액션을 실행한 파이프라인의 Runner에서 SSH 키와 클라우드 크리덴셜, Kubernetes 토큰이 유출됐고요.
5월 18일에는 공격자가 개발자 단말에서 탈취한 크리덴셜로, build-bot, ci-bot 같은 커밋 작성자 이름을 위장해 6시간여 만에 5561개 저장소에 워크플로를 심었습니다. 대다수는 모든 push와 Pull request(PR)에 실행되도록 설정됐고, 일부는 공격자가 호출해야만 작동하는 잠복형이었습니다. 이 워크플로는 실행될 때마다 CI 시크릿과 클라우드 크리덴셜, SSH 키, OIDC 토큰을 공격자 서버로 전송하도록 설계됐습니다.
이러한 공격이 발생한 원인은 파이프라인 구조 자체에 있습니다. 파이프라인은 워크플로 파일에 적힌 대로 실행하고, 그 실행에는 저장소의 권한이 그대로 따라붙습니다. 공격자는 외부에서 보낸 코드를 실행시키거나, 파일이 참조하는 액션의 태그를 옮기거나, 아예 파일을 바꿔치기해 실행될 코드를 자기 것으로 만들었습니다. 파이프라인은 크리덴셜이 모이는 Runner에서 실행돼 Job 하나가 뚫리면 그 Job에 주입된 크리덴셜이 전부 유출되고요.
이 글은 2026년에 보고된 CI/CD 파이프라인을 겨냥한 공급망 공격을 세 가지 유형으로 정리하고 각 공격이 발생한 원인을 짚습니다. 그다음 GitLab에서 이러한 공격 유형에 어떻게 대응할 수 있는지, GitLab만으로 방어하기 어려운 지점은 무엇인지 살펴봅니다. 보고된 사건은 GitHub Actions에서 나왔지만, 원인은 파이프라인 구조에 공통됩니다.
2026년 CI/CD 공급망 공격은 어떻게 일어났나
2026년에 보고된 공급망 공격을 파이프라인에 들어온 경로로 묶으면 세 가지 유형이 나옵니다. 유형별로 무슨 일이 있었는지 살펴보고, 해당 공격이 왜 발생했는지 함께 알아보겠습니다.

유형 1. 외부에서 온 코드가 저장소 권한으로 실행됐습니다
무슨 일이 있었나
2월 27일, hackerbot-claw라는 자동화 봇이 Trivy 저장소에 PR을 열기 시작했습니다. 이 봇은 2월 20일 계정을 만든 직후부터 3월 초까지 공개 저장소를 훑으며 악용 가능한 GitHub Actions 워크플로를 찾고 있었죠.
Trivy의 API Diff Check 워크플로가 여기에 해당했습니다. 이 워크플로는 pull_request_target 트리거를 쓰면서 PR의 head를 체크아웃해 실행하는 구성이었습니다. 공격자는 그럴듯한 브랜치 이름을 단 PR로 워크플로를 촉발했고요. 페이로드는 Runner의 프로세스 메모리를 읽어 aqua-bot 서비스 계정의 조직 범위 개인 액세스 토큰을 빼냈습니다. 조직 내 최소 33개 워크플로에서 쓰이던 토큰이었습니다.
워크플로가 실행된 2월 28일 03시 28분(UTC), 그로부터 19분 만에 공격자는 이 토큰으로 저장소에 직접 커밋을 밀어 넣었습니다. 이어서 저장소를 비공개로 돌려 이름을 바꾸고 v0.27.0부터 v0.69.1까지 릴리즈를 삭제했으며, Open VSX 마켓플레이스에 악성 VS Code 확장을 올렸죠. 같은 캠페인은 최소 7개 저장소를 대상으로 삼아 12개가 넘는 PR을 열었고, 그중 다수에서 임의 코드 실행에 성공했습니다. Microsoft와 DataDog, CNCF 프로젝트가 여기에 포함됐습니다.
왜 발생했나
트리거와 체크아웃의 조합 때문입니다. pull_request_target으로 실행되는 워크플로는 base 저장소의 GITHUB_TOKEN과 시크릿, 기본 브랜치 캐시 접근 권한을 갖습니다. 여기까지는 의도된 설계입니다. 문제는 이 워크플로 안에서 검토되지 않은 포크의 PR head를 체크아웃할 때 생깁니다. 공격자가 통제하는 코드가 워크플로의 전체 권한으로 실행되죠. GitHub은 이를 pwn request라 부르며, 생태계 전반의 여러 공급망 사고에서 근본 원인이었다고 밝혔습니다.
여기서 사각지대가 생깁니다. 이 워크플로는 정상 동작했습니다. 리뷰 대상이 되는 것은 PR에 담긴 코드인데, 그 코드에 권한을 넘겨준 것은 워크플로 설정입니다.
GitHub은 6월 18일 actions/checkout v7의 기본값을 바꿔 이 패턴을 차단하기 시작했고, 7월 20일부터 지원 중인 다른 메이저 버전에도 같은 규칙을 적용했습니다. 다만 막는 것은 포크 PR의 head와 merge 커밋을 체크아웃하는 경우뿐입니다. GitHub도 밝혔듯 run 블록에서 직접 코드를 받아 실행하는 경우, issue_comment처럼 다른 트리거로 들어오는 경우, 무관한 서드파티 저장소를 체크아웃하는 경우는 그대로 남습니다. allow-unsafe-pr-checkout 입력으로 빠져나갈 수도 있고요. 기본값이 바뀌었다고 해서 자기 워크플로가 자동으로 안전해지지는 않습니다.
유형 2. 신뢰하던 액션의 태그가 옮겨졌습니다
무슨 일이 있었나
Aqua Security는 3월 1일 침해를 공개하고 크리덴셜을 회전했습니다. 그러나 회전이 한 번에 이뤄지지 않았는데요. 유출된 토큰은 aquasecurity 조직 전체에 repo 범위를 가졌고 최소 33개 워크플로에서 쓰이고 있었습니다.
3월 19일 17시 43분(UTC)경, 공격자는 살아남은 크리덴셜로 세 곳을 동시에 건드렸습니다. trivy-action의 버전 태그 77개 중 76개를 악성 커밋으로 force-push했고, setup-trivy의 태그 7개를 전부 교체했으며, 악성 Trivy v0.69.4 바이너리를 릴리즈했습니다. 노출 시간은 trivy-action이 약 12시간, setup-trivy가 약 4시간, 바이너리가 약 3시간이었습니다. 3월 22일에는 별도로 침해된 Docker Hub 크리덴셜로 v0.69.5와 v0.69.6 이미지가 GitHub을 거치지 않고 직접 발행됐고요.
0.35.0 태그만 무사했습니다. 이 태그가 이미 공격자가 기반으로 삼은 master HEAD를 가리키고 있어 교체하면 자기 참조 커밋이 생기고 최신 릴리즈에 주의를 끌 위험이 있었기 때문입니다. GitHub 릴리즈 UI는 포이즈닝된 태그에도 Immutable 배지를 그대로 표시했습니다. 이 배지는 태그 force-push를 막지 못합니다.
악성 코드는 /proc/<pid>/mem으로 Runner.Worker 프로세스 메모리를 덤프해 시크릿을 추출하고, 50개가 넘는 파일 시스템 경로를 훑어 SSH 키와 AWS·GCP·Azure 크리덴셜, Kubernetes 토큰, Docker 설정, 데이터베이스 크리덴셜을 수집했습니다. 수집한 데이터는 AES-256-CBC와 RSA-4096 하이브리드 암호화로 묶어 외부로 보냈고요. 정상 Trivy 스캔을 실행했습니다. 파이프라인 출력은 평소와 같았습니다. trivy-action을 참조하는 워크플로는 1만 개가 넘습니다.

왜 발생했나
참조의 단위가 이름이기 때문입니다. 태그는 특정 커밋을 가리키는 이름일 뿐이고, 이름은 다른 커밋을 가리키도록 옮길 수 있습니다.
SHA 고정도 완전한 답은 아니었습니다. trivy-action이 자기가 호출하는 액션을 고정하기 시작한 것은 2025년 4월 9일부터인데요. 그 이전 커밋으로 trivy-action을 고정해 둔 경우, trivy-action 자체는 안전하지만 setup-trivy가 노출됐던 약 4시간 안에 워크플로가 돌았다면 악성 setup-trivy를 받게 됐습니다. 고정은 직접 참조하는 대상만 묶어둘 뿐, 그 대상이 다시 무엇을 당겨 쓰는지까지 따라가지는 않습니다.
여기서도 사각지대가 생깁니다. 워크플로 파일은 하나도 바뀌지 않았습니다. trivy-action@0.34.2라고 적힌 줄은 그대로인데 그 줄이 가리키는 코드가 바뀌었죠. 자기 저장소의 변경 이력을 아무리 들여다봐도 나타나지 않는 변화입니다. 다만 흔적은 남았습니다.
- 원본 커밋은 GitHub 웹 UI에서 병합되며 자동으로 GPG 서명됐지만, 공격자가 밀어 넣은 커밋은 그 경로를 거치지 않아 서명이 없었습니다.
- 원본 릴리즈 시점의 타임스탬프를 단 커밋의 부모가 2026년 3월 커밋이었습니다.
- 각 악성 커밋은
entrypoint.sh하나만 수정했습니다. 원본 커밋은 여러 파일을 건드렸고요. - 릴리즈 페이지에 오래된 태그인데도 "0 commits to master since this release"가 표시됐습니다.
유형 3. 워크플로 파일 자체가 심겼습니다
무슨 일이 있었나
5월 18일, 자동화된 캠페인이 여섯 시간여 만에 5561개 저장소에 5718개 커밋을 밀어 넣었습니다. 공격자는 임시 계정으로 build-bot, auto-ci, ci-bot 같은 작성자 이름을 위장했고, "ci: add build optimization step" 같은 문구로 통상적인 CI 유지보수처럼 보이게 했습니다.
심긴 워크플로는 두 종류였습니다.
| 대다수 변종 | 소수 변종 | |
|---|---|---|
| 방식 | 새 워크플로 파일 추가 | 기존 워크플로 교체 |
| 트리거 | 모든 브랜치의 push와 pull_request_target | 공격자의 수동 호출 |
| 실행 시점 | 커밋 직후 자동 | 공격자가 부를 때까지 대기 |
두 변종의 페이로드는 같았습니다. base64로 인코딩된 bash 스크립트가 CI 시크릿과 클라우드 크리덴셜, SSH 키를 수집해 C2 서버로 보냈습니다. 두 변종 모두 id-token: write 권한을 요청했는데요. GitHub Actions의 OIDC 토큰 요청 URL과 토큰을 함께 빼내, 공격자가 CI 아이덴티티를 가장해 AWS·GCP·Azure에 인증할 수 있게 하려는 것이었습니다. 장기 시크릿 대신 OIDC 페더레이션을 쓰는 조직이 오히려 더 큰 피해를 봅니다.
소수 변종은 커밋 시점에 CI 실행을 만들지 않았습니다. 파일에 이미 수동 호출 트리거가 적혀 있어 push가 페이로드를 촉발하지 않았기 때문입니다. Actions 탭에는 실패한 빌드도, 이상 징후도 남지 않았습니다. 애플리케이션 코드는 손대지 않았습니다.
왜 발생했나
워크플로 파일 변경이 코드 변경과 같은 통제를 받지 않았기 때문입니다. 분석된 사례에서 악성 커밋은 침해된 개인 액세스 토큰이나 배포 키로, PR도 병합 커밋도 없이 master에 직접 push됐습니다. 저장소 쪽에서는 리뷰 자체가 열리지 않았고, 하류에서는 패키지에 딸려 온 워크플로 파일을 들여다보는 사람이 없었습니다. 실제로 한 유지관리자는 오염된 저장소에서 그대로 배포해 애플리케이션 코드는 그대로인 채 워크플로 파일만 바뀐 패키지를 일곱 개 버전에 걸쳐 내보냈습니다.
사각지대는 표적이 정해지는 방식에도 있습니다. 공격자가 저장소를 하나씩 고른 것이 아니라, 감염된 개발자 단말에서 나온 크리덴셜이 닿는 곳이 표적이 됐습니다. 실제로 영향받은 저장소에 연관된 사용자명 978개를 인포스틸러 감염 기록과 대조했더니 331개가 직접 일치했고, 조사 기관은 나머지도 이메일 주소를 통해 감염 사례가 확인된다고 봤습니다. 여기에 워크플로 파일을 보호하지 않은 설정이 겹치면서 커밋이 그대로 들어갔고요. 저장소의 규모나 중요도는 표적 선정 기준이 아니었습니다.
세 유형의 공통점
세 가지 공격 유형을 나란히 놓으면 다음과 같은 공통점을 확인할 수 있습니다.
| 유형 1 | 유형 2 | 유형 3 | |
|---|---|---|---|
| 시점 | 2월 | 3월 | 5월 |
| 진입 경로 | pull_request_target 워크플로 | 회전에서 살아남은 크리덴셜 | 탈취된 개발자 크리덴셜 |
| 공격자가 바꾼 것 | 자기 포크의 코드 | 태그가 가리키는 대상 | 워크플로 파일 |
| 저장소의 워크플로 파일 | 그대로 | 그대로 | 바뀜 |
| 눈에 보인 것 | 정상 동작 | 정상 성공 | 흔적 없음 |
세 유형 모두 공격자의 코드가 저장소 권한으로 실행됐습니다. 파이프라인은 워크플로 파일에 적힌 대로 실행하고, 그 실행에는 저장소의 권한이 따라붙기 때문입니다.
세 사건 모두 애플리케이션 소스 코드는 그대로였습니다. 바뀐 것은 파이프라인이 무엇을 실행할지 정하는 층이었습니다. 워크플로 파일이거나, 그 파일이 불러오는 액션의 코드였죠. 애플리케이션을 검사하는 도구는 이 층을 보지 않습니다. 그렇다고 검사할 방법이 없는 것은 아닙니다. 유형 1의 취약한 워크플로는 2025년 10월부터 있었고, Boost Security의 poutine이 2025년 11월 29일에 이미 지적한 상태였습니다. 유형 3의 워크플로 파일 변경은 코드 리뷰였다면 걸렸을 내용이고요. 탐지 수단이 아니라 조치가 이어지지 않은 것이 문제였습니다.
Aqua Security는 사후 보고에서 세 가지를 원인으로 짚었습니다.
- 조직 전반에서 범위 제한이나 격리 없이 높은 권한의 서비스 계정을 사용했습니다
- 조직 구조가 복잡해 시크릿 회전 과정이 길고 복잡해져 효과적이지 않았습니다
- 특정 계정의 크리덴셜을 회전하는 동안 공격자가 다른 계정으로 시스템에 남아 있었습니다
침해를 겪은 조직이 직접 공개한 내용이라 참고할 만합니다.
여기서 마지막 문제가 나옵니다. 침해를 알게 된 시점에, 그 기간에 배포한 아티팩트가 오염됐는지 판별할 수 없다는 것입니다. 권고문이 "해당 기간에 실행된 파이프라인은 전부 침해된 것으로 간주하라"는 형태를 띠는 이유죠. 판별할 수 없으니 전부 회전하라는 뜻입니다. 다만 Aqua Security의 사례가 보여주듯 회전이 한 번에 끝나지 않으면 회전 자체가 공격자에게 새 크리덴셜을 넘기는 창이 됩니다.
GitLab에서 CI/CD 공급망 공격을 어떻게 방어하나
GitLab은 앞서 정리한 세 가지 공격 유형에 각각 대응하는 기능을 지원합니다. 이 기능을 비롯해 침해 이후의 피해를 방어하는 GitLab 기능과 사후 판별 수단도 함께 살펴보겠습니다.
유형 1 대응: 보호된 변수와 포크 파이프라인 설정으로 시크릿을 차단합니다
유형 1의 문제는 외부 기여자가 보낸 코드가 저장소의 시크릿과 권한을 가진 채 실행됐다는 점입니다. GitHub Actions에서는 pull_request_target 트리거와 PR head 체크아웃의 조합이 그 조건을 만들었죠.
GitLab의 기본 동작은 반대 방향입니다. 포크 프로젝트에서 도는 파이프라인은 부모 프로젝트의 CI/CD 변수에 접근할 수 없습니다. 여기에 변수를 protected로 지정하면 한 겹이 더 붙습니다. 보호된 변수는 보호된 브랜치나 태그에서 도는 파이프라인에만 전달됩니다.
Merge request(MR) 파이프라인이 보호된 변수와 Runner에 접근하려면 조건이 더 붙습니다. 소스와 타깃 브랜치가 모두 보호돼야 하고, 파이프라인을 촉발하는 사용자가 타깃 브랜치에 push나 merge 권한을 가져야 하며, 두 브랜치가 같은 프로젝트에 속해야 합니다. 포크된 저장소에서 온 MR 파이프라인은 이 리소스에 접근할 수 없습니다. 다만 이 접근은 프로젝트 설정으로 별도 허용해야 하는데요. GitLab 18.1 이후 만들어진 프로젝트는 기본으로 켜져 있고, 그 이전에 만들어진 프로젝트는 꺼져 있습니다.
여기서 권한 설계가 중요해집니다. 보호된 변수에 접근하려면 타깃 브랜치에 push나 merge 권한이 있어야 하기에 보호 브랜치 merge 권한을 주는 것은 곧 배포 크리덴셜에 접근할 수 있게 하는 것과 같습니다.
문제는 예외 경로인데요. 같은 MR이라도 부모 프로젝트 멤버가 부모 프로젝트에서 파이프라인을 실행하면 이야기가 달라집니다. 부모 프로젝트의 CI/CD 설정과 변수, Runner를 쓰고 실행한 멤버의 권한으로 돌아갑니다. GitLab도 포크 MR이 merge 전에 부모 프로젝트의 시크릿을 훔치려는 코드를 담을 수 있다고 명시합니다.
두 가지 방식으로 막을 수 있습니다.
- GitLab은 이 실행 전에 수락해야 하는 경고를 표시합니다. 다만 API나
/rebase빠른 동작으로 촉발하면 경고가 뜨지 않기에 그 경로를 쓰는 자동화가 있는지 확인해야 합니다. - 아예 차단하려면 projects API로
ci_allow_fork_pipelines_to_run_in_parent_project설정을 끕니다. 설정을 끄기 전에 만들어진 파이프라인은 계속 실행되기에 기존 파이프라인도 함께 정리해야 합니다.
설정 파일 자체의 변경은 CODEOWNERS로 통제합니다. GitLab 문서도 포크에서 제출된 MR의 파이프라인을 부모 프로젝트에서 실행하기 전, 변경을 merge하기 전에 .gitlab-ci.yml을 바꾸는 모든 MR을 검토하라고 권고합니다. Premium 이상에서 .gitlab-ci.yml을 CODEOWNERS 대상으로 지정하고 보호된 브랜치에서 Code Owner 승인을 요구하면, 지정된 리뷰어가 승인하기 전에는 파이프라인 설정이 바뀌지 않습니다.
유형 1에서 리뷰를 거친 것은 PR에 담긴 코드였습니다. 정작 그 코드에 저장소 권한을 넘겨준 워크플로 설정은 누구도 다시 보지 않았고요. CODEOWNERS로 설정 파일을 지정하면 코드와 마찬가지로 승인을 받아야 merge됩니다.
유형 2 대응: CI/CD Components를 커밋 SHA로 고정합니다
유형 2의 문제는 워크플로 파일이 하나도 바뀌지 않았는데 그 파일이 참조하는 태그가 다른 커밋을 가리키게 됐다는 점입니다. 참조의 단위가 이름이었기 때문이죠.
CI/CD Components의 버전은 우선순위가 높은 순으로 커밋 SHA, 태그, 브랜치 이름을 지정할 수 있습니다. 태그와 커밋 SHA가 같은 이름으로 존재하면 커밋 SHA가 우선하고, 브랜치와 태그가 같은 이름이면 태그가 우선하고요. 이와 별개로 카탈로그의 최신 시맨틱 버전을 항상 가리키는 ~latest도 쓸 수 있습니다. 커밋 SHA로 참조하면 이름이 아니라 내용으로 대상을 지정하게 돼 태그를 옮겨도 파이프라인이 당겨 쓰는 코드는 바뀌지 않습니다.
# 권장: 커밋 SHA로 고정
include:
- component: $CI_SERVER_FQDN/my-group/my-component/scan@e3262fdd0914fa823210cdb79a8c421e2cef79d8
GitLab 공식 문서도 컴포넌트의 무결성을 위해 특정 커밋 SHA로 고정할 것을 권장하며, 릴리즈 태그는 컴포넌트 관리자를 신뢰할 때만 쓰고 항상 최신을 가리키는 참조는 피하라고 안내합니다.
순서는 이렇습니다.
- 셉니다. CI/CD Catalog의 Components Analytics로 컴포넌트 사용 현황을 확인합니다. 사용 횟수와 도입 현황을 보는 고수준 뷰는 GitLab 18.9에 추가돼 Free를 포함한 전 티어에서 Explore > CI/CD Catalog > Analytics로 접근할 수 있습니다. 어떤 프로젝트가 어떤 버전을 실행 중인지까지 파고드는 드릴다운은 GitLab 19.0에서 Ultimate 대상으로 제공됩니다.
- 고정합니다. 확인된 컴포넌트를 커밋 SHA로 참조하도록 바꿉니다. 세어본 적이 없다면 고정할 대상도 정할 수 없어 순서가 중요합니다.
- 전이를 확인합니다. 그 컴포넌트가 자기 의존성을 고정하고 있는지 봅니다.
3번이 유형 2에서 실제로 뚫린 지점입니다. GitLab은 다른 프로젝트의 컴포넌트에 의존할 때 ~latest나 Git 참조 같은 움직이는 대상 대신 카탈로그의 릴리즈로 버전을 고정하고, 의존성을 정기적으로 갱신한 뒤 새 릴리즈를 발행하라고 안내합니다. 자기가 쓰는 컴포넌트를 고정하는 것과 그 컴포넌트가 자기 의존성을 고정하는 것은 별개의 문제입니다.
컴포넌트를 만들어 배포하는 쪽이라면 반대편 조치도 있습니다. GitLab은 컴포넌트 프로젝트의 기본 브랜치와 릴리즈 브랜치를 보호하고, 보호 브랜치 변경은 모두 MR을 거치게 하며, push와 merge 허용 대상을 아무도 없음으로 설정하라고 안내합니다. 유형 2에서 뚫린 쪽이 바로 이 자리였습니다.
유형 3 대응: Pipeline execution policies로 필수 Job을 강제합니다
유형 3의 문제는 워크플로 파일 자체가 새로 심기거나 교체됐고, 그 커밋이 리뷰 없이 기본 브랜치에 직접 들어갔다는 점입니다.
GitLab의 대응은 Ultimate에서 제공하는 Pipeline execution policies입니다. 보안 정책 프로젝트에서 CI/CD Job을 정의하고 대상 프로젝트에 강제하는 기능인데요. 프로젝트의 .gitlab-ci.yml과 별개로 동작해 개발자가 자신의 파일에 무엇을 정의하든 정책이 지정한 Job은 실행됩니다. 프로젝트에 CI/CD 구성 파일이 아예 없어도 정책은 그대로 적용되고요. 공격자가 .gitlab-ci.yml을 통째로 바꿔도 정책 Job은 지워지지 않습니다.
전략은 두 가지입니다.
inject_policy: 정책에 정의한 Job을 기존 파이프라인에 더합니다. 프로젝트 원본 구성을 대체하지 않으면서 필수 검사를 보장하고, 커스텀 정책 스테이지를 파이프라인의 원하는 위치에 주입할 수 있습니다. 이전의inject_ci전략은 GitLab 17.9에서 deprecated됐습니다.override_project_ci: 프로젝트의 CI 구성을 무시하고 정책의 구성으로 대체합니다.
수정 권한이 없는 프로젝트에 정책을 강제할 때는 .pipeline-policy-pre와 .pipeline-policy-post 스테이지에 Job을 정의하라고 GitLab이 안내합니다. 이 두 스테이지는 프로젝트의 CI/CD 구성과 무관하게 항상 사용할 수 있어 여기에 Job을 주입하면 언제나 동작이 보장되기 때문입니다. .pipeline-policy-pre는 .pre 앞에, .pipeline-policy-post는 .post 뒤에 놓입니다. 특히 .pipeline-policy-pre는 보안·컴플라이언스 용도로 설계돼, 파이프라인의 다른 Job은 이 스테이지가 끝날 때까지 시작하지 않습니다.
정책 Job은 [skip ci]와 [no_pipeline] 디렉티브도 기본적으로 무시하고 실행됩니다. 보호 브랜치에 이 디렉티브를 담은 커밋을 push해 일반 파이프라인을 건너뛰더라도, 정책이 정의한 검사는 그대로 돕니다. 필요하면 정책에서 특정 사용자나 서비스 계정에만 이 디렉티브 사용을 허용할 수도 있고요. 유형 3의 소수 변종이 실행 기록 자체를 남기지 않는 방식이었다는 점을 떠올리면, 실행을 회피하는 경로를 막는 조치가 여기에 해당합니다.
커밋이 들어오는 경로 자체도 막아야 합니다. 유형 3의 악성 커밋은 MR도 병합 커밋도 없이 직접 push됐습니다. 리뷰가 통과된 게 아니라 아예 열리지 않은 거죠. 보호된 브랜치로 push와 merge 권한을 제한하면 이 경로가 닫힙니다. Premium 이상에서 .gitlab-ci.yml을 CODEOWNERS 대상으로 지정하면 승인 없이는 변경이 merge되지 않고요.
최근 변화를 짚어야 합니다. Compliance pipelines는 GitLab 17.3에서 deprecated됐고 제거가 예고된 상태입니다. GitLab은 같은 프로젝트의 기존 컴플라이언스 파이프라인을 마이그레이션하기 전에는 Pipeline execution policies를 켜지 말라고 안내합니다. 둘을 함께 구성하면 컴플라이언스 파이프라인이 표준 프로젝트 파이프라인을 대체하는데 정책은 원래 프로젝트 파이프라인 기준으로 적용돼, Job 중복과 파이프라인 실패, 필수 보안·컴플라이언스 검사 누락이 생길 수 있습니다. 아직 마이그레이션하지 않은 조직이라면 지금이 확인할 시점입니다.
공통 대응: Job 토큰과 ID 토큰으로 침해 반경을 줄입니다
세 유형 모두 크리덴셜 유출로 이어졌습니다. 유입 경로를 전부 막을 수 없다면 남은 문제는 반경입니다. 토큰 하나가 닿는 범위가 넓을수록 한 번의 침해가 조직 전체로 번집니다. GitLab에는 이 반경을 줄이는 수단이 둘 있고, 모두 Free를 포함한 전 티어에서 쓸 수 있습니다.
Job 토큰이 닿는 프로젝트를 제한합니다. CI/CD job token allowlist는 Job 토큰이 접근할 수 있는 프로젝트를 명시적으로 허용된 목록으로 제한합니다. 기본적으로 각 프로젝트의 목록에는 자기 자신만 들어 있고, 다른 그룹이나 프로젝트가 접근하려면 목록에 추가해야 합니다. Job 하나가 침해돼도 토큰이 닿는 범위가 목록 안에 묶입니다.
운영 형태에 따라 상황이 다릅니다.
- GitLab.com: allowlist가 모든 프로젝트에 강제됩니다. 전환 과정에서 중단을 피하기 위해 GitLab이 Job 토큰 인증 로그를 근거로 각 프로젝트의 목록을 자동으로 채웠습니다.
- GitLab Self-Managed, GitLab Dedicated: 관리자가 직접 켜야 합니다. Admin 영역의 Settings > CI/CD에서 Authorized groups and projects 아래 Enable and enforce job token allowlist for all projects를 선택합니다.
이미 운영 중인 인스턴스에서 이 설정을 그냥 켜면 프로젝트 간 접근이 끊깁니다. GitLab은 인스턴스의 전체 또는 일부 프로젝트에 대해 allowlist를 자동으로 채우는 Rake 태스크를 제공하는데요. PREVIEW 옵션으로 실제 데이터를 바꾸지 않고 수행될 단계만 먼저 확인할 수 있고, 특정 프로젝트 ID만 대상으로 삼거나 제외할 수도 있습니다.
Aqua Security 사례에서 토큰 하나가 조직 전체에 접근 권한을 갖고 33개 워크플로에서 재사용되고 있었다는 점을 떠올리면, 범위를 좁히는 작업의 의미가 분명해집니다. 앞서 GitLab 액세스 토큰 점검 4단계에서 토큰을 세고 회전하는 방법을 다뤘는데요. 회전이 1단계라면, 토큰이 닿는 범위를 줄이는 것이 2단계입니다.
클라우드 인증 토큰을 서비스별로 나눕니다. 유형 3에서 공격자가 노린 것은 OIDC 토큰이었습니다. 장기 시크릿을 없애려고 도입한 페더레이션이 오히려 표적이 된 경우죠. 토큰 하나로 여러 클라우드 서비스에 인증할 수 있다면, 그 토큰이 유출됐을 때 피해가 전부로 번집니다.
GitLab CI/CD Job은 id_tokens 블록으로 서드파티 인증용 JWT를 발급받습니다. 여기서 aud 클레임을 서비스별로 나눠 지정할 수 있습니다.
job_with_id_tokens:
id_tokens:
FIRST_ID_TOKEN:
aud: https://first.service.com
SECOND_ID_TOKEN:
aud: https://second.service.com
script:
- first-service-authentication-script.sh $FIRST_ID_TOKEN
- second-service-authentication-script.sh $SECOND_ID_TOKEN
서드파티 서비스는 자기 대상과 맞지 않는 aud를 가진 토큰을 거부하도록 구성할 수 있습니다. 하나의 토큰이 인증할 수 있는 서비스 수를 줄이면 그 토큰이 침해됐을 때의 심각도가 낮아집니다.
범위는 sub 클레임으로도 좁힙니다. sub의 기본 형식은 project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}이라, 클라우드 신뢰 정책을 특정 브랜치나 태그로 제한할 수 있습니다. 다만 운영 형태에 따라 쓸 수 있는 조건이 다릅니다. AWS는 namespace_id와 project_id를 gitlab.com OIDC 제공자에 대해서만 지원하기에 Self-Managed와 Dedicated에서는 신뢰 정책을 sub 기준으로 설계해야 합니다. 이 경우 이름 변경에 영향받지 않게 하려면 sub의 첫 구성 요소로 project_id를 쓰라고 GitLab은 안내합니다.
사후 판별: SLSA provenance로 빌드 출처를 증명합니다
세 유형의 공통 결과는 판별 불가였습니다. 침해를 알게 된 시점에 그 기간에 배포한 아티팩트가 오염됐는지 알 수 없어 권고문이 "전부 침해된 것으로 간주하라"는 형태를 띠었습니다.
대응 기능은 SLSA provenance입니다. 빌드가 어디서 어떤 커밋으로 무엇을 만들었는지를 기계가 읽을 수 있는 형태로 남기는 것입니다. 기록이 있으면 침해 기간에 나온 아티팩트를 골라낼 수 있습니다.
GitLab Runner는 RUNNER_GENERATE_ARTIFACTS_METADATA 변수를 true로 설정한 Job에서 provenance 명세를 자동으로 생성합니다. Job 아티팩트로 업로드되는 모든 아티팩트가 대상이고, 명세에는 저장소 URL과 커밋 정보, Job 정보, Runner 정보, 아티팩트 다이제스트가 담깁니다. 이 수준이 SLSA Level 1에 해당합니다. 다만 provenance가 붙는 대상은 Job 아티팩트입니다. 컨테이너 이미지 자체가 아니라, 이미지 다이제스트를 담은 파일을 아티팩트로 올려 간접적으로 다뤄야 합니다.
여기서부터가 한계입니다. Level 3에 해당하는 어테스테이션도 문서화돼 있지만 제약이 큽니다. 티어는 Ultimate, Offering은 GitLab.com 하나로 표기돼 있어 Self-Managed와 Dedicated에서는 현재 쓸 수 없습니다. 상태도 Experiment이고, 문서에 프로덕션 사용 준비가 되지 않았다고 명시돼 있습니다.
여기에 전제 조건이 붙습니다.
- 빌드와 연관된 프로젝트가 공개여야 합니다
- 빌드가
build스테이지를 사용해야 합니다 - 프로젝트에서
slsa_provenance_statement기능 플래그가 켜져 있어야 합니다. GitLab 18.3에 도입됐고 기본은 비활성화입니다
공개 프로젝트 요구사항은 Rekor 투명성 로그에 정보가 실수로 공개되는 것을 막기 위한 것입니다. 조건을 충족한 뒤 아티팩트 어테스테이션을 생성하려면 ATTEST_BUILD_ARTIFACTS 변수를 true로 설정하고, 아티팩트 크기가 100MB를 넘지 않아야 합니다. 컨테이너는 ATTEST_CONTAINER_IMAGES를 true로 두고 IMAGE_DIGEST에 유효한 SHA256 참조를 지정합니다.
생성된 어테스테이션은 프로젝트의 Build > Attestations 페이지에서 확인하고, glab attestation verify 명령으로 검증합니다. Attestations API로 가져올 수도 있고요. 침해 기간에 나온 아티팩트를 골라내려면 생성뿐 아니라 이 검증 경로가 함께 있어야 합니다.
정리하면 Self-Managed에서 비공개 저장소를 운영하는 조직은 현재 이 경로를 쓸 수 없습니다. Level 1 수준의 provenance 메타데이터는 지금 생성할 수 있지만, 서명이 포함된 Level 3 어테스테이션은 조건이 맞지 않습니다. "SLSA를 지원한다"보다 어느 수준까지 지금 가능한지를 구분해 두는 편이 정확합니다.
GitLab 티어별로 가능한 조치
| 대응 | Free | Premium | Ultimate |
|---|---|---|---|
| 보호된 브랜치·태그 | 제공 | 제공 | 제공 |
| 포크 파이프라인 부모 실행 차단 | 제공 | 제공 | 제공 |
| CI/CD job token allowlist | 제공 | 제공 | 제공 |
| ID 토큰 aud 분리 | 제공 | 제공 | 제공 |
| CI/CD Components SHA 고정 | 제공 | 제공 | 제공 |
| Components Analytics 사용 현황 | 제공 | 제공 | 제공 |
| SLSA Level 1 provenance | 제공 | 제공 | 제공 |
| 보호 브랜치의 사용자·그룹 단위 권한 지정 | 미제공 | 제공 | 제공 |
| CODEOWNERS 승인 강제 | 미제공 | 제공 | 제공 |
| 컴포넌트 버전별 드릴다운 | 미제공 | 미제공 | 제공 |
| Pipeline execution policies | 미제공 | 미제공 | 제공 |
| SLSA Level 3 어테스테이션 | 미제공 | 미제공 | GitLab.com 한정, 실험 |
티어에 따라 선택지가 달라집니다. 다만 침해 반경을 줄이는 수단은 대부분 Free에서도 쓸 수 있습니다. 보호된 브랜치와 태그, 포크 파이프라인의 부모 프로젝트 실행 차단, Job 토큰 allowlist, ID 토큰의 aud 분리, 컴포넌트 SHA 고정, 사용 현황 확인이 여기에 해당합니다. 사후 판별용 SLSA Level 1 provenance도 티어 제약 없이 생성할 수 있고요.
Premium부터는 CODEOWNERS로 .gitlab-ci.yml 변경에 지정된 리뷰어의 승인을 요구할 수 있습니다. 보호 브랜치의 merge 권한을 역할이 아니라 특정 사용자나 그룹 단위로 좁히는 것도 이 티어부터고요. Ultimate가 아니어서 Pipeline execution policies를 쓸 수 없다면, 이 조합이 설정 파일 변경 통제의 현실적인 대안입니다.
Free에서는 승인을 강제할 수 없고, merge 권한도 역할 단위로만 지정할 수 있습니다. 대신 보호된 브랜치로 직접 push를 막는 것만으로도 상당 부분이 닫힙니다. 유형 3의 악성 커밋이 MR도 병합 커밋도 없이 직접 push된 것이었다는 점을 떠올리면 그렇습니다.
티어만으로 판단할 수 없는 항목도 하나 있습니다. Ultimate라도 SLSA Level 3 어테스테이션은 GitLab.com에서만 쓸 수 있고, Self-Managed와 Dedicated는 현재 대상이 아닙니다.
GitLab만으로 방어하기 어려운 지점
GitLab 기능을 켜는 것으로 해결되지 않는 부분도 있습니다.
- 외부 구성 요소의 신뢰성은 판단해주지 않습니다. GitLab 문서도 서드파티 컴포넌트 사용은 사용자 책임이며 GitLab이 보안을 보장할 수 없다고 명시합니다. 컴포넌트 소스 코드를 직접 감사하고, 크리덴셜이 예상하고 승인한 동작에만 쓰이는지 확인해야 합니다.
- 개발자 단말의 크리덴셜 유출은 파이프라인 밖의 문제입니다. 유형 3의 출발점이 감염된 개발자 컴퓨터였다는 점을 생각하면, 파이프라인 설정만으로는 초기 접근을 막을 수 없습니다.
- 레지스트리 크리덴셜은 저장소 통제 밖입니다. 유형 2에서 Docker Hub 이미지는 별도로 침해된 Docker Hub 크리덴셜로, GitLab을 거치지 않고 직접 발행됐습니다.
- 빌드 출처 증명은 아직 완전하지 않습니다. Level 1 수준의 메타데이터는 티어 제약 없이 지금 생성할 수 있습니다. 다만 서명이 포함된 Level 3 어테스테이션은 Ultimate 티어의 GitLab.com 공개 프로젝트에서만 가능하고, 그마저 Experiment 상태입니다. Self-Managed나 Dedicated를 운영한다면 사후 판별 문제는 당분간 다른 방법으로 다뤄야 합니다.
- Runner를 어디에 두고 어떤 네트워크에 붙일지는 설계 문제입니다. 셀프 호스팅 Runner가 내부망에 폭넓게 접근할 수 있다면 기능 설정만으로는 확산 범위가 줄지 않습니다.
지금 확인할 CI/CD 파이프라인 보안 점검 5가지
우선순위 순으로 정리했습니다.
- 포크에서 온 MR의 파이프라인을 부모 프로젝트에서 실행할 수 있는가.
ci_allow_fork_pipelines_to_run_in_parent_project설정을 확인합니다. 켜져 있다면 API나/rebase로 경고 없이 촉발하는 자동화가 있는지도 함께 봅니다. GitLab에서 포크의 코드가 부모 프로젝트의 시크릿과 만나는 지점입니다. - CI/CD job token allowlist가 켜져 있는가. GitLab.com이 아니라면 Admin 영역에서 인스턴스 차원의 강제 여부를 직접 확인합니다. 이 설정은 GitLab 18.0 업그레이드 과정에서 기본으로 켜집니다. 다만 그때 파이프라인이 인증 오류로 실패해 수동으로 되돌린 경우가 있어 실제 상태는 직접 확인해야 합니다. 강제돼 있지 않으면 개별 프로젝트가 이 설정을 끌 수 있고요. 이미 운영 중인 인스턴스라면 Rake 태스크의
PREVIEW옵션으로 영향 범위를 먼저 보는 편이 안전합니다. .gitlab-ci.yml변경이 리뷰 없이 merge될 수 있는가. 보호된 브랜치의 push·merge 권한과 CODEOWNERS 지정 여부를 확인합니다. Premium 이상이라면 Code Owner 승인 요구까지 켭니다.- 파이프라인이 외부에서 당겨 쓰는 구성 요소의 목록을 확보했는가. CI/CD Catalog의 Components Analytics에서 시작합니다. 버전별 사용 현황까지 보려면 Ultimate가 필요하고, 그 아래 티어라면 워크플로 파일을 직접 훑어야 합니다. 목록이 나오면 커밋 SHA로 고정하고, 그 컴포넌트가 자기 의존성을 고정하고 있는지까지 확인합니다.
- Runner 하나가 침해됐을 때 회전해야 할 크리덴셜의 목록을 지금 작성할 수 있는가. 작성할 수 없다면 그것이 첫 번째 과제입니다. 목록이 있더라도 한 번에 회전할 수 없다면 회전 자체가 새로운 노출 창이 됩니다.
맺음말
지금까지 2026년 파이프라인 공급망 공격의 세 가지 유형과 각 공격이 발생한 원인, GitLab에서 대응할 수 있는 지점과 그렇지 못한 지점을 살펴봤습니다. 요점은 다음과 같은데요.
- 뚫린 것은 애플리케이션 코드가 아니라 파이프라인입니다. 세 사건 모두 소스 코드는 그대로였고, 바뀐 것은 파이프라인이 무엇을 실행할지 정하는 층이었습니다. 워크플로 파일이거나 그 파일이 불러오는 액션의 코드였죠. 애플리케이션을 검사하는 도구는 이 층을 보지 않습니다.
- 파이프라인은 파일에 적힌 대로 실행하고, 그 실행에는 저장소의 권한이 따라붙습니다. 공격자는 외부에서 보낸 코드를 실행시키거나, 파일이 참조하는 태그를 옮기거나, 파일 자체를 바꿔치기했습니다. 방법은 셋이지만 결과는 하나였습니다. 공격자의 코드가 저장소 권한으로 돌았습니다.
- 눈에 보이는 신호가 없었습니다. 워크플로는 정상 동작했고, 스캔은 정상 성공했고, 일부는 실행 기록조차 남기지 않았습니다. 탐지 수단이 없어서가 아닙니다. 유형 1의 취약한 워크플로는 악용 3개월 전에 이미 도구가 지적한 상태였습니다. 탐지가 아니라 조치가 이어지지 않은 것이 문제였습니다.
- GitLab에서 반경을 줄이는 수단은 대부분 Free에서도 쓸 수 있습니다. 보호된 브랜치와 태그, 포크 파이프라인의 부모 실행 차단, Job 토큰 allowlist, ID 토큰의
aud분리, 컴포넌트 SHA 고정이 여기에 해당합니다. 티어가 가르는 것은 승인 강제와 정책 주입입니다. CODEOWNERS 승인은 Premium부터, Pipeline execution policies는 Ultimate부터고요. - 다만 GitLab만으로 닫히지 않는 지점도 분명합니다. 외부 컴포넌트의 신뢰성, 개발자 단말의 크리덴셜 유출, 레지스트리 크리덴셜, Runner를 어느 네트워크에 붙일지는 기능 설정 밖의 문제입니다. 서명이 포함된 빌드 출처 증명도 현재로서는 조건이 까다롭습니다.
- 회전은 한 번에 끝나야 회전입니다. 침해를 알게 된 시점에 어느 아티팩트가 오염됐는지 판별할 수 없어 권고문은 전부 회전하라고 합니다. 그러나 Aqua Security의 사례가 보여주듯, 회전이 며칠에 걸쳐 이뤄지면 그 기간 자체가 공격자에게 새 크리덴셜을 넘기는 창이 됩니다. 회전할 대상의 목록을 미리 갖고 있어야 하는 이유입니다.
Job 하나가 뚫리면 어디까지 열립니까
유입을 전부 막을 수 없다면 남는 문제는 반경입니다. 인포그랩이 Job 토큰 범위와 클라우드 인증 설계를 점검해 침해 이후의 피해 확산을 줄이는 방법을 제안해 드립니다.
참고 자료
- "Trivy ecosystem supply chain temporarily compromised", GitHub Advisory GHSA-69fq-xp46-6x23, https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23
- "Trivy Supply Chain Attack: What You Need to Know", Aqua Security, https://www.aquasec.com/blog/trivy-supply-chain-attack-what-you-need-to-know/
- "Trivy Security incident 2026-03-19", aquasecurity/trivy Discussion 10425, https://github.com/aquasecurity/trivy/discussions/10425
- "Trivy Security incident 2026-03-19 conclusion", aquasecurity/trivy Discussion 10462, https://github.com/aquasecurity/trivy/discussions/10462
- Varun Sharma, "hackerbot-claw: An AI-Powered Bot Actively Exploiting GitHub Actions", StepSecurity, 2026-03-01, https://www.stepsecurity.io/blog/hackerbot-claw-github-actions-exploitation
- "20 Days Later: Trivy Compromise, Act II", Boost Security Labs, https://labs.boostsecurity.io/articles/20-days-later-trivy-compromise-act-ii/
- "Trivy GitHub Actions Supply Chain Compromise", Snyk, https://snyk.io/articles/trivy-github-actions-supply-chain-compromise/
- "Megalodon: Mass GitHub Repo Backdooring via CI Workflows", SafeDep, 2026-05-21, https://safedep.io/megalodon-mass-github-repo-backdooring-ci-workflows/
- "Infostealers Just Spawned a 5,000+ Repo GitHub Supply Chain Attack", Hudson Rock, https://www.infostealers.com/article/infostealers-just-spawned-a-5000-repo-github-supply-chain-attack/
- "Poisoned Pipeline Execution, T1677", MITRE ATT&CK, https://attack.mitre.org/techniques/T1677/
- "Safer pull_request_target defaults for GitHub Actions checkout", GitHub Changelog, 2026-06-18, https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/
- "Securely using pull_request_target", GitHub Docs, https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target
- "Merge request pipelines", GitLab Docs, https://docs.gitlab.com/ci/pipelines/merge_request_pipelines/
- "CI/CD variables", GitLab Docs, https://docs.gitlab.com/ci/variables/
- "Protected branches", GitLab Docs, https://docs.gitlab.com/user/project/repository/branches/protected/
- "Code Owners", GitLab Docs, https://docs.gitlab.com/user/project/codeowners/
- "CI/CD components", GitLab Docs, https://docs.gitlab.com/ci/components/
- "Track CI component usage across your organization", GitLab Blog, https://about.gitlab.com/blog/track-ci-component-usage/
- "Compliance pipelines (deprecated)", GitLab Docs, https://docs.gitlab.com/user/compliance/compliance_pipelines/
- "GitLab CI/CD job token", GitLab Docs, https://docs.gitlab.com/ci/jobs/ci_job_token/
- "CI/CD settings", GitLab Docs, https://docs.gitlab.com/administration/settings/continuous_integration/
- "Pipelines failing due to authentication errors after upgrade to GitLab 18.0", GitLab Support, https://support.gitlab.com/hc/en-us/articles/19552066487068-Pipelines-failing-due-to-authentication-errors-after-upgrade-to-GitLab-18-0
- "OpenID Connect (OIDC) Authentication Using ID Tokens", GitLab Docs, https://docs.gitlab.com/ci/secrets/id_token_authentication/
- "Connect to cloud services", GitLab Docs, https://docs.gitlab.com/ci/cloud_services/
- "Configure OpenID Connect in AWS to retrieve temporary credentials", GitLab Docs, https://docs.gitlab.com/ci/cloud_services/aws/
- "SLSA level 3 provenance attestations", GitLab Docs, https://docs.gitlab.com/ci/pipeline_security/slsa/level_3/
- "CI/CD job token access", GitLab 18.1 Release Notes, https://support.gitlab.com/hc/en-us/articles/21750745306012-CI-COMMIT-REF-PROTECTED-unexpectedly-returns-False-in-Merge-Request-Pipelines-for-Protected-Branches
- Grace(박민영), "GitLab 액세스 토큰 점검 4단계: 경찰청 GitHub 토큰 유출 권고문 대응", 인포그랩, 2026-07-29, https://insight.infograb.net/blog/2026/07/29/credential-gitlab/
이 글이 도움이 되셨나요?
인포그랩 전문가가 맞춤 상담을 도와드립니다.
관련 글

GitLab 액세스 토큰 점검 4단계: 경찰청 GitHub 토큰 유출 권고문 대응
경찰청이 GitHub 개인 액세스 토큰 유출 권고문을 배포했습니다. GitLab Self-Managed에서 토큰을 세고, 보고, 회전하고, 강제하는 4단계 실행 방법과 Free·Premium·Ultimate 티어별로 가능한 조치를 정리했습니다.

GitLab Secrets Manager로 CI/CD 시크릿 유출 막기
GitLab Secrets Manager로 CI/CD 시크릿을 안전하게 다루는 법을 정리합니다. base64 한 줄로 뚫리는 마스킹의 한계를 직접 재현하고, 요청한 job만 시크릿을 받는 구조, HashiCorp Vault·클라우드 도구와의 비교, 도입 시 점검할 점까지 다룹니다.

폐쇄망 Maven 빌드가 429를 만난다면 - GitLab Virtual Registry로 의존성 에러 해결하기
폐쇄망 CI 환경에서 NAT 게이트웨이 뒤에 Maven 빌드를 돌리다 보면, 다른 빌드 도구에 비해 자주 멈추는 패턴을 마주합니다. 이 글은 폐쇄망 빌드의 의존성 수급 단계에서 발생하는 Maven 빌드 중단 문제를 GitLab Maven Virtual Registry로 해결한 과정과 실전 팁을 다뤘습니다.