Cách ngăn chặn tấn công chuỗi cung ứng npm trên VPS
Tìm hiểu cách các gói npm độc hại xâm nhập server qua postinstall scripts hoặc typosquats. Hướng dẫn quy trình deploy an toàn để chặn mã nguồn lạ thực thi trên hệ thống Node.js.
Tấn công chuỗi cung ứng npm trên server của bạn
Tấn công chuỗi cung ứng npm xâm nhập vào server của bạn thông qua một package mà bạn đã chọn cài đặt. Không có cổng nào bị mở và không có bước khai thác lỗ hổng nào ở đây. npm (node package manager) cài đặt mã nguồn, và việc cài đặt mã nguồn đồng nghĩa với việc thực thi mã nguồn đó. Do đó, 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 qua, và bất kỳ package nào trong số đó cũng có thể phát hành một phiên bản mới ngay trong một giờ tới.
Quá trình deploy của bạn tải về một phiên bản độc hại vì lệnh cài đặt của bạn yêu cầu phiên bản mới nhất phù hợp. Mã nguồn đó sau đó chạy với quyền hạn của người thực hiện lệnh cài đặt. Mọi hệ quả dưới đây đều bắt nguồn từ hai câu trên.
Các hình thức tấn công được sắp xếp theo tần suất ảnh hưởng đến một cá nhân deploy một ứng dụng Node lên một VPS. Thứ tự này không giống với thứ tự mà một công ty lớn sẽ gặp phải, vì công ty lớn có registry nội bộ, đội ngũ kiểm duyệt và một bản sao (mirror) của registry công cộng. Còn bạn, bạn chỉ có một script deploy.
Dạng 1: tài khoản bảo trì bị chiếm quyền và phát hành bản vá
Registry của npm không cho phép bất kỳ ai thay đổi nội dung của một phiên bản đã tồn tại. Do đó, kẻ tấn công lừa đảo (phishing) người bảo trì hoặc đánh cắp token phát hành không thể ghi đè 4.18.2. Chúng sẽ phát hành 4.18.3.
Hãy xem lại package.json của bạn. Một dòng như "express": "^4.18.2" không có nghĩa là phiên bản 4.18.2. Dấu mũ (caret) có nghĩa là "bất kỳ phiên bản 4.x nào bằng hoặc cao hơn phiên bản này", và ~4.18.2 có nghĩa là "bất kỳ phiên bản 4.18.x nào". npm install phân giải dải phiên bản đó tại thời điểm chạy, vì vậy cùng một git commit, nếu được deploy hai lần trong cùng một buổi chiều, có thể cài đặt hai bộ mã nguồn khác nhau. Khoảng trống đó chính là bề mặt tấn công. Không cần bất cứ thứ gì trên máy của bạn bị chiếm quyền thì lỗ hổng này mới mở ra.
Các bản phát hành độc hại thường được báo cáo và gỡ bỏ, nhưng việc gỡ bỏ chỉ diễn 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ó mã nguồn trên ổ đĩa. Một pipeline phân giải dải phiên bản trong mỗi lần chạy sẽ tự động rơi vào khoảng thời gian đó, vài lần mỗi tuần, mà không cần ai quyết định thực hiện.
Dạng 2: script cài đặt chạy dưới quyền người dùng thực hiện deploy
package.json của một package có thể khai báo preinstall, install, postinstall và prepare trong khối scripts. npm thực thi chúng trong quá trình cài đặt. Các script này không được sandbox và không ai kiểm duyệt chúng. Đây là các lệnh shell chạy dưới quyền người dùng đã gõ lệnh cài đặt, trong thư mục home của người dùng đó, với quyền truy cập mạng và toàn bộ biến môi trường của shell đó.
Vì vậy, câu hỏi thực tế không phải là package có thể làm gì, mà là người dùng đó có thể đọc được những gì. Trên một máy chủ deploy thông thường, câu trả lời bao gồm ~/.npmrc chứa token registry, ~/.ssh/id_ed25519 được dùng làm key deploy cho SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json, và mọi biến môi trường được export trong shell, nơi mà DATABASE_URL thường lưu trữ.
Một payload dạng này không cần duy trì trạng thái (persistence) hay leo thang đặc quyền. Nó chỉ cầ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 sẽ không thấy gì cả vì npm mặc định ẩn output của script cài đặt. Hãy tắt tùy chọn đó để theo dõi những gì thực sự đang chạy:
npm ci --foreground-scriptsforeground-scripts chia sẻ standard input, output và error với tiến trình npm, nhờ đó các script build sẽ in trực tiếp ra terminal của bạn thay vì vào một bộ đệm bị npm xóa bỏ khi cài đặt thành công.
Dạng 3: typosquat và cái tên bạn gõ không chính xác
Typosquat là một gói được xuất bản dưới một cái tên gần giống với một gói phổ biến, chờ đợi một lệnh cài đặt bị gõ sai hoặc dán nhầm. Cơ chế ở đây là lệnh thực thi, không phải mã nguồn, vì vậy lockfile không giúp ích gì cho bạn: bạn thêm nhầm tên một lần, và từ đó về sau lockfile sẽ ghim chính xác cái tên sai đó.
Biến thể nhắm vào các nhóm thay vì cá nhân là dependency confusion. Gói nội bộ của bạn có tên là billing-utils và nằm trên một registry riêng. Nếu không có gói nào tên là billing-utils tồn tại trên registry công cộng, bất kỳ ai cũng có thể xuất bản một gói như vậy. npm phân giải các tên không có scope dựa trên registry công cộng mặc định, vì vậy bản sao công cộng có thể thắng. Cách khắc phục là sử dụng một scope mà bạn sở hữu kèm theo ánh xạ registry cho scope đó, trong .npmrc:
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}Giờ đây @yourorg/billing-utils sẽ chỉ được lấy từ host đó, vì ánh xạ scope-đến-registry được kiểm tra trước registry mặc định. Một tên nội bộ không có scope sẽ không có ánh xạ, do đó nó không có sự bảo vệ nào.
Trước khi bạn thêm bất kỳ dependency mới nào, hãy xem xét nó thay vì chỉ nhìn vào huy hiệu lượt tải xuống:
npm view some-lib repository.url maintainers time.created time.modifiedMột gói được tạo vào tháng trước, xuất bản bởi một tài khoản mà bạn không thể liên kết với một repository công cộng, là một rủi ro khác biệt so với một gói có lịch sử sáu năm. Không dữ kiện nào trong số đó là bằng chứng xác thực. Cả hai đều rất dễ kiểm tra.
Dạng 4: dependency có chủ sở hữu thay đổi âm thầm
Các maintainer chuyển giao gói phần mềm. Một người kiệt sức, một người lạ đề nghị giúp đỡ, quyền xuất bản được chuyển giao, và không có bất kỳ thông báo nào gửi đến các dự án phụ thuộc vào nó. Không có gì bị xâm phạm. Sự tin tưởng bạn dành ra từ năm 2021 giờ đây thuộc về một người khác.
Đây là dạng chậm nhất và khó phát hiện nhất, không có lệnh nào trả lời trực tiếp vấn đề này. Hai việc giúp thu hẹp phạm vi. Hãy kiểm tra ai có quyền xuất bản trước khi bạn sử dụng một gói, bằng dòng npm view ở trên. Sau đó, hãy đọc diff khi một gói 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 tên file đã thay đổi, đủ nhanh để thực hiện mỗi khi nâng cấp một gói mà bạn quan tâm. Một bản patch release có tác động đến script build, thêm file vào root của gói, hoặc chỉnh sửa block scripts đều đáng để đọc toàn bộ trước khi nó được đưa lên server của bạn.
Build từ một lockfile đã commit với npm ci
package-lock.json ghi lại phiên bản chính xác của mọi package trong cây phụ thuộc, URL nguồn, mã hash integrity sha512 của mỗi tarball và package nào yêu cầu nó. Hãy commit file này. Đây là file duy nhất xác nhận những gì bạn đã thực sự kiểm thử.
Sau đó, hãy cài đặt bằng npm ci, tuyệt đối không dùng npm install, trên bất kỳ máy nào không phải là laptop của lập trình viên:
npm ci --omit=dev --ignore-scriptsnpm ci khác với npm install ở những điểm quan trọng trong ngữ cảnh này. Nó yêu cầu phải có sẵn lockfile. Nó xóa sạch node_modules hiện có trước khi bắt đầu, vì vậy các file thừa từ lần deploy trước không thể tồn tại trong lần này. Nó 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 nâng cấp phiên bản của bạn. Nếu lockfile và package.json không khớp nhau, nó sẽ thoát với lỗi thay vì tự động giải quyết sự khác biệt.
Lỗi đó là một tính năng, không phải sự phiền toái. Nó đảm bảo rằng mọi thay đổi về dependency phải đến từ một commit đã được ai đó review, chứ không phải là tác dụng phụ của một lần deploy lúc 02:00 sáng.
Mã hash integrity được kiểm tra trong mỗi lần fetch. Một tarball có dữ liệu byte không khớp với mã hash đã ghi sẽ khiến quá trình cài đặt thất bại với code EINTEGRITY thay vì giải nén. Hãy hiểu rõ lợi ích của việc này: nó chứng minh file bạn nhận được chính là file mà lockfile đã ghim, đây cũng là sự đảm bảo tương tự như xác thực file tải về bằng checksum mang lại, và nó cũng có những giới hạn tương tự. Nó không đảm bảo rằng phiên bản được ghim không chứa mã độc ngay từ khi được publish.
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 ổ đĩa. Ít package trên ổ đĩa hơn đồng nghĩa với việc ít script cài đặt hơn và ít code được load khi runtime hơn, vì vậy việc này rất đáng làm. Nó không loại bỏ dependency khỏi cây phụ thuộc của bạn.
Coi các script cài đặt là mã nguồn và biết cách từ chối chúng
Bạn có thể tắt các script cài đặt. Hãy thêm nội dung này vào .npmrc của dự án và commit nó cùng với file lock:
ignore-scripts=true
save-exact=trueignore-scripts=true ngăn npm chạy các script được khai báo trong dependencies. 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 dải phiên bản (resolving range) sẽ không vô tình lọt vào manifest của bạn.
Việc này có thể gây lỗi, và bạn cần hiểu rõ cơ chế trước khi kích hoạt. Các gói cần biên dịch native addon hoặc tải xuống binary dựng sẵn thường thực hiện công việc đó trong script cài đặt. Khi tắt script, quá trình cài đặt vẫn thành công nhưng lỗi sẽ xuất hiện sau đó, tại thời điểm runtime, dưới dạng một module không thể load file binding của nó. Giải pháp là sử dụng danh sách cho phép (allowlist):
npm ci --ignore-scripts
npm rebuild better-sqlite3npm rebuild <package> sẽ chạy các script build cho riêng gói đó. Giờ đây bạn đã đưa ra quyết định cho từng gói, thay vì cấp quyền thực thi mặc định cho hàng trăm người lạ mà bạn không bao giờ gặp mặt.
Để xem phạm vi quyền hạn hiện tại lớn đến mức nào, hãy hỏi npm:
npm query ":attr(scripts, [postinstall])"Lệnh này sẽ in ra mọi gói trong cây cài đặt có chứa script postinstall. Trên một ứng dụng thông thường, danh sách này ngắn hơn mọi người nghĩ, đó chính là lý do khiến việc sử dụng danh sách cho phép trở nên khả thi.
Tách biệt quá trình build và quá trình phục vụ lưu lượng
User deploy cần quyền ghi vào node_modules. Quá trình phản hồi các request HTTP thì không. Nếu chúng dùng chung một tài khoản, code chạy trong lúc cài đặt có thể ghi đè lên code đang phục vụ người dùng, và code chạy lúc runtime cũng có thể làm điều tương tự.
Hãy tách biệt chúng. Build bằng một user, chạy service bằng một user khác, và đặt thư mục chứa code ở chế độ chỉ đọc (read-only) đối với tài khoản chạy service:
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 đó dùng systemd để thực thi việc này. Viết /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ộ hệ thống file ở chế độ chỉ đọc cho service này, ngoại trừ /dev, /proc, /sys và bất kỳ thư mục nào bạn liệt kê trong ReadWritePaths. Do đó, mọi nỗ lực ghi vào node_modules của ứng dụng sẽ thất bại với lỗi EROFS: read-only file system, bạn có thể kiểm tra lại trong log của mình chỉ sau một phút. NoExecPaths bao phủ thư mục upload có quyền ghi: service có thể ghi file vào đó nhưng kernel sẽ từ chối thực thi chúng. Tùy chọn này yêu cầu systemd 249 trở lên, và Ubuntu 24.04 đã có sẵn bản 255.
Có hai cái bẫy trong file unit này. Thứ nhất, đừng thêm MemoryDenyWriteExecute=yes. Nó xuất hiện trong hầu hết các danh sách hardening cho systemd, nhưng nó sẽ khiến Node không thể khởi động, vì V8 biên dịch JavaScript thành mã máy tại runtime và cần các trang bộ nhớ vừa có quyền ghi vừa có quyền thực thi. Thứ hai, lấy đường dẫn ExecStart từ command -v node. Nếu Node được cài đặt bằng một trình quản lý phiên bản, nó sẽ nằm trong thư mục home của user deploy, ProtectHome=yes sau đó sẽ ẩn thư mục đó khỏi service, và unit sẽ thất bại ngay lập tức với lỗi status=203/EXEC cùng một dòng log báo không tìm thấy file thực thi.
Kiểm tra kết quả thay vì chỉ tin vào file cấu hình:
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 thiết lập hardening cùng mức độ phơi nhiễm, giúp bạn thấy những thiết lập nào vẫn đang ở giá trị mặc định. Lệnh touch sẽ thất bại với lỗi Permission denied, vì nodeapp không sở hữu bất cứ thứ gì trong current. Nếu nó thành công, quyền sở hữu file của bạn đang sai và các thiết lập systemd chỉ đang che đậy lỗi đó một cách âm thầm.
Một lưu ý về EnvironmentFile: systemd đọc file này dưới quyền root trước khi chuyển sang user User=nodeapp, vì vậy file đó có thể được đặt quyền root:root với mode 600. Ứng dụng vẫn nhận được các biến môi trường. Bất kỳ ai có shell là nodeapp vẫn có thể đọc chúng từ /proc/<pid>/environ, vì vậy cách này chỉ bảo vệ secret khi lưu trữ chứ không bảo vệ được tiến trình đang chạy.
Không để credential triển khai trong môi trường build
Script cài đặt kế thừa môi trường. Thực tế này quyết định nơi bạn thực hiện build.
Cách an toàn nhất là build ở một nơi không phải là production server, sau đó copy thư mục đã hoàn thiện sang. Khi đó, máy build chỉ giữ một token registry ở chế độ read-only và không có gì khác. Không SSH deploy key, không cloud access key, không mật khẩu database, không thông tin đăng nhập container registry.
npm token create --read-onlyMột token read-only chỉ có thể tải gói về chứ không thể publish. Nếu nó bị đánh cắp từ môi trường build, thiệt hại chỉ là khả năng tải các gói public.
Nếu bắt buộc phải build trên server, hãy build với user deploy cùng một môi trường được giới hạn chặt chẽ, và lưu các secret runtime trong /etc/nodeapp/env, nơi mà deploy không thể đọc được. Lập luận tương tự áp dụng cho các hệ thống tự động hóa build mà bạn tự host: một GitHub Actions runner tự host nắm giữ các token và thực thi code tùy ý được publish trong mỗi job, khiến nó trở thành máy có giá trị cao nhất trong một hệ thống triển khai nhỏ. Bất kỳ chương trình nào bạn không viết mà lại nhận được toàn bộ môi trường của bạn đều thuộc cùng loại này, đó là lý do tại sao việc không để secret trong môi trường của AI agent cũng chính là vấn đề này nhưng với một chương trình khác ở giữa.
Ghim hoặc vendor những gì bạn không thể kiểm tra
Một dependency được ghim là dependency có phiên bản không thể thay đổi nếu không có commit. File lockfile đã được commit thực hiện việc này cho toàn bộ cây dependency. Có hai trường hợp cần xử lý thêm.
Trường hợp đầu tiên là các transitive dependency. Bạn không kiểm soát được những gì mà dependency của bạn phụ thuộc vào. overrides trong package.json ép buộc một phiên bản cụ thể ở bất kỳ đâu trong cây:
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}Chạy npm install một lần sau khi thêm nó để lockfile ghi lại kết quả, sau đó commit cả hai file.
Trường hợp thứ hai là một package mà bạn không thể kiểm tra và không thể loại bỏ. Hãy vendor nó. npm pack tải xuống chính xác file tarball mà registry sẽ cung cấp, và 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"
}
}File tarball hiện nằm trong repository của bạn và không thể thay đổi ngoài ý muốn. Bạn cũng đã nhận trách nhiệm cập nhật nó mãi mãi, vì vậy hãy chỉ dùng cách này cho các package nhỏ đã bị bỏ rơi mà bạn buộc phải dùng, không dùng cho web framework của bạn.
Ngoài ra còn có một khoảng thời gian chờ (cooling-off period), không tốn chi phí gì cả:
npm install --before=2026-08-01Tùy chọn before xây dựng lại cây dependency chỉ sử dụng các phiên bản được xuất bản vào hoặc trước ngày đó. Hãy đặt lùi lại một hoặc hai tuần khi bạn làm mới các dependency, bạn sẽ tránh được khoảng thời gian mà một bản phát hành lỗi đang tồn tại nhưng chưa được báo cáo. Đây là một công cụ thô sơ, vì nó cũng ngăn chặn cả các bản vá bảo mật thực sự. Hãy sử dụng nó để giải quyết các dải phiên bản, đọc xem những gì đã thay đổi, sau đó commit lockfile.
Làm sao để biết tôi thực sự đã release phiên bản nào?
Lockfile trong git cho biết những gì lẽ ra phải được cài đặt. Ổ cứng cho biết những gì thực sự đang có. Chỉ có cái thứ hai mới là bằng chứng xác thực.
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls đọc node_modules, vì vậy nó báo cáo những gì hiện diện vật lý thay vì những gì lockfile dự định. Dòng node -e đọc manifest đã cài đặt theo đường dẫn, cách này hoạt động ngay cả với các gói mà trường exports chặn việc import subpath, và in ra một phiên bản duy nhất mà không kèm theo sơ đồ cây.
Đối với nửa còn lại của quá trình 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"'Hãy tạo kết nối vĩnh viễn giữa hai thành phần bằng cách đưa commit vào layout deploy. Release vào /srv/nodeapp/releases/<short commit sha> và trỏ /srv/nodeapp/current vào đó bằng một symlink. Câu trả lời cho câu hỏi "cái gì đang chạy ngay lúc này" trở thành readlink /srv/nodeapp/current, và thông tin này vẫn khả dụng vào lúc 03:00 cho bất kỳ ai, kể cả người không phải là người đã thực hiện deploy.
Cuối cùng, hãy kiểm tra xem registry xác nhận điều gì:
npm audit signaturesLệnh này xác minh chữ ký registry trên các gói trong cây đã cài đặt của bạn, và xác minh các chứng thực nguồn gốc (provenance attestations) cho các gói có hỗ trợ. Provenance liên kết một tarball đã xuất bản với bản build CI (continuous integration) công khai đã tạo ra nó, vì vậy một chứng thực đã xác minh có nghĩa là bạn có thể truy xuất mã nguồn ngược lại một commit thay vì một máy tính cá nhân không xác định. Độ bao phủ không phải là toàn diện, vì vậy hãy hiểu việc thiếu chứng thực là "không có thông tin", chứ không phải là "gói xấu".
Phải làm gì sau khi một bản release lỗi xuất hiện trên server của bạn
Hãy bắt đầu từ những gì đã chạy và chạy dưới quyền user nào.
Nếu code đã chạy trong quá trình cài đặt, hãy mặc định rằng mọi thứ mà user build có quyền đọc đều đã bị lộ. Hãy xoay vòng (rotate) token registry, các SSH key trong thư mục home đó, các credential cloud và bất kỳ secret nào đã được export trong shell đó. Xoay vòng là phản ứng trung thực duy nhất, vì bạn không thể chứng minh được một file nào đó đã không bị đọc.
Nếu code chạy ở runtime dưới một service account đã được giới hạn quyền, phạm vi ảnh hưởng sẽ nhỏ hơn nhiều: chỉ bao gồm các biến môi trường của ứng dụng và bất cứ thứ gì mà quyền truy cập mạng của nó có thể chạm tới. Đó là toàn bộ lý do cho việc chạy các service dưới quyền user không có đặc quyền trên VPS. Nó không ngăn chặn được việc bị chiếm quyền. Nó quyết định mức độ ảnh hưởng của việc bị chiếm quyền lên máy chủ, và liệu nó có tồn tại sau khi khởi động lại hay không.
Sau đó, hãy rebuild thay vì dọn dẹp. Xóa node_modules, ghim package bị ảnh hưởng 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 nó, và deploy bằng npm ci. Đừng sửa chữa cây thư mục tại chỗ. Bạn không thể liệt kê hết những gì một script cài đặt đã tác động vào.
Hãy ghi lại khoảng thời gian xảy ra sự cố: lần deploy đầu tiên có khả năng đã kéo phiên bản đó về, và lần deploy đã loại bỏ nó. Khoảng thời gian đó cho bạn biết cần đọc log nào của chính mình, và nó chỉ có thể xác định được nếu các bản release của bạn được đặt tên theo các commit.
Những vấn đề mà các cách trên không giải quyết được
Lockfile không làm cho một 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 xem xét và ghi lại, thay vì là một tác dụng phụ của quá trình deploy. Mọi phương pháp nêu trên đều thực hiện cùng một sự chuyển đổi này: từ sự ngẫu nhiên thành sự lựa chọn.
npm audit không phải là một cơ chế phòng thủ ở đây. Nó so sánh cây dependency của bạn với một cơ sở dữ liệu về các lỗ hổng đã được báo cáo, vì vậy nó chỉ tìm ra những vấn đề đã được công bố và định danh. Một cuộc tấn công chuỗi cung ứng (supply-chain attack) sẽ không có tên trong suốt thời gian nó còn hiệu quả. Hãy chạy npm audit để tìm các lỗi cũ đã biết và đừng kỳ vọng nó phát hiện được gì về một bản release vừa được phát hành cách đây bốn tiếng.
Việc giảm số lượng dependency giúp ích nhiều hơn bất kỳ công cụ nào trong hướng dẫn này, và đây cũng là lời khuyên ít được ưa chuộng nhất. Mỗi package bạn không thêm vào là một nhà phát hành không thể bị lừa đảo (phished) thay cho bạn, và là một tập lệnh cài đặt không bao giờ chạy dưới quyền user deploy của bạn.
Không có điều nào trong số này chỉ dành riêng cho npm. Bốn hình thái tương tự cũng áp dụng cho PyPI, RubyGems, container image và trình quản lý package của bản phân phối bạn đang dùng. npm là nơi vấn đề này lộ rõ nhất vì cây dependency ở đó sâu nhất và các tập lệnh cài đặt mặc định được thực thi. Việc bạn có thể bảo vệ bao nhiêu phần của hệ thống xung quanh phụ thuộc vào nơi nó chạy, đây là một phần của câu hỏi rộng hơn về việc liệu VPS hosting có an toàn hay không.
FAQ
npm ci có bảo vệ tôi khỏi các gói npm bị chiếm quyền điều khiển không?
Nó bảo vệ bạn khỏi việc phiên bản bị thay đổi mà bạn không biết. npm ci cài đặt chính xác những gì package-lock.json ghi lại, kiểm tra từng tarball dựa trên mã hash sha512 integrity, và thoát với lỗi nếu package.json và lockfile không khớp thay vì tự động giải quyết sự khác biệt. Nó không đảm bảo phiên bản đã ghim là an toàn. Nếu bạn commit một lockfile ghim một phiên bản độc hại, npm ci sẽ cài đặt chính xác phiên bản đó trên mọi máy chủ của bạn, mỗi lần chạy.
Tôi có nên đặt ignore-scripts=true cho mọi thứ không?
Hãy đặt nó, sau đó dùng danh sách cho phép (allowlist). ignore-scripts=true trong .npmrc của dự án ngăn các script cài đặt dependency chạy, loại bỏ con đường trực tiếp nhất từ một gói độc hại đến thông tin xác thực của user deploy. Các gói cần biên dịch native addon hoặc tải binary dựng sẵn thực sự cần script của chúng, và khi tắt script, chúng sẽ lỗi ở runtime với thông báo thiếu file binding thay vì lỗi ở bước cài đặt. Hãy chạy npm ci --ignore-scripts, sau đó npm rebuild <package> cho một vài gói bạn quyết định tin tưởng. npm query ":attr(scripts, [postinstall])" sẽ cho thấy thực sự có bao nhiêu gói như vậy.
Làm thế nào để biết phiên bản gói nào máy chủ của tôi thực sự đã cài đặt?
Hãy đọc trực tiếp trên đĩa, đừng đọc lockfile. npm ls <package> báo cáo những gì đang có trong node_modules, và node -e "console.log(require('./node_modules/<package>/package.json').version)" chỉ in ra chuỗi phiên bản. Lockfile trong git trả lời một câu hỏi khác, đó là những gì đáng lẽ phải được cài đặt, và việc so sánh hai cái này chính là mục đích. Deploy vào một thư mục đặt tên theo git commit giúp bạn giữ được cả hai câu trả lời sau nhiều tháng, khi bạn cần đến chúng.
npm audit có tìm ra các cuộc tấn công chuỗi cung ứng không?
Không. npm audit so sánh cây dependency của bạn với cơ sở dữ liệu các lỗ hổng đã được báo cáo, vì vậy nó chỉ tìm thấy các vấn đề đã được công bố và cấp định danh. Một bản phát hành độc hại sẽ không được báo cáo trong những giờ hoặc ngày mà việc cài đặt nó gây ra hậu quả. npm audit signatures là lệnh hữu ích hơn: nó xác minh chữ ký registry trên toàn bộ cây đã cài đặt và kiểm tra các chứng thực nguồn gốc (provenance attestations) nơi nhà phát hành cung cấp, cho bạn biết tarball đó đến từ một bản build công khai thay vì từ một máy lạ.
Tại sao việc chạy ứng dụng bằng user không có đặc quyền 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 ảnh hưởng khác nhau và bạn đang phòng thủ trước cả hai. Mã chạy lúc cài đặt thực thi dưới quyền user deploy và có thể đọc SSH keys, registry tokens và thông tin xác thực cloud của user đó. Mã chạy lúc runtime thực thi dưới quyền service account, và với User=nodeapp, ProtectSystem=strict cùng việc không có thông tin xác thực nào trên đĩa mà nó có thể đọc, phạm vi ảnh hưởng của nó chỉ dừng lại ở môi trường của ứng dụng và cơ sở dữ liệu của nó. Việc tách biệt các tài khoản cũng có nghĩa là tiến trình phục vụ lưu lượng không thể ghi đè node_modules, vì vậy một vụ chiếm quyền lúc runtime sẽ biến mất sau lần khởi động lại tiếp theo thay vì trở thành vĩnh viễn.