> ## 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.

# تمارين Kata في الأمن بثلاثة مستويات مع الحلول
- URL: https://arabroot.io/articles/تمارين-kata-في-الامن/
- Published: 2026-10-06T14:15:00.000Z
- Updated: 2026-10-09T09:44:12.000Z
- Description: تمارين أمن دفاعية تحلها بيدك دون ذكاء اصطناعي، تجد فيها الثغرة في كود صغير وتصلحها وتعرف الأداة التي كانت ستكتشفها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة, الاستضافة الذاتية, أساسيات

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

وهذا المقال جزء من [سلسلة تمارين Kata](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D8%AD%D8%A7%D9%81%D8%B8-%D8%B9%D9%84%D9%89-%D9%85%D9%87%D8%A7%D8%B1%D8%AA%D9%83-%D8%AF%D9%88%D9%86-%D8%B0%D9%83%D8%A7%D8%A1-%D8%A7%D8%B5%D8%B7%D9%86%D8%A7%D8%B9%D9%8A/) التي شرحنا فيها الفكرة وطريقة التدريب. والتمارين هنا دفاعية Defensive: كل كود فيها كود صغير كتبناه نحن للتعلم ويعمل على جهازك فقط، والهدف أن ترى الخطأ وتفهم سببه وتكتب الإصلاح وتعرف الأداة أو الاختبار الذي كان سيكتشفه، وكل ذلك شغلناه فعلاً والمخرج المنشور هو المخرج الحقيقي.

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

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

## كيف تحل هذه التمارين؟

أغلق أداة الذكاء الاصطناعي، واضبط مؤقتاً على «الوقت المقترح»، واكتب جوابك قبل أن تفتح «التلميح Hint» أو «الحل Answer». والجواب الكامل في كل تمرين أربعة أجزاء، وهي نفس الأجزاء التي سوف تجدها في كل حل: ما هي المشكلة، ولماذا هي مشكلة، وما هو الإصلاح بالكود، وما هي الأداة أو الاختبار الذي كان سيكتشفها قبل الإنتاج. والجزء الأخير هو الذي ينساه أغلب الناس، مع أنه الذي يمنع تكرار نفس الخطأ في الكود القادم.

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

![ثلاث درجات متصاعدة، الأولى خضراء للمبتدئ وفيها التمارين من 1 إلى 6، والثانية برتقالية للمتوسط وفيها التمارين من 7 إلى 11، والثالثة بنفسجية داكنة للمتقدم وفيها التمارين من 12 إلى 16، وبجانب كل رقم اسم التمرين بالإنجليزية](https://arabroot.io/content/images/2026/10/katas-security-01-levels.webp)

ستة عشر تمريناً تبدأ من سطر واحد في الكود وتنتهي بقرارات يتخذها الفريق

وتحتاج لتشغيل الحلول Python 3.13 وNode.js 22 وDocker، أما الأدوات فهي Semgrep وGitleaks من خلال Docker Images الرسمية، وكل منفذ في التمارين على العنوان 127.0.0.1 فقط حتى لا يصل إليه أحد من الشبكة. وقبل أن تبدأ يفيدك أن تعرف [قائمة OWASP Top 10](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، لأن أغلب التمارين مثال صغير على أحد بنودها.

## المستوى الأول: مبتدئ Beginner

كل تمرين في هذا المستوى ملف قصير فيه مشكلة واحدة، فاقرأ الملف كاملاً كما تقرأ طلب دمج Pull Request، وحدد السطر بالضبط.

### 1\. البحث عن مستخدم بالاسم

ما المشكلة في الدالة `find_user`؟ ولماذا أعاد السطر الثاني كل المستخدمين؟

```python
# sql_vuln.py: search a user by name
import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users (name, email) VALUES ('ali', 'ali@example.com'), ('sara', 'sara@example.com');
""")

def find_user(name):
    sql = "SELECT id, email FROM users WHERE name = '" + name + "'"
    return db.execute(sql).fetchall()

print(find_user("ali"))
print(find_user("nobody' OR '1'='1"))
```

```text
[(1, 'ali@example.com')]
[(1, 'ali@example.com'), (2, 'sara@example.com')]
```

الوقت المقترح: 10 دقائق.

تلميح Hint

اكتب على الورق نص الاستعلام الذي وصل إلى قاعدة البيانات في الاستدعاء الثاني حرفاً حرفاً، وانظر أين انتهت القيمة وأين بدأ كود SQL.

الحل Answer 

المشكلة حقن SQL أي SQL Injection، وهو البند A05 في OWASP Top 10 و[CWE-89](https://cwe.mitre.org/data/definitions/89.html?ref=arabroot.io). والسبب أن الاسم أضيف إلى نص الاستعلام بجمع النصوص، فأصبح الاستعلام `WHERE name = 'nobody' OR '1'='1'`، والشرط الثاني صحيح دائماً، فقاعدة البيانات لا تعرف أين انتهت البيانات وبدأ الكود. والإصلاح أن ترسل القيمة منفصلة عن نص الاستعلام بالمعاملات Parameterized Queries:

```python
# sql_fixed.py: the value travels separately from the SQL text
import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users (name, email) VALUES ('ali', 'ali@example.com'), ('sara', 'sara@example.com');
""")

def find_user(name):
    return db.execute("SELECT id, email FROM users WHERE name = ?", (name,)).fetchall()

print(find_user("ali"))
print(find_user("nobody' OR '1'='1"))
```

```text
[(1, 'ali@example.com')]
[]
```

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

- علامة `?` ليست استبدالاً للنص داخل Python، وإنما مكان تملؤه قاعدة البيانات بالقيمة بعد أن تحلل الاستعلام، فالاسم الغريب بقي اسماً لم يطابق أحداً.
- تنظيف المدخل بحذف علامة التنصيص ليس إصلاحاً، لأن كل قاعدة بيانات لها طرق كتابة مختلفة، والمعاملات تعمل في كل الحالات كما يشرح [دليل OWASP لمنع حقن SQL](https://cheatsheetseries.owasp.org/cheatsheets/SQL%5FInjection%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io).
- الأداة التي تكتشفه: Semgrep أبلغ عن السطر 12 بالقاعدة `sqlalchemy-execute-raw-query` كما سترى في التمرين 16، ولم يبلغ عن الملف المصلح.

للتفصيل: [الاستعلامات ذات المعاملات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#sql) في مقال البرمجة الآمنة، وتجربة الحقن كاملة في [مقال ثغرات الويب بالتجربة](https://arabroot.io/articles/%D8%AB%D8%BA%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%A8%D8%A7%D9%84%D8%AA%D8%AC%D8%B1%D8%A8%D8%A9-sql-injection-%D9%88-xss-%D9%88-ssrf/).

### 2\. إعدادات عميل الدفع

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

```python
# settings_vuln.py: settings of the payments client
PAYMENTS_URL = "https://payments.example.com/v1"
payments_api_key = "pk_9f3Kq2Lm8Xw4Rt7Vb1Nc6Hz0Jd5Ys2Ue"
TIMEOUT_SECONDS = 10
```

الوقت المقترح: 10 دقائق.

تلميح Hint

من يستطيع قراءة هذا المستودع اليوم؟ ومن سوف يستطيع بعد سنة؟ وتذكر أن Git يحفظ كل نسخة سابقة من كل ملف.

الحل Answer 

المشكلة سر مكتوب في الكود Hard-coded Secret، وهو [CWE-798](https://cwe.mitre.org/data/definitions/798.html?ref=arabroot.io). والسبب أن المفتاح أصبح متاحاً لكل من يقرأ المستودع أو نسخه أو ال Docker Image المبنية منه أو سجل خط CI، والحذف في التزام جديد لا يحذفه من التاريخ History. والإصلاح أن يقرأ المفتاح من البيئة أو من خزنة أسرار، وأن يفشل التطبيق عند التشغيل إذا لم يجده:

```python
# settings_fixed.py: the key comes from the environment (or a secrets manager), never from the code
import os

PAYMENTS_URL = "https://payments.example.com/v1"
payments_api_key = os.environ["PAYMENTS_API_KEY"]      # fails at startup if missing: fail closed
TIMEOUT_SECONDS = 10
```

والأداة التي تكتشفه هي فاحص الأسرار، وهذا مخرج [Gitleaks](https://github.com/gitleaks/gitleaks?ref=arabroot.io) على مجلد التمارين كاملاً:

```bash
docker run --rm -v "$PWD:/src" ghcr.io/gitleaks/gitleaks:latest dir /src --no-banner --no-color --redact=60 -v
```

```text
Finding:     payments_api_key = "pk_9f3Kq2Lm8Xw..."
Secret:      pk_9f3Kq2Lm8Xw...
RuleID:      generic-api-key
Entropy:     5.072140
File:        /src/settings_vuln.py
Line:        3
Fingerprint: /src/settings_vuln.py:generic-api-key:3

8:15AM INF scanned ~15052 bytes (15.05 KB) in 459ms
8:15AM WRN leaks found: 1
```

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

- القاعدة `generic-api-key` لم تعرف نوع المفتاح، وإنما رأت اسم متغير فيه `api_key` وقيمة عشوائيتها Entropy عالية 5.07، والخيار `--redact=60` أخفى أغلب السر حتى لا يتسرب مرة ثانية في سجل الأداة.
- Gitleaks وجد ملفاً واحداً في كل التمارين، ولم يبلغ عن `settings_fixed.py`.
- الجواب على السؤال الثاني: لا يكفي، فالمفتاح الذي دفع مرة يعتبر مكشوفاً، والخطوة الأولى أن تلغيه وتصدر مفتاحاً جديداً من لوحة مزود الخدمة، ثم تنظف التاريخ إذا أردت، ثم تضيف الفحص في Pre-commit وفي خط 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/#leak-response) في مقال اكتشاف الأسرار المسربة.

### 3\. صفحة نتائج البحث

صفحة بحث في Node.js تعرض للمستخدم ما بحث عنه. افتح الرابط التالي في ذهنك وقل ماذا سيحدث في متصفح المستخدم، ثم اكتب الإصلاح:

```javascript
// xss_vuln.js: a search page that repeats what you searched for
const http = require("node:http");

http.createServer((req, res) => {
  const q = new URL(req.url, "http://localhost").searchParams.get("q") ?? "";
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.end(`<h1>Results for: ${q}</h1><p>No results.</p>`);
}).listen(21400, "127.0.0.1");
```

```text
http://127.0.0.1:21400/?q=%3Cscript%3Ealert(1)%3C/script%3E
```

الوقت المقترح: 10 دقائق.

تلميح Hint

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

الحل Answer 

المشكلة XSS منعكس Reflected XSS، وهو [CWE-79](https://cwe.mitre.org/data/definitions/79.html?ref=arabroot.io). والسبب أن القيمة من الرابط دخلت الصفحة كما هي، فأصبح الوسم `<script>` جزءاً من الصفحة، وأي سكربت فيها يعمل بصلاحيات المستخدم على موقعك. والإصلاح ترميز المخرج حسب السياق Output Encoding، وهنا السياق HTML، مع سياسة CSP كطبقة ثانية:

```javascript
// xss_fixed.js: encode for the HTML context before the value touches the page
const http = require("node:http");

const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) =>
  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);

http.createServer((req, res) => {
  const q = new URL(req.url, "http://localhost").searchParams.get("q") ?? "";
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'");   // second layer
  res.end(`<h1>Results for: ${escapeHtml(q)}</h1><p>No results.</p>`);
}).listen(21401, "127.0.0.1");
```

```text
$ curl -s "http://127.0.0.1:21400/?q=%3Cscript%3Ealert(1)%3C/script%3E"
<h1>Results for: <script>alert(1)</script></h1><p>No results.</p>
$ curl -s "http://127.0.0.1:21401/?q=%3Cscript%3Ealert(1)%3C/script%3E"
<h1>Results for: &lt;script&gt;alert(1)&lt;/script&gt;</h1><p>No results.</p>
```

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

- النسخة الأولى أعادت الوسم كما هو، والثانية حولته إلى `&lt;script&gt;` فيعرضه المتصفح نصاً ولا ينفذه.
- الترميز يعتمد على السياق: داخل HTML يختلف عن داخل خاصية Attribute أو داخل JavaScript أو داخل رابط، لذلك استخدم محرك القوالب Template Engine الذي يرمز تلقائياً بدلاً من كتابة الدالة بيدك، كما يشرح [دليل OWASP لمنع XSS](https://cheatsheetseries.owasp.org/cheatsheets/Cross%5FSite%5FScripting%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io).
- الأداة التي تكتشفه: قاعدة Semgrep من نوع Taint كتبناها في التمرين 16 تتبع القيمة من `searchParams.get` إلى `res.end`، والاختبار بالأمر `curl` أعلاه يصلح اختبار انحدار Regression Test.

للتفصيل: [ترميز المخرجات حسب السياق](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#output-encoding) في مقال البرمجة الآمنة، و[Content-Security-Policy](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#csp) في مقال ترويسات الأمان.

### 4\. توكن إعادة تعيين الباسورد

هذه الدالة تولد التوكن الذي يرسل في رابط «نسيت كلمة المرور». التوكن طويل، 32 حرفاً من 62 حرفاً ممكناً. ما المشكلة إذن؟

```python
# token_vuln.py: password reset token
import random
import string

def reset_token(length=32):
    alphabet = string.ascii_letters + string.digits
    return "".join(random.choice(alphabet) for _ in range(length))

print(reset_token())
```

الوقت المقترح: 10 دقائق.

تلميح Hint

الطول ليس المشكلة. اقرأ أول فقرة في توثيق الوحدة `random` في Python، وابحث عن كلمة «security».

الحل Answer 

المشكلة مولد عشوائي غير آمن للتشفير، وهو [CWE-338](https://cwe.mitre.org/data/definitions/338.html?ref=arabroot.io). والسبب أن `random` يستخدم Mersenne Twister، وهو مولد حتمي من يعرف حالته الداخلية يعرف كل ما سيخرجه، و[توثيق random](https://docs.python.org/3/library/random.html?ref=arabroot.io) يقول صراحة إنه لا يصلح للأغراض الأمنية ويحيلك إلى `secrets`. والإصلاح:

```python
# token_fixed.py: the secrets module uses the operating system's CSPRNG
import secrets

def reset_token():
    return secrets.token_urlsafe(32)        # 32 random bytes, about 43 URL-safe characters

print(reset_token())
```

```text
2obZNz87ErhAjGGKBPZTHTulDcsDLMMV
1bRR7VbiaXJ6gV_ZjujFrl-UnPlySqDxkHXF_qjYZCQ
```

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

- السطر الأول من المخرج للدالة القديمة والثاني للجديدة، وهما يبدوان متشابهين في العشوائية، وهذا بالضبط ما يجعل الخطأ يمر في المراجعة، فالفرق في مصدر العشوائية وليس في شكلها.
- `secrets.token_urlsafe(32)` يأخذ 32 بايت من مولد نظام التشغيل CSPRNG ويرمزها بـ Base64url فتصلح للرابط مباشرة، وبعد ذلك احفظ هاش التوكن في قاعدة البيانات مع مدة صلاحية قصيرة واستخدام واحد.
- الأداة التي تكتشفه: قاعدة Semgrep في التمرين 16 تبحث عن `random.` داخل دالة اسمها فيه token أو secret أو password أو key أو salt، والقاعدة البسيطة في الفريق: لا `random` ولا `Math.random` في أي كود أمني.

للتفصيل: [العشوائية CSPRNG مقابل random](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#randomness) في مقال التشفير للمطورين، وفيه تجربة توقع المخرج التالي بعد 624 قيمة.

### 5\. اقرأ ترويسات الاستجابة من curl -I

طلب منك الفريق مراجعة سريعة لموقع قبل إطلاقه، وهذا كل ما لديك، مخرج `curl -I` على الصفحة الرئيسية. ما الذي ينقص؟ وما الذي يجب حذفه؟ وكيف تجعل هذه المراجعة تتكرر تلقائياً؟

```text
$ curl -sI http://127.0.0.1:21404/
HTTP/1.1 200 OK
Server: nginx/1.29.8
Date: Wed, 07 Oct 2026 08:48:44 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Tue, 07 Apr 2026 12:09:53 GMT
Connection: keep-alive
ETag: "69d4f411-380"
Accept-Ranges: bytes
```

الوقت المقترح: 10 دقائق.

تلميح Hint

ابحث عن أربع ترويسات أمان معروفة غير موجودة، وانظر إلى الترويسة `Server`: ما الذي تخبر به من لا يعرفك؟

الحل Answer 

المشكلة إعداد أمني ناقص Security Misconfiguration، وهو البند A02 في OWASP Top 10\. والذي ينقص: `Strict-Transport-Security` حتى لا يفتح المتصفح الموقع عبر HTTP مرة أخرى، و`Content-Security-Policy` حتى يحد مما تحمله الصفحة وهي طبقة ثانية ضد XSS، و`X-Content-Type-Options: nosniff`، و`Referrer-Policy`، والذي يجب حذفه رقم الإصدار `nginx/1.29.8` لأنه يسهل مطابقة السيرفر مع ثغرات معروفة. والإصلاح في إعداد Nginx:

```nginx
server {
    listen 80;
    server_tokens off;                                   # no version in the Server header
    root /usr/share/nginx/html;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
```

```text
$ curl -sI http://127.0.0.1:21405/
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 07 Oct 2026 08:48:44 GMT
Content-Type: text/html
Content-Length: 896
Last-Modified: Tue, 07 Apr 2026 12:09:53 GMT
Connection: keep-alive
ETag: "69d4f411-380"
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Accept-Ranges: bytes
```

والاختبار الذي يكتشفه سكربت صغير يعمل في خط CI بعد كل نشر ويفشل إذا نقصت ترويسة:

```bash
#!/bin/sh
# check-headers.sh URL: fails (exit 1) when a required header is missing or the version leaks
url="$1"; fail=0
h=$(curl -sI "$url")
for name in Strict-Transport-Security Content-Security-Policy X-Content-Type-Options Referrer-Policy; do
  echo "$h" | grep -qi "^$name:" && echo "ok       $name" || { echo "MISSING  $name"; fail=1; }
done
echo "$h" | grep -qiE '^Server: .*[0-9]' && { echo "LEAK     $(echo "$h" | grep -i '^Server:' | tr -d '\r')"; fail=1; }
exit $fail
```

```text
$ sh check-headers.sh http://127.0.0.1:21404/; echo "exit $?"
MISSING  Strict-Transport-Security
MISSING  Content-Security-Policy
MISSING  X-Content-Type-Options
MISSING  Referrer-Policy
LEAK     Server: nginx/1.29.8
exit 1
$ sh check-headers.sh http://127.0.0.1:21405/; echo "exit $?"
ok       Strict-Transport-Security
ok       Content-Security-Policy
ok       X-Content-Type-Options
ok       Referrer-Policy
exit 0
```

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

- الكلمة `always` في نهاية كل `add_header` تضيف الترويسة لصفحات الأخطاء أيضاً، ودونها تختفي من استجابات 404 و500.
- المتصفح يتجاهل HSTS إذا وصلت عبر HTTP كما في [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797?ref=arabroot.io#section-8.1)، فوجودها في هذا المختبر المحلي للتوضيح فقط، ومكانها الصحيح الموقع الذي يعمل عبر HTTPS.
- بقيت الكلمة `nginx` دون رقم بعد `server_tokens off`، وهذا مقبول، فالمهم أن لا يظهر الإصدار.

للتفصيل: [المجموعة الأساسية والمجموعة الأشد](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#baseline) و[كيف تختبر الترويسات](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#testing) في مقال ترويسات الأمان.

### 6\. ماذا يكشف هذا السجل؟

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

```python
# log_vuln.py: log every failed login with the username the client sent
import logging, sys

logging.basicConfig(stream=sys.stdout, format="%(asctime)s %(levelname)s %(message)s",
                    datefmt="%Y-%m-%dT%H:%M:%S", level=logging.INFO)

def login_failed(username, ip, password):
    logging.warning("login failed user=%s ip=%s password=%s", username, ip, password)

login_failed("sara", "203.0.113.7", "Summer2026!")
login_failed("x\n2026-10-07T08:00:00 INFO login ok user=admin ip=10.0.0.5", "203.0.113.7", "x")
```

```text
2026-10-07T11:04:48 WARNING login failed user=sara ip=203.0.113.7 password=Summer2026!
2026-10-07T11:04:48 WARNING login failed user=x
2026-10-07T08:00:00 INFO login ok user=admin ip=10.0.0.5 ip=203.0.113.7 password=x
```

الوقت المقترح: 15 دقيقة.

تلميح Hint

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

الحل Answer 

المشكلة الأولى تسجيل كلمة المرور، وهو [CWE-532](https://cwe.mitre.org/data/definitions/532.html?ref=arabroot.io)، والسبب أن السجل يقرؤه أشخاص وأنظمة أكثر بكثير من قاعدة البيانات، ويحفظ مدة أطول، وكلمة المرور الفاشلة كثيراً ما تكون قريبة من الصحيحة. والمشكلة الثانية حقن السجلات Log Injection وهو [CWE-117](https://cwe.mitre.org/data/definitions/117.html?ref=arabroot.io): اسم المستخدم فيه سطر جديد، فصنع سطراً مزوراً يقول إن `admin` دخل بنجاح، ومن يقرأ السجل أو أداة التنبيه لا تعرف أنه مزور. والإصلاح سجل منظم Structured Log بصيغة JSON، فالمرمز يحول السطر الجديد إلى `\n` داخل القيمة، ولا توجد كلمة مرور:

```python
# log_fixed.py: one JSON object per line; the encoder escapes control characters, and no password
import json, logging, sys
from datetime import datetime, timezone

logging.basicConfig(stream=sys.stdout, format="%(message)s", level=logging.INFO)

def event(level, name, **fields):
    record = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "level": level, "event": name, **fields}
    logging.log(getattr(logging, level), json.dumps(record, ensure_ascii=False))

def login_failed(username, ip):
    event("WARNING", "login_failed", user=username[:64], ip=ip)

login_failed("sara", "203.0.113.7")
login_failed("x\n2026-10-07T08:00:00 INFO login ok user=admin ip=10.0.0.5", "203.0.113.7")
```

```text
{"ts": "2026-10-07T08:04:49+00:00", "level": "WARNING", "event": "login_failed", "user": "sara", "ip": "203.0.113.7"}
{"ts": "2026-10-07T08:04:49+00:00", "level": "WARNING", "event": "login_failed", "user": "x\n2026-10-07T08:00:00 INFO login ok user=admin ip=10.0.0.5", "ip": "203.0.113.7"}
```

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

- السطر المزور بقي داخل قيمة `user` في نفس الحدث، فلا يمكن أن يقرأ كحدث مستقل.
- النسخة القديمة تكتب الوقت المحلي دون منطقة زمنية، والجديدة تكتب UTC مع الفارق، وهذا يهمك عندما تجمع سجلات من أكثر من سيرفر في التمرين 10.
- قص الاسم إلى 64 حرفاً يمنع من يرسل اسماً بحجم ميغابايت من ملء القرص بالسجلات.
- الأداة التي تكتشفه: Semgrep أبلغ عن السطر 8 بالقاعدة `python-logger-credential-disclosure` بسبب كلمة password، أما حقن السطر فلم تره القواعد التي استخدمناها، ويكشفه اختبار وحدة يمرر اسماً فيه `\n` ويتأكد أن المخرج سطر واحد.

للتفصيل: [ما لا يدخل السجل أبداً](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/#never-log) و[حقن السجلات](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/#log-injection) في مقال السجلات.

## المستوى الثاني: متوسط Intermediate

في هذا المستوى لا تكفي قراءة السطر، فالخطأ يظهر عندما تسأل: من يرسل هذا الطلب؟ وما الذي يملكه؟ وما الذي يحدث عندما تكون القيمة غير متوقعة؟

### 7\. فاتورة من هذه؟ IDOR

API للفواتير في Node.js، وكل طلب يحتاج توكن صحيحاً وإلا يرجع 401\. أليس هذا كافياً؟ اكتب الطلب الذي يثبت المشكلة، ثم الإصلاح، ثم الاختبار الذي يمنع عودتها:

```javascript
// idor.js: GET /api/invoices/:id   (node idor.js vuln | node idor.js fixed)
const http = require("node:http");
const FIXED = process.argv[2] === "fixed";

const sessions = { "tok-alice": "alice", "tok-bob": "bob" };       // who is logged in (authentication)
const invoices = {
  1001: { owner: "alice", total: 120 },
  1002: { owner: "bob", total: 990 },
};

http.createServer((req, res) => {
  const user = sessions[(req.headers.authorization ?? "").replace("Bearer ", "")];
  if (!user) { res.writeHead(401).end(); return; }

  const m = req.url.match(/^\/api\/invoices\/(\d+)$/);
  const inv = m && invoices[m[1]];
  if (!inv || (FIXED && inv.owner !== user)) {        // the fix: is this invoice yours? (authorization)
    res.writeHead(404).end(); return;                 // 404, so ids of others are not confirmed
  }
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ id: Number(m[1]), ...inv }));
}).listen(FIXED ? 21403 : 21402, "127.0.0.1");
```

الوقت المقترح: 15 دقيقة.

تلميح Hint

المصادقة Authentication تجيب عن سؤال «من أنت؟»، والصلاحيات Authorization تجيب عن سؤال «هل يحق لك هذا الشيء بالذات؟». أي السؤالين يسأله الكود عندما يقرأ الفاتورة 1002؟

الحل Answer 

المشكلة مرجع مباشر غير آمن لكائن Insecure Direct Object Reference، أو IDOR، ويسمى في عالم ال API باسم BOLA، وهو [CWE-639](https://cwe.mitre.org/data/definitions/639.html?ref=arabroot.io) والبند A01 في OWASP Top 10\. والسبب أن الكود تحقق أن المستخدم مسجل دخوله، ولم يتحقق أن الفاتورة له، فمن يملك أي توكن يقرأ كل الفواتير بتغيير الرقم. والإصلاح هو الشرط `inv.owner !== user` الموجود في الملف ويعمل في وضع `fixed`، وهذا تشغيل النسختين على المنفذين 21402 و21403:

```text
$ curl -s -w ' %{http_code}' -H 'Authorization: Bearer tok-alice' http://127.0.0.1:21402/api/invoices/1001
{"id":1001,"owner":"alice","total":120} 200
$ curl -s -w ' %{http_code}' -H 'Authorization: Bearer tok-alice' http://127.0.0.1:21402/api/invoices/1002
{"id":1002,"owner":"bob","total":990} 200
$ curl -s -w ' %{http_code}' -H 'Authorization: Bearer tok-alice' http://127.0.0.1:21403/api/invoices/1001
{"id":1001,"owner":"alice","total":120} 200
$ curl -s -w ' %{http_code}' -H 'Authorization: Bearer tok-alice' http://127.0.0.1:21403/api/invoices/1002
 404
```

والاختبار الذي يكتشفه اختبار تكامل يحاول قراءة فاتورة مستخدم آخر:

```javascript
// idor.test.mjs: the test a scanner cannot write for you   (MODE=vuln|fixed node --test idor.test.mjs)
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";

const mode = process.env.MODE ?? "fixed";
const port = mode === "fixed" ? 21403 : 21402;
let server;
before(async () => { server = spawn("node", ["idor.js", mode]); await new Promise(r => setTimeout(r, 800)); });
after(() => server.kill());

const get = (id, token) => fetch(`http://127.0.0.1:${port}/api/invoices/${id}`, { headers: { Authorization: `Bearer ${token}` } });

test("alice reads her own invoice", async () => assert.equal((await get(1001, "tok-alice")).status, 200));
test("alice cannot read bob's invoice", async () => assert.equal((await get(1002, "tok-alice")).status, 404));
```

```bash
MODE=vuln node --test idor.test.mjs
MODE=fixed node --test idor.test.mjs
```

```text
ok 1 - alice reads her own invoice
not ok 2 - alice cannot read bob's invoice
# pass 1
# fail 1
```

```text
ok 1 - alice reads her own invoice
ok 2 - alice cannot read bob's invoice
# pass 2
# fail 0
```

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

- رجعنا 404 وليس 403، حتى لا يعرف من يجرب الأرقام أي الفواتير موجودة، كما يوصي [دليل OWASP لمنع IDOR](https://cheatsheetseries.owasp.org/cheatsheets/Insecure%5FDirect%5FObject%5FReference%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io).
- تحويل الأرقام إلى UUID يصعب التخمين ولا يصلح المشكلة، فالرقم الذي يتسرب في رابط أو بريد يفتح الفاتورة لمن يملكه.
- لم تبلغ أي أداة فحص ثابت SAST عن هذا الملف في التمرين 16، والسبب أن الأداة لا تعرف قاعدة العمل «الفاتورة لصاحبها»، لذلك اختبار الصلاحيات لكل Endpoint هو الحماية الحقيقية.

للتفصيل: [API1 تفويض مستوى الكائن BOLA](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api1) في مقال أمن واجهات API، و[A01 التحكم في الوصول المكسور](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#a01).

### 8\. تنزيل الملفات المرفوعة

دالة تقرأ ملفاً رفعه المستخدم من مجلد `uploads`، والاسم يأتي من الرابط. بجانب المجلد ملف `app.env` فيه إعدادات قاعدة البيانات. ما الذي يحدث في السطر الأخير؟ واكتب إصلاحاً يرفض أيضاً المسار المطلق مثل `/etc/passwd`:

```python
# path_vuln.py: download a file the user uploaded earlier
import os

BASE = os.path.abspath("../pt/uploads")

def read_upload(name):
    with open(os.path.join(BASE, name)) as f:
        return f.read().strip()

print(read_upload("1001.txt"))
print(read_upload("../app.env"))
```

الوقت المقترح: 15 دقيقة.

تلميح Hint

`os.path.join` لا يتحقق من شيء، فهو يلصق النصوص فقط، وإذا كان الجزء الثاني مساراً مطلقاً فهو يتجاهل الأول تماماً. الحل أن تحسب المسار النهائي بعد حل `..` ثم تسأل: هل ما زال داخل المجلد؟

الحل Answer 

المشكلة تجاوز المسار Path Traversal، وهو [CWE-22](https://cwe.mitre.org/data/definitions/22.html?ref=arabroot.io). والسبب أن `../` في الاسم خرج من المجلد إلى المجلد الأب، فقرأت الدالة ملف الإعدادات. والإصلاح:

```python
# path_fixed.py: resolve the final path, then check it is still inside the folder
from pathlib import Path

BASE = Path("../pt/uploads").resolve()

def read_upload(name):
    path = (BASE / name).resolve()                  # resolves .. and symbolic links
    if not path.is_relative_to(BASE):
        raise PermissionError(f"outside uploads: {name}")
    return path.read_text().strip()

print(read_upload("1001.txt"))
for bad in ["../app.env", "x/../../app.env", str(BASE.parent / "app.env")]:
    try:
        print(read_upload(bad))
    except PermissionError as e:
        print("blocked:", e)
```

وشغلنا النسختين داخل Container من `python:3.13-slim` حتى تكون المسارات مسارات Linux كما في السيرفر:

```bash
docker run --rm -v "$PWD:/lab" -w /lab/code python:3.13-slim sh -c 'python path_vuln.py; echo ----; python path_fixed.py'
```

```text
invoice 1001
DB_PASSWORD=not-for-users
----
invoice 1001
blocked: outside uploads: ../app.env
blocked: outside uploads: x/../../app.env
blocked: outside uploads: /lab/pt/app.env
```

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

- `resolve()` يحل `..` والروابط الرمزية Symbolic Links معاً، ثم [is\_relative\_to](https://docs.python.org/3/library/pathlib.html?ref=arabroot.io#pathlib.PurePath.is%5Frelative%5Fto) يقارن المسار النهائي بالمجلد، فرفض المسار النسبي والمسار الذي يدخل مجلداً ثم يخرج منه والمسار المطلق.
- البحث عن `..` في النص ورفضه حل بسيط ينكسر مع الترميز المزدوج والمسار المطلق، أما المقارنة بعد الحل فلا تهتم بكيف كتب المسار.
- الأفضل من كل ذلك أن لا يأتي اسم الملف من المستخدم أصلاً: احفظ الملف باسم تولده أنت مثل UUID، واحفظ الاسم الأصلي في قاعدة البيانات للعرض فقط.
- الأداة التي تكتشفه: قاعدة Semgrep كتبناها في التمرين 16 للنمط `open(os.path.join(base, param))`، واختبار وحدة بقائمة الأسماء الثلاثة أعلاه.

للتفصيل: [تجاوز المسار والتعامل الآمن مع الملفات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#paths) في مقال البرمجة الآمنة.

### 9\. توكن JWT يقبل دون التحقق من التوقيع

في هذا الملف دالتان تقرآن المستخدم من توكن JWT موقع بـ HS256\. الأولى كتبها مطور مستعجل، والثانية مراجعة. ما الذي تفعله الأولى ولا تفعله الثانية؟ وتوقع مخرج الحالات الثلاث: التوكن الصحيح، والتوكن بعد تعديل الدور role في الجزء الأوسط، والتوكن الذي رأسه `alg: none`:

```javascript
// jwt.js: HS256 tokens with node:crypto only, to see what "verify" really means
const crypto = require("node:crypto");
const SECRET = crypto.randomBytes(32);                      // in production: from a secrets manager
const b64u = (buf) => Buffer.from(buf).toString("base64url");
const json = (part) => JSON.parse(Buffer.from(part, "base64url").toString());

function sign(payload) {
  const head = b64u(JSON.stringify({ alg: "HS256", typ: "JWT" }));
  const body = b64u(JSON.stringify(payload));
  const sig = b64u(crypto.createHmac("sha256", SECRET).update(`${head}.${body}`).digest());
  return `${head}.${body}.${sig}`;
}

// the vulnerable version: reads the payload and trusts it
function getUserVuln(token) {
  return json(token.split(".")[1]);
}

// the fixed version: fixed algorithm, signature compared in constant time, expiry checked
function getUser(token) {
  const [head, body, sig] = token.split(".");
  if (!head || !body || !sig) throw new Error("malformed");
  if (json(head).alg !== "HS256") throw new Error("unexpected alg");          // never let the token choose
  const expected = crypto.createHmac("sha256", SECRET).update(`${head}.${body}`).digest();
  const given = Buffer.from(sig, "base64url");
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) throw new Error("bad signature");
  const claims = json(body);
  if (typeof claims.exp !== "number" || claims.exp < Date.now() / 1000) throw new Error("expired");
  return claims;
}

const good = sign({ sub: "alice", role: "user", exp: Math.floor(Date.now() / 1000) + 600 });
const [h, , s] = good.split(".");
const edited = `${h}.${b64u(JSON.stringify({ sub: "alice", role: "admin", exp: 9999999999 }))}.${s}`;
const none = `${b64u(JSON.stringify({ alg: "none", typ: "JWT" }))}.${b64u(JSON.stringify({ sub: "alice", role: "admin" }))}.`;

for (const [name, t] of [["good", good], ["edited payload", edited], ["alg none", none]]) {
  let fixed;
  try { fixed = getUser(t).role; } catch (e) { fixed = `rejected (${e.message})`; }
  console.log(`${name.padEnd(15)} vuln: role=${getUserVuln(t).role.padEnd(6)} fixed: ${fixed}`);
}
```

الوقت المقترح: 20 دقيقة.

تلميح Hint

الجزء الأوسط من JWT ليس مشفراً، وإنما مرمز Base64url، فأي شخص يستطيع قراءته وتعديله. الذي يمنع التعديل هو التوقيع في الجزء الثالث، بشرط أن يتحقق منه أحد.

الحل Answer 

```text
good            vuln: role=user   fixed: user
edited payload  vuln: role=admin  fixed: rejected (bad signature)
alg none        vuln: role=admin  fixed: rejected (malformed)
```

المشكلة قبول بيانات دون التحقق من توقيعها، وهو [CWE-347](https://cwe.mitre.org/data/definitions/347.html?ref=arabroot.io). والسبب أن `getUserVuln` تفك الجزء الأوسط وتثق به، فكل من يعدل كلمة `user` إلى `admin` أصبح مديراً. والإصلاح في `getUser`: تحديد الخوارزمية مسبقاً وعدم قبول ما يقوله رأس التوكن، ثم حساب HMAC ومقارنته في زمن ثابت، ثم التحقق من انتهاء الصلاحية `exp`.

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

- التوكن المعدل رفض لأن التوقيع لم يعد يطابق المحتوى، وتوكن `alg: none` رفض لأن جزء التوقيع فارغ، ولو وصل إلى فحص الخوارزمية لرفض هناك أيضاً.
- `timingSafeEqual` بدلاً من المقارنة العادية حتى لا يكشف زمن المقارنة عدد البايتات الصحيحة.
- في مشروع حقيقي لا تكتب هذا بيدك، بل استخدم مكتبة معروفة مع تحديد الخوارزميات المسموحة صراحة، كما يوصي [RFC 8725 لأفضل ممارسات JWT](https://www.rfc-editor.org/rfc/rfc8725?ref=arabroot.io)، وكتبناه هنا حتى ترى ما تفعله المكتبة من الداخل.
- الاختبار الذي يكتشفه اختبار وحدة يعدل الجزء الأوسط ويتوقع الرفض، وهو ما يطبعه الملف نفسه، أما أدوات SAST فتبلغ عادة عن استدعاء دالة `decode` في مكتبة معروفة دون `verify`، ولم تبلغ عن كودنا المكتوب يدوياً.

للتفصيل: [أخطاء تكررت مع JWT](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#jwt-pitfalls) في مقال الجلسات وJWT.

### 10\. اقرأ سجل الدخول وقرر ماذا حدث

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

```text
2026-10-07T02:10:00Z login_failed user=admin ip=198.51.100.23
2026-10-07T02:10:01Z login_failed user=admin ip=198.51.100.23
2026-10-07T02:10:03Z login_failed user=admin ip=198.51.100.23
2026-10-07T02:14:25Z login_failed user=admin ip=198.51.100.23
2026-10-07T02:14:27Z login_failed user=admin ip=198.51.100.23
2026-10-07T02:14:28Z login_failed user=admin ip=198.51.100.23
2026-10-07T03:00:00Z login_failed user=user000@example.com ip=203.0.113.15
2026-10-07T03:00:02Z login_failed user=user000@example.com ip=203.0.113.15
2026-10-07T03:00:06Z login_failed user=user001@example.com ip=203.0.113.7
2026-10-07T03:00:12Z login_failed user=user002@example.com ip=203.0.113.58
2026-10-07T03:00:18Z login_failed user=user003@example.com ip=203.0.113.38
2026-10-07T03:00:24Z login_failed user=user004@example.com ip=203.0.113.2
2026-10-07T03:01:12Z login_ok     user=user012@example.com ip=203.0.113.45
2026-10-07T08:55:00Z login_failed user=sara@example.com ip=192.0.2.10
2026-10-07T08:55:20Z login_failed user=sara@example.com ip=192.0.2.10
2026-10-07T08:55:55Z login_ok     user=sara@example.com ip=192.0.2.10
```

والملخص لكل ساعة من سكربت بسيط:

```python
# read_authlog.py: summarize per hour: attempts, failures, distinct addresses, distinct accounts, successes
import collections

hours = collections.defaultdict(lambda: {"n": 0, "fail": 0, "ips": set(), "users": set(), "ok": []})
for line in open("auth.log"):
    ts, event, user, ip = line.split()
    h = hours[ts[:13]]
    h["n"] += 1
    h["fail"] += event == "login_failed"
    h["ips"].add(ip[3:]); h["users"].add(user[5:])
    if event == "login_ok":
        h["ok"].append(user[5:])

print(f"{'hour (UTC)':14} {'tries':>5} {'fail':>5} {'IPs':>4} {'users':>5} {'fail/user':>9}  ok")
for hour, h in sorted(hours.items()):
    print(f"{hour:14} {h['n']:5} {h['fail']:5} {len(h['ips']):4} {len(h['users']):5} {h['fail'] / len(h['users']):9.1f}  {len(h['ok'])} {h['ok'][:4]}")
```

```text
hour (UTC)     tries  fail  IPs users fail/user  ok
2026-10-07T02    180   180    1     1     180.0  0 []
2026-10-07T03    354   350   60   300       1.2  4 ['user012@example.com', 'user057@example.com', 'user125@example.com', 'user140@example.com']
2026-10-07T08      3     2    1     1       2.0  1 ['sara@example.com']
```

لكل ساعة من الساعات الثلاث قرر: هجوم تخمين Brute Force، أم حشو بيانات الدخول Credential Stuffing، أم استخدام طبيعي؟ وما الذي تفعله الآن في كل حالة؟

الوقت المقترح: 15 دقيقة.

تلميح Hint

لا تنظر إلى عدد المحاولات الفاشلة وحده، وإنما إلى النسبة بين المحاولات والحسابات والعناوين، وإلى عدد مرات النجاح. ومن أين يأتي من يملك الباسورد الصحيح لحساب من المحاولة الأولى؟

الحل Answer 

والصورة التالية تبين الأشكال الثلاثة:

![ثلاثة أعمدة: في الأول جهاز واحد بعنوان 198.51.100.23 وسهم أحمر عليه 180 مرة إلى حساب admin دون نجاح، وفي الثاني اثنا عشر جهازاً تمثل 60 عنواناً بخطوط متشابكة إلى صفوف من المستخدمين تمثل 300 حساب منها أربعة بالأحمر نجحت، وفي الثالث جهاز واحد إلى حساب sara بمحاولتين فاشلتين ثم نجاح](https://arabroot.io/content/images/2026/10/katas-security-02-authlog.webp)

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

- الساعة 02: عنوان واحد ضد حساب واحد 180 مرة خلال أقل من خمس دقائق ودون نجاح، وهذا تخمين Brute Force كما يصفه [MITRE ATT&CK T1110.001](https://attack.mitre.org/techniques/T1110/001/?ref=arabroot.io). والإجراء: إبطاء أو حظر مؤقت للعنوان، وحد للمحاولات على الحساب، والتأكد أن `admin` عليه مصادقة ثنائية MFA.
- الساعة 03: ستون عنواناً ضد 300 حساب مختلف بنحو 1.2 محاولة لكل حساب، ونجحت أربع محاولات من المرة الأولى، وهذا حشو بيانات الدخول Credential Stuffing كما في [T1110.004](https://attack.mitre.org/techniques/T1110/004/?ref=arabroot.io): قوائم أسماء وكلمات مرور مسربة من مواقع أخرى. والنجاحات الأربعة هي الحادثة الحقيقية: أوقف جلسات هذه الحسابات وأجبرها على تغيير الباسورد وراجع ما فعلته بعد الدخول، ثم أضف حماية لا تعتمد على العنوان كما يشرح [دليل OWASP لمنع حشو بيانات الدخول](https://cheatsheetseries.owasp.org/cheatsheets/Credential%5FStuffing%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io).
- الساعة 08: عنوان واحد معروف، محاولتان فاشلتان ثم نجاح خلال دقيقة، وهذا شخص نسي كلمة المرور، ولا يحتاج أي إجراء.

ولاحظ أن الحظر بالعنوان الذي أوقف الساعة 02 لا يرى الساعة 03 أبداً، لأن كل عنوان فيها جرب نحو ست محاولات فقط موزعة على نصف ساعة، لذلك يحتاج التنبيه أن يعد الحسابات المختلفة لكل نافذة زمنية وليس المحاولات لكل عنوان فقط. والأداة التي تكتشفه: قاعدة تنبيه في نظام السجلات المركزي على «عدد الحسابات المختلفة الفاشلة في الساعة» و«نجاح دخول من عنوان جديد بعد فشل كثير»، وحدث `login_ok` يجب أن يكتب أيضاً في سجل التدقيق Audit Trail.

للتفصيل: [ماذا تسجل في كل حدث](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/#what-to-log) في مقال السجلات، و[ماذا تسجل في سجل التدقيق](https://arabroot.io/articles/%D8%B3%D8%AC%D9%84-%D8%A7%D9%84%D8%AA%D8%AF%D9%82%D9%8A%D9%82-audit-trail-%D9%85%D9%86-%D9%81%D8%B9%D9%84-%D9%85%D8%A7%D8%B0%D8%A7-%D9%88%D9%85%D8%AA%D9%89/#what-to-audit)، و[A07 أخطاء المصادقة](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#a07).

### 11\. اختر الأداة الصحيحة من التشفير

لكل حالة من الحالات الخمس التالية اختر: هاش Hash، أم هاش كلمات مرور بطيء، أم HMAC، أم تشفير متماثل Symmetric Encryption، أم توقيع Signature، واذكر الخوارزمية:

1. تخزين كلمات مرور المستخدمين.
2. تخزين مفاتيح API التي يصدرها نظامك للعملاء، بحيث تتحقق منها عند كل طلب.
3. التأكد أن طلب Webhook جاء فعلاً من مزود الدفع الذي يشاركك سراً.
4. تخزين أرقام الهوية الوطنية للعملاء، وتحتاج قراءتها لاحقاً.
5. ملف إصدار ينزله العملاء ويريدون التأكد أنه منك ولم يعدل.

الوقت المقترح: 15 دقيقة.

تلميح Hint

اسأل في كل حالة: هل أحتاج أن أعيد القيمة الأصلية؟ هل الطرفان يملكان نفس السر؟ هل القيمة الأصلية ضعيفة يمكن تخمينها مثل كلمة المرور، أم عشوائية طويلة مثل المفتاح؟

الحل Answer 

شغلنا كل حالة بمكتبة معروفة داخل Container من `python:3.13-slim` مع cryptography 50.0.2 وargon2-cffi 25.1.0:

```python
# crypto_cases.py: one right tool per case (pip install cryptography argon2-cffi)
import hashlib, hmac, os, secrets
from argon2 import PasswordHasher
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.exceptions import InvalidTag, InvalidSignature

# 1) user passwords: a slow, salted password hash
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
stored = ph.hash("Summer2026!")
print("1", stored[:31] + "...", ph.verify(stored, "Summer2026!"))

# 2) API keys you issue: 256 random bits, store only SHA-256 of the key (a fast hash is fine for random keys)
api_key = secrets.token_urlsafe(32)
stored_digest = hashlib.sha256(api_key.encode()).hexdigest()
print("2", hmac.compare_digest(hashlib.sha256(api_key.encode()).hexdigest(), stored_digest))

# 3) a webhook from the payment provider: HMAC-SHA256 with the shared secret, constant-time compare
shared = secrets.token_bytes(32)
body = b'{"order":1001,"status":"paid"}'
sig = hmac.new(shared, body, hashlib.sha256).hexdigest()
tampered = b'{"order":1001,"status":"paid","amount":1}'
print("3", hmac.compare_digest(sig, hmac.new(shared, body, hashlib.sha256).hexdigest()),
      hmac.compare_digest(sig, hmac.new(shared, tampered, hashlib.sha256).hexdigest()))

# 4) national ID numbers you must read again: AES-256-GCM, a new nonce each time, the row id as associated data
key = AESGCM.generate_key(bit_length=256)            # in production: from a KMS, never next to the data
aes = AESGCM(key)
nonce = os.urandom(12)
ct = aes.encrypt(nonce, "1234567890".encode(), b"customer:42")
print("4", aes.decrypt(nonce, ct, b"customer:42").decode(), end=" ")
for name, args in [("copied to another row", (nonce, ct, b"customer:43")),
                   ("one byte changed", (nonce, ct[:-1] + bytes([ct[-1] ^ 1]), b"customer:42"))]:
    try:
        aes.decrypt(*args)
    except InvalidTag:
        print(f"| {name}: InvalidTag", end=" ")
print()

# 5) a release file customers must trust: an Ed25519 signature, the public key published once
sk = Ed25519PrivateKey.generate()
release = b"app-2.4.1.tar.gz contents"
signature = sk.sign(release)
try:
    sk.public_key().verify(signature, release + b"!")
except InvalidSignature:
    print("5 signature ok:", sk.public_key().verify(signature, release) is None, "| changed file: InvalidSignature")
```

```text
1 $argon2id$v=19$m=19456,t=2,p=1$... True
2 True
3 True False
4 1234567890 | copied to another row: InvalidTag | one byte changed: InvalidTag 
5 signature ok: True | changed file: InvalidSignature
```

- كلمات المرور: هاش بطيء مع ملح Argon2id بمعاملات OWASP الدنيا m=19456 وt=2 وp=1، والسبب أن كلمات المرور ضعيفة ويمكن تخمينها، فالبطء هو الحماية، وSHA-256 هنا خطأ لأنه سريع.
- مفاتيح API: SHA-256 يكفي، لأن المفتاح 256 بت عشوائية ولا يمكن تخمينه مهما كان الهاش سريعاً، والهاش البطيء هنا يبطئ كل طلب دون فائدة.
- ال Webhook: HMAC-SHA256 بالسر المشترك مع `compare_digest`، فالهاش وحده لا يكفي لأن أي شخص يستطيع حسابه، والتعديل البسيط في الطلب جعل المقارنة `False`.
- أرقام الهوية: AES-256-GCM مع Nonce جديد لكل تشفير ورقم العميل كبيانات مرتبطة Associated Data، فلما نسخنا النص المشفر إلى صف عميل آخر رفضه، والمفتاح مكانه نظام إدارة المفاتيح KMS وليس بجانب البيانات.
- ملف الإصدار: توقيع Ed25519، فالمفتاح الخاص عندك وحدك والمفتاح العام ينشر للجميع، وتغيير حرف واحد في الملف أعطى `InvalidSignature`.

والأداة التي تكتشف الاختيار الخطأ هي قواعد SAST للخوارزميات الضعيفة مثل MD5 وSHA-1 للباسورد وECB، ومراجعة تصميم Design Review لكل حالة جديدة، فالأداة لا تعرف أن هذا الحقل كلمة مرور وذاك مفتاح عشوائي.

للتفصيل: [جدول القرار](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#decision-table) و[تخزين كلمات المرور](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/#password-storage) في مقال التشفير للمطورين.

## المستوى الثالث: متقدم Advanced

في هذا المستوى يصبح السؤال أوسع من سطر الكود: ما الذي نصلحه أولاً؟ وما الذي يمكن أن يسوء في ميزة لم تكتب بعد؟ وما الذي لا تراه أدوات الفحص أصلاً؟

### 12\. رتب ثلاث نتائج فحص CVSS وEPSS وKEV

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

1. CVE-2024-45491 في مكتبة libexpat داخل Docker Image لمهمة داخلية تعمل على سيرفرات amd64 بنظام 64 بت، وتقرأ ملف XML من إعداداتنا فقط.
2. CVE-2023-44487 المعروفة باسم HTTP/2 Rapid Reset في ال Reverse Proxy الذي يستقبل حركة الإنترنت، وHTTP/2 مفعل.
3. CVE-2024-6387 في OpenSSH على سيرفرات منفذ SSH فيها مفتوح للإنترنت.

الوقت المقترح: 20 دقيقة.

تلميح Hint

CVSS يقيس شدة الثغرة لو استغلت، وEPSS يقدر احتمال استغلالها في الثلاثين يوماً القادمة، وقائمة KEV من CISA تقول إنها استغلت فعلاً. ثم أضف ما لا تعرفه أي قائمة: هل الثغرة قابلة للوصول في بيئتك أنت؟ اقرأ وصف كل ثغرة في NVD.

الحل Answer 

جمعنا الأرقام الثلاثة من مصادرها الرسمية: CVSS من [واجهة NVD](https://nvd.nist.gov/developers/vulnerabilities?ref=arabroot.io)، وEPSS من [واجهة FIRST](https://api.first.org/epss/?ref=arabroot.io)، و[قائمة KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog?ref=arabroot.io) من CISA:

```python
# triage.py: CVSS from NVD, EPSS from FIRST, and the KEV catalog from CISA, for three findings
import json, time, urllib.request

CVES = ["CVE-2024-45491", "CVE-2023-44487", "CVE-2024-6387"]
get = lambda url: json.load(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "kata"})))

epss = {d["cve"]: d for d in get("https://api.first.org/data/v1/epss?cve=" + ",".join(CVES))["data"]}
kev = {v["cveID"]: v for v in get("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json")["vulnerabilities"]}

print(f"{'CVE':16} {'CVSS':>4}  {'EPSS':>8} {'pctl':>7}  KEV")
for c in CVES:
    nvd = get(f"https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={c}")["vulnerabilities"][0]["cve"]["metrics"]
    score = next(m["cvssData"]["baseScore"] for m in nvd["cvssMetricV31"] if m["type"] == "Primary")
    e = epss[c]
    print(f"{c:16} {score:>4}  {float(e['epss']):8.5f} {float(e['percentile']):7.5f}  {kev[c]['dateAdded'] if c in kev else 'no'}")
    time.sleep(1)
print("EPSS date:", epss[CVES[0]]["date"])
```

```text
CVE              CVSS      EPSS    pctl  KEV
CVE-2024-45491    9.8   0.01130 0.65306  no
CVE-2023-44487    7.5   0.99999 0.99998  2023-10-10
CVE-2024-6387     8.1   0.99506 0.99945  no
EPSS date: 2026-10-06
```

والترتيب الذي نقترحه:

1. **CVE-2023-44487 أولاً**: درجتها 7.5 فقط لأن أثرها على التوفر Availability، ولكنها في KEV منذ 2023-10-10 أي أنها استغلت فعلاً، وEPSS قريب من 1، وهي على سيرفر يواجه الإنترنت مباشرة، فحدث ال Reverse Proxy أو أوقف HTTP/2 مؤقتاً اليوم.
2. **CVE-2024-6387 ثانياً وخلال أيام**: درجتها 8.1، وEPSS فوق 0.99 مع أنها ليست في KEV حتى تاريخ الفحص، وهي في سيرفر SSH مفتوح، والتخفيف المؤقت أن تقصر المنفذ على عناوين الفريق أو VPN حتى يكتمل التحديث.
3. **CVE-2024-45491 أخيراً في دورة التحديث العادية**: درجتها الأعلى 9.8، ولكن [وصفها في NVD](https://nvd.nist.gov/vuln/detail/CVE-2024-45491?ref=arabroot.io) يقول إنها تحدث على منصات 32 بت فقط، وسيرفراتنا 64 بت، والمكتبة لا تقرأ إلا ملفاتنا، وEPSS نحو 1%، فهي غير قابلة للوصول Not Reachable في بيئتنا.

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

- الترتيب بالدرجة وحدها كان سيضع أقل النتائج خطراً في المقدمة ويؤخر الثغرة المستغلة فعلاً، وهذا أشهر خطأ في الفرز Triage.
- أرقام EPSS تتغير يومياً، والتي أمامك من يوم 2026-10-06، فاحفظ تاريخ القرار مع القرار.
- غياب الثغرة من KEV لا يعني أنها آمنة، وإنما أن CISA لم تؤكد استغلالها بعد، لذلك نقرأ الثلاثة معاً مع سياق بيئتك.

للتفصيل: [فرز النتائج Triage](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/#triage) في مقال DevSecOps، وفيه [CVSS](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/#cvss) و[EPSS](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/#epss) و[KEV](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/#kev) بالتفصيل.

### 13\. ملف تعريف الارتباط الذي ينفذ كوداً

خاصية «تذكرني» تحفظ تفضيلات المستخدم في ملف تعريف ارتباط Cookie بعد تحويلها بـ pickle. ماذا طبع السطر الثاني من المخرج، ومن اختار ما يطبعه؟

```python
# deser_vuln.py: the "remember me" cookie holds a pickled preferences object
import base64, pickle

class Prefs:
    def __init__(self, lang="ar", theme="dark"):
        self.lang, self.theme = lang, theme

def load_prefs(cookie):
    return pickle.loads(base64.b64decode(cookie))

# a harmless object of our own that shows the problem: unpickling CALLS what the data names
class Surprise:
    def __reduce__(self):
        return (print, ("code chosen by the cookie ran during loads()",))

good = base64.b64encode(pickle.dumps(Prefs())).decode()
evil = base64.b64encode(pickle.dumps(Surprise())).decode()
print(vars(load_prefs(good)))
print("result:", load_prefs(evil))
```

```text
{'lang': 'ar', 'theme': 'dark'}
code chosen by the cookie ran during loads()
result: None
```

الوقت المقترح: 20 دقيقة.

تلميح Hint

الكلاس `Surprise` لا يأتي من السيرفر، وإنما من البيانات نفسها، فمن يكتب الملف يختار الدالة التي يستدعيها `pickle.loads`. ونحن استخدمنا `print` لأنه غير ضار.

الحل Answer 

المشكلة فك تسلسل بيانات غير موثوقة Unsafe Deserialization، وهو [CWE-502](https://cwe.mitre.org/data/definitions/502.html?ref=arabroot.io) والبند A08 في OWASP Top 10\. والسبب أن صيغة pickle تحمل تعليمات لإعادة بناء الكائنات، ومنها استدعاء أي دالة تحددها البيانات، و[توثيق pickle](https://docs.python.org/3/library/pickle.html?ref=arabroot.io) يحذر في أوله من فك بيانات لا تثق بمصدرها. والمستخدم يملك ملف تعريف الارتباط بالكامل، فهو يختار ما ينفذه السيرفر. والإصلاح أن تخزن بيانات وليس كائنات، بصيغة JSON مع قائمة مسموحة للحقول والقيم:

```python
# deser_fixed.py: data, not objects: JSON plus an allowlist of fields and values
import base64, json
from dataclasses import dataclass

@dataclass(frozen=True)
class Prefs:
    lang: str = "ar"
    theme: str = "dark"

ALLOWED = {"lang": {"ar", "en"}, "theme": {"dark", "light"}}

def load_prefs(cookie):
    raw = json.loads(base64.b64decode(cookie))
    if not isinstance(raw, dict) or set(raw) - ALLOWED.keys():
        raise ValueError("unexpected fields")
    if any(raw[k] not in ALLOWED[k] for k in raw):
        raise ValueError("unexpected value")
    return Prefs(**raw)

print(load_prefs(base64.b64encode(json.dumps({"lang": "en"}).encode())))
for bad in [{"lang": "en", "is_admin": True}, {"theme": "<script>"}]:
    try:
        load_prefs(base64.b64encode(json.dumps(bad).encode()))
    except ValueError as e:
        print("rejected:", e)
```

```text
Prefs(lang='en', theme='dark')
rejected: unexpected fields
rejected: unexpected value
```

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

- JSON لا يستطيع أن يحمل إلا نصوصاً وأرقاماً وقوائم وقواميس، فلا توجد طريقة ليحدد ما يستدعى، والكلاس يبنيه الكود من قيم تحقق منها.
- الحقل الزائد `is_admin` رفض، وهذا يمنع أيضاً تعيين الخصائص الجماعي Mass Assignment.
- إذا كنت تحتاج أن لا يعدل المستخدم الملف فأضف توقيع HMAC، والتوقيع لا يجعل pickle آمناً إذا تسرب المفتاح يوماً.
- الأداة التي تكتشفه: Semgrep أبلغ عن ثلاثة أسطر بالقاعدة `avoid-pickle` في التمرين 16، ومثلها في Java و.NET قواعد لـ `ObjectInputStream` و`BinaryFormatter`.

للتفصيل: [فك التسلسل: البيانات لا تختار الكلاس](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#deserialization) في مقال البرمجة الآمنة.

### 14\. معاينة الروابط SSRF

ميزة «معاينة الرابط» تجلب الرابط الذي يكتبه المستخدم وتعرض أول سطر منه. وعلى نفس السيرفر خدمة داخلية على 127.0.0.1:21406 تمثل لوحة إدارة أو خدمة بيانات السحابة Metadata Service التي لا يصل إليها إلا السيرفر:

```python
# ssrf_vuln.py: "link preview": the server fetches any URL the user sends
import urllib.request

def preview(url):
    with urllib.request.urlopen(url, timeout=3) as r:
        return r.read(200).decode(errors="replace")

for url in ["https://example.com/", "http://127.0.0.1:21406/admin"]:
    print(url, "->", preview(url)[:60].replace("\n", " "))
```

```text
https://example.com/ -> <!doctype html><html lang=en><head><meta charset=utf-8><link
http://127.0.0.1:21406/admin -> {"internal": true, "db_password": "not-for-users"}
```

ما المشكلة؟ واكتب إصلاحاً بقائمة مسموحة Allowlist، ثم فكر: لماذا لا تكفي قائمة ممنوعة Blocklist فيها `127.0.0.1` و`localhost`؟

الوقت المقترح: 25 دقيقة.

تلميح Hint

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

الحل Answer 

المشكلة تزوير الطلب من جهة السيرفر Server-Side Request Forgery، أو SSRF، وهو [CWE-918](https://cwe.mitre.org/data/definitions/918.html?ref=arabroot.io) وAPI7 في قائمة أمن ال API. والسبب أن السيرفر جلب العنوان الداخلي وأعاد محتواه للمستخدم. والإصلاح:

```python
# ssrf_fixed.py: an allowlist of hosts, https only, the resolved address checked, no redirects
import ipaddress, socket, urllib.error, urllib.request
from urllib.parse import urlsplit

ALLOWED_HOSTS = {"example.com"}

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args):
        return None                                   # a redirect could lead to an internal address

def preview(url):
    u = urlsplit(url)
    if u.scheme != "https" or u.hostname not in ALLOWED_HOSTS or u.port not in (None, 443):
        raise PermissionError("host not allowed")
    for info in socket.getaddrinfo(u.hostname, 443, proto=socket.IPPROTO_TCP):
        if not ipaddress.ip_address(info[4][0]).is_global:
            raise PermissionError("resolves to a non-public address")
    # a DNS answer can change between this check and the request; the egress firewall closes that gap
    opener = urllib.request.build_opener(NoRedirect)
    with opener.open(url, timeout=3) as r:
        return r.read(200).decode(errors="replace")

for url in ["https://example.com/", "http://127.0.0.1:21406/admin", "http://localhost:21406/",
            "https://example.com@127.0.0.1:21406/", "http://2130706433:21406/", "file:///etc/hostname"]:
    try:
        print(url, "->", preview(url)[:60].replace("\n", " "))
    except PermissionError as e:
        print(url, "-> blocked:", e)
```

```text
https://example.com/ -> <!doctype html><html lang=en><head><meta charset=utf-8><link
http://127.0.0.1:21406/admin -> blocked: host not allowed
http://localhost:21406/ -> blocked: host not allowed
https://example.com@127.0.0.1:21406/ -> blocked: host not allowed
http://2130706433:21406/ -> blocked: host not allowed
file:///etc/hostname -> blocked: host not allowed
```

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

- كل العناوين الخمسة الأخيرة تشير إلى نفس السيرفر أو ملفاته بطرق كتابة مختلفة، ومنها الرقم العشري للعنوان 127.0.0.1، والقائمة المسموحة رفضتها كلها دون أن تحتاج أن تعرف هذه الطرق، أما القائمة الممنوعة فكانت ستحتاج أن تتذكر كل طريقة كتابة.
- التحقق من العنوان بعد ترجمة DNS يمنع اسماً مسموحاً يشير إلى عنوان داخلي، ومنع إعادة التوجيه يمنع رابطاً مسموحاً يحول إلى الداخل.
- بين التحقق والطلب يمكن أن تتغير إجابة DNS، لذلك الطبقة الأخيرة في الشبكة: جدار ناري للحركة الخارجة Egress يمنع السيرفر من الوصول إلى العناوين الداخلية وخدمة ال Metadata، كما يشرح [دليل OWASP لمنع SSRF](https://cheatsheetseries.owasp.org/cheatsheets/Server%5FSide%5FRequest%5FForgery%5FPrevention%5FCheat%5FSheet.html?ref=arabroot.io).
- الأداة التي تكتشفه: Semgrep أبلغ عن `urlopen` برابط متغير بالقاعدة `dynamic-urllib-use-detected`، وهي قاعدة تدقيق Audit تطلب منك أن تنظر وليست دليلاً على الثغرة، والاختبار الحقيقي اختبار وحدة بقائمة العناوين أعلاه.

للتفصيل: [API7 طلب السيرفر المزور SSRF](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api7)، والتجربة الكاملة في [مقال ثغرات الويب بالتجربة](https://arabroot.io/articles/%D8%AB%D8%BA%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%A8%D8%A7%D9%84%D8%AA%D8%AC%D8%B1%D8%A8%D8%A9-sql-injection-%D9%88-xss-%D9%88-ssrf/).

### 15\. نمذجة تهديدات STRIDE في 15 دقيقة

الفريق يبدأ الأسبوع القادم ميزة جديدة: تسجيل الدخول بالبريد وكلمة المرور، ثم رفع فاتورة بصيغة PDF أو صورة يراها المستخدم وفريق المحاسبة. المكونات: المتصفح، وال Reverse Proxy، وتطبيق الويب، وقاعدة بيانات المستخدمين والجلسات، وتخزين الكائنات Object Storage للملفات، وعامل خلفي يفحص الملفات، وسجل تدقيق. ارسم مخطط تدفق البيانات DFD على الورق، ثم اكتب تهديداً واحداً على الأقل لكل حرف من STRIDE مع الإجراء الذي يمنعه.

الوقت المقترح: 15 دقيقة.

تلميح Hint

STRIDE تعني انتحال الهوية Spoofing، والعبث Tampering، والإنكار Repudiation، وكشف المعلومات Information Disclosure، والحرمان من الخدمة Denial of Service، ورفع الصلاحيات Elevation of Privilege. ابدأ من كل سهم يعبر حد ثقة Trust Boundary.

الحل Answer 

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

![مخطط تدفق بيانات من اليسار إلى اليمين: المستخدم والمتصفح خارج حد الثقة الأول، ثم Reverse Proxy وتطبيق الويب، ثم بعد حد الثقة الثاني قاعدة المستخدمين والجلسات وتخزين الملفات وعامل الفحص وسجل التدقيق، وأسهم مرقمة من 1 إلى 6، ومربعات حمراء بحروف S عند المستخدم وT وD عند ال Reverse Proxy وI عند تخزين الملفات وE عند عامل الفحص وR عند سجل التدقيق](https://arabroot.io/content/images/2026/10/katas-security-03-stride.webp)

كل سهم يعبر خطاً متقطعاً هو مكان تسأل فيه أسئلة STRIDE

| الحرف | التهديد                                                                                  | الإجراء                                                                                                        | كيف تتحقق منه                           |
| ----- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| S     | دخول بكلمة مرور مسربة أو مخمنة، كما في التمرين 10                                        | MFA، وحد للمحاولات لكل حساب، وفحص كلمات المرور المسربة                                                         | اختبار يرسل 20 محاولة ويتوقع 429        |
| T     | ملف HTML أو SVG باسم invoice.pdf، أو اسم ملف فيه ../                                     | التحقق من النوع من محتوى الملف وليس من الامتداد، واسم تخزين يولده السيرفر                                      | اختبار وحدة بملف مزيف واسم فيه مسار     |
| R     | مستخدم ينكر أنه رفع الفاتورة أو حذفها                                                    | حدث في سجل التدقيق لكل رفع وحذف ودخول، بمعرف المستخدم والوقت والعنوان                                          | اختبار يتأكد من وجود الحدث بعد كل عملية |
| I     | تخزين ملفات عام يمكن قراءته بالرابط، أو IDOR في تنزيل الفاتورة كما في التمرين 7          | تخزين خاص وروابط موقعة قصيرة العمر، وفحص الملكية في كل تنزيل                                                   | اختبار صلاحيات، وفحص إعداد التخزين      |
| D     | ملف بحجم غيغابايت، أو آلاف طلبات الدخول                                                  | حد للحجم في ال Reverse Proxy والتطبيق، وحد للمعدل Rate Limit                                                   | اختبار بملف أكبر من الحد يتوقع 413      |
| E     | ملف يفتح في المتصفح من نفس النطاق فينفذ سكربتاً، أو عامل الفحص يشغل الملف بصلاحيات واسعة | تقديم الملفات من نطاق منفصل مع Content-Disposition: attachment، وعامل الفحص في Container دون شبكة ودون صلاحيات | مراجعة الإعداد، واختبار الترويسات       |

ولاحظ أن أغلب صفوف الجدول تنتهي باختبار، وهذا هو الفرق بين نمذجة تهديدات تبقى وثيقة في ملف، ونمذجة تتحول إلى متطلبات في المهام. وتعريف كل حرف كما في [توثيق Microsoft لأداة نمذجة التهديدات](https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats?ref=arabroot.io).

للتفصيل: [اسأل STRIDE](https://arabroot.io/articles/%D9%86%D9%85%D8%B0%D8%AC%D8%A9-%D8%A7%D9%84%D8%AA%D9%87%D8%AF%D9%8A%D8%AF%D8%A7%D8%AA-threat-modeling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#stride) و[ارسم مخطط تدفق البيانات](https://arabroot.io/articles/%D9%86%D9%85%D8%B0%D8%AC%D8%A9-%D8%A7%D9%84%D8%AA%D9%87%D8%AF%D9%8A%D8%AF%D8%A7%D8%AA-threat-modeling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#dfd) في مقال نمذجة التهديدات، و[رفع الملفات بأمان](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#uploads).

### 16\. ماذا سوف تجد أداة الفحص؟

جمعنا كل ملفات هذا المقال في مجلد واحد `code`، النسخ المعرضة للخطر Vulnerable والمصلحة Fixed معاً، وسوف نشغل عليها Semgrep بقواعد السجل العام `p/default` و`p/security-audit`، مع ثلاث قواعد كتبناها نحن، ثم Gitleaks. قبل أن تقرأ الحل: اكتب لكل تمرين من 1 إلى 14 هل سوف تجده الأدوات أم لا. وهذه قواعدنا:

```yaml
rules:
  - id: random-for-security-token
    languages: [python]
    severity: ERROR
    message: random is predictable; use the secrets module for tokens, keys and salts
    patterns:
      - pattern-inside: |
          def $FUNC(...):
            ...
      - metavariable-regex:
          metavariable: $FUNC
          regex: (?i).*(token|secret|password|key|salt).*
      - pattern-either:
          - pattern: random.$ANY(...)

  - id: path-join-with-parameter
    languages: [python]
    severity: WARNING
    message: a parameter reaches open() through os.path.join without a containment check
    patterns:
      - pattern-inside: |
          def $FUNC(..., $NAME, ...):
            ...
      - pattern: open(os.path.join($BASE, $NAME), ...)
      - pattern-not-inside: |
          if $P.is_relative_to(...):
            ...

  - id: reflected-html-without-encoding
    languages: [javascript]
    severity: ERROR
    message: a query parameter is written into HTML without encoding
    mode: taint
    pattern-sources:
      - pattern: $URL.searchParams.get(...)
    pattern-sanitizers:
      - pattern: escapeHtml(...)
    pattern-sinks:
      - pattern: $RES.end(...)
```

الوقت المقترح: 15 دقيقة.

تلميح Hint

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

الحل Answer 

```bash
docker run --rm -v "$PWD:/src" -w /src semgrep/semgrep:1.178.0 semgrep scan --metrics=off \
  --config p/default --config p/security-audit --config kata-rules.yml \
  --exclude idor.test.mjs --exclude internal_service.py --exclude crypto_cases.py --emacs -q code | cut -d: -f1-4
```

```text
code/deser_vuln.py:9:12:warning(avoid-pickle)
code/deser_vuln.py:16:25:warning(avoid-pickle)
code/deser_vuln.py:17:25:warning(avoid-pickle)
code/log_vuln.py:8:5:warning(python-logger-credential-disclosure)
code/path_vuln.py:7:10:warning(path-join-with-parameter)
code/sql_vuln.py:12:12:error(sqlalchemy-execute-raw-query)
code/ssrf_vuln.py:5:10:warning(dynamic-urllib-use-detected)
code/token_vuln.py:7:20:error(random-for-security-token)
code/xss_vuln.js:7:3:error(reflected-html-without-encoding)
```

ومعها نتيجة Gitleaks في التمرين 2\. والجدول التالي يجمع النتيجة لكل تمرين:

| التمرين        | من وجده؟                          | ما الذي يحميك فعلاً                      |
| -------------- | --------------------------------- | ---------------------------------------- |
| 1 حقن SQL      | Semgrep، قاعدة عامة               | المعاملات، والقاعدة في CI                |
| 2 سر في الكود  | Gitleaks                          | فحص الأسرار في Pre-commit وCI            |
| 3 XSS          | Semgrep، قاعدتنا                  | محرك قوالب يرمز تلقائياً، وCSP           |
| 4 توكن ضعيف    | Semgrep، قاعدتنا                  | قاعدة الفريق: secrets فقط                |
| 5 الترويسات    | لا تراها أداة كود                 | سكربت الترويسات بعد النشر                |
| 6 السجل        | Semgrep وجد الباسورد ولم ير الحقن | سجلات JSON، واختبار وحدة                 |
| 7 IDOR         | لم تجده أي أداة                   | اختبار صلاحيات لكل Endpoint              |
| 8 تجاوز المسار | Semgrep، قاعدتنا                  | المقارنة بعد resolve، واسم يولده السيرفر |
| 9 JWT          | لم تجده أي أداة                   | مكتبة معروفة، واختبار التوكن المعدل      |
| 13 pickle      | Semgrep، قاعدة عامة               | JSON مع قائمة مسموحة                     |
| 14 SSRF        | Semgrep، قاعدة تدقيق              | قائمة مسموحة، وجدار ناري للخروج          |

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

- القواعد العامة وحدها وجدت أربعة ملفات فقط من أحد عشر، وكانت قواعدنا الثلاث هي ما أضاف XSS والتوكن وتجاوز المسار، وأغلب قواعد السجل العام مكتوبة لأطر عمل Frameworks مثل Flask وExpress، وكودنا يستخدم المكتبات القياسية مباشرة.
- لم تبلغ الأدوات عن أي ملف مصلح، وهذا مهم بقدر أهمية الاكتشاف، فالأداة التي تبلغ عن الكود السليم يتجاهلها الفريق بعد أسبوع.
- القاعدة `sqlalchemy-execute-raw-query` وجدت حقن SQL مع أننا لا نستخدم SQLAlchemy، لأنها تبحث عن `execute` بنص مبني بالجمع، فاقرأ اسم القاعدة ورسالتها قبل أن تحكم على النتيجة.
- IDOR وJWT لم يجدهما أحد، وهما من أخطر ما في القائمة، والسبب أن الأداة لا تعرف من يملك الفاتورة ولا أن التوكن يجب أن يتحقق منه، وهذه هي [المناطق التي لا تراها أداة 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/#blind-spots)، ومكانها الاختبارات ومراجعة الكود.

للتفصيل: [كيف تعمل أداة 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/#how-it-works) في مقال الفحص الثابت، و[أين يعمل كل فحص في خط 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/#pipeline).

## جدول المتابعة

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

| #  | المستوى | التمرين                                                 | الوقت المقترح | تم / الوقت الفعلي |
| -- | ------- | ------------------------------------------------------- | ------------- | ----------------- |
| 1  | مبتدئ   | [البحث عن مستخدم بالاسم](#kata-security-1)              | 10 دقائق      | ☐                 |
| 2  | مبتدئ   | [إعدادات عميل الدفع](#kata-security-2)                  | 10 دقائق      | ☐                 |
| 3  | مبتدئ   | [صفحة نتائج البحث](#kata-security-3)                    | 10 دقائق      | ☐                 |
| 4  | مبتدئ   | [توكن إعادة تعيين الباسورد](#kata-security-4)           | 10 دقائق      | ☐                 |
| 5  | مبتدئ   | [ترويسات curl -I](#kata-security-5)                     | 10 دقائق      | ☐                 |
| 6  | مبتدئ   | [ماذا يكشف هذا السجل؟](#kata-security-6)                | 15 دقيقة      | ☐                 |
| 7  | متوسط   | [فاتورة من هذه؟](#kata-security-7)                      | 15 دقيقة      | ☐                 |
| 8  | متوسط   | [تنزيل الملفات المرفوعة](#kata-security-8)              | 15 دقيقة      | ☐                 |
| 9  | متوسط   | [JWT دون تحقق](#kata-security-9)                        | 20 دقيقة      | ☐                 |
| 10 | متوسط   | [سجل الدخول](#kata-security-10)                         | 15 دقيقة      | ☐                 |
| 11 | متوسط   | [اختيار التشفير](#kata-security-11)                     | 15 دقيقة      | ☐                 |
| 12 | متقدم   | [CVSS وEPSS وKEV](#kata-security-12)                    | 20 دقيقة      | ☐                 |
| 13 | متقدم   | [ملف تعريف الارتباط الذي ينفذ كوداً](#kata-security-13) | 20 دقيقة      | ☐                 |
| 14 | متقدم   | [معاينة الروابط SSRF](#kata-security-14)                | 25 دقيقة      | ☐                 |
| 15 | متقدم   | [STRIDE في 15 دقيقة](#kata-security-15)                 | 15 دقيقة      | ☐                 |
| 16 | متقدم   | [ماذا سوف تجد أداة الفحص؟](#kata-security-16)           | 15 دقيقة      | ☐                 |

## الخلاصة

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

- الجواب الكامل في أي مراجعة أمنية أربعة أجزاء: المشكلة، والسبب، والإصلاح بالكود، والأداة أو الاختبار الذي يمنع عودتها.
- أغلب الأخطاء في المستوى الأول تأتي من خلط البيانات بالكود: SQL مبني بالجمع، وHTML دون ترميز، وسطر جديد داخل السجل، والحل في كل مرة أن تبقى البيانات بيانات.
- المصادقة ليست الصلاحيات، والتوكن الذي لا يتحقق أحد من توقيعه مجرد نص يكتبه المستخدم.
- رتب الثغرات بالاستغلال الفعلي وقابلية الوصول في بيئتك، وليس بدرجة CVSS وحدها.
- أدوات الفحص وجدت أغلب الأنماط في التجربة ولم تجد IDOR ولا JWT، فاجمع بين الأداة والاختبار ومراجعة التصميم.

وبقية تمارين السلسلة في [مرحلة Kata في صفحة الخارطة](https://arabroot.io/start/#stage-katas)، والمقالات التي شرحنا فيها أساس هذه التمارين:

[![](https://arabroot.io/content/images/2026/10/katas-cover-cover.webp)دليللماذا تتمرن دون ذكاء اصطناعي؟ تمارين Kata لك ولفريقكما الذي تقوله الأبحاث عن تسليم التفكير للأدوات، وكيف يحفظ روتين صغير من التمارين تحلها بيدك مهارتك وحدك ومع فريقك.](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D8%AD%D8%A7%D9%81%D8%B8-%D8%B9%D9%84%D9%89-%D9%85%D9%87%D8%A7%D8%B1%D8%AA%D9%83-%D8%AF%D9%88%D9%86-%D8%B0%D9%83%D8%A7%D8%A1-%D8%A7%D8%B5%D8%B7%D9%86%D8%A7%D8%B9%D9%8A/)[![](https://arabroot.io/content/images/2026/10/owasp-top10-cover-cover.webp)دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.](https://arabroot.io/articles/owasp-top-10-%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/secure-coding-cover-cover.webp)دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%A7%D9%85%D9%86%D8%A9-secure-coding-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/web-vulns-lab-cover-cover.webp)دليلثغرات الويب بالتجربة: تطبيق نكسره ثم نصلحهنرى أثر أشهر ثغرات الويب على متجر صغير على جهازك، ثم السطر المسؤول عن كل ثغرة، ثم الإصلاح والاختبار الذي يثبته.](https://arabroot.io/articles/%D8%AB%D8%BA%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%88%D9%8A%D8%A8-%D8%A8%D8%A7%D9%84%D8%AA%D8%AC%D8%B1%D8%A8%D8%A9-sql-injection-%D9%88-xss-%D9%88-ssrf/)[![](https://arabroot.io/content/images/2026/10/api-security-cover-cover.webp)دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/)[![](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/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/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/threat-modeling-cover-cover.webp)دليلنمذجة التهديدات Threat Modeling للمبتدئينكيف تسأل ما الذي قد يسوء في تصميم تطبيقك قبل أن تكتب الكود، وتقرر لكل تهديد هل تصلحه أو تقبله، مع قالب جاهز للنسخ.](https://arabroot.io/articles/%D9%86%D9%85%D8%B0%D8%AC%D8%A9-%D8%A7%D9%84%D8%AA%D9%87%D8%AF%D9%8A%D8%AF%D8%A7%D8%AA-threat-modeling-%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/crypto-for-devs-cover-cover.webp)دليلالتشفير للمطورين: ماذا تستخدم ولماذا لا تخترع خوارزميتكأي هاش تستخدم لكلمات المرور، ومتى تحتاج التشفير المتماثل أو غير المتماثل، وأين تحفظ المفاتيح بأمان في تطبيقك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86-%D8%A7%D9%84%D9%87%D8%A7%D8%B4-%D9%88%D9%83%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B1%D9%88%D8%B1-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD/)

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

- أكتوبر 2026: كتابة المقال، وتشغيل كل التمارين على Windows 11 باستخدام Python 3.13.3 وNode.js 22.14.0، وداخل Docker باستخدام python:3.13-slim (Python 3.13.16) وnginx:1.29-alpine (Nginx 1.29.8) وSemgrep 1.178.0 وGitleaks 8.30.1، وبيانات EPSS بتاريخ 2026-10-06 وقائمة KEV بإصدار 2026.10.04.