تناولت الدروس التسعة السابقة كل بُعد من أبعاد أمان الحاوية بمعزل عن غيره. يجمع هذا المشروع الختامي جميعها في سير عمل واحد متكامل من البداية إلى النهاية: أخذ واجهة برمجية Node.js واقعية ودفعها عبر التصغير متعدد المراحل، وفحص الصورة، وتوقيع سلسلة التوريد، وتصليب التشغيل بمستخدم غير root. النتيجة هي صورة إنتاجية يمكن الدفاع عنها في أي مراجعة أمنية لدى أي شركة من الشركات الكبرى.
سنعمل مع بنية تطبيق ملموسة ونطبق كل خطوة تصليب بالترتيب، مع عرض الأوامر الدقيقة والتحسين القابل للقياس بعد كل خطوة ونقاط الاندماج مع مسار CI/CD.
نقطة البداية: الصورة الساذجة
تبدأ معظم الفرق من هنا — ملف Dockerfile أحادي المرحلة مبني على صورة Node الرسمية الكاملة ويعمل بصلاحيات root:
# قبل التصليب — الخط الأساسي الساذج وغير الآمن
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]
التقييم السريع لهذه الصورة يكشف ثلاث مشاكل فورية: حجمها نحو 1.1 غيغابايت، وتعمل بمستخدم root (معرّف المستخدم 0)، وتشمل جميع تبعيات التطوير إلى جانب سلسلة أدوات npm الكاملة. يجد Grype وحده نحو 300 ثغرة CVE في الصورة الأساسية فقط.
الخطوة 1 — البناء متعدد المراحل لتقليص سطح الهجوم
التحويل الأول يفصل أدوات وقت البناء عن أصول وقت التشغيل. تشمل node_modules الإنتاجية فقط الحزم المُدرجة تحت dependencies في package.json، وليس devDependencies.
# syntax=docker/dockerfile:1.7
# ── المرحلة 0: تثبيت جميع التبعيات + البناء (ترجمة TypeScript / تحزيم الأصول) ──
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY src ./src
RUN npm run build # tsc تُخرج JavaScript المُترجَم إلى /app/dist
# ── المرحلة 1: تثبيت تبعيات الإنتاج فقط ──────────────────────────────────
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
# ── المرحلة 2: بيئة تشغيل مُصغَّرة ──────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
ينخفض حجم الصورة من ~1.1 غيغابايت إلى ~180 ميغابايت. وينخفض عدد ثغرات CVE بنحو 70 % لأن سلسلة أدوات البناء اختفت. لكننا لا نزال نعمل بصلاحيات root — لننتقل إلى الخطوة التالية.
الخطوة 2 — مستخدم غير root ونظام ملفات للقراءة فقط
تشتمل صورة node:20-alpine على مستخدم node (معرّف المستخدم 1000) مُنشأ مسبقًا. كل ما نحتاجه هو تملّك الملفات والتبديل إليه قبل نقطة الدخول:
# تكملة المرحلة 2 — إضافة المستخدم وتصليب نظام الملفات
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# تغيير ملكية الملفات للمستخدم غير الجذر
RUN chown -R node:node /app
USER node
# تعريف مجلد مؤقت للكتابة إن احتاجها التطبيق
VOLUME ["/tmp"]
EXPOSE 3000
CMD ["node", "dist/index.js"]
عند النشر على Kubernetes، أضف SecurityContext لتصليب الحاوية أكثر:
لماذا يهم readOnlyRootFilesystem: true. الحاوية ذات نظام ملفات الجذر القابل للكتابة يمكن استغلالها لاستبدال ملفها الثنائي، أو تثبيت باب خلفي، أو كتابة مهمة cron. نظام الملفات للقراءة فقط يُجبر جميع الكتابات على وحدات التخزين الصريحة مثل emptyDir أو وحدات التخزين الدائمة القابلة للتدقيق. التطبيق الذي لا يستطيع العمل مع نظام ملفات للقراءة فقط يحمل افتراضًا خفيًا يستحق الإصلاح على مستوى الكود.
الخطوة 3 — الفحص قبل الرفع
ادمج Grype في مسار CI بحيث يفشل البناء الذي يحمل ثغرات حرجة قبل وصول الصورة إلى السجل:
# بناء الصورة
docker build --tag myapi:$GIT_SHA .
# الفحص — الخروج بقيمة غير صفرية إذا وُجدت ثغرة CRITICAL أو HIGH
grype myapi:$GIT_SHA \
--fail-on critical \
--output table
# إنتاج SBOM أولاً ثم فحصه (يتجنب إعادة جلب الصورة للتدقيق اللاحق)
syft myapi:$GIT_SHA -o spdx-json > sbom.spdx.json
grype sbom:sbom.spdx.json --fail-on critical
لا تكتفِ بالفحص وقت البناء. تُكشف الثغرات باستمرار. صورة تجتاز الفحص اليوم قد تصبح حرجة الأسبوع المقبل. شغّل عمليات إعادة فحص مجدولة ليلية على جميع الصور العاملة في الإنتاج باستخدام فحص السجل المدمج (ECR Inspector أو GCR Artifact Analysis أو مهايئ Trivy في Harbor) وأرسل تنبيهات لقناة الأمان عند اختراق حد الخطورة.
الخطوة 4 — التوقيع بـ Cosign (Sigstore)
يسد التوقيع الثغرة في سلسلة التوريد بين CI والإنتاج. بدون توقيع، لا شيء يمنع أحدًا من رفع صورة مختلفة تحت الوسم نفسه في سجلك. مع التوقيع بلا مفاتيح من Cosign (المدعوم بسجل شفافية Sigstore)، تحمل كل صورة إيصالًا مشفرًا يربطها بتشغيل سير العمل المحدد في GitHub Actions الذي أنتجها.
# تثبيت cosign (عبر brew أو تحميل الملف الثنائي)
# brew install cosign
# في CI — التوقيع بلا مفاتيح (رمز OIDC من GitHub Actions)
# يستخدم cosign متغير ACTIONS_ID_TOKEN_REQUEST_URL تلقائيًا
# رفع الصورة أولاً
docker push ghcr.io/org/myapi:$GIT_SHA
# التوقيع (بلا مفاتيح — لا إدارة مفاتيح مطلوبة، يستخدم Fulcio + Rekor)
cosign sign --yes ghcr.io/org/myapi:$GIT_SHA
# التحقق على أي جهاز (استبدل مُصدر OIDC والموضوع المتوقعَين)
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/org/myapi/.github/workflows/build.yml@refs/heads/main" \
ghcr.io/org/myapi:$GIT_SHA | jq .
في Kubernetes، طبّق هذا عند القبول باستخدام Policy Controller أو Kyverno، لترفض الصور غير الموقعة قبل وصولها إلى أي عقدة:
يُظهر المخطط التالي جميع المراحل الأربع مترابطة في سير عمل CI/CD واحد، من دفع المطور إلى صورة موثقة ومتوافقة مع السياسات تعمل في الإنتاج.
مسار الصورة المُصلَّبة الكامل: كل مرحلة تعمل كبوابة للمرحلة التالية، وصولاً إلى صور مُصغَّرة ومفحوصة وموقعة وغير root في الإنتاج.
قائمة مراجعة التحقق
بعد بناء صورتك المُصلَّبة، مرّ عبر هذه القائمة قبل وضع علامة عليها بأنها جاهزة للإنتاج. في شركات مثل Google وNetflix، تُطبَّق هذه القائمة تلقائيًا في CI — فشل أي فحص يوقف النشر.
# 1. التحقق من عدم تشغيل العملية بصلاحيات root
docker run --rm myapi:$GIT_SHA id
# المتوقع: uid=1000(node) gid=1000(node) — وليس uid=0(root)
# 2. فحص حجم الصورة
docker image inspect myapi:$GIT_SHA --format '{{.Size}}' | numfmt --to=iec
# الهدف: أقل من 200 ميغابايت لخدمات Node.js، أقل من 30 ميغابايت للثنائيات Go/distroless
# 3. التأكد من غياب Shell في صورة التشغيل
docker run --rm --entrypoint sh myapi:$GIT_SHA -c "echo hi" 2>&1 || echo "No shell — good"
# 4. سرد العمليات التي تعمل بصلاحيات root داخل الحاوية
docker run --rm myapi:$GIT_SHA ps aux | grep root
# المتوقع: فارغ (فقط عملية تطبيقك تعمل بمعرّف المستخدم 1000)
# 5. التأكد من وجود SBOM
syft myapi:$GIT_SHA -o table | head -20
# 6. التحقق من صحة التوقيع
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/org/myapi/.github/workflows/.*" \
ghcr.io/org/myapi:$GIT_SHA
# 7. فحص الصورة النهائية
grype myapi:$GIT_SHA --fail-on high
ثبّت الصورة الأساسية بالمجزم الرقمي وليس بالوسم. الوسوم قابلة للتغيير — يمكن أن تتغير node:20-alpine دون تحذير حين يرفع المُشرف الأصلي تصحيحًا. ثبّت المجزم الرقمي غير القابل للتغيير في Dockerfile وحدّثه عمدًا عبر Dependabot أو Renovate:
FROM node:20-alpine@sha256:a7f5...
يعزز هذا أيضًا التحقق من توقيع cosign: فالمجزم الرقمي جزء من الحمولة الموقعة، لذا أي تلاعب بطبقة الصورة الأساسية يكسر التوقيع.
حالات الفشل الإنتاجية التي يجب معرفتها
حتى الصور المُصلَّبة جيدًا تظهر مفاجآت في وقت التشغيل. هذه أكثر حالات الفشل شيوعًا التي تواجهها الفرق بعد التبديل إلى هذا الإعداد:
التطبيق يكتب في /app وقت التشغيل. تكتب كثير من الأطر ملفات القفل أو القوالب المُترجَمة أو مجلدات تحميل مؤقتة داخل مجلد العمل. مع readOnlyRootFilesystem: true، تنتج هذه الكتابات أخطاءً حرجة. الحل: ثبّت وحدة emptyDir عند المسار القابل للكتابة المحدد ووجّه إعداد الإطار إليه بدلًا من مجلد العمل.
شهادات CA المفقودة. تشتمل Alpine على حزمة ca-certificates، لكن إذا بدأت من scratch أو متغيرة distroless مُجرَّدة، تفشل اتصالات TLS بالخدمات الخارجية مع ظهور خطأ certificate signed by unknown authority. الحل: COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/.
ربط المنفذ أقل من 1024 بمستخدم غير root. تتطلب المنافذ 80 و443 صلاحية CAP_NET_BIND_SERVICE. تشغيل معرّف المستخدم 1000 بدون تلك الصلاحية ومحاولة ربط المنفذ 80 تعطي خطأ permission denied. الحل: ارتبط بالمنفذ 3000 (أو أي منفذ غير مميز) ودع خدمة Kubernetes أو موازن التحميل يتولى المنافذ 80 و443 خارجيًا.
معالجة الإشارات. حين تكون node هي PID 1 (وليس عملية init)، قد لا ينتقل SIGTERM القادم من kubectl rollout restart بشكل صحيح إلى العمليات الفرعية، مما يُخلّف عمليات ميتة. الحل: استخدم ["dumb-init", "node", "dist/index.js"] — أضف dumb-init من Alpine وعيّنه غلافًا لنقطة الدخول.
تصليب الأمان ليس حدثًا لمرة واحدة. يمكن أن تُبطَل الصورة المُصلَّبة المبنية اليوم في السبرينت القادم إذا أضاف مطور USER root لإصلاح مشكلة في الصلاحيات، أو رفّع صورة أساسية دون إعادة الفحص. طبّق هذه الضوابط في CI وفي webhooks القبول وفي أداة أمان وقت التشغيل (Falco أو Sysdig). عامل الحاوية العاملة التي تُولّد shell أو تكتب في مسارات غير متوقعة كحادثة أمنية وليس كإعداد خاطئ.
لديك الآن مسار قابل للتكرار والتدقيق من الكود المصدري إلى صورة إنتاجية تجتاز المراجعة الأمنية بمعايير الشركات الكبرى: مُصغَّرة (180 ميغابايت مقابل 1.1 غيغابايت)، وغير root (معرّف المستخدم 1000)، ومفحوصة (صفر ثغرات حرجة)، وموقعة (cosign + Rekor)، ومطبَّقة بالسياسات (Kyverno للقبول). شغّل هذا السير على كل دمج إلى الفرع الرئيسي يمنحك وضعًا أمنيًا مستمرًا وقابلًا للقياس، لا تدقيقًا في لحظة زمنية محددة.
نستخدم ملفات تعريف الارتباط لتشغيل هذا الموقع وتحليل الزيارات وعرض إعلانات مخصّصة. يمكنك قبول كل ملفات تعريف الارتباط أو رفض غير الأساسية منها.
سياسة الخصوصية