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

Secrets Scanning: كيف تكتشف الأسرار المسربة في الكود

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

Secrets Scanning: كيف تكتشف الأسرار المسربة في الكود

لنفرض أنك أضفت ملف .env إلى المستودع Repository بالخطأ، ثم انتبهت بعد دقائق فحذفته في Commit جديد وأضفته إلى .gitignore، وظننت أن الأمر انتهى. والحقيقة أن الملف ما زال في تاريخ المستودع History، وفي كل نسخة Clone وكل تفرع Fork أخذه أحد قبل الحذف وبعده، وربما في Docker Image بنيتها من المجلد نفسه، وكل من يصل إلى أي منها يستطيع أن يقرأ المفتاح. وهذا المقال جزء من سلسلة DevSecOps، وقد شرحنا في DevSecOps للمبتدئين الصورة الكاملة وأين يقع فحص الأسرار Secrets Scanning بين الفحوص الأخرى، أما هنا فسوف نتعمق في هذا الفحص وحده، على التطبيق التجريبي نفسه الذي تستخدمه السلسلة كلها.

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

  • الطرق التي يخرج بها السر من جهاز المطور: ال Commit والتاريخ والنسخ وسجلات CI وطبقات ال Docker Image، مع حادثة حقيقية بقي فيها مفتاح على GitHub قرابة خمس سنوات.
  • كيف تكتشف الأدوات السر من الداخل: الكلمات المفتاحية والتعبيرات النمطية Regex والعشوائية Entropy، ثم التحقق من المفتاح مع الجهة التي أصدرته Verification، وما الذي تفوته كل طريقة.
  • الأدوات مفتوحة المصدر Gitleaks وTruffleHog وإطار pre-commit وgit filter-repo، بتراخيصها وإصداراتها ونقاط القوة ونقاط الضعف.
  • أين يعمل الفحص: قبل ال Commit، وعند الدفع Push، وفي طلب الدمج Pull Request، وعلى التاريخ كله، وعلى ال Docker Image.
  • الاستثناءات Allowlists بالطريقة الصحيحة، وخطة العمل بعد التسرب: إلغاء المفتاح أولاً، ثم التقييم، ثم تنظيف التاريخ.
  • تجربة عملية كاملة في Containers: مستودع فيه مفتاح ملتزم ثم محذوف، نفحصه بالأداتين، ونتحقق من مفتاح أمام خدمة نشغلها بأنفسنا، ونوقف Commit بخطاف pre-commit، ونعالج إنذاراً خاطئاً، ونستخرج السر من طبقة Docker Image، ثم ننظف التاريخ ونعيد الفحص.

كيف يتسرب السر؟ طرق التسرب Leak Paths

السر Secret هو أي قيمة تعطي من يملكها صلاحية، مثل كلمة مرور قاعدة البيانات، ومفتاح API، وتوكن الدخول إلى GitHub، والمفتاح الخاص Private Key لشهادة أو لحساب SSH. والصورة التالية تبين الطرق التي يخرج بها هذا السر من جهاز المطور، وأغلبها لا يحتاج إلى أي اختراق، وإنما إلى خطأ عادي في يوم عمل عادي:

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

ال Commit والتاريخ History

كل Commit في Git لقطة كاملة لا تتغير، فإذا أضفت ملفاً فيه سر ثم حذفته في Commit لاحق، فإن الحذف يضيف لقطة جديدة لا يوجد فيها الملف، ولكنه لا يغير اللقطة القديمة، والأمر git show يطبع الملف منها في أي وقت. وقد رأينا ذلك خطوة بخطوة في قسم التزام سر ثم حذفه، وسوف نراه هنا بأدوات الفحص نفسها.

السيرفر والنسخ والتفرعات Clones وForks

بعد الدفع Push يصبح السر على سيرفر Git، ثم في كل نسخة يأخذها زميل أو خط CI أو أداة نشر، وفي كل تفرع Fork إذا كان المستودع عاماً. والمستودع الخاص لا يحل المشكلة، فهو مكشوف لكل من له صلاحية القراءة اليوم أو في المستقبل، ولكل من يسرق جهاز أحدهم، ولأي متعاقد يضاف إلى المشروع، وكما سوف ترى في الحادثة بعد قليل فقد يتحول المستودع الخاص إلى عام بخطأ في الإعداد.

سجلات CI Logs

خط CI يحمل عادة أثمن الأسرار، وقد تخرج في السجل بسطر set -x في سكربت Shell، أو بأمر printenv للتشخيص، أو برسالة خطأ تطبع رابط اتصال كاملاً فيه كلمة المرور. وGitea يخفي قيمة كل سر معرف في الإعدادات ويضع مكانها *** كما ذكرنا في دليل Gitea Actions، ولكن الإخفاء يعتمد على مطابقة القيمة حرفياً، لذلك يوصي توثيق GitHub Actions بألا تضع السر داخل كتلة JSON أو YAML، وبأن تسجل أي قيمة مشتقة منه، مثل نسخته المرمزة ب Base64، كسر جديد، والسبب أن القيمة المشتقة لا تطابق الأصل فتظهر كما هي. وقد شرحنا في أمن سلسلة التوريد حوادث سرقة أسرار الخطوط نفسها، فلن نكررها هنا.

طبقات ال Docker Image

السطر COPY . . في ملف Dockerfile ينسخ المجلد كله إلى ال Image، ومعه ملف .env المحلي ومجلد .git بكل تاريخه إذا لم تستثنهما في .dockerignore، ولاحظ أن .gitignore لا يؤثر على Docker إطلاقاً. وإذا أضفت بعد ذلك RUN rm .env فإن الملف يختفي من ال Container الذي يعمل، ولكنه يبقى كاملاً في الطبقة Layer التي أنشأها COPY، وكذلك كل قيمة تمررها بالتعليمة ARG أو ENV تكتب في بيانات ال Image، وسوف نستخرجها بأنفسنا في التجربة.

المحادثات والتذاكر Tickets وكل مكان آخر

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

📌
في 7 أكتوبر 2022 أعلنت Toyota أن شركة متعاقدة لتطوير موقع خدمة T-Connect رفعت في ديسمبر 2017 جزءاً من الكود المصدري للموقع إلى حساب على GitHub بإعداد عام، مخالفة لقواعد التعامل مع الكود، وأن هذا الكود فيه مفتاح وصول Access Key إلى سيرفر البيانات، يمكن به الوصول إلى 296,019 بريداً إلكترونياً ورقم إدارة عميل. وبقي الكود متاحاً حتى 15 سبتمبر 2022، أي قرابة خمس سنوات، فجعلته الشركة خاصاً في اليوم نفسه، وغيرت المفتاح في 17 سبتمبر، وذكرت أن سجلات الوصول لا تثبت دخول طرف ثالث ولا تنفيه بالكامل، بحسب بيان Toyota الرسمي (باليابانية). والدرس أن فحصاً آلياً للمستودع كان سيجد المفتاح في يوم رفعه، وأن الخطوة التي أنهت الخطر هي تغيير المفتاح، وليس إخفاء المستودع.

من المراجعة بالعين إلى الفحص الآلي Automated Scanning

الحل الأول الذي يخطر على البال هو مراجعة الكود Code Review، فالمراجع سوف يلاحظ كلمة مرور في طلب الدمج. وهذا يحدث أحياناً، ولكنه يفشل لأن المراجع يقرأ الفرق Diff الأخير فقط، ولا يقرأ ملف إعداد من مئات الأسطر بالتفصيل، ولا يرى ما دخل في Commit وسيط ثم حذف قبل الطلب، والأهم أن المراجعة تحدث بعد الدفع، أي بعد أن وصل السر إلى السيرفر.

والحل الثاني هو البحث بالأمر grep -ri password قبل كل إصدار، وهذا يفشل من الجهتين، فهو يعطيك مئات النتائج من أسماء متغيرات ونصوص توثيق لا علاقة لها بالأسرار، ويفوته المفتاح الذي لا تظهر بجانبه كلمة password مثل AKIA…، ولا يقرأ التاريخ إلا إذا كتبت له حلقة على كل Commit.

والحل الأنسب هو أداة متخصصة تعرف شكل مئات أنواع المفاتيح، وتقرأ التاريخ كله، وتعمل آلياً في أكثر من نقطة: على جهاز المطور قبل ال Commit حتى لا يخرج السر أصلاً، وفي خط CI حتى لا يتجاوزها أحد، وعلى ال Docker Image قبل أن تصل إلى ال Registry. ومع ذلك تذكر أنه لا توجد أداة تجد كل الأسرار، فالهدف الواقعي أن تجد الأغلب بسرعة، وأن تكون خطة الاستجابة جاهزة لما يفوتها.

كيف تكتشف الأدوات السر؟ Regex والعشوائية Entropy والتحقق Verification

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

مخطط من خمس مراحل: المدخل من فروقات ال Commits أو ملفات أو طبقات Image، ثم الكلمات المفتاحية، ثم Regex، ثم العشوائية Entropy بقيمة 4.80 لمفتاح وجد و3.34 لكلمة مرور فاتت، ثم الاستثناءات، ثم التحقق مع الخدمة في TruffleHog، والنتيجة verified أو unknown أو unverified
Gitleaks يتوقف عند المرحلة الرابعة، وTruffleHog يضيف التحقق مع الجهة التي أصدرت المفتاح

الكلمات المفتاحية Keywords والتعبيرات النمطية Regex

أغلب الخدمات تعطي مفاتيحها شكلاً ثابتاً، فمفتاح الوصول في AWS يبدأ بالحروف AKIA وبعدها 16 حرفاً، وتوكن GitHub الشخصي يبدأ بالبادئة ghp_ وبعدها 36 حرفاً، لذلك تحمل كل أداة مئات القواعد Rules، وكل قاعدة تعبير نمطي Regex يصف شكل مفتاح خدمة معينة. وحتى لا تشغل الأداة مئات التعبيرات على كل سطر، تبدأ بفحص رخيص هو البحث عن كلمة مفتاحية Keyword ثابتة مثل akia، ولا تشغل التعبير إلا إذا وجدتها. وفي ملف القواعد الافتراضي لإصدار Gitleaks الذي استخدمناه 222 قاعدة، وهذه قاعدة AWS منه:

id = "aws-access-token"
regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b'''
entropy = 3
keywords = ["a3t", "akia", "asia", "abia", "acca"]

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

العشوائية Entropy

للأسرار التي ليس لها شكل ثابت تستخدم الأدوات قاعدة عامة، مثل generic-api-key في Gitleaks، تبحث عن اسم يشبه token أو secret أو password وبعده قيمة، ثم تقيس عشوائية القيمة بمقياس Shannon Entropy، أي متوسط عدد البتات اللازمة لتمثيل كل حرف بحسب تكرار الحروف فيها، فالنص aaaaaaaa عشوائيته صفر، والمفتاح المولد آلياً عشوائيته عالية. والقاعدة العامة في Gitleaks لا تقبل إلا ما تزيد عشوائيته على 3.5، والسبب أن ما تحت ذلك يكون في الغالب كلاماً عادياً مثل user_id_and_role، فلو قبلته لامتلأ التقرير بإنذارات خاطئة False Positives. وفي التجربة وجد Gitleaks مفتاح AWS السري لأن عشوائيته 4.80، وفاتته كلمة مرور قاعدة البيانات Sh0p-Db-2026-pa55 لأن عشوائيتها 3.34 فقط، وهذا هو الثمن: كلمة المرور التي اخترتها بنفسك تشبه الكلام فلا تجدها الأداة.

التحقق من المفتاح Verification

هنا يختلف TruffleHog، فبعد أن يجد ما يشبه مفتاحاً يحاول أن يتحقق منه مع الجهة التي أصدرته، فمفتاح AWS مثلاً يرسله في طلب GetCallerIdentity إلى AWS كما يذكر مستودع TruffleHog، فإذا نجح الطلب فالمفتاح يعمل الآن. والنتيجة تكون واحدة من ثلاث:

  • verified: المفتاح يعمل الآن، وهذا إنذار لا يحتمل التأجيل.
  • unknown: حاولت الأداة التحقق ففشلت المحاولة نفسها بسبب الشبكة أو خطأ في الخدمة، فلا تعرف هل المفتاح صالح.
  • unverified: لم يؤكد التحقق أن المفتاح يعمل، إما لأنه ألغي أو مزيف، وإما لأن الأداة لا تملك طريقة لتحقق هذا النوع.

وهذا يرتب أولوياتك عندما تفحص مستودعاً عمره سنوات فتجد خمسين نتيجة، فتبدأ بالمفاتيح التي تعمل. وقد يتساءل البعض: أليس إرسال المفتاح للتحقق منه تسريباً جديداً؟ والإجابة أنه يذهب إلى الخدمة التي أصدرته أصلاً عبر HTTPS، ولكنه يعني أن أداة الفحص تتصل بالإنترنت وتستخدم المفتاح فعلاً، وقد يظهر ذلك في سجلات الحساب المسرب، لذلك يوفر TruffleHog الخيار --no-verification لمن لا يريد ذلك. ولهذا السبب أيضاً لا تقم بتجربة التحقق على مفاتيح حقيقية لخدمات لا تملكها، ولا على خدمات خارجية بمفاتيح مزيفة، وفي تجربتنا سوف نشغل TruffleHog في شبكة Docker داخلية لا تصل إلى الإنترنت، ونتحقق من توكن التطبيق التجريبي أمام خدمة تحقق نكتبها بأنفسنا، عبر ميزة المكتشفات المخصصة Custom Detectors التي ترسل ما تجده إلى عنوان تحدده أنت، فإذا رد بالرمز 200 فالمفتاح صالح.

الأدوات: Gitleaks وTruffleHog وما حولهما

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

الأداةالترخيص والإصدارنقاط القوةنقاط الضعف
GitleaksMIT، الإصدار 8.30.1 (مارس 2026)، والمستودع نشطملف تنفيذي واحد سريع، يعمل دون اتصال بالإنترنت، يفحص التاريخ والمجلد وال stdin، وقواعده وملف استثناءاته بصيغة TOML واضحة، وله خطاف pre-commit رسمي وخيار Baselineلا يتحقق من صلاحية المفتاح، فكل النتائج عنده بالأولوية نفسها، والقاعدة العامة تعتمد على العشوائية فتفوتها كلمات المرور البشرية
TruffleHogAGPL-3.0، الإصدار 3.98.0 (أكتوبر 2026)، ويصدر بشكل متكرريتحقق من المفاتيح مع الخدمات فيرتب الأولويات، ويفحص Git وال Docker Images وملفات النظام وخدمات تخزين كثيرة، ويفك الترميز Encoding مثل Base64، ويدعم مكتشفات مخصصة بخدمة تحقق تملكهاالتحقق يعني اتصالاً بالإنترنت واستخداماً فعلياً للمفتاح، وأبطأ من Gitleaks، ونتائجه غير المتحقق منها في ال Images كثيرة الضجيج كما سوف ترى
pre-commitMIT، الإصدار 4.6.2إطار واحد يدير خطافات Git لكل الأدوات، ويثبت كل خطاف في بيئة منفصلة بإصدار مثبت في ملف الإعداديعمل على جهاز المطور فقط، ويتجاوزه أي مطور بخيار واحد، ولا ينتقل مع git clone فيحتاج كل مطور إلى تثبيته
git filter-repoMIT لأغلب الملفات، الإصدار 2.47.0الأداة التي يوصي بها مشروع Git لإعادة كتابة التاريخ، سريعة، وفيها وضع خاص لحذف البيانات الحساسة يجلب كل المراجع ويخبرك بالخطوات التاليةتعيد كتابة كل ال Commits فتتغير معرفاتها، وتحتاج إلى دفع بالقوة Force Push وتنسيق مع كل من لديه نسخة

ولاحظ أن ترخيص AGPL-3.0 في TruffleHog لا يمنعك من تشغيله أداة فحص داخل خطك، والتزاماته تظهر عندما تعدل الأداة نفسها وتقدمها خدمة لغيرك عبر الشبكة، كما شرحنا في تشغيل برنامج GPL أو AGPL داخل شركتك. وإلى جانب هذه الأدوات تقدم منصات Git المستضافة فحصاً مدمجاً، مثل حماية الدفع Push Protection في GitHub وGitLab، وسوف نذكرها في مكانها بعد قليل.

وقد يتساءل البعض: أيهما أختار؟ والإجابة أن الأداتين تكملان بعضهما، فGitleaks مناسب لخطاف pre-commit ولكل طلب دمج لأنه سريع ولا يتصل بشيء، وTruffleHog مناسب للفحص الدوري للتاريخ كله ولل Docker Images، لأن التحقق يخبرك أي النتائج القديمة ما زال خطراً.

أين يعمل فحص الأسرار Secrets Scanning؟

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

خط من خمس محطات: خطاف pre-commit على جهاز المطور يفحص التغييرات المجهزة فقط، ثم حماية الدفع في سيرفر Git، ثم CI لطلب الدمج يفحص ال Commits الجديدة فقط، ثم فحص أسبوعي للتاريخ كله، ثم فحص ال Docker Image قبل رفعها إلى ال Registry
كلما تأخر اكتشاف السر زادت نسخه وزاد عمل التنظيف

قبل ال Commit: خطاف pre-commit Hook

خطاف pre-commit برنامج يشغله Git قبل إنشاء كل Commit، فإذا خرج برمز غير الصفر رفض Git إنشاء ال Commit، وهذه أفضل نقطة نظرياً لأن السر لا يخرج من جهازك أصلاً. وإطار pre-commit يدير هذه الخطافات من ملف .pre-commit-config.yaml في المستودع، ويوفر Gitleaks ثلاثة خطافات: gitleaks يبني الأداة من الكود بلغة Go، وgitleaks-docker يشغلها من Docker Image الرسمية، وgitleaks-system يستخدم نسخة مثبتة على جهازك، وكلها تنفذ الأمر gitleaks git --pre-commit --redact --staged --verbose الذي يفحص التغييرات المجهزة Staged فقط، فيعمل في جزء من الثانية.

ولكن تذكر حدود هذه النقطة، فالمطور يتجاوزها بالمتغير SKIP=gitleaks أو بالخيار git commit --no-verify، والخطاف لا ينتقل مع git clone، فكل مطور يحتاج إلى تنفيذ pre-commit install بنفسه، وسوف ترى في التجربة أن أحد الخطافات الثلاثة كان يمرر كل شيء بصمت حتى أصلحناه. لذلك فالخطاف أداة لراحة المطور، وليس ضماناً.

عند الدفع: حماية الدفع Push Protection

بعض منصات Git تفحص الدفع نفسه وترفضه إذا وجدت سراً معروف الشكل، فميزة Push Protection في GitHub مفعلة افتراضياً على مستوى المستخدم للمستودعات العامة، ويمكن تفعيلها على مستوى المستودع، ويستطيع المطور تجاوزها بعد أن يختار سبباً للتجاوز. وفي GitLab توجد ميزة Secret Push Protection في خطة Ultimate، وتتجاوز بخيار في أمر الدفع أو بعبارة في رسالة ال Commit ويسجل التجاوز. وإذا كنت تستضيف Gitea بنفسك فالبديل العملي هو فحص CI على كل طلب دمج، مع حماية الفرع الرئيسي Branch Protection بحيث لا يدمج طلب لم ينجح فحصه.

في خط CI: الفرق Diff أم التاريخ كله Full History؟

في طلب الدمج افحص ال Commits الجديدة فقط، أي الفرق بين فرع الطلب والفرع الرئيسي، والسبب أن هذا الفحص سريع، وأن نتيجته تخص صاحب الطلب وحده، فلا يفشل طلبه بسبب مفتاح قديم دخل قبل سنة. وGitleaks يفعل ذلك بالخيار --log-opts="origin/main..HEAD" الذي يمرر نطاق ال Commits إلى git log، وTruffleHog بالخيارين --since-commit main --branch <الفرع>. ولاحظ أن الفحص يقرأ كل Commit في الطلب وليس آخر نسخة من الملفات فقط، فالسر الذي أضيف ثم حذف داخل الطلب نفسه يوقف الدمج أيضاً، وهذا صحيح لأنه سوف يدخل التاريخ مع الدمج.

وأما فحص التاريخ كله فمكانه جدول زمني Schedule، مرة في الأسبوع مثلاً، والسبب أن الأدوات تضيف قواعد جديدة باستمرار، فقاعدة تضاف اليوم قد تجد مفتاحاً دخل قبل ثلاث سنوات، وأن التحقق في TruffleHog يخبرك أي المفاتيح القديمة ما زال يعمل. وعندما تضيف الأداة إلى مستودع قديم لأول مرة استخدم الخط الأساسي Baseline الذي شرحناه في DevSecOps للمبتدئين، وفي Gitleaks يكون ذلك بحفظ تقرير اليوم بالخيار --report-path ثم تمريره بالخيار --baseline-path في الفحوص التالية، كما في توثيق Gitleaks، فلا يظهر إلا الجديد، ولكن لا تقم بوضع مفتاح يعمل في ال Baseline أبداً، والسبب أن ال Baseline يخفي النتيجة ولا يلغي المفتاح.

قبل الوصول إلى ال Registry: فحص ال Docker Image

الفحص الأخير يكون على ال Docker Image بعد بنائها وقبل رفعها، لأنها قد تحمل ملفات لم تمر بال Git أصلاً، مثل ملف .env المحلي الذي يتجاهله .gitignore. والأمر trufflehog docker يفحص كل طبقة على حدة، فيجد الملف حتى لو حذف في طبقة لاحقة. وأما ثغرات حزم النظام في ال Image فهي موضوع فحص الحاويات والبنية ككود.

الاستثناءات Allowlists بالطريقة الصحيحة

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

  • تعليق في السطر: gitleaks:allow في آخر السطر نفسه يجعل الأداة تتجاهل هذا السطر وحده، وهو مناسب لقيمة اختبار تعرف أنها مزيفة، وأي مراجع يرى التعليق في الفرق. والمقابل في TruffleHog هو التعليق trufflehog:ignore.
  • ملف .gitleaksignore: يحتوي على بصمة Fingerprint كل نتيجة تريد تجاهلها، كما تظهر في مخرج الأداة، وفي فحص التاريخ تتكون البصمة من معرف ال Commit والملف والقاعدة والسطر، فهي تخص تلك النتيجة في ذلك ال Commit وحده، وهذا مناسب لنتيجة قديمة في التاريخ لا تستطيع تعديل سطرها، ويذكر التوثيق أن هذه الميزة تجريبية.
  • ملف .gitleaks.toml: يمد القواعد الافتراضية بالجملة [extend] useDefault = true ويضيف قوائم [[allowlists]] تحدد القاعدة المستهدفة بالحقل targetRules، والمسار بالحقل paths، وشكل السر بالحقل regexes، ومع الشرط condition = "AND" لا يستثنى السر إلا إذا تحققت كلها معاً، وهذا مناسب لنمط متكرر مثل توكنات الاختبار.

وسوف نطبق الطريقتين الأخيرتين في التجربة، ونرى أن الاستثناء المكتوب جيداً لا يمرر مفتاحاً حقيقياً حتى لو وضع في مجلد الاختبارات نفسه.

ماذا تفعل عندما يتسرب مفتاح؟ خطة الاستجابة Incident Response

الخطأ الأشهر هنا أن يبدأ المطور بتنظيف التاريخ، فيقضي ساعات مع أوامر Git، بينما المفتاح ما زال يعمل في كل نسخة أخذت قبل التنظيف. والصورة التالية تبين الترتيب الصحيح:

ست بطاقات مرقمة: إلغاء المفتاح وإصدار بديل أولاً، ثم تقييم استخدامه من سجلات الخدمة، ثم إصلاح الكود ليقرأ السر من الخارج، ثم تنظيف التاريخ بأداة git filter-repo والدفع بالقوة Force Push، ثم التعامل مع النسخ والتفرعات، ثم منع التكرار، وتحتها تنبيه أحمر بأن الخطوتين 4 و5 دون الخطوة 1 لا تغيران شيئاً
إلغاء المفتاح وحده يجعل التسرب بلا قيمة، والباقي تنظيف ومنع للتكرار
  1. ألغ المفتاح وأصدر بديلاً له Revoke and Rotate: من لوحة الخدمة التي أصدرته، خلال دقائق، وقبل أي شيء آخر، وهذه هي الخطوة الأولى في توثيق GitHub لإزالة البيانات الحساسة، والسبب أن المفتاح الملغى لا يفتح شيئاً مهما بقي من نسخه.
  2. قيم الأثر Assess: اقرأ سجلات الخدمة منذ أول Commit دخل فيه المفتاح وليس منذ اكتشافه، واسأل: هل استخدمه أحد غير تطبيقك؟ ومن أين؟ وماذا كان يستطيع أن يصل إليه؟ وفي حادثة Toyota أعلاه كانت الفترة خمس سنوات.
  3. أصلح الكود: اجعل التطبيق يقرأ السر من متغير بيئة أو من أداة أسرار، ثم انشر التطبيق بالمفتاح الجديد.
  4. نظف التاريخ Purge History: بأداة git filter-repo، ثم ادفع بالقوة، ثم اطلب من مدير السيرفر حذف الكائنات القديمة، ففي GitHub يكون ذلك بطلب إلى الدعم، وفي سيرفرك تفعله بنفسك كما سوف ترى في التجربة. وهذه الخطوة ليست إلزامية دائماً بعد الإلغاء، ولكنها تمنع إنذارات متكررة من الفحص الدوري، وتمنع أن يجد أحد في المستقبل مفتاحاً قديماً فيظنه يعمل.
  5. تعامل مع النسخ والتفرعات: كل من لديه نسخة يحذفها ويستنسخ من جديد، والسبب أن نسخته القديمة إذا دمجت ودفعت أعادت ال Commits القديمة إلى السيرفر، والتفرعات العامة تبقى كما هي، كما يذكر توثيق GitHub نفسه.
  6. امنع التكرار: خطاف pre-commit، وفحص في كل طلب دمج، وفحص دوري للتاريخ وال Images، كما في هذا المقال.

منع التسرب من البداية Prevention

  • ملفان للتجاهل وليس ملفاً واحداً: .gitignore يمنع الملف من Git، و.dockerignore يمنعه من ال Docker Image، فضع فيهما .env، وضع .git في الثاني أيضاً، واحفظ في المستودع ملف .env.example فيه أسماء المتغيرات دون قيم، كما ذكرنا في دليل Git للمبتدئين.
  • الأسرار وقت البناء بالطريقة الصحيحة: يذكر توثيق Docker أن ARG وENV غير مناسبين للأسرار لأنهما يبقيان في ال Image النهائية، والبديل هو RUN --mount=type=secret الذي يتيح السر لأمر واحد ولا يحفظه في أي طبقة، ومنذ الإصدار 1.10 من صيغة Dockerfile يمكن وضعه في متغير بيئة لذلك الأمر بالخيار env=.
  • أداة أسرار Secret Manager: بدلاً من ملف .env على كل جهاز، يقرأ التطبيق أسراره من خزنة مركزية تسجل من قرأ ماذا ومتى، وقد قارنا OpenBao وInfisical وغيرهما في إدارة الهوية والوصول المميز، وشرحنا ربط Dokploy بمزود أسرار خارجي في دليل Dokploy، وما يكتب في ملف الإعداد وما لا يكتب في قائمة تحقق المطور.
  • أسرار قصيرة العمر: التوكن الذي ينتهي بعد ساعة أقل خطراً من مفتاح يعمل سنوات، لذلك استخدم OIDC في خط CI حيث أمكن كما شرحنا في أقل الصلاحيات للتوكن، وأعط كل مفتاح أقل صلاحية تكفيه.

تجربة عملية Lab: مستودع يتسرب منه مفتاح ثم ننظفه

سوف نبني مستودعاً مؤقتاً من تطبيق السلسلة التجريبي الذي عرضناه في التطبيق التجريبي وثغراته السبع، وهو متجر صغير ب Flask فيه ثغرات مقصودة، والثغرة التي تهمنا هنا هي VULN-01: التوكن ADMIN_API_TOKEN ومفتاح الجلسات SECRET_KEY مكتوبان في الكود. ثم نضيف إليه ملف .env فيه مفتاح AWS مزيف ولد عشوائياً بالشكل الصحيح، ونحذفه في Commit لاحق، ونمر على كل مراحل المقال. كل شيء يعمل في Containers بأسماء تبدأ بالبادئة lab-dso-secrets-، والتطبيق ينشر على 127.0.0.1 فقط.

⚠️
كل المفاتيح في هذه التجربة مزيفة، ولا تقم بتشغيل TruffleHog بالتحقق على مفاتيح أو خدمات لا تملكها، ولهذا يعمل TruffleHog في التجربة داخل شبكة Docker داخلية لا تصل إلى الإنترنت، فلا يخرج أي طلب تحقق إلى AWS أو GitHub، والخدمة الوحيدة التي يتحقق أمامها هي خدمة نشغلها نحن.

تجهيز الأدوات Tools والشبكة الداخلية Internal Network

نجمع الأدوات في Docker Image واحدة، ونأخذ Gitleaks وTruffleHog من ال Docker Images الرسمية لهما:

# tools/Dockerfile
FROM zricethezav/gitleaks:v8.30.1 AS gitleaks
FROM trufflesecurity/trufflehog:3.98.0 AS trufflehog
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY --from=gitleaks /usr/bin/gitleaks /usr/local/bin/gitleaks
COPY --from=trufflehog /usr/bin/trufflehog /usr/local/bin/trufflehog
RUN pip install --no-cache-dir pre-commit==4.6.2 git-filter-repo==2.47.0
RUN git config --global user.name "Lab Dev" && git config --global user.email [email protected] \
 && git config --global init.defaultBranch main && git config --global --add safe.directory '*'
WORKDIR /work
docker build -t lab-dso-secrets-tools:1 tools
docker run --rm lab-dso-secrets-tools:1 sh -c 'gitleaks version; trufflehog --version; pre-commit --version; git filter-repo --version; git --version'

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

v8.30.1
trufflehog 3.98.0
pre-commit 4.6.2
a40bce548d2c
git version 2.47.3

والأمر git filter-repo --version يطبع معرفاً مختصراً بدلاً من رقم الإصدار، والإصدار المثبت هو 2.47.0 كما في سطر pip. بعد ذلك نشغل التطبيق التجريبي، ونصله بشبكة داخلية Internal Network لا تخرج إلى الإنترنت، ونشغل على الشبكة نفسها خدمة التحقق Verifier التي سوف يسألها TruffleHog:

docker network create --internal lab-dso-secrets-net
docker build -t lab-dso-secrets-app:1 devsecops-app
docker run -d --name lab-dso-secrets-app -p 127.0.0.1:19885:8000 lab-dso-secrets-app:1
docker network connect lab-dso-secrets-net lab-dso-secrets-app
docker run -d --name lab-dso-secrets-verifier --network lab-dso-secrets-net \
  -v "$PWD/verifier:/v:ro" python:3.13-slim python -u /v/verifier.py
curl -s -H "X-Api-Token: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa" http://127.0.0.1:19885/api/admin/stats; echo
curl -s http://127.0.0.1:19885/api/admin/stats

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

{"users":1}

{"error":"unauthorized"}

التوكن المكتوب في الكود يفتح مسار الإدارة، ودونه يرفض التطبيق الطلب. وخدمة التحقق التالية تلعب دور «الجهة التي أصدرت المفتاح»، فتستقبل من TruffleHog التوكن الذي وجده، وتسأل التطبيق هل يقبله، ثم ترد بالرمز 200 إذا كان يعمل و401 إذا كان لا يعمل:

# verifier/verifier.py
# A self-hosted "provider" for TruffleHog custom verification: it receives the token TruffleHog found
# and asks the demo app whether the token still works. 200 = live, 401 = rotated.
import json, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer

APP = "http://lab-dso-secrets-app:8000/api/admin/stats"
AUTH = "lab-verifier-shared-header"

class Verifier(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.headers.get("Authorization") != AUTH:
            self.send_response(403); self.end_headers(); return
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        token = body["DemoShopAdminToken"]["token"]
        token = token[0] if isinstance(token, list) else token
        req = urllib.request.Request(APP, headers={"X-Api-Token": token})
        try:
            status = urllib.request.urlopen(req, timeout=5).status
        except urllib.error.HTTPError as e:
            status = e.code
        print(f"verify {token[:12]}... -> app answered {status}", flush=True)
        self.send_response(200 if status == 200 else 401); self.end_headers()

    def log_message(self, *a):
        pass

HTTPServer(("", 8080), Verifier).serve_forever()

المستودع Repository: ملف .env التزمناه ثم «حذفناه»

ضع نسخة من مجلد التطبيق باسم shop-src بجانب السكربت التالي، وهو ينشئ المستودع داخل Volume باسم lab-dso-secrets-work، في ثلاثة Commits: التطبيق كما هو، ثم ملف .env فيه مفتاح AWS مزيف وكلمة مرور قاعدة البيانات، ثم حذف الملف من المستودع وإضافته إلى .gitignore، مع بقائه على القرص كما يحدث عند المطور فعلاً:

#!/bin/sh
# make-repo.sh: builds the throwaway repository: the demo app, then a .env committed by mistake, then "deleted"
set -e
rm -rf /work/shop && mkdir /work/shop && cd /work/shop
git init -q
cp /work/shop-src/app.py /work/shop-src/Dockerfile /work/shop-src/requirements.txt /work/shop-src/.dockerignore .
git add . && git commit -q -m "Demo shop: first version"

cat > .env <<'ENV'
AWS_ACCESS_KEY_ID=AKIA22THC5LBKKK2SZ7A
AWS_SECRET_ACCESS_KEY=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
DB_PASSWORD=Sh0p-Db-2026-pa55
ENV
git add .env && git commit -q -m "Add deploy settings"

git rm -q --cached .env && echo ".env" > .gitignore
git add .gitignore && git commit -q -m "Remove .env from the repo and ignore it"
git log --oneline --stat
docker volume create lab-dso-secrets-work
docker run --rm -v lab-dso-secrets-work:/work -v "$PWD:/src:ro" lab-dso-secrets-tools:1 \
  sh -c 'cp -r /src/shop-src /src/make-repo.sh /work/ && sh /work/make-repo.sh'

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

bcd4817 Remove .env from the repo and ignore it
 .env       | 3 ---
 .gitignore | 1 +
 2 files changed, 1 insertion(+), 3 deletions(-)
4eda72a Add deploy settings
 .env | 3 +++
 1 file changed, 3 insertions(+)
91a39f6 Demo shop: first version
 .dockerignore    |   3 ++
 Dockerfile       |  12 ++++++
 app.py           | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 requirements.txt |   5 +++
 4 files changed, 144 insertions(+)

ومن هنا فصاعداً نشغل كل أداة داخل Container من ال Docker Image نفسها، لذلك نعرف اختصاراً للأمر:

R="docker run --rm -v lab-dso-secrets-work:/work -v lab-dso-secrets-cache:/root/.cache -w /work/shop lab-dso-secrets-tools:1"

Gitleaks على المجلد Directory وعلى التاريخ History

شرحنا في تجربة أمن سلسلة التوريد الفرق بين الأمرين dir وgit، فلن نكرره، ولكن لاحظ هنا تفصيلاً إضافياً: الأمر dir على مجلد المطور نفسه يرى ملف .env المحلي لأنه يقرأ القرص ولا يهتم ب .gitignore، أما على نسخة جديدة من المستودع، كما يراها زميلك أو خط CI، فلا يجد إلا التوكن الموجود في الكود اليوم:

docker run --rm -v lab-dso-secrets-work:/work -w /work lab-dso-secrets-tools:1 \
  sh -c 'git clone -q shop clone && gitleaks dir clone --no-banner --no-color -v; echo "exit=$?"'
7:54AM INF scanned ~5216 bytes (5.22 KB) in 132ms
7:54AM WRN leaks found: 1
Finding:     ADMIN_API_TOKEN = "dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa"
Secret:      dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
RuleID:      generic-api-key
Entropy:     5.259991
File:        clone/app.py
Line:        17
Fingerprint: clone/app.py:generic-api-key:17

exit=1

والآن الأمر git على التاريخ كله:

$R gitleaks git . -v --no-color --no-banner; echo "exit=$?"

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

Finding:     AWS_SECRET_ACCESS_KEY=wo/V2jddtDBuESwUmspZUDcghA3jZE/+bL0CNCwe
Secret:      wo/V2jddtDBuESwUmspZUDcghA3jZE/+bL0CNCwe
RuleID:      generic-api-key
Entropy:     4.803056
File:        .env
Line:        2
Commit:      4eda72a2740bd8646c740fe328c4a23a47d140fe
Author:      Lab Dev
Email:       [email protected]
Date:        2026-10-06T07:50:18Z
Fingerprint: 4eda72a2740bd8646c740fe328c4a23a47d140fe:.env:generic-api-key:2

Finding:     AKIA22THC5LBKKK2SZ7A
Secret:      AKIA22THC5LBKKK2SZ7A
RuleID:      aws-access-token
Entropy:     3.446439
File:        .env
Line:        1
Commit:      4eda72a2740bd8646c740fe328c4a23a47d140fe
Author:      Lab Dev
Email:       [email protected]
Date:        2026-10-06T07:50:18Z
Fingerprint: 4eda72a2740bd8646c740fe328c4a23a47d140fe:.env:aws-access-token:1

Finding:     ADMIN_API_TOKEN = "dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa"
Secret:      dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
RuleID:      generic-api-key
Entropy:     5.259991
File:        app.py
Line:        17
Commit:      91a39f63648aa477092a42f7b5c55bb4b0d2ddda
Author:      Lab Dev
Email:       [email protected]
Date:        2026-10-06T07:50:18Z
Fingerprint: 91a39f63648aa477092a42f7b5c55bb4b0d2ddda:app.py:generic-api-key:17

7:54AM INF 3 commits scanned.
7:54AM INF scanned ~5348 bytes (5.35 KB) in 407ms
7:54AM WRN leaks found: 3
exit=1

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

  • مفتاح AWS وجد مرتين بقاعدتين مختلفتين: معرف المفتاح بالقاعدة الخاصة aws-access-token لأن شكله معروف، والمفتاح السري بالقاعدة العامة لأن عشوائيته 4.80، وكلاهما في ال Commit 4eda72a الذي «حذفناه» بعده.
  • كلمة مرور قاعدة البيانات في السطر الثالث من الملف نفسه لم تظهر، وكذلك مفتاح الجلسات SECRET_KEY في app.py، فهذه أسرار حقيقية فاتت الأداة، وسوف نعود إلى الثاني في آخر التجربة.
  • البصمة Fingerprint في فحص التاريخ تبدأ بمعرف ال Commit، وهي ما نضعه في .gitleaksignore إذا احتجنا إلى تجاهل نتيجة واحدة.

TruffleHog مع التحقق Verification أمام خدمة نملكها

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

# /work/trufflehog.yaml
detectors:
  - name: DemoShopAdminToken
    keywords:
      - dso_live_
    regex:
      token: '\b(dso_live_[A-Za-z0-9]{32})\b'
    verify:
      - endpoint: http://lab-dso-secrets-verifier:8080/
        unsafe: true
        headers:
          - "Authorization: lab-verifier-shared-header"
        successRanges:
          - "200"
        rotatedRanges:
          - "401"
docker run --rm --network lab-dso-secrets-net -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 \
  trufflehog git file:///work/shop --config /work/trufflehog.yaml --no-color; echo "exit=$?"

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

🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

2026-10-06T08:05:05Z	info-0	trufflehog	running source	{"source_manager_worker_id": "LU9QS", "with_units": true}
2026-10-06T08:05:05Z	info-0	trufflehog	scanning repo	{"source_manager_worker_id": "LU9QS", "unit_kind": "dir", "unit": "/tmp/trufflehog-17-3929623875", "repo": "file:///work/shop"}
✅ Found verified result 🐷🔑
Detector Type: CustomRegex
Decoder Type: HTML
Raw result: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
Response:
Name: DemoShopAdminToken
Commit: 91a39f63648aa477092a42f7b5c55bb4b0d2ddda
Email: Lab Dev <[email protected]>
File: app.py
Line: 17
Repository: file:///work/shop
Repository_local_path: /tmp/trufflehog-17-3929623875
Timestamp: 2026-10-06 07:50:18 +0000

Found unverified result 🐷🔑❓
Verification issue: lookup sts.us-east-1.amazonaws.com on 127.0.0.11:53: server misbehaving
Detector Type: AWS
Decoder Type: PLAIN
Raw result: AKIA22THC5LBKKK2SZ7A
Resource_type: Access key
Account: 744319281858
Commit: 4eda72a2740bd8646c740fe328c4a23a47d140fe
Email: Lab Dev <[email protected]>
File: .env
Line: 1
Repository: file:///work/shop
Repository_local_path: /tmp/trufflehog-17-3929623875
Timestamp: 2026-10-06 07:50:18 +0000

2026-10-06T08:05:08Z	info-0	trufflehog	finished scanning	{"chunks": 9, "bytes": 5589, "verified_secrets": 1, "unverified_secrets": 1, "scan_duration": "4.688937867s", "trufflehog_version": "3.98.0", "verification_caching": {"Hits":0,"Misses":3,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":3980}}
exit=0

وسجل خدمة التحقق يؤكد أن السؤال وصل إليها:

docker logs lab-dso-secrets-verifier
verify dso_live_7Hq... -> app answered 200
verify dso_live_7Hq... -> app answered 200

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

  • توكن التطبيق ظهر بالعلامة ✅ أي verified، لأن خدمة التحقق سألت التطبيق فقبله، وهذه المعلومة لا يعطيك إياها Gitleaks: هذا التوكن يعمل الآن ويجب إلغاؤه اليوم.
  • مفتاح AWS ظهر unverified ومعه سطر Verification issue، والسبب أن TruffleHog حاول الوصول إلى sts.us-east-1.amazonaws.com فلم يجد الشبكة، وهذا بالضبط ما أردناه، فلم يخرج أي طلب إلى AWS. ولاحظ أن رقم الحساب Account استخرجه الأداة من معرف المفتاح نفسه دون اتصال.
  • TruffleHog لم يذكر المفتاح السري لمفتاح AWS ولا كلمة المرور كنتائج مستقلة، لأن مكتشف AWS يجمع المعرف والمفتاح السري نتيجة واحدة، وكلمة المرور ليس لها مكتشف.
  • رمز الخروج 0 رغم وجود نتائج، لأننا لم نطلب الفشل بعد، وسوف نضيف الخيار --fail في خط CI.

خطاف pre-commit Hook: ولماذا مر السر في المرة الأولى

نضيف إعداد pre-commit إلى المستودع، ونستخدم الخطاف gitleaks-system لأن Gitleaks مثبت في ال Docker Image:

# .pre-commit-config.yaml (first version)
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks-system
$R sh -c 'pre-commit install && git add .pre-commit-config.yaml && git commit -m "Add the gitleaks pre-commit hook"'
pre-commit installed at .git/hooks/pre-commit
[INFO] Initializing environment for https://github.com/gitleaks/gitleaks.
Detect hardcoded secrets.................................................Passed
[main 88da04d] Add the gitleaks pre-commit hook
 1 file changed, 5 insertions(+)
 create mode 100755 .pre-commit-config.yaml

الآن يكتب مطور ملف notify.py يفتح Issue على GitHub عند كل طلب كبير، ويضع فيه توكن GitHub مباشرة، والتوكن هنا مزيف ولد عشوائياً بالشكل الصحيح:

"""Opens an issue for the team when a big order arrives."""
import json
import urllib.request

GITHUB_TOKEN = "ghp_CDhiULI1AzZgCCTqpMdBuaCZeFI5Swg76BJa"


def open_issue(title):
    req = urllib.request.Request(
        "https://api.github.com/repos/example/shop/issues",
        data=json.dumps({"title": title}).encode(),
        headers={"Authorization": f"Bearer {GITHUB_TOKEN}"},
    )
    return urllib.request.urlopen(req)
$R sh -c 'git add notify.py && git commit -m "Open an issue on big orders"; echo "exit=$?"'

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

Detect hardcoded secrets.................................................Passed
[main 1099de3] Open an issue on big orders
 1 file changed, 14 insertions(+)
 create mode 100644 notify.py
exit=0

الخطاف قال Passed، وال Commit دخل ومعه التوكن! فهل Gitleaks لا يعرف توكنات GitHub؟ لنتراجع عن ال Commit ونشغل الخطاف يدوياً مع التفاصيل:

$R sh -c 'git reset -q --soft HEAD~1 && pre-commit run gitleaks-system -v'

وآخر المخرج كما يلي:

4:23PM ERR [git] fatal: cannot change to 'notify.py': Not a directory
4:23PM ERR error="stderr is not empty"
4:23PM INF 0 commits scanned.
4:23PM INF scanned ~0 bytes (0) in 83.6ms
4:23PM INF no leaks found

والسبب أن pre-commit يمرر أسماء الملفات المجهزة إلى أمر الخطاف افتراضياً، وفي ملف الخطافات في مستودع Gitleaks يحمل الخطافان gitleaks وgitleaks-docker الإعداد pass_filenames: false، أما gitleaks-system فلا يحمله في الإصدار 8.30.1، فوصل اسم الملف notify.py إلى Gitleaks كأنه مسار المستودع، ففشل Git في الدخول إليه، ولم يجد Gitleaks شيئاً يفحصه، فخرج بالرمز 0. وهذا خطاف يفشل مفتوحاً Fail Open، أي أن عطله يبدو مثل النجاح تماماً، والدرس أن تختبر أي خطاف أو فحص جديد بسر مزيف قبل أن تعتمد عليه. والإصلاح سطر واحد في إعدادنا:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks-system
        pass_filenames: false
$R sh -c 'git restore --staged notify.py && git add .pre-commit-config.yaml \
  && git commit -q -m "Do not pass file names to the gitleaks hook" \
  && git add notify.py && git commit -m "Open an issue on big orders"; echo "exit=$?"'

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

Detect hardcoded secrets.................................................Passed
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks-system
- exit code: 1

○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     GITHUB_TOKEN = "REDACTED"
Secret:      REDACTED
RuleID:      github-pat
Entropy:     4.703056
File:        notify.py
Line:        5
Fingerprint: notify.py:github-pat:5

4:23PM INF 0 commits scanned.
4:23PM INF scanned ~430 bytes (430 bytes) in 67.9ms
4:23PM WRN leaks found: 1

exit=1

ال Commit الأول (إصلاح الإعداد) مر، والثاني رفض بالقاعدة github-pat، والخيار --redact في أمر الخطاف أخفى قيمة السر من المخرج، وهذا مهم لأن مخرج الخطاف قد يلصق في محادثة أو تذكرة. والإصلاح الصحيح هو أن يقرأ الملف التوكن من البيئة:

$R sh -c 'sed -i "s/^import json/import json\nimport os/; s/^GITHUB_TOKEN = .*/GITHUB_TOKEN = os.environ[\"GITHUB_TOKEN\"]/" notify.py \
  && sed -n 1,6p notify.py && git add notify.py && git commit -m "Open an issue on big orders"'
"""Opens an issue for the team when a big order arrives."""
import json
import os
import urllib.request

GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
Detect hardcoded secrets.................................................Passed
[main c9afab3] Open an issue on big orders
 1 file changed, 15 insertions(+)
 create mode 100644 notify.py

إنذار خاطئ False Positive واستثناؤه بشكل صحيح

الآن يكتب المطور اختباراً لمسار الإدارة، ويستخدم فيه توكناً مزيفاً بالبادئة dso_test_ لا يقبله التطبيق أبداً، حتى لا يحتاج الاختبار إلى التوكن الحقيقي:

# tests/test_admin.py
import app as shop

# A fake token that exists only in this test, so the test never needs the real one.
TEST_API_TOKEN = "dso_test_Q8vN2kP5rT1xL7mZ4cB9wE3yH6jS0aFd"


def test_admin_stats_rejects_a_wrong_token(monkeypatch):
    monkeypatch.setattr(shop, "ADMIN_API_TOKEN", TEST_API_TOKEN)
    client = shop.app.test_client()
    assert client.get("/api/admin/stats", headers={"X-Api-Token": "wrong"}).status_code == 401
$R sh -c 'git add tests && git commit -m "Test the admin token check"'
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks-system
- exit code: 1

○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     TEST_API_TOKEN = "REDACTED"
Secret:      REDACTED
RuleID:      generic-api-key
Entropy:     5.162430
File:        tests/test_admin.py
Line:        4
Fingerprint: tests/test_admin.py:generic-api-key:4

4:24PM INF 0 commits scanned.
4:24PM INF scanned ~420 bytes (420 bytes) in 60.6ms
4:24PM WRN leaks found: 1

هذا إنذار خاطئ، فالقيمة عشوائية فعلاً لكنها ليست سراً. والحل السهل الخاطئ هو أن يضيف المطور SKIP=gitleaks-system كل مرة، فيتعلم أن الخطاف مزعج ويتجاهله دائماً. والحل الصحيح هنا استثناء ضيق في .gitleaks.toml: القاعدة العامة وحدها، وفي مجلد tests/ وحده، وللقيم التي تبدأ بالبادئة dso_test_ وحدها، والثلاثة معاً بالشرط AND:

# .gitleaks.toml
[extend]
useDefault = true

[[allowlists]]
description = "Fake tokens used only by the tests (dso_test_ prefix, never accepted by the app)"
targetRules = ["generic-api-key"]
condition = "AND"
paths = ['''^tests/''']
regexes = ['''^dso_test_''']
$R sh -c 'git add .gitleaks.toml tests && git commit -m "Test the admin token check, allow its fake token"'
Detect hardcoded secrets.................................................Passed
[main 04cf11d] Test the admin token check, allow its fake token
 2 files changed, 19 insertions(+)
 create mode 100644 .gitleaks.toml
 create mode 100755 tests/test_admin.py

ولنتأكد أن الاستثناء لا يفتح باباً أوسع مما نريد، نضع في ملف الاختبار نفسه توكناً بشكل التوكن الحقيقي dso_live_، ونحاول إدخاله ثم نتراجع عنه:

$R sh -c 'printf "\nDEBUG_API_TOKEN = \"dso_live_Zp3Rk8Wq1Lm6Xt9Bv2Nc5Hd7Jf0Gs4Ya\"\n" >> tests/test_admin.py \
  && git add tests && git commit -m "Debug the admin test"; git reset -q HEAD tests; git checkout -q tests/test_admin.py'
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks-system
- exit code: 1

○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     DEBUG_API_TOKEN = "REDACTED"
Secret:      REDACTED
RuleID:      generic-api-key
Entropy:     5.162430
File:        tests/test_admin.py
Line:        12
Fingerprint: tests/test_admin.py:generic-api-key:12

4:24PM INF 0 commits scanned.
4:24PM INF scanned ~63 bytes (63 bytes) in 63.4ms
4:24PM WRN leaks found: 1

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

printf '%s\n' 'TEST_API_TOKEN = "dso_test_Q8vN2kP5rT1xL7mZ4cB9wE3yH6jS0aFd"  # gitleaks:allow' \
  | docker run --rm -i lab-dso-secrets-tools:1 gitleaks stdin --no-banner --no-color -v
4:24PM INF scanned ~79 bytes (79 bytes) in 104ms
4:24PM INF no leaks found

خط CI: طلب الدمج Pull Request الذي تجاوز الخطاف

الآن مطور على فرع جديد يكتب سكربتاً ينسخ فيه التوكن الحقيقي، ويتجاوز الخطاف بالمتغير SKIP، وهذا ما يجعل خط CI ضرورياً:

$R sh -c 'git switch -q -c feature/stats-script && mkdir -p scripts \
  && printf "#!/bin/sh\n# Prints the number of users from the admin API\ncurl -s -H \"X-Api-Token: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa\" http://localhost:8000/api/admin/stats\n" > scripts/stats.sh \
  && git add scripts && SKIP=gitleaks-system git commit -m "Add a stats script"'
Detect hardcoded secrets................................................Skipped
[feature/stats-script bcbea22] Add a stats script
 1 file changed, 3 insertions(+)
 create mode 100644 scripts/stats.sh

وخط CI لطلب الدمج يفحص ال Commits الجديدة فقط بالخيار --log-opts:

$R gitleaks git . --log-opts="main..HEAD" --redact --no-banner --no-color -v; echo "exit=$?"
Finding:     curl -s -H "X-Api-Token: REDACTED"
Secret:      REDACTED
RuleID:      curl-auth-header
Entropy:     5.259991
File:        scripts/stats.sh
Line:        3
Commit:      bcbea22a0243847630eeec4f07ec6e86cc00271c
Author:      Lab Dev
Email:       [email protected]
Date:        2026-10-06T16:24:45Z
Fingerprint: bcbea22a0243847630eeec4f07ec6e86cc00271c:scripts/stats.sh:curl-auth-header:3

4:24PM INF 1 commits scanned.
4:24PM INF scanned ~164 bytes (164 bytes) in 90.2ms
4:24PM WRN leaks found: 1
exit=1

Commit واحد فحص، ونتيجة واحدة تخص صاحب الطلب. ولاحظ أن القاعدة هذه المرة curl-auth-header، فالتوكن نفسه وجدته قاعدة مختلفة لأن سياقه مختلف. وللمقارنة، فحص التاريخ كله على الفرع نفسه يعيد النتائج القديمة أيضاً، وهذا مكانه الفحص الدوري وليس طلب الدمج:

$R sh -c 'gitleaks git . --redact --no-banner --no-color; echo "exit=$?"
  gitleaks git . --redact --no-banner --no-color --report-format json --report-path /tmp/r.json >/dev/null 2>&1
  python -c "import json;[print(f[\"RuleID\"],f[\"File\"],f[\"Commit\"][:7]) for f in json.load(open(\"/tmp/r.json\"))]"'
4:24PM INF 8 commits scanned.
4:24PM INF scanned ~6741 bytes (6.74 KB) in 137ms
4:24PM WRN leaks found: 4
exit=1
curl-auth-header scripts/stats.sh bcbea22
aws-access-token .env 4eda72a
generic-api-key .env 4eda72a
generic-api-key app.py 91a39f6

ولاحظ أن توكن الاختبار لم يظهر، لأن .gitleaks.toml في المستودع يقرأ تلقائياً. أما TruffleHog في الطلب نفسه فيضيف التحقق، ومع الخيارين --results=verified,unknown و--fail يفشل الخط فقط إذا كان المفتاح يعمل أو تعذر التحقق منه:

docker run --rm --network lab-dso-secrets-net -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 \
  trufflehog git file:///work/shop --branch feature/stats-script --since-commit main \
  --config /work/trufflehog.yaml --results=verified,unknown --fail --no-color; echo "exit=$?"
🐷🔑🐷  TruffleHog. Unearth your secrets. 🐷🔑🐷

2026-10-06T16:25:58Z	info-0	trufflehog	running source	{"source_manager_worker_id": "335kB", "with_units": true}
2026-10-06T16:25:58Z	info-0	trufflehog	scanning repo	{"source_manager_worker_id": "335kB", "unit_kind": "dir", "unit": "/tmp/trufflehog-18-1966267806", "repo": "file:///work/shop", "base": "04cf11d0995852b065d15c74959722a2c34030bf", "head": "bcbea22a0243847630eeec4f07ec6e86cc00271c"}
✅ Found verified result 🐷🔑
Detector Type: CustomRegex
Decoder Type: PLAIN
Raw result: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
Name: DemoShopAdminToken
Response:
Commit: bcbea22a0243847630eeec4f07ec6e86cc00271c
Email: Lab Dev <[email protected]>
File: scripts/stats.sh
Line: 3
Repository: file:///work/shop
Repository_local_path: /tmp/trufflehog-18-1966267806
Timestamp: 2026-10-06 16:24:45 +0000

2026-10-06T16:25:58Z	info-0	trufflehog	finished scanning	{"chunks": 2, "bytes": 235, "verified_secrets": 1, "unverified_secrets": 0, "scan_duration": "83.67794ms", "trufflehog_version": "3.98.0", "verification_caching": {"Hits":0,"Misses":1,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":28}}
exit=183

رمز الخروج 183 هو ما يعيده TruffleHog مع --fail عند وجود نتائج بحسب توثيقه، وأي رمز غير الصفر يوقف خطوة الخط.

⚠️
في تشغيل سابق لهذا الأمر ظهر التوكن نفسه unverified وليس verified، لأن Container التطبيق وخدمة التحقق كانا متوقفين بعد إعادة تشغيل Docker، فلم يجد TruffleHog من يسأله. وهذا يعني أن خدمة التحقق جزء من الخط، فإذا تعطلت تحولت المفاتيح الحية إلى نتائج منخفضة الأولوية، لذلك راقب عدد النتائج verified وunknown معاً، ولا تجعل الخط يمر بصمت إذا كانت خدمة التحقق لا ترد.

وفي Gitea Actions يكون الملف كما يلي، وهو بصيغة GitHub Actions نفسها، والأمران run فيه نفذناهما كما هما داخل Container على المستودع نفسه:

# .gitea/workflows/secrets.yml
name: secrets
on:
  pull_request:
  schedule:
    - cron: "0 3 * * 1"
jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install gitleaks 8.30.1 and check its checksum
        run: |
          curl -sSL -o /tmp/gl.tgz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
          echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb  /tmp/gl.tgz" | sha256sum -c -
          tar -xzf /tmp/gl.tgz -C /tmp gitleaks
      - name: Scan only the commits of this pull request
        if: github.event_name == 'pull_request'
        run: /tmp/gitleaks git . --log-opts="origin/${{ github.base_ref }}..HEAD" --redact --verbose
      - name: Scan the whole history every week
        if: github.event_name == 'schedule'
        run: /tmp/gitleaks git . --redact --verbose
/tmp/gl.tgz: OK
4:34PM INF Unknown SCM platform. Use --platform to include links in findings. host=
4:34PM INF 1 commits scanned.
4:34PM INF scanned ~164 bytes (164 bytes) in 510ms
4:34PM WRN leaks found: 1
exit=1

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

  • fetch-depth: 0 يجلب التاريخ كاملاً، والسبب أن ال Checkout الافتراضي يجلب آخر Commit فقط، فلا يجد Gitleaks الفرع origin/main ليقارن به.
  • تحقق المجموع sha256sum -c يتأكد أن الملف المنزل هو نفسه المنشور في صفحة الإصدار، وتثبيت ال Actions بمعرف ال Commit موضوع شرحناه في أمن سلسلة التوريد.
  • الخيار --redact يخفي قيمة السر من سجل الخط، فلا يتحول السجل نفسه إلى طريق تسرب.
  • حتى يمنع الفحص الدمج فعلاً، اجعل نجاحه شرطاً في حماية الفرع الرئيسي، والخط الكامل بكل الفحوص هو موضوع الخط الآمن من البداية للنهاية.

السر في طبقات Layers ال Docker Image

الآن يعدل مطور ملف Dockerfile لينسخ المجلد كله بدلاً من app.py وحده، ويضيف خطوة ترحيل قاعدة البيانات Migrations تحتاج كلمة المرور، فيمررها بالتعليمة ARG، ثم يحذف .env «احتياطاً». والمجلد هنا هو مجلد المطور نفسه، أي فيه ملف .env المحلي ومجلد .git، و.dockerignore في التطبيق التجريبي لا يستثني أياً منهما:

# Dockerfile.leaky
FROM python:3.8-slim-buster
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ARG DB_PASSWORD
COPY . .
RUN echo "running migrations as shop:$DB_PASSWORD" > /dev/null && rm -f .env
EXPOSE 8000
CMD ["python", "app.py"]
docker build -f Dockerfile.leaky --build-arg DB_PASSWORD=Sh0p-Db-2026-pa55 -t lab-dso-secrets-leaky:1 .

وآخر المخرج كما يلي:

 1 warning found (use docker --debug to expand):
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "DB_PASSWORD") (line 5)

حذرنا BuildKit نفسه بفحص SecretsUsedInArgOrEnv، ولكن البناء نجح. والصورة التالية تبين طبقات هذه ال Image كما سوف نراها بثلاث طرق:

طبقات ال Image من الأسفل: ال Base Image، ثم pip install بحجم 13.2MB، ثم ARG DB_PASSWORD، ثم COPY . . بحجم 537kB وفيها app/.env وapp/.git، ثم RUN rm التي تضيف علامة إخفاء، وفوقها ما يراه ال Container دون .env، وبجانبها ثلاث بطاقات: ls في ال Container لا يجد .env ويجد .git، وdocker save يجد الملف كاملاً في الطبقة 00b80c968a07، وdocker history يعرض كلمة المرور
الأمر rm يضيف طبقة تخفي الملف، والطبقة التي تحمله تبقى كما هي

نبدأ بما يراه ال Container، ثم تاريخ ال Image:

docker run --rm lab-dso-secrets-leaky:1 ls -a /app
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' lab-dso-secrets-leaky:1 | head -8
.
..
.dockerignore
.git
.gitignore
.gitleaks.toml
.pre-commit-config.yaml
Dockerfile
Dockerfile.leaky
app.py
notify.py
requirements.txt
tests
0B	CMD ["python" "app.py"]
0B	EXPOSE [8000/tcp]
8.19kB	RUN |1 DB_PASSWORD=Sh0p-Db-2026-pa55 /bin/sh -c echo "running migrations as shop:$DB_PASSWORD" > /dev/null && rm -f .env # buildkit
537kB	COPY . . # buildkit
0B	ARG DB_PASSWORD=Sh0p-Db-2026-pa55
13.2MB	RUN /bin/sh -c pip install --no-cache-dir -r requirements.txt # buildkit
12.3kB	COPY requirements.txt . # buildkit
8.19kB	WORKDIR /app

ملف .env اختفى من ال Container، ولكن كلمة المرور مكتوبة مرتين في تاريخ ال Image، ومجلد .git موجود بكل تاريخ المستودع، أي أن مفتاح AWS الذي «حذفناه» من Git عاد إلى ال Image من طريق آخر. والآن نصدر ال Image بالأمر docker save ونقرأ طبقاتها بسكربت صغير، يعمل في Container على قرص في الذاكرة --tmpfs:

#!/bin/sh
# inspect-image.sh: reads a "docker save" archive from stdin, lists the layers that hold or hide a .env file,
# then prints the file from the layer that still has it.
set -e
cd /x && cat > image.tar && mkdir img && tar -xf image.tar -C img
for layer in $(python3 -c 'import json; print(*json.load(open("img/manifest.json"))[0]["Layers"])'); do
  for f in $(tar -tf "img/$layer" | grep -E '(^|/)(\.wh\.)?\.env$'); do
    echo "$(basename "$layer" | cut -c1-12)  $f"
    if [ "${f##*/}" = ".env" ]; then tar -xOf "img/$layer" "$f" | sed 's/^/    /'; fi
  done
done
docker save lab-dso-secrets-leaky:1 | docker run -i --rm --tmpfs /x:size=900m \
  -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 sh /work/inspect-image.sh

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

00b80c968a07  app/.env
    AWS_ACCESS_KEY_ID=AKIA22THC5LBKKK2SZ7A
    AWS_SECRET_ACCESS_KEY=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
    DB_PASSWORD=Sh0p-Db-2026-pa55
0d41aded007e  app/.wh..env

الطبقة 00b80c968a07 التي أنشأها COPY . . تحمل الملف كاملاً، والطبقة التالية تحمل فقط ملفاً فارغاً اسمه .wh..env، وهو علامة الإخفاء Whiteout التي تخبر Docker أن يخفي الملف عند دمج الطبقات. وكل من يستطيع سحب ال Image من ال Registry يستطيع أن يفعل ما فعلناه. وTruffleHog يقرأ الطبقات بنفسه:

docker save lab-dso-secrets-leaky:1 | docker run -i --rm --network lab-dso-secrets-net --tmpfs /x:size=900m \
  -v lab-dso-secrets-work:/work -w /x lab-dso-secrets-tools:1 \
  sh -c 'cat > shop.tar; trufflehog docker --image file:///x/shop.tar --config /work/trufflehog.yaml --no-color 2>&1'

والمخرج طويل، وهذه أول نتيجتين منه:

✅ Found verified result 🐷🔑
Detector Type: CustomRegex
Decoder Type: PLAIN
Raw result: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
Response:
Name: DemoShopAdminToken
File: /app/app.py
Image: /x/shop.tar
Layer: sha256:00b80c968a0782fcfd68828211a1c60706d58067caccdb185983832e60ded3ef

Found unverified result 🐷🔑❓
Verification issue: lookup host.com on 127.0.0.11:53: server misbehaving
Detector Type: URI
Decoder Type: PLAIN
Raw result: http://username:[email protected]:80
File: /usr/local/lib/python3.8/site-packages/pip/_vendor/urllib3/util/url.py
Image: /x/shop.tar
Layer: sha256:276dfcf5deffff3c5d540a8e0d9a18656a4c03637a8b4f4eec1f4a147799c901

النتيجة الثانية إنذار خاطئ من مثال في توثيق مكتبة داخل pip، ولنعرف حجم هذه الضجة نعيد الفحص دون تحقق، بمخرج JSON، ونعد النتائج بحسب المكان:

docker save lab-dso-secrets-leaky:1 | docker run -i --rm --network lab-dso-secrets-net --tmpfs /x:size=900m \
  -v lab-dso-secrets-work:/work -w /x lab-dso-secrets-tools:1 sh -c '
cat > shop.tar
trufflehog docker --image file:///x/shop.tar --config /work/trufflehog.yaml --json --no-verification 2>/dev/null > all.json
echo "all (no verification): $(wc -l < all.json)"
python -c "
import json,collections
c=collections.Counter()
for l in open(\"all.json\"):
  d=json.loads(l); f=d[\"SourceMetadata\"][\"Data\"][\"Docker\"][\"file\"]; c[(d[\"DetectorName\"], \"/app\" if f.startswith(\"/app\") else \"base image\")]+=1
for k,v in sorted(c.items()): print(v,*k)
"'
all (no verification): 12
1 AWS /app
2 Box base image
1 CustomRegex /app
3 PrivateKey base image
3 RedHatPyxis base image
2 URI base image

نتيجتان فقط من 12 تخصان تطبيقنا، والعشر الباقية من ملفات ال Base Image وحزمها، مثل مفاتيح خاصة مضمنة في ملف مكتبة libgnutls، وبصمات MD5 في ملفات dpkg تشبه شكل مفاتيح بعض الخدمات. وهنا تظهر قيمة التحقق، فمن بين كل هذه النتائج كانت النتيجة الوحيدة verified هي توكن التطبيق، لذلك ففي فحص ال Images في الخط اطلب --results=verified,unknown، وراجع الباقي في الفحص الدوري.

والإصلاح الصحيح من جهتين: .dockerignore يستثني .env و.git، وكلمة المرور تصل إلى أمر الترحيل وحده كسر بناء Build Secret:

# .dockerignore
README.md
__pycache__/
*.db
.env
.env.*
.git
# Dockerfile.fixed
FROM python:3.8-slim-buster
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN --mount=type=secret,id=db_password,env=DB_PASSWORD \
    echo "running migrations as shop:$DB_PASSWORD" > /dev/null
EXPOSE 8000
CMD ["python", "app.py"]
DB_PASSWORD=Sh0p-Db-2026-pa55 docker build -f Dockerfile.fixed --secret id=db_password,env=DB_PASSWORD -t lab-dso-secrets-fixed:1 .
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' lab-dso-secrets-fixed:1 | head -4
docker run --rm lab-dso-secrets-fixed:1 ls -a /app
0B	CMD ["python" "app.py"]
0B	EXPOSE [8000/tcp]
4.1kB	RUN /bin/sh -c echo "running migrations as shop:$DB_PASSWORD" > /dev/null # buildkit
61.4kB	COPY . . # buildkit
.
..
.dockerignore
.gitignore
.gitleaks.toml
.pre-commit-config.yaml
Dockerfile
Dockerfile.fixed
Dockerfile.leaky
app.py
notify.py
requirements.txt
tests

البناء نجح دون تحذير، وسطر ال RUN في التاريخ يحمل اسم المتغير دون قيمته، وطبقة COPY صغرت من 537kB إلى 61.4kB لأن .git لم يعد فيها، و.env غير موجود في أي طبقة.

الخطوة الأولى: إلغاء التوكن Revoke وإصدار بديل Rotate

الآن نطبق خطة الاستجابة، ونبدأ بالتوكن الذي قال TruffleHog إنه يعمل. نجعل التطبيق يقرأه من البيئة، ونلتزم بالتعديل، ثم نبني التطبيق ونشغله بتوكن جديد مولد عشوائياً:

$R sh -c 'sed -i "s/^ADMIN_API_TOKEN = .*/ADMIN_API_TOKEN = os.environ[\"ADMIN_API_TOKEN\"]/" app.py \
  && git diff --stat && git commit -qam "Read the admin token from the environment"; git log --oneline -1'
 app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Detect hardcoded secrets.................................................Passed
fa40636 Read the admin token from the environment
NEW="dso_live_$(python -c "import secrets,string;print(''.join(secrets.choice(string.ascii_letters+string.digits) for _ in range(32)))")"
docker build -q -t lab-dso-secrets-app:2 shop
docker rm -f lab-dso-secrets-app
docker run -d --name lab-dso-secrets-app -e ADMIN_API_TOKEN="$NEW" -p 127.0.0.1:19885:8000 lab-dso-secrets-app:2
docker network connect lab-dso-secrets-net lab-dso-secrets-app
curl -s -o /dev/null -w "old token: %{http_code}\n" -H "X-Api-Token: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa" http://127.0.0.1:19885/api/admin/stats
curl -s -o /dev/null -w "new token: %{http_code}\n" -H "X-Api-Token: $NEW" http://127.0.0.1:19885/api/admin/stats

ومخرج أمري curl كما يلي:

old token: 401
new token: 200

التوكن القديم ما زال في التاريخ وفي ال Image القديمة، لكنه لم يعد يفتح شيئاً. ونعيد فحص TruffleHog على المستودع كله، ثم نقرأ سجل خدمة التحقق:

docker run --rm --network lab-dso-secrets-net -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 \
  trufflehog git file:///work/shop --config /work/trufflehog.yaml --no-color 2>&1 \
  | grep -E "Found|Detector Type|Raw result|Verification issue|File:|finished"
docker logs --since 1m lab-dso-secrets-verifier
Found unverified result 🐷🔑❓
Detector Type: CustomRegex
Raw result: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
File: scripts/stats.sh
Found unverified result 🐷🔑❓
Detector Type: CustomRegex
Raw result: dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa
File: app.py
2026-10-06T16:31:13Z	info-0	trufflehog	finished scanning	{"chunks": 22, "bytes": 7566, "verified_secrets": 0, "unverified_secrets": 3, "scan_duration": "3.974071888s", "trufflehog_version": "3.98.0", "verification_caching": {"Hits":0,"Misses":4,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":3950}}
Found unverified result 🐷🔑❓
Verification issue: lookup sts.us-east-1.amazonaws.com on 127.0.0.11:53: server misbehaving
Detector Type: AWS
Raw result: AKIA22THC5LBKKK2SZ7A
File: .env
verify dso_live_7Hq... -> app answered 401
verify dso_live_7Hq... -> app answered 401
verify dso_live_7Hq... -> app answered 401

التوكن نفسه في الملفين صار unverified، لأن خدمة التحقق ردت بالرمز 401 الذي عرفناه في rotatedRanges، وهذا ما تريد أن تراه بعد الإلغاء. أما الفحص بإعداد الخط فما زال يفشل:

docker run --rm --network lab-dso-secrets-net -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 \
  trufflehog git file:///work/shop --config /work/trufflehog.yaml --results=verified,unknown --fail --no-color 2>&1 \
  | grep -E "Found|Detector Type|Raw result|Verification issue|finished"; echo "exit=${PIPESTATUS[0]}"
Found unverified result 🐷🔑❓
Verification issue: lookup sts.us-east-1.amazonaws.com on 127.0.0.11:53: server misbehaving
Detector Type: AWS
Raw result: AKIA22THC5LBKKK2SZ7A
2026-10-06T16:31:27Z	info-0	trufflehog	finished scanning	{"chunks": 22, "bytes": 7566, "verified_secrets": 0, "unverified_secrets": 1, "scan_duration": "2.7560562s", "trufflehog_version": "3.98.0", "verification_caching": {"Hits":0,"Misses":4,"HitsWasted":0,"AttemptsSaved":0,"VerificationTimeSpentMS":2719}}
exit=183

التوكن الملغى خرج من النتائج، وبقي مفتاح AWS لأن التحقق منه تعذر، فهو في حالة unknown وإن طبع بالعبارة «unverified result» مع سطر Verification issue، والخيار --results=verified,unknown يعامله كخطر، وهذا هو السلوك الصحيح، لأن الأداة لا تعرف هل يعمل. وفي الواقع يكون الحل إلغاء هذا المفتاح أيضاً من لوحة AWS، أما في تجربتنا فالمفتاح مزيف أصلاً، فننتقل إلى تنظيف التاريخ.

تنظيف التاريخ بأداة git filter-repo

نحاكي سيرفر Git بمستودع Bare اسمه origin.git، ونأخذ منه نسخة جديدة Fresh Clone كما يطلب توثيق الأداة، ثم نحذف ملف .env من كل ال Commits، ونستبدل التوكن الملغى بنص ثابت في كل ملف يظهر فيه:

docker run --rm -v lab-dso-secrets-work:/work -w /work lab-dso-secrets-tools:1 sh -c '
git clone -q --bare shop origin.git && git clone -q origin.git cleanup && cd cleanup && git branch -a
printf "dso_live_7Hq2Lx9Vt4Rz8Wn3Bk6Mh1Sd5Gj0CyPa==>REMOVED_ROTATED_TOKEN\n" > ../replacements.txt
git filter-repo --sensitive-data-removal --invert-paths --path .env --replace-text ../replacements.txt'

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

* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/feature/stats-script
  remotes/origin/main
NOTICE: Fetching all refs from origin to make sure we rewrite
        all history that may reference the sensitive data, via
      git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
Parsed 9 commits
HEAD is now at 44b4cce Read the admin token from the environment

New history written in 0.30 seconds; now repacking/cleaning...
You rewrote 9 (of 9) commits.

NOTE: First Changed Commit(s) is/are:
  91a39f63648aa477092a42f7b5c55bb4b0d2ddda
NOTE: LFS object orphaning not checked (LFS not in use)

Repacking your repo and cleaning out old unneeded objects
Completely finished after 0.59 seconds.

NEXT STEPS FOR YOUR SENSITIVE DATA REMOVAL:
  * If you are doing your rewrite in multiple steps, ignore these next steps
    until you have completed all your invocations of git-filter-repo.
  * See the "Sensitive Data Removal" subsection of the "DISCUSSION" section
    of the manual for more details about any of the steps below.
  * Inspect this repository and verify that the sensitive data is indeed
    completely removed from all commits.
  * Force push the rewritten history to the server:
      git push --force --mirror origin
  * Contact the server admins for additional steps they need to take; the
    First Changed Commit(s) may come in handy here.
  * Have other colleagues with a clone either discard their clone and reclone
    OR follow the detailed steps in the manual to repeatedly rebase and
    purge the sensitive data from their copy.  Again, the First Changed
    Commit(s) may come in handy.
  * See the "Prevent repeats and avoid future sensitive data spills" section
    of the manual.

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

  • الخيار --sensitive-data-removal أضيف في الإصدار 2.47 من الأداة، كما يذكر توثيق GitHub، وأول ما فعله أنه جلب كل المراجع من السيرفر، فأعاد كتابة الفرع feature/stats-script أيضاً، ولو نسيته لبقي التوكن فيه.
  • كل ال Commits التسعة تغيرت معرفاتها بدءاً من أول Commit 91a39f6، لأن ملف app.py فيه تغير، وهذا سبب أن كل من لديه نسخة يجب أن يأخذ نسخة جديدة.
  • الأداة نفسها كتبت خطوات ما بعد التنظيف: الدفع بالقوة، والتواصل مع مدير السيرفر، والتعامل مع النسخ عند الزملاء.

نفحص النسخة المنظفة قبل الدفع:

docker run --rm -v lab-dso-secrets-work:/work -w /work/cleanup lab-dso-secrets-tools:1 sh -c '
git log --all --oneline -- .env | wc -l
gitleaks git . --no-banner --no-color -v; echo "exit=$?"'
0
4:31PM INF Unknown SCM platform. Use --platform to include links in findings. host=
Finding:     curl -s -H "X-Api-Token: REMOVED_ROTATED_TOKEN"
Secret:      REMOVED_ROTATED_TOKEN
RuleID:      curl-auth-header
Entropy:     3.272804
File:        scripts/stats.sh
Line:        3
Commit:      ddc93d0759caf3a71ad9e4de47907d37ac4398b8
Author:      Lab Dev
Email:       [email protected]
Date:        2026-10-06T16:24:45Z
Fingerprint: ddc93d0759caf3a71ad9e4de47907d37ac4398b8:scripts/stats.sh:curl-auth-header:3

4:31PM INF 8 commits scanned.
4:31PM INF scanned ~6617 bytes (6.62 KB) in 332ms
4:31PM WRN leaks found: 1
exit=1

لا يوجد أي Commit يذكر .env، ولكن النص البديل الذي اخترناه نحن صار إنذاراً خاطئاً، لأنه جاء بعد ترويسة تحمل توكناً في أمر curl. وهذه حالة .gitleaksignore بالضبط: نتيجة واحدة في Commit قديم لا نريد تعديله مرة أخرى، فنضع بصمتها كما ظهرت. ولو أعدت هذه الخطوة فاختر بديلاً لا يشبه السر، والبديل الافتراضي في الأداة إذا لم تكتب ==> هو ***REMOVED***. بعد ذلك ندفع بالقوة، ونقرأ ما بقي على السيرفر:

docker run --rm -v lab-dso-secrets-work:/work -w /work/cleanup lab-dso-secrets-tools:1 sh -c '
git push --force --mirror origin 2>&1
git -C /work/origin.git cat-file -t 4eda72a; git -C /work/origin.git show 4eda72a:.env | head -1'
To /work/origin.git
 + bcbea22...ddc93d0 feature/stats-script -> feature/stats-script (forced update)
 + fa40636...44b4cce main -> main (forced update)
commit
AWS_ACCESS_KEY_ID=AKIA22THC5LBKKK2SZ7A

الفروع على السيرفر صارت تشير إلى التاريخ الجديد، ولكن ال Commit القديم 4eda72a ما زال في قاعدة كائنات Objects السيرفر، ومن يعرف معرفه يقرأ الملف منه. وهذا ما يقصده التوثيق بالتواصل مع مدير السيرفر، ففي سيرفرك تحذف الكائنات التي لم يعد يشير إليها شيء بنفسك:

docker run --rm -v lab-dso-secrets-work:/work -w /work lab-dso-secrets-tools:1 sh -c '
git -C origin.git reflog expire --expire=now --all && git -C origin.git gc -q --prune=now
git -C origin.git cat-file -t 4eda72a
git -C shop show 4eda72a:.env | head -1'
fatal: Not a valid object name 4eda72a
AWS_ACCESS_KEY_ID=AKIA22THC5LBKKK2SZ7A

السيرفر لم يعد يعرف ال Commit، أما النسخة shop، وهي هنا نسخة المطور الأصلية، فما زالت تحمله كاملاً، ولو دفعها صاحبها بعد دمج لأعاد التاريخ القديم إلى السيرفر، لذلك يحذف كل زميل نسخته ويستنسخ من جديد. وفي GitHub أو غيره من المنصات المستضافة لا تملك أمر gc على السيرفر، ولذلك يطلب توثيق GitHub التواصل مع الدعم، ويذكر أن ال Forks تبقى كما هي.

الآن نضيف ملف الاستثناء، وندفع، ونفحص النسخة والسيرفر:

docker run --rm -v lab-dso-secrets-work:/work -w /work/cleanup lab-dso-secrets-tools:1 sh -c '
echo "ddc93d0759caf3a71ad9e4de47907d37ac4398b8:scripts/stats.sh:curl-auth-header:3" > .gitleaksignore
git add .gitleaksignore && git commit -q -m "Ignore the placeholder left by the history cleanup" && git push -q origin main
gitleaks git . --no-banner --no-color; echo "exit=$?"
gitleaks git /work/origin.git --config .gitleaks.toml --gitleaks-ignore-path .gitleaksignore --no-banner --no-color; echo "exit=$?"'
4:32PM INF Unknown SCM platform. Use --platform to include links in findings. host=
4:32PM INF 9 commits scanned.
4:32PM INF scanned ~6694 bytes (6.69 KB) in 82.5ms
4:32PM INF no leaks found
exit=0
4:32PM INF Unknown SCM platform. Use --platform to include links in findings. host=
4:32PM INF 9 commits scanned.
4:32PM INF scanned ~6694 bytes (6.69 KB) in 60.4ms
4:32PM INF no leaks found
exit=0

ولاحظ في الأمر الثاني أننا مررنا ملف الإعداد وملف الاستثناء صراحة، والسبب أن المستودع على السيرفر من نوع Bare ليس فيه مجلد عمل، فلا يجد Gitleaks فيه .gitleaks.toml، وفي تشغيل سابق دون هذين الخيارين ظهر توكن الاختبار dso_test_ من جديد. ثم نفحص نسخة جديدة من السيرفر ب TruffleHog بإعداد الخط نفسه:

docker run --rm --network lab-dso-secrets-net -v lab-dso-secrets-work:/work lab-dso-secrets-tools:1 sh -c '
git clone -q /work/origin.git /tmp/fresh
trufflehog git file:///tmp/fresh --config /work/trufflehog.yaml --results=verified,unknown --fail --no-color > /tmp/o 2>&1
echo "exit=$?"; grep -c Found /tmp/o'
exit=0
0

الفحص النظيف Clean Scan لا يعني أنه لا توجد أسرار

الأداتان تقولان الآن إن المستودع نظيف، ولكن مفتاح الجلسات SECRET_KEY ما زال مكتوباً في app.py منذ أول Commit، ولم تجده أي منهما. وإذا عرفت أن مشروعك يستخدم نوعاً من الأسرار لا تعرفه القواعد الافتراضية، فأضف له قاعدة خاصة في .gitleaks.toml نفسه:

[[rules]]
id = "flask-secret-key"
description = "Flask SECRET_KEY written in the code"
regex = '''SECRET_KEY["']?\]?\s*=\s*["']([^"']{8,})["']'''
secretGroup = 1
keywords = ["secret_key"]
docker run --rm -v lab-dso-secrets-work:/work -w /work/cleanup lab-dso-secrets-tools:1 \
  sh -c 'gitleaks dir app.py --config .gitleaks.toml --no-banner --no-color -v; echo "exit=$?"'
Finding:     SECRET_KEY"] = "flask-dev-secret-2024"
Secret:      flask-dev-secret-2024
RuleID:      flask-secret-key
Entropy:     3.748995
File:        app.py
Line:        16
Fingerprint: app.py:flask-secret-key:16

4:46PM INF scanned ~4534 bytes (4.53 KB) in 8.85ms
4:46PM WRN leaks found: 1
exit=1

والآن يعود هذا المفتاح إلى خطة الاستجابة من أولها: يولد مفتاح جديد ويقرأ من البيئة، والسبب أن من يعرف المفتاح القديم يستطيع أن يوقع ملف تعريف ارتباط Cookie لأي مستخدم، كما شرحنا في الجلسات وملفات تعريف الارتباط وJWT.

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

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

docker rm -f lab-dso-secrets-app lab-dso-secrets-verifier
docker network rm lab-dso-secrets-net
docker volume rm lab-dso-secrets-work lab-dso-secrets-cache
docker image rm lab-dso-secrets-tools:1 lab-dso-secrets-app:1 lab-dso-secrets-app:2 lab-dso-secrets-leaky:1 lab-dso-secrets-fixed:1

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

  • الحذف في Commit جديد ثم اعتبار الأمر منتهياً: السر باق في التاريخ وفي كل نسخة، والخطوة الأولى دائماً إلغاؤه.
  • تنظيف التاريخ قبل إلغاء المفتاح: ساعات من العمل والمفتاح ما زال يعمل في كل نسخة أخذت قبل التنظيف.
  • الاعتماد على خطاف pre-commit وحده: يتجاوزه أي مطور بخيار واحد، ولا ينتقل مع النسخ، وقد يفشل مفتوحاً كما رأيت، فاجعل الفحص في خط CI هو الحكم.
  • عدم اختبار الفحص نفسه: أدخل سراً مزيفاً بعد إعداد أي خطاف أو خطوة فحص، وتأكد أنها تفشل فعلاً.
  • .gitignore دون .dockerignore: الملف الذي لا يدخل Git يدخل ال Docker Image مع COPY . .، ومعه .git بكل تاريخه.
  • الأسرار في ARG وENV ثم rm: القيمة في تاريخ ال Image والملف في طبقته، واستخدم RUN --mount=type=secret.
  • استثناءات واسعة: استثناء مجلد كامل أو قاعدة كاملة يخفي الأسرار الحقيقية القادمة، واجعل الاستثناء على المسار والشكل معاً.
  • تحقق TruffleHog على ما لا تملكه: التحقق يستخدم المفتاح فعلاً، فلا تقم به على مفاتيح أو خدمات غيرك، واستخدم --no-verification أو خدمة تحقق خاصة بك.
  • اعتبار الفحص النظيف دليلاً على غياب الأسرار: كلمات المرور البشرية ومفاتيحك الخاصة لا تعرفها القواعد، فأضف لها قواعد، وراجع الكود بعين بشرية أيضاً.

قائمة تحقق Checklist

  • .env في .gitignore وفي .dockerignore، و.git في .dockerignore، وملف .env.example دون قيم في المستودع.
  • خطاف pre-commit لأداة Gitleaks في المستودع، مختبر بسر مزيف، وكل مطور نفذ pre-commit install.
  • كل طلب دمج يفحص ال Commits الجديدة بالأمر gitleaks git --log-opts، ونجاحه شرط للدمج في الفرع الرئيسي.
  • فحص أسبوعي للتاريخ كله على كل الفروع، مع TruffleHog لترتيب المفاتيح التي ما زالت تعمل.
  • فحص ال Docker Image قبل رفعها إلى ال Registry.
  • كل استثناء ضيق ومكتوب في المستودع ومعه سببه، ولا يوجد مفتاح يعمل في أي Baseline.
  • خطة استجابة مكتوبة: من يلغي المفاتيح، وأين لوحة كل خدمة، ومن يقرأ السجلات، ومن ينظف التاريخ.
  • الأسرار في متغيرات البيئة أو أداة أسرار، وأسرار البناء بالخيار --mount=type=secret.

الخلاصة

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

  • السر الذي دخل Commit واحداً مسرب، حتى لو حذفته بعد دقيقة، لأنه في التاريخ وفي كل نسخة، وقد يكون في Docker Image أيضاً.
  • الأدوات تكتشف الأسرار بالكلمات المفتاحية وRegex والعشوائية، فتجد المفاتيح ذات الشكل المعروف بدقة، وتفوتها كلمات المرور التي تشبه الكلام، وTruffleHog يضيف التحقق مع الجهة المصدرة فيخبرك أي المفاتيح يعمل الآن.
  • Gitleaks (MIT) سريع ولا يتصل بشيء، فمكانه خطاف pre-commit وكل طلب دمج، وTruffleHog (AGPL-3.0) مكانه الفحص الدوري وفحص ال Images.
  • الخطاف للراحة، وخط CI هو الحكم، والفحص نفسه يختبر بسر مزيف، فقد رأيت خطافاً يمرر كل شيء بصمت.
  • الاستثناء الصحيح ضيق على المسار والشكل معاً، ومكتوب في المستودع.
  • بعد التسرب: ألغ المفتاح أولاً، ثم قيم، ثم أصلح الكود، ثم نظف التاريخ بأداة git filter-repo وادفع بالقوة واحذف الكائنات من السيرفر، ثم تعامل مع النسخ والتفرعات.

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

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

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بأدوات Gitleaks 8.30.1 وTruffleHog 3.98.0 وpre-commit 4.6.2 وgit filter-repo 2.47.0 وGit 2.47.3 في Docker على تطبيق السلسلة التجريبي، والتحقق من تراخيص الأدوات من مستودعاتها، ومن بيان Toyota عن حادثة T-Connect.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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