أساسيات الحاسب والبرمجة الأدلة التقنية

مديرو الحزم Package Managers: npm و pip و Maven و Cargo

نجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.

مديرو الحزم Package Managers: npm و pip و Maven و Cargo

لنفرض أنك انضممت إلى شركة ناشئة تبيع برنامج فواتير للمحلات الصغيرة، وفي أسبوعك الأول وجدت أربعة مستودعات: واجهة API مكتوبة بلغة JavaScript على Node.js، وسكربتات تقارير بلغة Python، وخدمة مدفوعات قديمة بلغة Java، وخدمة جديدة لحساب الضرائب بدأ زميلك كتابتها بلغة Rust. وفي كل مستودع ملف صغير يقول «هذا المشروع يحتاج هذه المكتبات»، وأداة مختلفة تقرأه: npm وpip وMaven وCargo. وفي نفس الأسبوع حدثت ثلاثة أشياء: بناء خدمة Java نجح ولكن السجلات Logs اختفت من الإنتاج دون أي رسالة خطأ، وسكربت Python رفض أن يثبت أي مكتبة على السيرفر الجديد، وكتب زميلك اسم مكتبة Rust من ذاكرته فكاد يضيف مكتبة غير التي يقصدها. وكل هذه المشكلات لها نفس المصدر: الأداة التي تجلب كود غيرك إلى مشروعك، وتقرر أي إصدار تأخذ، ومن أين، وماذا تشغل أثناء ذلك.

وفي مقال أدوات البناء Build Tools رأينا ما يحدث بعد أن تصل المكتبات إلى مشروعك، أي الترجمة والاختبار والتغليف، وذكرنا ملفات القفل باختصار. وفي مقال أمن سلسلة التوريد البرمجية ومقال فحص الاعتماديات SCA شرحنا التهديدات والدفاعات وأدوات الفحص. وهنا سوف ندخل إلى الأداة نفسها: كيف يحل مدير الحزم Package Manager شجرة الاعتماديات، وماذا يعني الرقم ^4.18.0، ولماذا يختار Maven إصداراً قديماً دون أن يخبرك، وكيف تحصل على نفس الشجرة على جهازك وعلى سيرفر CI، مع تجربة حقيقية على الأدوات الأربع وقضية انتحال أسماء موثقة من نشرة أمنية رسمية.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا وجدت مديرو الحزم، وما الخطوات المشتركة بينها: ملف التعريف Manifest، والحل Resolver، والسجل Registry، وملف القفل Lockfile.
  • الإصدار الدلالي SemVer والنطاقات Ranges، وما يقبله كل نطاق بالأرقام.
  • npm: كم حزمة تأتي مع اعتمادية واحدة، وملف القفل، وnpm ci، والسكربتات التي تعمل أثناء التثبيت.
  • pip: البيئات الافتراضية، والملف pylock.toml الجديد، ووضع التحقق من الهاش.
  • Maven: قاعدة «الأقرب يفوز» Nearest Wins، وتعارض حقيقي أخفى السجلات، وإصلاحه.
  • Cargo: القفل والشجرة دون بناء، والإصدارات المكررة، والخيار --locked.
  • انتحال الأسماء Typosquatting: قضية rustdecimal من نشرة RustSec، ثم التثبيت القابل للتكرار Reproducible Installs، وقائمة تحقق وأخطاء شائعة.

لماذا وجدت مديرو الحزم Package Managers؟

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

ومدير الحزم يحل هذه المشكلات بفكرة واحدة: أن تكتب ما تحتاجه مباشرة في ملف تعريف Manifest، وتترك للأداة أن تكتشف الباقي من بيانات كل حزمة في السجل Registry، وتختار إصداراً لكل حزمة يرضي كل المتطلبات، وتنزلها وتتحقق منها وتضعها في مكانها. وكل لغة كبيرة اليوم لها مدير حزم وسجل عام: npm مع السجل npmjs.org للغة JavaScript، وpip مع PyPI للغة Python، وMaven مع Maven Central للغة Java، وCargo مع crates.io للغة Rust. والثمن الذي تدفعه مقابل هذه الراحة أن مشروعك صار يعتمد على كود كثير لم تقرأه، كتبه أشخاص لا تعرفهم، ويتغير دون أن تطلب، وهذا ما سوف نقيسه بالأرقام.

تجهيز المختبر Lab Setup: أربع أدوات في Container واحد

كل التجارب تعمل في Container واحد مبني على Debian 13، فيه Python وpip وMaven وJava 21 وCargo من حزم Debian، وNode.js 22 مع npm منسوخة من الـ Image الرسمية:

FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates python3 python3-venv python3-pip maven openjdk-21-jdk-headless cargo git && rm -rf /var/lib/apt/lists/*
# Node.js 22 and npm from the official image (Debian trixie ships Node 20)
COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /lab
docker build -t scp5-pm:1 .
docker run --rm scp5-pm:1 sh -c 'cat /etc/debian_version; node --version; npm --version; python3 --version; pip3 --version; mvn -v | head -3; cargo --version; rustc --version'
13.7
v22.23.4
10.9.9
Python 3.13.5
pip 25.1.1 from /usr/lib/python3/dist-packages/pip (python 3.13)
Apache Maven 3.9.9
Maven home: /usr/share/maven
Java version: 21.0.13, vendor: Debian, runtime: /usr/lib/jvm/java-21-openjdk-amd64
cargo 1.85.1 (d73d2caf9 2024-12-31)
rustc 1.85.1 (4eb161250 2025-03-15) (built from a source tarball)

وفي مجلد المختبر أربعة مشاريع صغيرة بنفس الاسم invoice-api، كل منها بلغة، وسكربت لكل أداة يعمل بالأمر docker run --rm -v "$PWD":/lab scp5-pm:1 sh /lab/run-npm.sh وما يشبهه. وكل تشغيل يبدأ بذاكرة مؤقتة Cache فارغة، لأن الـ Container جديد في كل مرة، وهذا يشبه سيرفر CI نظيفاً أكثر مما يشبه جهاز المطور.

ماذا يحدث عندما تكتب install؟ الحل Resolver والسجل Registry والقفل Lockfile

كل مديري الحزم، مهما اختلفت أسماؤهم، يمرون بنفس الخطوات، والصورة التالية تجمعها مع الفرق الأهم بين الأدوات الأربع:

خطوات مدير الحزم: ملف التعريف مثل package.json وrequirements.in وpom.xml وCargo.toml، ثم الحل الذي يختار إصداراً لكل حزمة، ثم ملف القفل، ثم التنزيل من السجل npmjs.org وPyPI وMaven Central وcrates.io، ثم التحقق من الهاش، ثم التثبيت الذي قد يشغل كوداً من الحزمة، وتحتها بطاقة لكل أداة: npm وCargo بقفل افتراضي، وpip بقفل تجريبي، وMaven دون قفل
ست خطوات مشتركة، والفرق بين الأدوات في القفل والتحقق والكود الذي يعمل أثناء التثبيت
  • ملف التعريف Manifest: ما تكتبه أنت، أي الاعتماديات المباشرة Direct Dependencies ونطاق الإصدارات الذي تقبله لكل منها، مثل package.json في npm وpom.xml في Maven وCargo.toml في Cargo، وفي pip ملف المتطلبات requirements.in أو قسم الاعتماديات في pyproject.toml.
  • الحل Resolver: الجزء الأصعب في الأداة. يقرأ من السجل اعتماديات كل حزمة، ثم اعتماديات اعتمادياتها، ويبحث عن مجموعة إصدارات ترضي كل النطاقات في نفس الوقت. وهذه مسألة صعبة حسابياً في الحالة العامة، ولذلك يختلف سلوك الأدوات عندما لا يوجد حل كامل: npm يسمح بأكثر من إصدار للحزمة نفسها في الشجرة، وCargo كذلك بشرط أن تختلف الإصدارات في الرقم المتوافق، وpip يتراجع ويجرب إصدارات أقدم كما في صفحة حل الاعتماديات في توثيق pip، وMaven يختار إصداراً واحداً بقاعدة بسيطة كما سوف ترى.
  • ملف القفل Lockfile: نتيجة الحل مكتوبة في ملف، أي الإصدار الدقيق لكل حزمة في الشجرة، ومصدرها، وهاش محتواها. وهذا الملف هو ما يجعل التثبيت القادم على جهاز آخر يعطي نفس الشجرة.
  • السجل Registry: سيرفر عام يحفظ الحزم وبياناتها، وأي شخص له حساب يستطيع أن ينشر فيه حزمة باسم لم يأخذه أحد، وهذه النقطة هي أصل أغلب مشكلات الأمان في هذا المقال.
  • التحقق Verify: مقارنة هاش الملف المنزل بالهاش المكتوب في القفل، فإذا تغير محتوى الحزمة في السجل أو في الطريق رفضت الأداة التثبيت.
  • التثبيت Install: فك الحزمة في مكانها، وفي بعض الأدوات تشغيل كود منها، مثل سكربت postinstall في npm، أو الملف build.rs في Cargo الذي يعمل عند البناء، وهذه نقطة سوف نجربها بأنفسنا.

الإصدار الدلالي SemVer والنطاقات Ranges

أغلب الحزم ترقم إصداراتها بثلاثة أرقام حسب مواصفة الإصدار الدلالي Semantic Versioning: الرقم الرئيسي MAJOR يتغير عندما يتغير شيء يكسر التوافق مع الكود القديم، والرقم الثانوي MINOR لميزة جديدة متوافقة، ورقم الإصلاح PATCH لإصلاح لا يغير السلوك المعلن. وهذا وعد من صاحب الحزمة، لا ضمان تقني، فلا يوجد ما يمنع أحداً من نشر تغيير كاسر في إصدار إصلاح، ولكن هذا الوعد هو ما يسمح لك بأن تكتب نطاقاً بدل رقم ثابت، أي أن تقول «أقبل أي إصلاح أو ميزة جديدة دون أن أغير ملف التعريف». والنطاقات في npm موثقة في صفحة الإصدار الدلالي في توثيق npm وفي مكتبة node-semver التي يستخدمها npm نفسه، فسألناها مباشرة عن بعض النطاقات:

npm view express@^4.18.0 version | tail -4
node -e 'const s=require("/usr/local/lib/node_modules/npm/node_modules/semver");for(const [r,v] of [["^4.18.0","4.21.2"],["^4.18.0","5.0.0"],["~4.18.0","4.19.0"],["^0.2.3","0.2.9"],["^0.2.3","0.3.0"],[">=4.18.0","5.1.0"]])console.log(r.padEnd(10),v.padEnd(7),s.satisfies(v,r))'
[email protected] '4.22.0'
[email protected] '4.22.1'
[email protected] '4.22.2'
[email protected] '4.22.3'
^4.18.0    4.21.2  true
^4.18.0    5.0.0   false
~4.18.0    4.19.0  false
^0.2.3     0.2.9   true
^0.2.3     0.3.0   false
>=4.18.0   5.1.0   true

في المخرج أعلاه لاحظ التالي:

  • الأسطر الأربعة الأولى هي آخر الإصدارات التي يقبلها النطاق ^4.18.0 في السجل يوم التجربة، وآخرها 4.22.3، أي أن من كتب هذا النطاق قبل سنوات يحصل اليوم على إصدار لم يكن موجوداً حين كتبه.
  • العلامة ^ تقبل كل ما لا يغير الرقم الأول، والعلامة ~ تقبل الإصلاحات فقط، و>= تقبل كل شيء أعلى، حتى الإصدار الرئيسي القادم الذي سوف يكسر كودك.
  • قبل الإصدار 1.0 تتغير القاعدة: ^0.2.3 لا يقبل 0.3.0، لأن المواصفة تعتبر كل ما قبل 1.0 غير مستقر، فيعامل الرقم الثاني كأنه الرقم الرئيسي.

وكل أداة تكتب النطاقات بشكل مختلف قليلاً، والصورة التالية تجمع ما رأيناه في التجارب الأربع:

ست بطاقات للنطاقات: ^4.18.0 في npm يقبل 4.21.2 ويرفض 5.0.0 والمثبت 4.22.3، و~4.18.0 يقبل إصلاحات 4.18 فقط، وقبل الإصدار 1.0 النطاق ^0.2.3 يرفض 0.3.0 وفي Cargo أخذ reqwest 0.12.28 مع وجود 0.13.5، و1 في Cargo يعني ^1 فأخذ rust_decimal 1.43.0، و~=3.0 في pip من 3.0 حتى أقل من 4.0 فأخذ Flask 3.1.3، و>=4.18.0 دون حد أعلى
النطاق يقول ما تقبل به في المستقبل، والقيم من مخرجات المختبر

وفي pip تكتب النطاقات حسب مواصفة محددات الإصدار، والعلامة ~=3.0 تعني «إصدار متوافق»، أي من 3.0 حتى أقل من 4.0. وفي Cargo الرقم وحده "1" يعني ^1 كما في توثيق تحديد الاعتماديات في Cargo. أما Maven فالمعتاد فيه أن تكتب إصداراً ثابتاً، ولديه صيغة نطاقات مثل [1.0,2.0) ولكن قليلاً ما تستخدم، كما في توثيق آلية الاعتماديات في Maven.

npm: شجرة اعتماديات Dependency Tree كاملة من سطر واحد

مشروع Node.js في المختبر يحتاج مكتبة واحدة فقط، وهي إطار الويب express، وهذا ملف التعريف كاملاً:

{
  "name": "invoice-api",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "express": "^4.18.0"
  }
}
npm install --no-audit --no-fund
npm ls --depth=0
added 68 packages in 8s
elapsed 7741 ms
[email protected] /lab/npm
`-- [email protected]

حذفنا من المخرج أسطر npm notice التي تقترح إصداراً أحدث من npm، والسطر elapsed تطبعه دالة صغيرة اسمها timeit في أول كل سكربت تقيس الوقت حول الأمر، لأن الأداة /usr/bin/time غير موجودة في الـ Image. وسطر واحد في ملف التعريف أدخل إلى المشروع 68 حزمة، أي أن 67 منها لم يكتبها أحد في الفريق ولم يخترها. ولنعرف أين وضعها npm، نعد مجلدات الحزم في node_modules، ونبحث في ملف القفل عن أعمق مسار:

find node_modules -name package.json | grep -cE "^node_modules/(@[^/]+/)?[^/]+/package.json$"
find node_modules -name package.json | grep -E "node_modules/[^/]+/node_modules/[^/]+/package.json$"
67
node_modules/send/node_modules/ms/package.json
echo "entries in package-lock.json: $(node -e 'console.log(Object.keys(require("./package-lock.json").packages).length - 1)')"
echo "max depth: $(node -e 'const p=require("./package-lock.json").packages;let m=0,w="";for(const k of Object.keys(p)){const d=(k.match(/node_modules\//g)||[]).length;if(d>m){m=d;w=k}}console.log(m, w)')"
du -sh node_modules package-lock.json
npm ls ms
entries in package-lock.json: 68
max depth: 2 node_modules/send/node_modules/ms
3.3M	node_modules
32K	package-lock.json
[email protected] /lab/npm
`-- [email protected]
  +-- [email protected]
  | `-- [email protected]
  `-- [email protected]
    `-- [email protected]

في المخرج أعلاه لاحظ التالي:

  • 67 حزمة في المستوى الأول من node_modules، وحزمة واحدة متداخلة. فالشجرة المنطقية عميقة، ولكن npm يرفع كل حزمة إلى أعلى مستوى ممكن Hoisting، حتى تشترك فيها كل الحزم التي تحتاجها بنفس الإصدار، ولا تتكرر على القرص.
  • الحالة الوحيدة التي لم يستطع فيها الرفع هي الحزمة ms: المكتبة debug تطلب الإصدار 2.0.0 بالضبط، والمكتبة send تطلب نطاقاً أعطى 2.1.3، فوضع npm الأولى في الأعلى، والثانية داخل مجلد send نفسه. وهذا هو الفرق الكبير بين npm وأدوات مثل Maven وpip: npm يسمح بإصدارين من نفس الحزمة في نفس المشروع، فلا يحدث تعارض، والثمن حجم أكبر واحتمال أن يوجد كائنان من نفس المكتبة لا يعرف أحدهما الآخر.
  • الحجم 3.3 MB وملف القفل 32 KB، وهذا مشروع صغير جداً. وفي مشاريع الواجهات الأمامية مع أدوات البناء ليس غريباً أن تجد مئات الحزم.

ماذا يوجد داخل ملف القفل package-lock.json؟

الملف package-lock.json يكتبه npm تلقائياً بعد كل تثبيت، وصيغته موثقة في صفحة package-lock.json في توثيق npm. وهذا السجل الخاص بالمكتبة express، مقطوعاً عند قائمة اعتمادياتها:

{
  "node_modules/express": {
    "version": "4.22.3",
    "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
    "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
    "license": "MIT",
    "dependencies": {
      "accepts": "~1.3.8",
      "array-flatten": "1.1.1",
      "body-parser": "~1.20.5",
      "content-disposition": "~0.5.4",
      "content-type": "~1.0.4",
      "cookie": "~0.7.1",
      "cookie-signature": "~1.0.6",
      "debug": "2.6.9",
      "depd": "2.0.0",

كل حزمة في الشجرة لها ثلاث معلومات تكفي لإعادة تثبيتها بالضبط: الإصدار version، والرابط الذي نزلت منه resolved، والهاش integrity من نوع SHA-512 لمحتوى الملف المضغوط. ولاحظ أن express نفسها تكتب اعتمادياتها بالعلامة ~ أو بأرقام ثابتة، أي أنها لا تقبل إلا إصلاحات، وهذا اختيار حذر من أصحابها يقلل المفاجآت لمن يستخدمها.

التثبيت من القفل: npm ci

الأمر npm install يقرأ ملف القفل، ولكنه قد يعدله إذا وجد أن ملف التعريف تغير، أما npm ci فيثبت من القفل حرفياً، ويحذف node_modules قبل أن يبدأ، ويرفض العمل إذا لم يطابق القفل ملف التعريف. ولنتأكد أنه يعطي نفس الشجرة، حفظنا ناتج npm ls --all، ثم حذفنا node_modules وثبتنا من جديد وقارنا الملفين:

npm ls --all > /tmp/tree1.txt
rm -rf node_modules
npm ci --no-audit --no-fund
npm ls --all > /tmp/tree2.txt
cmp /tmp/tree1.txt /tmp/tree2.txt && echo "same tree after npm ci"
added 68 packages in 2s
elapsed 2385 ms
same tree after npm ci

نفس الشجرة، والتثبيت الثاني أخذ 2.4 ثانية بدل 7.7، لأن الحزم صارت في الذاكرة المؤقتة لمدير الحزم في ~/.npm، وهذا سبب أن خطوط CI الجيدة تحفظ هذا المجلد بين التشغيلات. والآن نجرب ما يحدث لو أضاف أحد الزملاء مكتبة إلى package.json بيده ونسي أن يحدث القفل:

npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: [email protected] from lock file

رفض npm ci أن يكمل، وقال بالضبط ما الذي ينقص القفل. وهذا هو السلوك الذي تريده في خط CI: أن يفشل البناء عند أول اختلاف بين ما كتبه المطور وما اختبره، بدل أن يحل الشجرة من جديد بصمت ويبني شيئاً لم يجربه أحد.

ماذا يحدث إذا كان في الحزمة سكربت تثبيت Install Script؟ postinstall

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

{
  "name": "evil-demo",
  "version": "1.0.0",
  "description": "Lab only: shows that npm runs install scripts of every package it installs",
  "scripts": {
    "postinstall": "node -e \"console.log('postinstall ran: user=' + require('os').userInfo().username + ' cwd=' + process.cwd() + ' HOME=' + process.env.HOME)\""
  }
}
npm install ./evil-demo --no-audit --no-fund --foreground-scripts
npm uninstall evil-demo --no-audit --no-fund
npm install ./evil-demo --no-audit --no-fund --foreground-scripts --ignore-scripts
> [email protected] postinstall
> node -e "console.log('postinstall ran: user=' + require('os').userInfo().username + ' cwd=' + process.cwd() + ' HOME=' + process.env.HOME)"
postinstall ran: user=root cwd=/lab/npm/evil-demo HOME=/root
added 1 package in 470ms
added 1 package in 553ms

في المخرج أعلاه لاحظ التالي:

  • في التثبيت الأول عمل السكربت بصلاحيات root ومعه مجلد HOME كامل، أي أنه يستطيع أن يقرأ ~/.npmrc بما فيه من توكن النشر، ومفاتيح SSH، ومتغيرات البيئة التي فيها أسرار CI، وأن يرسلها إلى أي مكان. وهذا ليس افتراضاً نظرياً، فقد كانت سكربتات التثبيت الطريق الذي استخدمته حزم خبيثة كثيرة في السجلات العامة، وعرضنا أمثلة منها في مقال أمن سلسلة التوريد.
  • الخيار --foreground-scripts جعلنا نرى المخرج، فبدونه يخفي npm مخرج السكربتات الناجحة، أي أن السكربت يعمل دون أن تراه أصلاً.
  • في التثبيت الثاني مع --ignore-scripts لم يعمل السكربت، وثبتت الحزمة. ويمكنك أن تجعل هذا الخيار افتراضياً في ملف .npmrc بالسطر ignore-scripts=true كما في صفحة الإعدادات في توثيق npm، والثمن أن الحزم التي تحتاج سكربتها فعلاً، مثل بعض المكتبات التي تبني كوداً أصلياً، سوف تحتاج أن تشغل سكربتها بنفسك.

وفي Cargo يوجد ما يقابل هذا الخطر، وهو الملف build.rs الذي يعمل عند بناء المكتبة، وأيضاً الماكرو الإجرائية Procedural Macros التي تعمل داخل المترجم. وفي pip تشغل الحزم التي تأتي كملف مصدري Source Distribution كودها عند البناء، أما الحزم الجاهزة Wheels فلا تشغل شيئاً عند التثبيت. والقاعدة العامة: تثبيت أي حزمة قرار ثقة بصاحبها، لا بالكود الذي سوف تستدعيه منها فقط.

pip والبيئات الافتراضية Virtual Environments

مشروع Python في المختبر يحتاج مكتبتين: requests للطلبات وflask للويب، وهذا ملف المتطلبات:

requests>=2.31,<3
flask~=3.0

المشكلة الأولى: النظام يرفض pip، والبيئة المدارة Externally Managed

أول ما فعلناه هو ما يفعله أغلب الناس على سيرفر جديد، أي تثبيت المكتبة مباشرة:

pip3 install requests
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install

هذا الخطأ جديد نسبياً على كثيرين، ومصدره PEP 668: توزيعات Linux الحديثة، ومنها Debian 13، تعلن أن Python النظام يديره مدير حزم التوزيعة apt، فيرفض pip أن يكتب فوقه. والسبب أن أدوات النظام نفسها مكتوبة بلغة Python، وتثبيت إصدار آخر من مكتبة تعتمد عليها قد يعطل أداة في النظام لا علاقة لها بمشروعك. والحل الأنسب ليس الخيار --break-system-packages الذي يتجاوز الحماية، وإنما بيئة افتراضية Virtual Environment لكل مشروع بالوحدة venv، وهي مجلد فيه نسخة معزولة من مكان المكتبات:

python3 -m venv .venv
. .venv/bin/activate
pip --version
timeit pip install -q -r requirements.in 2>&1
pip list --format=freeze
pip 25.1.1 from /lab/pip/.venv/lib/python3.13/site-packages/pip (python 3.13)
elapsed 24559 ms
blinker==1.9.0
certifi==2026.10.23
charset-normalizer==3.5.2
click==8.5.0
Flask==3.1.3
idna==3.20
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
pip==25.1.1
requests==2.34.2
urllib3==2.8.0
Werkzeug==3.1.9

في المخرج أعلاه لاحظ التالي:

  • السطر الأول يؤكد أن pip الآن من داخل .venv لا من النظام، وهذا أول ما تفحصه عندما يثبت شيء في مكان لا تتوقعه.
  • مكتبتان مباشرتان أدخلتا 12 حزمة، دون حساب pip نفسه، وهذا أقل بكثير من npm، لأن مكتبات Python تميل إلى أن تكون أكبر وأقل عدداً، ولأن المكتبة القياسية في Python واسعة.
  • التثبيت أخذ 24.6 ثانية مع ذاكرة مؤقتة فارغة، ومعظمها تنزيل وتحليل للبيانات من PyPI.
  • المكتبة certifi برقم إصدار هو تاريخ، 2026.10.23، لأنها تحمل قائمة شهادات الجهات الموثوقة، وتصدر حسب التاريخ لا حسب SemVer، فلا تفترض أن كل الحزم تتبع نفس نظام الترقيم.

ولمعرفة من أدخل كل حزمة، يعطيك الأمر pip show قائمة الاعتماديات المباشرة لكل مكتبة:

Name: requests
Version: 2.34.2
Requires: certifi, charset_normalizer, idna, urllib3
Name: Flask
Version: 3.1.3
Requires: blinker, click, itsdangerous, jinja2, markupsafe, werkzeug

ملف القفل في Python: pip lock و pylock.toml

لسنوات طويلة لم يكن في pip ملف قفل حقيقي، وكان الفريق يستخدم pip freeze لكتابة الإصدارات الحالية في ملف، أو أدوات خارجية مثل pip-tools وPoetry وuv، ولكل منها صيغته. ثم اعتمدت مواصفة موحدة في PEP 751 باسم pylock.toml، وأضاف pip في الإصدار 25.1 الأمر pip lock لكتابتها، وما زال يوصف بأنه تجريبي:

pip lock -r requirements.in -o pylock.toml
grep -c '^\[\[packages\]\]' pylock.toml
12

اثنتا عشرة حزمة، نفس العدد الذي ثبت، وهذا سجل requests فيها:

name = "requests"
version = "2.34.2"

[[packages.wheels]]
name = "requests-2.34.2-py3-none-any.whl"
url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl"

[packages.wheels.hashes]
sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"

نفس المعلومات الثلاث التي رأيناها في npm: الإصدار، والرابط، والهاش. والفرق أن الحزمة في Python قد يكون لها أكثر من ملف جاهز Wheel، لكل نظام تشغيل وإصدار Python ملف، فيحمل القفل هاشاً لكل ملف منها.

ماذا يحدث إذا تغير الملف؟ وضع التحقق من الهاش Hash-Checking Mode

حتى دون pylock.toml يستطيع pip أن يرفض أي ملف لا يطابق هاشاً تكتبه أنت في ملف المتطلبات، وهذا ما يسميه توثيقه وضع التحقق من الهاش. نحسب الهاش الحقيقي لملف بالأمر pip hash، ثم نثبت به مرة، ثم نضع هاشاً خاطئاً لنرى ما يحدث لو تغير الملف في السجل أو في الطريق:

pip download -q -d wheels "idna==3.10" --no-deps
pip hash wheels/idna-3.10-py3-none-any.whl
echo "idna==3.10 --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" > requirements.lock
pip install --require-hashes --no-deps -r requirements.lock
echo "idna==3.10 --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000" > requirements.lock
pip install --force-reinstall --require-hashes --no-deps -r requirements.lock
wheels/idna-3.10-py3-none-any.whl:
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
Successfully installed idna-3.10
  Using cached idna-3.10-py3-none-any.whl (70 kB)
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    idna==3.10 from https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl (from -r requirements.lock (line 1)):
        Expected sha256 0000000000000000000000000000000000000000000000000000000000000000
             Got        946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3

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

Maven: إصدارات ثابتة وقاعدة «الأقرب يفوز» Nearest Wins

في عالم Java الوضع مختلف: لا يوجد ملف قفل، والسبب أن الاعتماديات في pom.xml تكتب عادة بإصدارات ثابتة، فالحل يعطي نفس النتيجة كل مرة ما دام الملف لم يتغير. ولكن هذا لا يعني أنه لا توجد قرارات، فالمكتبات المباشرة تحمل إصدارات ثابتة لاعتمادياتها هي، وعندما يطلب مساران في الشجرة نفس الحزمة بإصدارين، يجب أن يختار Maven إصداراً واحداً، لأن آلة Java الافتراضية لا تستطيع تحميل كلاسين بنفس الاسم من إصدارين في نفس المسار. وخدمة المدفوعات في المختبر تحتاج مكتبتين: عميل HTTP ومكتبة سجلات:

<dependencies>
    <!-- HTTP client: brings slf4j-api 1.7.x transitively -->
    <dependency>
      <groupId>org.apache.httpcomponents.client5</groupId>
      <artifactId>httpclient5</artifactId>
      <version>5.3.1</version>
    </dependency>
    <!-- Logging: needs slf4j-api 2.0.x -->
    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <version>1.5.6</version>
    </dependency>
  </dependencies>

والكود يكتب سطر سجل واحداً عند البدء، ويطبع اسم الكلاس الذي ينفذ السجلات فعلاً:

package com.example;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Main {
    private static final Logger log = LoggerFactory.getLogger(Main.class);

    public static void main(String[] args) {
        log.info("invoice-api started");
        System.out.println("logger implementation: " + log.getClass().getName());
    }
}

نطلب الشجرة بالإضافة dependency:tree، مرة عادية ومرة بالخيار -Dverbose الذي يظهر ما حذفه Maven:

mvn -B dependency:tree 2>&1 | sed -n '/--- dependency/,/BUILD/p' | sed 's/^\[INFO\] //'
mvn -B dependency:tree -Dverbose 2>&1 | sed -n '/--- dependency/,/BUILD/p' | sed 's/^\[INFO\] //'

والأمر sed يقطع جزء الشجرة من مخرج Maven ويحذف البادئة [INFO] من كل سطر، وهذا مخرج الأمر الثاني:

com.example:invoice-api:jar:1.0.0
+- org.apache.httpcomponents.client5:httpclient5:jar:5.3.1:compile
|  +- org.apache.httpcomponents.core5:httpcore5:jar:5.2.4:compile
|  +- org.apache.httpcomponents.core5:httpcore5-h2:jar:5.2.4:compile
|  |  \- (org.apache.httpcomponents.core5:httpcore5:jar:5.2.4:compile - omitted for duplicate)
|  \- org.slf4j:slf4j-api:jar:1.7.36:compile
\- ch.qos.logback:logback-classic:jar:1.5.6:compile
   +- ch.qos.logback:logback-core:jar:1.5.6:compile
   \- (org.slf4j:slf4j-api:jar:2.0.13:compile - omitted for conflict with 1.7.36)

في المخرج أعلاه لاحظ التالي:

  • الشجرة فيها ستة ملفات JAR فقط، وهذا عدد صغير مقارنة بالأدوات الأخرى، لأن مكتبات Java الكبيرة تميل إلى أن تجمع وظائفها داخلها.
  • omitted for duplicate يعني أن نفس الحزمة بنفس الإصدار ظهرت مرتين، فأخذت مرة واحدة، ولا مشكلة هنا.
  • السطر الأخير هو المشكلة: logback-classic طلبت slf4j-api بالإصدار 2.0.13، وعميل HTTP طلبها بالإصدار 1.7.36، فحذف Maven الإصدار الأحدث. والقاعدة في توثيق Maven اسمها الأقرب يفوز Nearest Definition: يختار الإصدار الأقرب إلى مشروعك في الشجرة، وإذا تساوى العمق، كما هنا حيث كلاهما في المستوى الثاني، يفوز الذي ظهر أولاً في pom.xml. فترتيب سطرين في الملف قرر أي إصدار يعمل في الإنتاج.
  • والبناء نجح، وهذا أخطر ما في الأمر: لا خطأ ولا تحذير في mvn package.

ماذا يحدث عند التشغيل؟ السجلات Logs تختفي

mvn -B -q compile
mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp target/classes:$(cat /tmp/cp.txt) com.example.Main
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
logger implementation: org.slf4j.helpers.NOPLogger

هذه هي المشكلة التي وصفناها في أول المقال: السطر invoice-api started لم يظهر أبداً. فالمكتبة slf4j-api بالإصدار 1.7 تبحث عن التطبيق الفعلي للسجلات بكلاس اسمه StaticLoggerBinder، بينما logback 1.5 مكتوبة لطريقة الاكتشاف الجديدة في SLF4J 2، فلم تجد 1.7 أي تطبيق، واستخدمت سجلاً لا يفعل شيئاً NOP Logger، كما تشرح صفحة رموز SLF4J. والتحذير الوحيد ثلاثة أسطر في بداية التشغيل، تضيع بسهولة بين مئات الأسطر في سجلات Container. وفي الإنتاج معنى ذلك أن الخدمة تعمل، ولكن أي خطأ فيها لن يكتب في أي مكان.

قبل الإصلاح: شجرة Maven فيها httpclient5 مع slf4j-api 1.7.36 المختار، وlogback-classic مع slf4j-api 2.0.13 المحذوف بسبب التعارض، وعند التشغيل Defaulting to no-operation logger، وبعد الإصلاح: slf4j-api 2.0.13 في dependencyManagement، وعند التشغيل يظهر سطر INFO والـ Logger هو ch.qos.logback.classic.Logger
ترتيب سطرين في pom.xml قرر إصدار المكتبة، والبناء نجح والسجلات اختفت

الإصلاح: dependencyManagement وقاعدة التقارب Dependency Convergence

الحل الأنسب ليس تبديل ترتيب السطرين، لأن هذا يصلح المشكلة اليوم ويترك القاعدة نفسها تقرر غداً، وإنما أن تعلن الإصدار الذي تريده صراحة في القسم dependencyManagement، فيصبح هو الإصدار لهذه الحزمة أينما ظهرت في الشجرة:

<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.13</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
mvn -B dependency:tree 2>&1 | grep slf4j | sed 's/^\[INFO\] //'
mvn -B -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp target/classes:$(cat /tmp/cp.txt) com.example.Main
|  \- org.slf4j:slf4j-api:jar:2.0.13:compile
23:36:39.641 [main] INFO com.example.Main -- invoice-api started
logger implementation: ch.qos.logback.classic.Logger

الآن الشجرة فيها الإصدار 2.0.13، وظهر سطر السجل بتنسيق logback، والكلاس الفعلي هو ch.qos.logback.classic.Logger. ولكي لا يتكرر هذا مع مكتبة أخرى، أضف إلى المشروع القاعدة dependencyConvergence من إضافة Maven Enforcer، وهي تفشل البناء عندما تطلب مسارات الشجرة إصدارات مختلفة من نفس الحزمة، فيتحول التعارض الصامت إلى خطأ صريح في CI يجبرك على قرار واع. وعلى الجهة الأخرى، Gradle يختار في نفس الحالة الإصدار الأحدث لا الأقرب كما في توثيق حل الاعتماديات في Gradle، وهذا اختيار له مشكلاته أيضاً، فالأحدث قد يكسر المكتبة التي طلبت الأقدم.

Cargo: القفل Cargo.lock وشجرة الاعتماديات Dependency Tree دون بناء

خدمة الضرائب في المختبر مكتوبة بلغة Rust، وتحتاج خمس مكتبات مشهورة: rust_decimal للأرقام المالية، وserde وserde_json للتحويل إلى JSON، وtokio للتنفيذ غير المتزامن، وreqwest للطلبات:

[package]
name = "invoice-api"
version = "0.1.0"
edition = "2021"

[dependencies]
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }

وميزة Cargo أنه يستطيع أن يحل الشجرة ويكتب القفل دون أن يترجم شيئاً، بالأمر cargo generate-lockfile، ثم يعرضها بالأمر cargo tree:

timeit cargo generate-lockfile 2>&1 | tail -3
echo "packages in Cargo.lock: $(grep -c '^\[\[package\]\]' Cargo.lock)"
echo "lines in cargo tree: $(cargo tree --prefix none | sort -u | wc -l)"
cargo tree --depth 1
      Adding reqwest v0.12.28 (available: v0.13.5)
      Adding wasip2 v1.0.4+wasi-0.2.12 (requires Rust 1.87.0)
elapsed 16507 ms
packages in Cargo.lock: 187
lines in cargo tree: 152
invoice-api v0.1.0 (/lab/cargo)
├── reqwest v0.12.28
├── rust_decimal v1.43.0
├── serde v1.0.229
├── serde_json v1.0.154
└── tokio v1.54.1

في المخرج أعلاه لاحظ التالي:

  • خمس مكتبات مباشرة أدخلت إلى القفل 187 حزمة، أي أكثر من npm مع express. ولكن الشجرة الفعلية على Linux 152 حزمة فقط، لأن Cargo.lock يحل الشجرة لكل المنصات مرة واحدة، فيحمل حزماً لا تستخدم إلا على Windows أو في WebAssembly. وعددنا في القفل 15 حزمة يبدأ اسمها بكلمة windows، وحزماً مثل wasm-bindgen وweb-sys، وهذا مقصود حتى يكون القفل واحداً لكل المطورين مهما كان نظامهم.
  • السطر الأول يقول إن reqwest أخذت 0.12.28 مع أن 0.13.5 متاحة، وهذه قاعدة ما قبل 1.0 التي رأيناها في npm: النطاق "0.12" يعني ^0.12، والإصدار 0.13 قد يكسر التوافق، فلا يقبله Cargo إلا إذا غيرت ملف التعريف بيدك.
  • السطر الثاني يقول إن الحزمة wasip2 تحتاج Rust 1.87 بينما المترجم في Debian 1.85.1. وهي حزمة لمنصة WASI فقط، فلا تمنع البناء على Linux، ولكنها تنبهك إلى أن السجل يتحرك أسرع من مترجم التوزيعة، ولذلك يحدد كثير من المشاريع أقل إصدار مدعوم Minimum Supported Rust Version في Cargo.toml بالحقل rust-version.
  • والأمر cargo tree نزل ملفات الحزم كلها، أكثر من 140 سطر Downloaded في المخرج الكامل، لأنه يحتاج أن يقرأ ملف Cargo.toml داخل كل حزمة ليعرف المزايا Features المفعلة، مع أنه لم يترجم أي حزمة.

وهذا سجل rust_decimal في القفل:

name = "rust_decimal"
version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
dependencies = [
 "arrayvec",
 "borsh",
 "bytes",
 "num-traits",
 "rand 0.8.8",
 "rand 0.9.5",
 "serde",
 "serde_json",
 "wasm-bindgen",
]

نفس المعلومات مرة أخرى: الإصدار، والمصدر، والهاش checksum من نوع SHA-256. ولاحظ في الاعتماديات rand 0.8.8 وrand 0.9.5 معاً، فالمكتبة نفسها تعتمد على إصدارين من rand، وهذا ما يسمح به Cargo عندما يختلف الإصداران في الرقم المتوافق.

الإصدارات المكررة Duplicates

cargo tree -d --depth 0
base64 v0.22.1
base64 v0.23.1
syn v2.0.119
syn v3.0.9

الخيار -d يعرض الحزم التي دخلت الشجرة بأكثر من إصدار، وهنا base64 بإصدارين وsyn بإصدارين رئيسيين. والمكتبة syn هي محلل كود Rust الذي تستخدمه الماكرو الإجرائية، وهي من أثقل الحزم في الترجمة، فترجمتها مرتين تضيف وقتاً ملحوظاً إلى كل بناء نظيف. وهذا الأمر هو أول ما تشغله عندما يكبر وقت البناء أو حجم الملف التنفيذي دون سبب واضح، وغالباً يكون الحل تحديث مكتبة مباشرة إلى إصدار يستخدم نفس الإصدار الذي يستخدمه الباقون.

ماذا يحدث إذا تغير Cargo.toml ولم يتغير القفل؟ --locked

أضفنا مكتبة إلى Cargo.toml دون تحديث القفل، ثم طلبنا الشجرة بالخيار --locked، وهو الخيار الذي يستخدم في CI مع cargo build كما في توثيق cargo build:

sed -i 's/^serde_json = "1"/serde_json = "1"\nuuid = "1"/' Cargo.toml
cargo tree --locked --depth 0
    Updating crates.io index
error: the lock file /lab/cargo/Cargo.lock needs to be updated but --locked was passed to prevent this
If you want to try to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.

نفس سلوك npm ci: القفل لم يعد يطابق ملف التعريف، فرفض Cargo أن يكمل بدل أن يحل الشجرة بصمت. ودون --locked كان سوف يضيف uuid وأي حزم تحتاجها إلى القفل ويكمل، وهذا مناسب على جهاز المطور وغير مناسب إطلاقاً في CI. وقد كانت التوصية القديمة في Cargo ألا تحفظ Cargo.lock في مستودعات المكتبات، ثم تغيرت لتقول احفظه في أغلب الحالات، كما في دليل Cargo.toml مقابل Cargo.lock.

المقارنة Comparison: ما تكتبه وما يصل إلى مشروعك

أعمدة لكل أداة: npm اعتمادية مباشرة واحدة express أدخلت 68 حزمة، وpip اثنتان requests وflask أدخلتا 12، وMaven اثنتان httpclient5 وlogback-classic أدخلتا 6، وCargo خمس أدخلت 187 في القفل منها 152 على Linux
كل اعتمادية تكتبها تجلب معها شجرة، وحجم الشجرة يختلف كثيراً بين النظم
npmpipMavenCargo
المباشرة ← الشجرة في المختبر1 ← 682 ← 122 ← 65 ← 187 (152 على Linux)
ملف القفلpackage-lock.json افتراضيpylock.toml بالأمر pip lock التجريبيلا يوجدCargo.lock افتراضي
التثبيت الصارم من القفلnpm ci--require-hashesإصدارات ثابتة في pom.xml--locked
إصداران من نفس الحزمةمسموح (ms 2.0.0 و2.1.3)غير مسموحغير مسموح، «الأقرب يفوز»مسموح بين أرقام غير متوافقة (syn 2 و3)
كود يعمل عند التثبيت أو البناءسكربتات installبناء الحزم المصدريةالإضافات Plugins عند البناءbuild.rs والماكرو
البيئة المعزولةnode_modules في المشروعvenv يدوياًالمستودع المحلي ~/.m2 مشترك~/.cargo مشترك والبناء في المشروع

والملاحظ في Maven أن الشجرة ست حزم فقط، ولكن المستودع المحلي ~/.m2 حمل بعد التجربة 78 ملف JAR بحجم 28 MB، أغلبها إضافات Maven نفسها مثل إضافة الاعتماديات وإضافة الترجمة، وهذه أيضاً كود من السجل يعمل على جهازك وفي CI، وتنطبق عليه نفس قواعد الثقة والتثبيت.

انتحال الأسماء Typosquatting: قضية rustdecimal

الآن نصل إلى المشكلة الثالثة في قصتنا: الزميل الذي كتب اسم المكتبة من ذاكرته. فالسجلات العامة لا تراجع الحزم قبل نشرها، وأي شخص يستطيع أن ينشر حزمة باسم يشبه اسماً مشهوراً، بحرف ناقص أو شرطة مختلفة، وينتظر من يخطئ في الكتابة. وهذا ما حدث في crates.io كما في إعلان فريق Rust الأمني في مايو 2022 والنشرة RUSTSEC-2022-0042، والمسجلة أيضاً في قاعدة GitHub برقم GHSA-7pwq-f4pq-78gm.

💡
في 25 مارس 2022 نشرت على crates.io مكتبة اسمها rustdecimal، أي اسم المكتبة المشهورة rust_decimal دون الشرطة السفلية، وفيها نفس كود المكتبة الأصلية ونفس وظائفها، إلا الدالة Decimal::new. فعند استدعائها كانت تفحص وجود متغير البيئة GITLAB_CI، فإذا وجدته نزلت ملفاً تنفيذياً إلى /tmp/git-updater.bin وشغلته، على Linux وmacOS. وفي 2 مايو 2022 أبلغ فريق crates.io، فحذفها نهائياً وفحص كل الحزم الأخرى بحثاً عن كود مشابه فلم يجد، وكانت قد نزلت أقل من 500 مرة ولم تعتمد عليها أي حزمة في السجل. والنصيحة في النشرة: إذا كان مشروعك يعمل على GitLab CI واعتمد على هذه الحزمة منذ 25 مارس، فاعتبر بيئة CI مخترقة.
تسلسل قضية rustdecimal: نشرت في 25 مارس 2022 على crates.io، وعند استدعاء Decimal::new إذا وجد المتغير GITLAB_CI نزلت ملفاً إلى /tmp/git-updater.bin وشغلته، وفي 2 مايو 2022 أبلغ فريق crates.io وحذفت بأقل من 500 تنزيل، والنشرات الرسمية RUSTSEC-2022-0042 وGHSA-7pwq-f4pq-78gm، وبطاقتان: لماذا GitLab CI لأن سيرفرات CI تحمل أسرار النشر، وتكرر في 2025 مع faster_log وasync_println
شرطة سفلية واحدة بين المكتبة الحقيقية والمكتبة الخبيثة

لاحظ ذكاء الاختيار: المكتبة الخبيثة تعمل تماماً مثل الأصلية، فلن يلاحظ المطور أي فرق، والاختبارات تنجح، والكود الخبيث لا يعمل على جهاز المطور أصلاً، وإنما في CI فقط، حيث توجد أسرار النشر والتوكنات ومفاتيح السحابة، وحيث تشغل الاختبارات كل الدوال تلقائياً. والقصة لم تنته في 2022، ففي سبتمبر 2025 حذف فريق crates.io مكتبتين أخريين، faster_log وasync_println، نسختا مكتبة سجلات حقيقية وأضافتا كوداً يبحث في ملفات السجلات عن مفاتيح محافظ رقمية ويرسلها، بعد 7181 و1243 تنزيلاً، كما في إعلان crates.io عنهما.

ولنرى ماذا يرى المطور اليوم، سألنا السجل عن الاسمين، ثم حاولنا إضافة الاسم الخاطئ:

cargo search rust_decimal --limit 3
cargo search rustdecimal --limit 3
cargo add rustdecimal
rust_decimal = "1.43.0"           # Decimal number implementation written in pure Rust suitable for financial and fixed-precision…
rust_decimal_macros = "1.40.0"    # Shorthand macros to assist creating Decimal types.
nutype = "0.8.0"                  # The newtype with guarantees.
... and 3907 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
cratestack-macros = "0.17.0"    # Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services…
decimal-bytes = "0.6.0"         # Arbitrary precision decimals with lexicographically sortable byte encoding
helios-fhir = "0.2.6"           # The helios-fhir crate is the comprehensive FHIR (Fast Healthcare Interoperability Resources) sp…
... and 7 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
    Updating crates.io index
error: the crate `rustdecimal` could not be found in registry index.

الاسم الخاطئ لم يعد موجوداً، والبحث عنه يعطي نتائج لا علاقة لها، وcargo add يرفض. ولكن هذا لأن الحزمة حذفت بعد اكتشافها، والخطر في الأيام أو الأشهر التي تسبق الاكتشاف. وفي npm تجد أثر قضية مشابهة قديمة، وهي الحزمة crossenv التي انتحلت اسم cross-env في 2017 كما شرحنا في مقال أمن سلسلة التوريد، فقد استبدلها npm بحزمة فارغة حتى لا ينشر أحد بالاسم من جديد:

npm view crossenv versions description
versions = [ '0.0.1-security', '0.0.2-security', '1.0.0', '1.0.1' ]
description = 'security holding package'

الوصف security holding package والإصدارات المنتهية بكلمة security هي ما يضعه npm مكان الحزم التي حذفها لأسباب أمنية، حسب سياسة الأمان في npm. والدروس العملية من القضيتين:

  • لا تكتب اسم مكتبة من ذاكرتك، وانسخه من التوثيق الرسمي للمكتبة أو من صفحتها في السجل، وتأكد من عدد التنزيلات وتاريخ أول إصدار ورابط المستودع.
  • مراجعة الكود Code Review يجب أن تنتبه لأي اعتمادية جديدة في ملف التعريف كأنها كود جديد، لأنها كذلك فعلاً.
  • أدوات فحص الاعتماديات تعرف الحزم الخبيثة المعروفة، فالنشرة RUSTSEC-2022-0042 مسجلة في قاعدة OSV باسم MAL-2022-1، وقد شرحنا تشغيل هذه الأدوات في مقال فحص الاعتماديات SCA.
  • بيئة CI هي الهدف الأول، فاعطها أقل الصلاحيات، وأسراراً قصيرة العمر، وشغل التثبيت دون سكربتات حيث أمكن.

التثبيت القابل للتكرار Reproducible Installs في CI

جمعنا ما رأيناه في الأدوات الأربع في صورة واحدة، والفكرة في كل الحالات: ملف القفل في المستودع، والتثبيت في CI يرفض أي اختلاف عنه بدل أن يحل الشجرة من جديد:

مقارنة: دون قفل يعطي النطاق نفسه إصداراً مختلفاً على جهاز المطور في يناير وسيرفر CI في مارس، وأي إصدار خبيث داخل النطاق يدخل تلقائياً، ومع القفل والتثبيت الصارم npm ci يعطي نفس الشجرة، والقفل غير المطابق يرفضه npm ci بالخطأ EUSAGE وcargo --locked، والهاش غير المطابق يرفضه pip --require-hashes، وتحتها الهاش في كل ملف قفل
النطاق يقول ما تقبل به، والقفل يقول ما اخترته فعلاً، والتثبيت الصارم يفرض القفل
الأداةعلى جهاز المطورفي CIماذا تحفظ في المستودع
npmnpm installnpm ci --ignore-scripts مع تشغيل السكربتات اللازمة صراحةpackage.json وpackage-lock.json
pippip install -r requirements.in داخل venvpip install --require-hashes -r ملف بهاشات كاملة، أو التثبيت من pylock.toml بأداة تدعمهملف المتطلبات والملف المقفل
Mavenmvn packagemvn -B package مع قاعدة dependencyConvergence وإصدارات ثابتة للإضافاتpom.xml
Cargocargo buildcargo build --lockedCargo.toml وCargo.lock

والتثبيت القابل للتكرار يحل نصف المشكلة فقط: يضمن أنك تبني اليوم نفس ما اختبرته أمس، ولكنه لا يضمن أن ما اخترته أمس آمن. والنصف الآخر هو التحديث المنتظم للقفل بأداة مثل Renovate أو Dependabot مع مراجعة كل تحديث وفحصه، وهذا ما شرحناه بالتفصيل في مقال فحص الاعتماديات SCA. وإذا كانت عندك حزم داخلية بأسماء خاصة، فهناك هجوم آخر اسمه الخلط بين الحزم Dependency Confusion، حيث ينشر المهاجم في السجل العام حزمة بنفس اسم حزمتك الداخلية برقم إصدار أعلى، وحله مصدر واحد للحزم Proxy Registry وأسماء بنطاق Scope تملكه، كما في مقال أمن سلسلة التوريد.

وهناك سؤال أخير يظهر في المؤسسات التي تبني على سيرفرات لا تصل إلى الإنترنت، أو تريد أن تبني نفس الإصدار بعد خمس سنوات حتى لو حذفت حزمة من السجل: هل يكفي القفل؟ والإجابة لا، فالقفل يحفظ اسم الحزمة وهاشها، ولكنه لا يحفظ الحزمة نفسها، فإذا اختفت من السجل فشل التثبيت مع أن القفل سليم. والحل أن تحتفظ بنسخة من الحزم نفسها، إما بسجل داخلي يعمل كوسيط Proxy ويحفظ كل ما مر به، مثل الأدوات التي ذكرناها في مقال أمن سلسلة التوريد، وإما بنسخ الاعتماديات داخل المشروع Vendoring، مثل الأمر cargo vendor في Cargo الذي ينسخ مصادر كل الحزم إلى مجلد في المستودع، أو pip download الذي نزل به ملف idna في تجربتنا ثم التثبيت بالخيار --no-index من ذلك المجلد. والسجل الداخلي هو الأنسب لأغلب الفرق، لأنه يخدم كل المشاريع مرة واحدة ولا يضخم المستودعات، ويعطيك مكاناً واحداً تمنع فيه حزمة معروفة الخطر قبل أن تصل إلى أي جهاز.

قائمة تحقق Checklist لكل مشروع جديد

  • ملف القفل محفوظ في المستودع، وCI يثبت منه بالوضع الصارم: npm ci أو --locked أو --require-hashes.
  • النطاقات في ملف التعريف لها حد أعلى، ولا يوجد >= مفتوح ولا *.
  • كل اعتمادية جديدة تمر بمراجعة: الاسم منسوخ من التوثيق الرسمي، والمستودع موجود ونشط، وعدد التنزيلات وتاريخ النشر منطقيان.
  • Python في venv لكل مشروع، لا في Python النظام ولا بالخيار --break-system-packages.
  • سكربتات التثبيت في npm معطلة افتراضياً في CI، وتشغل صراحة للحزم التي تحتاجها.
  • في Maven قاعدة dependencyConvergence مفعلة، والإصدارات المشتركة معلنة في dependencyManagement.
  • في Cargo يعرض cargo tree -d دورياً، وتعالج الإصدارات المكررة الثقيلة.
  • الذاكرة المؤقتة لمدير الحزم محفوظة بين تشغيلات CI لتسريع التثبيت، والقفل هو مفتاحها.
  • فحص للاعتماديات من الثغرات والحزم الخبيثة المعروفة في كل بناء، وتحديث منتظم للقفل مع مراجعة.

أخطاء شائعة Common Mistakes

  • عدم حفظ ملف القفل في المستودع، أو حذفه عند كل مشكلة: كل تثبيت بعدها يحل الشجرة من جديد بإصدارات لم يختبرها أحد.
  • استخدام npm install في CI بدل npm ci: الأول قد يعدل القفل بصمت، والثاني رفض الاختلاف بخطأ EUSAGE واضح.
  • الظن أن الاعتمادية الواحدة حزمة واحدة: express أدخلت 68 حزمة، وخمس مكتبات Rust أدخلت 187.
  • تجاهل قاعدة ما قبل 1.0: ^0.2.3 لا يقبل 0.3.0، وreqwest = "0.12" لن يصل إلى 0.13 دون تعديل يدوي.
  • تجاوز خطأ externally-managed-environment بالخيار --break-system-packages بدل بيئة افتراضية.
  • ترك Maven يختار إصدار المكتبات المشتركة: ترتيب سطرين اختار slf4j-api 1.7.36، فاختفت كل السجلات والبناء ناجح.
  • تجاهل التحذيرات في أول أسطر التشغيل: Defaulting to no-operation (NOP) logger كان التنبيه الوحيد.
  • تشغيل التثبيت في CI بصلاحيات كاملة وأسرار متاحة: سكربت postinstall عمل كـ root ومعه HOME كاملاً.
  • كتابة اسم مكتبة من الذاكرة: rustdecimal بدل rust_decimal كانت كافية لتشغيل برنامج خبيث في GitLab CI.
  • الظن أن القفل يعني الأمان: القفل يثبت ما اخترته، ولا يقول إن ما اخترته خال من الثغرات.

حذف المختبر Cleanup

docker rmi scp5-pm:1
rm -rf lab/npm/node_modules lab/pip/.venv lab/pip/wheels lab/cargo/target lab/maven/target

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • كل مدير حزم يمر بنفس الخطوات: ملف تعريف تكتبه أنت، وحل يختار الإصدارات، وقفل يحفظ الاختيار بالهاش، وتنزيل من سجل عام، وتحقق، وتثبيت قد يشغل كوداً.
  • الإصدار الدلالي وعد من صاحب الحزمة، والنطاق ^ يقبل كل ما لا يغير الرقم الرئيسي، وقبل 1.0 يصبح الرقم الثاني هو الرئيسي.
  • اعتمادية واحدة في npm أدخلت 68 حزمة، وnpm يسمح بإصدارين من نفس الحزمة، وnpm ci أعطى نفس الشجرة ورفض القفل غير المطابق.
  • سكربت postinstall عمل كـ root لحظة التثبيت ومخرجه مخفي افتراضياً، و--ignore-scripts يمنعه.
  • Python النظام في Debian 13 يرفض pip بسبب PEP 668، والحل venv لكل مشروع، وpip lock يكتب الآن pylock.toml الموحد، ووضع الهاش رفض ملفاً لا يطابق.
  • Maven دون قفل، ويختار إصداراً واحداً بقاعدة الأقرب ثم الأسبق، فأخفى تعارض slf4j-api كل السجلات، والإصلاح dependencyManagement مع قاعدة dependencyConvergence.
  • Cargo يكتب القفل لكل المنصات، 187 حزمة منها 152 على Linux، وcargo tree -d كشف إصدارين من syn، و--locked رفض التغيير.
  • قضية rustdecimal في 2022 وقضيتا 2025 تبين أن حرفاً واحداً في الاسم يكفي، وأن CI هو الهدف، وأن نسخ الأسماء من التوثيق ومراجعة كل اعتمادية جديدة جزء من الأمان.
دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.دليلاللغة شيء والمترجم شيء آخر: من يقرر مستقبل لغات البرمجة؟لماذا لا تصح جمل مثل «Python بطيئة»، وما الفرق بين مواصفة اللغة والبرامج التي تطبقها، ومن يقرر مستقبلها.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من Debian 13.7 (trixie) مع Node.js 22.23.4 وnpm 10.9.9 وPython 3.13.5 وpip 25.1.1 وApache Maven 3.9.9 مع OpenJDK 21.0.13 وcargo 1.85.1، على Docker Engine 29.8.0.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.