هجمات npm الجديدة تعمل عند الـ import لا عند التثبيت

في 14 يوليو 2026 أُعيد نشر خمس packages من منظومة AsyncAPI وبداخلها كود خبيث. وقبلها بثلاثة أيام مرّت package باسم jscrambler بشيء مشابه. الحملتان لم تحتاجا إلى postinstall hook إطلاقًا، وكلتاهما اشتغلت في اللحظة التي تم فيها الـ import.

هذي التفصيلة هي القصة كاملة، ولأنها كذلك فهي تُسقط إجراءً وقائيًا تعتمد عليه اليوم كثير من الـ pipelines وكأنه كافٍ.

الافتراض الذي سقط للتو

لسنوات كان تصوّرنا لمخاطر npm بسيطًا ومريحًا: الـ package الخبيثة تُنفّذ حمولتها أثناء التثبيت عبر lifecycle hooks مثل preinstall وpostinstall. وبُنيت الدفاعات على هذا الشكل بالضبط: أدوات الفحص ترصد الـ hooks، وملفات الـ CI تضيف --ignore-scripts، ثم جاء npm 12 وجعل install scripts اختيارية بشكل افتراضي — وكان تحسينًا حقيقيًا.

رصد Unit 42 لحملات npm في النصف الأول من 2026 يقرأ كفهرس لنمط واحد متكرر: Bitwarden في 22 أبريل، وSAP/CAP في 29 أبريل، وTanStack في 11 مايو، و@antv في 19 مايو، وRed Hat في 1 يونيو — preinstall hook تلو الآخر.

ثم أُغلق الباب، فانتقلت الحمولة إلى مكان لا يغطيه هذا الباب.

ما الذي تغيّر

في اختراق AsyncAPI، الإصدارات المتأثرة — @asyncapi/specs بنسختيه 6.11.2 و6.11.2-alpha.1، و@asyncapi/generator 3.3.1، و@asyncapi/generator-components 0.7.1، و@asyncapi/generator-helpers 1.1.1، وكلها أُعيد نشرها خلال تسعين دقيقة تقريبًا — لم تُعلن أي lifecycle hooks على الإطلاق. وتحليل Microsoft صريح في النتيجة: أي build أو تطبيق يستورد package مسمومة، يُنفَّذ الكود المحقون فورًا، وإجراء npm install --ignore-scripts المعتاد لا يُبطل ذلك.

حادثة jscrambler تُظهر التطور نفسه مضغوطًا في يوم واحد. الإصدارات من 8.14.0 إلى 8.17.0 استخدمت preinstall hook يشير إلى dist/setup.js. ومن 8.18.0 فصاعدًا حُقن الـ dropper مباشرة داخل dist/index.js ونقطة دخول الـ CLI: يعمل ذاتيًا عند الـ import، وينجو من --ignore-scripts، ولا تراه أي أداة فحص تقرأ الـ hooks في package.json فقط.

كيف يبدو الـ dropper عند وقت الـ import

لا شيء معقّد. جسم الـ module يُنفَّذ عند require أو import، فيكفي تأثير جانبي واحد في المستوى الأعلى:

js
// dist/index.js — الـ exports الحقيقية تحت، والـ dropper فوق
const { spawn } = require('node:child_process')

spawn('node', [payloadPath], {
  detached: true,      // يبقى بعد انتهاء العملية الأم
  stdio: 'ignore',     // لا شيء يظهر في مخرجات الـ build
  windowsHide: true,   // بلا نافذة
}).unref()             // العملية الأم تنتهي بشكل طبيعي

module.exports = require('./real-entry.js')

العملية الأم تنتهي بنظافة، وسجل الـ build يبقى أخضر. وفي حالة AsyncAPI سحبت المرحلة الأولى حزمة مشفّرة بحجم ~8.2 ميغابايت من IPFS عبر CID مكتوب مسبقًا، ثم فكّت تشفيرها بمفاتيح HKDF-SHA256 و AES-256-GCM مدمجة، وشغّلت runtime معياريًا للتحكم والسيطرة مع آلية بقاء خاصة به.

وبما أن --ignore-scripts لم يعد يحسم النتيجة، تنتقل نقطة التأثير إلى الأعلى: أي إصدار يُسمح له أصلًا بالوصول إلى الجهاز.

jsonc
{
  "overrides": { "@asyncapi/specs": "6.11.1" },
  "scripts": {
    "ci": "npm ci --ignore-scripts && npm audit signatures"
  }
}

ملاحظات من أرض الواقع

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

نظّف الـ cache. توصية Microsoft تشمل صراحةً مسح cache الخاص بـ npm وYarn، لأن ملفًا مسمومًا داخل الـ cache يعيش أطول من سحب الإصدار من الـ registry.

دوِّر بيانات الاعتماد في أجهزة الـ build لا في اللابتوب فقط. الحمولتان جمعتا بيانات اعتماد سحابية وtokens وملفات إعداد أدوات AI تحتوي مفاتيح API. ومشغّلات الـ CI تحمل أثمن مجموعة منها.

راقب الشبكة الخارجة لا الـ packages وحدها. حجب بوابات IPFS عند حدود الشبكة يكسر المرحلة الثانية حتى لو نجحت المرحلة الأولى.

الخلاصة

يستحق --ignore-scripts البقاء لأنه يغلق بابًا حقيقيًا، لكنه لم يعد حدًا فاصلًا. ما تستطيع أي اعتمادية فعله عند وقت الـ import صارت تفعله دون استئذان، ومعنى ذلك أن الضوابط المؤثرة اليوم هي تثبيت الإصدارات، وتأخير عمر النشر، ونظافة الـ cache، وتقييد الشبكة الخارجة في بنية الـ build.

المصادر الأساسية: Microsoft Security Blog — اختراق AsyncAPI على npm وSocket — هجوم سلسلة التوريد على jscrambler.