망분리 환경은 외부 공격이 막혀 있다고 믿기 쉽지만, 개발에 필요한 라이브러리는 결국 인터넷에서 받아 안으로 들인다. 반입 과정에 검사와 기록이 없으면 악성 패키지와 알려진 취약점도 라이브러리와 함께 내부망에 들어오고, 사고가 났을 때 어디에 퍼졌는지조차 답하지 못한다. 이 글은 Maven·npm 의존성을 망분리 환경에 들이는 경로를 하나로 만들고, 수집·검사·반입·사용 단계마다 무엇을 통제하고 남겨야 하는지 정리한 것이다.

한눈에 보기
| 단계 | 위치 | 통제 | 남길 기록 |
|---|---|---|---|
| 1. 요청 | 내부망 | 잠금 파일로 이름·버전·해시 고정 | 반입 요청서, 잠금 파일 |
| 2. 수집 | 인터넷망 수집 서버 | 깨끗한 환경에서 잠금 파일대로만 받기 | 받은 파일 목록과 해시 |
| 3. 검사 | 인터넷망 수집 서버 | 해시·서명 확인, 취약점·라이선스·악성코드 검사 | 검사 결과, SBOM |
| 4. 반입 | 망연계 구간 | 승인된 묶음만 전송 | 전송 기록, 승인자 |
| 5. 등록 | 내부 저장소(Nexus 등) | hosted 저장소에만 등록, 쓰기 권한 분리 | 등록 이력 |
| 6. 사용 | 개발 PC, CI | 내부 저장소만 바라보게 설정, 설치 스크립트 차단 | 빌드별 SBOM |

무엇을 막으려는가
공급망 위협은 크게 다섯 가지다. 실제로 있었던 사건 이름을 붙여 두면 보안 담당과 대화가 빨라진다.
- 알려진 취약점. Log4Shell(CVE-2021-44228)처럼 널리 쓰는 라이브러리에서 원격 코드 실행 취약점이 나온다. 망분리 내부에서도 입력값이 로그에 찍히는 경로가 있으면 위험은 같다.
- 악성 버전. 2018년 npm
event-stream에는 새 관리자가 추가한 의존성(flatmap-stream)에 특정 지갑 앱을 노린 코드가 들어갔다. 2021년ua-parser-js는 관리자 계정이 탈취되어 악성 버전이 배포됐다. - 의존성 혼동(dependency confusion). 내부 패키지와 같은 이름의 패키지를 공개 저장소에 더 높은 버전으로 올려, 빌드 도구가 공개 쪽을 받게 만드는 공격이다. 2021년 공개된 연구로 여러 대기업 빌드에서 재현됐다.
- 설치 스크립트. npm 패키지는
preinstall·postinstall스크립트로 설치 순간 임의 명령을 실행할 수 있다. 검사 전에 설치부터 하면 검사 서버가 먼저 감염된다. - 반입 중 변조. 받은 파일과 들인 파일이 같은지 확인하지 않으면 중간에 바뀌어도 모른다.
1단계: 요청은 잠금 파일로 받는다
"Spring Boot 최신 버전 넣어 주세요" 같은 요청은 받지 않는다. 반입 단위는 이름 + 정확한 버전 + 해시다.
- npm은
package-lock.json을 그대로 받는다. 항목마다resolved(받을 주소)와integrity(SHA-512 해시)가 있어 그 자체가 반입 목록이다. - Maven은 버전 범위(
[1.0,))와LATEST,SNAPSHOT을 금지하고,mvn dependency:tree결과로 전이 의존성까지 펼친 목록을 붙인다. - 요청서에는 쓰는 프로젝트와 요청자를 적는다. 나중에 취약점이 나오면 누구에게 알릴지가 여기서 정해진다.
2단계: 인터넷망에서 깨끗하게 수집한다
수집 서버는 매번 비어 있는 환경에서 시작한다. 이전 수집의 캐시가 섞이면 무엇을 새로 받았는지 알 수 없다.
Maven
# 빈 로컬 저장소를 지정해 실제 빌드를 한 번 돌린다(플러그인까지 받기 위해)
mvn -B -Dmaven.repo.local=./m2-collect --strict-checksums -DskipTests package
# 받은 파일과 해시 목록
find ./m2-collect -type f \( -name '*.jar' -o -name '*.pom' \) -exec sha256sum {} + > collected.sha256
dependency:go-offline만으로는 빌드 중에 쓰는 플러그인 일부가 빠질 수 있다. 실제 빌드를 한 번 돌려 받는 편이 확실하다. --strict-checksums는 저장소의 체크섬과 받은 파일이 다르면 빌드를 실패시킨다.
npm
# 설치 스크립트를 끄고 잠금 파일대로만 받는다
npm ci --ignore-scripts --cache ./npm-cache
# 잠금 파일의 resolved 주소에서 tarball을 따로 모으고 integrity 값과 대조한다
node collect-tarballs.js package-lock.json ./tarballs
collect-tarballs.js는 잠금 파일의 resolved에서 .tgz를 받아 integrity의 SHA-512와 비교하는 수십 줄짜리 스크립트로 만들면 된다. 값이 다르면 그 묶음 전체를 버린다.
3단계: 검사하고 SBOM을 만든다
확인
- 해시: npm은
integrity, Maven은 저장소의 체크섬 파일과 대조한다. Maven Central에 배포된 아티팩트는 PGP 서명(.asc)도 함께 있으므로 서명 검증까지 하면 더 좋다. - 취약점: OWASP Dependency-Check, Trivy,
npm audit같은 도구로 알려진 CVE를 찾는다. 기준을 미리 정한다(예: CVSS 9.0 이상은 반입 불가, 7.0 이상은 보안 담당 승인). - 라이선스: GPL 계열처럼 배포 조건이 붙는 라이선스는 사업 기준에 맞는지 본다.
- 악성코드: 기관 지정 백신 검사. 압축 파일은 풀어서 검사한다.
SBOM(소프트웨어 구성 목록)
검사를 통과한 묶음마다 CycloneDX 형식의 SBOM을 만든다. Maven은 cyclonedx-maven-plugin, npm은 @cyclonedx/cyclonedx-npm이나 최근 npm의 npm sbom 명령을 쓴다(쓰는 버전의 지원 여부 확인). SBOM은 반입 묶음과 함께 들어가고, 나중에 "이 라이브러리가 어디에 있나"를 답하는 색인이 된다.
망분리 내부에서 취약점 검사를 다시 돌리려면 취약점 DB도 반입해야 한다. 내부 검사기의 DB가 석 달 전 것이면 그 뒤에 나온 CVE는 보이지 않는다. DB 반입 주기를 정해 둔다.
4단계: 망연계로 반입하고 내부 저장소에 등록한다
- 승인된 묶음(파일, 해시 목록, 검사 결과, SBOM)만 망연계 솔루션으로 보낸다. 반입 후 내부에서 해시를 다시 계산해 목록과 맞는지 확인한다.
- 내부 저장소(Nexus, Artifactory 등)는 외부를 대신 받아 오는 proxy 저장소를 쓸 수 없으므로 hosted 저장소에 등록한다. Nexus는 화면 업로드와 REST API를 지원하므로 등록을 스크립트로 만든다.
- 저장소 쓰기 권한은 반입 담당 계정만 갖는다. 개발자와 CI는 읽기만 한다. 누구나 올릴 수 있으면 검사 절차를 건너뛰는 길이 생긴다.
- 내부에서 만든 패키지는 별도 hosted 저장소에 두고 이름 규칙(npm은
@기관명/같은 scope, Maven은 내부groupId)을 강제한다. 공개 패키지와 이름이 겹치지 않아야 의존성 혼동이 생기지 않는다.
5단계: 빌드는 내부 저장소만 본다
개발 PC와 CI가 다른 저장소를 바라보면 반입 경로가 둘이 된다. 설정으로 하나만 남긴다.
<!-- ~/.m2/settings.xml: 모든 저장소 요청을 내부 저장소로 -->
<mirrors>
<mirror>
<id>internal</id>
<mirrorOf>*</mirrorOf>
<url>https://nexus.internal.example/repository/maven-group/</url>
</mirror>
</mirrors>
# .npmrc: 내부 저장소만, 설치 스크립트는 기본 차단
registry=https://nexus.internal.example/repository/npm-group/
ignore-scripts=true
- CI는
npm install대신npm ci를 쓴다. 잠금 파일과package.json이 어긋나면 실패하므로 몰래 버전이 바뀌지 않는다. - 설치 스크립트가 꼭 필요한 패키지(네이티브 모듈 빌드 등)는 목록으로 관리하고 그 패키지만 따로 실행한다.
- Maven은
maven-enforcer-plugin으로 SNAPSHOT 의존성과 금지 라이브러리(예: 취약 버전 범위의log4j-core)를 빌드 단계에서 막는다. - 빌드마다 SBOM을 만들어 배포 산출물과 함께 보관한다.
6단계: 새 취약점이 나왔을 때
공급망 보안의 실력은 사고 당일에 드러난다. Log4Shell 때 많은 조직이 가장 오래 걸린 일은 패치가 아니라 "우리 시스템 어디에 log4j-core가 있는가"를 찾는 일이었다.
- 취약점의 CVE 번호와 영향 버전 범위를 확인한다.
- 보관한 SBOM에서 해당 컴포넌트와 버전을 검색해 영향받는 시스템 목록을 만든다.
- 내부 저장소에서도 같은 버전을 찾아 사용 금지 표시를 하거나 격리한다.
- 패치 버전을 1~4단계 절차로 긴급 반입한다. 긴급이라도 해시와 검사는 건너뛰지 않는다.
- 영향받은 시스템을 재빌드·배포하고, 새 SBOM으로 제거됐는지 확인한다.
통제를 왜 두는가
| 통제 | 주로 보는 사람 | 없으면 생기는 일 |
|---|---|---|
| 잠금 파일 단위 요청 | 반입 담당 | 무엇을 들였는지 아무도 모름 |
| 깨끗한 수집 환경 | 반입 담당 | 이전 캐시가 섞여 검사 대상이 흐려짐 |
| 설치 스크립트 차단 | 반입 담당, 개발자 | 검사 전에 수집 서버·개발 PC에서 코드 실행 |
| 해시 재확인 | 보안 담당 | 반입 중 변조를 알아채지 못함 |
| 내부 저장소 쓰기 권한 분리 | 보안 담당 | 검사를 건너뛴 파일이 저장소에 올라감 |
| 내부 패키지 이름 규칙 | 개발 리더 | 의존성 혼동 공격에 노출 |
| SBOM 보관 | 보안 담당, PM | 취약점 발표 날 영향 범위를 며칠 걸려 찾음 |
자주 하는 실수
- USB로
node_modules나.m2폴더를 통째로 옮긴다. 무엇이 들었는지, 받은 그대로인지 확인할 방법이 없다. - 수집 서버에서 그냥
npm install을 돌린다. 설치 스크립트가 검사보다 먼저 실행된다. - 내부 검사기의 취약점 DB를 갱신하지 않는다. 검사는 통과하는데 실제로는 알려진 취약점이 있는 상태가 된다.
- 개발자에게 저장소 쓰기 권한을 준다. 급할 때 검사 없이 올리는 길이 상시로 열린다.
- SBOM을 만들고 보관하지 않는다. 사고 당일 필요한 것은 만드는 방법이 아니라 과거 빌드의 목록이다.
도입 점검표
- 반입 요청은 잠금 파일(또는 전이 의존성까지 펼친 목록)로만 받는다
- 수집은 매번 빈 캐시·빈 로컬 저장소에서 시작한다
- 수집 단계에서 npm 설치 스크립트가 꺼져 있다
- 받은 파일의 해시를 저장소 값과 대조하고, 반입 후 내부에서 다시 대조한다
- 취약점·라이선스 반입 기준이 수치로 정해져 있다
- 검사 통과 묶음마다 SBOM이 있고 반입 기록과 함께 보관된다
- 내부 검사기의 취약점 DB 반입 주기가 정해져 있다
- 내부 저장소 쓰기 권한은 반입 담당만 갖는다
- 개발 PC와 CI 설정이 내부 저장소 하나만 가리킨다
- 새 CVE가 나오면 SBOM으로 영향 시스템을 찾는 절차가 문서로 있다
망분리 환경의 공급망 보안은 벽을 높이는 일이 아니라, 벽에 난 단 하나의 문을 정해 그 문을 지나는 모든 파일을 이름·버전·해시로 기록하고 검사하는 일이다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.