SSD Nodes Learn Hosting plans →
الأدلة Matt Connorبقلم Matt Connor · آخر تحديث في 2026-08-27

كيف تصل هجمات npm إلى خادمك وتمنعها أثناء النشر

تعرّف إلى هجمات سلسلة توريد npm: إصدارات patch ضارة، وpostinstall، وحزم typosquat، ولماذا يوقف تثبيت lockfile مع npm ci الخطر أثناء النشر.

ما هو هجوم سلسلة توريد npm على خادمك

يصل هجوم سلسلة توريد npm إلى خادمك من خلال حزمة اخترت تثبيتها. لا يتطلب ذلك منفذاً مفتوحاً أو خطوة استغلال. يثبّت npm (مدير حزم Node) الشيفرة، وتؤدي عملية تثبيت الشيفرة إلى تشغيلها. لذلك يسحب تطبيق 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 ذلك النطاق عند لحظة تشغيله، ولذلك يمكن لعملية commit نفسها في git، إذا نُشرت مرتين في فترة بعد الظهر نفسها، أن تثبّت مجموعتين مختلفتين من الشيفرة. هذه الفجوة هي سطح الهجوم. ولا يلزم اختراق أي شيء على جهازك حتى تُفتح.

عادةً ما يُبلّغ عن الإصدارات الضارة وتُسحب، لكن السحب يحدث بعد أن يكون الأشخاص قد ثبّتوها. ومن نشر خلال هذه الفترة تكون الشيفرة موجودة على القرص لديه. يدخل خط أنابيب يحل النطاقات في كل تشغيل هذه الفترة تلقائياً عدة مرات في الأسبوع، من دون أن يقرر أي شخص ذلك صراحةً.

النمط 2: يعمل البرنامج النصي للتثبيت بامتيازات المستخدم الذي ينفّذ النشر

يمكن أن يعلن حقل package.json في حزمة ما عن preinstall وinstall وpostinstall وprepare ضمن كتلة scripts. يشغّلها npm أثناء التثبيت. لا تعمل هذه البرامج النصية داخل بيئة معزولة، ولا يراجعها أحد. إنها أوامر shell تعمل بامتيازات المستخدم الذي كتب أمر التثبيت، وفي الدليل المنزلي لذلك المستخدم، وبصلاحيات وصوله إلى الشبكة، ومع كامل بيئة shell الخاصة به.

لذلك، ليس السؤال المفيد هو ما الذي تستطيع الحزمة فعله، بل ما الذي يستطيع ذلك المستخدم قراءته. في خادم نشر عادي، يشمل ذلك ~/.npmrc الذي يحتوي على رمز registry، و~/.ssh/id_ed25519 المستخدم كمفتاح نشر لـSSH (Secure Shell)، و~/.aws/credentials، و~/.docker/config.json، وكل متغير مُصدَّر في shell، وهو المكان الذي يوجد فيه DATABASE_URL عادةً.

لا يحتاج payload من هذا النوع إلى آلية بقاء أو تصعيد امتيازات. إنه يقرأ بضعة ملفات، ويرسلها إلى مضيف عبر HTTPS، ثم يخرج بحالة 0. لن ترى شيئاً، لأن npm يخفي مخرجات install script افتراضياً. عطّل ذلك وراقب ما يُشغَّل فعلياً:

npm ci --foreground-scripts

يشارك foreground-scripts الإدخال والإخراج القياسيين وإخراج الأخطاء مع عملية npm، ولذلك تطبع build scripts مخرجاتها في الطرفية بدلاً من وضعها في مخزن مؤقت يتخلص منه npm عند نجاح التثبيت.

الشكل 3: الحزم التي تحاكي أسماء الحزم، والاسم الذي لم تكتبه بدقة

الحزمة التي تحاكي اسم حزمة أخرى هي حزمة منشورة باسم قريب من اسم حزمة شائعة، وتنتظر أمراً للتثبيت كُتب أو لُصق بصورة خاطئة. الآلية هنا هي الأمر، لا الشيفرة، لذلك لا يفيدك lockfile: تضيف الاسم الخطأ مرة واحدة، ومنذ ذلك الحين يثبّته lockfile بدقة.

النوع الذي يستهدف الفرق، لا الأفراد، هو التباس التبعيات. اسم حزمتك الداخلية هو billing-utils، وهي موجودة في registry خاص. إذا لم توجد حزمة باسم billing-utils في registry العام، فيمكن لأي شخص نشر واحدة بهذا الاسم. يحل npm الأسماء غير المسبوقة بنطاق مقابل registry العام الافتراضي، لذلك قد تتغلب النسخة العامة. الحل هو استخدام scope تملكه، مع تعيين registry لذلك الـscope في .npmrc:

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

الآن لا يجلب @yourorg/billing-utils إلا من ذلك المضيف، لأن تعيين الـscope إلى registry يُطبَّق قبل استخدام registry الافتراضي. أما الاسم الداخلي غير المسبوق بنطاق، فلا يملك أي تعيين، ولذلك لا يتمتع بهذه الحماية.

قبل إضافة أي تبعية جديدة، افحص الحزمة نفسها بدلاً من الاكتفاء بشارة عدد التنزيلات:

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

الحزمة التي أُنشئت الشهر الماضي ونشرها حساب لا يمكنك ربطه بمستودع عام تمثل خطراً مختلفاً عن حزمة لها سجل يمتد لست سنوات. لا تثبت أي من المعلومتين شيئاً بمفردها، لكن التحقق من كلتيهما لا يتطلب جهداً كبيراً.

الشكل 4: التبعية التي تغيّر مالكها من دون إشعار

يسلّم المشرفون حزم البرامج إلى بعضهم. قد يُصاب أحدهم بالإرهاق، أو يعرض شخص غير معروف المساعدة، أو تنتقل صلاحيات النشر، ولا يصل أي إشعار إلى المشاريع التي تعتمد على الحزمة. لا يوجد اختراق. لكن الثقة التي منحتها في 2021 أصبحت الآن بيد شخص آخر.

هذا أبطأ الأشكال وأصعبها اكتشافاً، ولا يجيب عنه أي أمر مباشرةً. هناك خطوتان لتضييق نطاقه. تحقّق من الأشخاص الذين يمكنهم النشر قبل اعتماد حزمة، باستخدام السطر npm view أعلاه. ثم اقرأ الفرق عندما تتغير حزمة تعتمد عليها فعلياً:

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

يسجل package-lock.json الإصدار الدقيق لكل حزمة في الشجرة، وعنوان URL الذي جاءت منه كل حزمة، وقيمة تجزئة سلامة sha512 لكل أرشيف tarball، والحزمة التي طلبتها. أضفه إلى المستودع. فهذا هو الملف الوحيد الذي يحدد ما اختبرته فعلياً.

ثبّت الحزم بعد ذلك باستخدام npm ci، وليس npm install، على أي جهاز ليس حاسوباً محمولاً للمطور:

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

يختلف npm ci عن npm install بطرق مهمة جميعها هنا. فهو يتطلب وجود ملف قفل. ويحذف أي node_modules موجود قبل أن يبدأ، لذلك لا يمكن لبقايا نشر سابق أن تنتقل إلى هذا النشر. ولا يكتب أبداً إلى package.json أو إلى ملف القفل، لذلك لا يمكن لعملية التثبيت أن تنقلك بصمت إلى إصدار أحدث. إذا اختلف ملف القفل عن package.json، تنتهي العملية بخطأ بدلاً من حل الاختلاف.

هذا الخطأ هو الغرض من الميزة، وليس مصدر إزعاج. فهو يعني أن تغيير التبعية يجب أن يصل في commit راجعه شخص ما، لا أن يحدث كأثر جانبي لعملية نشر عند الساعة 02:00.

تُفحص قيمة تجزئة السلامة عند كل عملية جلب. إذا لم تتطابق بايتات أرشيف tarball مع قيمة التجزئة المسجلة، تفشل عملية التثبيت مع code EINTEGRITY بدلاً من فك الأرشيف. كن دقيقاً بشأن ما يضمنه ذلك: فهو يثبت أن الملف الذي استلمته هو الملف الذي ثبّته ملف القفل، وهو الضمان نفسه الذي يوفره التحقق من التنزيلات باستخدام قيم التجزئة، وله الحدود نفسها. ولا يثبت أن الإصدار المثبت لم يكن ضاراً عند نشره.

هناك تفصيل يتعلق بـ--omit=dev: ما زال يجري حل هذه الحزم، وما زالت تُكتب في ملف القفل. لكنها لا توضع على القرص. يعني انخفاض عدد الحزم على القرص انخفاض عدد نصوص التثبيت وتقليل الشفرة المحمّلة أثناء التشغيل، لذلك يستحق هذا الإجراء التنفيذ. لكنه لا يزيل تبعية من شجرتك.

عامِل نصوص التثبيت كتعليمة برمجية، واعرف متى ترفض تشغيلها

يمكنك تعطيل نصوص التثبيت. أضف ما يلي إلى .npmrc في المشروع، ثم نفّذه بجانب ملف القفل:

ignore-scripts=true
save-exact=true

يمنع ignore-scripts=true npm من تشغيل النصوص المعلنة في التبعيات. ويجعل save-exact=true الأمر npm install some-lib يكتب 1.4.2 في package.json بدلاً من ^1.4.2، لذلك لا يدخل نطاق حلّ التبعية إلى ملف البيان عن طريق الخطأ.

قد يؤدي ذلك إلى تعطيل بعض المكونات، ويجب أن تعرف الطريقة اللازمة لمعالجة ذلك قبل تفعيله. تنفّذ الحزم التي تترجم إضافة أصلية أو تنزّل ملفاً تنفيذياً مُعدّاً مسبقاً هذه المهام في نص تثبيت. عند تعطيل النصوص، ينجح التثبيت نفسه، ثم يظهر الفشل لاحقاً وقت التشغيل على شكل وحدة يتعذر عليها تحميل ملف الربط الخاص بها. الحل هو قائمة سماح:

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 و/sys وأي مسارات تدرجها في ReadWritePaths. لذلك يفشل أيّ attempt من التطبيق للكتابة في node_modules مع EROFS: read-only file system، ويمكنك التحقق من ذلك وإعادة إنتاجه في سجلاتك خلال نحو دقيقة. يخصّص NoExecPaths دليل الرفع القابل للكتابة: يمكن للخدمة كتابة الملفات فيه، لكن النواة ترفض تنفيذها. يتطلب هذا الخيار systemd 249 أو إصداراً أحدث، وتأتي Ubuntu 24.04 مع الإصدار 255.

هناك نقطتان يجب الانتباه إليهما في ملف الوحدة هذا. أولاً، لا تضف MemoryDenyWriteExecute=yes. يظهر هذا الخيار في معظم قوائم تقوية systemd، لكنه يمنع Node من البدء، لأن V8 يترجم JavaScript إلى تعليمات آلة أثناء التشغيل ويحتاج إلى صفحات تكون قابلة للكتابة والتنفيذ معاً. ثانياً، خذ مسار ExecStart من command -v node. إذا ثُبّت 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 كل إعدادات التقوية مع مستوى تعرّضها، حتى ترى الإعدادات التي ما زالت بالقيم الافتراضية. يجب أن يفشل touch مع Permission denied، لأن nodeapp لا يملك أي شيء ضمن current. إذا نجح، فملكية ملفاتك غير صحيحة، وإعدادات systemd تغطي الخطأ بصمت.

توجد ملاحظة بشأن EnvironmentFile: يقرأه systemd بصفته root قبل أن يتنحى إلى User=nodeapp، لذلك يمكن أن يكون هذا الملف root:root مع الوضع 600. وما زال التطبيق يتلقى المتغيرات. يستطيع أي شخص يملك shell باسم nodeapp قراءتها من /proc/<pid>/environ، لذلك تحمي هذه الطريقة السر من التخزين، لا من العملية أثناء تشغيلها.

إبعاد بيانات اعتماد النشر عن بيئة البناء

ترث نصوص التثبيت متغيرات البيئة. يجب أن تحدد هذه الحقيقة وحدها المكان الذي تجري فيه عملية البناء.

أفضل حل هو إجراء البناء في مكان ليس خادم الإنتاج، ثم نسخ الدليل النهائي إليه. عندها لا يحتفظ جهاز البناء إلا برمز وصول للـregistry بصلاحية القراءة. لا يحتفظ بمفتاح نشر SSH، ولا بمفتاح وصول سحابي، ولا بكلمة مرور قاعدة بيانات، ولا ببيانات تسجيل الدخول إلى container registry.

npm token create --read-only

يمكن للرمز ذي صلاحية القراءة فقط جلب الحزم، ولا يمكنه نشرها. إذا سُرق من بيئة البناء، فإن الخسارة تقتصر على إمكانية تنزيل الحزم العامة.

إذا اضطررت إلى البناء على الخادم، فابنِ بصفتك المستخدم deploy ضمن بيئة محدودة عمداً، واحتفظ بأسرار وقت التشغيل في /etc/nodeapp/env، الذي لا يستطيع deploy قراءته. ينطبق المنطق نفسه على أتمتة البناء التي تستضيفها بنفسك: يحتفظ مشغّل GitHub Actions المستضاف ذاتياً بالرموز وينفّذ في كل مهمة تعليمات برمجية منشورة عشوائية، ما يجعله الجهاز الأعلى قيمة في عملية نشر صغيرة. ينتمي أي برنامج لم تكتبه أنت ويتلقى بيئتك كاملة إلى الفئة نفسها. لذلك فإن إبعاد الأسرار عن بيئة وكيل ذكاء اصطناعي هو المشكلة نفسها مع وجود برنامج مختلف في الوسط.

ثبّت أو أدرج في المستودع ما لا يمكنك تدقيقه

الاعتمادية المثبّتة هي التي لا يمكن أن يتغير إصدارها من دون commit. ويطبّق ملف القفل الملتزم به ذلك بالفعل على شجرة الاعتماديات بأكملها. لكن هناك حالتين تتطلبان إجراءً إضافياً.

الاعتماديات الانتقالية هي الحالة الأولى. لا تتحكم في ما تعتمد عليه اعتمادياتك. يفرض overrides في package.json إصداراً محدداً في أي موضع من الشجرة:

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

شغّل npm install مرة واحدة بعد إضافته حتى يسجّل ملف القفل النتيجة، ثم نفّذ commit للملفين معاً.

الحالة الثانية هي حزمة لا يمكنك تدقيقها ولا يمكنك حذفها. أدرجها في المستودع. ينزّل 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"
  }
}

أصبح ملف tarball الآن داخل مستودعك، ولا يمكن أن يتغير من دون علمك. لكنك أصبحت مسؤولاً عن تحديثاته إلى الأبد، لذلك استخدم هذا الخيار مع الحزمة الصغيرة المهجورة التي لا تستطيع الاستغناء عنها، وليس مع إطار عمل الويب.

توجد أيضاً فترة تهدئة لا تكلّف شيئاً:

npm install --before=2026-08-01

يعيد الخيار before بناء الشجرة باستخدام الإصدارات التي نُشرت في ذلك التاريخ أو قبله فقط. اضبطه على تاريخ يسبق أسبوعاً أو أسبوعين عند تحديث الاعتماديات، وبذلك تتجاوز الفترة التي يكون فيها إصدار ضار متاحاً ولم يُبلّغ عنه بعد. هذه أداة عامة وقاسية، لأنها تؤخر أيضاً إصلاحات الأمان الحقيقية. استخدمها لحل نطاقات الإصدارات، واقرأ التغييرات، ثم نفّذ commit لملف القفل. ومن المفيد تطبيق المبدأ نفسه على أدوات سطر الأوامر التي تثبّتها من npm بدلاً من اعتمادها، إذ إن استدعاء npx غير المثبّت يجلب أي إصدار نُشر في ذلك الصباح، بينما تثبيت إصدار محدد من dsh هو ما يجعل جهازين يشغّلان الشفرة نفسها.

كيف تعرف الإصدار الذي نشرته فعلياً؟

يُظهر 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 استيراد المسارات الفرعية، ويطبع إصداراً واحداً من دون رسم شجرة حوله.

لإجراء النصف الآخر من المقارنة، اقرأ git:

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

اجعل الصلة بين الاثنين دائمة بإدراج commit في بنية النشر. انشر الإصدار في /srv/nodeapp/releases/<short commit sha>، وأشر /srv/nodeapp/current إليه باستخدام symlink. تصبح إجابة السؤال «ما الذي يعمل الآن؟» هي readlink /srv/nodeapp/current، وتكون متاحة في الساعة 03:00 لشخص لم يكن هو من نفّذ النشر.

وأخيراً، تحقّق مما سيؤكده السجل:

npm audit signatures

يتحقق هذا من توقيعات السجل للحزم الموجودة في شجرتك المثبتة، ومن attestations الخاصة بمصدر الحزم التي تتوفر لها هذه البيانات. تربط معلومات المصدر tarball منشوراً ببنية continuous integration (CI) العامة التي أنشأته، ولذلك يعني التحقق من attestation أنه يمكنك تتبع الشيفرة إلى commit بدلاً من حاسوب محمول غير معروف. التغطية ليست شاملة، لذلك فسّر غياب attestation على أنه «لا توجد معلومات»، لا على أنه «حزمة سيئة».

ما الذي تفعله بعد وصول إصدار ضار إلى خادمك

ابدأ بما تم تشغيله، وتحت أي حساب مستخدم تم تشغيله.

إذا شُغِّلت الشفرة أثناء التثبيت، فافترض أن كل ما يستطيع مستخدم البناء قراءته قد كُشف. بدّل رمز registry، ومفاتيح SSH الموجودة في ذلك الدليل المنزلي، وبيانات اعتماد السحابة، وأي سر جرى تصديره في جلسة shell تلك. التبديل هو الاستجابة الصادقة الوحيدة، لأنك لا تستطيع إثبات أن ملفاً ما لم تتم قراءته.

إذا شُغِّلت الشفرة أثناء وقت التشغيل تحت حساب خدمة مقيّد، فستكون مجموعة الموارد التي يمكنها الوصول إليها أصغر بكثير: متغيرات البيئة الخاصة بالتطبيق، وأي مورد يمكنها الوصول إليه عبر الشبكة. وهذا هو السبب الكامل وراء تشغيل الخدمات كمستخدمين غير مميّزين على VPS. لا يمنع ذلك الاختراق. لكنه يحدد مقدار أجزاء الجهاز التي يمكن أن يصل إليها الاختراق، وما إذا كان سيبقى بعد إعادة التشغيل.

بعد ذلك، أعد البناء بدلاً من التنظيف. احذف node_modules، وثبّت الحزمة المتأثرة على إصدار أقدم من الإصدار الضار في package.json، وشغّل npm install مرة واحدة لتحديث lockfile، ثم أجرِ commit له، وانشر باستخدام npm ci. لا تُصلح شجرة في مكانها. لا يمكنك حصر ما لمسه سكربت التثبيت.

دوّن أيضاً الفترة الزمنية: أول عملية deploy كان يمكن أن تسحب الإصدار، وعملية deploy التي أزالته. تخبرك هذه الفترة بأي من سجلاتك يجب قراءته، ولا يمكن تحديدها إلا إذا كانت إصداراتك تحمل أسماء commits.

ما الذي لا تعالجه هذه الإجراءات

لا يجعل lockfile الاعتمادية آمنة. بل يحوّل لحظة قبولك لهذه الاعتمادية إلى قرار مؤرَّخ وخاضع للمراجعة، بدلاً من أن تكون نتيجة جانبية لعملية النشر. كل ممارسة مذكورة أعلاه تجري التحويل نفسه، من حادث عرضي إلى اختيار مقصود.

لا يُعد npm audit وسيلة دفاع هنا. فهو يقارن شجرة الاعتماديات لديك بقاعدة بيانات للثغرات المُبلّغ عنها، ولذلك يعثر على المشكلات التي نُشرت وسُمّيت مسبقاً. يظل هجوم سلسلة التوريد بلا اسم طوال الفترة التي يحقق فيها فائدة للمهاجم. شغّل npm audit للبحث عن الأخطاء القديمة المعروفة، ولا تتوقع منه أي معلومات عن إصدار صدر قبل أربع ساعات.

يساعد تقليل عدد الاعتماديات لديك أكثر من أي أداة في هذا الدليل، لكنه أقل نصيحة تحظى بالقبول. كل حزمة لا تضيفها تعني ناشراً إضافياً لا يمكن تصيّده نيابةً عنك، ونص تثبيت إضافياً لن يُشغَّل أبداً بصلاحيات مستخدم النشر لديك.

لا يختص أيٌّ من ذلك بـnpm أيضاً. تنطبق الأشكال الأربعة نفسها على PyPI وRubyGems وصور الحاويات ومدير الحزم في توزيعتك. يظهر ذلك بوضوح أكبر مع npm لأن أشجار الاعتماديات أعمق، ولأن نصوص التثبيت تُشغَّل افتراضياً. كل ما يوسّع أداة تستخدمها بالفعل يرث المشكلة نفسها. لذلك فإن تحديد ما يمكن لإضافة dsh الوصول إليه قبل تثبيتها هو التمرين نفسه الذي تجريه عند قراءة نص postinstall، مع استبدال صلاحيات مستخدم النشر لديك بصلاحيات وكيلك. يعتمد مقدار الجهاز المحيط الذي تقع مسؤولية حمايته عليك على موضع تشغيل الأداة. وهذا جزء من السؤال الأوسع حول مدى أمان استضافة VPS.

FAQ

هل تحميني npm ci من حزمة npm مخترقة؟

تحميك من تغيّر الإصدار دون علمك. يثبّت npm ci الإصدار المسجّل في package-lock.json فقط، ويتحقق من كل tarball باستخدام تجزئة السلامة sha512، ويتوقف مع ظهور خطأ إذا اختلف package.json عن lockfile بدلاً من محاولة حل الاختلاف. لكنها لا تحدد ما إذا كان الإصدار المثبّت آمناً. إذا أرسلت إلى المستودع lockfile يثبّت إصداراً ضاراً، فسيثبّت npm ci ذلك الإصدار بأمانة على كل خادم تملكه، في كل مرة.

هل ينبغي أن أضبط ignore-scripts=true لكل شيء؟

اضبطه، ثم أضف الاستثناءات إلى allowlist. يؤدي ignore-scripts=true في .npmrc الخاص بالمشروع إلى إيقاف تشغيل نصوص تثبيت التبعيات، وبذلك يزيل المسار المباشر بين الحزمة السيئة وبيانات اعتماد مستخدم النشر. تحتاج الحزم التي تترجم native addon أو تجلب 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 عن سؤال مختلف، وهو ما كان ينبغي تثبيته. وتكمن الفائدة في مقارنة الإجابتين. يضمن النشر في دليل يحمل اسم git commit بقاء الإجابتين متاحتين بعد أشهر، عندما تحتاج إليهما.

هل يكتشف npm audit هجمات سلسلة الإمداد؟

لا. يقارن npm audit شجرة التبعيات لديك بقاعدة بيانات للثغرات المُبلّغ عنها، ولذلك لا يكتشف إلا المشكلات التي نُشرت مسبقاً ومُنحت معرّفاً. قد يبقى الإصدار الضار دون إبلاغ خلال الساعات أو الأيام التي يكون تثبيته فيها مؤثراً. يُعد npm audit signatures الأمر الأكثر فائدة: فهو يتحقق من توقيعات السجل عبر شجرة التبعيات المثبّتة لديك، ويفحص إثباتات المصدر عندما ينشئها الناشر، وبذلك يوضح لك أن tarball جاء من عملية build عامة لا من جهاز مجهول.

لماذا يهم تشغيل التطبيق كمستخدم غير مميّز إذا وقع الهجوم أثناء التثبيت؟

لأن للفشلين نطاقين مختلفين، وعليك الحماية من كليهما. تعمل الشفرة أثناء التثبيت بصلاحيات مستخدم النشر، ويمكنها قراءة مفاتيح SSH الخاصة بذلك المستخدم، ورموز registry، وبيانات اعتماد السحابة. أما شفرة وقت التشغيل فتعمل بحساب الخدمة، ومع User=nodeapp وProtectSystem=strict وعدم وجود بيانات اعتماد على القرص، يقتصر نطاق وصولها على بيئة التطبيق وقاعدة بياناته. كما أن فصل الحسابات يعني أن العملية التي تخدم حركة الشبكة لا تستطيع إعادة كتابة node_modules، ولذلك يزول أثر الاختراق أثناء التشغيل عند إعادة التشغيل التالية بدلاً من أن يصبح دائماً.