يتصل بك أحد العملاء يوم الأحد ليسأل لماذا تغير بريده في حسابه على متجرك، وهو لم يغيره. فتفتح السيرفر لتعرف من دخل إلى لوحة الإدارة يوم الخميس، فتجد أن سجل الوصول Access Log يدور كل سبعة أيام فبقي منه يومان، وأن التطبيق لا يكتب أصلاً من غير بيانات العميل، وأن سجل SSH موجود ولكن وقته بتوقيت السيرفر الذي لا تعرف هل هو UTC أم بتوقيت السعودية. وفي هذه اللحظة تكتشف أن السؤال الذي كان يجب أن تسأله قبل شهور ليس «هل نجمع السجلات؟» وإنما «هل نجمع السجلات التي تجيب عن السؤال الذي سيسأله أحد يوماً ما؟».
وهذا ليس موقفاً يخص الشركات الصغيرة. ففي يونيو 2023 اكتشفت جهة حكومية أمريكية دخولاً غريباً إلى صناديق بريد موظفيها في Microsoft 365، ولم تكتشفه بأداة جديدة، وإنما بنوع واحد من السجلات اسمه MailItemsAccessed يسجل كل وصول إلى رسالة في البريد، كما يروي تحذير CISA وFBI المشترك AA23-193A. وكان هذا السجل في ذلك الوقت يحتاج ترخيص G5 أو E5 لتسجيل التدقيق المتقدم Purview Audit (Premium)، أي أن جهة لديها نفس الأنظمة بترخيص أقل ما كانت لترى الاختراق نفسه. والدرس الذي ختم به التحذير بسيط: فعل السجلات التي تكشف هذا النوع من النشاط قبل أن تحتاجها.
وقد شرحنا في مقال مركز العمليات الأمنية أن أول خطوة في دائرة المركز هي الجمع، وفي هذا المقال نفتحها بالتفصيل: ماذا تجمع، ومن أين، وبأي شكل، وكم تحتفظ، ثم نبني مختبراً صغيراً يجمع سجلات ثلاثة مصادر بأداة Vector، ونرى بأعيننا ثلاثة أخطاء لا تطلق أي تحذير: وقت بلا منطقة زمنية، وسطر بلا هوية، ومصدر يسكت.
دليلمركز العمليات الأمنية SOC: كيف يعمل ومن يعمل فيه؟ما الذي يفعله مركز العمليات الأمنية فعلاً، وكم محللاً يحتاج مقعد واحد على مدار الساعة، وأي نموذج تشغيل يناسب شركتك.
دليلمراقبة السيرفرات مع Wazuh: من السجل Log إلى حظر المهاجمشغلنا Wazuh مع وكيل على سيرفر تجريبي، فكشف تخمين كلمات مرور SSH وحظر المهاجم تلقائياً، ونبه على ملف PHP دخيل خلال ثانية.
دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.وسوف نناقش في هذا المقال ما يلي:
- لماذا لا تكفي «كل السجلات»، وكيف ترتب المصادر بحسب الأسئلة التي ستسألها.
- من syslog إلى السجل المنظم Structured Logging، ولماذا يتعطل التحليل بالتعبيرات النمطية بصمت.
- خمس طبقات للسجلات: الهوية والسيرفرات والشبكة والتطبيقات والسحابة، وما الذي يجب أن تراه في كل واحدة.
- شكل السطر المفيد: ستة أسئلة يجب أن يجيب عنها، والحد الأدنى الذي تطلبه مذكرة OMB M-21-31.
- الوقت: لماذا UTC، ولماذا المللي ثانية، وماذا يحدث عندما ينسى تطبيق واحد المنطقة الزمنية.
- تجربة عملية: nginx وتطبيق صغير وsshd، ومجمع Vector يوحد الحقول ويكتبها في ملف يومي.
- تجارب «ماذا لو»: وقت محلي بلا إزاحة، وحذف السجل من المصدر، ومصدر يسكت.
- خط الأساس Baseline قبل كتابة القواعد.
- الاحتفاظ Retention والحجم، وما تطلبه الضوابط الأساسية ECC، وحماية السجل نفسه.
- أخطاء شائعة وقائمة تحقق.
لماذا لا تكفي «اجمع كل شيء»؟
الإجابة الأولى التي يعطيها أغلب الفرق عند سؤال «ماذا نجمع؟» هي «كل شيء»، وهي إجابة تفشل لثلاثة أسباب. الأول أن «كل شيء» يعني في الواقع «كل ما هو سهل»، فتصل سجلات الوصول إلى الموقع وسجلات الجدار الناري بآلاف الأسطر في الدقيقة، لأن ربطها لا يحتاج إلا سطر إعداد، بينما يغيب سجل نظام الهوية لأنه يحتاج ترخيصاً أو صلاحية، ويغيب سجل التطبيق لأن أحداً لم يكتبه. والثاني أن الحجم يتحول إلى فاتورة، والفاتورة تتحول إلى قرار بتقليل مدة الاحتفاظ، فتخسر الأشهر التي ستحتاجها في التحقيق. والثالث أن السجل الذي لا يقرؤه أحد ولا تكتب عليه قاعدة لا يكشف شيئاً، وإنما ينتظر التحقيق بعد الحادثة، وهذا مفيد ولكنه ليس كشفاً.
والطريقة الصحيحة تبدأ من الاتجاه المعاكس: ما الأسئلة التي ستسألها عند حادثة؟ من دخل إلى أي حساب، ومن أين، ومتى؟ من غير صلاحية من؟ أي عملية بدأت على السيرفر، ومن بدأها؟ أي اتصال خرج إلى الإنترنت، وكم حمل من البيانات؟ ومن قرأ أو صدر بيانات العملاء؟ ولكل سؤال مصدر يجيب عنه، ولكل مصدر تكلفة جمع وتخزين، ومن هذا الجدول ترتب ما تجمعه أولاً. وهذا بالضبط منطق مذكرة OMB M-21-31 التي أصدرها البيت الأبيض في أغسطس 2021 للجهات الفدرالية الأمريكية، فهي تعطي كل فئة من السجلات درجة أهمية Criticality من 0 للأكثر فائدة في كشف التهديدات إلى 3 للأقل، وتقسم النضج إلى أربعة مستويات من EL0 حيث لا تتحقق حتى سجلات الأهمية القصوى، إلى EL3 حيث تتحقق كل الفئات.
من syslog إلى السجل المنظم: لماذا تغير شكل السجل؟
السجل في أصله رسالة نصية يكتبها برنامج ليقرأها إنسان، وهذا ما بنيت عليه طريقة syslog التي طورت أول مرة في توزيعة BSD من جامعة كاليفورنيا في بيركلي، ثم انتقلت إلى أغلب أنظمة التشغيل وأجهزة الشبكة لإرسال الرسائل إلى ملف أو إلى سيرفر آخر. وبقيت سنوات طويلة بلا مواصفة رسمية، حتى وثقت ممارستها الشائعة في RFC 3164 في 2001، ثم جاءت مواصفة RFC 5424 في مارس 2009 بديلاً عنها، وفيها وقت بصيغة RFC 3339 ومعه المنطقة الزمنية، وبيانات منظمة Structured Data بجانب الرسالة. ومع ذلك ستجد اليوم برامج كثيرة تكتب سطراً نصياً حراً بلا سنة في التاريخ ولا منطقة زمنية، كما يكتب sshd في المختبر.
والمشكلة في السطر الحر أن الإنسان يقرؤه بسهولة، والآلة تحتاج تعبيراً نمطياً Regex لكل صيغة، والتعبير يتعطل بصمت عندما يغير المطور جملة واحدة في إصدار جديد، فتتوقف القاعدة عن الإطلاق دون أن يعرف أحد. لذلك اتجهت الأدوات الحديثة إلى السجل المنظم Structured Logging، أي أن يكتب البرنامج كائن JSON أو أزواج مفتاح وقيمة بأسماء ثابتة، فيقرأ المجمع الحقول مباشرة. وفي المختبر سترى الفرق بنفسك: nginx والتطبيق كتبا JSON فقرأناهما بدالة واحدة، وsshd كتب جملاً فاحتجنا تعبيرين نمطيين وقائمة بما نسقطه.
خمس طبقات، وسؤال لكل طبقة
الصورة التالية ترتب المصادر في خمس طبقات، مع السؤال الذي تجيب عنه كل طبقة:

الهوية Identity: ابدأ هنا دائماً
أغلب الحوادث في الشركات الصغيرة تبدأ بحساب، إما كلمة مرور مسروقة أو مخمنة، وإما رسالة تصيد أعطت المهاجم جلسة صالحة، وقد رأينا في مقال المركز كيف بدأ الفريق الأحمر في تقرير CISA برسائل تصيد. لذلك فأول ما تجمعه سجلات المصادقة Authentication: كل دخول ناجح وفاشل إلى نظام الهوية الموحد SSO، وإلى الشبكة الخاصة VPN، وإلى SSH، وإلى لوحات الإدارة، ومعها كل تغيير في الحسابات: إنشاء حساب، وتغيير كلمة مرور، وإضافة إلى مجموعة المدراء، وتعطيل التحقق متعدد العوامل MFA. وفي الضابط 2-12-3-2 من وثيقة ECC-2:2024 نص صريح على تفعيل سجلات الحسابات الحساسة والمميزة وأحداث الوصول عن بعد.
السيرفرات والأجهزة: ماذا حدث داخل النظام؟
بعد الهوية يأتي سؤال ما حدث داخل السيرفر نفسه: أي عملية Process بدأت ومن أبيها، وأي ملف تغير في مجلدات حساسة، ومن استخدم sudo، وأي مهمة مجدولة أضيفت. وفي Linux تجد ذلك في سجل النظام journald ونظام التدقيق auditd ومراقبة سلامة الملفات FIM التي جربناها في مقال Wazuh، وفي Windows في سجلات الأحداث وأداة Sysmon. وانتبه هنا إلى أن الإعداد الافتراضي في أغلب الأنظمة لا يسجل بدء العمليات بسطر الأوامر الكامل، وهو أكثر ما يحتاجه المحقق، فهذه من الإعدادات التي يجب أن تفعلها بنفسك.
الشبكة: من اتصل بمن؟
سجلات الشبكة تجيب عن الأسئلة التي لا يجيب عنها السيرفر المخترق، لأن المهاجم يستطيع أن يمسح سجلات السيرفر الذي دخله، ولا يستطيع بسهولة أن يمسح سجلات الجدار الناري أو سيرفر DNS أو مستشعر Zeek الذي رأى اتصاله. وأهمها ثلاثة: سجل الاتصالات Connection Log الذي يقول من اتصل بمن وكم بايتاً في كل اتجاه، وسجل DNS الذي يقول أي اسم طلب كل جهاز، وسجل ال Proxy إن كان موجوداً. وقد شرحنا سجلات Zeek بالتفصيل في مقال Suricata وZeek.
التطبيقات: السجل الذي لا يكتبه أحد غيرك
هذه أكثر طبقة تغيب، والسبب أنها لا تأتي جاهزة مع أي أداة. فسجل الوصول في nginx يقول إن طلباً وصل إلى /role ورد عليه السيرفر بالرمز 200، ولا يقول إن سارة رقت عمر إلى مدير. وهذه الجملة الأخيرة هي ما يحتاجه المحقق، ولا يكتبها إلا التطبيق نفسه. لذلك يجب أن يكتب كل تطبيق أحداث تدقيق Audit Events للأفعال المهمة أمنياً: الدخول والخروج، وتغيير الصلاحيات، وتغيير البيانات الحساسة مثل البريد ورقم الهاتف وبيانات الدفع، والتصدير الجماعي للبيانات، وتغيير الإعدادات. وقد خصصنا لهذا الموضوع مقالاً كاملاً عن سجل التدقيق Audit Trail، والمهم هنا أن يصل هذا السجل إلى نفس المكان الذي تصل إليه بقية السجلات، وبنفس الشكل.
السحابة والخدمات SaaS: اقرأ الترخيص قبل العقد
البريد والتخزين السحابي وأنظمة الموارد البشرية والخدمات السحابية كلها لها سجلات تدقيق، ولكن مدة الاحتفاظ بها ونوع الأحداث المسجلة فيها تختلف بحسب الترخيص، كما رأينا في حالة MailItemsAccessed. لذلك فسؤال «أي سجلات تدقيق نحصل عليها، وكم تبقى، وكيف نصدرها إلى نظامنا؟» يجب أن يكون في قائمة الأسئلة قبل التعاقد مع أي خدمة، لا بعد الحادثة.
شكل السطر المفيد: ستة أسئلة
جمع المصدر الصحيح نصف العمل، والنصف الآخر أن يكون في كل سطر ما يجعله مفيداً. والصورة التالية تجمع الأسئلة الستة التي يجب أن يجيب عنها أي سطر أمني، مع أسماء الحقول التي سنستخدمها في المختبر:

والمذكرة نفسها تطلب في جدول المستوى الأساسي EL1 أن يحمل كل سجل، حيث ينطبق، وقتاً دقيقاً بتنسيق صحيح، ورمز الحالة، ومعرف الجهاز، ومعرف الجلسة أو المعاملة، وعناوين المصدر والوجهة، واسم المستخدم والأمر المنفذ عند الحاجة، وأن تكتب البيانات أزواجاً من مفتاح وقيمة Key-Value حتى يسهل استخراجها، وأن يحمل كل حدث معرفاً فريداً للربط. ولاحظ الكلمة الأخيرة: الربط Correlation. فالطلب الواحد يمر على ال Proxy ثم التطبيق ثم قاعدة البيانات، وإذا لم يحمل معرفاً واحداً في السجلات الثلاثة فلن تعرف أن سطر nginx وسطر التطبيق يتحدثان عن نفس الفعل إلا بالتخمين من الوقت.
وهناك أيضاً سؤال أسماء الحقول. فـ nginx يسمي عنوان العميل remote_addr، وsshd يكتبه داخل جملة، والتطبيق قد يسميه ip أو client، والقاعدة التي تبحث عن «عشر محاولات فاشلة من نفس العنوان» تحتاج أن تجده في مكان واحد. لذلك ظهرت المخططات الموحدة Schemas، وأشهرها Elastic Common Schema ECS ومخطط OCSF، وقد شرحنا لماذا تحتاجها في محرك كشف SIEM من الداخل. وفي المختبر سنستخدم أسماء على نمط ECS، مثل source.ip وuser.name وevent.outcome.
الوقت: UTC، والمللي ثانية، والمنطقة الزمنية مكتوبة
إذا كان هناك حقل واحد يستحق قسماً خاصاً فهو الوقت، والسبب أن كل تحقيق هو في النهاية ترتيب أحداث على خط زمني، والخط الزمني الخاطئ يحكي قصة خاطئة. وتطلب المذكرة نفسها أن يكتب الوقت بتنسيق مبني على ISO 8601 وRFC 3339، بالمللي ثانية ومع المنطقة الزمنية، مثل 2026-10-28T10:46:00.706Z حيث الحرف Z يعني UTC. وهنا ثلاثة قرارات:
- UTC في السجل، والتوقيت المحلي في العرض: لأن السيرفرات في أكثر من منطقة، والخدمات السحابية تكتب UTC، ولأن التوقيت المحلي في بعض الدول يتغير مع التوقيت الصيفي فيتكرر فيه نفس الوقت مرتين. والمملكة لا تغير توقيتها، ولكن خدماتك وموردوك ليسوا كلهم فيها. أما لوحة التحكم فتعرض بتوقيت السعودية لمن يقرأ.
- المنطقة الزمنية مكتوبة دائماً: الوقت
13:37:59بلا إزاحة ليس معلومة، وإنما تخمين، وسوف نرى في المختبر ماذا يفعل هذا التخمين. - المللي ثانية: لأن تخمين كلمات المرور بأداة آلية يرسل عشرات المحاولات في الثانية، والسطر الذي يقول
10:38:00لعشرين حدثاً لا يرتبها.
والوقت الصحيح في السطر لا يفيد إذا كانت ساعة السيرفر نفسها خاطئة، لذلك فمزامنة الوقت Time Synchronization بخدمة NTP على كل جهاز شرط لكل ما سبق، ومن أرخص الإعدادات وأكثرها نسياناً.
تجربة عملية: ثلاثة مصادر ومجمع واحد
الآن نبني ما سبق على جهازك. المختبر فيه ثلاثة مصادر تمثل متجراً صغيراً: scu-log-web وهو nginx أمام التطبيق، وscu-log-app وهو تطبيق Python صغير فيه تسجيل دخول وتغيير صلاحيات، وscu-log-ssh وهو سيرفر SSH. والمجمع Collector هو Vector بالإصدار 0.60.0 الصادر في 19 أكتوبر 2026، برخصة MPL 2.0، يقرأ سجلات ال Containers الثلاث من Docker، ويوحدها، ويكتبها في ملف لكل يوم. والصورة التالية تبين الطريق:

واخترنا Vector لأنه ملف تنفيذي واحد صغير، ولغته VRL تجعل التوحيد مكتوباً في ملف إعداد يقرؤه الفريق ويراجعه، ونفس الفكرة تعمل مع Fluent Bit أو Logstash أو وكيل Wazuh. ولا يحتاج المختبر أي منفذ على جهازك، فكل الحركة داخل شبكة Docker خاصة.
المصدر الأول: nginx بسجل JSON
السجل الافتراضي في nginx نص بصيغة Combined، وهو مقروء للإنسان ولكنه يحتاج تحليلاً بتعبير نمطي Regex لكل حقل، ولا يحمل معرف الطلب ولا مدته. لذلك نعرف صيغة JSON خاصة بالأمر log_format مع الخيار escape=json الذي يمنع أي قيمة يرسلها العميل، مثل User-Agent فيه علامة تنصيص، من إفساد السطر، كما في توثيق وحدة السجلات:
events {}
http {
# one JSON object per request: UTC-offset timestamp, request id, client, result, duration
log_format sec escape=json '{"ts":"$time_iso8601","req_id":"$request_id","src_ip":"$remote_addr",'
'"xff":"$http_x_forwarded_for","method":"$request_method","uri":"$request_uri","status":$status,'
'"bytes":$body_bytes_sent,"rt":$request_time,"ua":"$http_user_agent","host":"$host"}';
access_log /dev/stdout sec;
error_log /dev/stderr warn;
server {
listen 80;
location = /healthz { access_log off; return 200 "ok\n"; }
location / { proxy_pass http://scu-log-app:8000; proxy_set_header X-Request-ID $request_id;
proxy_set_header X-Forwarded-For $remote_addr; }
}
}في الإعداد أعلاه لاحظ التالي:
$request_idمعرف عشوائي لكل طلب، ونرسله إلى التطبيق في الترويسةX-Request-ID، فيكتبه التطبيق في سجله، وبهذا يرتبط السطران.$time_iso8601يكتب الوقت مع المنطقة الزمنية، ولكن بالثانية لا بالمللي ثانية، وإذا احتجت الدقة فالمتغير$msecيعطيها.- مسار
/healthzبلا سجل، لأن فحص الصحة كل خمس ثوان من موزع الحمل يكتب 17,280 سطراً في اليوم لا يسأل عنها أحد. - السجل يكتب إلى
/dev/stdoutلأن هذا هو الطريق الطبيعي لسجلات ال Containers، فيقرؤها Docker والمجمع منه.
المصدر الثاني: التطبيق وأحداث التدقيق
التطبيق فيه مساران: /login لتسجيل الدخول، و/role لتغيير صلاحية مستخدم، ويكتب لكل حدث سطر JSON. وكتبنا النسخة الأولى بخطأ شائع جداً عن قصد: الوقت محلي بلا منطقة زمنية، وال Container يعمل بتوقيت السعودية TZ=Asia/Riyadh:
"""Tiny shop login API for the logging lab. Writes one JSON line per security event to stdout.
First version: the timestamp is local time without an offset (a common mistake)."""
import json, os, sys, time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs
USERS = {"sara": "correct-horse", "omar": "battery-staple"}
ROLES = {"sara": "admin", "omar": "user"}
UTC_TS = os.environ.get("UTC_TS") == "1"
def log(**event):
now = datetime.now(timezone.utc).isoformat(timespec="milliseconds") if UTC_TS else datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(json.dumps({"ts": now, "app": "shop-api", **event}), flush=True)
class H(BaseHTTPRequestHandler):
def log_message(self, *a): # silence the default access log; nginx already has it
pass
def do_GET(self):
self.send_response(200 if self.path == "/" else 404)
self.end_headers()
self.wfile.write(b"shop" + bytes([10]))
def do_POST(self):
body = parse_qs(self.rfile.read(int(self.headers.get("Content-Length", 0))).decode())
user, pw = body.get("user", [""])[0], body.get("password", [""])[0]
src = self.headers.get("X-Forwarded-For", self.client_address[0])
rid = self.headers.get("X-Request-ID", "-")
if self.path == "/login":
ok = USERS.get(user) == pw
log(event="login", user=user, src_ip=src, outcome="success" if ok else "failure", req_id=rid)
self.send_response(200 if ok else 401)
elif self.path == "/role":
target, role = body.get("target", [""])[0], body.get("role", [""])[0]
ok = USERS.get(user) == pw and ROLES.get(user) == "admin"
if ok:
ROLES[target] = role
log(event="role_change", user=user, target=target, new_role=role, src_ip=src,
outcome="success" if ok else "failure", req_id=rid)
self.send_response(200 if ok else 403)
else:
self.send_response(404)
self.end_headers()
HTTPServer(("0.0.0.0", 8000), H).serve_forever()المصدر الثالث: sshd بنص عادي
سيرفر SSH على Debian يكتب سجله عادة إلى syslog أو journald، وداخل Container لا يوجد أي منهما، لذلك نشغله بالخيار -e الذي يكتب السجل إلى stderr، كما في صفحة sshd، فيصل إلى Docker:
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server openssh-client sshpass \
&& rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd \
&& useradd -m -s /bin/bash deploy && echo 'deploy:lab-only-password' | chpasswd
# -D: stay in the foreground, -e: write the log to stderr so Docker (and the collector) sees it
CMD ["/usr/sbin/sshd", "-D", "-e"]المجمع: Vector يوحد الحقول
ملف الإعداد يقرأ السجلات بالمصدر docker_logs، ثم يوزعها بالتحويل route بحسب اسم ال Container، ثم يمر كل مصدر على برنامج VRL صغير في تحويل remap يستخرج الحقول ويحول الوقت إلى UTC بأسماء موحدة:
# Collects the container logs of the lab, normalises them to a few common fields
# (ECS-style names) with UTC timestamps, and writes one NDJSON file per day.
sources:
docker:
type: docker_logs
include_containers: ["scu-log-web", "scu-log-ssh", "scu-log-app"]
transforms:
by_source:
type: route
inputs: [docker]
reroute_unmatched: false
route:
web: '.container_name == "scu-log-web"'
ssh: '.container_name == "scu-log-ssh"'
app: '.container_name == "scu-log-app"'
web:
type: remap
inputs: [by_source.web]
source: |
p, err = parse_json(.message)
if err != null { abort } # nginx start-up notices are not JSON
outcome = "success"
if to_int!(p.status) >= 400 { outcome = "failure" }
. = {
"@timestamp": format_timestamp!(parse_timestamp!(p.ts, "%+"), "%Y-%m-%dT%H:%M:%S%.3fZ", timezone: "UTC"),
"event": {"dataset": "nginx.access", "outcome": outcome},
"source": {"ip": p.src_ip},
"url": {"path": p.uri}, "http": {"method": p.method, "status": p.status, "duration_s": p.rt},
"user_agent": p.ua, "trace": {"id": p.req_id}, "host": {"name": "web"}
}
ssh:
type: remap
inputs: [by_source.ssh]
source: |
m = parse_regex(.message, r'^(?P<action>Accepted|Failed) (?P<method>\S+) for (?:invalid user )?(?P<user>\S+) from (?P<ip>\S+) port (?P<port>\d+)') ??
parse_regex(.message, r'^Invalid user (?P<user>\S+) from (?P<ip>\S+) port (?P<port>\d+)') ?? {}
if m == {} { abort } # keep only authentication events
outcome = "failure"
if m.action == "Accepted" { outcome = "success" }
action = m.action || "Invalid"
# sshd -e writes no timestamp of its own: the time is when Docker read the line
. = {
"@timestamp": format_timestamp!(.timestamp, "%Y-%m-%dT%H:%M:%S%.3fZ", timezone: "UTC"),
"event": {"dataset": "sshd.auth", "action": action, "outcome": outcome},
"user": {"name": m.user}, "source": {"ip": m.ip, "port": to_int!(m.port)}, "host": {"name": "ssh"}
}
app:
type: remap
inputs: [by_source.app]
source: |
p, err = parse_json(.message)
if err != null { abort }
ts = parse_timestamp(p.ts, "%+") ?? parse_timestamp!(p.ts, "%Y-%m-%d %H:%M:%S") # no offset: read as UTC
. = {
"@timestamp": format_timestamp!(ts, "%Y-%m-%dT%H:%M:%S%.3fZ", timezone: "UTC"),
"event": {"dataset": "shop.audit", "action": p.event, "outcome": p.outcome},
"user": {"name": p.user, "target": p.target}, "source": {"ip": p.src_ip},
"trace": {"id": p.req_id}, "host": {"name": "app"}, "collected_at": format_timestamp!(.timestamp, "%Y-%m-%dT%H:%M:%S%.3fZ", timezone: "UTC")
}
sinks:
normalized:
type: file
inputs: [web, ssh, app]
path: "/out/events-%Y-%m-%d.ndjson"
encoding: {codec: json}
raw:
type: file
inputs: [docker]
path: "/out/raw-%Y-%m-%d.log"
encoding: {codec: text}في الإعداد أعلاه لاحظ التالي:
include_containersيطابق ببداية الاسم Prefix، كما يقول توثيق المصدر، فلو كتبتscu-logوحدها لدخلت سجلات أي Container يبدأ اسمه بها، ومنها أدوات الاختبار نفسها. لذلك كتبنا الأسماء كاملة.- كل برنامج يبدأ بـ
abortلما لا يفهمه، فرسائل بدء nginx وسطرConnection closedمن sshd لا تصل إلى الملف الموحد، ولكنها تبقى في الملف الخام. - sshd بالخيار
-eلا يكتب وقتاً، فالوقت في حدثه هو الوقت الذي قرأ فيه Docker السطر، وهذا مقبول هنا لأن الفرق أجزاء من الثانية، ولكنه يعني أن أي تأخير في الجمع يصبح خطأ في الوقت. - في التطبيق نحاول قراءة الوقت بصيغة ISO أولاً، فإذا فشلت نقرؤه بالصيغة المحلية ونفترض أنه UTC، وهذا الافتراض هو الخطأ الذي سنراه بعد قليل. وأضفنا حقل
collected_atلوقت الجمع حتى نقارن.
وقبل التشغيل نتحقق من الإعداد بالأمر validate، وهذا ما أنقذنا من أول محاولة، فقد كتبنا شرط if داخل تعريف الكائن مباشرة، وVRL لا يقبل ذلك، فرد بالخطأ unexpected syntax token: "If". والحل أن تحسب القيمة في متغير قبل الكائن، كما في الملف أعلاه:
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W)/vector.yaml:/etc/vector/vector.yaml:ro" \
timberio/vector:0.60.0-debian validate --no-environment /etc/vector/vector.yaml√ Loaded ["/etc/vector/vector.yaml"]
√ Transforms configuration
√ Sinks configuration
------------------------------------
Validatedالتشغيل والحركة
docker network create scu-log-net
docker build -t scu-log-ssh:1 -f Dockerfile.ssh .
MSYS_NO_PATHCONV=1 docker run -d --name scu-log-app --network scu-log-net -e TZ=Asia/Riyadh \
-v "$(pwd -W)/app.py:/app.py:ro" python:3.13.17-slim python -u /app.py
MSYS_NO_PATHCONV=1 docker run -d --name scu-log-web --network scu-log-net \
-v "$(pwd -W)/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31.6-alpine
docker run -d --name scu-log-ssh --network scu-log-net scu-log-ssh:1
MSYS_NO_PATHCONV=1 docker run -d --name scu-log-vector -v /var/run/docker.sock:/var/run/docker.sock:ro \
-v "$(pwd -W)/vector.yaml:/etc/vector/vector.yaml:ro" -v "$(pwd -W)/out:/out" timberio/vector:0.60.0-debianثم نرسل الحركة: عشرين طلباً عادياً إلى الصفحة الرئيسية، وست محاولات دخول خاطئة إلى التطبيق ثم واحدة صحيحة، ثم تغيير صلاحية عمر إلى مدير، وبعدها ثماني محاولات SSH خاطئة لحساب deploy، ومحاولتين لحسابين غير موجودين، ثم دخولاً صحيحاً. وكل هذه الحركة من Container داخل نفس الشبكة الخاصة إلى Containers المختبر فقط:
"""Lab traffic through nginx: normal browsing, a password-guessing run against /login, a success, and a role change."""
import time, urllib.request, urllib.parse, urllib.error
B = "http://scu-log-web"
def post(path, **form):
try:
return urllib.request.urlopen(B + path, data=urllib.parse.urlencode(form).encode(), timeout=5).status
except urllib.error.HTTPError as e:
return e.code
for _ in range(20):
urllib.request.urlopen(B + "/", timeout=5).read()
codes = [post("/login", user="sara", password=f"guess{i}") for i in range(6)]
codes.append(post("/login", user="sara", password="correct-horse"))
codes.append(post("/role", user="sara", password="correct-horse", target="omar", role="admin"))
print("login/role responses:", codes)MSYS_NO_PATHCONV=1 docker run --rm --network scu-log-net -v "$(pwd -W)/traffic.py:/t.py:ro" python:3.13.17-slim python /t.py
for i in 1 2 3 4 5 6 7 8; do
docker run --rm --network scu-log-net scu-log-ssh:1 sshpass -p "wrong$i" ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 deploy@scu-log-ssh true
done
for u in admin oracle; do
docker run --rm --network scu-log-net scu-log-ssh:1 sshpass -p x ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password -o NumberOfPasswordPrompts=1 $u@scu-log-ssh true
done
docker run --rm --network scu-log-net scu-log-ssh:1 sshpass -p lab-only-password ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null deploy@scu-log-ssh idlogin/role responses: [401, 401, 401, 401, 401, 401, 200, 200]
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy)ماذا وصل خاماً؟
الملف الخام فيه 61 سطراً، وهذه عينة منه لكل مصدر:
{"ts":"2026-10-28T10:37:59+00:00","req_id":"f74845654d974850619de6ef8571e92e","src_ip":"192.168.112.5","xff":"","method":"POST","uri":"/login","status":401,"bytes":5,"rt":0.014,"ua":"Python-urllib/3.13","host":"scu-log-web"}
{"ts": "2026-10-28 13:37:59", "app": "shop-api", "event": "login", "user": "sara", "src_ip": "192.168.112.5", "outcome": "failure", "req_id": "f74845654d974850619de6ef8571e92e"}
{"ts": "2026-10-28 13:38:00", "app": "shop-api", "event": "role_change", "user": "sara", "target": "omar", "new_role": "admin", "src_ip": "192.168.112.5", "outcome": "success", "req_id": "0a95fdc3a4269d1af8365fc281e84832"}
Failed password for deploy from 192.168.112.5 port 39532 ssh2
Failed password for deploy from 192.168.112.5 port 38316 ssh2
Connection closed by authenticating user deploy 192.168.112.5 port 39532 [preauth]
Invalid user admin from 192.168.112.5 port 60860
Failed password for invalid user admin from 192.168.112.5 port 60860 ssh2
Accepted password for deploy from 192.168.112.5 port 60882 ssh2في العينة أعلاه لاحظ التالي:
- سطر nginx وسطر التطبيق لنفس محاولة الدخول يحملان نفس المعرف
f74845654d974850619de6ef8571e92e، فتستطيع أن تربطهما دون أي تخمين. - سطر nginx يقول إن الوقت
10:37:59+00:00، وسطر التطبيق لنفس الطلب يقول13:37:59بلا أي إشارة إلى المنطقة الزمنية، والفرق ثلاث ساعات بالضبط، أي فرق توقيت السعودية عن UTC. - محاولة الحساب غير الموجود
adminتكتب سطرين،Invalid userثمFailed password for invalid user، فإذا عددت الأسطر فستعد المحاولة مرتين. - سطر
Connection closed by authenticating userيتكرر بعد كل محاولة، ولا يضيف معلومة أمنية، لذلك أسقطناه من الملف الموحد. - وسطر الدخول الناجح
Accepted password for deployهو أهم سطر في الملف كله، وهو الذي يجب أن تطلق عليه قاعدة إذا سبقه عدد من الفشل من نفس العنوان.
وماذا خرج موحداً؟
هذه نفس الأحداث بعد التوحيد، سطر من كل مصدر:
{"@timestamp":"2026-10-28T10:37:59.000Z","event":{"dataset":"nginx.access","outcome":"failure"},"host":{"name":"web"},"http":{"duration_s":0.014,"method":"POST","status":401},"source":{"ip":"192.168.112.5"},"trace":{"id":"f74845654d974850619de6ef8571e92e"},"url":{"path":"/login"},"user_agent":"Python-urllib/3.13"}
{"@timestamp":"2026-10-28T13:37:59.000Z","collected_at":"2026-10-28T10:37:59.999Z","event":{"action":"login","dataset":"shop.audit","outcome":"failure"},"host":{"name":"app"},"source":{"ip":"192.168.112.5"},"trace":{"id":"f74845654d974850619de6ef8571e92e"},"user":{"name":"sara","target":null}}
{"@timestamp":"2026-10-28T10:38:52.035Z","event":{"action":"Accepted","dataset":"sshd.auth","outcome":"success"},"host":{"name":"ssh"},"source":{"ip":"192.168.112.5","port":60882},"user":{"name":"deploy"}}الآن كل حدث فيه @timestamp وsource.ip وevent.outcome في نفس المكان، فتستطيع أن تكتب قاعدة واحدة عن الفشل المتكرر تعمل على SSH والتطبيق معاً. ولكن انظر إلى وقت حدث التطبيق: 13:37:59.000Z، ووقت جمعه 10:37:59.999Z.
سكربت الفحص: أربعة أسئلة عن السجلات نفسها
كتبنا سكربتاً صغيراً يسأل الملف الموحد ما يسأله المحلل: ماذا وصل وما حجمه، وهل خط زمني لعنوان واحد منطقي، وهل الساعات متفقة، وأي مصدر سكت:
"""Reads the collected NDJSON and answers four questions a SOC asks of its logs:
what arrived (and how big), does one actor's timeline make sense, do clocks agree, and which source went quiet."""
import json, sys, os, collections
from datetime import datetime, timezone
path, raw = sys.argv[1], sys.argv[2]
ev = [json.loads(l) for l in open(path)]
ts = lambda s: datetime.fromisoformat(s.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
by = collections.Counter(e["event"]["dataset"] for e in ev)
print(f"events: {len(ev)} " + " ".join(f"{k}={v}" for k, v in sorted(by.items())))
print(f"bytes: raw {os.path.getsize(raw)} normalized {os.path.getsize(path)} "
f"avg normalized event {os.path.getsize(path) // len(ev)} B")
print("\ntimeline for source.ip 192.168.112.5 (logins and changes only):")
for e in sorted(ev, key=lambda e: e["@timestamp"]):
if e["event"]["dataset"] == "nginx.access" or e.get("source", {}).get("ip") != "192.168.112.5":
continue
a = e["event"].get("action", "")
if a in ("Failed", "Invalid"):
continue # keep the timeline short: failures are counted below
print(f" {e['@timestamp']} {e['host']['name']:4} {a:12} {e['user']['name']:7} {e['event']['outcome']}")
fails = [e for e in ev if e["event"].get("outcome") == "failure" and e["event"]["dataset"] != "nginx.access"]
print(f" failures before them: {len(fails)} ({collections.Counter(e['host']['name'] for e in fails)})")
print("\nclock check (event time vs collection time):")
for e in ev:
if "collected_at" in e:
d = (ts(e["@timestamp"]) - ts(e["collected_at"])).total_seconds()
if abs(d) > 60:
print(f" {e['host']['name']}: event says {e['@timestamp']}, collected {e['collected_at']}, skew {d:+.0f} s")
break
else:
print(" no skew above 60 s")
print("\nlast event per source:")
for h in ("web", "app", "ssh"):
last = max((ts(e["@timestamp"]) for e in ev if e["host"]["name"] == h), default=None)
age = (now - last).total_seconds() / 60 if last else None
flag = " <-- silent" if age is None or age > float(sys.argv[3] if len(sys.argv) > 3 else 10) else ""
print(f" {h:4} {last.isoformat(timespec='seconds') if last else '-':26} {age if age is None else round(age, 1)} min ago{flag}")MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd -W)/out:/out" -v "$(pwd -W)/check.py:/check.py:ro" \
python:3.13.17-slim python /check.py /out/events-2026-10-28.ndjson /out/raw-2026-10-28.logوالمخرج سوف يكون كما يلي:
events: 49 nginx.access=28 shop.audit=8 sshd.auth=13
bytes: raw 9421 normalized 13745 avg normalized event 280 B
timeline for source.ip 192.168.112.5 (logins and changes only):
2026-10-28T10:38:52.035Z ssh Accepted deploy success
2026-10-28T13:37:59.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara failure
2026-10-28T13:38:00.000Z app login sara success
2026-10-28T13:38:00.000Z app role_change sara success
failures before them: 18 (Counter({'ssh': 12, 'app': 6}))
clock check (event time vs collection time):
app: event says 2026-10-28T13:37:59.000Z, collected 2026-10-28T10:37:59.999Z, skew +10799 s
last event per source:
web 2026-10-28T10:38:00+00:00 5.5 min ago
app 2026-10-28T13:38:00+00:00 -174.5 min ago
ssh 2026-10-28T10:38:52+00:00 4.7 min agoفي المخرج أعلاه لاحظ التالي:
- 49 حدثاً موحداً من 61 سطراً خاماً، والفرق ما أسقطناه عن قصد، والحجم الموحد 13,745 بايت أكبر من الخام 9,421، لأن أسماء الحقول المتداخلة تضيف بايتات لكل حدث. ومتوسط الحدث الموحد 280 بايت، وسنحتاج هذا الرقم في حساب الاحتفاظ.
- الخط الزمني يقول إن
deployدخل SSH بنجاح في 10:38:52، ثم بعد ثلاث ساعات حاولت سارة الدخول إلى التطبيق ست مرات ثم رقت عمر. وهذه قصة خاطئة تماماً، فالذي حدث فعلاً أن محاولات التطبيق جاءت أولاً، ثم محاولات SSH بعدها بأقل من دقيقة. - فحص الساعات وجد فرقاً بـ 10,799 ثانية بين وقت الحدث ووقت جمعه، أي ثلاث ساعات إلا ثانية.
- وآخر حدث من التطبيق «قبل -174.5 دقيقة»، أي في المستقبل، وهذه علامة لا تخطئ: أي سجل يأتي من المستقبل فيه خطأ في المنطقة الزمنية.
والأخطر في هذا الخطأ أنه لم يطلق أي تحذير: كل الأسطر وصلت، وكل الأسطر توحدت بنجاح، ولوحة التحكم كانت ستعرضها بلا شكوى. ومحقق يقرأ هذا الخط الزمني دون أن ينتبه سيقول إن الاختراق بدأ من SSH، وسيبحث في المكان الخاطئ.

الإصلاح: سطر واحد في التطبيق
الإصلاح في المصدر لا في المجمع، فالمجمع لا يستطيع أن يعرف بأي منطقة كتب التطبيق وقته إلا إذا أخبرته، والأصح أن يكتب التطبيق UTC بالمللي ثانية ومعه الإزاحة. في التطبيق أعلاه يفعل ذلك المتغير UTC_TS=1، فأعدنا تشغيله به وأعدنا نفس الحركة مع ثلاث محاولات SSH ودخول صحيح:
docker rm -f scu-log-app
MSYS_NO_PATHCONV=1 docker run -d --name scu-log-app --network scu-log-net -e TZ=Asia/Riyadh -e UTC_TS=1 \
-v "$(pwd -W)/app.py:/app.py:ro" python:3.13.17-slim python -u /app.pyevents: 40 nginx.access=28 shop.audit=8 sshd.auth=4
bytes: raw 8377 normalized 11911 avg normalized event 297 B
timeline for source.ip 192.168.112.5 (logins and changes only):
2026-10-28T10:46:00.682Z app login sara failure
2026-10-28T10:46:00.684Z app login sara failure
2026-10-28T10:46:00.687Z app login sara failure
2026-10-28T10:46:00.689Z app login sara failure
2026-10-28T10:46:00.692Z app login sara failure
2026-10-28T10:46:00.703Z app login sara failure
2026-10-28T10:46:00.706Z app login sara success
2026-10-28T10:46:00.719Z app role_change sara success
2026-10-28T10:46:18.640Z ssh Accepted deploy success
failures before them: 9 (Counter({'app': 6, 'ssh': 3}))
clock check (event time vs collection time):
no skew above 60 s
last event per source:
web 2026-10-28T10:46:00+00:00 0.4 min ago
app 2026-10-28T10:46:00+00:00 0.4 min ago
ssh 2026-10-28T10:46:18+00:00 0.1 min agoالآن الخط الزمني يحكي ما حدث: ست محاولات فاشلة لسارة خلال 21 مللي ثانية، ثم نجاح، ثم تغيير صلاحية عمر بعد 13 مللي ثانية، ثم دخول SSH بعد ثماني عشرة ثانية. ولاحظ أن ست محاولات في 21 مللي ثانية لا يكتبها إنسان على لوحة مفاتيح، وهذه المعلومة وحدها كانت ستضيع لو بقي الوقت بالثانية. ولاحظ أيضاً أن ال Container ما زال يعمل بتوقيت السعودية، فالمنطقة الزمنية للنظام لم تكن المشكلة، وإنما أن التطبيق لم يكتبها في السطر.
ماذا لو مسح المهاجم السجل؟
من أول ما يفعله المهاجم بعد الدخول أن يمسح أثره من السيرفر، وهو أسلوب معروف في ATT&CK باسم إزالة المؤشرات Indicator Removal. لنجرب ذلك بأبسط صورة: نحذف Container ال SSH كاملاً بسجلاته:
docker logs scu-log-ssh 2>&1 | grep -c "Failed password\|Accepted"
docker rm -f scu-log-ssh
docker logs scu-log-ssh
grep -c sshd.auth out/events-2026-10-28.ndjson15
Error response from daemon: No such container: scu-log-ssh
4كان في سجل ال Container خمسة عشر سطر مصادقة من التشغيلين، واختفت كلها مع حذفه، بينما بقيت الأحداث الأربعة من التشغيل الثاني في الملف المركزي، وبقيت أحداث التشغيل الأول في ملفه الذي نقلناه جانباً. وهذه أهم فائدة للجمع المركزي على الإطلاق: السجل يخرج من السيرفر لحظة كتابته، فما وصل قبل المسح لا يمسحه المهاجم إلا إذا وصل إلى المجمع نفسه. لذلك يجب أن يكون المجمع على سيرفر مختلف، بصلاحيات مختلفة، لا يستطيع حساب على السيرفرات المراقبة أن يكتب فيه أو يحذف منه.
ماذا لو سكت المصدر؟
بعد حذف سيرفر SSH توقفت أحداثه، ولم يطلق ذلك أي تنبيه، لأن القواعد تطلق على ما يصل، لا على ما لا يصل. لذلك يحتوي سكربت الفحص على سؤال رابع: متى آخر حدث من كل مصدر؟ وبعد دقيقتين أرسلنا حركة جديدة إلى الموقع، وشغلنا الفحص بحد دقيقة ونصف:
last event per source:
web 2026-10-28T10:51:02+00:00 0.1 min ago
app 2026-10-28T10:51:02+00:00 0.1 min ago
ssh 2026-10-28T10:46:18+00:00 4.9 min ago <-- silentالمصدر الذي كان يرسل ثم سكت يظهر هنا، وفي الإنتاج تكتب هذه القاعدة في أداة المراقبة نفسها بحد يناسب كل مصدر: سيرفر مزدحم يسكت خمس دقائق أمر غريب، وسيرفر نسخ احتياطي يسكت ساعات أمر طبيعي. ومصدر يسكت قد يكون وكيلاً توقف بعد تحديث، أو قرصاً امتلأ، أو مهاجماً أوقف الوكيل، وكلها تستحق أن يعرف بها أحد في نفس اليوم.

ماذا لو توقف المجمع نفسه؟
بقي سؤال لم نجربه في المختبر ولكن يجب أن تقرر فيه قبل الإنتاج: ماذا يحدث للأحداث إذا توقف المجمع أو انقطعت الشبكة بينه وبين المخزن؟ يشرح توثيق نموذج التخزين المؤقت في Vector أن كل مكون له مخزن مؤقت Buffer صغير في الذاكرة، وأن المخرج Sink يحتفظ افتراضياً بخمسمئة حدث، وأن ما في الذاكرة يضيع إذا توقف Vector أو السيرفر الذي يعمل عليه. والبديل مخزن مؤقت على القرص Disk Buffer يحفظ الأحداث بين التوقف والتشغيل، ويكتب إلى القرص كل 500 مللي ثانية افتراضياً لا مع كل حدث، أي أن نصف ثانية من الأحداث قد تضيع في أسوأ الأحوال. والقرار هنا مثل كل قرار في السجلات: أي المصادر تستحق أن تحفظ حتى لو تعطل المجمع؟ سجلات الهوية والحسابات المميزة نعم، وسجلات الوصول إلى الصور الثابتة غالباً لا. وتذكر أن المصدر نفسه قد يحفظ ما لم يرسل، فـ Docker يحتفظ بسجلات ال Container حتى يحذف، والوكيل الذي يقرأ ملفاً يتذكر آخر موضع قرأه، فيكمل منه بعد عودة المجمع.
الموارد وحذف المختبر
استهلك Vector في المختبر 90 MiB من الذاكرة، وnginx أقل من 3 MiB، والتطبيق 14 MiB، وحجم Docker Image الخاص بـ Vector بنسخة Debian نحو 377 MB. أي أن المجمع ليس عبئاً على سيرفر صغير، والعبء الحقيقي في التخزين والفهرسة كما سنرى. وبعد الانتهاء نحذف كل شيء:
docker rm -f scu-log-vector scu-log-web scu-log-app
docker network rm scu-log-net
docker image rm scu-log-ssh:1بعد الجمع: خط الأساس Baseline قبل القواعد
عندما تصل السجلات لأول مرة إلى مكان واحد، فأول ما تكتشفه ليس الهجمات، وإنما كم يحدث في شبكتك كل يوم مما لا تعرفه: سيرفر نسخ احتياطي يدخل بحساب root كل ليلة في الثانية، وأداة مراقبة تفشل في الدخول كل دقيقة لأن أحداً غير كلمة مرورها قبل سنة، وموظف يدخل من الشبكة الخاصة كل يوم في منتصف الليل لأنه يعمل من مدينة أخرى. وهذه كلها لو كتبت لها قواعد من اليوم الأول لأغرقت الطابور بإنذارات خاطئة، وجعلت الفريق يكره المشروع قبل أن يبدأ.
لذلك فالأسبوعان الأولان بعد الجمع ليسا للقواعد، وإنما لبناء خط الأساس Baseline، أي صورة عن النشاط الطبيعي: من يدخل عادة، ومن أين، وفي أي ساعات، وكم بايتاً يخرج من كل سيرفر في اليوم، وأي الحسابات الخدمية Service Accounts تعمل وماذا تفعل. وهذا ما فعلته الجهة التي اكتشفت اختراق البريد في تحذير CISA الذي بدأنا به، فهي لم تكتشفه بقاعدة جاهزة، وإنما لأن لديها خطاً أساسياً لنشاط Outlook الطبيعي عندها، فظهر تطبيق بمعرف غير معتاد يقرأ البريد. وهي أيضاً أول توصية في تقرير الفريق الأحمر في CISA الذي ذكرناه في مقال المركز: ابن خطاً أساساً للنشاط الطبيعي، ثم اضبط أدواتك لترى ما يخرج عنه.
وخط الأساس يعطيك ثلاثة أشياء. الأول قائمة إصلاحات قبل أي قاعدة، فأداة المراقبة التي تفشل في الدخول كل دقيقة مشكلة تشغيلية تصلح، لا إنذار يسكت. والثاني قائمة سماح Allowlist ضيقة ومكتوبة، فسيرفر النسخ الاحتياطي يدخل من عنوانه وحده وفي ساعته وحدها، وأي دخول آخر بنفس الحساب يستحق تنبيهاً. والثالث أرقام حقيقية للحدود Thresholds، فبدل أن تكتب «أكثر من خمس محاولات فاشلة» لأنها رقم شائع، تكتب الرقم الذي لا يصل إليه أي مستخدم حقيقي عندك في أسبوعين. وفي المختبر رأينا مثالاً صغيراً: ست محاولات في 21 مللي ثانية لا يصل إليها إنسان أبداً، بينما ست محاولات في عشر دقائق قد تكون موظفاً نسي أنه غير كلمة مروره.
وخط الأساس لا يبنى مرة واحدة، فالشركة تتغير: موظفون جدد، وخدمات جديدة، ومورد جديد يدخل لصيانة نظام. لذلك راجعه كل ربع سنة، وراجع معه قائمة السماح، واحذف منها كل سطر لم يعد صاحبه موجوداً.
كم تحتفظ، وكم تحتاج من القرص؟
الضابط 2-12-3-5 في ECC يطلب ألا تقل مدة الاحتفاظ بسجلات الأمن السيبراني عن اثني عشر شهراً، ومذكرة OMB M-21-31 تذهب أبعد: اثنا عشر شهراً في تخزين سريع Active Storage يسهل البحث فيه، ثم ثمانية عشر شهراً في تخزين بارد Cold Storage أرخص، مع استثناء التقاط الحزم الكامل الذي تكفيه 72 ساعة. والسبب في المدة الطويلة أن الاختراق لا يكتشف في يومه، فإذا عرفت اليوم أن مهاجماً دخل قبل خمسة أشهر، فأنت تحتاج سجلات ما قبل خمسة أشهر لتعرف من أين دخل وماذا أخذ.

والحساب نفسه بسيط: عدد الأحداث في اليوم ضرب متوسط حجم الحدث ضرب أيام الاحتفاظ. ففي التشغيل الثاني كان متوسط الحدث الموحد 297 بايت، فإذا كان متجرك يستقبل مئتي ألف طلب في اليوم وخمسة آلاف حدث مصادقة، فهذا قرابة 61 ميجابايت في اليوم، و22 جيجابايت في السنة قبل الضغط. وهذا مثال حسابي لا قياس، فعدل الأعداد بحسب ما تراه في سجلاتك أنت. وانتبه إلى ثلاثة أمور تغير الرقم كثيراً:
- الفهرسة Indexing: أدوات البحث مثل OpenSearch تبني فهارس فوق البيانات، وقد يصبح الحجم على القرص ضعف حجم الملف أو أكثر بحسب الإعداد، لذلك احسب على الحجم بعد الفهرسة.
- الضغط Compression: السجلات نص متكرر، فتضغط بنسبة كبيرة، وهذا ما يجعل الأرشيف البارد رخيصاً.
- السجلات التي لا تحتاجها: فحص الصحة وطلبات الملفات الثابتة وسجلات مستوى Debug، فقرار
access_log offلمسار/healthzوحده وفر سبعة عشر ألف سطر يومياً لكل موزع حمل يفحص كل خمس ثوان.
لذلك تقسم أغلب المراكز التخزين إلى طبقتين: بيانات ساخنة مفهرسة لأسابيع يبحث فيها المحلل يومياً، وأرشيف مضغوط رخيص لبقية السنة يسترجع عند التحقيق، وهذا ما يجعل اثني عشر شهراً ممكنة في ميزانية شركة صغيرة.
احم السجل نفسه
السجل دليل، والدليل يجب أن يثبت أنه لم يتغير، وقد رأينا في المختبر أن المهاجم يستطيع أن يمسح ما على السيرفر. لذلك يحتاج السجل حماية مثل أي بيانات حساسة:
- اخرج به من السيرفر فوراً: كما فعلنا في المختبر، فالسطر الذي وصل إلى المجمع خرج من يد من يملك السيرفر.
- افصل الصلاحيات: مدير السيرفرات لا يملك حذف السجلات في المجمع، وحساب الوكيل يكتب فقط ولا يقرأ ولا يحذف.
- اجعل الأرشيف غير قابل للتعديل: بخاصية الكتابة مرة واحدة WORM أو قفل الكائنات Object Lock في التخزين، حتى لا يحذف أحد أرشيف الشهر الماضي ولو أراد.
- شفر النقل: السجلات فيها أسماء مستخدمين وعناوين وأحياناً أكثر، فلا تنقلها نصاً واضحاً عبر الشبكة.
- سجل من قرأ السجلات: فالوصول إلى السجلات نفسه حدث يستحق التسجيل.
وهناك جانب آخر: السجلات فيها بيانات شخصية، فعنوان IP واسم المستخدم والبريد بيانات تحدد شخصاً، ونظام حماية البيانات الشخصية PDPL يسري عليها. وهذا لا يمنع جمعها لأغراض الأمن، ولكنه يعني أن تحدد الغرض والمدة ومن يصل إليها، وألا تكتب في السجل ما لا تحتاجه أصلاً، مثل كلمات المرور أو أرقام البطاقات أو محتوى الطلبات كاملاً. وقد شرحنا ما يعنيه النظام للخدمات المستضافة في حماية البيانات الشخصية PDPL.
ماذا تطلب الضوابط الأساسية؟
المكون 2-12 في وثيقة ECC-2:2024 مخصص لإدارة سجلات الأحداث ومراقبة الأمن السيبراني، وهذه ضوابطه الفرعية مع ما يعنيه كل منها عملياً:
| الضابط | النص باختصار | ماذا تفعل |
|---|---|---|
| 2-12-3-1 | تفعيل سجلات الأحداث للأصول المعلوماتية الحساسة | قائمة بالأصول الحساسة، وأمام كل أصل المصادر المفعلة منه |
| 2-12-3-2 | تفعيل سجلات الحسابات الحساسة والمميزة وأحداث الوصول عن بعد | طبقة الهوية أولاً: SSO وVPN وSSH ولوحات الإدارة وتغييرات الحسابات |
| 2-12-3-3 | تحديد تقنيات SIEM اللازمة لجمع السجلات | مجمع مركزي وأداة بحث وقواعد، موثقة في المعمارية |
| 2-12-3-4 | المراقبة المستمرة لسجلات الأحداث | قواعد تقرأ، ومن يقرأ تنبيهاتها، وقاعدة للمصادر الصامتة |
| 2-12-3-5 | مدة الاحتفاظ لا تقل عن 12 شهراً | حساب الحجم، وطبقتان ساخنة وباردة، واختبار استرجاع الأرشيف |
ولاحظ أن المدقق لن يكتفي بأن يرى الأداة تعمل، وإنما سيطلب أن تثبت كل سطر: قائمة الأصول الحساسة ومصادرها، وسجلاً من الحساب المميز لم يمض عليه أحد عشر شهراً، ودليلاً على أن أحداً يقرأ التنبيهات. وقد شرحنا كيف تجمع هذه الأدلة تلقائياً في جمع أدلة الامتثال تلقائياً.
أخطاء شائعة
- جمع ما هو سهل بدل ما هو مهم: آلاف أسطر الوصول في الدقيقة، وغياب سجل نظام الهوية.
- وقت بلا منطقة زمنية: رأينا أنه قلب ترتيب القصة ثلاث ساعات دون أي تحذير.
- وقت بالثانية: ست محاولات في 21 مللي ثانية تصبح ست محاولات «في نفس الوقت».
- سجل بلا معرف ربط: فتخمن من الوقت أي سطر في التطبيق يقابل أي سطر في ال Proxy.
- الاعتماد على سجل التطبيق الافتراضي: سجل الوصول لا يقول من غير صلاحية من، وهذا لا يكتبه إلا التطبيق.
- إبقاء السجل على السيرفر نفسه فقط: أول ما يمسحه المهاجم، كما رأينا مع حذف Container ال SSH.
- عدم مراقبة المصادر الصامتة: المصدر المتوقف يجعل اليوم أهدأ، لا أخطر، في عين من يقرأ اللوحة.
- تقصير مدة الاحتفاظ لتوفير القرص: فتخسر الأشهر التي يحتاجها التحقيق، والحل في التخزين البارد المضغوط.
- تسجيل كلمات المرور ومحتوى الطلبات: فيصبح السجل نفسه تسريباً.
- عد الأسطر بدل الأحداث: محاولة الحساب غير الموجود في sshd سطران لا سطر.
قائمة تحقق قبل أن تبني SOC
| السؤال | كيف تتحقق |
|---|---|
| هل نعرف كل دخول ناجح وفاشل إلى SSO وVPN وSSH ولوحات الإدارة؟ | ابحث عن دخولك أنت أمس في المكان المركزي |
| هل يكتب كل تطبيق أحداث تدقيق لتغيير الصلاحيات والبيانات الحساسة؟ | غير صلاحية مستخدم تجريبي وابحث عن الحدث |
| هل كل وقت بصيغة ISO 8601 ومعه المنطقة الزمنية والمللي ثانية؟ | قارن وقت الحدث بوقت جمعه كما في سكربت الفحص |
| هل الساعات متزامنة بخدمة NTP على كل جهاز؟ | أمر timedatectl أو ما يقابله على كل سيرفر |
| هل يحمل الطلب معرفاً واحداً من ال Proxy إلى التطبيق؟ | ابحث بمعرف طلب واحد في كل السجلات |
| هل تخرج السجلات من السيرفر لحظة كتابتها إلى مجمع بصلاحيات منفصلة؟ | هل يستطيع مدير السيرفر حذف السجل في المجمع؟ |
| هل توجد قاعدة للمصادر الصامتة؟ | أوقف وكيلاً تجريبياً وانتظر التنبيه |
| هل الاحتفاظ اثنا عشر شهراً على الأقل، وهل جربنا الاسترجاع؟ | استرجع يوماً من أرشيف قبل عشرة أشهر |
| هل السجلات خالية من كلمات المرور وبيانات الدفع؟ | ابحث في السجلات عن كلمة password وعن أرقام البطاقات |
| هل نعرف أي سجلات تدقيق تعطينا كل خدمة سحابية وبأي ترخيص؟ | جدول بالخدمات ونوع السجل ومدته وطريقة تصديره |
الخلاصة
- اجمع من الأسئلة لا من الأدوات: من دخل، ومن غير ماذا، وأي عملية بدأت، وأي اتصال خرج، ومن قرأ البيانات.
- الهوية أولاً، ثم السيرفرات والشبكة، ثم سجلات التطبيق التي لا يكتبها أحد غير فريقك، ثم السحابة بحسب الترخيص.
- السطر المفيد يجيب عن ستة أسئلة، وفيه وقت UTC بالمللي ثانية، ومعرف يربطه بالسجلات الأخرى.
- في المختبر قلب وقت بلا منطقة زمنية ترتيب القصة ثلاث ساعات دون أي تحذير، وأصلحه سطر واحد في التطبيق.
- حذف المصدر محا سجله المحلي ولم يمح ما وصل إلى المجمع، والصمت نفسه يجب أن يطلق تنبيهاً.
- الاحتفاظ اثنا عشر شهراً على الأقل بحسب الضابط 2-12-3-5، والحل الاقتصادي طبقة ساخنة قصيرة وأرشيف بارد مضغوط.
- السجل دليل وبيانات شخصية معاً: احمه من الحذف، ولا تكتب فيه ما لا تحتاجه.
وأقرب المقالات إلى هذا الموضوع في المسار:
دليلمركز العمليات الأمنية SOC: كيف يعمل ومن يعمل فيه؟ما الذي يفعله مركز العمليات الأمنية فعلاً، وكم محللاً يحتاج مقعد واحد على مدار الساعة، وأي نموذج تشغيل يناسب شركتك.
دليلمراقبة السيرفرات مع Wazuh: من السجل Log إلى حظر المهاجمشغلنا Wazuh مع وكيل على سيرفر تجريبي، فكشف تخمين كلمات مرور SSH وحظر المهاجم تلقائياً، ونبه على ملف PHP دخيل خلال ثانية.
دليلمراقبة الشبكة واكتشاف التسلل IDS مع Suricata وZeekمختبر حقيقي يقرأ الحركة نفسها بأداتين، فيكشف Suricata الهجمات المعروفة ويكتب Zeek سجلاً لكل اتصال، مع الضبط وفقد الحزم.
دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.
دليلكيف يعمل محرك كشف SIEM من الداخل؟كيف يجمع SIEM ملايين السجلات ويوحدها ويطبق قواعد الكشف، مع خط أنابيب Vector وLoki وقاعدة Sigma تطلق فعلاً.
دليلجمع أدلة الامتثال Evidence تلقائياً بدل لقطات الشاشةنبني جامع أدلة صغيراً يقرأ إعدادات السيرفرات عبر SSH ويحفظها بالهاش، ثم نجربه على تغيير مفاجئ ودليل معدل وجامع فقد صلاحيته بصمت.سجل التحديثات
- أكتوبر 2026: كتابة المقال، ومختبر يوم 28 أكتوبر 2026 بأداة Vector 0.60.0 وnginx 1.31.6 وPython 3.13.17 وOpenSSH من Debian trixie.