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

وتحتاج لتشغيل الحلول 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) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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: <script>alert(1)</script></h1><p>No results.</p>
في الإصلاح أعلاه لاحظ التالي:
- النسخة الأولى أعادت الوسم كما هو، والثانية حولته إلى
<script>فيعرضه المتصفح نصاً ولا ينفذه. - الترميز يعتمد على السياق: داخل 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
والصورة التالية تبين الأشكال الثلاثة:

- الساعة 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، واذكر الخوارزمية:
- تخزين كلمات مرور المستخدمين.
- تخزين مفاتيح API التي يصدرها نظامك للعملاء، بحيث تتحقق منها عند كل طلب.
- التأكد أن طلب Webhook جاء فعلاً من مزود الدفع الذي يشاركك سراً.
- تخزين أرقام الهوية الوطنية للعملاء، وتحتاج قراءتها لاحقاً.
- ملف إصدار ينزله العملاء ويريدون التأكد أنه منك ولم يعدل.
الوقت المقترح: 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
أرسل لك فحص الاعتماديات والسيرفرات ثلاث نتائج، ولديك وقت لإصلاح نتيجة واحدة هذا اليوم. رتبها، واذكر سبب كل ترتيب:
- CVE-2024-45491 في مكتبة libexpat داخل Docker Image لمهمة داخلية تعمل على سيرفرات amd64 بنظام 64 بت، وتقرأ ملف XML من إعداداتنا فقط.
- CVE-2023-44487 المعروفة باسم HTTP/2 Rapid Reset في ال Reverse Proxy الذي يستقبل حركة الإنترنت، وHTTP/2 مفعل.
- 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
والترتيب الذي نقترحه:
- CVE-2023-44487 أولاً: درجتها 7.5 فقط لأن أثرها على التوفر Availability، ولكنها في KEV منذ 2023-10-10 أي أنها استغلت فعلاً، وEPSS قريب من 1، وهي على سيرفر يواجه الإنترنت مباشرة، فحدث ال Reverse Proxy أو أوقف HTTP/2 مؤقتاً اليوم.
- CVE-2024-6387 ثانياً وخلال أيام: درجتها 8.1، وEPSS فوق 0.99 مع أنها ليست في KEV حتى تاريخ الفحص، وهي في سيرفر SSH مفتوح، والتخفيف المؤقت أن تقصر المنفذ على عناوين الفريق أو VPN حتى يكتمل التحديث.
- 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
هذا تمرين تصميم، فلا يوجد كود نشغله، والجواب مخطط وجدول. والصورة التالية تبين المخطط مع مكان كل حرف:

| الحرف | التهديد | الإجراء | كيف تتحقق منه |
|---|---|---|---|
| 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 لأداة نمذجة التهديدات.
للتفصيل: اسأل 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 حقن 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، ومكانها الاختبارات ومراجعة الكود.
للتفصيل: كيف تعمل أداة SAST من الداخل في مقال الفحص الثابت، وأين يعمل كل فحص في خط CI/CD.
جدول المتابعة
انسخ هذا الجدول وسجل فيه تقدمك، وبعد شهر أعد التمارين التي احتجت فيها الحل، وفي المرة الثانية اكتب الأجزاء الأربعة كاملة: المشكلة، والسبب، والإصلاح، والأداة:
| # | المستوى | التمرين | الوقت المقترح | تم / الوقت الفعلي |
|---|---|---|---|---|
| 1 | مبتدئ | البحث عن مستخدم بالاسم | 10 دقائق | ☐ |
| 2 | مبتدئ | إعدادات عميل الدفع | 10 دقائق | ☐ |
| 3 | مبتدئ | صفحة نتائج البحث | 10 دقائق | ☐ |
| 4 | مبتدئ | توكن إعادة تعيين الباسورد | 10 دقائق | ☐ |
| 5 | مبتدئ | ترويسات curl -I | 10 دقائق | ☐ |
| 6 | مبتدئ | ماذا يكشف هذا السجل؟ | 15 دقيقة | ☐ |
| 7 | متوسط | فاتورة من هذه؟ | 15 دقيقة | ☐ |
| 8 | متوسط | تنزيل الملفات المرفوعة | 15 دقيقة | ☐ |
| 9 | متوسط | JWT دون تحقق | 20 دقيقة | ☐ |
| 10 | متوسط | سجل الدخول | 15 دقيقة | ☐ |
| 11 | متوسط | اختيار التشفير | 15 دقيقة | ☐ |
| 12 | متقدم | CVSS وEPSS وKEV | 20 دقيقة | ☐ |
| 13 | متقدم | ملف تعريف الارتباط الذي ينفذ كوداً | 20 دقيقة | ☐ |
| 14 | متقدم | معاينة الروابط SSRF | 25 دقيقة | ☐ |
| 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.