لنفرض أن فريقك شغل الفحص الثابت للكود SAST على كل طلب دمج، وفحص الاعتماديات SCA على كل بناء، وأن النتائج كلها خضراء، ثم نشرتم التطبيق خلف ال Reverse Proxy فاكتشف أحد المستخدمين أن ملف تعريف الارتباط Cookie الخاص بالجلسة يرسل دون الخاصية Secure، وأن الصفحة لا ترسل أي ترويسة أمان Security Header، وأن ال Reverse Proxy يكشف رقم إصدار السيرفر في كل استجابة. والسبب أن هذه الأشياء غير موجودة في الكود الذي قرأه الفحص الثابت، وإنما في التطبيق كما يعمل فعلاً بعد أن اجتمع الكود مع الإعداد وال Reverse Proxy وإصدار المكتبة. وهذا هو المكان الذي يعمل فيه الفحص الديناميكي لأمان التطبيقات Dynamic Application Security Testing واختصاراً DAST، فهو يقف خارج التطبيق وهو يعمل، ويرسل إليه طلبات HTTP كما يفعل المتصفح أو المخترق، ثم يحكم على ما يعود.
وهذا المقال جزء من سلسلة DevSecOps، وقد شرحنا الصورة الكاملة للفحوص ومكان كل منها في خط CI في DevSecOps للمبتدئين، لذلك سوف نركز هنا على الفحص الديناميكي وحده، على تطبيق السلسلة التجريبي نفسه.
وسوف نناقش في هذا المقال ما يلي:
- ما المشكلة التي يحلها الفحص الديناميكي، وما الذي يراه ولا يراه الفحص الثابت.
- كيف يعمل الفاحص من الداخل بمصطلحات ZAP نفسها: الزحف Crawling، والفحص السلبي Passive Scan، والفحص النشط Active Scan، والتنبيهات Alerts بدرجة الخطورة Risk ودرجة الثقة Confidence.
- الفحص الأساسي Baseline والفحص الكامل Full Scan وفحص ال API من ملف OpenAPI، والفحص بعد تسجيل الدخول Authenticated Scan.
- Nuclei وقوالبه Templates، وما حال Nikto اليوم، ومتى تحتاج IAST.
- الجانب الأخلاقي والقانوني: على أي هدف يحق لك أن تشغل الفحص، وبأي سرعة.
- تشغيل الفحص في خط CI على بيئة المعاينة Preview Environment، وتحويل نتيجته إلى بوابة Gate بملف قواعد يحدد ما يفشل الخط وما يتجاهل.
- تجربة عملية كاملة على تطبيق السلسلة: فحص أساسي ثم كامل ثم بعد تسجيل الدخول ثم من ملف OpenAPI، ثم ملف القواعد، ثم إصلاح الترويسات وإعادة الفحص، ثم Nuclei.
ما الذي لا يراه الفحص الثابت SAST؟
الحل الأول: نكتفي بالفحص الثابت Static Analysis
الحل الأول الذي تبدأ به أغلب الفرق هو الفحص الثابت، وقد شرحناه في تحليل الكود الثابت SAST، وهو ممتاز في إيجاد السطر الذي يلصق مدخلات المستخدم في استعلام SQL، لكنه يقرأ ملفات المستودع فقط، والتطبيق الذي يصل إلى المستخدم أكبر من ذلك بكثير، ففيه إعداد ال Reverse Proxy الذي قد يضيف الترويسات أو يحذفها، وفيه متغيرات البيئة التي تشغل وضع التطوير Debug أو تطفئه، وفيه السلوك الافتراضي لإطار العمل Framework الذي لم يكتبه أحد في الكود، مثل خصائص ملف تعريف الارتباط الذي تصدره المكتبة للجلسة.
الحل الثاني: الفحص اليدوي Manual Testing قبل كل إصدار
والحل الثاني أن يفتح أحد المطورين أدوات المطور في المتصفح قبل كل إصدار ويتأكد من الترويسات وملفات تعريف الارتباط، وهذا مفيد مرة واحدة، لكنه يفشل للأسباب نفسها التي ذكرناها عن النقر اليدوي في أنواع الاختبارات، فهو بطيء ولا يتكرر بالشكل نفسه، ولا يصل إلى كل صفحة وكل معامل Parameter، ولا يجرب أنواع الثغرات المعروفة واحداً واحداً.
الحل الأنسب: فاحص آلي Automated Scanner على نسخة تملكها من التطبيق
الحل الأنسب هو أداة تعمل آلياً في خط CI على نسخة حقيقية من التطبيق، أي بيئة معاينة أو staging مبنية من ال Docker Image نفسها التي سوف تصل إلى الإنتاج Production، فتزحف على الصفحات وتقرأ كل استجابة وتجرب الثغرات المعروفة، ثم تعطي تقريراً ونتيجة يفهمها الخط. والصورة التالية تبين الفرق بين ما يراه كل من الفحصين:

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

الزحف Crawling: أي صفحات يعرفها الفاحص؟
الفاحص لا يعرف عن تطبيقك إلا الرابط الذي أعطيته، لذلك يبدأ بالزحف، والعنكبوت التقليدي Spider في ZAP يطلب الصفحة الأولى ثم يقرأ ال HTML ويستخرج الروابط ومصادر السكربتات والنماذج Forms بطريقتي GET وPOST، ويستطيع أن يقرأ ملفي robots.txt وsitemap.xml، ويضع كل ما وجده في شجرة الموقع Sites Tree. لكن هذا العنكبوت لا يشغل JavaScript، فإذا كانت واجهتك تبني الروابط في المتصفح كما في تطبيقات React وVue، فسوف يرى صفحة شبه فارغة.
ولهذا كان ZAP يوفر عنكبوت AJAX الذي يقود متصفحاً حقيقياً وينقر في الصفحة، لكن وثائقه تقول اليوم إنه لم يعد الخيار المنصوح به، وإن العنكبوت من جهة المتصفح Client Spider هو الخيار المنصوح به للتطبيقات الحديثة، لأنه يقرأ ال DOM عبر إضافة ZAP في المتصفح فيجد ما لا يجده عنكبوت AJAX. والقاعدة المهمة هنا أن ما لم يصل إليه الزحف لن يفحص أبداً، فالصفحة التي لا يقود إليها رابط، أو التي تحتاج تسجيل دخول، تبقى خارج الفحص مهما كانت قواعده جيدة، وسوف ترى ذلك بنفسك في التجربة.
الفحص السلبي Passive Scan: يقرأ ولا يلمس
الفحص السلبي يعمل على كل طلب واستجابة يمران عبر ZAP، سواء أرسلها العنكبوت أو أرسلها متصفحك أنت، فيقرأ الترويسات وملفات تعريف الارتباط والمحتوى بحثاً عن مشكلات معروفة، دون أن يرسل أي طلب إضافي أو يغير شيئاً. لذلك هو سريع وآمن نسبياً، ومنه تأتي أغلب نتائج الفحص الأساسي: ترويسة Content-Security-Policy غائبة، وملف تعريف ارتباط دون HttpOnly أو SameSite، وترويسة Server تكشف الإصدار، وصفحة خطأ تكشف تفاصيل داخلية.
الفحص النشط Active Scan: يجرب بنفسه
الفحص النشط يأخذ كل صفحة وكل معامل في شجرة الموقع، ويرسل إليها طلبات اختبار لأنواع الثغرات المعروفة، مثل الحقن Injection وانعكاس المدخلات في الصفحة Reflected Input والوصول إلى ملفات خارج المسار Path Traversal، ثم يقارن الاستجابات ليحكم هل ظهرت علامة الثغرة. وتحدد سياسة الفحص Scan Policy أي القواعد تعمل وبأي قوة Strength وبأي حد Threshold. وتقول وثائق ZAP صراحة إن الفحص النشط هجوم على الهدف، وإنه لا يجب أن تستخدمه على تطبيقات لا تملكها، وإنه لا يجد الثغرات المنطقية مثل ضعف التحكم في الصلاحيات Broken Access Control.
والصورة التالية تبين الفرق بين الفحصين:

لاحظ أن الفحص النشط يرسل النماذج كما هي، فإذا كان في تطبيقك نموذج «احذف حسابي» أو «أرسل رسالة لكل العملاء» فسوف يرسله الفاحص، لذلك لا تشغله على بيئة فيها بيانات حقيقية أو خدمات خارجية حقيقية، وهذا ما ذكرناه عن عزل بيئات المعاينة في البيئات ومعاينة الميزات: حسابات Sandbox للدفع، والبريد يذهب إلى صندوق التقاط، وقاعدة بيانات خاصة بالمعاينة.
التنبيهات Alerts: درجة الخطورة ودرجة الثقة
كل مشكلة يجدها ZAP تصبح تنبيهاً Alert فيه اسم القاعدة ورقمها، والرابط والمعامل، والدليل Evidence الذي وجده في الاستجابة، ومعه قيمتان مختلفتان:
- درجة الخطورة Risk: معلوماتي Informational أو منخفض Low أو متوسط Medium أو مرتفع High، وتعني حجم الضرر إذا كانت المشكلة حقيقية.
- درجة الثقة Confidence: منخفضة Low أو متوسطة Medium أو مرتفعة High، وتعني مدى تأكد القاعدة من أن ما وجدته مشكلة حقيقية. وهناك قيمتان أخريان هما False Positive وConfirmed، ولا يضعهما ZAP بنفسه أبداً، وإنما تضعهما أنت بعد المراجعة أو عبر مرشح التنبيهات Alert Filter.
والسبب في الفصل بينهما أن تنبيهاً مرتفع الخطورة منخفض الثقة يحتاج مراجعة قبل أن يوقف الخط، وتنبيهاً منخفض الخطورة مرتفع الثقة قد يكون أسهل إصلاحاً من أن تناقشه. وقد شرحنا ترتيب النتائج والتعامل مع الإنذارات الخاطئة False Positives بشكل عام في المقال الرئيسي للسلسلة.
ما الذي يجده الفحص الديناميكي DAST، وما الذي يفوته؟
الفحص الديناميكي يجد كل ما يظهر في استجابة HTTP لصفحة وصل إليها، أي الترويسات التي شرحناها في ترويسات الأمان، وخصائص ملفات تعريف الارتباط التي شرحناها في الجلسات وملفات تعريف الارتباط، وإعداد ال Reverse Proxy وال TLS كما هو منشور، وصفحات الأخطاء ووضع التطوير، والحقن الذي يستطيع أن يثيره من الخارج. أما ما يفوته فهو الكود الذي لا يصل إليه، ومنطق العمل Business Logic مثل كوبون يطبق مرتين أو سعر يرسله المتصفح، وقراءة مستخدم لبيانات مستخدم آخر، وهذه يكشفها اختبار يبحث عنها صراحة كما رأينا في أنواع الاختبارات، ومراجعة إنسان يعرف التطبيق، وتوصي إرشادات OWASP لاختبار منطق العمل بأن هذا النوع يحتاج من يفهم ما يجب أن يفعله التطبيق. والصورة التالية تجمع ذلك، ومعه IAST الذي نشرحه بعد قليل:

ثلاثة أنواع من الفحص Scan Types في ZAP
يوفر ZAP Docker Images رسمية فيها ثلاثة سكربتات جاهزة Packaged Scans، وهي ما تستخدمه في خط CI غالباً:
- الفحص الأساسي zap-baseline.py: يزحف دقيقة واحدة افتراضياً، ثم ينتظر الفحص السلبي ويكتب النتائج، ولا يرسل أي طلب اختبار، لذلك هو سريع ويناسب كل طلب دمج.
- الفحص الكامل zap-full-scan.py: يزحف دون حد زمني افتراضياً ثم يشغل الفحص النشط كاملاً، وتنبه الوثائق إلى أنه ينفذ هجمات فعلية وقد يستمر وقتاً طويلاً، لذلك مكانه الجدولة الليلية على staging.
- فحص ال API zap-api-scan.py: لا يزحف، وإنما يقرأ تعريف ال API من ملف OpenAPI أو SOAP أو من عنوان GraphQL، فيعرف كل مسار وكل معامل، ثم يشغل الفحص النشط عليها، ومعه الخيار
-Sالذي يكتفي بالفحص السلبي.
وال Images متاحة على Docker Hub باسم zaproxy/zap-stable، وعلى سجل GitHub باسم ghcr.io/zaproxy/zaproxy:stable، ومعها النسخ weekly وnightly وbare.
الفحص بعد تسجيل الدخول Authenticated Scan
أغلب الصفحات المهمة في أي تطبيق تقع خلف تسجيل الدخول، والفاحص الذي لا يعرف كيف يسجل الدخول سوف يرى صفحة الدخول فقط، وتقول وثائق ZAP عن المصادقة ذلك صراحة: إذا كان تطبيقك يستخدم المصادقة فيجب أن تعد ZAP للتعامل معها، وإلا فلن يصل إلى الجزء المحمي. ويجمع ZAP هذا الإعداد في سياق Context، وفيه ما يلي بحسب إطار الأتمتة Automation Framework:
- الروابط Include وExclude: ما يدخل في الفحص وما يخرج منه، وأهم ما تستثنيه رابط تسجيل الخروج، والسبب أن العنكبوت سوف ينقر عليه فيخرج من الجلسة ويكمل بقية الفحص كزائر.
- طريقة الدخول Authentication Method: manual أو http أو form أو json أو script أو autodetect أو browser أو client، وأبسطها form لنموذج دخول عادي، وbrowser للتطبيقات التي تسجل الدخول عبر JavaScript أو عبر SSO.
- التحقق Verification: كيف يعرف ZAP أنه ما زال داخل الجلسة، بنص يظهر في الصفحة بعد الدخول loggedInRegex أو نص يظهر بعد الخروج loggedOutRegex، أو بطلب دوري لرابط محدد poll.
- إدارة الجلسة Session Management: cookie أو http أو script، أي هل يحمل الجلسة ملف تعريف ارتباط أو ترويسة.
- المستخدمون Users: اسم المستخدم وكلمة المرور، ويمكن أن يضاف سر TOTP للتطبيقات التي تطلب رمزاً ثانياً.
والصورة التالية تبين الفرق بين الفحصين على تطبيقنا:

استخدم مستخدم اختبار خاصاً بالفحص، بصلاحيات المستخدم العادي أولاً ثم بصلاحيات المدير في فحص آخر، ولا تستخدم حساب شخص حقيقي، والسبب أن الفحص النشط سوف يغير إعدادات هذا الحساب ويرسل نماذجه، وكلمة المرور تأتي من أسرار خط CI وليس من ملف في المستودع، كما شرحنا في اكتشاف الأسرار المسربة.
Nuclei وNikto وIAST: متى تحتاج غير ZAP؟
Nuclei: قوالب Templates صغيرة لمشكلات معروفة
Nuclei من ProjectDiscovery يعمل بطريقة مختلفة عن ZAP، فهو لا يزحف على التطبيق ولا يبني شجرة موقع، وإنما يشغل قوالب Templates مكتوبة بصيغة YAML، كل قالب يصف طلباً أو أكثر وشروط مطابقة Matchers على الاستجابة، مثل قالب يتحقق من وجود ترويسات الأمان، وقالب يتعرف على لوحة إدارة معروفة مكشوفة، وقالب لثغرة لها رقم CVE في منتج محدد. والقوالب تأتي من مستودع nuclei-templates الذي يحدثه المجتمع باستمرار، وتستطيع أن تكتب قوالبك الخاصة لمشكلة وجدتها مرة في تطبيقك حتى لا تعود.
ونقاط قوته أنه سريع وأن نتائجه محددة، فالقالب يطابق شيئاً محدداً أو لا يطابقه، لذلك هو مناسب لفحص كل الخدمات التي تملكها بحثاً عن ثغرة نشرت اليوم، أما نقطة ضعفه فهي أنه لا يجد إلا ما كتب له قالب، فهو لا يفهم نماذج تطبيقك ولا يجرب كل معامل كما يفعل الفحص النشط في ZAP. ولاحظ أن حد السرعة الافتراضي في وثائق Nuclei هو 150 طلباً في الثانية، وهذا رقم كبير على تطبيق صغير أو بيئة معاينة، لذلك حدده بنفسك بالخيار -rl كما سوف ترى في التجربة.
Nikto: أداة قديمة ما زالت تعمل
Nikto فاحص لسيرفرات الويب بدأ عام 2001 وما زال يصدر إصدارات جديدة، ويبحث عن الملفات والمسارات الخطرة المعروفة وإصدارات السيرفرات القديمة وأخطاء الإعداد. لكنه يعمل بقاعدة بيانات من الفحوص المعروفة مثل Nuclei، ولا يفهم التطبيق مثل ZAP، ونتائجه فيها ضجيج أكثر، ولاحظ أن ترخيصه فيه تفصيل: الكود بترخيص GPL-3.0، أما ملفات قاعدة البيانات فليست بترخيص GPL ولا توزع إلا مع Nikto نفسه بحسب ملف الترخيص في المستودع. وفي خط CI جديد اليوم فإن ZAP مع Nuclei يغطيان ما يغطيه Nikto وأكثر، لذلك لن نستخدمه في التجربة.
IAST: وكيل Agent داخل التطبيق
الفحص التفاعلي Interactive Application Security Testing واختصاراً IAST يضع وكيلاً Agent داخل التطبيق العامل نفسه، مكتبة تحمل مع التطبيق في Java أو .NET أو Node.js مثلاً، فتراقب الكود من الداخل بينما ترسل الاختبارات أو الفاحص الديناميكي الطلبات من الخارج، فإذا وصلت مدخلات المستخدم إلى استعلام SQL دون معالجة عرف الوكيل الطلب الذي أوصلها والملف والسطر معاً. ويشرح دليل OWASP DevSecOps هذا النوع ومكانه في الخط. ونقاط قوته أنه يجمع ميزة الفحصين، فيرى الطلب الحقيقي ويشير إلى السطر، وإنذاراته الخاطئة أقل، أما نقاط ضعفه فهي أنه يحتاج وكيلاً لكل لغة وبيئة تشغيل، ولا يرى إلا الكود الذي مرت عليه الاختبارات، ويضيف حملاً على التطبيق، والأدوات التي يذكرها دليل OWASP كلها تجارية وبعضها له نسخة مجانية محدودة. لذلك ابدأ بالفحص الثابت والديناميكي، وفكر في IAST عندما تكون لديك اختبارات E2E تغطي التطبيق جيداً كما في اختبارات النهاية إلى النهاية.
الأدوات Tools في جدول واحد
| الأداة | الترخيص والإصدار | نقاط القوة | نقاط الضعف |
|---|---|---|---|
| ZAP | Apache-2.0، الإصدار 2.17.0 | زحف وفحص سلبي ونشط وفحص API ومصادقة، وDocker Images رسمية وسكربتات جاهزة لخط CI، وملف قواعد للبوابة | الفحص الكامل بطيء، وإعداد المصادقة يحتاج وقتاً، وبعض التنبيهات تحتاج مراجعة |
| Nuclei | MIT، الإصدار 3.11.1، والقوالب MIT | سريع ونتائجه محددة، وآلاف القوالب لثغرات معروفة، وقوالبك الخاصة بصيغة YAML بسيطة | لا يجد إلا ما له قالب، ولا يفهم نماذج التطبيق، وسرعته الافتراضية عالية |
| Nikto | GPL-3.0 للكود، وقاعدة البيانات بترخيص خاص، الإصدار 2.6.1 | بسيط ويفحص أخطاء إعداد السيرفر والمسارات المعروفة | ضجيج أكثر، ولا يفهم التطبيق، ولا توجد بوابة جاهزة لخط CI |
| أدوات IAST | تجارية، وبعضها بنسخة مجانية محدودة | ترى الطلب والسطر معاً، وإنذارات خاطئة أقل | وكيل لكل لغة، وتعتمد على تغطية الاختبارات، وتضيف حملاً |
ولاحظ أن ZAP بدأ مشروعاً من مشاريع OWASP باسم OWASP ZAP، ثم انضم فريقه الأساسي إلى Checkmarx في سبتمبر 2024 وأصبح اسمه ZAP by Checkmarx، وبحسب إعلان المشروع فإنه يبقى مفتوح المصدر بترخيص Apache-2.0 وتحت إدارة فريقه الأساسي، لذلك سوف تجد الاسمين في المقالات القديمة والحديثة.
على أي هدف Target يحق لك أن تشغل الفحص؟
هذا القسم أهم من كل الأوامر في هذا المقال، والسبب أن الفحص النشط وNuclei يرسلان طلبات تشبه الهجوم تماماً، وسجلات السيرفر وجدار الحماية لا تعرف نيتك، وتشغيلها على نظام لا تملكه أو لم تحصل على إذن مكتوب بفحصه قد يعد دخولاً غير مصرح به تعاقب عليه قوانين الجرائم المعلوماتية، مهما كانت نيتك حسنة. وفيما يلي القواعد التي نلتزم بها:
- أهدافك فقط: شغل الفحص على تطبيق تملكه أو على نظام معك إذن مكتوب بفحصه يحدد النطاق والوقت، ولا تشغله على موقع عميل أو مورد أو منافس لأنك «تريد أن ترى فقط».
- ليس على الإنتاج: مكان الفحص النشط بيئة المعاينة أو staging، أما الإنتاج فلا يفحص فحصاً نشطاً إلا بموافقة مكتوبة من صاحب النظام ونافذة زمنية متفق عليها وفريق يراقب، والسبب أن الفحص النشط يرسل النماذج ويملأ قاعدة البيانات والسجلات وقد يبطئ الخدمة.
- البيئات المستضافة: إذا كان التطبيق على سحابة أو خلف شبكة توزيع محتوى CDN فاقرأ سياسة اختبار الاختراق عند المزود قبل الفحص، لأن الطلبات تمر على أجهزته هو أيضاً.
- السرعة Rate Limiting: حدد عدد الطلبات في الثانية وعدد الخيوط Threads، وأضف ترويسة تعرف طلبات الفاحص كما تنصح وثائق Nuclei، حتى يعرف من يقرأ السجلات أنها فحص مجدول وليست هجوماً.
- البيانات: لا تفحص بيئة فيها بيانات عملاء حقيقية، ولا تستخدم حساباً حقيقياً، وتذكر أن تقرير الفحص نفسه يحتوي على طلبات واستجابات من التطبيق، فعامله كملف حساس.
وسوف تلاحظ أن كل الفحوص في التجربة التالية تعمل على تطبيق السلسلة في Container على جهازنا، على العنوان 127.0.0.1 أو على شبكة Docker داخلية، ولا يخرج منها أي طلب إلى الإنترنت إلا تحميل قوالب Nuclei.
تجربة عملية Lab: نفحص تطبيق السلسلة من الخارج
سوف نستخدم تطبيق السلسلة التجريبي، وهو متجر صغير مكتوب ب Flask فيه ثغرات متعمدة كل منها عليها تعليق VULN-xx، وقد شرحنا فكرته في المقال الرئيسي للسلسلة. وأضفنا في نسختنا من التطبيق ثلاثة أشياء تخص هذا المقال فقط، كل منها عليه تعليق DAST-LAB-xx: ملف تعريف ارتباط اسمه cart دون الخاصيتين HttpOnly وSameSite، وصفحة /account لا يصلها إلا من سجل الدخول وفيها انعكاس لمدخلات المستخدم دون تهريب، و API في /api/products بالثغرة نفسها التي في صفحة البحث ومعه ملف openapi.json يصفه، ومفتاح HARDEN=1 في متغيرات البيئة يشغل إصلاح الترويسات وملفات تعريف الارتباط حتى نعيد الفحص بعده.
وكل الفحوص تعمل على جهازنا فقط: التطبيق في Container اسمه lab-dso-dast-app على شبكة Docker داخلية، واسمه على الشبكة app، ومنشور على 127.0.0.1:19905 لنفتحه من المتصفح، والفاحص في Container آخر على الشبكة نفسها. والأدوات المستخدمة هي ZAP 2.17.0 من ال Image الرسمية ghcr.io/zaproxy/zaproxy:stable، وNuclei 3.11.1 مع القوالب 10.5.0.
تجهيز التطبيق والشبكة Network
الأوامر التالية تبني التطبيق وتشغله، ولاحظ أن كلمة مرور المدير تأتي من متغير بيئة وليست في الكود:
docker build -t lab-dso-dast-app:1 .
docker network create lab-dso-dast-net
docker run -d --name lab-dso-dast-app --network lab-dso-dast-net --network-alias app \
-e ADMIN_PASSWORD=Lab-Only-Pass-91 -p 127.0.0.1:19905:8000 lab-dso-dast-app:1
mkdir -p wrk
الفحص الأساسي Baseline: ماذا يرى الفحص السلبي؟
نبدأ بالفحص الأساسي، ونطلب منه تقريراً بصيغة HTML وآخر بصيغة JSON، وملف قواعد مبدئياً بالخيار -g فيه كل القواعد بالقيمة WARN:
docker run --rm --name lab-dso-dast-zap --network lab-dso-dast-net \
-v "$(pwd)/wrk:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://app:8000 -r baseline.html -J baseline.json -g gen.conf
echo "exit=$?"
والمخرج سوف يكون كما يلي، بعد أن حذفنا 57 سطراً تبدأ ب PASS للقواعد التي لم تجد شيئاً:
Total of 6 URLs
WARN-NEW: Cookie No HttpOnly Flag [10010] x 2
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
WARN-NEW: Missing Anti-clickjacking Header [10020] x 4
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
http://app:8000/login (200 OK)
http://app:8000/search?q=ZAP (200 OK)
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 4
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
http://app:8000/login (200 OK)
http://app:8000/search?q=ZAP (200 OK)
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 7
http://app:8000/ (200 OK)
http://app:8000/robots.txt (404 NOT FOUND)
http://app:8000 (200 OK)
http://app:8000/sitemap.xml (404 NOT FOUND)
http://app:8000/login (200 OK)
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 7
http://app:8000/ (200 OK)
http://app:8000/robots.txt (404 NOT FOUND)
http://app:8000 (200 OK)
http://app:8000/sitemap.xml (404 NOT FOUND)
http://app:8000/login (401 UNAUTHORIZED)
WARN-NEW: Cookie without SameSite Attribute [10054] x 2
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
WARN-NEW: Permissions Policy Header Not Set [10063] x 7
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
http://app:8000/robots.txt (404 NOT FOUND)
http://app:8000/sitemap.xml (404 NOT FOUND)
http://app:8000/login (200 OK)
WARN-NEW: Absence of Anti-CSRF Tokens [10202] x 1
http://app:8000/login (200 OK)
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 9
http://app:8000/ (200 OK)
http://app:8000/ (200 OK)
http://app:8000/ (200 OK)
http://app:8000 (200 OK)
http://app:8000 (200 OK)
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 9 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 57
exit=2
في المخرج أعلاه لاحظ التالي:
- العنكبوت وجد 6 روابط فقط، منها robots.txt وsitemap.xml اللذان يطلبهما دائماً، ولا يوجد بينها
/accountولا/api/products، والسبب أن الصفحة الأولى لا تحتوي على رابط لأي منهما، وهذا ما ذكرناه عن الزحف: ما لم يصل إليه الفاحص لن يفحص. - كل التنبيهات من الفحص السلبي، أي من قراءة الاستجابات فقط: ترويسات الأمان الغائبة التي شرحناها في ترويسات الأمان، وملف تعريف الارتباط cart دون HttpOnly وSameSite، وترويسة Server التي تكشف
Werkzeug/2.2.2 Python/3.8.17. وهذه كلها لا يراها الفحص الثابت لأنها سلوك التطبيق وإطار العمل وقت التشغيل. - رمز الخروج 2، أي توجد تحذيرات ولا يوجد FAIL، لأننا لم نعط الفاحص ملف قواعد بعد، وسوف نحول هذا الرقم إلى بوابة بعد قليل.
- الثغرتان الأخطر في صفحة البحث، الحقن والانعكاس، غير موجودتين في المخرج، والسبب أن الفحص الأساسي لا يرسل طلبات اختبار أصلاً، فهو يرى أن الصفحة بلا CSP لكنه لا يعرف أن المدخلات تعود إليها دون تهريب.
والصورة التالية تبين بداية تقرير HTML الذي كتبه الفحص، وفيه ملخص التنبيهات حسب درجة الخطورة ثم قائمتها:

الفحص الكامل Full Scan: الآن يجرب بنفسه
نشغل الآن الفحص الكامل على التطبيق نفسه، مع دقيقتين للعنكبوت، وهو يشغل الفحص النشط بعد الزحف:
docker run --rm --name lab-dso-dast-zapfull --network lab-dso-dast-net \
-v "$(pwd)/wrk:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py -t http://app:8000 -m 2 -r full.html -J full.json
echo "exit=$?"
استغرق الفحص 325 ثانية على تطبيق فيه ست صفحات، وانتهى برمز الخروج 2. وبدل أن ننسخ المخرج كما هو، كتبنا سكربتاً صغيراً يقرأ تقرير JSON ويطبع سطراً لكل تنبيه فيه رقم القاعدة ودرجة الخطورة والثقة والمسار والمعامل، دون التنبيهات المعلوماتية:
import json, sys
# Prints one line per alert: rule id, risk (confidence), name, and the first place it was seen.
for site in json.load(open(sys.argv[1]))["site"]:
for a in site["alerts"]:
if a["riskdesc"].startswith("Informational"):
continue
i = a["instances"][0]
path = i["uri"].split("?")[0].split("#")[0].replace("http://app:8000", "") or "/"
print(f'{a["pluginid"]:>6} {a["riskdesc"]:<16} {a["name"]} [{path} {i.get("param") or ""}]'.rstrip(" ]") + "]")
وعند تشغيله على تقرير الفحص الكامل بالأمر python alerts.py wrk/full.json سوف تجد المخرج كما يلي:
40026 High (High) Cross Site Scripting (DOM Based) [/]
40012 High (Medium) Cross Site Scripting (Reflected) [/search q]
40018 High (Low) SQL Injection [/search q]
10202 Medium (Low) Absence of Anti-CSRF Tokens [/login]
10038 Medium (High) Content Security Policy (CSP) Header Not Set [/]
10020 Medium (Medium) Missing Anti-clickjacking Header [/ x-frame-options]
10010 Low (Medium) Cookie No HttpOnly Flag [/ cart]
10054 Low (Medium) Cookie without SameSite Attribute [/ cart]
90004 Low (Medium) Cross-Origin-Embedder-Policy Header Missing or Invalid [/ Cross-Origin-Embedder-Policy]
90004 Low (Medium) Cross-Origin-Opener-Policy Header Missing or Invalid [/ Cross-Origin-Opener-Policy]
90004 Low (Medium) Cross-Origin-Resource-Policy Header Missing or Invalid [/ Cross-Origin-Resource-Policy]
10063 Low (Medium) Permissions Policy Header Not Set [/]
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/]
10021 Low (Medium) X-Content-Type-Options Header Missing [/ x-content-type-options]
في المخرج أعلاه لاحظ التالي:
- ظهرت ثلاثة تنبيهات بدرجة خطورة High لم يرها الفحص الأساسي: Cross Site Scripting (Reflected) وSQL Injection على المعامل
qفي/search، وهما الثغرتان VULN-03 وVULN-02 في الكود، وCross Site Scripting (DOM Based) على الصفحة الأولى. - درجة الثقة في تنبيه SQL Injection منخفضة Low، والسبب أن القاعدة استنتجت الثغرة من خطأ 500 أعاده السيرفر، وليس من بيانات خرجت من قاعدة البيانات، لذلك يحتاج هذا التنبيه أن يراجعه أحد قبل أن يحكم عليه، وعند المراجعة سوف تجد السطر الذي يبني الاستعلام بلصق النص في الكود، أي أن التنبيه صحيح.
- التقرير يقول أين وبأي نوع، ولا يقول السطر، فالانتقال من التنبيه إلى السطر عمل المطور أو عمل الفحص الثابت الذي يرى السطر نفسه في مقال SAST.
تنبيه في المكان الخطأ: هل هو إنذار خاطئ False Positive؟
لاحظ التنبيه الأول: Cross Site Scripting (DOM Based) على المسار /، وهذا النوع يعني أن JavaScript في الصفحة يقرأ شيئاً من الرابط ويكتبه في الصفحة. لكن الصفحة الأولى في تطبيقنا لا تحتوي على أي سطر JavaScript، فهي نص ورابط ونموذج بحث فقط، لذلك لا يمكن أن تكون فيها هذه الثغرة. والتفسير الأقرب أن قاعدة DOM XSS تشغل متصفحاً حقيقياً، فأرسل المتصفح نموذج البحث فوصل إلى /search حيث الانعكاس الحقيقي، فنسب التنبيه إلى الصفحة التي بدأ منها. أي أن التنبيه على / إنذار خاطئ False Positive من حيث المكان والنوع، لكنه يشير إلى ثغرة حقيقية في صفحة أخرى، وسوف ترى بعد قليل أنه اختفى وحده عندما أصبحت صفحة البحث لا تنفذ أي سكربت.
وقد يتساءل البعض: لماذا لا نضع هذه القاعدة في ملف القواعد بالقيمة IGNORE وننتهي؟ والإجابة أن ملف القواعد يعمل على مستوى القاعدة كلها، فتجاهل القاعدة 40026 يعني أنك لن ترى أي DOM XSS حقيقي في المستقبل، وهذا أسوأ من التنبيه الخاطئ نفسه. والطريقة الصحيحة على ترتيبها: أصلح السبب الحقيقي أولاً، فإذا بقي تنبيه خاطئ بعد المراجعة فعلمه False Positive لهذا الرابط وحده عبر مرشح التنبيهات alertFilter في إطار الأتمتة، الذي يقبل رقم القاعدة والقيمة الجديدة False Positive مع رابط أو تعبير نمطي Regex للرابط أو للمعامل، واكتب السبب في تعليق بجانبه حتى يعرف من يأتي بعدك لماذا تجاهلته.
الفحص بعد تسجيل الدخول Authenticated Scan يصل إلى /account
الفحصان السابقان لم يريا /account، لذلك نكتب خطة لإطار الأتمتة فيها سياق بالدخول عبر النموذج، كما شرحنا في الفحص بعد تسجيل الدخول، والملف wrk/auth.yaml كما يلي:
env:
contexts:
- name: shop
urls:
- http://app:8000
includePaths:
- http://app:8000.*
excludePaths:
- http://app:8000/logout.*
authentication:
method: form
parameters:
loginPageUrl: http://app:8000/login
loginRequestUrl: http://app:8000/login
loginRequestBody: username={%username%}&password={%password%}
verification:
method: response
loggedInRegex: 'Signed in as'
loggedOutRegex: 'Sign in</a>'
sessionManagement:
method: cookie
users:
- name: scanner
credentials:
username: admin
password: ${SCAN_PASSWORD}
parameters:
failOnError: true
progressToStdout: true
jobs:
- type: spider
parameters:
context: shop
user: scanner
maxDuration: 2
- type: passiveScan-wait
parameters:
maxDuration: 5
- type: activeScan
parameters:
context: shop
user: scanner
maxScanDurationInMins: 10
- type: report
parameters:
template: traditional-html
reportDir: /zap/wrk
reportFile: auth.html
- type: report
parameters:
template: traditional-json
reportDir: /zap/wrk
reportFile: auth.json
في الإعداد أعلاه لاحظ التالي:
excludePathsيستثني/logout، ولولاه لنقر العنكبوت على «Sign out» في الصفحة فخرج من الجلسة وأكمل الفحص كزائر دون أن يعرف.loginRequestBodyفيه{%username%}و{%password%}، ويضع ZAP مكانهما بيانات المستخدم من قسمusers.loggedInRegexهو النص الذي يظهر في الصفحة بعد الدخول، وloggedOutRegexهو رابط «Sign in» الذي يظهر للزائر، وبهما يعرف ZAP أن الجلسة انتهت فيسجل الدخول مرة أخرى.- كلمة المرور
${SCAN_PASSWORD}تأتي من متغير بيئة يمرره خط CI من أسراره، ولا تكتب في الملف. - استخدمنا هنا المستخدم admin لأنه الوحيد في التطبيق التجريبي، وفي تطبيقك استخدم مستخدم اختبار خاصاً بالفحص كما ذكرنا.
ونشغل الخطة بالأمر zap.sh -cmd -autorun داخل ال Image نفسها:
docker run --rm --name lab-dso-dast-zapauth --network lab-dso-dast-net \
-e SCAN_PASSWORD=Lab-Only-Pass-91 -v "$(pwd)/wrk:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable zap.sh -cmd -autorun /zap/wrk/auth.yaml
والمخرج سوف يكون كما يلي، بعد حذف أسطر تشغيل Java وأسطر إعداد كل مهمة:
Job spider started
Job spider requesting URL http://app:8000
Job spider found 7 URLs
Job spider finished, time taken: 00:00:02
Job passiveScan-wait started
Job passiveScan-wait finished, time taken: 00:00:04
Job activeScan started
Job activeScan finished, time taken: 00:04:20
Job report started
Job report generated report /zap/wrk/auth.html
Job report finished, time taken: 00:00:02
Job report started
Job report generated report /zap/wrk/auth.json
Job report finished, time taken: 00:00:00
Automation plan succeeded!
العنكبوت وجد هذه المرة 7 روابط بدل 6، والرابط الجديد هو /account الذي ظهر في الصفحة الأولى بعد الدخول، وعند تشغيل السكربت على التقرير python alerts.py wrk/auth.json سوف تجد المخرج كما يلي:
40026 High (High) Cross Site Scripting (DOM Based) [/]
40012 High (Medium) Cross Site Scripting (Reflected) [/account display_name]
40018 High (Low) SQL Injection [/search q]
10038 Medium (High) Content Security Policy (CSP) Header Not Set [/]
10020 Medium (Medium) Missing Anti-clickjacking Header [/ x-frame-options]
10010 Low (Medium) Cookie No HttpOnly Flag [/ cart]
10054 Low (Medium) Cookie without SameSite Attribute [/ cart]
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/]
10021 Low (Medium) X-Content-Type-Options Header Missing [/ x-content-type-options]
لاحظ السطر الثاني: Cross Site Scripting (Reflected) على المعامل display_name في /account، وهي الثغرة DAST-LAB-02 التي لم يرها أي من الفحصين السابقين لأنهما لم يصلا إلى الصفحة أصلاً. وفي تقرير JSON كله لا يوجد أي رابط ل /logout، أي أن الاستثناء عمل والجلسة بقيت حتى نهاية الفحص.
فحص ال API من ملف OpenAPI
ال API في /api/products لا يوجد له رابط في أي صفحة، والعنكبوت لا يقرأ JSON بحثاً عن معاملات، لذلك نعطي الفاحص ملف OpenAPI الذي يصفه:
docker run --rm --name lab-dso-dast-zapapi --network lab-dso-dast-net \
-v "$(pwd)/wrk:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py -t http://app:8000/openapi.json -f openapi -r api.html -J api.json
echo "exit=$?"
استغرق الفحص 116 ثانية وانتهى برمز الخروج 2، وقال في بدايته Number of Imported URLs: 5، أي أنه قرأ المسارات من الملف ثم أضاف إليها ما وجده أثناء الفحص. ومخرج السكربت على تقريره كما يلي:
40018 High (Low) SQL Injection [/api/products q]
100000 Low (High) A Server Error response code was returned by the server [/api/products]
90004 Low (Medium) Cross-Origin-Resource-Policy Header Missing or Invalid [/api/health Cross-Origin-Resource-Policy]
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/api/health]
100001 Low (High) Unexpected Content-Type was returned [/]
10021 Low (Medium) X-Content-Type-Options Header Missing [/api/health x-content-type-options]
ظهرت SQL Injection على المعامل q في /api/products، وهي DAST-LAB-03، ومعها تنبيه بأن السيرفر أعاد خطأ 500 على المسار نفسه، وهذا منطقي لأن الخطأ 500 هو العلامة التي استنتجت منها القاعدة الثغرة. ولاحظ أن ملف OpenAPI يحتوي على مثال لقيمة المعامل q، وكلما كان الملف أدق في أنواع المعاملات وأمثلتها كان فحص ال API أفضل.
ملف القواعد Rules File: ما يفشل الخط وما يتجاهل
رمز الخروج 2 وحده لا يصلح بوابة، لأن كل فحص فيه تحذيرات تقريباً، لذلك نكتب ملف قواعد بصيغة الملف الذي أنشأه الخيار -g: رقم القاعدة ثم Tab ثم القيمة، ثم Tab ثم أي تعليق. والملف wrk/rules.tsv كما يلي:
10038 FAIL (Content Security Policy (CSP) Header Not Set)
10020 FAIL (Anti-clickjacking Header)
10021 FAIL (X-Content-Type-Options Header Missing)
10010 FAIL (Cookie No HttpOnly Flag)
10054 FAIL (Cookie without SameSite Attribute)
90004 IGNORE (Insufficient Site Isolation) COEP/COOP not needed: no cross-origin isolation features. CORP is set by the app. Reviewed in SEC-12
في الإعداد أعلاه لاحظ التالي:
- الترويسات الأساسية وخصائص ملفات تعريف الارتباط أصبحت FAIL، والسبب أنها رخيصة الإصلاح وثابتة في كل صفحة، فلا يوجد سبب لقبول عودتها بعد إصلاحها.
- القاعدة 90004 أصبحت IGNORE، وهي قاعدة واحدة تنبه على ثلاث ترويسات: COEP وCOOP وCORP. وقد ذكرنا في ترويسات الأمان أن COEP لا تضاف إلا إذا طلب التطبيق العزل بين المواقع، لأنها تكسر كل مورد خارجي، وتطبيقنا لا يحتاجه، لذلك قررنا أن التنبيه لا ينطبق علينا، وكتبنا السبب ورقم المراجعة في التعليق نفسه. ولاحظ أن هذا قبول مدروس لتنبيه صحيح تقنياً، وهو مختلف عن الإنذار الخاطئ الذي رأيناه في DOM XSS.
- بقية القواعد تبقى WARN، فتظهر في المخرج ولا تفشل الخط.
ونشغل الفحص الأساسي بالملف، ومعه الخيار -I حتى لا يعيد رمز خطأ بسبب التحذيرات وحدها، والخيار -s لمخرج مختصر:
docker run --rm --name lab-dso-dast-zap --network lab-dso-dast-net \
-v "$(pwd)/wrk:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://app:8000 -c rules.tsv -I -s
echo "exit=$?"
والمخرج سوف يكون كما يلي:
Using the Automation Framework
IGNORE: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 9
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 5
WARN-NEW: Non-Storable Content [10049] x 6
WARN-NEW: Permissions Policy Header Not Set [10063] x 5
WARN-NEW: Authentication Request Identified [10111] x 1
WARN-NEW: Absence of Anti-CSRF Tokens [10202] x 1
FAIL-NEW: Cookie No HttpOnly Flag [10010] x 1
FAIL-NEW: Missing Anti-clickjacking Header [10020] x 3
FAIL-NEW: X-Content-Type-Options Header Missing [10021] x 3
FAIL-NEW: Content Security Policy (CSP) Header Not Set [10038] x 4
FAIL-NEW: Cookie without SameSite Attribute [10054] x 1
FAIL-NEW: 5 FAIL-INPROG: 0 WARN-NEW: 5 WARN-INPROG: 0 INFO: 0 IGNORE: 1 PASS: 56
exit=1
رمز الخروج 1 يعني وجود FAIL واحد على الأقل، وهذا ما سوف يوقف خط CI، ولاحظ أن القاعدة 90004 ظهرت بكلمة IGNORE ولم تحسب في التحذيرات.
إصلاح الترويسات Security Headers وإعادة الفحص
الإصلاح في نسختنا دالة after_request في Flask تضيف الترويسات إلى كل استجابة، ومعها الخاصيتان HttpOnly وSameSite لملف تعريف الارتباط cart والخاصية SameSite لملف الجلسة، وكلها تعمل عندما يكون HARDEN=1:
@app.after_request
def security_headers(resp):
if HARDEN:
resp.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'; form-action 'self'"
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
resp.headers["Cross-Origin-Resource-Policy"] = "same-origin"
resp.headers["Cache-Control"] = "no-store"
return resp
وفي تطبيق حقيقي خلف Reverse Proxy قد تضيف هذه الترويسات في ال Proxy نفسه كما شرحنا في ترويسات الأمان، والفحص الديناميكي لا يهمه أين أضيفت، فهو يرى الاستجابة النهائية فقط، وهذه ميزة مهمة له. والآن نعيد تشغيل التطبيق بالمتغير HARDEN=1 ونتأكد من الترويسات:
docker rm -f lab-dso-dast-app
docker run -d --name lab-dso-dast-app --network lab-dso-dast-net --network-alias app \
-e ADMIN_PASSWORD=Lab-Only-Pass-91 -e HARDEN=1 -p 127.0.0.1:19905:8000 lab-dso-dast-app:1
curl -sI http://127.0.0.1:19905/
والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.8.17
Date: Tue, 06 Oct 2026 12:15:42 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 204
Set-Cookie: cart=empty; HttpOnly; Path=/; SameSite=Lax
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Resource-Policy: same-origin
Cache-Control: no-store
Connection: close
ثم نعيد أمر البوابة نفسه دون أي تغيير، ونضيف إليه -r baseline-hardened.html لنحفظ التقرير، والمخرج سوف يكون كما يلي:
Using the Automation Framework
IGNORE: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 6
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 5
WARN-NEW: Non-Storable Content [10049] x 5
WARN-NEW: Authentication Request Identified [10111] x 1
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 3 WARN-INPROG: 0 INFO: 0 IGNORE: 1 PASS: 63
exit=0
رمز الخروج 0، أي أن البوابة نجحت، وبقي تحذير تسريب الإصدار في ترويسة Server، والسبب أن السيرفر المضمن في Werkzeug يضيفها بنفسه، وفي الإنتاج يكون أمام التطبيق سيرفر حقيقي وReverse Proxy يحذفها كما شرحنا في حماية تطبيقات الويب.
الترويسات Headers لا تصلح الكود
البوابة نجحت، لكنها بوابة الفحص الأساسي الذي لا يجرب الثغرات أصلاً، لذلك أعدنا الفحص الكامل على التطبيق بعد الترويسات، وكان مخرج السكربت على تقريره كما يلي:
40012 High (Medium) Cross Site Scripting (Reflected) [/search q]
40018 High (Low) SQL Injection [/search q]
10202 Medium (Low) Absence of Anti-CSRF Tokens [/login]
90004 Low (Medium) Cross-Origin-Embedder-Policy Header Missing or Invalid [/ Cross-Origin-Embedder-Policy]
90004 Low (Medium) Cross-Origin-Opener-Policy Header Missing or Invalid [/ Cross-Origin-Opener-Policy]
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/]
لاحظ أن تنبيه DOM XSS على / اختفى، والسبب أن قاعدته تحكم بما يحدث في المتصفح، وCSP بالقيمة default-src 'self' تمنع المتصفح من تشغيل السكربت المحقون داخل الصفحة، أما الانعكاس في /search والحقن فما زالا موجودين، لأنهما في الكود والترويسة لا تغير الكود. وهذا يؤكد ما قلناه في شرح CSP: هي خط دفاع ثان يقلل أثر الثغرة، وليست إصلاحاً لها.
والإصلاح الحقيقي في الكود: استعلام بمعاملات Parameterized Query بدل لصق النص في SQL، وتهريب Escaping كل نص يكتب في HTML. والفرق بين نسختي التطبيق كما يلي:
> from markupsafe import escape
< rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
< items = "".join(f"<li>{r[0]}: {r[1]}</li>" for r in rows)
> rows = db().execute("SELECT name, price FROM products WHERE name LIKE ?", (f"%{q}%",)).fetchall()
> items = "".join(f"<li>{escape(r[0])}: {r[1]}</li>" for r in rows)
< return PAGE.format(f"<h1>Results for {q}</h1><ul>{items}</ul>")
> return PAGE.format(f"<h1>Results for {escape(q)}</h1><ul>{items}</ul>")
< f"<p>Signed in as {session['user']}</p><h1>Hello {name}</h1>"
> f"<p>Signed in as {escape(session['user'])}</p><h1>Hello {escape(name)}</h1>"
< rows = db().execute(f"SELECT name, price FROM products WHERE name LIKE '%{q}%'").fetchall()
> rows = db().execute("SELECT name, price FROM products WHERE name LIKE ?", (f"%{q}%",)).fetchall()
وفي تطبيق حقيقي استخدم قوالب Jinja2 التي تهرب النص تلقائياً بدل بناء HTML بالنصوص. وبعد بناء ال Image الجديدة lab-dso-dast-app:2 وتشغيلها مع HARDEN=1، أعدنا الفحوص الثلاثة النشطة بالأوامر نفسها، وكان مخرج السكربت على تقاريرها كما يلي:
== full-fixed
10202 Medium (Low) Absence of Anti-CSRF Tokens [/login]
90004 Low (Medium) Cross-Origin-Embedder-Policy Header Missing or Invalid [/ Cross-Origin-Embedder-Policy]
90004 Low (Medium) Cross-Origin-Opener-Policy Header Missing or Invalid [/ Cross-Origin-Opener-Policy]
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/]
== auth-fixed
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/]
== api-fixed
10036 Low (High) Server Leaks Version Information via "Server" HTTP Response Header Field [/api/health]
100001 Low (High) Unexpected Content-Type was returned [/]
اختفت كل تنبيهات High في الفحص الكامل والفحص بعد تسجيل الدخول وفحص ال API. وبقي تنبيه Absence of Anti-CSRF Tokens على نموذج الدخول في الفحص الكامل، وهو صحيح، فالتطبيق التجريبي لا يستخدم توكن CSRF، وإصلاحه بمكتبة مثل Flask-WTF أو بالاعتماد على SameSite كما شرحنا في CORS وCSRF وSameSite، ونتركه هنا WARN في البوابة حتى يصلح في مهمة مستقلة.
Nuclei بقوالب Templates محددة
نحمل القوالب أولاً إلى Volume حتى لا تحمل مع كل تشغيل، ثم نشغل Nuclei بأربعة قوالب محددة بالخيار -id، وبحد عشرة طلبات في الثانية وخمسة قوالب بالتوازي، وبترويسة تعرف طلباتنا في السجلات. وقد شغلناه على التطبيق قبل الإصلاح:
docker volume create lab-dso-dast-nuclei
docker run --rm -v lab-dso-dast-nuclei:/root projectdiscovery/nuclei:v3.11.1 -ut
docker run --rm --name lab-dso-dast-nuclei --network lab-dso-dast-net \
-v lab-dso-dast-nuclei:/root projectdiscovery/nuclei:v3.11.1 \
-u http://app:8000 -id http-missing-security-headers,flask-detect,flask-werkzeug-debug,tech-detect \
-rl 10 -c 5 -H "X-Scanner: arabroot-lab" -nc
والمخرج سوف يكون كما يلي، بعد حذف شعار الأداة:
[INF] Current nuclei version: v3.11.1 (latest)
[INF] Current nuclei-templates version: v10.5.0 (latest)
[INF] New templates added in latest release: 295
[INF] Templates loaded for current scan: 4
[INF] Executing 4 signed templates from projectdiscovery/nuclei-templates
[INF] Targets loaded for current scan: 1
[http-missing-security-headers:cross-origin-opener-policy] [http] [info] http://app:8000
[http-missing-security-headers:permissions-policy] [http] [info] http://app:8000
[http-missing-security-headers:x-frame-options] [http] [info] http://app:8000
[http-missing-security-headers:x-content-type-options] [http] [info] http://app:8000
[http-missing-security-headers:cross-origin-embedder-policy] [http] [info] http://app:8000
[http-missing-security-headers:cross-origin-resource-policy] [http] [info] http://app:8000
[http-missing-security-headers:strict-transport-security] [http] [info] http://app:8000
[http-missing-security-headers:content-security-policy] [http] [info] http://app:8000
[http-missing-security-headers:x-permitted-cross-domain-policies] [http] [info] http://app:8000
[http-missing-security-headers:referrer-policy] [http] [info] http://app:8000
[tech-detect:python] [http] [info] http://app:8000
[INF] Scan completed in 3.020984663s. 11 matches found.
[INF] HTTP connections: 11 total, 11 new, 0 reused (0.0%)
في المخرج أعلاه لاحظ التالي:
- القالب
http-missing-security-headersطلب الصفحة مرة واحدة وأعطى سطراً لكل ترويسة غائبة، وهي النتيجة نفسها التي أعطاها الفحص السلبي في ZAP بشكل أبسط، وكل النتائج بدرجة info، أي أن Nuclei يعتبرها معلومات وليست ثغرات. - القالب
tech-detectتعرف على Python، والقالبانflask-detectوflask-werkzeug-debugلم يطابقا شيئاً، والثاني نتيجة جيدة لأنه يبحث عن وضع التطوير في Werkzeug مكشوفاً، وهو مطفأ في تطبيقنا. - 11 طلباً في 3 ثوان، وهذا هو الفرق بين Nuclei والفحص النشط في ZAP: قوالب محددة تعرف ما تبحث عنه، بدل تجربة كل نوع على كل معامل.
- ظهرت strict-transport-security في النتائج لأن التطبيق يعمل على HTTP في التجربة، وفي الإنتاج تضيفها على HTTPS كما شرحنا في HSTS.
إزالة التجربة Cleanup
نزيل ما أنشأناه بالأسماء نفسها، ولا نستخدم أوامر prune لأنها تحذف ما يخص مشاريع أخرى على الجهاز:
docker rm -f lab-dso-dast-app
docker network rm lab-dso-dast-net
docker volume rm lab-dso-dast-nuclei
docker rmi lab-dso-dast-app:1 lab-dso-dast-app:2
الفحص الديناميكي DAST في خط CI على بيئة المعاينة Preview Environment
الفحص الديناميكي يحتاج تطبيقاً يعمل، لذلك مكانه في الخط بعد بناء ال Docker Image وتشغيلها، وأنسب مكان لذلك بيئة المعاينة التي شرحناها في بيئة معاينة لكل طلب دمج، والسبب أنها مبنية من ال Image نفسها التي سوف تصل إلى الإنتاج، ولها قاعدة بيانات خاصة بها، فلا يضر أحداً أن يملأها الفحص ببيانات الاختبار. والصورة التالية تبين مكان كل فحص:

والملف التالي مثال لمهمة في Gitea Actions تشغل الفحص الأساسي على رابط المعاينة بعد نشرها، وهو مكتوب للتوضيح بالأوامر نفسها التي شغلناها في التجربة، أما خط CI الكامل الذي يجمع كل الفحوص فهو موضوع مقال الخط الآمن:
dast-baseline:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ZAP baseline against the preview environment
run: |
mkdir -p zap-wrk && cp .zap/rules.tsv zap-wrk/
docker run --rm -v "$PWD/zap-wrk:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t "https://pr-${{ github.event.number }}.preview.example.com" \
-c rules.tsv -I -r report.html -J report.json
- name: Keep the report
if: always()
uses: actions/upload-artifact@v4
with:
name: zap-baseline
path: zap-wrk/report.*
في الإعداد أعلاه لاحظ التالي:
- المهمة تعتمد على مهمة نشر المعاينة
needs: deploy-preview، فلا تبدأ إلا بعد أن يعمل التطبيق. - ملف القواعد في المستودع نفسه في
.zap/rules.tsv، أي أن كل تغيير فيه، خاصة كل IGNORE جديد، يمر بمراجعة طلب الدمج مثل أي كود. - الخيار
-Iيجعل رمز الخروج 0 مع التحذيرات، فلا يوقف الخط إلا FAIL، وهذا ما رأيناه في التجربة: 1 قبل الإصلاح و0 بعده. - التقرير يرفع كملف مرفق حتى عند الفشل
if: always()، والسبب أن المطور يحتاجه بالذات عندما يفشل الخط. - إذا كانت بيئة المعاينة خلف دخول موحد SSO كما نصحنا في حماية بيئات المعاينة، فشغل الفاحص من داخل الشبكة الداخلية مباشرة على التطبيق، أو أعطه مستخدماً في سياق كما في الفحص بعد تسجيل الدخول.
وإذا كنت على GitHub فيوجد Action رسمي باسم zaproxy/action-baseline يشغل الفحص نفسه ويقبل ملف القواعد عبر المعامل rules_file_name. أما الفحص الكامل والفحص بعد تسجيل الدخول وفحص ال API وNuclei فشغلها كمهمة مجدولة كل ليلة على staging، لأن الفحص الكامل وحده استغرق أكثر من خمس دقائق على تطبيق فيه ست صفحات، والتطبيق الحقيقي يأخذ ساعات، وأرسل نتائجها إلى مكان واحد للمتابعة كما سوف نشرح في مقال الخط الآمن.
أخطاء شائعة Common Mistakes
- فحص نشط على الإنتاج «مرة واحدة فقط»: الفحص يرسل النماذج ويملأ قاعدة البيانات والبريد والسجلات، وقد يطلق تنبيهات فريق الأمن أو يحظر عنوان الخط.
- فحص موقع لا تملكه: حتى لو كان موقع عميل تعمل عنده، فلا تفحصه دون إذن مكتوب يحدد النطاق والوقت.
- الاكتفاء بالفحص الأساسي: رأيت في التجربة أن البوابة نجحت والحقن والانعكاس ما زالا موجودين، لأن الفحص الأساسي لا يجربهما أصلاً.
- الفحص دون تسجيل دخول: النتيجة الخضراء تعني أن الصفحات العامة سليمة، ولا تقول شيئاً عن بقية التطبيق.
- نسيان استثناء /logout: يخرج الفاحص من الجلسة في الدقيقة الأولى ويكمل كزائر، ويبدو التقرير نظيفاً.
- IGNORE لقاعدة كاملة بسبب تنبيه خاطئ واحد: تفقد معها كل تنبيه حقيقي من النوع نفسه، فأصلح السبب أو استخدم مرشح التنبيهات لرابط محدد.
- الاعتماد على CSP بدل إصلاح الكود: اختفى تنبيه DOM XSS بعد CSP، لكن الثغرة في الكود بقيت حتى أصلحناها.
- سرعة Nuclei الافتراضية على بيئة صغيرة: 150 طلباً في الثانية قد تسقط بيئة معاينة على سيرفر صغير، فحدد
-rlدائماً. - حساب حقيقي في الفحص، أو كلمة مرور في ملف الخطة: استخدم مستخدم اختبار، وكلمة المرور من أسرار الخط.
قائمة تحقق Checklist
- كل الأهداف تملكها أو معك إذن مكتوب بفحصها، والفحص النشط على المعاينة أو staging فقط.
- الفحص الأساسي يعمل على كل طلب دمج ضد بيئة المعاينة، ويفشل الخط عند FAIL.
- ملف القواعد في المستودع، وكل IGNORE معه سبب مكتوب ورقم مراجعة.
- الفحص الكامل والفحص بعد تسجيل الدخول وفحص ال API تعمل كل ليلة على staging.
- مستخدم اختبار خاص بالفحص، و
/logoutمستثنى، ونص يثبت حالة الدخول. - ملف OpenAPI محدث ودقيق لكل API.
- Nuclei بقوالب مختارة وحد سرعة وترويسة تعرف طلباتك.
- التقارير تحفظ مع كل تشغيل، وتعامل كملفات حساسة.
- كل تنبيه High يراجعه إنسان، وينتهي بإصلاح في الكود أو بتعليم مدروس مع السبب.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الفحص الديناميكي يرى التطبيق كما يراه المستخدم والمخترق، فيجد ما لا يوجد في الكود: الترويسات وخصائص ملفات تعريف الارتباط وتسريب الإصدار والإعداد كما هو منشور، ومعها الحقن الذي يستطيع أن يثيره من الخارج.
- لا يفحص إلا ما وصل إليه، لذلك رأيت في التجربة أن
/accountلم تظهر إلا بعد إعداد سياق بالدخول، وأن ال API لم يظهر إلا من ملف OpenAPI. - الفحص السلبي يراقب فقط ويناسب كل طلب دمج، والفحص النشط هجوم فعلي مكانه بيئة تملكها ولا يضرها.
- ملف القواعد يحول النتيجة إلى بوابة: FAIL لما لا تقبل عودته، وIGNORE مع سبب مكتوب لما لا ينطبق عليك، وأصلح السبب قبل أن تتجاهل أي قاعدة.
- الترويسات خط دفاع ثان، والإصلاح الحقيقي للحقن والانعكاس في الكود: استعلام بمعاملات وتهريب للمخرجات.
- Nuclei مكمل سريع لمشكلات معروفة بقوالب محددة، وIAST خيار لاحق عندما تكون اختباراتك تغطي التطبيق جيداً.
وفي المقال القادم بإذن الله سوف نجمع كل الفحوص في هذه السلسلة في خط CI واحد مع البوابات وإدارة النتائج.
دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.
دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.
دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.
دليلفحص ال Docker Images وملفات البنية ككود IaCماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.
دليلخط CI/CD آمن من البداية للنهاية على Gitea Actionsكيف تجمع فحوص الأمن في خط واحد لا يستطيع طلب الدمج تجاوزه، ثم ترسل إلى الإنتاج نفس ال Docker Image الموقعة التي فحصتها.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة على تطبيق السلسلة التجريبي بالفحص الأساسي والكامل وفحص ال API والفحص بعد تسجيل الدخول في ZAP 2.17.0، وملف قواعد كبوابة قبل الإصلاح وبعده، وNuclei 3.11.1 بالقوالب 10.5.0، والتحقق من تراخيص الأدوات من مستودعاتها.