보안

npm audit CVE 대응: 15개 프로젝트를 나눈 기준

npm audit와 컨테이너 스캔 결과를 읽고 CVE를 패치·상위 의존성 갱신·라이브러리 교체·예외로 나누는 실무 판단법을 정리했다.

이 글의 목차이번 작업에서 실제로 완료한 것13
  1. 이번 작업에서 실제로 완료한 것
  2. 첫 번째 질문은 ‘몇 개’가 아니라 ‘어디에서 왔나’였다
  3. npm 결과와 컨테이너 결과를 따로 봤다
  4. 네 가지 처리 경로로 나눴다
  5. 1. 같은 메이저 버전 안에서 수정 가능
  6. 2. 상위 라이브러리를 먼저 갱신
  7. 3. 유지보수 종료로 라이브러리 교체
  8. 4. 지금 실행 경로에는 없지만 근거를 둔 예외
  9. npm audit fix --force를 일괄 실행하지 않은 이유
  10. 심각도 하나로 순서를 정하지 않았다
  11. 리포트 한 장이 다음 작업을 움직이게 하려면
  12. 결론
  13. 참고한 공식 문서

보안 취약점 목록을 처음 펼쳤을 때 가장 눈에 잘 들어온 것은 빨간색 개수였다.

그 숫자를 빨리 줄이고 싶어질수록 한꺼번에 업데이트하면 안 됐다.

내가 확인해야 했던 범위는 패키지 하나가 아니라 모노레포의 15개 이상 프로젝트와 관련 컨테이너 이미지였다.

모노레포는 여러 애플리케이션과 라이브러리를 하나의 저장소에서 관리하는 구조다.

컨테이너 이미지는 애플리케이션 코드와 실행에 필요한 운영체제 패키지·런타임을 함께 묶은 배포 단위다.

그 안에는 Node.js와 Angular뿐 아니라 Java, Ruby·Fastlane, 각 서비스의 컨테이너 기본 이미지가 함께 있었다.

CVE(Common Vulnerabilities and Exposures)는 공개된 보안 취약점을 공통 번호로 식별하는 체계다.

CVE가 있다는 사실은 중요한 경고지만, 같은 번호가 보인다고 모든 프로젝트에 같은 명령을 실행할 수 있는 것은 아니다.

어떤 항목은 같은 메이저 버전 안의 패치로 끝났고, 어떤 항목은 상위 라이브러리를 먼저 올려야 했다.

node-sass, vm2, AWS SDK v2처럼 버전 한 줄보다 사용 방식과 대체 경로를 먼저 검토해야 하는 항목도 있었다.

나는 취약점 숫자를 없애는 작업부터 시작하지 않았다.

각 항목이 어디에서 들어왔고, 실제 배포물에 포함되는지, 수정할 때 무엇이 함께 바뀌는지를 분류하는 리포트부터 만들었다.

취약점 스캐너는 소프트웨어 구성 요소의 이름과 버전을 알려진 취약점 목록과 대조한다.

여기서 나온 경고는 조사를 시작할 위치이지, 그 서비스가 이미 공격당했거나 해당 기능이 실제로 노출됐다는 판정은 아니다.

예를 들어 인터넷 요청 본문을 직접 해석하는 라이브러리의 취약한 기능을 운영 API가 사용한다면 먼저 막아야 한다.

반면 빌드 단계에서만 사용하고 최종 실행 이미지에는 남지 않는 도구라면 실제 포함 여부부터 확인할 수 있다.

둘 다 같은 High 등급으로 보이더라도 수정 순서는 달라진다.

이 글은 보안 등급을 무시하는 방법이 아니라, 등급에 유입 경로·실행 위치·외부 노출·수정 영향도를 더해 안전한 처리 순서를 만드는 방법을 다룬다.

이번 작업에서 실제로 완료한 것

범위와 결과로 확인되는 내용은 다음과 같다.

  • 모노레포의 15개 이상 프로젝트와 컨테이너 이미지를 조사했다.
  • Node.js, Angular, Java, Ruby·Fastlane 스택으로 나눴다.
  • 직접 의존성과 전이 의존성을 구분했다.
  • 조사 시점에 다시 취약한 버전과 지원 종료 라인을 제외해 권장 경로를 정리했다.
  • 단순 버전 갱신과 라이브러리 교체·메이저 마이그레이션을 별도 과제로 나눴다.
  • 서비스별 현재 버전, 권장 버전, 특수 조치와 이미지 전이 의존성을 비교하는 리포트를 만들었다.

직접 의존성은 프로젝트의 package.json처럼 개발자가 명시적으로 추가한 패키지다.

전이 의존성은 그 패키지가 다시 필요로 해서 함께 설치된 하위 패키지다.

문제의 라이브러리 이름이 내 package.json에 보이지 않더라도 전이 의존성으로 배포될 수 있다.

여기서 완료한 것은 취약점의 분석과 이행 경로 설계다.

15개 프로젝트의 취약점을 모두 수정하고 운영 배포까지 끝냈다는 기록은 아니다.

실제 제거 건수와 재스캔 결과가 없는데 “전수 해결”이라고 쓰면 보고서 작성과 구현 성과를 섞게 된다.

프로젝트와 컨테이너에서 발견한 취약점을 같은 메이저 패치, 상위 의존성 갱신, 유지보수 종료 라이브러리 교체, 근거와 만료일이 있는 예외로 나누는 판단 경로
취약점 한 건의 처리 방법은 심각도 색상보다 유입 경로와 수정 영향에 따라 달라진다.

첫 번째 질문은 ‘몇 개’가 아니라 ‘어디에서 왔나’였다

Node.js 프로젝트에서는 lockfile을 기준으로 의존성 트리를 확인했다.

lockfile은 package-lock.json처럼 실제 설치할 패키지와 버전을 고정한 파일이다.

같은 package.json이라도 lockfile이 없으면 설치 시점에 따라 하위 버전이 달라질 수 있다.

조사할 때는 먼저 다음 명령으로 알려진 취약점 보고서를 받는다.

lockfile 기준 npm 취약점 보고서 조회Shell
npm audit --json

npm audit은 프로젝트의 의존성 정보를 설정된 npm 레지스트리에 보내 알려진 취약점 데이터와 대조한다.

--json은 사람이 읽는 표 대신 자동으로 정리하기 쉬운 JSON 형식으로 출력하라는 옵션이다.

이 명령은 보고서를 조회할 뿐 패키지를 수정하지 않는다.

또 모든 보안 문제를 발견한다고 보장하지 않는다.

새 취약점이 데이터베이스에 반영되기까지 시간이 걸릴 수 있고, 프로젝트만의 잘못된 사용 방식은 CVE 목록에 나타나지 않을 수 있다.

전이 의존성이 발견되면 어느 부모 패키지에서 들어왔는지 따라간다.

vm2가 설치된 의존 경로 확인Shell
npm ls vm2

npm ls vm2는 현재 설치 트리에서 vm2를 끌어온 상위 패키지 경로를 보여 준다.

의존성을 변경하지 않는 조회 명령이다.

출력에서 직접 추가한 항목인지, 다른 도구의 하위 의존성인지 확인해야 수정할 대상을 고를 수 있다.

하위 패키지를 강제로 덮는 것보다 부모 패키지의 안전한 버전으로 올리는 편이 계약을 지키기 쉽다.

npm 결과와 컨테이너 결과를 따로 봤다

애플리케이션의 npm 트리만 검사하면 실제 배포 이미지의 일부만 본 셈이다.

컨테이너 이미지에는 Linux 배포판의 시스템 패키지, 언어 런타임, 빌드 도구와 기본 이미지에서 물려받은 파일이 들어간다.

컨테이너는 애플리케이션과 필요한 실행 환경을 하나의 이미지로 묶어 배포하는 방식이다.

Docker Scout 같은 이미지 분석 도구는 SBOM을 만든 뒤 포함 패키지를 취약점 데이터와 대조한다.

SBOM(Software Bill of Materials)은 이미지나 소프트웨어 안에 어떤 구성 요소와 버전이 들어 있는지 적은 목록이다.

이미지 분석에서는 “어떤 계층에서 들어왔는가”도 중요하다.

직접 설치한 패키지라면 Dockerfile을 고칠 수 있지만, 기본 이미지에서 온 항목이라면 유지보수되는 새 기본 이미지로 다시 빌드해야 한다.

따라서 조사표에는 npm 결과와 이미지 결과를 별도 열로 뒀다.

같은 이름이 두 곳에 보이더라도 하나는 JavaScript 의존성이고 다른 하나는 운영체제 패키지일 수 있다.

두 결과를 단순 합산하면 중복 집계하거나 수정 위치를 잘못 고르게 된다.

네 가지 처리 경로로 나눴다

프로젝트마다 명령을 다르게 적는 대신 먼저 판단 경로를 네 가지로 통일했다.

1. 같은 메이저 버전 안에서 수정 가능

호환성을 깨지 않는 패치나 마이너 버전에 수정이 있다면 가장 먼저 검토한다.

SemVer(Semantic Versioning)는 버전을 메이저.마이너.패치로 표현하고 변경의 호환성 수준을 전달하는 규칙이다.

패치라고 해서 테스트를 생략하지는 않지만, 공개 API가 바뀌는 메이저 업그레이드보다 영향 범위가 작을 가능성이 높다.

2. 상위 라이브러리를 먼저 갱신

취약한 패키지가 전이 의존성이라면 부모 패키지가 새 하위 버전을 사용하도록 올라왔는지 확인한다.

상위 패키지가 고정한 범위를 무시하고 하위 버전만 강제로 바꾸면 런타임 계약이 깨질 수 있다.

이 경로에서는 변경 전후 lockfile과 테스트 결과를 함께 남긴다.

3. 유지보수 종료로 라이브러리 교체

EOL(End of Life)은 제품이나 버전의 공식 지원이 끝난 상태다.

수정 버전이 없거나 프로젝트가 더 이상 유지되지 않는다면 같은 라이브러리 안에서 숫자를 올리는 방법이 없다.

조사에서는 xlsx, node-sass, vm2, AWS SDK v2, Hibernate 5.x 등이 단순 갱신과 별개의 검토 대상으로 분리됐다.

이 이름들이 모두 같은 위험을 가졌다는 뜻은 아니다.

어떤 것은 대체 라이브러리 선택이 필요했고, 어떤 것은 순수 ESM 전환이나 프레임워크 메이저 업그레이드처럼 호출 코드까지 바뀌는 작업이었다.

ESM(ECMAScript Modules)은 JavaScript의 표준 모듈 형식으로, 기존 CommonJS와 불러오는 방식이 다를 수 있다.

4. 지금 실행 경로에는 없지만 근거를 둔 예외

스캔에 보였다는 이유만으로 곧바로 실제 공격 가능성이 같다고 볼 수는 없다.

취약한 기능을 사용하지 않거나 실행 이미지에 남지 않는 빌드 전용 항목일 수 있다.

그렇다고 경고를 지우고 잊지는 않는다.

Docker의 VEX는 특정 제품에서 취약점이 왜 영향을 주지 않는지 상태와 근거를 전달하는 형식이다.

VEX(Vulnerability Exploitability eXchange)는 취약점의 실제 적용 가능성에 관한 판단을 기계가 읽을 수 있게 표현한다.

예외에는 검토자, 근거, 대상 버전과 만료일을 두고 다음 이미지 변경 때 다시 확인한다.

npm audit fix --force를 일괄 실행하지 않은 이유

보안 경고 옆에 자동 수정 명령이 보이면 전체 저장소에서 한 번에 실행하고 싶어진다.

검토 없이 일괄 사용하지 않은 명령Shell
npm audit fix --force

npm audit fix는 npm이 제안하는 호환 가능한 업데이트를 lockfile과 설치 트리에 반영한다.

여기에 --force를 붙이면 선언된 SemVer 범위 밖의 버전이나 메이저 변경도 설치할 수 있고 npm의 여러 보호 장치를 해제한다.

--force는 경고를 더 강하게 검사하라는 뜻이 아니라, 호환성 보호를 무시할 수 있게 하는 옵션이다.

15개 프로젝트에 일괄 실행하면 빨간 숫자는 줄어도 빌드와 런타임이 서로 다른 방식으로 깨질 수 있다.

Angular나 NestJS 메이저 버전은 주변 패키지의 호환 범위가 연결돼 있고, Spring Boot 업그레이드는 Hibernate와 Java 런타임까지 영향을 줄 수 있다.

그래서 자동 수정은 하나의 후보 패치로만 보고 다음 단계를 거친다.

  1. 생성된 manifest와 lockfile diff를 읽는다.
  2. 메이저 버전과 제거된 패키지를 따로 표시한다.
  3. 테스트와 빌드를 실행한다.
  4. 새 이미지로 다시 스캔한다.
  5. 실제 서비스의 핵심 경로를 검증한다.
  6. 문제가 생겼을 때 되돌릴 커밋과 이미지를 보관한다.

manifest는 package.json처럼 프로젝트가 원하는 의존성을 선언한 파일이다.

스캔 결과가 0이 됐더라도 테스트와 배포 검증이 없다면 수정 완료로 보지 않는다.

심각도 하나로 순서를 정하지 않았다

Critical, High 같은 등급은 출발점이다.

인터넷에서 바로 닿는 API에 포함된 취약점과 내부 빌드 단계에서만 쓰이는 도구의 경고는 같은 우선순위를 갖지 않을 수 있다.

반대로 점수가 낮아도 인증 없이 접근 가능한 경로와 연결돼 있다면 먼저 봐야 한다.

내가 리포트에 적용한 판단 질문은 다음과 같다.

질문판단에 주는 정보
실제 배포물에 들어가는가개발 도구와 런타임 노출을 구분한다
인터넷이나 신뢰 낮은 입력에 닿는가공격자가 기능에 접근할 가능성을 본다
수정 버전이 있는가패치와 교체 작업을 나눈다
어떤 상위 패키지에서 들어왔는가실제 변경할 저장소와 담당 범위를 찾는다
메이저 변경이 필요한가회귀 테스트와 배포 단위를 크게 잡는다
서비스 중요도가 높은가실패했을 때의 사업 영향을 반영한다
예외 근거는 언제 만료되는가임시 판단이 영구 방치가 되는 것을 막는다

이 우선순위 모델은 npm이나 Docker가 자동으로 보장하는 공식 점수표가 아니다.

공식 스캔 결과에 서비스 노출과 운영 중요도를 더한 내부 판단 방식이다.

리포트 한 장이 다음 작업을 움직이게 하려면

취약점 이름만 나열한 표는 다음 담당자가 다시 조사하게 만든다.

각 행에는 최소한 다음 정보가 있어야 한다.

  • 프로젝트와 배포 이미지
  • 직접·전이·기본 이미지 중 유입 경로
  • 현재 버전과 수정 가능한 버전
  • 인터넷 노출과 사용 기능
  • 패치·상위 갱신·교체·예외 중 선택한 경로
  • 필요한 테스트와 배포 순서
  • 수정 뒤 재스캔할 기준
  • 예외라면 근거와 만료일

이번 작업의 결과는 이 정보를 서비스별로 비교할 수 있게 만든 리포트였다.

Story Point는 13으로 기록됐지만, Story Point는 팀이 예상한 작업량이지 보안 성과 지표가 아니다.

취약점을 몇 건 제거했는지 대신 13 SP를 제시하면 숫자는 있어 보여도 독자에게 잘못된 결론을 준다.

수정 성과를 말하려면 PR, 테스트, 배포와 재스캔 결과가 별도로 필요하다.

결론

15개 이상 프로젝트를 조사하며 가장 먼저 버린 생각은 “CVE 개수만 줄이면 끝난다”였다.

같은 경고도 직접 의존성인지, 전이 의존성인지, 기본 이미지에서 왔는지에 따라 손댈 위치가 달랐다.

수정 버전이 없는 라이브러리는 교체 과제가 됐고, 실행 경로에 닿지 않는 항목은 근거와 만료일을 가진 예외가 됐다.

내가 이 작업에서 완료한 것은 모든 취약점의 제거가 아니다.

현재 상태를 재현할 수 있게 만들고, 안전하게 고칠 수 있는 순서로 바꾼 일이다.

보안 스캔의 숫자는 문제의 양을 알려 주지만, 실제 해결 순서는 의존성 경로와 노출 범위가 결정한다.

Node.js·NestJS·Sequelize 메이저 버전을 나눠 올린 실제 변경은 런타임과 프레임워크 업그레이드 순서에 정리했다.

lockfile이 왜 재현 가능한 검증의 기준인지 궁금하다면 Docker 빌드에서 npm ci로 돌아온 이유에서 이어서 볼 수 있다.

참고한 공식 문서

소신을 상징하는 고래 일러스트
작성자소신 · Soshin

직접 부딪힌 문제와 해결 과정을 다음에 다시 꺼내 볼 수 있도록 기록합니다.