npm Supply-Chain Attack trên server: cách chặn
Tìm hiểu cách package độc hại vào Node app trên VPS qua patch release, postinstall, typosquat và cách deploy dùng lockfile để chặn chúng.
Một cuộc tấn công supply-chain qua npm trên server của bạn
Một cuộc tấn công supply-chain qua npm xâm nhập server của bạn thông qua package mà bạn đã chọn cài đặt. Cuộc tấn công này không cần cổng đang mở và cũng không cần bước khai thác nào. npm (node package manager) cài đặt code, và cài đặt code đồng nghĩa với chạy code. Vì vậy, một ứng dụng Node nhỏ có thể kéo theo hàng trăm package mà bạn chưa từng đọc, và bất kỳ package nào trong số đó cũng có thể phát hành một version mới vào giờ tới.
Quá trình deploy của bạn tải về một version độc hại vì lệnh cài đặt yêu cầu version mới nhất phù hợp. Code đó sau đó chạy với quyền của người đã thực hiện lệnh cài đặt. Mọi nội dung bên dưới đều bắt nguồn từ hai câu này.
Các dạng tấn công được sắp xếp theo tần suất chúng ảnh hưởng đến một người deploy một ứng dụng Node lên một VPS. Thứ tự này không phù hợp với một công ty lớn, vì công ty lớn có registry nội bộ, đội ngũ review và mirror của registry công khai. Bạn chỉ có một deploy script.
Hình 1: tài khoản của maintainer bị chiếm quyền và phát hành một patch
npm registry không cho phép thay đổi nội dung của một version đã tồn tại. Vì vậy, attacker đã phishing maintainer hoặc lấy cắp publish token không thể ghi đè 4.18.2. Họ phát hành 4.18.3.
Hãy xem package.json của bạn. Một dòng như "express": "^4.18.2" không có nghĩa là version 4.18.2. Dấu caret có nghĩa là “bất kỳ version 4.x nào bằng hoặc mới hơn version này”, còn ~4.18.2 có nghĩa là “bất kỳ version 4.18.x nào”. npm install phân giải range đó tại thời điểm chạy. Vì vậy, cùng một git commit được deploy hai lần trong cùng một buổi chiều có thể cài đặt hai tập code khác nhau. Khoảng thời gian đó là attack surface. Không cần máy của bạn bị compromise thì attack surface này mới xuất hiện.
Các malicious release thường được báo cáo và gỡ khỏi registry, nhưng việc gỡ chỉ xảy ra sau khi người dùng đã cài đặt chúng. Bất kỳ ai deploy trong khoảng thời gian đó đều đã có code trên disk. Pipeline phân giải các range ở mỗi lần chạy sẽ tự động đi vào khoảng thời gian này vài lần mỗi tuần mà không cần ai chủ động quyết định.
Hình thức 2: install script chạy dưới tài khoản thực hiện deploy
package.json của một package có thể khai báo preinstall, install, postinstall và prepare trong block scripts. npm chạy các script này trong quá trình cài đặt. Chúng không chạy trong sandbox và không có ai kiểm tra. Đây là các shell command chạy dưới tài khoản đã nhập lệnh cài đặt, trong home directory của tài khoản đó, với quyền truy cập network của tài khoản đó và toàn bộ environment của shell đó.
Vì vậy, câu hỏi hữu ích không phải là package có thể làm gì. Câu hỏi là tài khoản đó có thể đọc được gì. Trên một deploy box thông thường, câu trả lời gồm ~/.npmrc chứa registry token, ~/.ssh/id_ed25519 được dùng làm deploy key cho SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json và mọi biến đã export trong shell. DATABASE_URL thường nằm trong số đó.
Payload như vậy không cần persistence và cũng không cần privilege escalation. Nó đọc một vài file, gửi chúng đến một host qua HTTPS rồi thoát với status 0. Bạn không thấy gì vì npm mặc định ẩn output của install script. Hãy tắt tùy chọn đó và theo dõi chính xác những gì đang chạy:
npm ci --foreground-scriptsforeground-scripts chia sẻ standard input, output và error với tiến trình npm. Vì vậy, build script in trực tiếp vào terminal thay vì vào một buffer bị npm loại bỏ khi quá trình cài đặt thành công.
Hình thức 3: typosquat và tên bạn đã gõ không hoàn toàn đúng
Typosquat là package được publish dưới một tên gần giống tên phổ biến, chờ một lệnh install bị gõ sai hoặc dán nhầm. Cơ chế nằm ở command chứ không nằm ở code, nên lockfile không giúp được trong trường hợp này: bạn thêm nhầm tên một lần, rồi từ đó lockfile sẽ pin đúng tên nhầm đó.
Biến thể thường đánh lừa cả team thay vì chỉ một cá nhân là dependency confusion. Package nội bộ của bạn có tên billing-utils và nằm trên private registry. Nếu trên public registry chưa có package nào tên billing-utils, bất kỳ ai cũng có thể publish một package như vậy. npm resolve các tên không có scope bằng default public registry, nên bản public có thể được chọn. Cách khắc phục là dùng một scope do bạn sở hữu và cấu hình registry mapping cho scope đó trong .npmrc:
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}Từ bây giờ, @yourorg/billing-utils chỉ được fetch từ host đó, vì scope-to-registry mapping được kiểm tra trước default registry. Tên nội bộ không có scope không có mapping, nên không được bảo vệ.
Trước khi thêm dependency mới, hãy xem package thay vì chỉ nhìn download badge:
npm view some-lib repository.url maintainers time.created time.modifiedMột package được tạo vào tháng trước và được publish bởi account mà bạn không thể liên kết với một public repository có mức rủi ro khác với package đã có lịch sử sáu năm. Không sự kiện nào trong hai sự kiện này là bằng chứng chắc chắn. Nhưng cả hai đều dễ kiểm tra.
Hình thái 4: dependency mà chủ sở hữu đã âm thầm thay đổi
Các maintainer chuyển giao package cho người khác. Một người kiệt sức, một người lạ đề nghị hỗ trợ, quyền publish được chuyển đi, nhưng không có bất kỳ thông báo nào đến các project đang phụ thuộc vào package đó. Không có gì bị breach. Trust bạn trao vào năm 2021 hiện do một người khác nắm giữ.
Đây là hình thái diễn ra chậm nhất và khó phát hiện nhất, đồng thời không có command nào trả lời trực tiếp được. Có 2 việc giúp thu hẹp phạm vi. Trước khi dùng một package, hãy kiểm tra ai có quyền publish bằng dòng npm view ở trên. Sau đó đọc diff khi một package mà bạn thực sự phụ thuộc vào có thay đổi:
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.3Dạng đầu tiên chỉ in ra các filename đã thay đổi, nên đủ nhanh để chạy sau mỗi lần upgrade một package quan trọng với bạn. Một patch release có thay đổi build script, thêm file ở package root hoặc chỉnh sửa block scripts đều đáng để đọc toàn bộ trước khi được đưa lên server.
Build từ lockfile đã commit bằng npm ci
package-lock.json ghi lại chính xác phiên bản của mọi package trong cây dependency, URL nguồn của từng package, integrity hash sha512 của từng tarball và package nào yêu cầu nó. Hãy commit file này. Đây là file duy nhất cho biết chính xác những gì bạn đã kiểm thử.
Sau đó cài đặt bằng npm ci, không bao giờ dùng npm install, trên mọi máy không phải laptop của developer:
npm ci --omit=dev --ignore-scriptsnpm ci khác npm install theo nhiều điểm đều quan trọng trong trường hợp này. Lệnh này yêu cầu phải có lockfile. Trước khi bắt đầu, lệnh xóa node_modules hiện có, nên phần còn sót lại từ lần deploy trước không thể tồn tại trong lần này. Lệnh không bao giờ ghi vào package.json hoặc lockfile, nên quá trình cài đặt không thể âm thầm đưa bạn lên phiên bản mới hơn. Nếu lockfile và package.json không khớp, lệnh sẽ thoát với lỗi thay vì tự giải quyết khác biệt.
Lỗi đó là một tính năng, không phải điều phiền toái. Nó có nghĩa là thay đổi dependency phải được đưa vào bằng một commit đã được người khác review, thay vì xuất hiện như tác dụng phụ của một lần deploy lúc 02:00.
Integrity hash được kiểm tra trong mỗi lần fetch. Nếu các byte của tarball không khớp với hash đã ghi nhận, quá trình cài đặt sẽ thất bại với code EINTEGRITY thay vì giải nén tarball. Cần hiểu chính xác giá trị của cơ chế này: nó chứng minh file bạn nhận được chính là file mà lockfile đã pin. Đây cũng là đảm bảo mà xác minh download bằng checksum cung cấp, và nó có cùng giới hạn. Cơ chế này không cho biết phiên bản đã pin có chứa mã độc ngay từ thời điểm được phát hành hay không.
Một chi tiết về --omit=dev: các package đó vẫn được resolve và vẫn được ghi vào lockfile. Chúng chỉ không được đặt trên disk. Ít package trên disk hơn đồng nghĩa với ít install script hơn và ít code được load lúc runtime hơn, nên bạn nên thực hiện việc này. Tuy nhiên, nó không xóa dependency khỏi cây của bạn.
Xem install script như code và biết cách từ chối chúng
Bạn có thể tắt install script. Thêm nội dung này vào .npmrc của project rồi commit cùng lockfile:
ignore-scripts=true
save-exact=trueignore-scripts=true ngăn npm chạy các script được khai báo trong dependency. save-exact=true khiến npm install some-lib ghi 1.4.2 vào package.json thay vì ^1.4.2, nhờ đó một resolving range không vô tình xuất hiện trong manifest.
Cách này sẽ làm hỏng một số thứ, và bạn cần biết cách xử lý trước khi bật nó. Các package biên dịch native addon hoặc tải prebuilt binary thường thực hiện công việc đó trong install script. Khi tắt script, quá trình install vẫn thành công nhưng lỗi chỉ xuất hiện sau đó, lúc runtime, dưới dạng module không thể load file binding của nó. Cách xử lý là dùng allowlist:
npm ci --ignore-scripts
npm rebuild better-sqlite3npm rebuild <package> chạy build script cho riêng package đó. Bạn đã đưa ra quyết định cho từng package, thay vì cấp quyền execute không giới hạn cho vài trăm người xa lạ mà bạn sẽ không bao giờ gặp.
Để biết hiện tại quyền cấp đó lớn đến đâu, hãy hỏi npm:
npm query ":attr(scripts, [postinstall])"Lệnh này in ra mọi package trong cây đã install có chứa script postinstall. Với một application thông thường, danh sách này ngắn hơn nhiều so với dự đoán của mọi người. Chính điều đó khiến allowlist trở nên thực tế.
Tách bước build khỏi tiến trình xử lý lưu lượng
User deploy cần quyền ghi vào node_modules. Tiến trình xử lý các HTTP request thì không cần quyền đó. Nếu dùng cùng một account, code chạy trong lúc cài đặt có thể sửa code đang phục vụ người dùng, và code chạy khi runtime cũng có thể sửa nó.
Hãy tách hai account này. Dùng một user để build, một user khác để serve, đồng thời đặt thư mục được serve ở chế độ read-only đối với account serving:
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/nodeappSau đó để systemd thực thi chính sách này. Tạo /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.targetProtectSystem=strict mount toàn bộ file system ở chế độ read-only đối với service này, ngoại trừ /dev, /proc, /sys và mọi đường dẫn bạn liệt kê trong ReadWritePaths. Vì vậy, khi application cố ghi vào node_modules, thao tác đó sẽ fail với EROFS: read-only file system. Bạn có thể kiểm tra và tái hiện lỗi này trong log của mình trong khoảng 1 phút. NoExecPaths xử lý thư mục upload có thể ghi: service được phép ghi file vào đó, nhưng kernel sẽ từ chối execute các file này. Tùy chọn này cần systemd 249 trở lên, và Ubuntu 24.04 đi kèm systemd 255.
Có 2 điểm dễ mắc lỗi trong unit file này. Thứ nhất, không thêm MemoryDenyWriteExecute=yes. Tùy chọn này xuất hiện trong hầu hết danh sách hardening systemd và khiến Node không khởi động, vì V8 compile JavaScript thành machine code ở runtime nên cần các page vừa writable vừa executable. Thứ hai, lấy path ExecStart từ command -v node. Nếu Node được cài bằng version manager, nó nằm trong home directory của deploy user. Khi đó ProtectHome=yes sẽ ẩn directory này khỏi service, và unit fail ngay lập tức với status=203/EXEC cùng một dòng log cho biết không tìm thấy executable.
Hãy kiểm tra kết quả thay vì chỉ tin vào file:
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probesystemd-analyze security liệt kê mọi hardening setting cùng mức độ exposure của chúng, để bạn biết setting nào vẫn ở mặc định. touch phải fail với Permission denied, vì nodeapp không sở hữu gì bên dưới current. Nếu lệnh này thành công, ownership của file đang sai và các setting systemd đang âm thầm che giấu lỗi đó.
Một điểm cần lưu ý về EnvironmentFile: systemd đọc file này dưới quyền root trước khi chuyển sang User=nodeapp, vì vậy file có thể được root:root với mode 600. Application vẫn nhận được các biến này. Bất kỳ ai có shell dưới quyền nodeapp vẫn có thể đọc chúng từ /proc/<pid>/environ. Do đó, cơ chế này bảo vệ secret khi lưu trữ, nhưng không bảo vệ secret trong tiến trình đang chạy.
Để credential deploy ngoài build environment
Script cài đặt kế thừa environment. Chỉ riêng điều đó đã quyết định nơi bạn nên build.
Cách an toàn nhất là build ở một nơi không phải production server, sau đó copy thư mục hoàn chỉnh sang server. Khi đó build machine chỉ giữ một read-only registry token và không giữ gì khác. Không có SSH deploy key, cloud access key, database password hoặc container registry login.
npm token create --read-onlyRead-only token có thể tải package nhưng không thể publish. Nếu token bị đánh cắp khỏi build environment, thiệt hại chỉ giới hạn ở khả năng tải các package public.
Nếu bắt buộc phải build trên server, hãy build bằng user deploy với environment được giới hạn chặt chẽ. Giữ các runtime secret trong /etc/nodeapp/env, nơi deploy không thể đọc. Cùng nguyên tắc này áp dụng cho build automation do bạn tự host: một self-hosted GitHub Actions runner giữ token và thực thi code tùy ý đã được publish trong mỗi job. Vì vậy, đây là machine có giá trị cao nhất trong một deployment nhỏ. Bất kỳ program nào bạn không tự viết nhưng được nhận toàn bộ environment của bạn đều thuộc cùng nhóm này. Vì thế, giữ secret ngoài environment của AI agent chính là vấn đề này với một program khác ở giữa.
Pin hoặc vendor những gì bạn không thể audit
Một dependency được pin là dependency chỉ có thể đổi version khi có một commit. Lockfile đã commit vốn đã làm điều đó cho toàn bộ dependency tree. Có 2 trường hợp cần xử lý thêm.
Dependency bắc cầu là trường hợp đầu tiên. Bạn không kiểm soát những dependency mà dependency của bạn sử dụng. overrides trong package.json sẽ buộc một version ở bất kỳ vị trí nào trong tree:
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}Chạy npm install một lần sau khi thêm cấu hình này để lockfile ghi lại kết quả, rồi commit cả 2 file.
Trường hợp thứ 2 là một package bạn không thể audit nhưng cũng không thể loại bỏ. Hãy vendor package đó. npm pack tải xuống đúng tarball mà registry sẽ cung cấp, còn một dependency file: sẽ cài đặt từ bản sao của bạn:
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"
}
}Tarball giờ nằm trong repository của bạn và không thể tự thay đổi. Bạn cũng phải tự chịu trách nhiệm cập nhật package này về sau. Vì vậy, chỉ dùng cách này cho package nhỏ đã bị bỏ rơi mà bạn buộc phải tiếp tục sử dụng, không dùng cho web framework.
Bạn cũng có thể áp dụng một khoảng thời gian chờ, không tốn chi phí:
npm install --before=2026-08-01Tùy chọn before sẽ dựng lại tree bằng cách chỉ sử dụng các version được publish vào hoặc trước ngày đó. Khi refresh dependency, hãy đặt mốc lùi lại 1 hoặc 2 tuần. Cách này giúp bỏ qua khoảng thời gian một bản release lỗi đã được phát hành nhưng chưa được báo cáo. Đây là công cụ khá thô, vì nó cũng trì hoãn các bản sửa bảo mật thực sự. Hãy dùng nó để resolve các range, đọc những thay đổi, rồi commit lockfile. Cùng cách suy nghĩ này cũng nên áp dụng cho các CLI tool bạn cài từ npm thay vì dùng làm dependency. Một lệnh gọi npx không pin sẽ tải bất cứ thứ gì được phát hành vào sáng hôm đó, còn pin một version dsh cụ thể mới bảo đảm 2 máy chạy cùng một code.
Làm thế nào biết chính xác phiên bản đã deploy?
Lockfile trong git cho biết những gì đáng lẽ phải được cài đặt. Disk cho biết những gì đang được cài đặt. Chỉ thông tin thứ hai là bằng chứng.
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls đọc node_modules, nên báo cáo những gì thực sự có trên máy thay vì những gì lockfile chỉ định. Dòng node -e đọc manifest đã cài đặt theo path. Cách này vẫn hoạt động với các package có trường exports chặn việc import subpath, đồng thời chỉ in ra một version mà không vẽ tree.
Để lấy nửa còn lại của phép so sánh, hãy đọc git:
git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'Ghi lại mối liên hệ giữa hai thông tin này bằng cách đưa commit vào layout deploy. Release vào /srv/nodeapp/releases/<short commit sha> rồi trỏ /srv/nodeapp/current vào đó bằng symlink. Khi đó, câu trả lời cho "hiện tại đang chạy gì" sẽ là readlink /srv/nodeapp/current. Thông tin này vẫn có sẵn lúc 03:00 cho người không trực tiếp deploy.
Cuối cùng, kiểm tra những gì registry có thể xác nhận:
npm audit signaturesLệnh này xác minh chữ ký registry của các package trong tree đã cài đặt, đồng thời xác minh provenance attestation của những package có thông tin đó. Provenance liên kết tarball đã publish với bản build continuous integration (CI) công khai đã tạo ra nó. Vì vậy, attestation đã được xác minh cho phép truy vết code về một commit thay vì một laptop không xác định. Coverage không áp dụng cho mọi package, nên hãy hiểu attestation bị thiếu là "không có thông tin", không phải "package có vấn đề".
Cần làm gì sau khi một bản release lỗi đã được triển khai lên server
Hãy bắt đầu từ những gì đã chạy và chạy với user nào.
Nếu code chạy trong lúc cài đặt, hãy coi như mọi thứ mà build user có thể đọc đều đã bị lộ. Hãy rotate registry token, các SSH key trong home directory đó, cloud credential và mọi secret đã được export trong shell đó. Rotation là phản ứng duy nhất trung thực, vì bạn không thể chứng minh một file chưa từng bị đọc.
Nếu code chạy lúc runtime dưới một service account bị giới hạn quyền, phạm vi có thể truy cập sẽ nhỏ hơn nhiều: các biến môi trường của chính ứng dụng và mọi thứ mà quyền network của ứng dụng có thể truy cập. Đó là lý do đầy đủ để chạy service bằng user không có đặc quyền trên VPS. Cách này không ngăn được việc bị compromise. Nó quyết định phần nào của máy bị compromise chiếm được và liệu việc đó có tồn tại sau khi restart hay không.
Sau đó hãy rebuild thay vì dọn dẹp. Xóa node_modules, pin affected package xuống dưới phiên bản lỗi trong package.json, chạy npm install một lần để cập nhật lockfile, commit thay đổi rồi deploy bằng npm ci. Không sửa một tree ngay tại chỗ. Bạn không thể liệt kê hết những gì install script đã tác động.
Đồng thời hãy ghi lại khoảng thời gian: deploy đầu tiên có thể đã kéo phiên bản đó về và deploy đã loại bỏ nó. Khoảng này cho biết cần đọc những log nào của chính bạn. Bạn chỉ xác định được khoảng này nếu release của bạn được đặt tên theo commit.
Những biện pháp này không giải quyết được gì
Lockfile không làm cho dependency trở nên an toàn. Nó biến thời điểm bạn chấp nhận dependency đó thành một quyết định có ngày tháng và được review, thay vì để dependency xuất hiện như một hệ quả ngoài ý muốn của deploy. Mỗi biện pháp ở trên đều thực hiện cùng một chuyển đổi: biến sự cố ngẫu nhiên thành lựa chọn có chủ đích.
npm audit không phải là biện pháp phòng vệ trong trường hợp này. Nó đối chiếu cây dependency của bạn với cơ sở dữ liệu các lỗ hổng đã được báo cáo, nên chỉ tìm được những vấn đề đã được công bố và đặt tên. Một cuộc tấn công supply chain không có tên trong toàn bộ khoảng thời gian nó còn hữu ích với kẻ tấn công. Hãy chạy npm audit để tìm các lỗi cũ đã biết, nhưng đừng kỳ vọng nó phát hiện được vấn đề trong một bản release vừa được phát hành cách đây 4 giờ.
Giảm số lượng dependency giúp ích nhiều hơn bất kỳ tool nào trong hướng dẫn này, nhưng đây cũng là lời khuyên ít được yêu thích nhất. Mỗi package bạn không thêm vào là thêm một publisher không thể bị phishing thay cho bạn, và thêm một install script không bao giờ chạy với quyền của deploy user.
Điều này cũng không chỉ đúng với npm. Cùng bốn dạng rủi ro đó áp dụng cho PyPI, RubyGems, container image và package manager của bản phân phối bạn đang dùng. Vấn đề xuất hiện rõ nhất với npm vì cây dependency thường sâu hơn và install script được chạy mặc định. Bất kỳ thứ gì mở rộng một tool bạn đã chạy đều kế thừa cùng vấn đề này. Vì vậy, xác định plugin dsh có thể truy cập những gì trước khi cài cũng là việc tương tự như đọc một script postinstall, trong đó quyền của agent thay cho quyền của deploy user. Phạm vi của máy xung quanh mà bạn phải bảo vệ phụ thuộc vào nơi agent chạy. Đây cũng là một phần của câu hỏi rộng hơn về việc hosting VPS có an toàn hay không.
FAQ
npm ci có bảo vệ tôi khỏi npm package bị cài mã độc không?
Nó bảo vệ bạn khỏi việc version thay đổi mà bạn không biết. npm ci cài chính xác những gì package-lock.json ghi lại, kiểm tra từng tarball bằng integrity hash sha512 của nó, rồi thoát với lỗi nếu package.json và lockfile không khớp, thay vì tự xử lý khác biệt. Nó không cho biết version đã pin có an toàn hay không. Nếu bạn commit một lockfile pin vào version độc hại, npm ci sẽ cài đúng version đó trên mọi server bạn sở hữu, trong mọi lần cài đặt.
Tôi có nên đặt ignore-scripts=true cho mọi thứ không?
Hãy bật nó, sau đó allowlist. ignore-scripts=true trong .npmrc của project ngăn các dependency install script chạy. Điều này loại bỏ đường trực tiếp nhất để package xấu truy cập credential của deploy user. Những package biên dịch native addon hoặc tải prebuilt binary thực sự cần script của chúng. Khi tắt script, chúng sẽ fail sau đó ở runtime vì thiếu binding file, thay vì fail ngay lúc cài đặt. Chạy npm ci --ignore-scripts, sau đó chạy npm rebuild <package> cho một vài package mà bạn quyết định tin cậy. npm query ":attr(scripts, [postinstall])" cho biết thực sự có bao nhiêu package như vậy.
Làm cách nào để biết server thực sự đã cài version nào của một package?
Hãy đọc dữ liệu trên disk, không đọc lockfile. npm ls <package> báo cáo những gì đang có trong node_modules, còn node -e "console.log(require('./node_modules/<package>/package.json').version)" chỉ in chuỗi version. Lockfile trong git trả lời một câu hỏi khác: đáng lẽ phải cài những gì. Việc so sánh hai kết quả mới là mục đích. Deploy vào một directory đặt tên theo git commit giúp giữ lại cả hai thông tin này nhiều tháng sau, khi bạn cần đối chiếu.
npm audit có phát hiện supply-chain attack không?
Không. npm audit đối chiếu tree của bạn với cơ sở dữ liệu về các lỗ hổng đã được báo cáo, nên nó chỉ phát hiện những vấn đề đã được công bố và cấp identifier. Một release độc hại có thể chưa được báo cáo trong vài giờ hoặc vài ngày, đúng lúc việc cài release đó có tác động. npm audit signatures là command hữu ích hơn: nó xác minh registry signature trên toàn bộ tree đã cài và kiểm tra provenance attestation khi publisher có cung cấp. Nhờ đó, bạn biết tarball đến từ một public build thay vì một machine không xác định.
Tại sao việc chạy app bằng unprivileged user lại quan trọng nếu cuộc tấn công xảy ra lúc cài đặt?
Vì hai lỗi này có phạm vi tác động khác nhau và bạn cần phòng chống cả hai. Code chạy lúc cài đặt chạy với quyền của deploy user, nên có thể đọc SSH key, registry token và cloud credential của user đó. Code chạy lúc runtime chạy bằng service account. Khi có User=nodeapp, ProtectSystem=strict và không có credential trên disk để đọc, phạm vi truy cập của nó chỉ dừng ở environment của chính application và database của application. Việc tách riêng các account cũng có nghĩa là process phục vụ traffic không thể ghi đè node_modules. Vì vậy, runtime compromise sẽ biến mất ở lần restart tiếp theo thay vì trở thành lỗi tồn tại vĩnh viễn.