SSD Nodes Learn 🎉 VPS از $5.50/ماه
راهنماها Matt Connorتوسط Matt Connor

جلوگیری از حملات زنجیره تأمین npm در سرور Node.js

حملات زنجیره تأمین npm از طریق اسکریپت‌های postinstall و بسته‌های مخرب به سرور شما نفوذ می‌کنند. با استفاده از npm ci و lockfile از اجرای کدهای ناخواسته جلوگیری کنید.

حمله زنجیره تأمین npm روی سرور شما چیست

حمله زنجیره تأمین npm از طریق بسته‌ای که برای نصب انتخاب کرده‌اید به سرور شما نفوذ می‌کند. در این فرآیند هیچ پورت بازی درگیر نیست و نیازی به مرحله اکسپلویت وجود ندارد. npm (مدیریت بسته node) کد نصب می‌کند و نصب کد به معنای اجرای آن است؛ بنابراین یک برنامه کوچک Node، صدها بسته را که هرگز نخوانده‌اید فراخوانی می‌کند و هر کدام از آن‌ها می‌توانند یک ساعت دیگر نسخه جدیدی منتشر کنند.

فرآیند deploy شما یک نسخه مخرب را دریافت می‌کند، زیرا دستور نصب شما درخواست جدیدترین نسخه منطبق را داشته است. آن کد سپس با دسترسی‌های کاربری که دستور نصب را اجرا کرده، اجرا می‌شود. تمام موارد زیر از همین دو جمله ناشی می‌شوند.

این الگوها بر اساس میزان تکرار وقوع برای شخصی که یک برنامه Node را روی یک VPS مستقر می‌کند، مرتب شده‌اند. این ترتیب با ترتیبی که یک شرکت بزرگ استفاده می‌کند متفاوت است، زیرا یک شرکت بزرگ دارای یک رجیستری داخلی، تیم بررسی و یک آینه (mirror) از رجیستری عمومی است. شما فقط یک اسکریپت deploy دارید.

شکل 1: یک حساب کاربری نگهدارنده (maintainer) مورد نفوذ قرار می‌گیرد و یک وصله منتشر می‌کند

رجیستری 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 این بازه را در لحظه اجرا حل (resolve) می‌کند؛ بنابراین یک commit یکسان در git، اگر دو بار در یک بعدازظهر مستقر (deploy) شود، می‌تواند دو مجموعه کد متفاوت را نصب کند. این شکاف، سطح حمله است. برای باز شدن این شکاف، نیازی نیست هیچ‌چیز روی سیستم شما مورد نفوذ قرار گرفته باشد.

انتشارهای مخرب معمولاً گزارش و حذف می‌شوند، اما حذف آن‌ها پس از آن صورت می‌گیرد که افراد آن‌ها را نصب کرده‌اند. هر کسی که در آن بازه زمانی استقرار انجام داده باشد، کد مخرب را روی دیسک خود دارد. خط لوله‌ای (pipeline) که در هر بار اجرا بازه‌ها را حل می‌کند، به‌طور خودکار و چندین بار در هفته، بدون اینکه کسی تصمیمی گرفته باشد، وارد آن بازه زمانی می‌شود.

شکل 2: اسکریپت نصب با دسترسی کاربری که عملیات استقرار را انجام می‌دهد اجرا می‌شود

یک package.json در یک بسته می‌تواند preinstall، install، postinstall و prepare را در بلوک scripts خود تعریف کند. npm این موارد را در حین نصب اجرا می‌کند. این اسکریپت‌ها در محیط ایزوله (sandbox) نیستند و هیچ‌کس آن‌ها را بازبینی نمی‌کند. این‌ها دستورات shell هستند که با دسترسی کاربری که دستور نصب را تایپ کرده، در دایرکتوری home همان کاربر، با دسترسی شبکه همان کاربر و با دسترسی به تمام متغیرهای محیطی (environment) آن shell اجرا می‌شوند.

بنابراین، پرسش مفید این نیست که آن بسته چه کاری می‌تواند انجام دهد؛ بلکه این است که آن کاربر به چه فایل‌هایی دسترسی خواندن دارد. در یک سرور استقرار (deploy box) معمولی، پاسخ شامل ~/.npmrc که حاوی توکن رجیستری است، ~/.ssh/id_ed25519 که به عنوان کلید استقرار برای SSH (secure shell) استفاده می‌شود، ~/.aws/credentials، ~/.docker/config.json و تمام متغیرهای export شده در shell است که معمولاً DATABASE_URL در آنجا قرار دارد.

یک payload مانند این، نیازی به ماندگاری (persistence) یا ارتقای سطح دسترسی (privilege escalation) ندارد. این اسکریپت چند فایل را می‌خواند، آن‌ها را از طریق HTTPS به یک میزبان ارسال می‌کند و با وضعیت 0 خارج می‌شود. شما چیزی نمی‌بینید، زیرا npm به‌طور پیش‌فرض خروجی اسکریپت‌های نصب را مخفی می‌کند. این قابلیت را غیرفعال کنید تا ببینید واقعاً چه چیزی اجرا می‌شود:

npm ci --foreground-scripts

foreground-scripts ورودی، خروجی و خطای استاندارد را با پردازش npm به اشتراک می‌گذارد، بنابراین اسکریپت‌های build به جای چاپ در بافری که npm پس از موفقیت نصب آن را دور می‌ریزد، مستقیماً در ترمینال شما چاپ می‌کنند.

شکل 3: تایپ‌اسکوآت (typosquat) و نامی که دقیقاً تایپ نکردید

تایپ‌اسکوآت (typosquat) بسته‌ای است که با نامی بسیار شبیه به یک بسته محبوب منتشر می‌شود و منتظر می‌ماند تا شما دستور نصب را اشتباه تایپ کنید یا اشتباه کپی کنید. مکانیزم این حمله، خودِ دستور است، نه کد؛ بنابراین lockfile در اینجا کمکی به شما نمی‌کند: شما یک بار نام اشتباه را اضافه می‌کنید و از آن پس، lockfile با وفاداری همان بسته اشتباه را برای شما پین (pin) می‌کند.

نوعی از این حمله که به‌جای افراد، تیم‌ها را هدف قرار می‌دهد، dependency confusion نام دارد. فرض کنید بسته داخلی شما billing-utils نام دارد و در یک رجیستری خصوصی میزبانی می‌شود. اگر بسته‌ای با نام billing-utils در رجیستری عمومی وجود نداشته باشد، هر کسی می‌تواند آن را منتشر کند. ابزار npm نام‌های بدون scope را در رجیستری عمومی پیش‌فرض جستجو می‌کند، بنابراین نسخه عمومی می‌تواند جایگزین نسخه داخلی شود. راه حل این مشکل، استفاده از یک scope اختصاصی و نگاشت (mapping) آن به رجیستری مربوطه در فایل .npmrc است:

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

اکنون @yourorg/billing-utils همیشه فقط از همان میزبان دریافت می‌شود، زیرا نگاشت scope به رجیستری، پیش از رجیستری پیش‌فرض بررسی می‌شود. یک نام داخلی بدون scope، هیچ نگاشتی ندارد و در نتیجه هیچ محافظتی برای آن وجود ندارد.

پیش از اضافه کردن هر dependency جدید، به‌جای نگاه کردن به نشان (badge) تعداد دانلود، خودِ بسته را بررسی کنید:

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

شکل اول فقط نام فایل‌های تغییریافته را چاپ می‌کند که برای انجام در هر بار ارتقای بسته‌ای که برایتان اهمیت دارد، به اندازه کافی سریع است. یک نسخه patch که اسکریپت build را تغییر می‌دهد، فایلی به ریشه بسته اضافه می‌کند یا بلوک scripts را ویرایش می‌کند، ارزش آن را دارد که پیش از رسیدن به سرور، به‌طور کامل خوانده شود.

ساخت پروژه از روی lockfile متعهد شده با استفاده از npm ci

فایل package-lock.json نسخه دقیق هر بسته در درخت وابستگی‌ها، URL منبع هر کدام، هش یکپارچگی sha512 برای هر tarball و اینکه کدام بسته به آن نیاز داشته است را ثبت می‌کند. این فایل را commit کنید. این تنها فایلی است که مشخص می‌کند دقیقاً چه چیزی را تست کرده‌اید.

سپس در هر ماشینی که لپ‌تاپ توسعه‌دهنده نیست، نصب را با npm ci انجام دهید و هرگز از npm install استفاده نکنید:

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

عملکرد npm ci با npm install در مواردی تفاوت دارد که همگی در اینجا اهمیت دارند. این دستور مستلزم وجود یک lockfile است. پیش از شروع، هر node_modules موجود را حذف می‌کند تا بقایای یک استقرار (deploy) قبلی به استقرار فعلی منتقل نشود. این دستور هرگز در package.json یا فایل lockfile تغییری ایجاد نمی‌کند، بنابراین نصب نمی‌تواند به‌طور خودکار شما را به نسخه جدیدتر منتقل کند. اگر lockfile و package.json با هم مغایرت داشته باشند، دستور به جای تلاش برای حل اختلاف، با خطا متوقف می‌شود.

آن خطا یک ویژگی است، نه یک مزاحمت. این یعنی تغییر در وابستگی‌ها باید به عنوان یک commit که توسط شخصی بازبینی شده است اعمال شود، نه به عنوان یک اثر جانبی از یک استقرار در ساعت 02:00 بامداد.

هش یکپارچگی در هر بار دریافت (fetch) بررسی می‌شود. اگر بایت‌های یک tarball با هش ثبت‌شده مطابقت نداشته باشد، نصب با خطای code EINTEGRITY متوقف می‌شود و فایل استخراج نمی‌شود. در مورد مزیت این کار دقیق باشید: این کار ثابت می‌کند فایلی که دریافت کرده‌اید همان فایلی است که در lockfile پین شده است؛ این همان تضمینی است که تأیید دانلودها با checksum به شما می‌دهد و محدودیت‌های مشابهی نیز دارد. این موضوع هیچ اطلاعاتی درباره اینکه آیا نسخه پین‌شده در زمان انتشار مخرب بوده است یا خیر، ارائه نمی‌دهد.

یک نکته درباره --omit=dev: این بسته‌ها همچنان resolve شده و در lockfile نوشته می‌شوند. فقط روی دیسک قرار نمی‌گیرند. بسته‌های کمتر روی دیسک به معنای اسکریپت‌های نصب کمتر و کد کمتر در زمان اجرا (runtime) است، بنابراین انجام آن ارزشمند است. این کار وابستگی را از درخت پروژه شما حذف نمی‌کند.

اسکریپت‌های نصب را به چشم کد ببینید و یاد بگیرید چطور آن‌ها را رد کنید

شما می‌توانید اسکریپت‌های نصب را غیرفعال کنید. این تنظیمات را در .npmrc پروژه قرار دهید و آن را در کنار lockfile کامیت کنید:

ignore-scripts=true
save-exact=true

ignore-scripts=true مانع از اجرای اسکریپت‌هایی می‌شود که در dependencies تعریف شده‌اند. save-exact=true باعث می‌شود npm install some-lib به جای ^1.4.2، مقدار 1.4.2 را در package.json بنویسد تا یک بازهٔ حل‌شونده (resolving range) به‌طور تصادفی وارد manifest شما نشود.

این کار باعث بروز اختلال در عملکرد می‌شود و پیش از فعال‌سازی، باید بدانید چگونه با آن برخورد کنید. بسته‌هایی که یک native addon را کامپایل می‌کنند یا یک binary از پیش ساخته‌شده را دانلود می‌کنند، این کار را در یک اسکریپت نصب انجام می‌دهند. با غیرفعال بودن اسکریپت‌ها، خودِ عملیات نصب با موفقیت انجام می‌شود اما خطا بعداً در زمان اجرا (runtime) ظاهر می‌شود؛ به این صورت که ماژول نمی‌تواند فایل binding خود را بارگذاری کند. راه حل، استفاده از یک لیست مجاز (allowlist) است:

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

npm rebuild <package> اسکریپت‌های build را فقط برای همان یک بسته اجرا می‌کند. اکنون شما به جای اعطای مجوز اجرای کلی به صدها غریبه که هرگز ملاقات نخواهید کرد، برای هر بسته به‌صورت جداگانه تصمیم گرفته‌اید.

برای اینکه ببینید این مجوز در حال حاضر چقدر گسترده است، از npm بپرسید:

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

این دستور تمام بسته‌های موجود در درخت نصب‌شده را که دارای اسکریپت postinstall هستند، چاپ می‌کند. در یک برنامهٔ معمولی، این لیست کوتاه‌تر از آن چیزی است که تصور می‌کنید، و دقیقاً همین موضوع باعث می‌شود استفاده از لیست مجاز عملی باشد.

جداسازی فرآیند ساخت از فرآیند سرویس‌دهی ترافیک

کاربر deploy نیاز به نوشتن در node_modules دارد، اما فرآیندی که به درخواست‌های HTTP پاسخ می‌دهد، خیر. اگر این دو یک حساب کاربری واحد باشند، کدی که در زمان نصب اجرا می‌شود می‌تواند کدی که به کاربران شما سرویس می‌دهد را بازنویسی کند؛ همچنین کدی که در زمان اجرا فعال است نیز می‌تواند همین کار را انجام دهد.

آن‌ها را از هم جدا کنید. ساخت برنامه را با یک کاربر و اجرای آن را با کاربر دیگری انجام دهید و دایرکتوری سرویس‌دهی را برای حساب کاربریِ اجراکننده، فقط‌خواندنی (read-only) کنید:

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، /sys و هر آنچه در ReadWritePaths لیست می‌کنید. بنابراین، تلاش برنامه برای نوشتن در node_modules با خطای EROFS: read-only file system مواجه می‌شود که می‌توانید در کمتر از یک دقیقه آن را در لاگ‌های خود بازتولید کنید. گزینه NoExecPaths دایرکتوری آپلودِ قابل‌نوشتن را پوشش می‌دهد: سرویس می‌تواند در آنجا فایل بنویسد، اما هسته سیستم‌عامل از اجرای آن‌ها جلوگیری می‌کند. این گزینه به systemd نسخه 249 یا جدیدتر نیاز دارد و Ubuntu 24.04 با نسخه 255 عرضه می‌شود.

دو تله در این فایل unit وجود دارد. اول، MemoryDenyWriteExecute=yes را اضافه نکنید. این گزینه در اکثر لیست‌های امن‌سازی (hardening) systemd دیده می‌شود، اما باعث می‌شود Node اجرا نشود؛ زیرا V8 در زمان اجرا، JavaScript را به کد ماشین کامپایل می‌کند و به صفحاتی نیاز دارد که هم قابل‌نوشتن و هم قابل‌اجرا باشند. دوم، مسیر ExecStart را از command -v node بردارید. اگر Node با یک مدیریت‌کننده نسخه (version manager) نصب شده باشد، در دایرکتوری home کاربر deploy قرار دارد؛ در این صورت ProtectHome=yes آن دایرکتوری را از دید سرویس مخفی می‌کند و unit بلافاصله با خطای 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 تمام تنظیمات امن‌سازی را به همراه وضعیت آن‌ها لیست می‌کند تا ببینید کدام‌یک هنوز در حالت پیش‌فرض هستند. دستور touch باید با خطای Permission denied مواجه شود، زیرا nodeapp مالک هیچ فایلی در current نیست. اگر دستور با موفقیت اجرا شد، مالکیت فایل‌های شما اشتباه است و تنظیمات systemd به‌طور بی‌صدا در حال پوشش دادن این نقص هستند.

یک نکته در مورد EnvironmentFile: systemd این فایل را پیش از تغییر سطح دسترسی به User=nodeapp، با دسترسی root می‌خواند؛ بنابراین فایل می‌تواند root:root با مجوز 600 باشد. برنامه همچنان متغیرها را دریافت می‌کند. هر کسی که دسترسی shell با کاربر nodeapp داشته باشد، همچنان می‌تواند آن‌ها را از /proc/<pid>/environ بخواند؛ بنابراین این روش از secretها در حالت ذخیره‌شده محافظت می‌کند، نه در فرآیند در حال اجرا.

اعتبارنامه‌های استقرار را از محیط build دور نگه دارید

اسکریپت‌های نصب، محیط (environment) را به ارث می‌برند. همین یک واقعیت باید تعیین‌کننده محل انجام build باشد.

قوی‌ترین روش این است که build را در جایی غیر از سرور تولید (production) انجام دهید و دایرکتوری نهایی را منتقل کنید. در این حالت، ماشین build فقط یک توکن registry با دسترسی خواندن‌فقط (read-only) دارد و هیچ چیز دیگری در آن نیست. نه کلید SSH برای استقرار، نه کلید دسترسی ابری، نه رمز عبور پایگاه داده و نه اطلاعات ورود به container registry.

npm token create --read-only

یک توکن با دسترسی خواندن‌فقط می‌تواند بسته‌ها را دریافت کند اما نمی‌تواند چیزی منتشر کند. اگر این توکن از محیط build سرقت شود، خسارت وارده تنها محدود به امکان دانلود بسته‌های عمومی است.

اگر مجبور هستید build را روی سرور انجام دهید، این کار را با کاربر deploy و در یک محیط به‌شدت محدودشده انجام دهید و اسرار زمان اجرا (runtime secrets) را در /etc/nodeapp/env نگهداری کنید، که deploy امکان خواندن آن را نداشته باشد. همین استدلال در مورد اتوماسیون build که خودتان میزبانی می‌کنید نیز صدق می‌کند: یک GitHub Actions runner خود-میزبان توکن‌ها را نگه می‌دارد و کدهای منتشرشدهٔ دلخواه را در هر job اجرا می‌کند، که این امر آن را به ارزشمندترین ماشین در یک استقرار کوچک تبدیل می‌کند. هر برنامه‌ای که خودتان ننوشته‌اید و کل محیط شما را دریافت می‌کند، در همین دسته قرار می‌گیرد؛ به همین دلیل است که دور نگه داشتن اسرار از محیط یک عامل هوش مصنوعی دقیقاً همین مشکل است، با این تفاوت که یک برنامه دیگر در میان قرار دارد.

پین کردن یا vendor کردن آنچه نمی‌توانید بازبینی کنید

وابستگی پین‌شده (pinned dependency) وابستگی‌ای است که نسخهٔ آن بدون ثبت یک commit تغییر نمی‌کند. فایل lockfile که commit شده است، همین کار را برای کل درخت وابستگی‌ها انجام می‌دهد. دو مورد وجود دارد که به اقدامات بیشتری نیاز دارند.

مورد اول، وابستگی‌های گذرا (transitive dependencies) هستند. شما کنترلی بر آنچه وابستگی‌هایتان به آن وابسته هستند ندارید. overrides در package.json یک نسخه را در هر جای درخت که باشد، اجبار می‌کند:

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

پس از افزودن آن، یک بار npm install را اجرا کنید تا نتیجه در lockfile ثبت شود، سپس هر دو فایل را commit کنید.

مورد دوم، بسته‌ای است که نمی‌توانید آن را بازبینی کنید و نمی‌توانید کنار بگذارید. آن را vendor کنید. npm pack دقیقاً همان tarballای را دانلود می‌کند که registry ارائه می‌دهد و یک وابستگی 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"
  }
}

این tarball اکنون در مخزن شما قرار دارد و نمی‌تواند بدون اطلاع شما تغییر کند. شما همچنین مسئولیت به‌روزرسانی‌های آن را برای همیشه بر عهده گرفته‌اید؛ بنابراین از این روش برای بسته‌های کوچک و رهاشده‌ای که مجبور به استفاده از آن‌ها هستید استفاده کنید، نه برای فریم‌ورک وب خود.

یک دورهٔ انتظار (cooling-off period) نیز وجود دارد که هزینه‌ای ندارد:

npm install --before=2026-08-01

گزینهٔ before درخت را فقط با استفاده از نسخه‌هایی که در آن تاریخ یا پیش از آن منتشر شده‌اند، بازسازی می‌کند. هنگام تازه‌سازی وابستگی‌ها، این تاریخ را یک یا دو هفته عقب‌تر تنظیم کنید تا از بازهٔ زمانی که یک نسخهٔ مخرب منتشر شده و هنوز گزارش نشده است، عبور کنید. این یک ابزار کلی است، زیرا جلوی اصلاحات امنیتی واقعی را نیز می‌گیرد. از آن برای حل محدودیت‌های نسخه (ranges) استفاده کنید، تغییرات را بخوانید و سپس lockfile را commit کنید.

چگونه بفهمم دقیقاً چه نسخه‌ای را منتشر کرده‌ام؟

فایل lockfile در git نشان می‌دهد که چه چیزی باید نصب می‌شد. دیسک نشان می‌دهد که چه چیزی نصب شده است. تنها مورد دوم سندیت دارد.

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

دستور npm ls فایل node_modules را می‌خواند، بنابراین آنچه را که به‌صورت فیزیکی موجود است گزارش می‌دهد، نه آنچه را که lockfile قصد داشته است. خط node -e مانیفست نصب‌شده را بر اساس مسیر می‌خواند؛ این کار حتی برای بسته‌هایی که فیلد exports آن‌ها واردات subpath را مسدود می‌کند نیز کارساز است و یک نسخه را بدون رسم ساختار درختی چاپ می‌کند.

برای بخش دیگر مقایسه، git را بخوانید:

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

با قرار دادن commit در طرح deploy، ارتباط بین این دو را دائمی کنید. نسخه را در /srv/nodeapp/releases/<short commit sha> منتشر کنید و با یک symlink، فایل /srv/nodeapp/current را به آن اشاره دهید. پاسخ به پرسش «در حال حاضر چه چیزی در حال اجراست» تبدیل به readlink /srv/nodeapp/current می‌شود و این اطلاعات در ساعت 03:00 برای کسی که عملیات deploy را انجام نداده است نیز در دسترس خواهد بود.

در نهایت، بررسی کنید که registry چه چیزی را تأیید می‌کند:

npm audit signatures

این دستور امضاهای registry را روی بسته‌های موجود در درخت نصب‌شدهٔ شما بررسی می‌کند و گواهی‌های اصالت (provenance attestations) را برای بسته‌هایی که دارای آن هستند، تأیید می‌نماید. Provenance یک tarball منتشرشده را به build سیستم continuous integration (CI) عمومی که آن را تولید کرده است، پیوند می‌دهد. بنابراین، یک گواهی تأییدشده به این معناست که می‌توانید کد را به یک commit مشخص ردیابی کنید، نه به یک لپ‌تاپ ناشناس. پوشش این قابلیت همگانی نیست، بنابراین نبود گواهی را به معنای «اطلاعاتی در دسترس نیست» تلقی کنید، نه به معنای «بسته مخرب».

پس از رسیدن یک نسخه مخرب به سرور چه باید کرد

از آنچه اجرا شده و با چه کاربری، به سمت بیرون حرکت کنید.

اگر کد در حین نصب اجرا شده است، فرض کنید هر فایلی که برای کاربر build قابل خواندن بوده، لو رفته است. توکن رجیستری، کلیدهای SSH در آن دایرکتوری home، اعتبارنامه‌های ابری و هر secret دیگری که در آن shell صادر (export) شده است را تغییر دهید (rotate). تغییر دادن تنها پاسخ صادقانه است، زیرا نمی‌توانید ثابت کنید که فایلی خوانده نشده است.

اگر کد در زمان اجرا (runtime) تحت یک حساب کاربری محدود (service account) اجرا شده باشد، دامنه دسترسی بسیار کوچک‌تر است: متغیرهای محیطی خود برنامه و هر آنچه که دسترسی شبکه آن می‌تواند به آن برسد. این تمام استدلال برای اجرای سرویس‌ها با کاربران بدون دسترسی ویژه روی یک VPS است. این کار از نفوذ جلوگیری نمی‌کند، بلکه تعیین می‌کند که نفوذ چقدر از ماشین را درگیر می‌کند و آیا پس از راه‌اندازی مجدد باقی می‌ماند یا خیر.

سپس به جای پاکسازی، سیستم را دوباره بسازید (rebuild). فایل node_modules را حذف کنید، بسته آسیب‌دیده را در نسخه پایین‌تر از نسخه مخرب در package.json پین کنید، دستور npm install را یک بار برای به‌روزرسانی lockfile اجرا کنید، آن را commit کرده و با npm ci مستقر (deploy) کنید. درخت فایل‌ها را در محل تعمیر نکنید. شما نمی‌توانید تمام مواردی که یک اسکریپت نصب تغییر داده است را فهرست کنید.

بازه زمانی را نیز یادداشت کنید: اولین استقراری که ممکن است نسخه مخرب را دریافت کرده باشد و استقراری که آن را حذف کرده است. این بازه به شما می‌گوید که کدام‌یک از لاگ‌های خود را بررسی کنید، و این تنها در صورتی قابل پاسخگویی است که نسخه‌های شما بر اساس commitها نام‌گذاری شده باشند.

مواردی که هیچ‌کدام از این روش‌ها حل نمی‌کنند

یک lockfile، وابستگی (dependency) را ایمن نمی‌کند. این فایل، لحظه‌ای را که شما آن وابستگی را پذیرفته‌اید، به یک تصمیم تاریخ‌دار و بازبینی‌شده تبدیل می‌کند، نه یک اثر جانبی از فرآیند deploy. هر روشی که در بالا ذکر شد، همین تبدیل را انجام می‌دهد: تبدیل اتفاق به انتخاب.

npm audit در اینجا یک دفاع محسوب نمی‌شود. این ابزار درخت وابستگی‌های شما را با پایگاه داده‌ای از آسیب‌پذیری‌های گزارش‌شده مقایسه می‌کند، بنابراین مشکلاتی را پیدا می‌کند که قبلاً منتشر و نام‌گذاری شده‌اند. یک حمله به زنجیره تأمین (supply-chain attack) در تمام طول عمر مفید خود، بدون نام باقی می‌ماند. برای باگ‌های قدیمی و شناخته‌شده از npm audit استفاده کنید، اما از آن برای نسخه‌ای که چهار ساعت پیش منتشر شده است، هیچ انتظاری نداشته باشید.

کاهش تعداد وابستگی‌ها بیش از هر ابزار دیگری در این راهنما به شما کمک می‌کند، و این کم‌طرفدارترین توصیه‌ای است که کسی ارائه می‌دهد. هر بسته‌ای که اضافه نمی‌کنید، یک ناشر کمتر است که می‌تواند به جای شما فیشینگ شود، و یک اسکریپت نصب کمتر است که هرگز با کاربر deploy شما اجرا نمی‌شود.

هیچ‌کدام از این موارد مختص npm نیستند. همین چهار الگو برای PyPI، RubyGems، ایمیج‌های کانتینر و مدیر بسته توزیع سیستم‌عامل شما نیز صدق می‌کنند. npm جایی است که این مسائل بیشتر نمایان می‌شوند، زیرا درخت‌های وابستگی در آن عمیق‌تر هستند و اسکریپت‌های نصب به‌طور پیش‌فرض اجرا می‌شوند. اینکه چه بخشی از ماشینِ پیرامونی متعلق به شماست تا از آن دفاع کنید، به محل اجرای آن بستگی دارد، که بخشی از پرسش گسترده‌تر درباره امنیت میزبانی VPS است.

FAQ

آیا npm ci من را در برابر یک پکیج npm آلوده محافظت می‌کند؟

این ابزار شما را در برابر تغییر نسخه بدون اطلاع شما محافظت می‌کند. npm ci دقیقاً همان چیزی را نصب می‌کند که package-lock.json ثبت کرده است، هر tarball را با هش یکپارچگی sha512 آن تطبیق می‌دهد و اگر package.json و lockfile با هم اختلاف داشته باشند، به‌جای تلاش برای حل اختلاف، با خطا متوقف می‌شود. این ابزار هیچ تضمینی در مورد امن بودن نسخه پین‌شده نمی‌دهد. اگر lockfileای را commit کنید که یک نسخه مخرب را پین کرده باشد، npm ci آن نسخه را با دقت در تمام سرورهای شما و در هر بار اجرا، نصب خواهد کرد.

آیا باید ignore-scripts=true را برای همه چیز تنظیم کنم؟

آن را تنظیم کنید و سپس لیست سفید (allowlist) بسازید. ignore-scripts=true در فایل .npmrc پروژه، از اجرای اسکریپت‌های نصب وابستگی‌ها جلوگیری می‌کند که مستقیم‌ترین مسیر دسترسی یک پکیج مخرب به اعتبارنامه‌های کاربر deploy شما را مسدود می‌سازد. پکیج‌هایی که یک افزونه native را کامپایل می‌کنند یا یک binary پیش‌ساخته را دانلود می‌کنند، واقعاً به اسکریپت‌های خود نیاز دارند؛ با غیرفعال کردن اسکریپت‌ها، این پکیج‌ها در زمان اجرا به دلیل نبود فایل binding با خطا مواجه می‌شوند، نه در زمان نصب. دستور 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)" فقط رشته نسخه را چاپ می‌کند. lockfile موجود در git به سؤال متفاوتی پاسخ می‌دهد، یعنی اینکه چه چیزی باید نصب می‌شد؛ هدف اصلی، مقایسه این دو است. استقرار (deploy) در دایرکتوری‌هایی که با نام commit گیت نام‌گذاری شده‌اند، باعث می‌شود هر دو پاسخ ماه‌ها بعد، زمانی که به آن‌ها نیاز دارید، در دسترس باقی بمانند.

آیا npm audit حملات زنجیره تأمین را شناسایی می‌کند؟

خیر. npm audit درخت وابستگی‌های شما را با پایگاه داده‌ای از آسیب‌پذیری‌های گزارش‌شده تطبیق می‌دهد، بنابراین فقط مشکلاتی را پیدا می‌کند که قبلاً منتشر شده و دارای شناسه هستند. یک نسخه مخرب در طول ساعاتی یا روزهایی که نصب آن اهمیت دارد، گزارش‌نشده باقی می‌ماند. npm audit signatures دستور مفیدتری است: این دستور امضاهای registry را در سراسر درخت نصب‌شده شما تأیید می‌کند و گواهی‌های اصالت (provenance attestations) را در مواردی که ناشر آن‌ها را تولید کرده باشد بررسی می‌کند؛ این کار به شما می‌گوید که یک tarball از یک build عمومی آمده است، نه از یک ماشین ناشناس.

چرا اگر حمله در زمان نصب رخ می‌دهد، اجرای برنامه با یک کاربر بدون دسترسی (unprivileged) اهمیت دارد؟

زیرا این دو شکست، دامنه اثر متفاوتی دارند و شما در حال دفاع در برابر هر دو هستید. کد زمان نصب با دسترسی کاربر deploy اجرا می‌شود و می‌تواند کلیدهای SSH، توکن‌های registry و اعتبارنامه‌های ابری آن کاربر را بخواند. کد زمان اجرا با دسترسی حساب کاربری سرویس اجرا می‌شود و با استفاده از User=nodeapp، ProtectSystem=strict و نبود اعتبارنامه‌های قابل خواندن روی دیسک، دامنه دسترسی آن به محیط خودِ برنامه و پایگاه داده‌اش محدود می‌شود. جداسازی حساب‌ها همچنین به این معنی است که فرآیند پاسخ‌دهنده به ترافیک نمی‌تواند node_modules را بازنویسی کند، بنابراین یک نفوذ در زمان اجرا با restart بعدی از بین می‌رود و دائمی نمی‌شود.