SSD Nodes Learn 🎉 VPS من $5.50/شهر
الأدلة Matt Connorبقلم Matt Connor

كيف تصل هجمات سلسلة توريد npm إلى خادمك؟

تعرّف كيف تصل هجمات npm إلى تطبيق Node على VPS عبر إصدارات تصحيح ضارة، وpostinstall، والحزم المنتحلة، ولماذا يوقف تثبيت الإصدارات الهجوم.

ما هو هجوم سلسلة توريد 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 الذي يحتوي على رمز وصول إلى السجل، و~/.ssh/id_ed25519 المستخدم كمفتاح نشر لـ SSH (secure shell)، و~/.aws/credentials، و~/.docker/config.json، وكل متغير مُصدَّر في shell، وهو المكان الذي يوجد فيه DATABASE_URL عادةً.

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

npm ci --foreground-scripts

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

النمط 3: أسماء مشابهة بسبب الأخطاء الإملائية، والاسم الذي لم تكتبه تماماً

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

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

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

الآن لا تُجلب @yourorg/billing-utils إلا من ذلك المضيف، لأن تعيين النطاق إلى 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=truenpm 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. لذلك يفشل أيّـة محاولة من التطبيق للكتابة في 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، لذلك يحمي هذا السر أثناء تخزينه، وليس العملية أثناء تشغيلها.

أبقِ بيانات اعتماد النشر خارج بيئة البناء

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

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

npm token create --read-only

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

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

تثبيت الإصدارات أو تضمين الحزم التي لا يمكنك تدقيقها

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

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

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

شغّل npm install مرة واحدة بعد إضافته حتى يسجّل lockfile النتيجة، ثم نفّذ 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 لـlockfile.

كيف أعرف أي إصدار أطلقت فعلياً؟

يحدد ملف القفل في git ما يفترض أن يكون قد ثُبّت. ويحدد القرص ما هو مثبّت فعلياً. الدليل هو الثاني فقط.

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

يقرأ npm ls القيمة node_modules، ولذلك يعرض ما هو موجود فعلياً بدلاً مما قصده ملف القفل. ويقرأ السطر 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>، وأشر إلى الإصدار عبر symlink في /srv/nodeapp/current. تصبح إجابة السؤال «ما الذي يعمل الآن؟» هي readlink /srv/nodeapp/current، وتبقى متاحة عند الساعة 03:00 لشخص لم يكن هو من نفّذ النشر.

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

npm audit signatures

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

ما الذي يجب فعله بعد وصول إصدار سيئ إلى خادمك

ابدأ بما نُفِّذ، وحدد المستخدم الذي نُفِّذ باسمه.

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

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

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

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

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

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

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

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

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

FAQ

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

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

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

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

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

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