> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# خط CI/CD آمن من البداية للنهاية على Gitea Actions
- URL: https://arabroot.io/articles/خط-ci-cd-امن-من-البداية-للنهاية/
- Published: 2026-10-06T00:00:00.000Z
- Updated: 2026-10-09T09:43:12.000Z
- Description: كيف تجمع فحوص الأمن في خط واحد لا يستطيع طلب الدمج تجاوزه، ثم ترسل إلى الإنتاج نفس ال Docker Image الموقعة التي فحصتها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps والتشغيل, DevOps وCI/CD, Docker والحاويات, الاستضافة الذاتية

في مقالات سلسلة DevSecOps شغلنا كل فحص وحده: Gitleaks على الأسرار، وSemgrep على الكود، وOSV-Scanner على ملف القفل، وTrivy على ال Docker Image، وZAP على التطبيق وهو يعمل، ووقعنا ال Image بأداة Cosign في مقال سلسلة التوريد. وكل مقال انتهى بجملة تقول إن تجميع الفحوص في خط واحد موضوع مقال آخر، وهذا هو المقال. والسؤال هنا ليس «كيف أشغل الأداة؟» فقد أجبنا عليه، وإنما كيف تجعل هذه الفحوص بوابات حقيقية لا يستطيع أحد تجاوزها بالخطأ أو بالقصد، وكيف تحمي الخط نفسه وهو يملك مفاتيح التوقيع والنشر، وكيف تصل Image واحدة بنيت مرة واحدة إلى الإنتاج دون أن تتغير في الطريق.

وإذا كنت تبدأ من هنا فاقرأ أولاً [المقال الرئيسي للسلسلة DevSecOps للمبتدئين](https://arabroot.io/articles/devsecops-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-sast-%D9%88-dast-%D9%88-sca/)، فهو يشرح كل نوع من الفحوص ومكانه، وهذا المقال لا يعيد شرح أي أداة، وإنما يربطها ببعضها ويحيلك إلى مقالها عند الحاجة.

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

- لماذا لا تكفي الفحوص المتفرقة، حتى لو كانت كلها تعمل في خط CI.
- الخريطة الكاملة للخط: من خطاف pre-commit على جهاز المطور إلى الإنتاج والتشغيل.
- حماية الخط نفسه: الفرع المحمي Protected Branch والفحوص المطلوبة Required Checks، وسير العمل المفروض على مستوى المنظمة Scoped Workflows، وصلاحيات توكن المهمة، وتثبيت ال Actions بالهاش، وأين توضع الأسرار، مع نتيجة من التجربة لم نكن نتوقعها.
- تجربة عملية كاملة على Gitea وGitea Runner: طلب دمج ينجح، وطلب فيه سر مزروع، وطلب فيه مكتبة مصابة، وبناء يوقع ال Digest ويرفق SBOM وشهادة بناء Provenance، ونشر على staging مع فحص ZAP، ثم ترقية Promotion ال Digest نفسه إلى الإنتاج، ورفض Image غير موقعة.
- جدول سياسة البوابات Gate Policy: ما الذي يوقف الخط وما الذي ينبه فقط، وطريقة الاستثناء بتاريخ انتهاء.
- أزمنة الخط كاملاً بأرقام من التشغيل، وما يقابل الملفات نفسها في GitHub Actions.
- مسار النضج Maturity Path: كيف تبدأ بثلاث بوابات ثم تضيف الباقي.

## لماذا لا تكفي الفحوص المتفرقة؟

لنفرض أن فريقك قرأ مقالات السلسلة وأضاف كل فحص إلى ملف workflow، فأصبح كل طلب دمج Pull Request يشغل Gitleaks وSemgrep وOSV-Scanner، وكل دمج في main يبني ال Image ويفحصها بأداة Trivy ثم ينشرها. قد يبدو هذا من الوهلة الأولى خطاً آمناً، ولكن في الواقع فهو غير كذلك، والتفصيل فيما يلي.

### الطريقة البسيطة: نضيف الفحوص ونثق بأنها تعمل

المشكلة الأولى أن الفحص الذي يفشل لا يمنع شيئاً ما لم يقل الفرع الرئيسي إنه مطلوب، وقد رأينا ذلك بأنفسنا في [تجربة Renovate في مقال SCA](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/#lab-renovate)، حيث دمج Gitea طلب تحديث Jinja2 خلال ثوان من إنشائه، لأن الدمج التلقائي يعني «ادمج عندما تنجح الفحوص المطلوبة»، والمستودع لم تكن فيه فحوص مطلوبة أصلاً. والمشكلة الثانية أن ملف workflow نفسه جزء من طلب الدمج، فالمطور الذي يريد أن ينهي مهمته قبل نهاية اليوم يستطيع أن يحذف خطوة الفحص في الطلب نفسه، فيعمل الخط بالملف الجديد وينجح. والمشكلة الثالثة أن الخط ينشر بالوسم Tag، فما يصل إلى الإنتاج هو ما يشير إليه الوسم لحظة النشر، وليس بالضرورة ما فحصه الخط.

### الطريقة الثانية: مهمة واحدة كبيرة تفعل كل شيء

والحل الثاني الذي يخطر على البال هو أن نجمع كل شيء في مهمة Job واحدة على main: تفحص وتبني وتوقع وتنشر على staging ثم على الإنتاج. وهذه الطريقة تحل مشكلة الترتيب، لكنها تضع مفتاح التوقيع وشهادة الدخول إلى سيرفر الإنتاج في المهمة نفسها التي تشغل كوداً من طلبات الدمج ومكتبات من الإنترنت، فأي مكتبة خبيثة أو Action مخترقة تعمل في هذه المهمة ترى كل شيء، وقد شرحنا حوادث من هذا النوع في [أمن سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#ci-actions). ثم إن الإنتاج يصبح امتداداً آلياً لكل دمج، دون لحظة يقرر فيها شخص مسؤول أن هذا الإصدار جاهز.

### الطريقة الصحيحة: بوابات مفروضة، وبناء واحد، وترقية بال Digest

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

- **البوابة مفروضة من خارج طلب الدمج:** الفحوص الأمنية يملكها فريق الأمن في مستودع مستقل، ويفرضها Gitea على كل طلب دمج في المنظمة، والفرع الرئيسي لا يقبل الدمج إلا بعد نجاحها ومراجعة شخص آخر.
- **البناء مرة واحدة Build Once:** ال Docker Image تبنى مرة واحدة على main، فتفحص، وتوقع، ويرفق بها SBOM وشهادة بناء، ثم تنتقل كما هي بين البيئات، كما شرحنا في [ابن ال Docker Image مرة واحدة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/#build-once).
- **الترقية بال Digest وليس بالوسم:** ما يصل إلى الإنتاج هو الهاش نفسه الذي فحصه الخط ونشره على staging، ويتحقق الخط من توقيعه قبل كل نشر، فإذا لم يجد التوقيع توقف.
- **كل سر في المكان الذي يحتاجه فقط:** أسرار الإنتاج في مستودع لا يصل إليه المطورون، وتعمل مهامه على Runner لا يشغل غيره.

## الخريطة الكاملة للخط

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

![سبع مراحل مرقمة: الأولى pre-commit على جهاز المطور بأداة gitleaks توقف ال commit ويمكن تجاوزها بالخيار no-verify، والثانية طلب الدمج بخمسة فحوص مطلوبة secrets وsast وsca وconfig وtest مع مراجعة واحدة توقف الدمج، والثالثة البناء مرة واحدة بالأوامر docker build وsyft وtrivy image والرفع بال Digest وتوقف الإصدار على ثغرات HIGH وCRITICAL القابلة للإصلاح، والرابعة التوقيع وإرفاق SBOM وشهادة SLSA بمفتاح في أسرار Gitea دون سجل Rekor العام، والخامسة staging مع cosign verify وتشغيل ال Digest وفحص zap-baseline، والسادسة ترقية ال Digest نفسه إلى الإنتاج بيد فريق الإصدار من مستودع acme/deploy وRunner خاص به مع رفض أي Image غير موقعة، والسابعة التشغيل مع إعادة فحص ليلية وSBOM في Dependency-Track](https://arabroot.io/content/images/2026/10/secure-pipeline-01-pipeline-map.webp)

كل مرحلة تفحص ما أصبح موجوداً فيها، والهاش نفسه ينتقل من البناء إلى الإنتاج

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

- المرحلة الأولى وحدها يمكن تجاوزها، لأنها تعمل على جهاز المطور، ولذلك تتكرر الفحوص نفسها في المرحلة الثانية على السيرفر، وهذا ما سوف تراه في التجربة.
- المرحلتان الثانية والثالثة تشغلان الأدوات التي شرحناها في مقالاتها: [فحص الأسرار](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/)، و[الفحص الثابت SAST](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/)، و[فحص الاعتماديات SCA](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/)، و[فحص ال Docker Images والبنية ككود](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/)، والجديد هنا هو ربطها ببعضها ومن يملك كل جزء.
- المرحلة الرابعة تطبق ما شرحناه نظرياً في [توقيع ال Images](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#signing) و[شهادة البناء ومستويات SLSA](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#slsa)، ولكن داخل الخط نفسه.
- المرحلة الخامسة هي [الفحص أثناء التشغيل DAST](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/)، والسادسة هي الترقية التي شرحنا فكرتها في [البيئات وGitOps](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/)، ولكن مع التحقق من التوقيع قبلها.

## تجهيز التجربة: Gitea وRunner وسيرفران

سوف نبني التجربة كلها في Docker على جهاز واحد، كما فعلنا في [دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/)، ولكن بأكثر من Container لأن الفكرة كلها في الفصل بين الأجزاء: سيرفر Gitea واحد يعمل أيضاً Registry لل Docker Images، وRunner للمنظمة كلها، وRunner ثان لمستودع النشر فقط، وسيرفران يعملان بدور staging والإنتاج، وكل منهما Docker يقبل الاتصال عبر TLS بشهادة عميل Client Certificate فقط. والبادئة لكل ما ننشئه `lab-pipeline-`، والمنافذ على العنوان 127.0.0.1 فقط.

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

![ثلاثة مستودعات: acme/ci-policies يملكه فريق الأمن وفيه الملف security.yml المفروض على كل طلب دمج ولا أسرار فيه، وacme/shop يكتب فيه المطورون وتعمل مهامه على runner-01 للمنظمة وتوكن المهمة فيه للقراءة، وأسراره REGISTRY_TOKEN لحساب ci-bot بصلاحية write:package ومفتاح Cosign الخاص وكلمة مروره وشهادة staging وتوكن السحب وأسرار التطبيق، ولا توجد فيه أي شهادة للإنتاج، وتحتها ملاحظة حمراء أن أي فرع يدفع إلى المستودع يقرأ كل هذه الأسرار، وacme/deploy لفريق الإصدار وحده وله runner-deploy خاص وفيه المفتاح العام وأسرار الإنتاج ويشغل الترقية بإدخال Digest واحد، وفي الأسفل ال Registry وسيرفر staging وسيرفر الإنتاج عبر TLS على المنفذ 2376](https://arabroot.io/content/images/2026/10/secure-pipeline-02-trust-boundaries.webp)

كل سر في المكان الذي يحتاجه فقط، وأسرار الإنتاج في مستودع لا يصل إليه المطورون

### Gitea والمنظمة والفرق Teams

نشغل Gitea بأقل إعداد ممكن، ثم ننشئ المستخدمين: مطوراً، ومدير إصدار، ومراجعاً من فريق الأمن، وحسابين للآلات Bots، واحداً يرفع ال Images وآخر يسحبها فقط:

```bash
docker network create lab-pipeline-net
docker volume create lab-pipeline-gitea-data
docker run -d --name lab-pipeline-gitea --network lab-pipeline-net -p 127.0.0.1:19910:3000 \
  -v lab-pipeline-gitea-data:/var/lib/gitea \
  -e GITEA__security__INSTALL_LOCK=true -e GITEA__database__DB_TYPE=sqlite3 \
  -e GITEA__server__ROOT_URL=http://lab-pipeline-gitea:3000/ -e GITEA__server__DOMAIN=lab-pipeline-gitea \
  -e GITEA__service__DISABLE_REGISTRATION=true -e GITEA__actions__ENABLED=true \
  gitea/gitea:28.1.0-rootless

docker exec lab-pipeline-gitea gitea admin user create --admin --username lab \
  --password 'LabOnly-Pipe-2026!' --email lab@example.com --must-change-password=false
for u in dev1 relmgr sec1 ci-bot deploy-bot; do
  docker exec lab-pipeline-gitea gitea admin user create --username $u \
    --password "LabOnly-$u-2026!" --email $u@example.com --must-change-password=false
done

# The bots get tokens with one scope each
docker exec lab-pipeline-gitea gitea admin user generate-access-token --username ci-bot \
  --token-name registry-push --scopes write:package --raw
docker exec lab-pipeline-gitea gitea admin user generate-access-token --username deploy-bot \
  --token-name pull --scopes read:package --raw
```

ثم ننشئ المنظمة `acme` وثلاثة مستودعات عبر ال API، وخمسة فرق بصلاحيات محددة لكل وحدة Unit، والجدول التالي يجمعها:

| الفريق     | الأعضاء    | المستودعات    | الصلاحيات                                                         |
| ---------- | ---------- | ------------- | ----------------------------------------------------------------- |
| developers | dev1       | shop          | كتابة على الكود وطلبات الدمج، وقراءة فقط على Actions وال Packages |
| release    | relmgr     | shop وdeploy  | كتابة على الكود وActions، وهو الوحيد الذي يصل إلى مستودع النشر    |
| security   | sec1       | كل المستودعات | قراءة الكود، والمراجعة على طلبات الدمج                            |
| ci         | ci-bot     | shop          | كتابة على ال Packages فقط                                         |
| deployers  | deploy-bot | shop          | قراءة ال Packages فقط                                             |

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

```bash
curl -s -H "Authorization: token $ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:19910/api/v1/orgs/acme/teams -d '{
  "name": "developers", "permission": "write", "includes_all_repositories": false,
  "units_map": {"repo.code": "write", "repo.pulls": "write", "repo.issues": "write",
                "repo.actions": "read", "repo.packages": "read", "repo.releases": "read"}}'
```

### سيرفرا staging والإنتاج: Docker عبر TLS

الخط يحتاج أن ينشر على سيرفر، والطريقة الأبسط في التجربة أن يكون كل سيرفر Container من النوع `docker:dind`، وال Image الرسمية تولد شهادات TLS للسيرفر والعميل بنفسها عند التشغيل إذا حددت المتغير `DOCKER_TLS_CERTDIR` كما تشرح [صفحتها على Docker Hub](https://hub.docker.com/%5F/docker?ref=arabroot.io)، فلا يقبل Docker على المنفذ 2376 أي اتصال لا يحمل شهادة العميل، وهو ما يوصي به [توثيق حماية Docker daemon](https://docs.docker.com/engine/security/protect-access/?ref=arabroot.io):

```bash
printf '{\n  "insecure-registries": ["lab-pipeline-gitea:3000"]\n}\n' > daemon.json

for e in staging:19911 prod:19912; do n=${e%%:*}; p=${e##*:}
  docker run -d --name lab-pipeline-$n --hostname lab-pipeline-$n --privileged --network lab-pipeline-net \
    -e DOCKER_TLS_CERTDIR=/certs -v lab-pipeline-$n-certs:/certs -v lab-pipeline-$n-docker:/var/lib/docker \
    -v "$PWD/daemon.json:/etc/docker/daemon.json:ro" -p 127.0.0.1:$p:8080 docker:29.8.1-dind
done

# The client certificate of each host becomes a secret of the repository that deploys to it
docker exec lab-pipeline-staging ls /certs/client
```

```text
ca.pem
cert.pem
csr.pem
key.pem
openssl.cnf
```

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

- ملف `daemon.json` يسمح بال Registry دون TLS لأن Gitea في التجربة يعمل على HTTP داخل شبكة Docker، ولا تفعل ذلك مع Registry حقيقي، فال Registry في الإنتاج خلف HTTPS كما في [دليل تثبيت Gitea](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-gitea-%D9%85%D8%B9-docker-compose/).
- شهادة العميل هي مفتاح السيرفر كله، فمن يملكها يستطيع أن يشغل أي Container عليه، ولذلك سوف ترى أن شهادة staging في أسرار مستودع التطبيق، وشهادة الإنتاج في مستودع آخر.
- في سيرفر حقيقي تفضل غالباً الاتصال عبر SSH بالأمر `docker context`، أو أن يسحب السيرفر نفسه ما يجب نشره كما في GitOps، والفكرة واحدة: صلاحية النشر محصورة في المكان الذي يحتاجها.

### Runner للمنظمة وRunner لمستودع النشر وحده

نسجل Runner على مستوى المنظمة `acme` بالوسم `docker`، من النوع `dind` الذي شرحنا سببه في [دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/#runner)، ثم Runner ثانياً على مستوى المستودع `acme/deploy` فقط بالوسم `deploy`. وملف الإعداد واحد للاثنين إلا في الوسم:

```yaml
log:
  level: info
runner:
  capacity: 2
  timeout: 1h
  labels:
    - "docker:docker://docker:29.8.1-cli"
container:
  network: ""
  privileged: false
```

```bash
RT=$(curl -s -X POST -H "Authorization: token $ADMIN_TOKEN" \
  http://127.0.0.1:19910/api/v1/orgs/acme/actions/runners/registration-token | jq -r .token)

docker run -d --name lab-pipeline-runner --privileged --network lab-pipeline-net \
  -e GITEA_INSTANCE_URL=http://lab-pipeline-gitea:3000/ -e GITEA_RUNNER_NAME=runner-01 \
  -e CONFIG_FILE=/config/config.yaml -e GITEA_RUNNER_REGISTRATION_TOKEN=$RT \
  -v "$PWD/runner-config.yaml:/config/config.yaml:ro" -v "$PWD/daemon.json:/etc/docker/daemon.json:ro" \
  -v lab-pipeline-runner-data:/data -v lab-pipeline-runner-docker:/var/lib/docker gitea/runner:4.1.0-dind

# The second runner takes its token from the repository acme/deploy, not from the organisation
RT=$(curl -s -X POST -H "Authorization: token $ADMIN_TOKEN" \
  http://127.0.0.1:19910/api/v1/repos/acme/deploy/actions/runners/registration-token | jq -r .token)
```

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

```text
level=info msg="Runner registered successfully."
level=info msg="runner: runner-01, with version: v4.1.0, with labels: [docker], declare successfully"
level=info msg="runner: runner-deploy, with version: v4.1.0, with labels: [deploy], declare successfully"
```

وقد يتساءل البعض: لماذا Runner ثان والأول يكفي؟ والإجابة أن ال Runner من النوع dind يحتفظ بال Images التي سحبتها المهام في Docker الخاص به، ويعمل بالوضع privileged، فمهمة خبيثة من طلب دمج تستطيع أن تترك فيه أثراً، مثل Image بديلة لأداة Cosign بالوسم نفسه، فتجدها مهمة النشر إلى الإنتاج بعد ساعة وتستخدمها. وال Runner المسجل على مستودع `acme/deploy` لا يأخذ مهام أي مستودع آخر، فلا يشغل كود المطورين أبداً، وهذا ما يقصده توثيق Gitea بقاعدته البسيطة في [صفحة Actions](https://docs.gitea.com/usage/actions/overview/?ref=arabroot.io): لا تستخدم Runner لا تثق به، ولا تعط Runner لمستودع لا تثق به. ولهذا السبب نفسه نكتب كل Image في الخط بال Digest، فحتى لو وجدت مهمة النشر Image بديلة بالوسم نفسه فلن تطابق الهاش المكتوب في الملف.

## حماية الخط نفسه قبل أن يحمي الكود

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

### الفرع المحمي Protected Branch والفحوص المطلوبة Required Checks

أول إعداد وأهمه أن الفرع `main` لا يقبل الدفع المباشر Push، ولا يقبل الدمج إلا بعد نجاح فحوص محددة بالاسم ومراجعة شخص آخر. وهذا هو الإصلاح المباشر لما رأيناه في [تجربة Renovate](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/#lab-renovate)، فالدمج التلقائي بعد هذا الإعداد ينتظر الفحوص فعلاً، لأن [توثيق الفروع المحمية في Gitea](https://docs.gitea.com/usage/access-control/protected-branches/?ref=arabroot.io) يقول إن القواعد تطبق على كل طرق الكتابة، ومنها ال API والمهام الخلفية مثل الدمج التلقائي. والقاعدة كما أنشأناها عبر ال API:

```json
{
  "rule_name": "main",
  "enable_push": false,
  "enable_force_push": false,
  "required_approvals": 1,
  "dismiss_stale_approvals": true,
  "block_on_rejected_reviews": true,
  "block_on_codeowner_reviews": true,
  "block_on_outdated_branch": true,
  "block_admin_merge_override": true,
  "enable_status_check": true,
  "status_check_contexts": ["pr / test (pull_request)"]
}
```

```bash
curl -s -H "Authorization: token $ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:19910/api/v1/repos/acme/shop/branch_protections -d @protect.json
```

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

- **`enable_push: false`:** لا يوجد دفع مباشر إلى main لأي أحد، فكل تغيير يمر بطلب دمج.
- **`dismiss_stale_approvals`:** الموافقة تسقط إذا دفع المطور Commit جديداً بعدها، والسبب أن المراجع وافق على كود معين، وليس على كل ما سوف يضاف إلى الفرع لاحقاً.
- **`block_on_codeowner_reviews`:** إذا لمس الطلب ملفاً له مالك في ملف `CODEOWNERS` فلا يدمج إلا بموافقة المالك، وسوف نستخدمه لملفات الاستثناءات وملفات الخط نفسه.
- **`block_admin_merge_override`:** حتى مدير المستودع لا يستطيع أن يضغط زر الدمج بالقوة Force Merge، فالقاعدة على الجميع.
- **`status_check_contexts`:** اسم الفحص بالصيغة التي يرسلها Gitea Actions، أي اسم ال workflow ثم اسم المهمة ثم الحدث. وهنا فحص الاختبارات فقط، لأن الفحوص الأمنية مفروضة بطريقة أقوى في القسم التالي.

ولكي نتأكد أن القاعدة تعمل، حاول المطور أن يدفع Commit مباشرة إلى main، فكان المخرج كما يلي:

```text
remote: error: Not allowed to push to protected branch main
To http://127.0.0.1:19910/acme/shop.git
 ! [remote rejected] direct -> main (pre-receive hook declined)
```

ثم حاول أن يدمج طلب الدمج الأول عبر ال API قبل أن تنتهي الفحوص، فرفضه Gitea برمز HTTP 405:

```text
{"message":"Not all required status checks successful"}
```

### فحوص يملكها فريق الأمن ولا يغيرها طلب الدمج: Scoped Workflows

إذا كانت الفحوص الأمنية في المجلد `.gitea/workflows` داخل المستودع نفسه، فطلب الدمج يستطيع أن يعدلها، لأن Gitea يشغل ملف workflow كما هو في الفرع الذي فتح منه الطلب. والفرع المحمي لا يحل هذه المشكلة، لأن الطلب الذي حذف خطوة الفحص سوف يرسل حالة success باسم الفحص نفسه. وقد أضاف Gitea في الإصدار [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0?ref=arabroot.io) ميزة سير العمل المفروض Scoped Workflows، وتشرح [صفحتها في التوثيق](https://docs.gitea.com/usage/actions/scoped-workflows/?ref=arabroot.io) الفكرة: تضع ملفات workflow في مستودع مصدر داخل المجلد `.gitea/scoped_workflows`، ثم تسجله على مستوى المنظمة، فيعمل على كل مستودعات المنظمة، ويقرأ Gitea محتواه من الفرع الرئيسي للمستودع المصدر وقت الحدث، وإذا علمته مطلوباً Required فلا يستطيع أي مستودع أن يعطله، ولا يدمج أي طلب في فرع محمي حتى تنجح الفحوص التي تطابق الأنماط التي حددتها.

لذلك وضعنا الفحوص الأمنية الأربعة في مستودع `acme/ci-policies` الذي يملكه فريق الأمن، وهذا هو الملف كاملاً:

```yaml
# Owned by the security team. Gitea runs it on every pull request of every acme repository,
# from this repository's default branch, so a pull request cannot change or skip it.
name: security
on:
  pull_request:

permissions:
  contents: read

env:
  GITLEAKS: ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f
  SEMGREP: semgrep/semgrep:1.179.0@sha256:93963d9295a366f59e4850127b1550400ee7b388f04fe144e4a1f6325d96e01b
  OSV: ghcr.io/google/osv-scanner:v2.6.0@sha256:afd838850ac1a0fcc15ff4a041dc9ba11123c3f0d2666217a5f0fcf9222b55fa
  TRIVY: aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa

jobs:
  secrets:
    runs-on: docker
    steps:
      - uses: builtin:checkout
        with:
          fetch-depth: 0
      - name: Gitleaks on the commits of this pull request
        run: |
          RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
          docker run --rm --volumes-from "$(hostname)" -w "$PWD" "$GITLEAKS" \
            git . --log-opts="$RANGE" --redact --no-banner --exit-code 1

  sast:
    runs-on: docker
    steps:
      - uses: builtin:checkout
        with:
          fetch-depth: 0
      - name: Semgrep, new findings only
        run: |
          # The Semgrep image expects the code in /src, so the job's workspace is copied there first
          docker run --rm --volumes-from "$(hostname)" "$SEMGREP" sh -c "
            cp -a $PWD/. /src/ && cd /src && git config --global --add safe.directory /src &&
            semgrep scan --config p/python --config p/flask --config p/dockerfile \
              --baseline-commit ${{ github.event.pull_request.base.sha }} --error --metrics=off"

  sca:
    runs-on: docker
    steps:
      - uses: builtin:checkout
      - name: OSV-Scanner on the lockfile
        run: |
          docker run --rm --volumes-from "$(hostname)" -w "$PWD" "$OSV" \
            scan source -L requirements.txt

  config:
    runs-on: docker
    steps:
      - uses: builtin:checkout
      - name: Trivy on the Dockerfile and the deployment files
        run: |
          docker run --rm --volumes-from "$(hostname)" -w "$PWD" "$TRIVY" \
            config -q --severity HIGH,CRITICAL --exit-code 1 .
```

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

- **كل أداة Image بإصدار ثم Digest:** الإصدار للقراءة، وال Digest هو ما يستخدمه Docker فعلاً، فلو نشر أحد Image أخرى بالوسم `v8.30.1` فلن تعمل في خطنا، وقد ذكرنا في [مقال سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lab-pin) حادثة نشرت فيها إصدارات خبيثة من Image أداة فحص نفسها.
- **`builtin:checkout`:** يأتي مع Gitea Runner 4 ولا ينزل أي Action من الإنترنت كما تقول [صفحة الفروق عن GitHub Actions](https://docs.gitea.com/usage/actions/comparison/?ref=arabroot.io)، فهذا الملف كله لا يعتمد على كود Action خارجي، وأقل Action خطراً هي التي لا تستخدمها.
- **`--volumes-from "$(hostname)"`:** مهمة الخط تعمل داخل Container، والأداة تعمل في Container مجاور على Docker نفسه، فلا يصلح معها `-v "$PWD:/src"` لأن المسار غير موجود على Docker الخاص بال Runner. والمجلد الذي فيه الكود في Gitea Runner هو Volume مربوط بمهمة الخط، فنعطي الأداة ال Volumes نفسها عبر اسم ال Container، وهو اسم الجهاز داخلها.
- **فحص الأسرار على Commits الطلب فقط:** المدى من base إلى head، وفحص التاريخ كله مكانه مهمة مجدولة كما شرحنا في [الفرق أم التاريخ كله](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/#ci-diff-history).
- **Semgrep بالخيار `--baseline-commit`:** يفشل على النتائج الجديدة فقط، كما في [الخط الأساسي في مقال SAST](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#lab-baseline).

ثم سجلنا المستودع مصدراً من إعدادات المنظمة Settings ثم Actions ثم Scoped Workflows، وعلمنا الملف مطلوباً بنمط فحص لكل مهمة. وعرضت صفحة الإعداد الأسماء التي سوف ترسلها المهام، وهي بالشكل التالي:

```text
acme/ci-policies: security / secrets (pull_request)
acme/ci-policies: security / sast (pull_request)
acme/ci-policies: security / sca (pull_request)
acme/ci-policies: security / config (pull_request)
```

ثم كتبنا في خانة الأنماط سطراً لكل مهمة، والصورة التالية تبين الإعداد كما ظهر في الصفحة بعد الحفظ، وتحته الفحوص التي يتوقعها Gitea وكل منها مطابق:

![جدول في إعدادات المنظمة: الملف security.yml مع علامة Required، وخانة أنماط الفحوص المطلوبة فيها أربعة أسطر acme/ci-policies: security / secrets وsast وsca وconfig بالحدث pull_request، وتحتها قائمة Expected status checks وكل سطر منها عليه كلمة Matched](https://arabroot.io/content/images/2026/10/secure-pipeline-07-scoped-required.webp)

نمط لكل مهمة، فالمهمة التي لا ترسل حالة توقف الدمج ولا تختفي

### صلاحيات توكن المهمة GITEA\_TOKEN

كل مهمة في Gitea Actions تحصل على توكن اسمه `GITEA_TOKEN` تتعامل به مع المستودع، وكانت صلاحيته في الوضع الافتراضي القديم قراءة وكتابة على أغلب أجزاء المستودع. ومنذ الإصدار [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0?ref=arabroot.io) يمكنك تحديد صلاحياته، وتشرح [صفحة صلاحيات توكن المهمة](https://docs.gitea.com/usage/actions/token-permissions/?ref=arabroot.io) ثلاثة مستويات: ما تكتبه المهمة في `permissions`، ثم ما يكتبه ال workflow، ثم الإعداد الافتراضي للمنظمة أو المستودع، والنتيجة لا تتجاوز حداً أعلى Maximum تضبطه أنت. لذلك غيرنا الإعداد الافتراضي للمنظمة من Permissive إلى Restricted، أي قراءة فقط للكود والإصدارات وال Packages، وكتبنا في كل ملف workflow:

```yaml
permissions:
  contents: read
```

ولاحظ أمرين من الصفحة نفسها ومن [صفحة الفروق عن GitHub](https://docs.gitea.com/usage/actions/comparison/?ref=arabroot.io): الأول أن طلبات الدمج القادمة من Fork تحصل دائماً على قراءة فقط مهما كتبت، والثاني أن الصلاحية `id-token` غير مدعومة في Gitea Actions، أي أن المهمة لا تستطيع أن تطلب توكن OIDC تثبت به هويتها لجهة أخرى، وهذا هو السبب في أننا سوف نوقع بزوج مفاتيح وليس بالتوقيع بلا مفاتيح Keyless الذي شرحناه في [مقال سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#signing). ولاحظ أيضاً أن `GITEA_TOKEN` لا يستطيع أن يرفع Image إلى ال Registry الخاص بالمستودع كما تقول الصفحة نفسها، فنحتاج توكناً لحساب البوت `ci-bot` بصلاحية `write:package` وحدها.

### أين توضع الأسرار؟ ما وجدناه في التجربة

قبل أن نوزع الأسرار جربنا سؤالاً بسيطاً: هل يرى ال workflow في فرع جديد أسرار المستودع؟ فأنشأنا مستودعاً تجريبياً فيه سر اسمه `PROBE` قيمته `abc123`، ودفعنا فرعاً فيه ملف workflow يطبع طول السر وأول حرفين منه، ثم فتحنا منه طلب دمج:

```yaml
name: pr-probe
on: [pull_request]
jobs:
  p:
    runs-on: docker
    steps:
      - run: echo "secret-len=${#S} first2=$(echo $S | cut -c1-2)"
        env:
          S: ${{ secrets.PROBE }}
```

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

```text
secret-len=6 first2=ab
```

أي أن Gitea يستبدل قيمة السر الكاملة بالنجوم في السجل كما ذكرنا في [دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/#secrets)، ولكن أي جزء منها يظهر كما هو، والأهم أن ال workflow الموجود في فرع لم يراجعه أحد حصل على السر، وكذلك مهمة `push` على الفرع نفسه قبل أن يفتح طلب الدمج. وهذا منطقي لأن Gitea لا يدعم البيئات Environments ويتجاهل `jobs.<job_id>.environment` كما تقول [صفحة الفروق](https://docs.gitea.com/usage/actions/comparison/?ref=arabroot.io)، فلا توجد طريقة لتقول «هذا السر لفرع main فقط». والنتيجة العملية أن كل من يملك صلاحية الدفع إلى المستودع يملك أسراره، لذلك وزعنا الأسرار كما في الصورة السابقة:

- **مستودع التطبيق `acme/shop`:** توكن رفع ال Images، ومفتاح التوقيع، وشهادة staging وأسرارها. وهذه يستطيع أي مطور في الفريق أن يقرأها إذا أراد، وهذا مقبول بقدر ما نثق بالفريق، وسوف نعود إلى ما يعنيه ذلك للتوقيع.
- **مستودع النشر `acme/deploy`:** شهادة الإنتاج وأسراره، ولا يصل إليه المطورون أصلاً، فعندما طلب المطور المستودع أو حاول تشغيل ال workflow فيه عبر ال API كان الرد `404 not found` في الحالتين.
- **Fork من شخص خارج الفريق:** يحتاج موافقة قبل أن تعمل مهامه، وصلاحية توكنه قراءة فقط كما ذكرنا، ولا تقبل تشغيل مهامه قبل أن تقرأ ما عدله في المجلد `.gitea/workflows`، كما في [دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/#runner).

### ملفات الخط والاستثناءات تحتاج موافقة فريق الأمن: CODEOWNERS

بقي أن نمنع تغيير الخط أو إضافة استثناء دون أن يراه فريق الأمن، وهنا يأتي ملف `CODEOWNERS`، فتقول [صفحته في توثيق Gitea](https://docs.gitea.com/usage/repository/code-owners/?ref=arabroot.io) إن كل سطر فيه تعبير نمطي Regex بصيغة Go ثم المالك، وإن النقطة تكتب `\\.`، ومع خيار `block_on_codeowner_reviews` في الفرع المحمي لا يدمج الطلب حتى يوافق المالك:

```text
# .gitea/CODEOWNERS
# Exceptions, the pipeline and the trusted key are reviewed by the security team.
^osv-scanner\\.toml$ @acme/security
^\\.trivyignore\\.yaml$ @acme/security
^\\.gitleaksignore$ @acme/security
^\\.zap/.* @acme/security
^\\.gitea/.* @acme/security
^\\.cosign/.* @acme/security
^ci/.* @acme/security
```

ولاحظ أن ملفات workflow الخاصة بالمستودع نفسه، أي مهمة الاختبار وخط البناء، ما زالت في `.gitea/workflows`، فالطلب الذي يعدلها يعمل بالنسخة المعدلة قبل الدمج كما رأيت في تجربة الأسرار، ولكنه لا يدمج دون موافقة فريق الأمن، ولا يصل إلى main إلا بعدها.

### تثبيت كل شيء: ال Actions بالهاش وال Images بال Digest

الخط في هذا المقال يستخدم Action خارجية واحدة فقط، هي `actions/upload-artifact` لحفظ التقارير، ونكتبها بهاش Commit كامل كما شرحنا في [تثبيت ال Actions على هاش Commit كامل](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#pin-actions)، وحصلنا على الهاش من مستودعها مباشرة:

```bash
git ls-remote https://github.com/actions/upload-artifact 'refs/tags/v4.6.2*'
```

```text
ea165f8d65b6e75b540449e92b4886f43607fa02	refs/tags/v4.6.2
```

```yaml
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
```

وكل Image في الخط مكتوبة بال Digest بعد الإصدار، ومنها ال Base Images في ال Dockerfile، والهاش هنا هو هاش القائمة Index لكل المعماريات، وحصلنا عليه من `docker image inspect` بعد السحب. ولاحظ أن Image مهمة الخط نفسها `docker:29.8.1-cli` مكتوبة في إعداد ال Runner بالوسم، فاكتبها بال Digest أيضاً إذا كان ال Runner يخدم أكثر من فريق.

## التطبيق الذي يمر في الخط

نأخذ التطبيق التجريبي للسلسلة بعد أن أصلحنا فيه ما وجدته الأدوات في المقالات السابقة: الأسرار من متغيرات البيئة بدلاً من الكود، واستعلام SQL بمعاملات Parameterized، والصفحات من قوالب Jinja2 التي تهرب كل قيمة، وإعادة التوجيه إلى مسارات داخلية فقط، وترويسات الأمان، ومكتبات حديثة، وBase Image مدعومة على distroless بمستخدم عادي. وقد شرحنا كل إصلاح منها في مقاله، فلا نعيده هنا. والجديد في هذا المقال ثلاثة ملفات في المستودع:

- **ملف القفل بالهاشات:** الاعتماديات المباشرة في `requirements.in`، وملف القفل `requirements.txt` مولد منه بالأمر `uv pip compile --generate-hashes` كما في [توثيق uv](https://docs.astral.sh/uv/pip/compile/?ref=arabroot.io)، وال Dockerfile يثبته بالخيار `--require-hashes`، فيرفض pip أي ملف لا يطابق الهاش المكتوب كما يشرح [توثيق التثبيت الآمن في pip](https://pip.pypa.io/en/stable/topics/secure-installs/?ref=arabroot.io).
- **ملفات الاستثناءات:** `osv-scanner.toml` و`.trivyignore.yaml` و`.zap/rules.tsv`، وكلها تبدأ فارغة أو بقواعد مكتوبة السبب.
- **المفتاح العام `.cosign/cosign.pub`:** يستخدمه النشر على staging للتحقق، والمفتاح الخاص في أسرار المستودع فقط.

وهذا ال Dockerfile كما يبنيه الخط:

```dockerfile
# FIX-07: a supported base pinned by digest, a multi-stage build, and a non-root runtime without a shell.
FROM python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes --target /app/deps -r requirements.txt

FROM gcr.io/distroless/python3-debian13:nonroot@sha256:774595d652a294b54c9bd575b2d9fdd1a4b47547dc17b8bfa4c0e953c64855b3
WORKDIR /app
COPY --from=build /app/deps /app/deps
COPY app.py .
COPY templates ./templates
ENV PYTHONPATH=/app/deps
USER 65532:65532
EXPOSE 8000
CMD ["app.py"]
```

## المرحلة الأولى: خطاف pre-commit على جهاز المطور

أرخص مكان لإيقاف سر هو قبل أن يدخل التاريخ، لأن السر الذي لم يصل إلى ال Commit لا يحتاج تدويراً Rotation ولا تنظيف تاريخ، وقد شرحنا هذا كله مع إطار pre-commit في [مقال فحص الأسرار](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/#pre-commit). وهنا نستخدم أبسط شكل ممكن: ملف خطاف داخل المستودع يفعله كل مطور مرة واحدة:

```bash
#!/bin/sh
# .githooks/pre-commit
# Stops a commit that adds a secret, before it reaches the history.
# Enable it once per clone with: git config core.hooksPath .githooks
exec docker run --rm -v "$(git rev-parse --show-toplevel):/repo" -w /repo \
  ghcr.io/gitleaks/gitleaks:v8.30.1 git --pre-commit --staged --redact --no-banner -v .
```

ثم كتب المطور ملفاً يفتح Issue عند كل طلب كبير، ووضع فيه توكن GitHub مباشرة، والتوكن هنا مزيف ولد عشوائياً بالشكل الصحيح، وحاول أن يعمل Commit:

```bash
git config core.hooksPath .githooks
git add notify.py
git commit -m "Open an issue on big orders"; echo "exit=$?"
```

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

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

2:48PM INF 0 commits scanned.
2:48PM INF scanned ~430 bytes (430 bytes) in 125ms
2:48PM WRN leaks found: 1
exit=1
```

الخطاف أوقف ال Commit، والخيار `--redact` أخفى السر من المخرج حتى لا يظهر في شاشة أحد أو في تسجيل اجتماع. ولكن المطور مستعجل، فكتب الأمر نفسه مع `--no-verify`، فدخل ال Commit دون أي فحص، ثم دفع الفرع وفتح طلب دمج. وهذا هو الحد الطبيعي لكل ما يعمل على جهاز المطور، وهو أيضاً السبب في أن الفحص نفسه يتكرر على السيرفر في المرحلة التالية، حيث لا يوجد خيار يتجاوزه.

## المرحلة الثانية: طلب الدمج Pull Request

على كل طلب دمج يعمل ملفان: ملف `security` المفروض من `acme/ci-policies` الذي رأيته أعلاه بمهامه الأربع، وملف `pr` من المستودع نفسه، وفيه مهمة واحدة تبني ال Image وتشغلها وتتأكد أن الصفحة تهرب النص الخطر:

```yaml
# .gitea/workflows/pr.yml
name: pr
on:
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: docker
    steps:
      - uses: builtin:checkout
      - name: Build the Docker Image and start it
        run: |
          docker build -q -t "shop:pr-${{ github.event.pull_request.number }}" .
          docker run -d --rm --name "shop-pr-$GITHUB_RUN_ID" --network "container:$(hostname)" \
            -e SECRET_KEY=test -e ADMIN_API_TOKEN=test -e ADMIN_PASSWORD=test \
            "shop:pr-${{ github.event.pull_request.number }}"
          for i in $(seq 1 30); do wget -qO /dev/null http://127.0.0.1:8000/api/health && break; sleep 1; done
      - name: Smoke test
        run: |
          wget -qO- http://127.0.0.1:8000/api/health
          wget -qO- "http://127.0.0.1:8000/search?q=%3Cscript%3E" | grep -q '&lt;script&gt;'
      - name: Stop
        if: always()
        run: docker rm -f "shop-pr-$GITHUB_RUN_ID" || true
```

ولاحظ الخيار `--network "container:$(hostname)"`، فهو يشغل التطبيق في شبكة مهمة الخط نفسها، فتصل إليه المهمة على 127.0.0.1 دون أن ينشر أي منفذ على ال Runner.

### طلب دمج ينجح: إضافة الخطاف

أول طلب دمج فتحه المطور يضيف ملف الخطاف الذي رأيته في المرحلة الأولى، وهذا ما أرسلته المهام الخمس على آخر Commit في الطلب، كما أعاده ال API:

```text
success
  pr / test (pull_request) success
  acme/ci-policies: security / secrets (pull_request) success
  acme/ci-policies: security / sast (pull_request) success
  acme/ci-policies: security / sca (pull_request) success
  acme/ci-policies: security / config (pull_request) success
```

ومن سجلات المهام، هذه الأسطر التي تهمك من كل أداة:

```text
# secrets (gitleaks)
3:29PM INF 1 commits scanned.
3:29PM INF scanned ~311 bytes (311 bytes) in 195ms
3:29PM INF no leaks found
# sast (semgrep)
  Scanning 1 file tracked by git with 158 Code rules:
  Current version has 0 findings.
Skipping baseline scan, because there are no current findings.
# sca (osv-scanner)
Scanned /workspace/acme/shop/requirements.txt file and found 7 packages
No issues found
# config (trivy)
│ Dockerfile │ dockerfile │         0         │
```

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

### طلب دمج يفشل: السر الذي تجاوز الخطاف

نعود إلى المطور الذي استخدم `--no-verify`، فقد دفع الفرع وفتح طلب الدمج الثاني، فنجح الاختبار ونجحت ثلاث مهام أمنية، وفشلت مهمة الأسرار:

```text
failure
  pr / test (pull_request) success
  acme/ci-policies: security / secrets (pull_request) failure
  acme/ci-policies: security / sast (pull_request) success
  acme/ci-policies: security / sca (pull_request) success
  acme/ci-policies: security / config (pull_request) success
```

وهذا مخرج مهمة الأسرار، ولاحظ أن السر نفسه لا يظهر في السجل، وقد بحثنا عن قيمة التوكن في السجل كاملاً فلم نجدها:

```text
Finding:     GITHUB_TOKEN = "REDACTED
Secret:      REDACTED
RuleID:      github-pat
Entropy:     4.671928
File:        notify.py
Line:        5
Commit:      e7874cdd52d8b626b81981dec82d9afd8b6e5ada
Author:      Dev One
Email:       dev1@example.com
Date:        2026-10-07T14:49:02Z
Fingerprint: e7874cdd52d8b626b81981dec82d9afd8b6e5ada:notify.py:github-pat:5

3:38PM INF 1 commits scanned.
3:38PM INF scanned ~430 bytes (430 bytes) in 262ms
3:38PM WRN leaks found: 1
##[error]Process completed with exit code 1.
```

ثم وافق مدير الإصدار على الطلب، كأنه لم ينتبه إلى الفحص الأحمر، وحاول المطور الدمج، فرفضه Gitea برمز HTTP 405:

```text
{"message":"Not all required status checks successful"}
```

والطلب لم يدمج، ولكن السر وصل إلى السيرفر في فرع، فهو الآن في كل نسخة من المستودع سحبت هذا الفرع، وفي سجلات Gitea، ولذلك فالخطوة الأولى هي إلغاء التوكن وإصدار بديل، ثم إغلاق الطلب وكتابة الفرع من جديد دون ال Commit، كما شرحنا بالترتيب في [خطة الاستجابة لتسرب مفتاح](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/#leak-response). وكان نمط الفحص المطلوب وقت هذه التجربة نمطاً واحداً هو `acme/ci-policies: security / *`، وقد أوقف الدمج كما رأيت لأن إحدى المهام المطابقة فشلت، ولكننا غيرناه بعدها إلى نمط لكل مهمة كما في الصورة التي رأيتها في قسم Scoped Workflows، والسبب أن المهمة التي تحذف من الملف أو يتغير اسمها لا ترسل أي حالة أصلاً، والنمط الذي يحمل اسمها يبقى ينتظرها فيوقف الدمج، أما النمط العام فقد يكتفي بما وصل من المهام الأخرى

### طلب دمج يفشل: مكتبة فيها ثغرات معروفة

الطلب الثالث يضيف مكتبة `requests` بالإصدار 2.31.0 من أجل عميل لخدمة الشحن، وولد المطور ملف القفل من جديد بالأمر نفسه، فدخلت معها أربع مكتبات أخرى. ففشلت مهمة SCA بالمخرج التالي:

```text
Scanned /workspace/acme/shop/requirements.txt file and found 12 packages
Loaded filter from: /workspace/acme/shop/osv-scanner.toml

Total 1 package affected by 3 known vulnerabilities (0 Critical, 0 High, 3 Medium, 0 Low, 0 Unknown) from 1 ecosystem.
3 vulnerabilities can be fixed.

+-------------------------------------+------+-----------+----------+---------+---------------+------------------+
| OSV URL                             | CVSS | ECOSYSTEM | PACKAGE  | VERSION | FIXED VERSION | SOURCE           |
+-------------------------------------+------+-----------+----------+---------+---------------+------------------+
| https://osv.dev/PYSEC-2026-1872     | 5.3  | PyPI      | requests | 2.31.0  | 2.32.4        | requirements.txt |
| https://osv.dev/GHSA-9hjg-9r4m-mvj7 |      |           |          |         |               |                  |
| https://osv.dev/PYSEC-2026-1873     | 5.6  | PyPI      | requests | 2.31.0  | 2.32.0        | requirements.txt |
| https://osv.dev/GHSA-9wx4-h78v-vm56 |      |           |          |         |               |                  |
| https://osv.dev/PYSEC-2026-2275     | 5.5  | PyPI      | requests | 2.31.0  | 2.33.0        | requirements.txt |
| https://osv.dev/GHSA-gc5v-m9x4-r6x2 |      |           |          |         |               |                  |
+-------------------------------------+------+-----------+----------+---------+---------------+------------------+
##[error]Process completed with exit code 1.
```

كل الثغرات الثلاث من مستوى Medium، وكلها لها إصلاح، فهل يستحق هذا أن يوقف الدمج؟ نعم، والسبب أن الإصلاح هنا رفع إصدار صغير قبل أن تدخل المكتبة إلى المشروع، وكلفته دقائق الآن، أما بعد الدمج فسوف تصبح سطراً في قائمة طويلة لا يقرؤها أحد.

## الاستثناء Exception بسبب وصاحب وتاريخ انتهاء

المطور لم يرد أن يغير الإصدار في هذا الطلب، فأضاف الثغرات الثلاث إلى ملف `osv-scanner.toml` بالحقل `ignoreUntil` الذي يجعل الاستثناء ينتهي وحده، كما في [توثيق إعداد OSV-Scanner](https://google.github.io/osv-scanner/configuration/?ref=arabroot.io):

```toml
[[IgnoredVulns]]
id = "GHSA-9hjg-9r4m-mvj7"
ignoreUntil = 2026-11-07
reason = "Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1"

[[IgnoredVulns]]
id = "GHSA-9wx4-h78v-vm56"
ignoreUntil = 2026-11-07
reason = "Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1"

[[IgnoredVulns]]
id = "GHSA-gc5v-m9x4-r6x2"
ignoreUntil = 2026-11-07
reason = "Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1"
```

فنجحت مهمة SCA هذه المرة، والسبب مكتوب في السجل نفسه:

```text
Loaded filter from: /workspace/acme/shop/osv-scanner.toml
GHSA-9hjg-9r4m-mvj7 and 2 aliases have been filtered out because: Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1
GHSA-9wx4-h78v-vm56 and 2 aliases have been filtered out because: Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1
GHSA-gc5v-m9x4-r6x2 and 2 aliases have been filtered out because: Shipping client only calls one internal host. Upgrade planned in SHIP-31. Owner: dev1
Filtered 6 vulnerabilities from output

No issues found
```

ونجحت الفحوص الخمسة كلها، ووافق مدير الإصدار، ومع ذلك رفض Gitea الدمج:

```text
{"message":"There are missing code owner reviews"}
```

والسبب أن الطلب لمس الملف `osv-scanner.toml`، وهو في `CODEOWNERS` لفريق الأمن، فطلب Gitea مراجعة الفريق تلقائياً. وراجع عضو فريق الأمن الطلب، وطلب تغييراً Request Changes بجملة واحدة: الإصلاح رفع إصدار صغير، فارفع الإصدار بدلاً من الاستثناء. فأعاد المطور الملف كما كان، وغير السطر في `requirements.in` إلى `requests>=2.33.0`، فاختار uv الإصدار 2.34.2، ونجحت الفحوص الخمسة، ودمج الطلب بعد موافقة مدير الإصدار وموافقة فريق الأمن.

وفي هذه الخطوة ظهرت ملاحظة صغيرة تستحق الذكر: موافقة عضو فريق الأمن وحدها لم تكف، فرد Gitea `Does not have enough approvals`، والسبب أن [الموافقات المطلوبة](https://docs.gitea.com/usage/access-control/protected-branches/?ref=arabroot.io) تحسب من أصحاب صلاحية الكتابة، وفريق الأمن عندنا يقرأ الكود فقط، فموافقته تحقق شرط CODEOWNERS ولا تحقق عدد الموافقات. وهذا التقسيم مقصود: شخص يكتب الكود يوافق على التغيير، وفريق الأمن يوافق على كل استثناء.

### صيغة الاستثناء في كل أداة

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

| الأداة      | الملف أو الصيغة                                   | تاريخ انتهاء | من يراجعه                             |
| ----------- | ------------------------------------------------- | ------------ | ------------------------------------- |
| OSV-Scanner | osv-scanner.toml بالحقول id وignoreUntil وreason  | نعم          | فريق الأمن عبر CODEOWNERS             |
| Trivy       | .trivyignore.yaml بالحقلين statement وexpired\_at | نعم          | فريق الأمن عبر CODEOWNERS             |
| Gitleaks    | .gitleaksignore بال Fingerprint                   | لا           | فريق الأمن، والسبب في رسالة ال Commit |
| Semgrep     | تعليق nosemgrep: rule-id في السطر نفسه مع السبب   | لا           | مراجع الكود، ويظهر في الفرق Diff      |
| ZAP         | .zap/rules.tsv بالقيمة IGNORE ثم السبب            | لا           | فريق الأمن عبر CODEOWNERS             |

والأدوات التي لا تقبل تاريخاً اكتب لها التاريخ في السبب، وشغل كل شهر مهمة مجدولة تبحث عن التواريخ التي مرت، أو راجع الملفات في اجتماع ثابت، والمهم أن الاستثناء لا يبقى في الملف بعد أن يتغير سببه، وقد رأيت في [تجربة Trivy](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/#lab-ignore-expiry) كيف يعود الفحص إلى الفشل وحده بعد تاريخ `expired_at`.

## المرحلة الثالثة والرابعة: البناء مرة واحدة ثم التوقيع

بعد دمج الطلب في main يعمل ملف `release` بثلاث مهام متتالية: `build` ثم `staging` ثم `dast`، وكل مهمة لا تبدأ إلا إذا نجحت التي قبلها. وهذا هو الملف كاملاً كما عمل في التجربة:

```yaml
# .gitea/workflows/release.yml
# Build once on main, scan, sign and attest the digest, deploy that digest to staging, then DAST.
name: release
on:
  push:
    branches: [main]

permissions:
  contents: read

env:
  IMAGE: lab-pipeline-gitea:3000/acme/shop
  SYFT: anchore/syft:v1.54.1@sha256:3eb5379ba7b409c3f4069b686110527af0c47df993fa5c10d13e7cf34f49b1aa
  TRIVY: aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
  COSIGN: ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8
  ZAP: ghcr.io/zaproxy/zaproxy:2.17.0@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef

jobs:
  build:
    runs-on: docker
    outputs:
      ref: ${{ steps.push.outputs.ref }}
    steps:
      - uses: builtin:checkout
      - name: Build once
        run: |
          echo "STARTED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
          mkdir -p reports && chmod 777 reports
          docker build --label org.opencontainers.image.revision="$GITHUB_SHA" -t "$IMAGE:$GITHUB_SHA" .
          # The scanners read this exact file, not whatever the daemon holds under the tag later
          docker save "$IMAGE:$GITHUB_SHA" -o image.tar

      - name: SBOM with Syft (CycloneDX)
        run: |
          docker run --rm --volumes-from "$(hostname)" -w "$PWD" \
            "$SYFT" docker-archive:image.tar -q -o cyclonedx-json=reports/sbom.cdx.json
          echo "packages in the SBOM: $(grep -o '"type":"library"' reports/sbom.cdx.json | wc -l)"

      - name: Image gate with Trivy (fixable HIGH and CRITICAL only)
        run: |
          T="docker run --rm --volumes-from $(hostname) -w $PWD $TRIVY"
          $T image -q --scanners vuln --format json -o reports/trivy.json --input image.tar
          $T image -q --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
            --ignorefile .trivyignore.yaml --exit-code 1 --input image.tar

      - name: Push and resolve the digest
        id: push
        run: |
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login lab-pipeline-gitea:3000 -u ci-bot --password-stdin
          docker push -q "$IMAGE:$GITHUB_SHA"
          REF=$(docker image inspect "$IMAGE:$GITHUB_SHA" --format '{{index .RepoDigests 0}}')
          docker logout lab-pipeline-gitea:3000
          echo "ref=$REF" >> "$GITHUB_OUTPUT"
          echo "REF=$REF" >> "$GITHUB_ENV"
          echo "pushed $REF"

      - name: Sign the digest and attach the SBOM and the provenance
        env:
          COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          sh ci/provenance.sh "${REF##*@}" "$STARTED" > reports/provenance.json
          C="docker run --rm --network host --volumes-from $(hostname) -w $PWD -e COSIGN_KEY -e COSIGN_PASSWORD $COSIGN"
          AUTH="--allow-http-registry --registry-username ci-bot --registry-password $REGISTRY_TOKEN"
          $C sign --key env://COSIGN_KEY --signing-config ci/signing-config.json $AUTH --yes "$REF"
          $C attest --key env://COSIGN_KEY --signing-config ci/signing-config.json $AUTH --yes \
            --type cyclonedx --predicate reports/sbom.cdx.json "$REF"
          $C attest --key env://COSIGN_KEY --signing-config ci/signing-config.json $AUTH --yes \
            --type slsaprovenance1 --predicate reports/provenance.json "$REF"

      - name: Keep the reports
        if: always()
        run: apk add --no-cache -q nodejs
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        if: always()
        with:
          name: build-reports
          path: reports/

  staging:
    needs: build
    runs-on: docker
    steps:
      - uses: builtin:checkout
      - name: Verify and deploy the digest to staging
        env:
          TARGET_DOCKER_HOST: tcp://lab-pipeline-staging:2376
          TLS_CA: ${{ secrets.STAGING_TLS_CA }}
          TLS_CERT: ${{ secrets.STAGING_TLS_CERT }}
          TLS_KEY: ${{ secrets.STAGING_TLS_KEY }}
          PULL_USER: deploy-bot
          PULL_TOKEN: ${{ secrets.STAGING_PULL_TOKEN }}
          APP_SECRET_KEY: ${{ secrets.STAGING_SECRET_KEY }}
          APP_ADMIN_API_TOKEN: ${{ secrets.STAGING_ADMIN_API_TOKEN }}
          APP_ADMIN_PASSWORD: ${{ secrets.STAGING_ADMIN_PASSWORD }}
        run: sh ci/deploy.sh "${{ needs.build.outputs.ref }}" .cosign/cosign.pub

  dast:
    needs: staging
    runs-on: docker
    steps:
      - uses: builtin:checkout
      - name: ZAP baseline on staging (FAIL rules block, warnings do not)
        run: |
          mkdir -p reports && chmod 777 reports
          sleep 5
          docker run --rm --network host --volumes-from "$(hostname)" "$ZAP" sh -c "
            mkdir -p /zap/wrk && cp $PWD/.zap/rules.tsv /zap/wrk/ &&
            zap-baseline.py -t http://lab-pipeline-staging:8080 -c rules.tsv -I -s -J zap.json; rc=\$?;
            cp /zap/wrk/zap.json $PWD/reports/ 2>/dev/null; exit \$rc"
```

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

- **البناء مرة واحدة ثم ملف واحد:** بعد البناء نحفظ ال Image في الملف `image.tar`، ويقرأ Syft وTrivy الملف نفسه. وفي نسخة سابقة من الخط كان Trivy يسأل Docker عن ال Image بالوسم مرتين، وعندما كان ال Runner مشغولاً بفحص ZAP ومهام طلب دمج في الوقت نفسه انتهت المهلة وفشل الفحص بالخطأ `context deadline exceeded`، فالملف أسرع وأثبت، ويضمن أن ما فحصناه هو ما بنيناه.
- **البوابة على ما له إصلاح فقط:** الخيار `--ignore-unfixed` مع HIGH وCRITICAL، والسبب شرحناه في [سياسة البوابة في مقال فحص الحاويات](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/#gate-policy)، والتقرير الكامل بكل المستويات يحفظ في `reports/trivy.json` دون أن يوقف شيئاً.
- **ال Digest يأتي من ال Registry:** بعد الدفع نقرأ `RepoDigests`، أي الهاش الذي أعطاه ال Registry، ومن هذه اللحظة لا يستخدم الخط الوسم أبداً، وتمرر المهمة الهاش إلى المهام التالية عبر `outputs`.
- **المفتاح من متغير البيئة:** الصيغة `env://COSIGN_KEY` تجعل Cosign يقرأ المفتاح من المتغير دون أن نكتبه في ملف على القرص.
- **لا توجد ذاكرة مؤقتة مشتركة Cache بين المهام:** Trivy ينزل قاعدة الثغرات في كل تشغيل، وهذا يكلف ثواني، لكن Volume مشتركاً لقاعدة الثغرات على Runner يشغل أيضاً كود طلبات الدمج يعني أن مهمة خبيثة تستطيع أن تستبدل القاعدة بنسخة تخفي الثغرات.

### تشغيل ناجح: ما الذي خرج من مهمة البناء؟

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

```text
packages in the SBOM: 45
Report Summary
│ image.tar (debian 13.7)                        │   debian   │        0        │
│ app/deps/blinker-1.9.0.dist-info/METADATA      │ python-pkg │        0        │
│ app/deps/flask-3.1.3.dist-info/METADATA        │ python-pkg │        0        │
│ app/deps/werkzeug-3.1.9.dist-info/METADATA     │ python-pkg │        0        │
pushed lab-pipeline-gitea:3000/acme/shop@sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83
Pushing signature to: lab-pipeline-gitea:3000/acme/shop
Using payload from: reports/sbom.cdx.json
Signing artifact...
Using payload from: reports/provenance.json
Signing artifact...
Artifact build-reports has been successfully uploaded! Final size is 310936 bytes. Artifact ID is 6
```

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

- قائمة SBOM فيها 45 حزمة: 38 حزمة Debian و7 مكتبات Python، ومعها أكثر من ألفي سطر من نوع `file` لأن Syft يسجل الملفات أيضاً، فإذا رفعتها إلى Dependency-Track كما في [مقال SCA](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/#lab-dependency-track) فسوف يعيد تحليلها كل يوم دون أن تعيد البناء.
- جدول البوابة صفر في كل سطر، ولكن التقرير الكامل في الملف المرفق فيه 159 نتيجة في حزم Debian، منها 30 من مستوى HIGH، وكلها بحالة `affected` أو `will_not_fix` أو `fix_deferred`، أي لا إصلاح لأي منها اليوم، وهذا ما ناقشناه بالتفصيل في [حالة الثغرة في مقال فحص الحاويات](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/#statuses).
- ثلاثة توقيعات على ال Digest نفسه: التوقيع، وشهادة SBOM، وشهادة البناء، وكلها محفوظة في ال Registry بجانب ال Image.

### لماذا زوج مفاتيح، وماذا تحتوي شهادة البناء؟

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

![ثلاثة أعمدة: مهمة البناء في acme/shop تقرأ المفتاح الخاص وكلمة مروره من أسرار Gitea وتنفذ ثلاث خطوات مرقمة cosign sign ثم attest بنوع cyclonedx ثم attest بنوع slsaprovenance1 دون سجل Rekor وبال Digest دائماً، ثم ال Registry في Gitea وفيه ال Image بال Digest وبجانبها التوقيع وشهادتا SBOM وSLSA، ثم قبل كل نشر المفتاح العام وحده مع cosign verify وverify-attestation والتحقق أن المستودع acme/shop والفرع main ثم سحب ال Digest وتشغيله، وفي الأسفل مسار أحمر لمن يملك صلاحية الرفع فقط ويرفع Image أخرى دون توقيع أو بمفتاح آخر فيتوقف النشر بالخطأ no signatures found](https://arabroot.io/content/images/2026/10/secure-pipeline-03-sign-verify.webp)

المفتاح الخاص لا يغادر خط البناء، والمفتاح العام وحده يكفي لرفض أي Image لم يوقعها الخط

التوقيع بلا مفاتيح Keyless يحتاج أن تثبت المهمة هويتها بتوكن OIDC، وهذا غير متاح في Gitea Actions كما رأينا، ويحتاج أيضاً الوصول إلى Fulcio وسجل Rekor العام على الإنترنت. لذلك نستخدم زوج مفاتيح ولدناه مرة واحدة بالأمر `cosign generate-key-pair`، ووضعنا المفتاح الخاص وكلمة مروره في أسرار `acme/shop`، والمفتاح العام في الملف `.cosign/cosign.pub` في المستودع نفسه، ونسخة منه في `trust/cosign.pub` في مستودع النشر.

ولاحظ أن الخيار `--tlog-upload=false` الذي استخدمناه في [تجربة سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lab-sign) لم يعد موجوداً في Cosign 3.1.3، والطريقة الحالية أن تعطيه ملف إعداد توقيع Signing Config لا يذكر أي خدمة، وهذا الملف `ci/signing-config.json`:

```json
{
  "mediaType": "application/vnd.dev.sigstore.signingconfig.v0.2+json",
  "caUrls": [],
  "oidcUrls": [],
  "rekorTlogUrls": [],
  "tsaUrls": [],
  "rekorTlogConfig": { "selector": "ANY" },
  "tsaConfig": { "selector": "ANY" }
}
```

أما شهادة البناء فهي ملف JSON بصيغة [SLSA Provenance v1](https://slsa.dev/spec/v1.2/provenance?ref=arabroot.io) يكتبه السكربت `ci/provenance.sh` من متغيرات المهمة، وهذه هي الشهادة التي خرجت من التشغيل الناجح الأول:

```json
{
  "buildDefinition": {
    "buildType": "https://example.com/buildtypes/gitea-actions-docker-build@v1",
    "externalParameters": {
      "repository": "http://lab-pipeline-gitea:3000/acme/shop",
      "ref": "refs/heads/main",
      "workflow": ".gitea/workflows/release.yml"
    },
    "resolvedDependencies": [
      { "uri": "git+http://lab-pipeline-gitea:3000/acme/shop@refs/heads/main",
        "digest": { "gitCommit": "76c55056ccd476680521e613e3fee8d9483c44f9" } }
    ]
  },
  "runDetails": {
    "builder": { "id": "http://lab-pipeline-gitea:3000/acme/shop/actions/runners/runner-01" },
    "metadata": {
      "invocationId": "http://lab-pipeline-gitea:3000/acme/shop/actions/runs/6",
      "startedOn": "2026-10-07T14:52:51Z",
      "finishedOn": "2026-10-07T14:55:49Z"
    },
    "byproducts": [ { "name": "image",
      "digest": { "sha256": "a36d1b14190db4a22e573e2e258861a640f599e52aae56504c8ce481d876216e" } } ]
  }
}
```

وهل هذا يجعل الخط في مستوى SLSA Build L2؟ لا، والتفصيل فيما يلي: تقول [صفحة مستويات البناء](https://slsa.dev/spec/v1.2/build-track-basics?ref=arabroot.io) إن L2 يحتاج أن تولد منصة البناء الشهادة وتوقعها، بينما الشهادة هنا يكتبها سكربت داخل المهمة نفسها ويوقعها مفتاح تستطيع المهمة أن تقرأه، فالمهمة التي تريد أن تكذب تستطيع أن تكتب ما تشاء. والأقرب أن تقول إن الخط في L1 مع توقيع: الشهادة موجودة ومفيدة لكشف الأخطاء ولمعرفة أي Commit بني منه كل Digest، وتمنع من يملك صلاحية الرفع إلى ال Registry وحدها من تمرير Image لم يبنها الخط، ولكنها لا تحمي من شخص يستطيع أن يقرأ أسرار المستودع، وقد رأيت أن كل من يدفع إليه يستطيع ذلك.

## المرحلة الخامسة: staging والفحص أثناء التشغيل

مهمة staging تشغل السكربت `ci/deploy.sh`، وهو نفسه الذي يستخدمه النشر إلى الإنتاج، ويرفض أي مرجع ليس Digest، ثم يتحقق من التوقيع، ثم يتصل بسيرفر Docker عبر TLS ويشغل ال Container بنظام ملفات للقراءة فقط ودون أي Capabilities، كما شرحنا في [مقال فحص الحاويات](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/):

```bash
#!/bin/sh
# Verifies the signature of an image digest, then runs it on a remote Docker host over TLS.
# usage: deploy.sh <image@sha256:...> <public key file>
# env:   TARGET_DOCKER_HOST, TLS_CA, TLS_CERT, TLS_KEY, PULL_USER, PULL_TOKEN, APP_SECRET_KEY, APP_ADMIN_API_TOKEN, APP_ADMIN_PASSWORD
set -eu
REF="$1"; PUB="$2"
COSIGN=ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8

case "$REF" in *@sha256:*) ;; *) echo "refusing to deploy a tag, a digest is required: $REF"; exit 1 ;; esac

echo "== verify signature of $REF"
docker run --rm --network host --volumes-from "$(hostname)" -w "$PWD" "$COSIGN" verify \
  --key "$PUB" --insecure-ignore-tlog=true --allow-http-registry \
  --registry-username "$PULL_USER" --registry-password "$PULL_TOKEN" "$REF" > verify.json
echo "signature OK"

mkdir -p "$HOME/.remote-docker" && cd "$HOME/.remote-docker"
trap 'rm -f "$HOME/.remote-docker/key.pem"' EXIT
printf '%s\n' "$TLS_CA" > ca.pem; printf '%s\n' "$TLS_CERT" > cert.pem; printf '%s\n' "$TLS_KEY" > key.pem
export DOCKER_HOST="$TARGET_DOCKER_HOST" DOCKER_TLS_VERIFY=1 DOCKER_CERT_PATH="$PWD"

echo "== deploy to $DOCKER_HOST"
echo "$PULL_TOKEN" | docker login "${REF%%/*}" -u "$PULL_USER" --password-stdin > /dev/null
docker pull -q "$REF"
docker rm -f shop > /dev/null 2>&1 || true
docker run -d --name shop --restart unless-stopped -p 8080:8000 \
  --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
  -e SECRET_KEY="$APP_SECRET_KEY" -e ADMIN_API_TOKEN="$APP_ADMIN_API_TOKEN" -e ADMIN_PASSWORD="$APP_ADMIN_PASSWORD" \
  -e APP_VERSION="${REF##*@}" "$REF"
docker logout "${REF%%/*}" > /dev/null
docker ps --filter name=^shop$ --format 'running: {{.Image}} {{.Status}}'
```

في السكربت أعلاه لاحظ التالي:

- المتغير `DOCKER_HOST` يضبط بعد التحقق وليس في إعداد المهمة، وقد تعلمنا هذا من تشغيل فاشل: عندما كان في `env` المهمة ذهب أمر Cosign نفسه إلى سيرفر staging بدلاً من Docker الخاص بال Runner، فرفضه السيرفر بالخطأ `Client sent an HTTP request to an HTTPS server`.
- المفتاح الخاص بشهادة العميل يحذف عند خروج السكربت بأي طريقة بفضل `trap`، وفي نسخة سابقة حذفناه قبل آخر أمر `docker ps`، ففشل الأمر بالخطأ `tls: certificate required` رغم أن النشر نفسه نجح.
- السحب بحساب `deploy-bot` الذي يملك قراءة ال Packages فقط، وليس بحساب البوت الذي يرفع.

والمخرج في مهمة staging كان كما يلي:

```text
== verify signature of lab-pipeline-gitea:3000/acme/shop@sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83
signature OK
== deploy to tcp://lab-pipeline-staging:2376
running: lab-pipeline-gitea:3000/acme/shop Up Less than a second
```

ثم عملت مهمة DAST بفحص ZAP الأساسي Baseline وملف القواعد الذي شرحناه في [مقال DAST](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/#lab-gate)، والقواعد التي تحمل FAIL هي ترويسات الأمان وخصائص ملفات تعريف الارتباط، والمخرج كان كما يلي:

```text
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
WARN-NEW: Absence of Anti-CSRF Tokens [10202] x 2
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 4	WARN-INPROG: 0	INFO: 0	IGNORE: 1	PASS: 62
```

لا يوجد FAIL، فنجحت المهمة، والتحذيرات الأربعة ظهرت في السجل دون أن توقف شيئاً، وهي بالضبط ما يجب أن يراجعه أحد من الفريق: الترويسة `Server` تكشف Werkzeug وإصدار Python لأن التطبيق يعمل بسيرفر Flask للتطوير، ونموذج تسجيل الدخول بلا توكن CSRF. وهذه قرارات تتخذ في الكود، وتنقل إلى FAIL في `rules.tsv` بعد إصلاحها حتى لا تعود.

## المرحلة السادسة: ترقية ال Digest نفسه إلى الإنتاج

النشر على staging آلي بعد كل دمج، أما الإنتاج فيقرره شخص من فريق الإصدار، والقرار هنا ليس «انشر آخر إصدار» وإنما «انشر هذا الهاش بالتحديد»، أي الهاش الذي مر بالبوابات ونشر على staging ونجح فيه فحص ZAP. وقد شرحنا فكرة الترقية بين البيئات في [تجربة البيئات وGitOps](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/#lab-promote)، والجديد هنا هو التحقق قبلها. والصورة التالية تبين ما حدث في التجربة:

![شريط أسود فيه الهاش sha256:354885ff وتحته خمس مراحل مرقمة: البناء مرة واحدة من commit d31caa8 في release run 20 مع توقيع وشهادتين، ثم ال Registry حيث الوسم مجرد اسم وال Digest هو المحتوى، ثم staging مع signature OK وZAP FAIL-NEW 0 بشكل آلي على main، ثم الترقية بيد فريق الإصدار عبر workflow_dispatch بإدخال الهاش وشهادة البناء تذكر d31caa8، ثم الإنتاج بالهاش نفسه دون إعادة بناء على runner-deploy فقط، وفي الأسفل مساران مرفوضان: Image بالوسم hotfix رفعت بتوكن رفع مسرب وهاشها 43331a9c بلا توقيع فرفضت بالخطأ no signatures found وبقي الإنتاج على الهاش القديم، وإدخال وسم بدلاً من الهاش رفض رغم أن التوقيع والشهادة صحيحان](https://arabroot.io/content/images/2026/10/secure-pipeline-04-promotion.webp)

ما يصل إلى الإنتاج هو الهاش الذي فحصه الخط، وأي هاش آخر يتوقف عند التحقق

وملف الترقية في مستودع `acme/deploy`، ويعمل على ال Runner الخاص بهذا المستودع، وهو كما يلي:

```yaml
# Promotes one digest that already passed staging to production. Only the release team can run it,
# and only this repository holds the production credentials. Its jobs run on a runner that serves this repository only.
name: promote
on:
  workflow_dispatch:
    inputs:
      ref:
        description: "Image digest from the release run, e.g. lab-pipeline-gitea:3000/acme/shop@sha256:..."
        required: true

permissions:
  contents: read

env:
  COSIGN: ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8

jobs:
  production:
    runs-on: deploy
    steps:
      - uses: builtin:checkout
      - name: Verify the signature, then where and how the digest was built (SLSA provenance)
        env:
          REF: ${{ inputs.ref }}
          PULL_TOKEN: ${{ secrets.PROD_PULL_TOKEN }}
        run: |
          C="docker run --rm --network host --volumes-from $(hostname) -w $PWD $COSIGN"
          AUTH="--allow-http-registry --registry-username deploy-bot --registry-password $PULL_TOKEN"
          $C verify --key trust/cosign.pub --insecure-ignore-tlog=true $AUTH "$REF" > sig.json
          echo "signature OK"
          $C verify-attestation --key trust/cosign.pub --type slsaprovenance1 \
            --insecure-ignore-tlog=true $AUTH "$REF" > att.json
          apk add --no-cache -q jq
          jq -r '.payload' att.json | base64 -d | jq -e '
            .predicate.buildDefinition.externalParameters.repository == "http://lab-pipeline-gitea:3000/acme/shop" and
            .predicate.buildDefinition.externalParameters.ref == "refs/heads/main"' \
            && jq -r '.payload' att.json | base64 -d | jq -r '"built from commit " + .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit + " in run " + .predicate.runDetails.metadata.invocationId'
      - name: Deploy the same digest to production (deploy.sh verifies it again right before the pull)
        env:
          TARGET_DOCKER_HOST: tcp://lab-pipeline-prod:2376
          TLS_CA: ${{ secrets.PROD_TLS_CA }}
          TLS_CERT: ${{ secrets.PROD_TLS_CERT }}
          TLS_KEY: ${{ secrets.PROD_TLS_KEY }}
          PULL_USER: deploy-bot
          PULL_TOKEN: ${{ secrets.PROD_PULL_TOKEN }}
          APP_SECRET_KEY: ${{ secrets.PROD_SECRET_KEY }}
          APP_ADMIN_API_TOKEN: ${{ secrets.PROD_ADMIN_API_TOKEN }}
          APP_ADMIN_PASSWORD: ${{ secrets.PROD_ADMIN_PASSWORD }}
        run: sh ci/deploy.sh "${{ inputs.ref }}" trust/cosign.pub
```

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

- المفتاح العام `trust/cosign.pub` في مستودع النشر وليس في مستودع التطبيق، فطلب دمج في `acme/shop` يستبدل المفتاح العام بمفتاح آخر لا يؤثر على الإنتاج.
- التحقق من الشهادة لا يكتفي بأنها موقعة، وإنما يقرأ محتواها: المستودع `acme/shop` والفرع `refs/heads/main`، فال Image التي بنيت من فرع لم يدمج ووقعت بالمفتاح نفسه ترفض هنا.
- تشغيل ال workflow يحتاج صلاحية كتابة على Actions في هذا المستودع، ولا يملكها إلا فريق الإصدار، وقد رأيت أن المطور لا يرى المستودع أصلاً.

### الترقية الناجحة

شغل مدير الإصدار ال workflow عبر ال API بالهاش الذي خرج من تشغيل البناء، وهو ما يقابل زر Run workflow في الواجهة:

```bash
curl -s -H "Authorization: token $RELMGR_TOKEN" -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:19910/api/v1/repos/acme/deploy/actions/workflows/promote.yml/dispatches \
  -d '{"ref":"main","inputs":{"ref":"lab-pipeline-gitea:3000/acme/shop@sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83"}}'
```

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

```text
signature OK
Verification for lab-pipeline-gitea:3000/acme/shop@sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The signatures were verified against the specified public key
true
built from commit d31caa801fad262c051650adb913094169698d7b in run http://lab-pipeline-gitea:3000/acme/shop/actions/runs/20
== verify signature of lab-pipeline-gitea:3000/acme/shop@sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83
signature OK
== deploy to tcp://lab-pipeline-prod:2376
running: lab-pipeline-gitea:3000/acme/shop Up 1 second
```

ثم سألنا التطبيق في الإنتاج عن إصداره، وهو يعرض الهاش الذي مرره السكربت في المتغير `APP_VERSION`:

```bash
curl -s http://127.0.0.1:19912/api/health
```

```text
{"status":"ok","version":"sha256:354885ff10dfd5af179239a098824a0616c8f1fa294ab2c89c32dae7dd0a6e83"}
```

ولاحظ في المخرج أن السطر `Existence of the claims in the transparency log was verified offline` يظهر مع أننا لم نستخدم سجل Rekor أصلاً، فالخيار `--insecure-ignore-tlog=true` يتجاوز هذا التحقق ويطبع قبله تحذيراً صريحاً، فلا تقرأ السطر على أنه تحقق حقيقي من سجل شفافية.

وقد فشلت أول محاولة ترقية قبل هذه بالخطأ `UNAUTHORIZED` من ال Registry، والسبب أننا حفظنا توكن السحب في السر من ملف ينتهي بسطر جديد، فوصل التوكن إلى Cosign وفي آخره حرف السطر الجديد، بينما نجح في الخطوة الأولى لأن المتغير هناك بلا علامات تنصيص فحذف الشل السطر الجديد. لذلك عندما تضع سراً عبر ال API تأكد أن القيمة بلا مسافة أو سطر في آخرها.

### Image لم يوقعها الخط: الرفض

لنفرض الآن أن توكن الرفع الخاص بالبوت تسرب، وقد رأيت أن هذا ممكن لكل من يدفع إلى المستودع، فبنى المهاجم Image من ال Image الحقيقية وأضاف إليها ملفاً، ورفعها بالوسم `hotfix`:

```text
hotfix: digest: sha256:43331a9c73d7c16dec4f351a24b2baadb67b6fec0c6d56a853ca6120f144798f size: 857
```

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

```text
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Error: no signatures found
error during command execution: no signatures found
##[error]Process completed with exit code 10.
```

توقفت المهمة عند الخطوة الأولى، ولم تتصل بسيرفر الإنتاج أصلاً، وبقي التطبيق في الإنتاج على الهاش نفسه `sha256:354885ff…` كما أكد الأمر `curl` بعدها. وهذا هو المعنى العملي للتوقيع: صلاحية الرفع إلى ال Registry وحدها لم تعد كافية للوصول إلى الإنتاج.

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

```text
signature OK
true
built from commit d31caa801fad262c051650adb913094169698d7b in run http://lab-pipeline-gitea:3000/acme/shop/actions/runs/20
refusing to deploy a tag, a digest is required: lab-pipeline-gitea:3000/acme/shop:d31caa801fad262c051650adb913094169698d7b
##[error]Process completed with exit code 1.
```

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

## المرحلة السابعة: بعد النشر

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

- **إعادة الفحص الليلية:** مهمة مجدولة `schedule` تفحص ال Digests المنشورة فعلاً، أو ترفع قائمة SBOM إلى Dependency-Track ليعيد تحليلها بنفسه كما في [مقال SCA](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/#lab-dependency-track)، وإعادة البناء الأسبوعية كما في [مقال فحص الحاويات](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/#rebuild-cadence). والنتيجة الجديدة تذكرة لها موعد إصلاح SLA، وليست توقفاً للإنتاج.
- **إعدادات التشغيل:** السكربت يشغل ال Container بنظام ملفات للقراءة فقط و`--cap-drop ALL` و`no-new-privileges`، وال Image نفسها بمستخدم عادي، فما يستطيعه المهاجم بعد ثغرة في الكود محدود.
- **السجلات والتنبيهات:** سجلات التطبيق وسجل Gitea للتشغيلات وسجل ال Registry، فتعرف من رفع ماذا ومتى، وقد شرحنا ما يسجل في [مقال السجلات](https://arabroot.io/articles/%D8%A7%D9%84%D8%B3%D8%AC%D9%84%D8%A7%D8%AA-logging-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D9%88%D9%85%D8%AF%D9%8A%D8%B1%D9%8A-%D8%A7%D9%84%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D8%A7%D8%AA/).
- **تدوير الأسرار:** مفتاح التوقيع وتوكنات البوتات وشهادات العملاء لها تاريخ تدوير ثابت، وتدور فوراً عند أي شك، وتدوير مفتاح التوقيع يعني إضافة المفتاح العام الجديد إلى `trust/` قبل أن يبدأ الخط بالتوقيع به.
- **الحماية أمام التطبيق:** الفحوص لا تغني عن الطبقة التي شرحناها في [حماية تطبيقات الويب على مستوى HTTP](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%B9%D9%84%D9%89-%D9%85%D8%B3%D8%AA%D9%88%D9%89-http/).

## سياسة البوابات Gate Policy: ما الذي يوقف، وما الذي ينبه

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

![جدول من أحد عشر صفاً بأيقونة لكل فحص: الأسرار بأداة gitleaks في pre-commit وطلب الدمج توقف أي نتيجة في ال Commits الجديدة واستثناؤها gitleaksignore بلا تاريخ، وSemgrep في طلب الدمج يوقف النتائج الجديدة فقط بالخيار baseline-commit، وOSV-Scanner يوقف أي ثغرة معروفة في ملف القفل واستثناؤه ignoreUntil، وtrivy config يوقف HIGH وCRITICAL، والمراجعة والاختبار توقف دون موافقة واحدة ومالكي الكود، وTrivy على ال Image يوقف HIGH وCRITICAL التي لها إصلاح واستثناؤه expired_at، والتقرير الكامل للتنبيه فقط، والتوقيع يوقف كل نشر دون توقيع أو بمفتاح آخر ولا استثناء له، وشهادة البناء توقف الترقية إذا لم تبن من main في acme/shop، وZAP يوقف القواعد المعلمة FAIL، وإعادة الفحص الليلية تفتح تذكرة بموعد إصلاح](https://arabroot.io/content/images/2026/10/secure-pipeline-05-gate-policy.webp)

البوابة توقف ما يستطيع المطور أن يصلحه الآن، وكل استثناء ملف في المستودع

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

- كل ما يوقف الخط له إجراء واضح يستطيعه المطور اليوم: يحذف السر، أو يرفع إصدار مكتبة، أو يعيد البناء، أو يصلح ترويسة. وما لا يملك المطور له إجراء، مثل ثغرة في Debian لم يصدر لها إصلاح، يذهب إلى التقرير والتذكرة وليس إلى البوابة.
- التوقيع وشهادة البناء لا استثناء لهما أبداً، فالاستثناء هنا يعني أن الإنتاج يقبل ما لم يبنه الخط، وهذا يلغي الخط كله.
- الأرقام التي تحدد الحد ليست ثابتة، وقد شرحنا في [المقال الرئيسي](https://arabroot.io/articles/devsecops-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-sast-%D9%88-dast-%D9%88-sca/#gates) كيف تنقل القواعد من التنبيه إلى المنع كلما ثبتت دقتها.

## النتائج في مكان واحد مع DefectDojo

البوابات توقف ما يجب إصلاحه الآن، ولكن الخط يترك وراءه نتائج لا توقف شيئاً: 159 صفاً في حزم Debian، وتحذيرات ZAP، والسر الذي أوقفه فحص الأسرار ويجب أن نتأكد من تدويره. وهذه تحتاج مكاناً واحداً يتابعها فيه الفريق، وقد وعدنا في [مقال SAST](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/#sarif-display) بأن نجرب DefectDojo لهذا الغرض. و[DefectDojo](https://github.com/DefectDojo/django-DefectDojo?ref=arabroot.io) منصة مفتوحة المصدر بترخيص BSD من ثلاثة بنود كما في ملف LICENSE في مستودعها، تقرأ تقارير مئات الأدوات، وتزيل التكرار بين فحص وآخر، وتتابع حالة كل نتيجة. وشغلنا الإصدار [3.4.0](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.4.0?ref=arabroot.io) بملف Compose الرسمي مع ملف إضافي يربط المنفذ على 127.0.0.1 فقط:

```yaml
# compose.override.yml
services:
  nginx:
    ports: !override
      - "127.0.0.1:19914:8080"
```

```bash
curl -s -o docker-compose.yml \
  https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/3.4.0/docker-compose.yml
mkdir -p docker/extra_settings
printf 'NGINX_VERSION=3.4.0\nDJANGO_VERSION=3.4.0\nDD_SECRET_KEY=%s\nDD_CREDENTIAL_AES_256_KEY=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 16)" > .env
docker compose -p lab-pipeline-dd -f docker-compose.yml -f compose.override.yml up -d --no-build
docker logs lab-pipeline-dd-initializer-1 2>&1 | grep 'Admin password'
```

ولاحظ أن ملف Compose الرسمي يضع قيماً افتراضية لمفتاح Django ومفتاح التشفير إذا لم تحددها، لذلك ولدناها في ملف `.env`، ولا تشغله دون ذلك. ثم أخذنا توكن ال API ورفعنا ثلاثة تقارير حقيقية من التجربة: تقرير Trivy الكامل من مرفقات تشغيل البناء، وتقرير Gitleaks لفرع طلب الدمج الثاني، وتقرير ZAP على staging بصيغة XML، كل منها بطلب واحد إلى [واجهة الاستيراد](https://docs.defectdojo.com/en/connecting%5Fyour%5Ftools/import%5Fscan%5Ffiles/api%5Fpipeline%5Fmodelling/?ref=arabroot.io)، والخيار `auto_create_context` ينشئ المنتج والمرحلة Engagement إذا لم يكونا موجودين:

```bash
curl -s -H "Authorization: Token $DD_TOKEN" -X POST http://127.0.0.1:19914/api/v2/import-scan/ \
  -F "scan_type=Trivy Scan" -F "file=@trivy.json" \
  -F product_type_name=Lab -F product_name=acme/shop -F engagement_name=main \
  -F auto_create_context=true -F active=true -F verified=false -F minimum_severity=Info
```

والجدول التالي يجمع ما رجع من ال API للتقارير الثلاثة:

| التقرير                         | نوع الفحص في DefectDojo | النتائج | التوزيع حسب الخطورة                |
| ------------------------------- | ----------------------- | ------- | ---------------------------------- |
| trivy.json من تشغيل البناء      | Trivy Scan              | 159     | 30 High و74 Medium و53 Low و2 Info |
| gitleaks.json لفرع الطلب الثاني | Gitleaks Scan           | 1       | 1 High                             |
| zap.xml على staging             | ZAP Scan                | 6       | 1 Medium و3 Low و2 Info            |

ثم أعدنا رفع تقرير Trivy نفسه إلى الاختبار نفسه عبر `reimport-scan`، كما يحدث كل ليلة، فرجع العدد قبل وبعد 159 دون أي نتيجة جديدة، وهذه أهم ميزة في المنصة: الفحص الليلي لا يضاعف القائمة، وإنما يضيف الجديد ويغلق ما اختفى. ولاحظ أن تقرير ZAP هنا بلا ملف القواعد، فظهر فيه التنبيه 90004 الذي جعلناه IGNORE في الخط، والطريقة الصحيحة أن تعلمه في DefectDojo مقبولاً بالسبب نفسه حتى لا يتكرر النقاش.

وكيف تصل التقارير إلى DefectDojo من الخط؟ أضف خطوة في نهاية كل مهمة بالأمر نفسه، وتوكن ال API في الأسرار لحساب لا يملك إلا صلاحية الاستيراد إلى هذا المنتج. والمهمة التي تفشل بسبب البوابة ترفع تقريرها أيضاً بالشرط `if: always()`، لأن هذا بالضبط ما يحتاج الفريق أن يراه.

## كم يستغرق الخط كاملاً؟

الأرقام التالية من سجلات Gitea للتشغيلات في هذه التجربة، على جهاز واحد يعمل عليه Docker Desktop مع مشاريع أخرى، وRunner بسعة مهمتين، فخذها للمقارنة بين المراحل وليس كأرقام لسيرفرك:

| المرحلة   | المهمة أو الخطوة                      | تشغيل البناء 20                                                                                                                                                                     | تشغيل البناء 33   |
| --------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
| طلب الدمج | المهام الخمس من الدفع إلى آخر نتيجة   | 3 دقائق للطلب الأول، ودقيقتان و8 ثوان للطلب الثالث (test بين 32 ثانية ودقيقة و47 ثانية، وsecrets بين 23 و30 ثانية، وsast بين 39 و53 ثانية، وsca 17 ثانية، وconfig بين 42 و44 ثانية) |                   |
| البناء    | docker build وdocker save             | 39 ثانية                                                                                                                                                                            | 15 ثانية          |
| البناء    | SBOM بأداة Syft                       | 34 ثانية                                                                                                                                                                            | 20 ثانية          |
| البناء    | Trivy مرتين مع تنزيل قاعدة الثغرات    | دقيقة و56 ثانية                                                                                                                                                                     | 3 دقائق و35 ثانية |
| البناء    | الدفع والتوقيع والشهادتان             | 16 ثانية                                                                                                                                                                            | 26 ثانية          |
| البناء    | رفع التقارير                          | 25 ثانية                                                                                                                                                                            | 22 ثانية          |
| staging   | التحقق والنشر                         | 13 ثانية                                                                                                                                                                            | 18 ثانية          |
| DAST      | ZAP baseline                          | دقيقتان و5 ثوان                                                                                                                                                                     | دقيقتان و33 ثانية |
| المجموع   | من الدمج إلى نهاية DAST               | 6 دقائق و34 ثانية                                                                                                                                                                   | 8 دقائق و42 ثانية |
| الإنتاج   | الترقية مع التحقق من التوقيع والشهادة | دقيقة و29 ثانية                                                                                                                                                                     |                   |

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

- أبطأ خطوة في البناء هي Trivy، وأغلب وقتها تنزيل قاعدة الثغرات في كل تشغيل، وهو الثمن الذي قبلناه بدلاً من ذاكرة مؤقتة مشتركة يستطيع طلب دمج أن يعبث بها، والحل الأفضل لفريق كبير Mirror داخلي لقاعدة الثغرات يقرأ منه الخط ولا يكتب فيه.
- أول تشغيل على Runner جديد أبطأ بكثير، فقد استغرق سحب Image أداة ZAP وحدها، وحجمها 3.7 جيجابايت، خمس عشرة دقيقة في التشغيل الأول، وبعدها أصبحت محفوظة في Docker الخاص بال Runner.
- طلب الدمج ينتهي في دقيقتين إلى ثلاث، وهذا هو الرقم الذي يشعر به المطور، أما البناء وDAST فيعملان بعد الدمج ولا ينتظرهما أحد.

## الملفات نفسها على GitHub Actions

إذا كان مستودعك على GitHub فالأفكار نفسها، ولكن ثلاثة أجزاء تصبح أسهل: البيئات Environments تحمي الأسرار وتطلب موافقة شخص قبل النشر كما في [توثيق البيئات](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments?ref=arabroot.io)، والصلاحية `id-token: write` تسمح بالتوقيع بلا مفاتيح عبر [OIDC](https://docs.github.com/en/actions/concepts/security/openid-connect?ref=arabroot.io)، وشهادة البناء تصدرها المنصة نفسها بال Action الرسمية [attest-build-provenance](https://github.com/actions/attest-build-provenance?ref=arabroot.io) كما في [توثيق Artifact Attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations?ref=arabroot.io). والمقتطف التالي مكتوب للتوضيح ولم نشغله في هذه التجربة، والهاشات فيه من مستودعات ال Actions وقت كتابة المقال:

```yaml
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write        # keyless signing and attestations
      attestations: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      # build, scan and push as in release.yml, then:
      - run: cosign sign --yes "ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}"
      - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-name: ghcr.io/${{ github.repository }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

  production:
    needs: build
    runs-on: ubuntu-latest
    environment: production  # required reviewers and production-only secrets
    steps:
      - run: |
          cosign verify "ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}" \
            --certificate-identity-regexp "^https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main$" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com
```

ولاحظ أن التحقق في التوقيع بلا مفاتيح لا يسأل «أي مفتاح وقع؟» وإنما «أي workflow في أي مستودع وعلى أي فرع وقع؟»، وهذا يحل المشكلة التي رأيناها في Gitea، فالفرع الذي لم يدمج لا يستطيع أن ينتج توقيعاً يقبله الإنتاج، لأن هويته في الشهادة تذكر اسم الفرع.

## مسار النضج Maturity Path: لا تبدأ بكل شيء

الخط في هذا المقال فيه أحد عشر فحصاً وثلاثة مستودعات وRunner لكل مستوى ثقة، ولا ينصح ببنائه كله في أسبوع، والسبب أن الفريق سوف يرى عشرات الإخفاقات في يوم واحد فيحكم على الفكرة كلها بالفشل. وقد وضعنا خطة عامة في [المقال الرئيسي](https://arabroot.io/articles/devsecops-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-sast-%D9%88-dast-%D9%88-sca/#adoption)، وهذه هي الخطة نفسها مطبقة على هذا الخط:

![درج من أربع درجات: الأولى في الأسبوع الأول ثلاث بوابات مع حماية الفرع والفحوص المطلوبة وهي الأسرار في ال Commits الجديدة والثغرات المعروفة في ملف القفل وثغرات CRITICAL القابلة للإصلاح في ال Image، والثانية من الأسبوع الثاني إلى السادس الكود والإعداد أي SAST على النتائج الجديدة وقواعد Dockerfile وIaC وتوكنات المهام المقيدة والتثبيت بالهاش والاستثناءات بتاريخ وصاحب، والثالثة في الشهر الثاني التوقيع والترقية مع SBOM وشهادة البناء والتحقق قبل كل نشر والهاش نفسه إلى الإنتاج وDAST على staging، والرابعة من الشهر الثالث على مستوى المنظمة بسير عمل مفروض وإعادة فحص ليلية بموعد إصلاح والنتائج في مكان واحد والتوقيع بلا مفاتيح عندما يتوفر OIDC](https://arabroot.io/content/images/2026/10/secure-pipeline-06-maturity.webp)

البوابة التي لا يفرضها الفرع المحمي تنبيه فقط، لذلك تأتي حماية الفرع مع البوابات الثلاث

- **الأسبوع الأول، ثلاث بوابات:** الأسرار، والثغرات المعروفة في ملف القفل، والثغرات من مستوى CRITICAL التي لها إصلاح في ال Image، ومعها حماية الفرع والفحوص المطلوبة، لأن البوابة التي لا يفرضها الفرع تنبيه فقط كما رأينا في تجربة Renovate. والثلاث لا خلاف عليها تقريباً، وكل منها له إجراء واضح.
- **من الأسبوع الثاني إلى السادس:** SAST على النتائج الجديدة فقط، وفحص ال Dockerfile وملفات البنية، وتقييد توكن المهمة، وتثبيت ال Actions وال Images بالهاش، وملف CODEOWNERS لملفات الاستثناء.
- **الشهر الثاني:** التوقيع وشهادة البناء، والنشر بال Digest، ونقل أسرار الإنتاج إلى مستودع وRunner مستقلين، وDAST على staging.
- **الشهر الثالث وما بعده:** نقل الفحوص إلى سير عمل مفروض على المنظمة كلها، وإعادة الفحص الليلية بمواعيد إصلاح، وجمع النتائج في DefectDojo، ورفع البوابة في Trivy من CRITICAL إلى HIGH بعد أن يصبح الفريق معتاداً عليها.

## مشكلات واجهتنا وحلولها

- **`Detected Docker environment without a code volume` من Semgrep:** ال Image الخاصة به تتوقع الكود في `/src`، فانسخ المجلد إليها داخل ال Container كما في ملف `security.yml`.
- **`ENOTFOUND` من `upload-artifact` أو `bad address` من `wget`:** مهام الخط في شبكة داخلية يصنعها Docker الخاص بال Runner لكل مهمة، وهذه الشبكة لم تستطع في تجربتنا أن تحل أسماء Containers الشبكة الخارجية مثل `lab-pipeline-gitea`. وعلى سيرفر حقيقي باسم نطاق في DNS لا تظهر المشكلة، أما في التجربة فأضفنا الأسماء بالخيار `container.options` في إعداد ال Runner بالصيغة `--add-host lab-pipeline-gitea:192.168.48.2`، والأدوات التي تعمل بجانب المهمة شغلناها بالخيار `--network host`، أي في شبكة Docker الخاص بال Runner.
- **`context deadline exceeded` من Trivy:** عندما يسأل Docker عن ال Image وال Runner مشغول، والحل أن تحفظ ال Image في ملف وتفحص الملف بالخيار `--input`.
- **`Not all required status checks successful` وكل الفحوص ناجحة:** تأكد أن الاسم في الفرع المحمي يطابق ما يرسله Gitea حرفاً بحرف، أي `اسم ال workflow / اسم المهمة (الحدث)`، وصفحة Scoped Workflows تعرض الأسماء المتوقعة وتعلم ما يطابق منها.
- **`UNAUTHORIZED` من ال Registry مع توكن صحيح:** سطر جديد في آخر قيمة السر.

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

- **فحوص تعمل ولا يطلبها الفرع:** تفشل وتبقى حمراء ويدمج الطلب، أو يدمجه البوت تلقائياً كما في تجربة Renovate.
- **الفحوص الأمنية في ملف يعدله طلب الدمج:** الطلب الذي يحذف الخطوة ينجح باسم الفحص نفسه، فضعها في سير عمل مفروض أو على الأقل تحت CODEOWNERS.
- **أسرار الإنتاج في مستودع يدفع إليه كل المطورين:** رأيت أن أي فرع يقرأ كل أسرار المستودع في Gitea.
- **Runner واحد لطلبات الدمج والنشر إلى الإنتاج:** ما تتركه مهمة خبيثة في Docker الخاص به تجده مهمة النشر بعدها.
- **التوقيع دون تحقق:** التوقيع وحده لا يمنع شيئاً، والتحقق قبل النشر هو الذي يرفض.
- **النشر بالوسم بعد التحقق بالوسم:** الوسم قد يتغير بين اللحظتين، فاكتب الهاش في كل خطوة بعد الدفع.
- **بوابة على كل ثغرة دون إصلاح:** تتحول إلى ملف استثناءات طويل، فأوقف ما له إصلاح وتابع الباقي في DefectDojo.
- **استثناء بلا تاريخ ولا صاحب:** يبقى في الملف سنوات، فاستخدم `ignoreUntil` و`expired_at`، واجعل الملفات تحت CODEOWNERS.
- **ذاكرة مؤقتة مشتركة على Runner يشغل كود طلبات الدمج:** مهمة خبيثة تستطيع أن تستبدل قاعدة الثغرات أو المكتبات المحفوظة.
- **Actions وImages بالوسم:** الوسم يمكن نقله، والهاش لا يتغير.

## قائمة تحقق Checklist

- الفرع الرئيسي لا يقبل الدفع المباشر، ويطلب موافقة واحدة على الأقل وفحوصاً محددة بالاسم، وقاعدته على المديرين أيضاً.
- الفحوص الأمنية في مستودع يملكه فريق الأمن، مفروضة على المنظمة ومطلوبة، بنمط لكل مهمة.
- ملف CODEOWNERS يغطي ملفات الاستثناء وملفات الخط والمفتاح العام.
- توكن المهمة في الوضع Restricted، وكل ملف workflow يكتب `permissions: contents: read` ويرفع ما يحتاجه فقط.
- كل Action بهاش Commit كامل، وكل Image بال Digest، وRenovate يحدثها بطلب دمج.
- توكنات البوتات بصلاحية واحدة لكل منها: رفع ال Images، أو سحبها.
- أسرار الإنتاج في مستودع لا يصل إليه المطورون، ومهامه على Runner خاص به.
- ال Image تبنى مرة واحدة، وتفحص من ملف، ويرفق بها SBOM وشهادة بناء، وتوقع بال Digest.
- كل نشر يتحقق من التوقيع، والترقية إلى الإنتاج تتحقق أيضاً من المستودع والفرع في شهادة البناء، وترفض أي وسم.
- DAST على staging بملف قواعد فيه FAIL لما أصلحته، والتقارير كلها في مكان واحد.
- إعادة فحص ليلية للهاش المنشور، وإعادة بناء دورية، وتدوير مفتاح التوقيع والتوكنات بموعد ثابت.

## الخلاصة

وصلنا لنهاية السلسلة، وأهم ما في هذا المقال:

- الفحص لا يصبح بوابة حتى يطلبه الفرع المحمي بالاسم، والبوابة لا تصبح موثوقة حتى يخرج ملفها من يد طلب الدمج، وهذا ما فعله سير العمل المفروض Scoped Workflows في Gitea.
- في Gitea كل من يدفع فرعاً إلى المستودع يستطيع أن يقرأ أسراره، فقد قرأ workflow في فرع لم يراجعه أحد السر التجريبي، لذلك وضعنا أسرار الإنتاج في مستودع آخر وعلى Runner آخر.
- الخطاف على جهاز المطور أوقف السر، ثم تجاوزه المطور بخيار واحد، فأوقفه السيرفر برفض الدمج، والمكتبة المصابة أوقفها OSV-Scanner، والاستثناء عليها احتاج موافقة فريق الأمن فانتهى برفع الإصدار.
- ال Image تبنى مرة واحدة وتوقع بال Digest مع SBOM وشهادة بناء، ثم ينتقل الهاش نفسه إلى staging والإنتاج، والإنتاج رفض Image رفعت بتوكن مسرب لأنها بلا توقيع، ورفض وسماً لأن الوسم قد يتغير.
- التوقيع بزوج مفاتيح في Gitea يثبت أن الخط وقع، ولا يثبت أن الكود دمج، وهذا مستوى SLSA L1 مع توقيع، والتوقيع بلا مفاتيح عبر OIDC يحل ذلك حيث يتوفر.
- الخط كاملاً من الدمج إلى نهاية DAST استغرق بين ست وتسع دقائق، وطلب الدمج بين دقيقتين وثلاث، وابدأ بثلاث بوابات وحماية الفرع، ثم أضف الباقي على مراحل.

وبهذا المقال تكتمل سلسلة DevSecOps، وكل مقال منها يبقى المرجع لأداته، وهذه بطاقاتها:

[![](https://arabroot.io/content/images/2026/10/devsecops-cover-cover.webp)دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.](https://arabroot.io/articles/devsecops-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-sast-%D9%88-dast-%D9%88-sca/)[![](https://arabroot.io/content/images/2026/10/secrets-scanning-cover-cover.webp)دليلSecrets Scanning: كيف تكتشف الأسرار المسربة في الكودكيف يخرج المفتاح من جهازك إلى تاريخ Git وخط CI، وكيف تكتشفه قبل غيرك، وماذا تفعل في الساعة الأولى بعد تسربه.](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/)[![](https://arabroot.io/content/images/2026/10/sast-cover-cover.webp)دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.](https://arabroot.io/articles/%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D9%83%D9%88%D8%AF-%D8%A7%D9%84%D8%AB%D8%A7%D8%A8%D8%AA-sast-%D9%85%D8%B9-semgrep-%D9%88-sonarqube/)[![](https://arabroot.io/content/images/2026/10/sca-cover-cover.webp)دليلSCA: كيف تجد الثغرات المعروفة في مكتبات مشروعككيف تجد أداة فحص الاعتماديات SCA المكتبات المصابة في مشروعك، ولماذا تختلف نتائج الأدوات، وكيف تبقي مكتباتك محدثة.](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%AA%D9%85%D8%A7%D8%AF%D9%8A%D8%A7%D8%AA-sca-%D9%85%D8%B9-osv-scanner-%D9%88-dependency-track/)[![](https://arabroot.io/content/images/2026/10/container-scanning-cover-cover.webp)دليلفحص ال Docker Images وملفات البنية ككود IaCماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AD%D8%A7%D9%88%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D9%83%D9%88%D8%AF-%D9%85%D8%B9-trivy/)[![](https://arabroot.io/content/images/2026/10/dast-cover-cover.webp)دليلDAST: فحص تطبيقك من الخارج وهو يعملكيف يفحص ZAP تطبيقك من الخارج وهو يعمل، ومتى يحق لك أن تشغله، وكيف تحول نتائجه إلى بوابة في خط CI.](https://arabroot.io/articles/%D9%81%D8%AD%D8%B5-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D8%AB%D9%86%D8%A7%D8%A1-%D8%A7%D9%84%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-dast-%D9%85%D8%B9-zap/)[![](https://arabroot.io/content/images/2026/10/supply-chain-cover-cover.webp)دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/gitea-actions-cover-cover-2.webp)دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/)[![](https://arabroot.io/content/images/2026/10/environments-gitops-cover-cover.webp)دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/)[![](https://arabroot.io/content/images/2026/10/deployment-history-cover-cover.webp)دليلطرق نشر التطبيقات من FTP إلى GitOps، وأين تقف أنتكيف تطور نشر التطبيقات عبر السنين، وما الذي يتعطل في كل طريقة، وكيف تعرف أين تقف وما خطوتك التالية.](https://arabroot.io/articles/%D8%B7%D8%B1%D9%82-%D9%86%D8%B4%D8%B1-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D9%85%D9%86-ftp-%D8%A7%D9%84%D9%89-gitops/)[![](https://arabroot.io/content/images/2026/10/secure-sdlc-cover-cover.webp)دليلSecure SDLC: الأمن في كل مرحلة من حياة التطبيقماذا تفعل للأمن في كل مرحلة من حياة التطبيق من المتطلبات إلى إيقافه، ومن يملك كل خطوة، وكيف تبدأ في أول تسعين يوماً.](https://arabroot.io/articles/%D8%AF%D9%88%D8%B1%D8%A9-%D8%AD%D9%8A%D8%A7%D8%A9-%D8%AA%D8%B7%D9%88%D9%8A%D8%B1-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A7%D8%AA-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-sdlc/)

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

- أكتوبر 2026: كتابة المقال، وبناء خط كامل وتشغيله على Gitea 28.1.0 وGitea Runner 4.1.0 مع Gitleaks 8.30.1 وSemgrep 1.179.0 وOSV-Scanner 2.6.0 وTrivy 0.75.0 وSyft 1.54.1 وCosign 3.1.3 وZAP 2.17.0، وتشغيل DefectDojo 3.4.0 لجمع النتائج، والتحقق من صلاحيات توكن المهمة وسير العمل المفروض والفروع المحمية وCODEOWNERS من توثيق Gitea.