الأدلة التقنية

فحص ال Docker Images وملفات البنية ككود IaC

ماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.

فحص ال Docker Images وملفات البنية ككود IaC

في دليل DevSecOps للمبتدئين فحصنا ال Docker Image الخاصة بتطبيقنا التجريبي بأداة Trivy، فوجد 42 ثغرة من مستوى HIGH وCRITICAL في حزم النظام، ومعها تحذير يقول إن Debian 10 انتهى دعمه وإن الفحص نفسه قد يكون ناقصاً، ولم يقل شيئاً عن أن التطبيق يعمل بصلاحيات root لأننا طلبنا منه الثغرات فقط. وفي هذا المقال سوف نكمل من تلك النقطة، فالسؤال هنا ليس «هل نفحص ال Image؟» وإنما ماذا يرى الفاحص داخلها وماذا يفوته، ولماذا تعطيك أداتان رقمين مختلفين للـ Image نفسها، وكيف تختار ال Base Image، وكيف تجعل ال Container أقل فائدة للمهاجم إذا وصل إليه، ثم كيف تفحص ملفات البنية ككود Infrastructure as Code قبل أن تتحول إلى سيرفرات حقيقية.

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

  • لماذا يفشل فحص ال Image مرة واحدة قبل النشر، ولماذا لا يصلح عدد النتائج وحده لاختيار ال Base Image.
  • الفرق بين حزم النظام OS Packages وحزم اللغة Language Packages داخل ال Image، وما الذي لا يراه أي فاحص.
  • كيف يقرر Trivy وGrype أن الحزمة مصابة: قاعدة بيانات الحزم Package Database، ونشرات التوزيعة Distro Advisories، والإصلاحات المنقولة Backports، وحالة «لن يصلح» Won't Fix.
  • اختيار ال Base Image بين Debian slim وAlpine وdistroless وUbuntu Chiselled وChainguard، بأرقام من فحص حقيقي في يوم واحد.
  • البناء متعدد المراحل Multi-stage Build، والمستخدم العادي Non-root، ونظام الملفات للقراءة فقط Read-only، وإسقاط الصلاحيات Capabilities.
  • فحص ال Dockerfile بأداة Hadolint، وفحص ملفات Terraform وKubernetes وDocker Compose بأدوات Checkov وKICS وTrivy.
  • تجربة عملية كاملة على التطبيق التجريبي، مع إنذارات خاطئة False Positives نعالجها بالطريقة الصحيحة، واستثناءات تنتهي صلاحيتها، وبوابة Gate في خط CI بأرقام خروج Exit Codes حقيقية.

لماذا تحتاج ال Docker Image فحصاً خاصاً بها؟

ال Docker Image ليست كودك فقط، فهي نظام تشغيل صغير فيه عشرات الحزم، ومفسر لغة Runtime، ومكتبات ثبتها pip أو npm، ثم كودك في الطبقة الأخيرة. وأغلب ما فيها لم يكتبه فريقك ولم يراجعه أحد منه، وقد شرحنا في أمان سلسلة التوريد البرمجية لماذا تعتبر ال Base Image أكبر اعتمادية في مشروعك وأقلها مراجعة. والسؤال العملي هو كيف تفحصها، ومتى، وكيف تقرأ النتيجة.

الطريقة البسيطة: نفحص ال Image مرة واحدة قبل النشر

أول ما يفعله أغلب الفرق هو أن يضيف أمر فحص بعد docker build، فإذا نجح الفحص نشرت ال Image ونسيها الجميع. وهذه الطريقة تفشل لسببين، الأول أن قاعدة بيانات الثغرات تتغير كل يوم، فال Image التي كانت نظيفة يوم النشر قد يظهر فيها غداً عشر ثغرات دون أن يتغير فيها بايت واحد، والثاني أن ال Image نفسها تكبر في العمر، فال Base Image python:3.8-slim-buster التي يستخدمها تطبيقنا بنيت آخر مرة في 6 يونيو 2023 كما يظهر من الأمر docker image inspect، أي أن كل إصلاح نشرته Debian بعد ذلك التاريخ لم يصل إليها، وقد انتهت التحديثات الأمنية لإصدار buster في 30 يونيو 2022 ثم انتهى الدعم طويل المدى LTS في 30 يونيو 2024.

الطريقة الثانية: نختار ال Base Image صاحبة أقل عدد من النتائج

والحل الثاني الذي يخطر على البال هو أن نفحص عدة Base Images ونختار صاحبة الرقم الأصغر. وسوف ترى في التجربة أن هذا الرقم يخدع من الجهتين، فال Image القديمة python:3.8-slim-buster ظهر فيها 125 نتيجة في حزم النظام، بينما ظهر في python:3.13-slim الحديثة 165 نتيجة، والسبب أن التوزيعة لم تعد تتابع ثغرات buster فلا تظهر الثغرات الجديدة فيه أصلاً، وال Image المقطعة Chiselled من Ubuntu ظهر فيها صفر لأن Trivy لم ير فيها حزمة واحدة. فالرقم وحده لا يقول شيئاً قبل أن تعرف ماذا رأى الفاحص وماذا لم ير.

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

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

  • ثغرات ال Image Image Vulnerabilities: فحص الحزم داخل ال Image بعد البناء بأداة Trivy أو Grype، وبوابة توقف ما يمكن إصلاحه الآن فقط.
  • إعداد ال Dockerfile Dockerfile Configuration: فحص الملف نفسه قبل البناء بأداة Hadolint وفحص الإعداد trivy config، لأن المستخدم root وال Base Image القديمة يظهران في الملف قبل أن يظهرا في ال Image.
  • إعدادات التشغيل Runtime Settings: المستخدم العادي، ونظام الملفات للقراءة فقط، وإسقاط الصلاحيات، في docker run أو في Compose أو Kubernetes، وهذه لا يراها فاحص ال Image لأنها ليست داخلها.
  • البنية ككود Infrastructure as Code: فحص ملفات Terraform وKubernetes وCompose بأدوات Checkov وKICS وTrivy قبل أن تتحول إلى سيرفرات وشبكات حقيقية.

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

حزم النظام OS Packages وحزم اللغة Language Packages داخل ال Image

الفاحص لا يقرأ ال Image كما يقرؤها الإنسان، وإنما يبحث في طبقاتها Layers عن ملفات يعرف شكلها وتسجل ما ثبت فيها، ثم يقارن ما وجده بقاعدة بيانات الثغرات. والصورة التالية تبين طبقات ال Image الخاصة بتطبيقنا، وما رآه كل فاحص في كل طبقة:

أربع طبقات لل Image lab-dso-cont-app:1.0 من الأعلى إلى الأسفل: كود التطبيق app.py، ثم حزم اللغة في site-packages مثل Flask 2.2.2، ثم Python 3.8.17 المبني من المصدر في /usr/local دون سجل في dpkg، ثم حزم Debian 10 في /var/lib/dpkg/status وعددها 93، وأمامها عمودان: Trivy وجد 29 نتيجة في حزم اللغة ولم ير Python المبني من المصدر و125 نتيجة في حزم النظام، وGrype وجد 29 في حزم اللغة و69 في ملف Python التنفيذي بمطابقة CPE و199 في حزم النظام منها 75 Negligible، ولا يفحص أي منهما الكود نفسه
الفاحص يرى ما سجل في مكان يعرف كيف يقرؤه، وما لم يسجل قد لا يراه أبداً

في الصورة أعلاه لاحظ التالي:

  • حزم النظام OS Packages: الحزم التي ثبتها مدير حزم التوزيعة، مثل apt في Debian وapk في Alpine، وكل منها مسجل باسمه ونسخته في قاعدة بيانات الحزم، وهي في Debian الملف /var/lib/dpkg/status. وهذه هي الطبقة التي يملكها هذا المقال، والحل فيها غالباً هو إعادة البناء أو تغيير ال Base Image.
  • حزم اللغة Language Packages: المكتبات التي ثبتها pip أو npm أو Maven، وكل منها يترك ملفاً يصفه، مثل METADATA في Python. وهذه نفسها هي الثغرة VULN-06 في التطبيق التجريبي، وفحصها وتحديثها موضوع مقال فحص الاعتماديات SCA، وما يضيفه فحص ال Image هنا أنه يرى ما ثبت فعلاً داخلها، ومنه مكتبات جاءت مع ال Base Image ولا تظهر في ملف requirements.txt مثل setuptools وwheel.
  • البرامج المبنية من المصدر: ال Images الرسمية لـ Python تبني المفسر من الكود المصدري وتضعه في /usr/local، فلا يوجد له سجل في dpkg، وتقول صفحة Debian في توثيق Trivy إن البرامج المبنية بالأمر make لا تكتشف عادة. وفي تجربتنا لم يذكر Trivy مفسر Python 3.8.17 أبداً، بينما تعرف عليه Grype من الملف التنفيذي نفسه وطابقه برقم نسخته مع قاعدة NVD فأخرج 69 نتيجة.
  • كودك أنت: لا يفحصه أي من الأداتين، فثغرات مثل حقن SQL وXSS مكانها الفحص الثابت SAST، والأسرار المنسية في الطبقات مكانها فحص الأسرار في طبقات ال Image.

كيف يعرف Trivy وGrype أن الحزمة مصابة؟

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

أربع خطوات مرقمة: قائمة الحزم فيها libdb5.3 بنسخة 5.3.28+dfsg1-0.5، ثم إصدار التوزيعة debian 10.13 مع EOSL true، ثم البحث في Debian Security Tracker عن الحزمة المصدرية db5.3 في buster والثغرة CVE-2019-8457 وعليها ignored لأن الكود موجود وغير مستخدم في Debian، ثم الحالة في التقرير بأربعة ألوان: fixed وaffected وwill_not_fix وend_of_life، وتحتها مقارنة بين رؤية النسخة الأصلية python 3.12.3 أصغر من 3.12.11 فهي مصابة، ورؤية حزمة Ubuntu python3.12 3.12.3-1ubuntu0.17 التي نقلت إليها الإصلاحات
الفاحص يسأل التوزيعة عن نسختها هي، لأن رقم النسخة الأصلي يخدع مع الإصلاحات المنقولة

قاعدة بيانات الحزم Package Database ونسخة التوزيعة

الخطوة الأولى أن يحدد الفاحص التوزيعة وإصدارها من ملفات مثل /etc/os-release، ثم يقرأ قاعدة بيانات الحزم الخاصة بها، فيعرف أن الحزمة libdb5.3 مثبتة بالنسخة 5.3.28+dfsg1-0.5، وأنها مبنية من الحزمة المصدرية Source Package واسمها db5.3. والحزمة المصدرية مهمة لأن التوزيعة تنشر الثغرات عليها وليس على كل حزمة ثنائية Binary Package تخرج منها، ولهذا ترى في المخرج الثغرة نفسها مكررة على libc6 وlibc-bin، أو على أربع حزم Python في distroless، فالعدد هنا عدد الصفوف وليس عدد الثغرات المختلفة.

نشرات التوزيعة Distro Advisories، ولماذا يخدع رقم النسخة الأصلي

التوزيعات لا ترفع رقم النسخة الأصلي Upstream Version عندما تصلح ثغرة، وإنما تنقل الإصلاح Backport إلى النسخة نفسها وتضيف رقماً في آخرها. فال Image الرسمية ubuntu/python:3.12-24.04_stable مثلاً فيها الحزمة python3.12 بالنسخة 3.12.3-1ubuntu0.17، أي أن Ubuntu أصدرت لها سبعة عشر تحديثاً مع بقاء الرقم الأصلي 3.12.3. والفاحص الذي يقارن بالرقم الأصلي سوف يقول إنها مصابة بكل ثغرة أصلحت في Python بعد 3.12.3، ولهذا يعتمد Trivy على نشرات التوزيعة نفسها لحزم النظام، كما تشرح صفحة فحص الثغرات في توثيقه، ويعتمد على GitHub Advisory Database للحزم التي ثبتها pip.

وفي Debian مصدر هذه المعلومات هو Debian Security Tracker، وهو الذي يقول لكل ثغرة وكل إصدار من التوزيعة إن كانت أصلحت وفي أي نسخة، أو إنها ما زالت مفتوحة، أو إن الفريق قرر ألا يصلحها ولماذا.

حالة الثغرة Status: ما الذي يمكنك فعله بها؟

كل صف في تقرير Trivy عليه حالة Status، وهي أهم من الخطورة Severity عند اتخاذ القرار، والسبب أنها تقول لك هل يوجد شيء تفعله الآن أم لا. وتشرح صفحة التصفية Filtering الحالات التالية:

  • fixed: يوجد إصلاح في التوزيعة، وإعادة البناء أو تحديث الحزمة تكفي، وهذه هي النتائج التي يجب أن توقف خط CI.
  • affected: الحزمة مصابة ولم يصدر إصلاح بعد، وهنا تتابع ولا تستطيع أن تفعل شيئاً في ال Image نفسها.
  • will_not_fix: التوزيعة قررت ألا تصلحها في هذا الإصدار، وغالباً لأن الخطر قليل أو لأن الإصلاح أخطر من الثغرة.
  • fix_deferred وend_of_life: الإصلاح مؤجل، أو الإصدار نفسه انتهى عمره.

لنأخذ مثالاً من التجربة: الثغرة CVE-2019-8457 في libdb5.3 ظهرت في Trivy بخطورة CRITICAL وحالة will_not_fix. وعندما تفتح صفحتها في Debian Security Tracker تجد بجانب buster الوسم <ignored> مع السبب: الكود المصاب موجود لكنه غير مستخدم في Debian، والإصلاح معقد وخطير لنقله إلى النسخة القديمة. وهذه هي المعلومة التي تحتاجها لاتخاذ القرار، فلا يوجد إصلاح سوف يأتي، والحل الوحيد هو الانتقال إلى إصدار أحدث من Debian، ولن تعرف ذلك من عمود الخطورة وحده.

لماذا تختلف الخطورة Severity بين أداتين؟

الثغرة نفسها CVE-2019-8457 ظهرت في Grype بخطورة Negligible، وفي Trivy بخطورة CRITICAL، والسبب أن كل أداة تختار مصدر الخطورة بطريقتها، فتقول صفحة Debian في توثيق Trivy إنه يأخذ درجة الاستعجال Urgency من Debian، فإذا لم تحدد Debian درجة أخذ خطورة NVD، ولهذا يطبع Trivy تحذيراً في بداية الفحص يقول Using severities from other vendors for some vulnerabilities. ورأينا العكس في ال Image الجديدة، فالثغرة CVE-2026-19445 في Python كانت عند Trivy بخطورة HIGH وحالة affected، وعند Grype بخطورة Critical وحالة won't fix، بينما تقول صفحتها في Debian إنها no-dsa (Minor issue) في trixie، أي أنها مشكلة صغيرة لن تصدر لها نشرة أمنية مستقلة. والدرس هنا أن البوابة التي تعتمد على كلمة CRITICAL سوف تتصرف بشكل مختلف إذا غيرت الأداة، لذلك اكتب سياسة البوابة على الحالة أولاً ثم الخطورة، وجربها على الأداة التي تستخدمها فعلاً.

الأدوات Tools: فحص ال Image وال Dockerfile والبنية ككود

الجدول التالي يجمع الأدوات التي استخدمناها في التجربة، وكلها مفتوحة المصدر، والترخيص من ملف LICENSE في مستودع كل أداة، والإصدار هو آخر إصدار في أكتوبر 2026:

الأداةماذا تفحصالترخيصالإصدارنقاط القوةنقاط الضعف
Trivyال Image وحزم اللغة والأسرار وملفات الإعداد Dockerfile وTerraform وKubernetes وHelmApache-2.00.75.0أداة واحدة لأغلب الفحوص، وحالة لكل ثغرة، وملف استثناءات بتاريخ انتهاء، وتعليقات تجاهل داخل ملفات Terraformلا ترى البرامج المبنية من المصدر، ولا تقرأ ملفات Docker Compose، وترسل بيانات استخدام مجهولة إلا مع الخيار --disable-telemetry كما تقول صفحة Telemetry
Grypeال Image والمجلدات وملفات SBOMApache-2.00.120.1يتعرف على البرامج التنفيذية مثل مفسر Python، ويطبع EPSS وKEV بجانب كل نتيجة، وقواعد تجاهل دقيقة بالحزمة والحالةقواعد التجاهل بلا تاريخ انتهاء، والمطابقة برقم النسخة الأصلي للبرامج التنفيذية تعطي إنذارات خاطئة مع الإصلاحات المنقولة
Hadolintال Dockerfile وأوامر shell داخلهGPL-3.02.15.1سريع جداً، وقواعد DL واضحة لأخطاء البناء الشائعة، وتجاهل داخل الملف بتعليقمدقق أسلوب Linter وليس فاحص أمان، فلم يعترض على Dockerfile ليس فيه سطر USER
CheckovTerraform وKubernetes وDockerfile وCloudFormation وغيرهاApache-2.03.3.24 (Docker Hub)أكبر عدد من القواعد لمزودي السحابة، وتجاهل بتعليق فيه السبب يظهر في التقريرلا يقرأ Docker Compose، والتصفية بالخطورة تحتاج مفتاح API من المنصة التجارية، ولم يعترض في تجربتنا على ربط قرص السيرفر كاملاً hostPath
KICSTerraform وKubernetes وDocker Compose وDockerfile وAnsible وغيرهاApache-2.02.1.20 (Docker Hub)الوحيد من الثلاثة الذي قرأ ملف Compose، ورمز خروج مختلف لكل مستوى خطورةأبطأ من غيره (41 ثانية في تجربتنا مقابل ثوان)، ويحاول جلب أوصاف القواعد من الإنترنت

وقد يتساءل البعض عن tfsec الذي تجده في كثير من المقالات القديمة، والإجابة أن Aqua Security أعلنت في مستودع tfsec نقل فحص Terraform إلى Trivy، وأن tfsec سوف يبقى متاحاً لكن جهد التطوير يذهب إلى Trivy، ولديها دليل للانتقال، لذلك لا ينصح بالبدء به في مشروع جديد. ولاحظ أيضاً أن آخر إصدار من Checkov على GitHub هو 3.3.25 وآخر إصدار من KICS هو 2.2.0، لكن ال Docker Images المنشورة على Docker Hub يوم التجربة كانت 3.3.24 و2.1.20، فاستخدمناها.

اختيار ال Base Image

ال Base Image تحدد أغلب ما يجده الفاحص، ولذلك فهي أهم قرار في ال Dockerfile. والصورة التالية تبين ست Base Images لـ Python فحصناها في اليوم نفسه وبقاعدة البيانات نفسها:

جدول من ستة أعمدة: python:3.8-slim-buster على Debian 10 المنتهي بحجم 180 ميجابايت و93 حزمة وفيه shell وapt وglibc والمستخدم root و125 نتيجة منها 53 لها إصلاح، وpython:3.13-slim على Debian 13 بحجم 176 و87 حزمة و165 نتيجة لا إصلاح لأي منها، وpython:3.13-alpine على Alpine 3.24 بحجم 79 و29 حزمة وmusl وصفر نتائج مع أن Python لم يظهر، وdistroless python3 على Debian 13 بحجم 91 و38 حزمة دون shell ودون مدير حزم والمستخدم 65532 و159 نتيجة لا إصلاح لها، وubuntu/python chiselled بحجم 74 وصفر حزم ظاهرة والمستخدم 584792، وchainguard python latest على Wolfi بحجم 101 و29 حزمة والمستخدم 65532 وصفر نتائج مع Python 3.14 فقط
الصف الأخير لا يقرأ وحده، فالصفر قد يعني أن الفاحص لم ير شيئاً

Debian slim: الخيار الافتراضي المعروف

ال Images التي تنتهي بـ -slim هي Debian بأقل الحزم، وفيها shell وapt، فتستطيع أن تثبت ما تحتاجه وأن تدخل إليها للتشخيص، وكل حزمة فيها لها سجل يقرؤه الفاحص. وعيبها أنها تحمل أدوات لا يحتاجها التطبيق، وأن Debian تترك ثغرات كثيرة صغيرة مفتوحة في الإصدار المستقر، ولهذا ظهر في python:3.13-slim 165 صفاً لا إصلاح لأي منها. وانتبه إلى اسم الإصدار في الوسم Tag، فالوسم bullseye مثلاً يعني Debian 11 الذي انتهى دعمه طويل المدى في 31 أغسطس 2026 بحسب صفحة LTS في Debian، أي أنه أصبح الآن في وضع buster نفسه.

Alpine وmusl: صغيرة جداً ولكن بمكتبة C مختلفة

Alpine أصغر بكثير، وفيها 29 حزمة فقط، ولم يجد فيها Trivy أي ثغرة في حزم النظام. ولكن لاحظ شيئين، الأول أن Python فيها مبني من المصدر أيضاً، فلم يره Trivy، بينما وجد Grype نتيجتين في ملفه التنفيذي، والثاني أن Alpine تستخدم مكتبة musl بدلاً من glibc، وتنبه صفحة Python على Docker Hub إلى أن البرامج قد تواجه مشاكل بحسب اعتمادها على glibc. وفي Python تحديداً قد لا تجد للمكتبة المترجمة نسخة جاهزة لـ musl، فيترجمها pip أثناء البناء ويحتاج gcc وملفات headers، والنتيجة بناء أبطأ وأحياناً سلوك مختلف في DNS والذاكرة، لذلك اختبر تطبيقك عليها قبل أن تعتمدها.

distroless: لا يوجد shell ولا مدير حزم

مشروع distroless من Google (ترخيص Apache-2.0) يبني Images فيها التطبيق ومكتبات تشغيله فقط، دون shell ودون apt، وهي مبنية الآن على Debian 13 trixie، ولكل منها وسم nonroot يعمل بالمستخدم 65532. والميزة الأمنية الحقيقية هنا ليست عدد النتائج، فقد رأيت أنه قريب من slim، وإنما أن المهاجم الذي يصل إلى التطبيق لا يجد shell يشغل فيه أوامره ولا مدير حزم يثبت به أدواته. والعيب أنك لا تستطيع أن تدخل إليها بالأمر docker exec للتشخيص، ولهذا يوفر المشروع وسماً debug فيه shell من busybox، وتنبه صفحة python3 إلى أن مرحلة البناء يجب أن تطابق إصدار التشغيل، أي python:3.13-slim-trixie مع python3-debian13، وإلا فقد تتعطل المكتبات المترجمة.

Ubuntu Chiselled: شرائح من حزم Ubuntu

Canonical تبني Images مقطعة Chiselled بأداة chisel (ترخيص AGPL-3.0 للأداة نفسها)، فتأخذ من كل حزمة Ubuntu الملفات التي يحتاجها التشغيل فقط، مثل ubuntu/python التي تعمل بمستخدم عادي ودون shell. ولكن انتبه إلى ما حدث في تجربتنا: هذه ال Image لا تحتوي على قاعدة بيانات dpkg، وإنما على ملف /var/lib/chisel/manifest.wall يسجل الحزم ونسخها، ولم يقرأه Trivy 0.75 ولا Grype 0.120، فأظهر Trivy صفر حزم، وطابق Grype ملف Python التنفيذي بالرقم 3.12.3 فأخرج 62 نتيجة، أغلبها إنذارات خاطئة لأن الحزمة الحقيقية هي 3.12.3-1ubuntu0.17 وفيها الإصلاحات المنقولة. فإذا اخترتها فتحقق أن الفاحص الذي تستخدمه يقرأ هذا الملف قبل أن تثق بالصفر.

Chainguard وWolfi: قليلة الثغرات، والمجاني منها latest فقط

Chainguard تبني Images على توزيعة Wolfi، ووصفات البناء فيها بترخيص Apache-2.0 والحزم بتراخيص مشاريعها، وتحدثها باستمرار، فلم يجد فيها Trivy أي نتيجة ووجد Grype ثماني نتائج لا إصلاح لها. ولكن تقول صفحة Chainguard الرسمية إن ال Images المجانية تقتصر على آخر بناء بالوسمين latest وlatest-dev، وإن الإصدارات المحددة جزء من الاشتراك التجاري. وهذا يعني عملياً أن cgr.dev/chainguard/python:latest كانت يوم التجربة Python 3.14.8، وأن تطبيقك سوف ينتقل إلى إصدار Python الجديد يوم يصدر، لذلك ثبتها بال Digest واجعل الانتقال بطلب دمج تختبره، كما شرحنا في ال Base Image الصغيرة التي يعاد بناؤها.

أي Base Image تختار؟

لا توجد إجابة واحدة، والأنسب لأغلب الفرق ما يلي: إذا كان الفريق جديداً على هذا الموضوع فابدأ بـ Debian slim من آخر إصدار مستقر مع مستخدم عادي، لأنها الأسهل في البناء والتشخيص والفحص، ثم انتقل إلى distroless بالبناء متعدد المراحل عندما يستقر خط البناء، ولا تختر Alpine لتطبيق Python فيه مكتبات مترجمة إلا بعد اختباره، ولا تختر Image لا يقرأ الفاحص محتواها ثم تعتبر الصفر نجاحاً. وأهم من كل ذلك أن تعيد البناء على جدول، فأحدث Base Image تصبح قديمة خلال أسابيع إذا لم يعاد بناء ال Image فوقها.

تجربة عملية Lab: من Image قديمة تعمل بصلاحيات root إلى Image صغيرة بمستخدم عادي

سوف نأخذ التطبيق التجريبي للسلسلة كما هو، وفيه الثغرة VULN-07: Base Image قديمة والتطبيق يعمل بصلاحيات root، ثم نفحصه بأداتين، ثم نبنيه مرتين بطريقة أفضل ونقارن، ثم نفحص ملفات البنية ككود، وننهي ببوابة في خط CI. وكل الأوامر تعمل في Docker، والبادئة lab-dso-cont-، والمنافذ من 19900 إلى 19904 على العنوان 127.0.0.1 فقط.

تجهيز نسخة التطبيق وذاكرة الأدوات Cache

ننسخ مجلد التطبيق، ونبني ال Image الأصلية، وننشئ Volume لكل أداة تحفظ فيه قاعدة بيانات الثغرات حتى لا تنزلها في كل تشغيل، وقد شرحنا تثبيت Docker نفسه في دليل تثبيت Docker:

cp -r content/labs/devsecops-app lab-cont/app && cd lab-cont/app
docker build -t lab-dso-cont-app:1.0 .
docker run -d --name lab-dso-cont-v10 -p 127.0.0.1:19900:8000 lab-dso-cont-app:1.0
curl -s http://127.0.0.1:19900/api/health
docker exec lab-dso-cont-v10 id

docker volume create lab-dso-cont-trivy-cache
docker volume create lab-dso-cont-grype-db

والمخرج سوف يكون كما يلي:

{"status":"ok"}
uid=0(root) gid=0(root) groups=0(root)

وفي الأوامر القادمة نعطي الفاحص الملف /var/run/docker.sock حتى يقرأ ال Image من Docker مباشرة. وانتبه إلى أن من يملك هذا الملف يملك Docker كله على السيرفر، وسوف ترى بعد قليل أن KICS يعتبر ربطه في Compose ثغرة من مستوى HIGH، لذلك استخدمه فقط مع Image أداة موثوقة بإصدار محدد، أو احفظ ال Image في ملف بالأمر docker save وافحص الملف كما فعلنا في المقال الرئيسي للسلسلة.

الفحص الأول: Trivy وGrype على ال Image نفسها

نشغل Trivy بكل مستويات الخطورة ونحفظ المخرج بصيغة JSON، ثم نشغل Grype على ال Image نفسها:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  -v lab-dso-cont-trivy-cache:/root/.cache aquasec/trivy:0.75.0 \
  image -q --scanners vuln --format json lab-dso-cont-app:1.0 > trivy-1.0.json

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  -v lab-dso-cont-grype-db:/grypedb -e GRYPE_DB_CACHE_DIR=/grypedb \
  anchore/grype:v0.120.1 lab-dso-cont-app:1.0 > grype-1.0.txt

ثم نعد الصفوف حسب الحالة في كل جزء من تقرير Trivy باستخدام jq:

jq -r '.Results[] | .Target as $t | [.Vulnerabilities[]?.Status] | group_by(.)
  | map("\(.[0])=\(length)") | "\($t): \(join(" "))"' trivy-1.0.json

والمخرج سوف يكون كما يلي:

lab-dso-cont-app:1.0 (debian 10.13): affected=35 fix_deferred=8 fixed=53 will_not_fix=29
Python: fixed=29

وهذه أول خمسة أسطر من جدول Grype، وهو يرتبها حسب عمود المخاطرة RISK:

NAME                INSTALLED               FIXED IN                                  TYPE    VULNERABILITY        SEVERITY    EPSS           RISK
libsystemd0         241-7~deb10u9           (won't fix)                               deb     CVE-2023-50387       High        100.0% (99th)  75.0
libudev1            241-7~deb10u9           (won't fix)                               deb     CVE-2023-50387       High        100.0% (99th)  75.0
libc-bin            2.28-10+deb10u2         2.28-10+deb10u3                           deb     CVE-2024-2961        High        88.3% (99th)   65.4
libc6               2.28-10+deb10u2         2.28-10+deb10u3                           deb     CVE-2024-2961        High        88.3% (99th)   65.4
libsystemd0         241-7~deb10u9           (won't fix)                               deb     CVE-2023-50868       High        73.7% (99th)   55.3   

وعندما عددنا صفوف Grype حسب نوع الحزمة بالطريقة نفسها كانت النتيجة binary=69 deb=199 python=29، أي 297 صفاً مقابل 154 في Trivy. وفي المخرج أعلاه لاحظ التالي:

  • 53 صفاً بحالة fixed في حزم النظام: أي أن Debian أصدرت لها إصلاحات قبل انتهاء الدعم، لكنها لم تصل إلى ال Base Image لأنها لم يعاد بناؤها منذ 2023. وهذا هو الرقم الذي يهمك، لأنه ما يمكن إصلاحه الآن.
  • 29 صفاً بحالة will_not_fix و35 بحالة affected: لن يصلحها أحد في buster، فالتحذير The vulnerability detection may be insufficient الذي رآه المقال الرئيسي معناه هنا بالضبط.
  • الصفوف من نوع binary في Grype: كلها لمفسر Python 3.8.17 في /usr/local/bin/python3.8، ومنها ثغرة حرجة واحدة CVE-2025-4517 في مكتبة tarfile، وهذه لم تظهر في Trivy أبداً لأنه لا يرى البرنامج المبني من المصدر.
  • الثغرة CVE-2023-50387 في libsystemd0: عند Grype حالتها won't fix وعند Trivy حالتها affected، فالأداتان تترجمان وسوم Debian إلى حالاتهما بطريقة مختلفة أيضاً، وليس الخطورة فقط.

فحص ال Dockerfile: Hadolint لم ير المشكلة

الآن نفحص ملف Dockerfile نفسه، والمتوقع أن تجد الأداة أن الملف لا يحتوي على سطر USER، فنشغل Hadolint:

docker run --rm -i hadolint/hadolint:v2.15.1 < Dockerfile; echo "exit=$?"

والمخرج سوف يكون كما يلي:

exit=0

لا توجد أي ملاحظة، والسبب أن Hadolint مدقق أسلوب Linter يتحقق من أخطاء كتابة ال Dockerfile، وقاعدته عن المستخدم DL3002 تعترض فقط إذا كتبت USER root صراحة في آخر الملف، أما غياب السطر كلياً فلا يعتبره خطأ. والآن نفحص المجلد نفسه بفحص الإعداد في Trivy:

docker run --rm -v "$PWD:/src:ro" -v lab-dso-cont-trivy-cache:/root/.cache \
  aquasec/trivy:0.75.0 config -q /src

والمخرج سوف يكون كما يلي:

Dockerfile (dockerfile)
=======================
Tests: 27 (SUCCESSES: 25, FAILURES: 2)
Failures: 2 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 1, CRITICAL: 0)

DS-0002 (HIGH): Specify at least 1 USER command in Dockerfile with non-root user as argument
════════════════════════════════════════
Running containers with 'root' user can lead to a container escape situation. It is a best practice to run containers as non-root users, which can be done by adding a 'USER' statement to the Dockerfile.

See https://avd.aquasec.com/misconfig/ds-0002
────────────────────────────────────────


DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
════════════════════════════════════════
You should add HEALTHCHECK instruction in your docker container images to perform the health check on running containers.

See https://avd.aquasec.com/misconfig/ds-0026
────────────────────────────────────────

وجد Trivy النصف الثاني من VULN-07 بالقاعدة DS-0002، ووجده أيضاً Checkov بالقاعدة CKV_DOCKER_3 وKICS بالقاعدة Missing User Instruction عندما شغلناهما على الملف نفسه. ومكان Hadolint الصحيح هو جودة البناء، فهو يجد الأخطاء التي تكبر ال Image وتجعل البناء غير قابل للتكرار، ولكي ترى ذلك كتبنا ملفاً سريعاً بأخطاء شائعة:

FROM python:latest
RUN apt-get update
RUN apt-get install -y curl
ADD . /app
RUN cd /app && pip install -r requirements.txt
USER root
CMD python /app/app.py

وعند تشغيل Hadolint عليه بالخيار --no-color سوف تجد أنه يخرج ما يلي، برمز خروج 1:

-:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag
-:2 DL3009 info: Delete the apt lists (/var/lib/apt/lists) after installing something
-:3 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
-:3 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
-:3 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
-:4 DL3020 error: Use COPY instead of ADD for files and folders
-:5 DL3042 warning: Avoid use of cache directory with pip. Use `pip install --no-cache-dir <package>`
-:5 DL3003 warning: Use WORKDIR to switch to a directory
-:6 DL3002 warning: Last USER should not be root
-:6 DL3066 info: Non-numeric user-id may not be resolvable by host system
-:7 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

والخلاصة العملية أن تشغل الأداتين معاً، Hadolint لجودة البناء، وtrivy config أو Checkov للأمان.

قاعدة لا تناسب مشروعك: DL3008 وتجاهلها بسبب مكتوب

القاعدة DL3008 تطلب تثبيت نسخة كل حزمة apt، مثل gcc=14.2.0-1. والفكرة صحيحة في ظاهرها، لكن Debian تحذف النسخ القديمة من مستودعاتها عند صدور إصلاح، فيفشل البناء بعد أسابيع لأن النسخة المثبتة لم تعد موجودة، والبديل الأنسب هو تثبيت ال Base Image بال Digest وإعادة البناء الدوري. وفي هذه الحالة لا تحذف القاعدة من كل المشروع، وإنما تجاهلها في السطر نفسه مع السبب، كما يوضح توثيق القاعدة:

FROM python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c AS build
# The base is pinned by digest and rebuilt weekly; Debian removes old package versions from its mirrors,
# so pinning apt versions would break the build instead of making it reproducible.
# hadolint ignore=DL3008
RUN apt-get update \
 && apt-get install -y --no-install-recommends gcc libc6-dev \
 && rm -rf /var/lib/apt/lists/*

قبل إضافة التعليق خرج Hadolint بالسطر -:2 DL3008 warning ورمز خروج 1، وبعد إضافته خرج دون أي ملاحظة ورمز خروج 0، والسبب مكتوب في الملف ويمر بمراجعة الكود مع أي تغيير.

إعادة البناء على Debian 13 slim بمستخدم عادي

الخطوة الأولى في الإصلاح أن ننتقل إلى Base Image حديثة مثبتة بال Digest، وأن ننشئ مستخدماً عادياً، ولاحظ أننا لم نغير requirements.txt عن قصد، حتى نرى أثر ال Base Image وحدها:

FROM python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .

RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin app
USER 10001

EXPOSE 8000
CMD ["python", "app.py"]
docker build -f Dockerfile.slim -t lab-dso-cont-app:1.1-slim .
docker run -d --name lab-dso-cont-v11 -p 127.0.0.1:19901:8000 lab-dso-cont-app:1.1-slim
docker exec lab-dso-cont-v11 id

والمخرج سوف يكون كما يلي:

uid=10001(app) gid=10001(app) groups=10001(app)

في الإعداد أعلاه لاحظ التالي:

  • ال Digest بعد العلامة @ يجعل البناء يستخدم ال Image نفسها كل مرة، ولا يتغير شيء إلا بطلب دمج يغير ال Digest، وهذا ما يفعله Renovate كما تشرح صفحة Docker في توثيقه.
  • المستخدم برقم UID صريح وليس باسم فقط، لأن Kubernetes يستطيع أن يتحقق من runAsNonRoot بالرقم ولا يستطيع ذلك بالاسم.
  • سطر USER يأتي بعد تثبيت المكتبات، لأن pip يكتب في مجلدات النظام ويحتاج root أثناء البناء، والتطبيق نفسه لا يحتاجها وقت التشغيل.

البناء متعدد المراحل Multi-stage Build على distroless

الخطوة الثانية أن نفصل البناء عن التشغيل، فالمرحلة الأولى فيها pip وshell، والمرحلة الثانية فيها Python والمكتبات والكود فقط، والصورة التالية تبين ما يبقى في كل مرحلة:

مرحلتان: الأولى build على python:3.13-slim فيها sh وbash وapt وdpkg وpip وsetuptools و87 حزمة Debian وgcc عند الحاجة، وفيها الأمر pip install --target /app/deps، ثم سهم أخضر COPY --from=build إلى المرحلة الثانية runtime على distroless/python3-debian13 وفيها python 3.13 مع 38 حزمة و/app/deps و/app/app.py وUSER 65532:65532، ودون shell أو apt أو pip، والحجم من 197 إلى 98 ميجابايت
أدوات البناء تحذف مع المرحلة الأولى، ولا تصل إلى السيرفر
# Stage 1: install the libraries with pip, in an image that has pip and a shell
FROM python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target /app/deps -r requirements.txt

# Stage 2: only Python, the libraries and our code. No shell, no pip, no apt
FROM gcr.io/distroless/python3-debian13:nonroot@sha256:774595d652a294b54c9bd575b2d9fdd1a4b47547dc17b8bfa4c0e953c64855b3
WORKDIR /app
COPY --from=build /app/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
EXPOSE 8000
CMD ["app.py"]
docker build -f Dockerfile.distroless -t lab-dso-cont-app:1.2-distroless .
docker run -d --name lab-dso-cont-v12 -p 127.0.0.1:19902:8000 lab-dso-cont-app:1.2-distroless
curl -s http://127.0.0.1:19902/api/health
docker exec lab-dso-cont-v12 sh -c id
docker image inspect lab-dso-cont-app:1.2-distroless --format 'User={{.Config.User}}'
docker images lab-dso-cont-app --format '{{.Tag}} {{.Size}}'

والمخرج سوف يكون كما يلي:

{"status":"ok"}
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
User=65532
1.2-distroless 97.8MB
1.1-slim 197MB
1.0 197MB

التطبيق يعمل، ولا يوجد shell داخل ال Container، والمستخدم 65532 يأتي من الوسم nonroot، والحجم نزل إلى النصف. ولاحظ أن CMD هنا هو اسم الملف فقط، لأن نقطة الدخول Entrypoint في distroless هي python كما تقول صفحتها.

مقارنة النتائج: ما الذي تغير فعلاً؟

فحصنا ال Images بالأوامر نفسها، ثم بنينا نسخة رابعة 1.3-distroless من ال Dockerfile نفسه مع تحديث المكتبات في requirements.txt إلى Flask 3.1.3 وWerkzeug 3.1.9 وJinja2 3.1.6، وهذا التحديث موضوع مقال SCA، لكننا نحتاجه هنا حتى نرى البوابة تنجح. والجدول التالي يجمع الأرقام كما خرجت:

ال ImageالحجمالمستخدمTrivy: حزم النظاممنها fixedCRITICALTrivy: حزم PythonGrype: كل الصفوف
1.0 (3.8-slim-buster)197MBroot12553229297
1.1-slim (3.13-slim)197MB100011650023178
1.2-distroless97.8MB655321590017175
1.3-distroless (مكتبات محدثة)97.8MB65532159000158

في الجدول أعلاه لاحظ التالي:

  • عدد صفوف حزم النظام زاد بعد الإصلاح: من 125 إلى 165، وهذا هو الفخ الذي ذكرناه في بداية المقال، فـ Debian 13 إصدار حي تتابع فيه Debian كل ثغرة صغيرة وتتركها مفتوحة إذا لم تستحق نشرة أمنية، بينما توقفت متابعة Debian 10.
  • الرقم الذي يهم هو عمود fixed: نزل من 53 إلى صفر، أي أنه لا يوجد شيء في حزم النظام يمكن إصلاحه بإعادة البناء الآن، ونزلت الثغرات الحرجة من 2 إلى صفر في Trivy.
  • distroless نقص عدد الحزم ولم ينقص عدد الصفوف كثيراً: من 87 حزمة إلى 38، لكن Python فيها حزمة من Debian نفسها مقسومة على أربع حزم، فكل ثغرة في Python تظهر أربع مرات، والفائدة الحقيقية هي غياب shell وpip.
  • حزم Python لم تتغير بتغيير ال Base Image: بقيت ثغرات Flask وWerkzeug وJinja2 حتى حدثنا المكتبات نفسها، وهذا هو الفرق بين فحص ال Image وفحص الاعتماديات.

الإنذار الخاطئ False Positive: المستخدم root ليس المشكلة هنا

عندما فحصنا ملفات Dockerfile الثلاثة بالأمر trivy config خرجت النتيجة التالية:

┌───────────────────────┬────────────┬───────────────────┐
│        Target         │    Type    │ Misconfigurations │
├───────────────────────┼────────────┼───────────────────┤
│ Dockerfile            │ dockerfile │         2         │
├───────────────────────┼────────────┼───────────────────┤
│ Dockerfile.distroless │ dockerfile │         2         │
├───────────────────────┼────────────┼───────────────────┤
│ Dockerfile.slim       │ dockerfile │         1         │
└───────────────────────┴────────────┴───────────────────┘

ال Dockerfile الخاص ب distroless ظهر فيه DS-0002 (HIGH) مع أن ال Image تعمل بالمستخدم 65532 كما رأيت، والسبب أن الفاحص يقرأ الملف ولا يعرف أن الوسم nonroot يحدد المستخدم داخل ال Base Image. وهنا يوجد طريقان، الأسوأ أن تتجاهل القاعدة، والأنسب أن تكتب السطر صراحة، فيعرف القارئ والفاحص معاً بأي مستخدم يعمل التطبيق، ولا يتغير شيء إذا غيرت ال Base Image يوماً إلى أخرى تعمل ب root:

ENV PYTHONPATH=/app/deps
# The :nonroot tag already runs as 65532; writing it here makes the intent visible to reviewers and scanners
USER 65532:65532

أما القاعدة DS-0026 التي تطلب HEALTHCHECK فهي هنا لا تناسبنا، لأن distroless ليس فيها shell أو curl لتشغيل أمر الفحص، والفحص يجب أن يكون في Compose أو في Kubernetes على المسار /api/health. ولأن هذه القاعدة تعترض على غياب سطر، فلا يمكن تجاهلها بتعليق داخل الملف كما تقول صفحة إعداد فحص الإعداد، وإنما في ملف .trivyignore.yaml مع السبب:

misconfigurations:
  - id: DS-0026
    statement: >-
      No HEALTHCHECK in the image: distroless has no shell or curl.
      Health is checked by the orchestrator (Compose healthcheck or Kubernetes probes) on /api/health.

وبعد التعديلين خرج trivy config --exit-code 1 --ignorefile .trivyignore.yaml على ملف distroless بصفر ملاحظات ورمز خروج 0.

إعدادات التشغيل: نظام ملفات للقراءة فقط وإسقاط الصلاحيات

المستخدم العادي خطوة أولى، لكنه لا يمنع المهاجم الذي وصل إلى التطبيق من الكتابة في المجلدات التي يملكها التطبيق، ولا من استخدام صلاحيات Linux Capabilities التي يعطيها Docker لكل Container افتراضياً. والصورة التالية تبين الفرق بين التشغيل الافتراضي والتشغيل المقيد، بقيم أخذناها من التجربة:

جهتان فيهما مهاجم وثغرة في التطبيق: على اليسار التشغيل الافتراضي لل Image 1.0 حيث id يعطي uid=0 root، وtouch /app/x ينجح، وCapEff قيمته 00000000a80425fb، وNoNewPrivs قيمته 0، وسهم أحمر إلى السيرفر مكتوب عليه root مع ثغرة في runc 2019 يساوي root على السيرفر، وعلى اليمين التشغيل مع USER 10001 وread-only وcap-drop ALL وno-new-privileges حيث id يعطي uid=10001، وtouch يفشل بالخطأ Read-only file system، وCapEff صفر، وNoNewPrivs واحد، والمسار الوحيد القابل للكتابة tmpfs على /tmp
الثغرة نفسها في الجهتين، والفرق في ما يستطيع المهاجم فعله بعدها

نشغل الآن ال Image الخاصة ب slim بنظام ملفات للقراءة فقط Read-only Root Filesystem، ودون أي Capability، ومع منع رفع الصلاحيات No New Privileges، وهي خيارات الأمر docker run الموثقة في مرجع docker run:

docker run -d --name lab-dso-cont-ro1 --read-only --cap-drop ALL \
  --security-opt no-new-privileges -p 127.0.0.1:19903:8000 lab-dso-cont-app:1.1-slim
docker logs lab-dso-cont-ro1 2>&1 | tail -3

والمخرج سوف يكون كما يلي:

  File "/app/app.py", line 37, in init_db
    conn = sqlite3.connect(DB_PATH)
sqlite3.OperationalError: unable to open database file

اكسرها لتفهمها: التطبيق توقف لأنه يكتب قاعدة بيانات SQLite في /tmp/shop.db، وكل نظام الملفات أصبح للقراءة فقط. وهذه هي الفائدة الثانية من هذا الخيار، فهو يكشف لك كل مكان يكتب فيه التطبيق، والحل أن تعطيه مساحة كتابة صغيرة ومحددة بالخيار --tmpfs، أو Volume للبيانات التي يجب أن تبقى كما شرحنا في قائمة تحقق المطور:

docker rm -f lab-dso-cont-ro1
docker run -d --name lab-dso-cont-ro2 --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL --security-opt no-new-privileges \
  -p 127.0.0.1:19903:8000 lab-dso-cont-app:1.1-slim
curl -s http://127.0.0.1:19903/api/health
docker exec lab-dso-cont-ro2 sh -c 'touch /app/x; grep -E "^(CapEff|NoNewPrivs)" /proc/1/status'
docker exec lab-dso-cont-v10 grep -E "^(CapEff|NoNewPrivs)" /proc/1/status

والمخرج سوف يكون كما يلي، والسطران الأخيران من ال Container الأصلي للمقارنة:

{"status":"ok"}
touch: cannot touch '/app/x': Read-only file system
CapEff:	0000000000000000
NoNewPrivs:	1
CapEff:	00000000a80425fb
NoNewPrivs:	0

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

  • CapEff هي الصلاحيات الفعالة للعملية رقم 1، والقيمة a80425fb هي المجموعة التي يعطيها Docker افتراضياً، وفيها مثلاً تغيير مالك الملفات وإرسال حزم شبكة خام Raw Sockets، والقيمة صفر تعني أن العملية لا تملك أياً منها، وتطبيق ويب على منفذ أعلى من 1024 لا يحتاج أياً منها.
  • NoNewPrivs: 1 يعني أن أي برنامج setuid داخل ال Container لن يرفع الصلاحيات، حتى لو وجد المهاجم واحداً.
  • الخيار noexec على /tmp يمنع تشغيل ملف ينزله المهاجم في المكان الوحيد القابل للكتابة.
  • هذه الخيارات لا تظهر في ال Image، فلن يجدها فاحص ال Image أبداً، ومكانها ملف Compose أو ملف Kubernetes، وهناك يجدها فاحص البنية ككود كما سوف ترى في الخطوة التالية. وتجد بقية خيارات العزل في صفحة الأمان في توثيق Docker.
📌
في 11 فبراير 2019 أعلن الباحثون عن الثغرة CVE-2019-5736 في runc، وهي الأداة التي يستخدمها Docker وcontainerd وCRI-O لتشغيل كل Container، وكانت تسمح لعملية تعمل ب root داخل ال Container بأن تكتب فوق ملف runc التنفيذي على السيرفر نفسه وتحصل على صلاحيات root عليه وعلى كل ال Containers الأخرى. وكتب فريق Kubernetes في مدونته في اليوم نفسه أن الثغرة لا تنطبق إذا لم تكن العملية تعمل بالرقم 0، وأن تشغيل كل Container بمستخدم عادي هو الحماية المباشرة إلى أن يتم التحديث. والدرس أن سطر USER في ال Dockerfile ليس تحسيناً شكلياً، فهو ما يحدد إن كانت ثغرة في أداة لا تملكها سوف تصل إلى السيرفر أم لا.

فحص البنية ككود: Terraform وKubernetes وDocker Compose

ملفات البنية ككود Infrastructure as Code تصف السيرفرات والشبكات وال Containers قبل أن توجد، وقد شرحنا فكرتها في البنية ككود للمبتدئين والفرق بين أداتيها في Terraform أم OpenTofu، وفحصها يعني أن تجد الخطأ في طلب الدمج بدلاً من أن تجده في حساب السحابة. وكتبنا ثلاثة ملفات فيها أخطاء مقصودة، أولها ملف Terraform فيه Bucket مفتوح للعالم وSSH مفتوح من أي عنوان:

# VULN-10: an S3 bucket open to the world and without encryption
# VULN-11: SSH open from any address on the Internet
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = "me-south-1"
}

resource "aws_s3_bucket" "uploads" {
  bucket = "arabroot-lab-uploads"
}

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_security_group" "web" {
  name        = "web"
  description = "web server"

  ingress {
    description = "ssh"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

والثاني ملف Kubernetes يشغل ال Container بصلاحيات كاملة Privileged ويربط فيه قرص السيرفر كاملاً، وقد شرحنا ال Pod وال Deployment في مصطلحات Kubernetes بالرسم:

# VULN-12: privileged Container, the node's whole disk mounted, image tag latest, runs as root
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop
spec:
  replicas: 1
  selector:
    matchLabels:
      app: shop
  template:
    metadata:
      labels:
        app: shop
    spec:
      containers:
        - name: shop
          image: registry.example.com/shop:latest
          ports:
            - containerPort: 8000
          securityContext:
            privileged: true
          volumeMounts:
            - name: host
              mountPath: /host
      volumes:
        - name: host
          hostPath:
            path: /

والثالث ملف Docker Compose بالأخطاء نفسها، ومعها ربط ملف docker.sock:

# VULN-13: the same mistakes in Docker Compose, plus the Docker socket mounted into the Container
services:
  shop:
    image: registry.example.com/shop:latest
    privileged: true
    ports:
      - "8000:8000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /:/host

نشغل الأدوات الثلاث على المجلد نفسه:

docker run --rm -v "$PWD:/src:ro" -v lab-dso-cont-trivy-cache:/root/.cache \
  aquasec/trivy:0.75.0 config -q /src/iac

docker run --rm -v "$PWD:/src:ro" bridgecrew/checkov:3.3.24 -d /src/iac --compact --quiet

docker run --rm -v "$PWD/iac:/src:ro" -v "$PWD/kics-out:/out" checkmarx/kics:v2.1.20 \
  scan -p /src -o /out --report-formats json --no-progress --disable-secrets --ci

وهذا ملخص Trivy، ثم قائمة القواعد التي فشلت في ملف Terraform:

┌─────────────────────┬────────────┬───────────────────┐
│       Target        │    Type    │ Misconfigurations │
├─────────────────────┼────────────┼───────────────────┤
│ k8s/deployment.yaml │ kubernetes │        21         │
├─────────────────────┼────────────┼───────────────────┤
│ terraform           │ terraform  │         0         │
├─────────────────────┼────────────┼───────────────────┤
│ terraform/main.tf   │ terraform  │        10         │
└─────────────────────┴────────────┴───────────────────┘

terraform/main.tf (terraform)
Tests: 10 (SUCCESSES: 0, FAILURES: 10)
Failures: 10 (UNKNOWN: 0, LOW: 2, MEDIUM: 1, HIGH: 6, CRITICAL: 1)
AWS-0086 (HIGH): Public access block does not block public ACLs
AWS-0087 (HIGH): Public access block does not block public policies
AWS-0089 (LOW): Bucket has logging disabled
AWS-0090 (MEDIUM): Bucket does not have versioning enabled
AWS-0091 (HIGH): Public access block does not ignore public ACLs
AWS-0093 (HIGH): Public access block does not restrict public buckets
AWS-0104 (CRITICAL): Security group rule allows unrestricted egress to any IP address.
AWS-0107 (HIGH): Security group rule allows unrestricted ingress from any IP address.
AWS-0124 (LOW): Security group rule does not have a description.
AWS-0132 (HIGH): Bucket does not encrypt data with a customer managed key.

ومن مخرج Checkov على ملف Kubernetes هذه الأسطر، من أصل 20 قاعدة فشلت:

kubernetes scan results:
Passed checks: 69, Failed checks: 20, Skipped checks: 0
Check: CKV_K8S_22: "Use read-only filesystem for containers where possible"
	FAILED for resource: Deployment.default.shop
Check: CKV_K8S_14: "Image Tag should be fixed - not latest or blank"
	FAILED for resource: Deployment.default.shop
Check: CKV_K8S_23: "Minimize the admission of root containers"
	FAILED for resource: Deployment.default.shop
Check: CKV_K8S_16: "Container should not be privileged"
	FAILED for resource: Deployment.default.shop
Check: CKV_K8S_43: "Image should use digest"
	FAILED for resource: Deployment.default.shop

ومن جدول KICS هذه الصفوف من مستوى HIGH، وقد انتهى برمز الخروج 50:

Platform         Query Name                                        Severity    CWE     Results
Kubernetes       Container Is Privileged                           HIGH        269     1
DockerCompose    Docker Socket Mounted In Container                HIGH        284     1
Kubernetes       Non Kube System Pod With Host Mount               HIGH        668     1
Kubernetes       Privilege Escalation Allowed                      HIGH        269     1
DockerCompose    Privileged Containers Enabled                     HIGH        250     1
Terraform        Sensitive Port Is Exposed To Entire Network       HIGH        200     1
Terraform        Unrestricted Security Group Ingress               HIGH        668     1
DockerCompose    Volume Has Sensitive Host Directory               HIGH        668     2
Kubernetes       Workload Mounting With Sensitive OS Directory     HIGH        200     1

والصورة التالية تجمع ما وجدته كل أداة في كل ملف:

جدول من أربعة ملفات وأربع أدوات: Dockerfile بلا سطر USER وجد فيه Hadolint صفر وCheckov وKICS وtrivy config ملاحظتين لكل منها، وmain.tf وجد فيه Checkov 15 وKICS 13 وTrivy 10، وdeployment.yaml وجد فيه Checkov 20 بلون برتقالي لأنه فاته hostPath وKICS 23 وTrivy 21، وcompose.yaml لم يقرأه Checkov ولا Trivy ووجد فيه KICS 8، وHadolint ليس من عمله إلا ال Dockerfile
كل أداة تقرأ صيغاً مختلفة، وتقسم القواعد بطريقتها، فلا تقارن أرقامها ببعضها

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

  • ملف Compose قرأه KICS وحده: فقائمة --misconfig-scanners في Trivy 0.75 لا تحتوي على Compose، وCheckov لم يذكره في نتائجه أصلاً، بينما تذكر صفحة المنصات في KICS أنه يقرأ ملفات Docker Compose بامتداد .yaml. فإذا كان نشرك على سيرفر واحد بملف Compose فهذه الأداة هي التي تحتاجها.
  • ربط قرص السيرفر كاملاً hostPath: وجده Trivy بالقاعدتين KSV-0023 وKSV-0121، ووجده KICS بثلاث قواعد، ولم يظهر في نتائج Checkov العشرين، مع أنه أخطر سطر في الملف، لأن من يكتب في /host يكتب في قرص السيرفر.
  • رموز الخروج Exit Codes مختلفة: Trivy خرج ب 0 لأن trivy config لا يفشل إلا مع الخيار --exit-code، وCheckov خرج ب 1 لوجود أي فشل، وKICS خرج ب 50 لأن صفحة النتائج في توثيقه تجعل الرمز 60 للحرجة و50 لل HIGH و40 لل MEDIUM و30 لل LOW و20 لل INFO، فانتبه إلى ذلك عندما تكتب شرط الفشل في خط CI.
  • ليست كل نتيجة خطأ يجب إصلاحه: القاعدة AWS-0104 بخطورة CRITICAL تعترض على السماح بالخروج Egress إلى أي عنوان، والسيرفر الذي ينزل التحديثات وال Docker Images يحتاج ذلك غالباً، وسوف نعالجها في الخطوة التالية.

ولاحظ أيضاً أن KICS طبع في بدايته Unable to get descriptions ثم Using default descriptions، لأنه يحاول جلب أوصاف القواعد من خدمة Checkmarx على الإنترنت، ففي شبكة مغلقة يكمل بالأوصاف المضمنة فيه.

إصلاح الملفات، وتجاهل ما لا يناسبنا بتاريخ انتهاء

نصلح ملف Terraform: نغلق الوصول العام للـ Bucket، ونحدد SSH بشبكة VPN المكتب، ونضيف مفتاح تشفير KMS، ونترك الخروج مفتوحاً مع تعليق تجاهل لكل أداة بالسبب، والتعليق في Trivy يقبل تاريخ انتهاء بعد exp:، وفي Checkov يقبل السبب بعد النقطتين كما تشرح صفحة التجاهل في توثيقه:

resource "aws_kms_key" "uploads" {
  description         = "uploads bucket key"
  enable_key_rotation = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" {
  bucket = aws_s3_bucket.uploads.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.uploads.arn
    }
  }
}

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# The server pulls OS updates and Docker Images from the Internet, so egress stays open.
# Reviewed by the platform team, ticket SEC-143. Re-check before the expiry date.
#trivy:ignore:AWS-0104:exp:2027-01-31
resource "aws_security_group" "web" {
  #checkov:skip=CKV_AWS_382:Egress open for updates and image pulls, ticket SEC-143
  name        = "web"
  description = "web server"

  ingress {
    description = "ssh from the office VPN only"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.20.0.0/24"]
  }

  egress {
    description = "updates and image pulls"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

ثم نشغل البوابة نفسها على الملف القديم والجديد، Trivy على HIGH وCRITICAL، وCheckov على قائمة قواعد نختارها بالاسم:

trivy config -q --severity HIGH,CRITICAL --exit-code 1 iac/terraform    # exit=1
trivy config -q --severity HIGH,CRITICAL --exit-code 1 iac2/terraform   # exit=0

checkov -d iac/terraform  --quiet --hard-fail-on CKV_AWS_24,CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_382   # exit=1
checkov -d iac2/terraform --quiet --hard-fail-on CKV_AWS_24,CKV_AWS_53,CKV_AWS_54,CKV_AWS_55,CKV_AWS_56,CKV_AWS_382   # exit=0

ورموز الخروج الحقيقية مكتوبة بجانب كل أمر. وعند تشغيل Checkov على القاعدة المستثناة وحدها سوف تجد أنه يذكرها ويذكر السبب معها:

Check: CKV_AWS_382: "Ensure no security groups allow egress from 0.0.0.0:0 to port -1"
	SKIPPED for resource: aws_security_group.web
	Suppress comment: Egress open for updates and image pulls, ticket SEC-143

في الإعداد أعلاه لاحظ التالي:

  • اخترنا قواعد Checkov بالاسم وليس بالخطورة، لأن ملف README في مستودعه يقول إن التصفية بالخطورة تحتاج مفتاح API من المنصة التجارية، وبدونه لا يعرف Checkov خطورة قواعده، ولهذا لم تجد عمود خطورة في مخرجه.
  • بقيت في Checkov سبع قواعد فاشلة خارج قائمتنا، مثل النسخ بين المناطق Cross-region Replication وسجلات الوصول، وهذه قرارات تصميم تختلف من مشروع لآخر، وليست كلها ثغرات، ولهذا لا ينصح بإيقاف البناء على كل قاعدة في أداة IaC من اليوم الأول.
  • التاريخ في exp:2027-01-31 يجعل الاستثناء ينتهي وحده، فيعود Trivy إلى الاعتراض بعده، ويضطر أحد إلى مراجعة القرار من جديد.

وأصلحنا ملف Kubernetes بإعدادات معيار Pod Security Standards في مستواه Restricted: مستخدم عادي، ونظام ملفات للقراءة فقط مع emptyDir صغير على /tmp، وإسقاط كل الصلاحيات، ومنع رفع الصلاحيات، وملف seccomp الافتراضي، وال Image بال Digest:

    spec:
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: shop
          image: registry.example.com/shop:1.3@sha256:491cf9d6deb3556ee325e2dc206246bc1a2fa71e1e503fa535af399d8ee34a75
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

فنزلت نتائج Trivy من 21 إلى نتيجة واحدة:

Tests: 98 (SUCCESSES: 97, FAILURES: 1)
Failures: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 0, CRITICAL: 0)
KSV-0125 (MEDIUM): Container shop in deployment shop (namespace: shop) uses an image from an untrusted registry.

وهذا إنذار خاطئ آخر، فال Registry الخاص بنا ليس في القائمة المضمنة في القاعدة، وهي فيها عناوين Azure وECR وGCR فقط. والحل الصحيح ليس تجاهل القاعدة، لأنها مفيدة إذا كتب أحدهم Image من Registry آخر، وإنما أن نخبرها بال Registry الذي نثق به، فالقاعدة في كودها المصدري تقرأ القائمة من ksv0125.trusted_registries إذا وجدت:

# trivy-data/registries.yaml, passed with: trivy config --config-data trivy-data
ksv0125:
  trusted_registries:
    - registry.example.com

وبعد إضافة --config-data خرج الفحص بصفر ملاحظات على الملف، وبقيت القاعدة تعمل لكل Registry آخر.

استثناء ثغرة في ال Image بسبب مكتوب وتاريخ انتهاء

نعود إلى ال Image النهائية 1.3-distroless، فعندما فحصناها بمستويي HIGH وCRITICAL دون --ignore-unfixed ظهر 30 صفاً كلها بحالة affected، ومنها الثغرة CVE-2026-19445 في Python على أربع حزم. وقرأنا وصفها في صفحتها: تخص السيرفر الذي يشغل TLS بنفسه ويستخدم sni_callback، وتطبيقنا لا يفعل ذلك لأن TLS ينتهي عند ال Reverse Proxy، وDebian صنفتها no-dsa (Minor issue). وهذا قرار يستحق أن يكتب، فنضعه في ملف .trivyignore.yaml بالحقلين statement وexpired_at اللذين تشرحهما صفحة التصفية:

vulnerabilities:
  - id: CVE-2026-19445
    statement: >-
      Our app does not terminate TLS and never sets sni_callback (TLS ends at the Reverse Proxy).
      Debian marks it no-dsa (minor issue) for trixie. Owner: platform team. Ticket: SEC-142
    expired_at: 2026-12-31

ثم نشغل الفحص ثلاث مرات: دون الملف، ومعه، ومع نسخة منه تاريخها 2026-10-01 أي منتهية:

no ignore exit=1
Total: 30 (HIGH: 30, CRITICAL: 0)
ignore exit=1
Total: 26 (HIGH: 26, CRITICAL: 0)
expired exit=1
Total: 30 (HIGH: 30, CRITICAL: 0)

ومع الخيار --show-suppressed يظهر جدول بالمستثنى وسببه ومصدره، وهذا أوله:

Suppressed Vulnerabilities (Total: 4)
=====================================
┌───────────────────────┬────────────────┬──────────┬─────────┬────────────────────────────────────────────────────────────┬────────────────────────┐
│        Library        │ Vulnerability  │ Severity │ Status  │                         Statement                          │         Source         │
├───────────────────────┼────────────────┼──────────┼─────────┼────────────────────────────────────────────────────────────┼────────────────────────┤
│ libpython3.13-minimal │ CVE-2026-19445 │ HIGH     │ ignored │ Our app does not terminate TLS and never sets sni_callback │ /cfg/.trivyignore.yaml │
│                       │                │          │         │ (TLS ends at the Reverse Proxy). Debian marks it no-dsa    │                        │
│                       │                │          │         │ (minor issue) for trixie. Owner: platform team. Ticket:    │                        │
│                       │                │          │         │ SEC-142                                                    │                        │

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

  • الاستثناء أخفى الصفوف الأربعة فقط، وبقي الفحص يفشل بسبب 26 صفاً آخر، فالاستثناء لا يستخدم لإسكات البوابة كلها.
  • بعد انتهاء التاريخ عادت الصفوف الأربعة دون أن يغير أحد شيئاً، وهذا بالضبط ما نريده، فالقرار يراجع كل فترة بدلاً من أن يبقى في الملف إلى الأبد.
  • الصيغة الأقدم .trivyignore تقبل التاريخ أيضاً في السطر نفسه CVE-2026-19445 exp:2026-12-31 مع تعليق قبله، لكن الصيغة YAML تحفظ السبب داخل التقرير نفسه.

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

# Review by 2026-12-31 (Grype ignore rules have no expiry date). Owner: platform team. Ticket: SEC-142
ignore:
  - vulnerability: CVE-2026-19445
    fix-state: wont-fix
    reason: "No TLS server in the app, TLS ends at the Reverse Proxy; Debian no-dsa"

وعند تشغيل Grype بالخيار --fail-on critical على ال Image نفسها كانت النتيجة كما يلي، قبل ملف الإعداد ثم بعده:

1.3 crit exit=2
[0024] ERROR discovered vulnerabilities at or above the severity threshold
libpython3.13-minimal  3.13.5-2+deb13u5             (won't fix)  deb   CVE-2026-19445    Critical    0.4% (34th)  0.4

1.3 crit ignore exit=0
libpython3.13-minimal  3.13.5-2+deb13u5             (won't fix)  deb   CVE-2026-19445    Critical    0.4% (34th)  0.4    (suppressed)  

ولاحظ هنا شيئين، الأول أن Grype يخرج برمز 2 عند تجاوز الحد وليس 1، والثاني أن الثغرة نفسها كانت HIGH في Trivy فلم توقف بوابة CRITICAL فيه، وكانت Critical في Grype فأوقفت بوابته، وهذا ما ذكرناه عن اختلاف مصدر الخطورة بين الأداتين.

البوابة Gate كما تعمل في خط CI

الآن نجمع ما تعلمناه في سكربت صغير يعمل في خطوة واحدة من خط CI، يحفظ التقرير الكامل للفريق دون أن يفشل، ثم يوقف ال Job فقط إذا وجد ثغرة HIGH أو CRITICAL لها إصلاح:

#!/bin/sh
# Container gate: fails the job when the image has a HIGH or CRITICAL finding that already has a fix
IMAGE="$1"
TRIVY="docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v lab-dso-cont-trivy-cache:/root/.cache aquasec/trivy:0.75.0"

# 1) The full report (every severity, fixed or not) is kept as a job artifact and never fails the job
$TRIVY image -q --scanners vuln --format json "$IMAGE" > trivy-report.json

# 2) The gate: only HIGH and CRITICAL findings that can be fixed now
$TRIVY image -q --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE" > trivy-gate.txt
status=$?
echo "gate for $IMAGE: exit code $status"
exit $status
for t in 1.0 1.3-distroless; do sh ci-gate.sh lab-dso-cont-app:$t; echo "job result: $?"; done

والمخرج سوف يكون كما يلي:

gate for lab-dso-cont-app:1.0: exit code 1
job result: 1
gate for lab-dso-cont-app:1.3-distroless: exit code 0
job result: 0

وعندما شغلنا البوابة نفسها على النسختين الوسطيتين فشلت 1.1-slim بسبع ثغرات HIGH في حزم Python، منها ثغرات في pip وurllib3 التي جاءت مع ال Base Image، وفشلت 1.2-distroless بثلاث في Flask وWerkzeug فقط، لأن distroless لا يحتوي على pip أصلاً. وفي المقابل خرجت كل النسخ الجديدة بصفر في حزم النظام، لأن الخيار --ignore-unfixed يخفي ما لا إصلاح له، وهو كما تقول صفحة التصفية اختصار للحالات affected وwill_not_fix وfix_deferred وend_of_life.

إزالة التجربة Cleanup

نحذف ما أنشأناه فقط، بالاسم:

docker rm -f lab-dso-cont-v10 lab-dso-cont-v11 lab-dso-cont-v12 lab-dso-cont-v13 lab-dso-cont-ro2
docker image rm lab-dso-cont-app:1.0 lab-dso-cont-app:1.1-slim lab-dso-cont-app:1.2-distroless lab-dso-cont-app:1.3-distroless
docker volume rm lab-dso-cont-trivy-cache lab-dso-cont-grype-db

ولا تستخدم docker system prune أو docker volume prune لتنظيف تجربة، لأنها تحذف ما يخص مشاريع أخرى على الجهاز نفسه.

فحص ال Docker Images والبنية ككود في خط CI

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

خط من خمس خطوات مرقمة: طلب الدمج، ثم فحوص الإعداد hadolint وtrivy config وcheckov وkics لملف compose، ثم docker build، ثم بوابة ال Image بالخيارات severity HIGH,CRITICAL وignore-unfixed وexit-code 1 وملف trivyignore بتاريخ انتهاء مع حفظ التقرير الكامل، ثم Registry بال Digest عند exit 0، وسهم أحمر exit 1 يعني فشل ال Job، وتحتها ثلاث بطاقات على جدول: كل ليلة إعادة فحص ال Digests المنشورة لأن قاعدة الثغرات تغيرت، وكل أسبوع إعادة البناء لتصل إصلاحات التوزيعة، وعندما تتغير ال Base Image يفتح Renovate طلب دمج بال Digest الجديد
البوابة توقف ما يمكن إصلاحه الآن، والجدول يكشف ما ظهر بعد النشر

سياسة البوابة Gate Policy: ما الذي يوقف البناء؟

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

  • البوابة توقف البناء على HIGH وCRITICAL بحالة fixed فقط، أي ما يمكن إصلاحه الآن بإعادة البناء أو بتحديث مكتبة.
  • التقرير الكامل بكل المستويات والحالات يحفظ مع كل بناء ويصل إلى مكان يراه الفريق، وجمع النتائج من كل الأدوات في مكان واحد موضوع الخط الآمن من البداية للنهاية.
  • كل استثناء في ملف داخل المستودع، بسبب مكتوب وصاحب وتاريخ انتهاء، ويمر بمراجعة الكود مثل أي تغيير آخر.
  • في فحص البنية ككود ابدأ بقائمة قصيرة من القواعد التي تمنع الكوارث (الوصول العام، Privileged، hostPath، SSH من أي عنوان) ثم أضف غيرها بالتدريج.
  • ال Base Image من إصدار توزيعة انتهى دعمه توقف البناء حتى لو لم يظهر فيها شيء، فالتحذير This OS version is no longer supported وحده سبب كاف.

إعادة البناء Rebuild وإعادة الفحص Re-scan على جدول

رأيت في التجربة أن python:3.8-slim-buster بنيت آخر مرة في 6 يونيو 2023، وأن python:3.13-slim بنيت في 6 أكتوبر 2026 أي قبل التجربة بيوم، فال Images الرسمية يعاد بناؤها باستمرار ما دام إصدارها مدعوماً، لكن Image تطبيقك لن تتغير إلا إذا أعدت بناءها. لذلك اجعل في خط CI ثلاث مهام على جدول:

  • كل ليلة: أعد فحص ال Digests المنشورة فعلاً في الإنتاج دون إعادة بنائها، فال Image لم تتغير لكن قاعدة الثغرات تغيرت، وقد رأيت هذا في تجربة مقال سلسلة التوريد عندما ظهرت ثغرات جديدة بين فحصين في اليوم نفسه.
  • كل أسبوع: أعد بناء ال Image من ال Dockerfile نفسه ومر بالبوابة نفسها، حتى تصل إصلاحات التوزيعة حتى لو لم يتغير كودك.
  • عندما تتغير ال Base Image: يفتح Renovate طلب دمج بال Digest الجديد، فيمر بالاختبارات والبوابة قبل أن يصل إلى الإنتاج.

مثال خط Gitea Actions

الملف التالي يشغل فحوص هذا المقال على كل طلب دمج، بالأوامر نفسها التي نفذناها في التجربة، وقد شرحنا تثبيت المشغل في Gitea Actions:

name: container-scan
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 3 * * 1"          # weekly rebuild, Monday 03:00

jobs:
  config:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Dockerfile lint
        run: docker run --rm -i hadolint/hadolint:v2.15.1 < Dockerfile
      - name: Dockerfile, Terraform and Kubernetes
        run: |
          docker run --rm -v "$PWD:/src:ro" aquasec/trivy:0.75.0 config -q \
            --severity HIGH,CRITICAL --exit-code 1 \
            --ignorefile /src/.trivyignore.yaml --config-data /src/trivy-data /src
      - name: Docker Compose
        run: |
          docker run --rm -v "$PWD:/src:ro" checkmarx/kics:v2.1.20 scan -p /src/deploy \
            --no-progress --disable-secrets --fail-on critical,high

  image:
    needs: config
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t app:${{ github.sha }} .
      - name: Image gate
        run: sh ci-gate.sh app:${{ github.sha }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: trivy-report
          path: trivy-report.json

في الإعداد أعلاه لاحظ التالي:

  • الجدول schedule يعيد البناء والفحص كل أسبوع حتى لو لم يدفع أحد كوداً.
  • الخيار --fail-on critical,high في KICS يجعله يفشل على هذين المستويين فقط، وبدونه يفشل حتى على INFO كما رأيت في رموز الخروج.
  • ملف ci-gate.sh هو السكربت نفسه الذي شغلناه في التجربة، والتقرير الكامل يرفع حتى لو فشلت البوابة بفضل if: always().
  • هذا الملف لم نشغله على مشغل Gitea في هذا المقال، وإنما شغلنا كل أمر فيه في Docker كما رأيت في التجربة، أما الخط الكامل الذي يجمع كل الفحوص على مشغل حقيقي مع تثبيت ال Actions بالهاش فهو موضوع مقال الخط الآمن من البداية للنهاية.

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

  • الحكم على ال Base Image بعدد النتائج: رأيت أن Debian 10 المنتهي أظهر أقل من Debian 13، وأن Chiselled أظهر صفراً لأن الفاحص لم ير الحزم، فاقرأ عمود الحالة وعدد الحزم التي رآها الفاحص قبل الرقم.
  • فحص ال Image مرة واحدة يوم البناء: قاعدة الثغرات تتغير كل يوم، فبدون إعادة فحص ليلي لن تعرف عن ثغرة ظهرت في Image منشورة منذ شهر.
  • بوابة توقف على كل ثغرة HIGH بلا إصلاح: تتحول خلال أيام إلى ملف استثناءات طويل لا يقرؤه أحد، فابدأ بما له إصلاح.
  • استثناء بلا سبب ولا تاريخ: سطر CVE-… وحده في .trivyignore يبقى في الملف سنوات بعد أن تتغير الظروف.
  • الاعتماد على Hadolint وحده للأمان: لم يعترض على Dockerfile ليس فيه سطر USER، فشغل معه trivy config أو Checkov.
  • الاعتماد على أداة IaC واحدة: Checkov لا يقرأ Compose وفاته hostPath في تجربتنا، وTrivy لا يقرأ Compose، فاعرف ما لا تقرؤه أداتك.
  • إغلاق المستخدم العادي في ال Dockerfile ثم تشغيل ال Container ب --privileged أو ربط docker.sock: إعدادات التشغيل تلغي كل ما فعلته في ال Image.
  • استخدام وسم يتحرك مثل latest أو 3.13-slim دون Digest: لا تعرف ما الذي تغير بين بناء وآخر، ولا تستطيع أن تعيد بناء ما نشرته بالضبط.
  • Alpine لتطبيق Python فيه مكتبات مترجمة دون اختبار: البناء يصبح أبطأ وقد يتغير السلوك بسبب musl.

قائمة تحقق Checklist

  • ال Base Image من إصدار توزيعة مدعوم، مثبتة بال Digest، ويحدثها Renovate بطلب دمج.
  • بناء متعدد المراحل، ولا يوجد في المرحلة النهائية أدوات بناء أو pip أو مدير حزم إلا لحاجة واضحة.
  • سطر USER برقم صريح في آخر ال Dockerfile، حتى لو كانت ال Base Image تعمل بمستخدم عادي.
  • التشغيل بـ --read-only مع --tmpfs أو Volume للمجلدات التي يكتب فيها التطبيق، و--cap-drop ALL، وno-new-privileges، أو ما يقابلها في Kubernetes.
  • Hadolint وtrivy config على ال Dockerfile في كل طلب دمج.
  • فحص ملفات Terraform وKubernetes بأداة، وملفات Compose ب KICS، مع قائمة قصيرة من القواعد التي توقف الدمج.
  • بوابة على ثغرات HIGH وCRITICAL التي لها إصلاح، وتقرير كامل يحفظ مع كل بناء.
  • كل استثناء بسبب وصاحب وتاريخ انتهاء، وexpired_at في Trivy أو تعليق بتاريخ مراجعة في Grype.
  • إعادة فحص ليلية لما هو منشور، وإعادة بناء أسبوعية حتى لو لم يتغير الكود.
  • إصدارات أدوات الفحص نفسها مثبتة، وتحدث بطلب دمج.

الخلاصة

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

  • فاحص ال Image يرى ما سجل في قاعدة بيانات الحزم وملفات وصف المكتبات، ولا يرى غالباً البرامج المبنية من المصدر، فرأى Grype مفسر Python في تطبيقنا ولم يره Trivy، ولم ير أي منهما حزم ال Image المقطعة Chiselled.
  • الفاحص يعتمد على نشرات التوزيعة لأن التوزيعات تنقل الإصلاحات إلى النسخ القديمة، والحالة fixed أو affected أو will_not_fix أهم من الخطورة عند اتخاذ القرار، والخطورة نفسها تختلف بين أداة وأخرى.
  • عدد النتائج لا يصلح وحده لاختيار ال Base Image، والرقم الذي يهم هو ما يمكن إصلاحه الآن، وقد نزل في تجربتنا من 53 إلى صفر بعد الانتقال إلى Debian 13، مع أن العدد الكلي زاد.
  • البناء متعدد المراحل على distroless قسم الحجم على اثنين وأزال shell وpip، والمستخدم العادي ونظام الملفات للقراءة فقط وإسقاط الصلاحيات تقلل ما يستطيعه المهاجم بعد الاختراق، كما في ثغرة runc عام 2019.
  • Hadolint لجودة ال Dockerfile وtrivy config وCheckov وKICS لأمانه ولأمان ملفات البنية ككود، وكل منها يقرأ صيغاً مختلفة، وKICS وحده قرأ ملف Compose.
  • البوابة توقف ما له إصلاح فقط، والاستثناء بسبب وتاريخ انتهاء، والجدول يعيد الفحص كل ليلة والبناء كل أسبوع.

وفي المقالات التالية من السلسلة بإذن الله سوف نتحدث عن فحص التطبيق أثناء التشغيل DAST، ثم نجمع كل الفحوص في خط واحد.

دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.دليلSecrets Scanning: كيف تكتشف الأسرار المسربة في الكودكيف يخرج المفتاح من جهازك إلى تاريخ Git وخط CI، وكيف تكتشفه قبل غيرك، وماذا تفعل في الساعة الأولى بعد تسربه.دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.دليلDAST: فحص تطبيقك من الخارج وهو يعملكيف يفحص ZAP تطبيقك من الخارج وهو يعمل، ومتى يحق لك أن تشغله، وكيف تحول نتائجه إلى بوابة في خط CI.دليلخط CI/CD آمن من البداية للنهاية على Gitea Actionsكيف تجمع فحوص الأمن في خط واحد لا يستطيع طلب الدمج تجاوزه، ثم ترسل إلى الإنتاج نفس ال Docker Image الموقعة التي فحصتها.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على نسخة من التطبيق التجريبي للسلسلة بأدوات Trivy 0.75.0 وGrype 0.120.1 وHadolint 2.15.1 وCheckov 3.3.24 وKICS 2.1.20 في Docker، وفحص ست Base Images لـ Python في اليوم نفسه، والتحقق من تراخيص الأدوات وحالات الثغرات في Debian Security Tracker وشروط Chainguard المجانية من مصادرها الرسمية.
نشرة عرب رووت | ArabRoot

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

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

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

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