SSD Nodes Learn 🎉 VPS $5.50/월부터
가이드 Matt Connor작성자 Matt Connor

npm 공급망 공격 원인과 서버 보안 예방 전략

npm 패키지 설치 과정에서 발생하는 공급망 공격의 원리를 분석합니다. 캐럿 기호로 인한 최신 버전 자동 업데이트의 위험성과 악성 postinstall 스크립트 실행 경로를 설명하며, 배포 스크립트에서 보안을 강화하여 서버 침해를 방지하는 실질적인 해결책을 제시합니다.

npm 공급망 공격이란 무엇인가

npm 공급망 공격은 사용자가 설치하기로 선택한 패키지를 통해 서버에 도달합니다. 이 과정에는 열린 포트나 별도의 익스플로잇 단계가 존재하지 않습니다. npm(node package manager)은 코드를 설치하며, 코드를 설치하는 행위는 곧 코드를 실행하는 것과 같습니다. 따라서 작은 Node 애플리케이션 하나를 설치해도 사용자가 읽어본 적 없는 수백 개의 패키지가 함께 설치되며, 그중 어느 하나라도 지금으로부터 한 시간 뒤에 새로운 버전을 배포할 수 있습니다.

배포 과정에서 설치 명령이 가장 최신 버전의 패키지를 요청하기 때문에 악성 버전이 포함됩니다. 해당 코드는 설치를 실행한 사용자의 권한으로 동작합니다. 아래에 서술된 모든 내용은 이 두 문장에서 비롯됩니다.

공격 유형은 한 명의 사용자가 하나의 Node 애플리케이션을 하나의 VPS에 배포할 때 발생하는 빈도순으로 정리되었습니다. 이 순서는 대기업이 사용하는 방식과는 다릅니다. 대기업은 내부 레지스트리, 검토 팀, 그리고 공개 레지스트리의 미러 서버를 운영하기 때문입니다. 반면, 개인은 배포 스크립트를 사용합니다.

시나리오 1: 관리자 계정이 탈취되어 패치가 배포되는 경우

npm 레지스트리는 이미 존재하는 버전의 내용을 변경하는 것을 허용하지 않습니다. 따라서 관리자를 피싱하거나 배포 토큰을 탈취한 공격자도 4.18.2를 수정할 수는 없습니다. 대신 이들은 4.18.3을 배포합니다.

package.json을 확인해 보십시오. "express": "^4.18.2"와 같은 줄은 4.18.2 버전을 의미하는 것이 아닙니다. 캐럿(caret) 기호는 "해당 버전 이상인 모든 4.x 버전"을 의미하며, ~4.18.2은 "모든 4.18.x 버전"을 의미합니다. npm install는 실행 시점에 해당 범위를 결정하므로, 같은 날 오후에 동일한 git 커밋을 두 번 배포하더라도 서로 다른 코드 세트가 설치될 수 있습니다. 이 간극이 바로 공격 표면입니다. 이를 열기 위해 사용자의 시스템이 직접적으로 침해될 필요조차 없습니다.

악성 릴리스는 보통 신고를 통해 삭제되지만, 삭제는 이미 사람들이 설치를 마친 후에 이루어집니다. 해당 기간 중에 배포를 수행한 사람은 누구나 디스크에 악성 코드를 보유하게 됩니다. 실행할 때마다 버전을 결정하는 파이프라인은 아무런 의도 없이도 매주 여러 번 자동으로 해당 위험 기간에 노출됩니다.

형태 2: 설치 스크립트가 배포를 수행하는 사용자의 권한으로 실행됨

패키지의 package.jsonscripts 블록 내에서 preinstall, install, postinstallprepare를 선언할 수 있습니다. npm은 설치 과정 중에 이들을 실행합니다. 이 스크립트들은 샌드박스 환경에서 동작하지 않으며, 누구도 검토하지 않습니다. 이들은 설치 명령을 입력한 사용자의 권한으로, 해당 사용자의 홈 디렉터리에서, 해당 사용자의 네트워크 접근 권한과 셸의 전체 환경을 사용하여 실행되는 셸 명령어입니다.

따라서 중요한 질문은 "패키지가 무엇을 할 수 있는가"가 아니라 "해당 사용자가 무엇을 읽을 수 있는가"입니다. 일반적인 배포 서버에서 그 답에는 레지스트리 토큰을 담고 있는 ~/.npmrc, SSH(secure shell) 배포 키로 사용되는 ~/.ssh/id_ed25519, ~/.aws/credentials, ~/.docker/config.json, 그리고 보통 DATABASE_URL가 위치하는 셸의 모든 내보낸(exported) 변수가 포함됩니다.

이와 같은 페이로드는 지속성(persistence)이나 권한 상승이 필요하지 않습니다. 몇 개의 파일을 읽어 HTTPS를 통해 호스트로 전송한 뒤 상태 코드 0으로 종료합니다. npm이 기본적으로 설치 스크립트의 출력을 숨기기 때문에 아무것도 볼 수 없습니다. 이 설정을 끄고 실제로 무엇이 실행되는지 확인하십시오:

npm ci --foreground-scripts

foreground-scripts은 npm 프로세스와 표준 입력, 출력 및 오류를 공유하므로, 빌드 스크립트는 npm이 설치 성공 후 폐기하는 버퍼가 아닌 터미널에 직접 내용을 출력합니다.

유형 3: 타이포스쿼팅과 잘못 입력한 이름

타이포스쿼팅(typosquatting)은 인기 있는 패키지와 유사한 이름으로 배포된 패키지를 의미하며, 사용자가 설치 명령어를 잘못 입력하거나 붙여넣기 할 때를 노립니다. 이 공격은 코드가 아닌 명령어를 통해 이루어지므로 lockfile은 아무런 도움이 되지 않습니다. 잘못된 이름을 한 번 추가하면, 이후부터는 lockfile이 해당 패키지를 충실히 고정(pin)하기 때문입니다.

개인이 아닌 팀을 노리는 변종 공격은 의존성 혼동(dependency confusion)입니다. 내부 패키지 이름이 billing-utils이고 비공개 레지스트리에 있다고 가정해 보겠습니다. 만약 공개 레지스트리에 billing-utils이라는 이름의 패키지가 없다면, 누구나 해당 이름으로 패키지를 배포할 수 있습니다. npm은 스코프가 지정되지 않은(unscoped) 이름을 기본 공개 레지스트리에서 찾으므로, 공개된 가짜 패키지가 우선순위를 가질 수 있습니다. 이를 해결하려면 직접 소유한 스코프를 사용하고 .npmrc 파일에 해당 스코프에 대한 레지스트리 매핑을 설정해야 합니다.

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

이제 @yourorg/billing-utils은 항상 해당 호스트에서만 가져오게 됩니다. 스코프와 레지스트리 간의 매핑이 기본 레지스트리보다 우선적으로 참조되기 때문입니다. 스코프가 지정되지 않은 내부 이름은 매핑 정보가 없으므로 보호받을 수 없습니다.

새로운 의존성을 추가하기 전, 다운로드 배지 대신 다음 사항을 확인하십시오.

npm view some-lib repository.url maintainers time.created time.modified

지난달에 생성되었고 공개 저장소와 연결할 수 없는 계정에서 배포된 패키지는 6년의 이력을 가진 패키지와는 다른 위험을 내포합니다. 물론 이 사실들 자체가 증거는 아닙니다. 하지만 확인하는 데 드는 비용은 매우 적습니다.

형태 4: 소유자가 조용히 변경된 의존성

메인테이너는 패키지 관리 권한을 넘깁니다. 누군가 번아웃을 겪고, 낯선 사람이 도움을 제안하며, 배포 권한이 이동하지만, 이를 의존하는 프로젝트에는 어떠한 알림도 전달되지 않습니다. 서버가 침해된 것은 아닙니다. 2021년에 부여했던 신뢰가 이제는 다른 사람에게 넘어가 있는 상태입니다.

이는 가장 느리게 진행되는 형태이며 탐지하기 가장 어렵습니다. 이를 직접적으로 알려주는 명령어는 없습니다. 두 가지 방법으로 범위를 좁힐 수 있습니다. 패키지를 도입하기 전에 위의 npm view 명령어를 사용하여 누가 배포할 수 있는지 확인하십시오. 그런 다음 실제로 의존하고 있는 패키지에 변경이 발생하면 diff를 읽어보십시오.

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

첫 번째 형식은 변경된 파일 이름만 출력하므로, 중요하게 관리하는 패키지를 업그레이드할 때마다 실행하기에 충분히 빠릅니다. 빌드 스크립트를 수정하거나, 패키지 루트에 파일을 추가하거나, scripts 블록을 편집하는 패치 릴리스는 서버에 적용하기 전에 전체 내용을 읽어볼 가치가 있습니다.

npm ci를 사용하여 커밋된 lockfile로부터 빌드하기

package-lock.json은 트리 내 모든 패키지의 정확한 버전, 각 패키지가 유래한 URL, 각 tarball의 sha512 무결성 해시, 그리고 해당 패키지를 요구한 상위 패키지 정보를 기록합니다. 이 파일을 커밋하십시오. 실제로 테스트를 거친 환경을 보장하는 유일한 파일입니다.

개발자용 노트북이 아닌 모든 머신에서는 npm install가 아닌 npm ci를 사용하여 설치하십시오:

npm ci --omit=dev --ignore-scripts

npm cinpm install과 여러 면에서 다르며, 이 차이점들은 모두 중요합니다. npm ci은 lockfile이 반드시 존재해야 합니다. 설치를 시작하기 전에 기존 node_modules을 삭제하므로, 이전 배포의 잔재가 현재 배포에 영향을 미치지 않습니다. 또한 package.json나 lockfile에 내용을 기록하지 않으므로, 설치 과정에서 임의로 버전이 업데이트되지 않습니다. 만약 lockfile과 package.json의 내용이 일치하지 않으면, 차이를 해결하려 시도하지 않고 즉시 오류를 발생시키며 종료합니다.

이 오류는 불편함이 아니라 기능입니다. 이는 의존성 변경 사항이 누군가 검토한 커밋을 통해 반영되어야 함을 의미하며, 새벽 02:00에 진행되는 배포의 부작용으로 발생해서는 안 된다는 뜻입니다.

모든 패키지를 가져올 때마다 무결성 해시를 검사합니다. 기록된 해시와 실제 tarball의 바이트가 일치하지 않으면 압축을 해제하지 않고 code EINTEGRITY 오류와 함께 설치를 중단합니다. 이 기능이 제공하는 이점을 정확히 이해해야 합니다. 이는 수신한 파일이 lockfile에 고정된 바로 그 파일임을 증명하며, 이는 체크섬을 통한 다운로드 검증이 제공하는 보증과 동일한 수준이며 그 한계 또한 같습니다. 고정된 버전이 배포될 당시 악의적인 코드였는지 여부에 대해서는 아무것도 보장하지 않습니다.

--omit=dev에 관한 한 가지 세부 사항입니다. 해당 패키지들은 여전히 해결(resolve) 과정을 거치며 lockfile에도 기록됩니다. 단지 디스크에 설치되지 않을 뿐입니다. 디스크에 설치되는 패키지가 적을수록 실행되는 설치 스크립트와 런타임에 로드되는 코드의 양이 줄어들므로, 이 설정을 사용하는 것이 좋습니다. 이 설정은 트리에서 의존성을 제거하는 것이 아닙니다.

설치 스크립트를 코드로 취급하고 거부하는 방법

설치 스크립트를 비활성화할 수 있습니다. 프로젝트의 .npmrc에 다음 내용을 추가하고 lockfile과 함께 커밋하십시오:

ignore-scripts=true
save-exact=true

ignore-scripts=true는 의존성 패키지에 선언된 스크립트가 npm에 의해 실행되는 것을 방지합니다. save-exact=true를 설정하면 npm install some-lib^1.4.2 대신 1.4.2package.json에 기록하므로, 의도치 않게 해결 범위(resolving range)가 매니페스트에 포함되는 일을 방지할 수 있습니다.

이 설정은 일부 기능을 작동하지 않게 만들 수 있으므로, 활성화하기 전에 그 이유를 이해해야 합니다. 네이티브 애드온을 컴파일하거나 미리 빌드된 바이너리를 다운로드하는 패키지는 설치 스크립트 단계에서 해당 작업을 수행합니다. 스크립트를 비활성화하면 설치 자체는 성공하지만, 나중에 런타임 시점에 바인딩 파일을 로드하지 못하는 모듈 오류가 발생합니다. 이에 대한 해결책은 허용 목록(allowlist)을 사용하는 것입니다:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package>은 해당 패키지에 대해서만 빌드 스크립트를 실행합니다. 이제 수백 명의 낯선 사람에게 무차별적인 실행 권한을 부여하는 대신, 패키지별로 직접 결정할 수 있게 되었습니다.

현재 부여된 권한의 규모를 확인하려면 npm에 다음과 같이 질의하십시오:

npm query ":attr(scripts, [postinstall])"

이 명령은 설치된 트리에서 postinstall 스크립트를 포함하는 모든 패키지를 출력합니다. 일반적인 애플리케이션에서 이 목록은 생각보다 짧으며, 바로 이 점이 허용 목록을 실용적으로 만들어 줍니다.

빌드 과정과 트래픽 처리 프로세스 분리

배포용 사용자는 node_modules에 대한 쓰기 권한이 필요합니다. 하지만 HTTP 요청을 처리하는 프로세스에는 이 권한이 필요 없습니다. 두 작업에 동일한 계정을 사용하면, 설치 과정에서 실행되는 코드가 사용자에게 서비스를 제공하는 코드를 덮어쓸 수 있으며, 런타임에 실행되는 코드 역시 이를 수정할 수 있습니다.

이 둘을 분리하십시오. 한 사용자로 빌드하고 다른 사용자로 서비스를 실행하며, 서비스 실행 계정에는 해당 디렉터리를 읽기 전용으로 설정하십시오.

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

그런 다음 systemd를 사용하여 이를 강제하십시오. /etc/systemd/system/nodeapp.service을 작성하십시오.

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict/dev, /proc, /sysReadWritePaths에 나열된 항목을 제외하고 이 서비스에 대한 전체 파일 시스템을 읽기 전용으로 마운트합니다. 따라서 애플리케이션이 node_modules에 쓰기를 시도하면 EROFS: read-only file system 오류가 발생하며, 이는 약 1분 내에 로그에서 직접 재현하여 확인할 수 있습니다. NoExecPaths은 쓰기 가능한 업로드 디렉터리를 처리합니다. 서비스는 해당 위치에 파일을 쓸 수 있지만 커널은 실행을 거부합니다. 이 옵션은 systemd 249 버전 이상이 필요하며, Ubuntu 24.04는 255 버전을 제공합니다.

이 유닛 파일에는 두 가지 주의사항이 있습니다. 첫째, MemoryDenyWriteExecute=yes를 추가하지 마십시오. 대부분의 systemd 보안 강화 목록에 포함되어 있지만, Node 실행을 중단시킵니다. V8 엔진은 런타임에 JavaScript를 기계어로 컴파일하며, 이때 쓰기와 실행 권한이 모두 있는 페이지가 필요하기 때문입니다. 둘째, command -v node에서 ExecStart 경로를 가져오십시오. Node가 버전 관리자를 통해 설치되었다면 배포 사용자의 홈 디렉터리 아래에 위치합니다. 이때 ProtectHome=yes는 해당 디렉터리를 서비스로부터 숨기며, 유닛은 status=203/EXEC 오류와 함께 실행 파일을 찾을 수 없다는 로그를 남기고 즉시 실패합니다.

파일 설정을 맹신하지 말고 결과를 직접 확인하십시오.

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security은 모든 보안 강화 설정과 그 노출 범위를 나열하므로, 어떤 설정이 기본값으로 유지되고 있는지 확인할 수 있습니다. nodeappcurrent 하위의 어떤 파일도 소유하지 않으므로 touchPermission denied와 함께 실패해야 합니다. 만약 성공한다면 파일 소유권 설정이 잘못된 것이며, systemd 설정이 이를 조용히 가리고 있는 상태입니다.

EnvironmentFile에 관한 한 가지 참고 사항입니다. systemd는 User=nodeapp으로 권한을 낮추기 전에 root 권한으로 파일을 읽으므로, 해당 파일은 모드 600으로 설정된 root:root여도 됩니다. 애플리케이션은 여전히 환경 변수를 전달받습니다. nodeapp 계정으로 셸에 접근할 수 있는 사람은 누구나 /proc/<pid>/environ에서 이를 읽을 수 있으므로, 이 방식은 실행 중인 프로세스가 아닌 저장된 상태의 비밀 정보를 보호합니다.

배포 자격 증명을 빌드 환경에 포함하지 마십시오

설치 스크립트는 환경 변수를 상속받습니다. 이 사실 하나만으로도 어디에서 빌드할지 결정해야 합니다.

가장 강력한 방법은 운영 서버가 아닌 곳에서 빌드하고 완성된 디렉터리만 복사하는 것입니다. 빌드 머신은 읽기 전용 레지스트리 토큰 외에는 아무것도 보유하지 않아야 합니다. SSH 배포 키, 클라우드 액세스 키, 데이터베이스 비밀번호, 컨테이너 레지스트리 로그인 정보는 포함하지 마십시오.

npm token create --read-only

읽기 전용 토큰은 패키지를 가져올 수는 있지만 게시할 수는 없습니다. 빌드 환경에서 이 토큰이 탈취되더라도, 공개 패키지를 다운로드할 수 있는 권한이 유출되는 수준에 그칩니다.

반드시 서버에서 빌드해야 한다면, 의도적으로 환경이 제한된 deploy 사용자로 빌드하고 런타임 비밀 정보는 deploy가 읽을 수 없는 /etc/nodeapp/env에 보관하십시오. 직접 호스팅하는 빌드 자동화 도구에도 동일한 논리가 적용됩니다. 자체 호스팅 GitHub Actions 러너는 토큰을 보유하고 모든 작업에서 임의의 게시된 코드를 실행하므로, 소규모 배포 환경에서 가장 가치가 높은 머신이 됩니다. 전체 환경 변수를 전달받는, 직접 작성하지 않은 모든 프로그램은 같은 범주에 속합니다. 이것이 바로 AI 에이전트 환경에서 비밀 정보를 제외하는 것이 중간에 다른 프로그램이 개입된 동일한 문제인 이유입니다.

감사할 수 없는 의존성은 고정하거나 벤더링하십시오

고정된(pinned) 의존성이란 커밋 없이는 버전이 변경될 수 없는 의존성을 의미합니다. 이미 커밋된 잠금 파일(lockfile)은 전체 트리 수준에서 이 역할을 수행합니다. 하지만 두 가지 경우에는 추가적인 조치가 필요합니다.

첫 번째는 전이적 의존성(transitive dependency)입니다. 의존성이 다시 어떤 패키지에 의존하는지는 직접 제어할 수 없습니다. overridespackage.json 설정을 사용하면 트리 내 어디에 있든 특정 버전을 강제할 수 있습니다.

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

추가한 후 npm install를 한 번 실행하여 잠금 파일에 결과를 기록하고, 두 파일을 모두 커밋하십시오.

두 번째는 감사할 수 없으면서 제거할 수도 없는 패키지입니다. 이 경우 벤더링(vendor)을 수행하십시오. npm pack은 레지스트리가 제공하는 것과 동일한 타르볼(tarball)을 다운로드하며, file: 의존성은 사용자가 복사해 둔 파일로부터 설치를 진행합니다.

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

이제 타르볼이 저장소 내부에 존재하므로 예기치 않게 변경될 수 없습니다. 다만, 이후 발생하는 모든 업데이트를 직접 관리해야 하므로, 이 방식은 웹 프레임워크와 같은 대규모 패키지가 아니라 어쩔 수 없이 사용해야 하는 소규모의 방치된 패키지에만 적용하십시오.

비용이 들지 않는 냉각 기간(cooling-off period)을 활용하는 방법도 있습니다.

npm install --before=2026-08-01

before 옵션은 해당 날짜 이전에 게시된 버전만을 사용하여 트리를 재구성합니다. 의존성을 갱신할 때 날짜를 1~2주 전으로 설정하면, 악성 릴리스가 배포되었으나 아직 보고되지 않은 위험 구간을 피할 수 있습니다. 이는 유효한 보안 패치까지 지연시킬 수 있는 다소 투박한 도구입니다. 범위를 해결하고 변경 사항을 확인한 뒤 잠금 파일을 커밋하는 용도로만 사용하십시오.

실제로 배포된 버전은 어떻게 확인합니까?

git의 lockfile은 설치되어야 할 대상을 명시합니다. 디스크는 실제로 설치된 상태를 보여줍니다. 오직 후자만이 증거가 됩니다.

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls은(는) node_modules을(를) 읽으므로, lockfile이 의도한 내용이 아니라 물리적으로 존재하는 내용을 보고합니다. node -e 라인은 설치된 매니페스트를 경로별로 읽습니다. 이 방식은 exports 필드가 하위 경로 임포트를 차단하는 패키지에서도 작동하며, 트리 구조 없이 버전 하나만을 출력합니다.

비교의 나머지 절반을 위해 git을 확인하십시오:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

커밋을 배포 레이아웃에 포함하여 두 상태 사이의 연결을 영구적으로 만드십시오. /srv/nodeapp/releases/<short commit sha>에 릴리스하고 /srv/nodeapp/current이(가) 심볼릭 링크를 통해 이를 가리키도록 설정하십시오. "현재 무엇이 실행 중인가"에 대한 답은 readlink /srv/nodeapp/current가 되며, 배포자가 아닌 사람도 03:00에 이를 확인할 수 있습니다.

마지막으로 레지스트리가 보증하는 내용을 확인하십시오:

npm audit signatures

이 명령은 설치된 트리의 패키지에 대한 레지스트리 서명을 검증하고, 패키지에 포함된 출처 증명(provenance attestation)을 확인합니다. 출처 증명은 게시된 tarball을 해당 파일을 생성한 공개 CI(continuous integration) 빌드와 연결합니다. 따라서 검증된 증명은 코드를 알 수 없는 개인용 노트북이 아닌 특정 커밋으로 추적할 수 있음을 의미합니다. 모든 패키지가 이를 지원하는 것은 아니므로, 증명이 누락된 경우 "잘못된 패키지"가 아니라 "정보 없음"으로 해석하십시오.

잘못된 릴리스가 서버에 배포되었을 때의 대응 방법

실행된 내용과 실행한 사용자를 기준으로 밖으로 범위를 넓혀가며 확인하십시오.

설치 과정에서 코드가 실행되었다면, 빌드 사용자가 읽을 수 있는 모든 파일이 유출되었다고 가정해야 합니다. 레지스트리 토큰, 해당 홈 디렉터리의 SSH 키, 클라우드 자격 증명, 그리고 해당 셸에서 내보낸 모든 비밀 정보를 교체하십시오. 파일이 읽히지 않았음을 증명할 방법은 없으므로, 교체만이 유일하고 정직한 대응입니다.

권한이 제한된 서비스 계정으로 런타임에 코드가 실행되었다면, 영향 범위는 훨씬 작습니다. 애플리케이션 자체의 환경 변수와 네트워크 접근을 통해 도달할 수 있는 범위로 한정됩니다. 이것이 바로 VPS에서 권한 없는 사용자로 서비스를 실행하는 이유입니다. 이 방식이 침해를 막아주는 것은 아닙니다. 다만 침해가 기계의 어느 범위까지 영향을 미치는지, 그리고 재시작 후에도 침해가 유지되는지를 결정할 뿐입니다.

그다음에는 정리하는 대신 재빌드하십시오. node_modules을 삭제하고, package.json에서 문제가 된 패키지를 잘못된 버전 아래로 고정(pin)한 뒤, npm install를 한 번 실행하여 잠금 파일(lockfile)을 업데이트하고 커밋한 다음, npm ci으로 배포하십시오. 이미 설치된 트리를 현장에서 수정하지 마십시오. 설치 스크립트가 무엇을 건드렸는지 일일이 파악할 수 없기 때문입니다.

또한 문제가 발생한 기간을 기록해 두십시오. 해당 버전을 가져올 수 있었던 첫 번째 배포 시점과, 해당 버전을 제거한 배포 시점을 기록합니다. 이 범위를 알아야 어떤 로그를 확인해야 할지 알 수 있으며, 릴리스에 커밋 이름을 붙여 관리하고 있을 때만 이 질문에 답할 수 있습니다.

이 방법들로도 해결되지 않는 문제

lockfile은 의존성을 안전하게 만들어 주지 않습니다. 의존성을 수락한 시점을 배포의 부수 효과가 아닌, 날짜가 기록되고 검토된 결정으로 바꿀 뿐입니다. 위에서 설명한 모든 관행은 이와 같이 우연을 선택으로 전환하는 과정을 수행합니다.

npm audit은 여기서 방어 수단이 되지 못합니다. 이 도구는 사용자의 의존성 트리를 보고된 취약점 데이터베이스와 비교하므로, 이미 공개되고 명명된 문제만 찾아냅니다. 공급망 공격은 유효한 공격 기간 내내 그 이름이 알려지지 않습니다. 알려진 구형 버그를 찾기 위해 npm audit을 실행할 수는 있으나, 4시간 전에 배포된 릴리스에 대해서는 아무런 도움도 기대할 수 없습니다.

의존성 개수를 줄이는 것이 이 가이드에 소개된 어떤 도구보다 효과적이지만, 가장 인기가 없는 조언이기도 합니다. 추가하지 않은 패키지 하나당 귀하를 대신해 피싱 공격을 당할 수 있는 게시자가 하나 줄어들며, 귀하의 배포 사용자 권한으로 실행될 설치 스크립트도 하나 줄어드는 셈입니다.

이러한 문제는 npm에만 국한되지 않습니다. 동일한 네 가지 양상이 PyPI, RubyGems, 컨테이너 이미지, 그리고 운영체제의 패키지 관리자에도 적용됩니다. npm에서 이 문제가 가장 두드러지는 이유는 의존성 트리가 가장 깊고 설치 스크립트가 기본적으로 실행되기 때문입니다. 주변 시스템 중 어느 범위까지를 직접 방어해야 하는지는 해당 시스템이 어디에서 실행되는지에 달려 있으며, 이는 VPS 호스팅이 안전한가에 대한 더 넓은 질문의 일부입니다.

FAQ

npm ci는 손상된 npm 패키지로부터 저를 보호해주나요?

이 도구는 사용자가 인지하지 못한 사이에 버전이 변경되는 것을 방지합니다. npm cipackage-lock.json에 기록된 내용을 정확하게 설치하며, 각 tarball을 sha512 무결성 해시와 대조합니다. 만약 package.json와 lockfile의 내용이 일치하지 않으면 차이를 해결하려 시도하지 않고 즉시 오류를 발생시키며 종료합니다. 다만, 고정된 버전이 안전한지 여부에 대해서는 보장하지 않습니다. 만약 악성 버전이 고정된 lockfile을 커밋한다면, npm ci은 모든 서버에 해당 버전을 매번 충실하게 설치할 것입니다.

모든 것에 ignore-scripts=true를 설정해야 하나요?

먼저 설정한 뒤 허용 목록(allowlist)을 관리하십시오. 프로젝트의 .npmrcignore-scripts=true를 설정하면 의존성 설치 스크립트 실행이 중단됩니다. 이는 악성 패키지가 배포 사용자의 자격 증명에 접근하는 가장 직접적인 경로를 차단합니다. 네이티브 애드온을 컴파일하거나 사전 빌드된 바이너리를 가져오는 패키지는 실제로 스크립트가 필요하지만, 스크립트를 끄면 설치 시점이 아닌 런타임에 바인딩 파일 누락 오류가 발생하며 실패하게 됩니다. npm ci --ignore-scripts을 실행한 뒤, 신뢰하기로 결정한 소수의 패키지에 대해서만 npm rebuild <package>을 사용하십시오. npm query ":attr(scripts, [postinstall])"를 통해 실제 신뢰하는 패키지가 몇 개인지 확인할 수 있습니다.

서버에 실제로 설치된 패키지 버전을 어떻게 확인하나요?

lockfile이 아닌 디스크를 직접 확인하십시오. npm ls <package>node_modules에 존재하는 내용을 보고하며, node -e "console.log(require('./node_modules/<package>/package.json').version)"는 버전 문자열만 출력합니다. git의 lockfile은 '설치되어야 하는 버전'이라는 다른 질문에 답하며, 이 둘을 비교하는 것이 핵심입니다. git 커밋 이름을 딴 디렉터리에 배포하면 몇 달 뒤 필요할 때 두 가지 답변을 모두 확인할 수 있습니다.

npm audit이 공급망 공격을 찾아낼 수 있나요?

아니요. npm audit는 보고된 취약점 데이터베이스와 트리 구조를 대조하므로, 이미 공개되어 식별자가 부여된 문제만 찾아낼 수 있습니다. 악성 릴리스는 설치가 중요한 수 시간 또는 수일 동안은 보고되지 않습니다. npm audit signatures가 더 유용한 명령어입니다. 이 명령어는 설치된 트리 전체의 레지스트리 서명을 검증하고, 게시자가 생성한 출처 증명(provenance attestations)을 확인합니다. 이를 통해 tarball이 알 수 없는 기기가 아닌 공개 빌드에서 생성되었음을 알 수 있습니다.

설치 시점에 공격이 발생한다면 권한 없는 사용자로 앱을 실행하는 것이 왜 중요한가요?

두 가지 실패 상황은 영향 범위가 다르며, 사용자는 이 두 가지 모두를 방어해야 하기 때문입니다. 설치 시점의 코드는 배포 사용자 권한으로 실행되므로 해당 사용자의 SSH 키, 레지스트리 토큰, 클라우드 자격 증명을 읽을 수 있습니다. 런타임 코드는 서비스 계정으로 실행되며, User=nodeapp, ProtectSystem=strict을 사용하고 디스크에 읽을 수 있는 자격 증명이 없다면 공격 범위는 애플리케이션 환경과 데이터베이스로 제한됩니다. 계정을 분리하면 트래픽을 처리하는 프로세스가 node_modules을 다시 쓸 수 없게 되므로, 런타임 침해 사고가 발생해도 다음 재시작 시점에 공격이 제거되며 영구적인 피해로 이어지지 않습니다.