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

تمارين Kata في الأمن بثلاثة مستويات مع الحلول

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

تمارين Kata في الأمن بثلاثة مستويات مع الحلول

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

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

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

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

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

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

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

ثلاث درجات متصاعدة، الأولى خضراء للمبتدئ وفيها التمارين من 1 إلى 6، والثانية برتقالية للمتوسط وفيها التمارين من 7 إلى 11، والثالثة بنفسجية داكنة للمتقدم وفيها التمارين من 12 إلى 16، وبجانب كل رقم اسم التمرين بالإنجليزية
ستة عشر تمريناً تبدأ من سطر واحد في الكود وتنتهي بقرارات يتخذها الفريق

وتحتاج لتشغيل الحلول Python 3.13 وNode.js 22 وDocker، أما الأدوات فهي Semgrep وGitleaks من خلال Docker Images الرسمية، وكل منفذ في التمارين على العنوان 127.0.0.1 فقط حتى لا يصل إليه أحد من الشبكة. وقبل أن تبدأ يفيدك أن تعرف قائمة OWASP Top 10، لأن أغلب التمارين مثال صغير على أحد بنودها.

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

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

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

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

# 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', '[email protected]'), ('sara', '[email protected]');
""")

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"))
[(1, '[email protected]')]
[(1, '[email protected]'), (2, '[email protected]')]

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

تلميح Hint

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

الحل Answer

المشكلة حقن SQL أي SQL Injection، وهو البند A05 في OWASP Top 10 وCWE-89. والسبب أن الاسم أضيف إلى نص الاستعلام بجمع النصوص، فأصبح الاستعلام WHERE name = 'nobody' OR '1'='1'، والشرط الثاني صحيح دائماً، فقاعدة البيانات لا تعرف أين انتهت البيانات وبدأ الكود. والإصلاح أن ترسل القيمة منفصلة عن نص الاستعلام بالمعاملات Parameterized Queries:

# 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', '[email protected]'), ('sara', '[email protected]');
""")

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"))
[(1, '[email protected]')]
[]

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

  • علامة ? ليست استبدالاً للنص داخل Python، وإنما مكان تملؤه قاعدة البيانات بالقيمة بعد أن تحلل الاستعلام، فالاسم الغريب بقي اسماً لم يطابق أحداً.
  • تنظيف المدخل بحذف علامة التنصيص ليس إصلاحاً، لأن كل قاعدة بيانات لها طرق كتابة مختلفة، والمعاملات تعمل في كل الحالات كما يشرح دليل OWASP لمنع حقن SQL.
  • الأداة التي تكتشفه: Semgrep أبلغ عن السطر 12 بالقاعدة sqlalchemy-execute-raw-query كما سترى في التمرين 16، ولم يبلغ عن الملف المصلح.

للتفصيل: الاستعلامات ذات المعاملات في مقال البرمجة الآمنة، وتجربة الحقن كاملة في مقال ثغرات الويب بالتجربة.

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

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

# 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. والسبب أن المفتاح أصبح متاحاً لكل من يقرأ المستودع أو نسخه أو ال Docker Image المبنية منه أو سجل خط CI، والحذف في التزام جديد لا يحذفه من التاريخ History. والإصلاح أن يقرأ المفتاح من البيئة أو من خزنة أسرار، وأن يفشل التطبيق عند التشغيل إذا لم يجده:

# 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 على مجلد التمارين كاملاً:

docker run --rm -v "$PWD:/src" ghcr.io/gitleaks/gitleaks:latest dir /src --no-banner --no-color --redact=60 -v
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.

للتفصيل: ماذا تفعل عندما يتسرب مفتاح؟ في مقال اكتشاف الأسرار المسربة.

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

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

// 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");
http://127.0.0.1:21400/?q=%3Cscript%3Ealert(1)%3C/script%3E

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

تلميح Hint

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

الحل Answer

المشكلة XSS منعكس Reflected XSS، وهو CWE-79. والسبب أن القيمة من الرابط دخلت الصفحة كما هي، فأصبح الوسم <script> جزءاً من الصفحة، وأي سكربت فيها يعمل بصلاحيات المستخدم على موقعك. والإصلاح ترميز المخرج حسب السياق Output Encoding، وهنا السياق HTML، مع سياسة CSP كطبقة ثانية:

// 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");
$ 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.
  • الأداة التي تكتشفه: قاعدة Semgrep من نوع Taint كتبناها في التمرين 16 تتبع القيمة من searchParams.get إلى res.end، والاختبار بالأمر curl أعلاه يصلح اختبار انحدار Regression Test.

للتفصيل: ترميز المخرجات حسب السياق في مقال البرمجة الآمنة، وContent-Security-Policy في مقال ترويسات الأمان.

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

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

# 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. والسبب أن random يستخدم Mersenne Twister، وهو مولد حتمي من يعرف حالته الداخلية يعرف كل ما سيخرجه، وتوثيق random يقول صراحة إنه لا يصلح للأغراض الأمنية ويحيلك إلى secrets. والإصلاح:

# 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())
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 في مقال التشفير للمطورين، وفيه تجربة توقع المخرج التالي بعد 624 قيمة.

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

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

$ 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:

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;
}
$ 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 بعد كل نشر ويفشل إذا نقصت ترويسة:

#!/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
$ 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.
  • بقيت الكلمة nginx دون رقم بعد server_tokens off، وهذا مقبول، فالمهم أن لا يظهر الإصدار.

للتفصيل: المجموعة الأساسية والمجموعة الأشد وكيف تختبر الترويسات في مقال ترويسات الأمان.

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

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

# 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")
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، والسبب أن السجل يقرؤه أشخاص وأنظمة أكثر بكثير من قاعدة البيانات، ويحفظ مدة أطول، وكلمة المرور الفاشلة كثيراً ما تكون قريبة من الصحيحة. والمشكلة الثانية حقن السجلات Log Injection وهو CWE-117: اسم المستخدم فيه سطر جديد، فصنع سطراً مزوراً يقول إن admin دخل بنجاح، ومن يقرأ السجل أو أداة التنبيه لا تعرف أنه مزور. والإصلاح سجل منظم Structured Log بصيغة JSON، فالمرمز يحول السطر الجديد إلى \n داخل القيمة، ولا توجد كلمة مرور:

# 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")
{"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 ويتأكد أن المخرج سطر واحد.

للتفصيل: ما لا يدخل السجل أبداً وحقن السجلات في مقال السجلات.

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

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

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

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

// 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 والبند A01 في OWASP Top 10. والسبب أن الكود تحقق أن المستخدم مسجل دخوله، ولم يتحقق أن الفاتورة له، فمن يملك أي توكن يقرأ كل الفواتير بتغيير الرقم. والإصلاح هو الشرط inv.owner !== user الموجود في الملف ويعمل في وضع fixed، وهذا تشغيل النسختين على المنفذين 21402 و21403:

$ 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

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

// 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));
MODE=vuln node --test idor.test.mjs
MODE=fixed node --test idor.test.mjs
ok 1 - alice reads her own invoice
not ok 2 - alice cannot read bob's invoice
# pass 1
# fail 1
ok 1 - alice reads her own invoice
ok 2 - alice cannot read bob's invoice
# pass 2
# fail 0

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

  • رجعنا 404 وليس 403، حتى لا يعرف من يجرب الأرقام أي الفواتير موجودة، كما يوصي دليل OWASP لمنع IDOR.
  • تحويل الأرقام إلى UUID يصعب التخمين ولا يصلح المشكلة، فالرقم الذي يتسرب في رابط أو بريد يفتح الفاتورة لمن يملكه.
  • لم تبلغ أي أداة فحص ثابت SAST عن هذا الملف في التمرين 16، والسبب أن الأداة لا تعرف قاعدة العمل «الفاتورة لصاحبها»، لذلك اختبار الصلاحيات لكل Endpoint هو الحماية الحقيقية.

للتفصيل: API1 تفويض مستوى الكائن BOLA في مقال أمن واجهات API، وA01 التحكم في الوصول المكسور.

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

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

# 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. والسبب أن ../ في الاسم خرج من المجلد إلى المجلد الأب، فقرأت الدالة ملف الإعدادات. والإصلاح:

# 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 كما في السيرفر:

docker run --rm -v "$PWD:/lab" -w /lab/code python:3.13-slim sh -c 'python path_vuln.py; echo ----; python path_fixed.py'
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 يقارن المسار النهائي بالمجلد، فرفض المسار النسبي والمسار الذي يدخل مجلداً ثم يخرج منه والمسار المطلق.
  • البحث عن .. في النص ورفضه حل بسيط ينكسر مع الترميز المزدوج والمسار المطلق، أما المقارنة بعد الحل فلا تهتم بكيف كتب المسار.
  • الأفضل من كل ذلك أن لا يأتي اسم الملف من المستخدم أصلاً: احفظ الملف باسم تولده أنت مثل UUID، واحفظ الاسم الأصلي في قاعدة البيانات للعرض فقط.
  • الأداة التي تكتشفه: قاعدة Semgrep كتبناها في التمرين 16 للنمط open(os.path.join(base, param))، واختبار وحدة بقائمة الأسماء الثلاثة أعلاه.

للتفصيل: تجاوز المسار والتعامل الآمن مع الملفات في مقال البرمجة الآمنة.

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

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

// 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
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. والسبب أن getUserVuln تفك الجزء الأوسط وتثق به، فكل من يعدل كلمة user إلى admin أصبح مديراً. والإصلاح في getUser: تحديد الخوارزمية مسبقاً وعدم قبول ما يقوله رأس التوكن، ثم حساب HMAC ومقارنته في زمن ثابت، ثم التحقق من انتهاء الصلاحية exp.

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

  • التوكن المعدل رفض لأن التوقيع لم يعد يطابق المحتوى، وتوكن alg: none رفض لأن جزء التوقيع فارغ، ولو وصل إلى فحص الخوارزمية لرفض هناك أيضاً.
  • timingSafeEqual بدلاً من المقارنة العادية حتى لا يكشف زمن المقارنة عدد البايتات الصحيحة.
  • في مشروع حقيقي لا تكتب هذا بيدك، بل استخدم مكتبة معروفة مع تحديد الخوارزميات المسموحة صراحة، كما يوصي RFC 8725 لأفضل ممارسات JWT، وكتبناه هنا حتى ترى ما تفعله المكتبة من الداخل.
  • الاختبار الذي يكتشفه اختبار وحدة يعدل الجزء الأوسط ويتوقع الرفض، وهو ما يطبعه الملف نفسه، أما أدوات SAST فتبلغ عادة عن استدعاء دالة decode في مكتبة معروفة دون verify، ولم تبلغ عن كودنا المكتوب يدوياً.

للتفصيل: أخطاء تكررت مع JWT في مقال الجلسات وJWT.

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

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

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 [email protected] ip=203.0.113.15
2026-10-07T03:00:02Z login_failed [email protected] ip=203.0.113.15
2026-10-07T03:00:06Z login_failed [email protected] ip=203.0.113.7
2026-10-07T03:00:12Z login_failed [email protected] ip=203.0.113.58
2026-10-07T03:00:18Z login_failed [email protected] ip=203.0.113.38
2026-10-07T03:00:24Z login_failed [email protected] ip=203.0.113.2
2026-10-07T03:01:12Z login_ok     [email protected] ip=203.0.113.45
2026-10-07T08:55:00Z login_failed [email protected] ip=192.0.2.10
2026-10-07T08:55:20Z login_failed [email protected] ip=192.0.2.10
2026-10-07T08:55:55Z login_ok     [email protected] ip=192.0.2.10

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

# 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]}")
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 ['[email protected]', '[email protected]', '[email protected]', '[email protected]']
2026-10-07T08      3     2    1     1       2.0  1 ['[email protected]']

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

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

تلميح Hint

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

الحل Answer

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

ثلاثة أعمدة: في الأول جهاز واحد بعنوان 198.51.100.23 وسهم أحمر عليه 180 مرة إلى حساب admin دون نجاح، وفي الثاني اثنا عشر جهازاً تمثل 60 عنواناً بخطوط متشابكة إلى صفوف من المستخدمين تمثل 300 حساب منها أربعة بالأحمر نجحت، وفي الثالث جهاز واحد إلى حساب sara بمحاولتين فاشلتين ثم نجاح
الهجوم الأخطر هو الأهدأ: محاولة واحدة لكل حساب، وأربع منها نجحت
  • الساعة 02: عنوان واحد ضد حساب واحد 180 مرة خلال أقل من خمس دقائق ودون نجاح، وهذا تخمين Brute Force كما يصفه MITRE ATT&CK T1110.001. والإجراء: إبطاء أو حظر مؤقت للعنوان، وحد للمحاولات على الحساب، والتأكد أن admin عليه مصادقة ثنائية MFA.
  • الساعة 03: ستون عنواناً ضد 300 حساب مختلف بنحو 1.2 محاولة لكل حساب، ونجحت أربع محاولات من المرة الأولى، وهذا حشو بيانات الدخول Credential Stuffing كما في T1110.004: قوائم أسماء وكلمات مرور مسربة من مواقع أخرى. والنجاحات الأربعة هي الحادثة الحقيقية: أوقف جلسات هذه الحسابات وأجبرها على تغيير الباسورد وراجع ما فعلته بعد الدخول، ثم أضف حماية لا تعتمد على العنوان كما يشرح دليل OWASP لمنع حشو بيانات الدخول.
  • الساعة 08: عنوان واحد معروف، محاولتان فاشلتان ثم نجاح خلال دقيقة، وهذا شخص نسي كلمة المرور، ولا يحتاج أي إجراء.

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

للتفصيل: ماذا تسجل في كل حدث في مقال السجلات، وماذا تسجل في سجل التدقيق، و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:

# 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")
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 لكل حالة جديدة، فالأداة لا تعرف أن هذا الحقل كلمة مرور وذاك مفتاح عشوائي.

للتفصيل: جدول القرار وتخزين كلمات المرور في مقال التشفير للمطورين.

المستوى الثالث: متقدم 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، وEPSS من واجهة FIRST، وقائمة KEV من CISA:

# 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"])
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 يقول إنها تحدث على منصات 32 بت فقط، وسيرفراتنا 64 بت، والمكتبة لا تقرأ إلا ملفاتنا، وEPSS نحو 1%، فهي غير قابلة للوصول Not Reachable في بيئتنا.

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

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

للتفصيل: فرز النتائج Triage في مقال DevSecOps، وفيه CVSS وEPSS وKEV بالتفصيل.

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

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

# 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))
{'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 والبند A08 في OWASP Top 10. والسبب أن صيغة pickle تحمل تعليمات لإعادة بناء الكائنات، ومنها استدعاء أي دالة تحددها البيانات، وتوثيق pickle يحذر في أوله من فك بيانات لا تثق بمصدرها. والمستخدم يملك ملف تعريف الارتباط بالكامل، فهو يختار ما ينفذه السيرفر. والإصلاح أن تخزن بيانات وليس كائنات، بصيغة JSON مع قائمة مسموحة للحقول والقيم:

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

للتفصيل: فك التسلسل: البيانات لا تختار الكلاس في مقال البرمجة الآمنة.

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

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

# 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", " "))
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 وAPI7 في قائمة أمن ال API. والسبب أن السيرفر جلب العنوان الداخلي وأعاد محتواه للمستخدم. والإصلاح:

# 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://[email protected]:21406/", "http://2130706433:21406/", "file:///etc/hostname"]:
    try:
        print(url, "->", preview(url)[:60].replace("\n", " "))
    except PermissionError as e:
        print(url, "-> blocked:", e)
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://[email protected]: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.
  • الأداة التي تكتشفه: Semgrep أبلغ عن urlopen برابط متغير بالقاعدة dynamic-urllib-use-detected، وهي قاعدة تدقيق Audit تطلب منك أن تنظر وليست دليلاً على الثغرة، والاختبار الحقيقي اختبار وحدة بقائمة العناوين أعلاه.

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

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

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

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

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

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
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
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 حقن SQLSemgrep، قاعدة عامةالمعاملات، والقاعدة في CI
2 سر في الكودGitleaksفحص الأسرار في Pre-commit وCI
3 XSSSemgrep، قاعدتنامحرك قوالب يرمز تلقائياً، وCSP
4 توكن ضعيفSemgrep، قاعدتناقاعدة الفريق: secrets فقط
5 الترويساتلا تراها أداة كودسكربت الترويسات بعد النشر
6 السجلSemgrep وجد الباسورد ولم ير الحقنسجلات JSON، واختبار وحدة
7 IDORلم تجده أي أداةاختبار صلاحيات لكل Endpoint
8 تجاوز المسارSemgrep، قاعدتناالمقارنة بعد resolve، واسم يولده السيرفر
9 JWTلم تجده أي أداةمكتبة معروفة، واختبار التوكن المعدل
13 pickleSemgrep، قاعدة عامةJSON مع قائمة مسموحة
14 SSRFSemgrep، قاعدة تدقيققائمة مسموحة، وجدار ناري للخروج

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

  • القواعد العامة وحدها وجدت أربعة ملفات فقط من أحد عشر، وكانت قواعدنا الثلاث هي ما أضاف XSS والتوكن وتجاوز المسار، وأغلب قواعد السجل العام مكتوبة لأطر عمل Frameworks مثل Flask وExpress، وكودنا يستخدم المكتبات القياسية مباشرة.
  • لم تبلغ الأدوات عن أي ملف مصلح، وهذا مهم بقدر أهمية الاكتشاف، فالأداة التي تبلغ عن الكود السليم يتجاهلها الفريق بعد أسبوع.
  • القاعدة sqlalchemy-execute-raw-query وجدت حقن SQL مع أننا لا نستخدم SQLAlchemy، لأنها تبحث عن execute بنص مبني بالجمع، فاقرأ اسم القاعدة ورسالتها قبل أن تحكم على النتيجة.
  • IDOR وJWT لم يجدهما أحد، وهما من أخطر ما في القائمة، والسبب أن الأداة لا تعرف من يملك الفاتورة ولا أن التوكن يجب أن يتحقق منه، وهذه هي المناطق التي لا تراها أداة SAST، ومكانها الاختبارات ومراجعة الكود.

للتفصيل: كيف تعمل أداة SAST من الداخل في مقال الفحص الثابت، وأين يعمل كل فحص في خط CI/CD.

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

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

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

الخلاصة

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

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

وبقية تمارين السلسلة في مرحلة Kata في صفحة الخارطة، والمقالات التي شرحنا فيها أساس هذه التمارين:

دليللماذا تتمرن دون ذكاء اصطناعي؟ تمارين Kata لك ولفريقكما الذي تقوله الأبحاث عن تسليم التفكير للأدوات، وكيف يحفظ روتين صغير من التمارين تحلها بيدك مهارتك وحدك ومع فريقك.دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.دليلالبرمجة الآمنة Secure Coding بأمثلة من أربع لغاتكيف تكتب الكود الذي يتعامل مع مدخلات المستخدم والملفات والأخطاء بحيث لا يحتاج إصلاحاً أمنياً لاحقاً، بأمثلة في أربع لغات.دليلثغرات الويب بالتجربة: تطبيق نكسره ثم نصلحهنرى أثر أشهر ثغرات الويب على متجر صغير على جهازك، ثم السطر المسؤول عن كل ثغرة، ثم الإصلاح والاختبار الذي يثبته.دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.دليلSAST: كيف يجد تحليل الكود الثابت الثغرات قبل التشغيلكيف تقرأ أداة SAST الكود بحثاً عن الثغرات، وما الذي لا تراه أبداً، وكيف تمنع الإنذارات الخاطئة من أن تفقد الفريق ثقته بها.دليلSecrets Scanning: كيف تكتشف الأسرار المسربة في الكودكيف يخرج المفتاح من جهازك إلى تاريخ Git وخط CI، وكيف تكتشفه قبل غيرك، وماذا تفعل في الساعة الأولى بعد تسربه.دليلDevSecOps للمبتدئين: الأمن داخل خط CI/CD ومتى يعمل كل فحصما الذي يجده كل فحص أمني في خط CI/CD وما الذي يفوته، وكيف ترتب نتائجه حتى تصل إلى قرار بدل قائمة طويلة.دليلنمذجة التهديدات Threat Modeling للمبتدئينكيف تسأل ما الذي قد يسوء في تصميم تطبيقك قبل أن تكتب الكود، وتقرر لكل تهديد هل تصلحه أو تقبله، مع قالب جاهز للنسخ.دليلالتشفير للمطورين: ماذا تستخدم ولماذا لا تخترع خوارزميتكأي هاش تستخدم لكلمات المرور، ومتى تحتاج التشفير المتماثل أو غير المتماثل، وأين تحفظ المفاتيح بأمان في تطبيقك.

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

  • أكتوبر 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.
نشرة عرب رووت | ArabRoot

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

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

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

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