يصلك صباح يوم أحد بريد من مدير المالية يقول إن موظفة اسمها منى أصبح لديها دور مدير الفواتير billing-admin في نظامكم، وإنها صدرت قائمة العملاء كاملة الأسبوع الماضي، وهو يريد أن يعرف من أعطاها هذه الصلاحية، ومتى، وبأي مبرر، وهل كان مسموحاً له أن يفعل ذلك. فتفتح سجلات التطبيق، وتجد سطراً بمستوى DEBUG فيه UPDATE users SET role=$1 دون اسم من نفذه، وسطراً آخر فيه POST /admin/users/57 200، ثم تكتشف أن السجلات الأقدم من سبعة أيام حذفت مع التدوير Rotation، وأن المدير الذي تشك فيه يملك root على نفس السيرفر الذي يحفظ هذه الملفات. وهنا تفهم أن السجلات التي عندك صممت لتخبرك لماذا تعطل الكود، وليس لتخبرك ماذا فعل الناس.
وفي دليل السجلات Logging للمطورين ومديري السيرفرات نتحدث عن سجلات التطبيق: المستويات Levels والسجلات المنظمة Structured Logs والجمع المركزي والاحتفاظ، وهذا المقال يكمله في جزء واحد محدد، وهو سجل التدقيق Audit Trail، أي السجل الذي يجيب عن سؤال المسؤولية: من فعل ماذا، بأي كائن، ومتى، ومن أين، وهل كان مسموحاً له. وسوف نناقش في هذا المقال ما يلي:
- السؤال الذي يجيب عنه سجل التدقيق Audit، ولماذا لا تجيب عنه سجلات التطبيق، والفرق بينه وبين سجل الأمان وسجل التغييرات.
- ماذا تسجل بحسب OWASP وNIST، وماذا لا تسجل أبداً.
- تشريح حدث التدقيق: الحقول التي يحتاجها كل حدث، مع مثال JSON كامل.
- أين تكتب الحدث: من التطبيق، أم من قاعدة البيانات بال Triggers، أم من التقاط التغييرات CDC مع Debezium.
- كيف تجعل السجل غير قابل للعبث: جدول لا يقبل إلا الإضافة Append-only، وصلاحيات قاعدة البيانات، وسلسلة الهاش Hash Chain، والمرساة الموقعة Signed Anchor، والنسخ خارج السيرفر إلى تخزين WORM، والفصل بين المهام Separation of Duties.
- تجربة عملية حقيقية على PostgreSQL 18: جدول تدقيق تحميه الصلاحيات، وسلسلة هاش، ومستخدم Superuser يعبث بالسجل فيكشفه استعلام التحقق، ثم pgAudit.
- سجلات التدقيق في المنصات: auditd في Linux، وsudo، وpgAudit، وسجل تدقيق Kubernetes مع تجربة حقيقية، وGitea، وسجل حساب مزود السحابة.
- الاحتفاظ والخصوصية والمراجعة والتنبيه، وما تطلبه ISO 27001 والضوابط الأساسية للأمن السيبراني ونظام حماية البيانات الشخصية، ثم الأخطاء الشائعة وقائمة تحقق.
من غير صلاحية هذا الحساب؟ سؤال لا تجيب عنه سجلات التطبيق
أول ما يخطر على بال أغلب الفرق عندما يطلب منهم «سجل تدقيق» هو أن يرفعوا مستوى السجلات إلى DEBUG في لوحة الإدارة، أو أن يضيفوا سطر logger.info("user updated") بعد كل عملية حساسة، ثم يعتبروا أن الموضوع انتهى. وقد يتساءل البعض: أليست هذه سجلات أيضاً؟ ما المشكلة فيها؟ والإجابة أن سجلات التطبيق تفشل في هذا الدور لأسباب كثيرة، وكل سبب منها وحده كاف:
- تحذف بسرعة: سجلات التطبيق تدور Rotate وتحذف بعد أيام أو أسابيع لأن حجمها كبير، والسبب منطقي، فلا أحد يريد أن يحتفظ بملايين أسطر DEBUG سنة كاملة. ولكن سؤال التدقيق يأتي غالباً بعد أشهر، عندما يكتشف أحد أن شيئاً تغير.
- يستطيع المديرون تعديلها: الملفات موجودة على نفس السيرفر، ومن يملك root يستطيع أن يحذف سطراً أو يعدله، وهذا يشمل الشخص الذي تسأل عنه. والسجل الذي يستطيع المتهم أن يعدله لا يصلح دليلاً على أي شيء.
- لا يوجد فيها من فعل: السطر يقول إن الكود نفذ استعلاماً، ولا يقول أي شخص ضغط الزر، ولا لصالح أي عميل، ولا ما القيمة السابقة.
- مستواها يتغير: مطور يخفض المستوى إلى WARN لتقليل الضجيج في إصدار جديد، فيختفي سطر «user updated» دون أن ينتبه أحد، لأن أحداً لم يعتبر هذا السطر عقداً Contract يجب أن يبقى.
- مختلطة بالضجيج: سطر تغيير الصلاحية مدفون بين آلاف أسطر الكاش والاتصالات، وصيغته تختلف من مطور لآخر، فلا تستطيع أن تبني عليه تنبيهاً أو تقريراً.
والصورة التالية تبين نفس العملية، علي يمنح منى دور billing-admin، كما تظهر في سجل التطبيق وكما تظهر في سجل التدقيق:

ما هو سجل التدقيق Audit Trail؟
سجل التدقيق Audit Trail هو سلسلة مرتبة زمنياً من أحداث تصف أفعال أشخاص أو أنظمة على كائنات مهمة، بحيث تستطيع أن تعيد بناء ما حدث، وتنسب كل فعل إلى من قام به، وتثبت أن السجل نفسه لم يتغير بعد كتابته. وأقرب تشبيه له هو دفتر اليومية في المحاسبة، فالمحاسب لا يمسح قيداً خاطئاً بالممحاة، وإنما يكتب قيداً جديداً يصححه، ويبقى القيد الخاطئ ظاهراً، والمراجع يستطيع أن يتتبع كل رقم إلى المستند الذي جاء منه. وبنفس المنطق فإن سجل التدقيق لا يعدل ولا يحذف منه شيء، وكل تصحيح هو حدث جديد.
ولاحظ أن الكلمة الإنجليزية Audit تستخدم بمعنيين: سجل التدقيق Audit Log الذي نتحدث عنه، والتدقيق Audit بمعنى المراجعة التي يقوم بها مدقق داخلي أو خارجي على مؤسستك، والثاني يعتمد كثيراً على الأول، فالمدقق سوف يطلب منك أن تثبت أن صلاحيات المديرين تراجع، وأن التغييرات على الإنتاج Production معروفة المصدر، والدليل يأتي من سجل التدقيق. وهذا الجزء الثاني، أي الاستعداد للتدقيق Audit وجمع الأدلة تلقائياً، له مقالات مخططة في قسم الحوكمة والمخاطر والامتثال GRC.
سجل التدقيق مقابل سجل التطبيق وسجل الأمان وسجل التغييرات
هناك أربعة أنواع من السجلات تتداخل في أذهان الفرق، وكل منها يجيب عن سؤال مختلف ويحتاج معاملة مختلفة، والجدول التالي يلخص الفرق:
| النوع | السؤال الذي يجيب عنه | من يقرؤه | الاحتفاظ المعتاد | مثال |
|---|---|---|---|---|
| سجل التطبيق Application Log | لماذا تعطل الكود؟ ماذا كان يفعل؟ | المطورون وفريق التشغيل | أيام أو أسابيع | ERROR timeout calling payments |
| سجل الأمان Security Log | هل هناك هجوم أو سلوك مريب؟ | فريق الأمن وال SIEM | أشهر | 200 محاولة دخول فاشلة من عنوان واحد |
| سجل التدقيق Audit Log | من فعل ماذا بأي كائن، ومتى، وهل كان مسموحاً؟ | الأمن والمراجعون والإدارة والعميل أحياناً | سنة أو أكثر، بحسب السياسة | علي منح منى دور billing-admin بسبب التذكرة OPS-142 |
| بيانات التغيير Change Data | ما القيمة التي تغيرت في الصف؟ | الأنظمة الأخرى (تكامل، مستودع بيانات) | بحسب الحاجة | صف users رقم 57: role من viewer إلى billing-admin |
والتداخل بينها طبيعي، فمحاولة الدخول الفاشلة حدث أمني وحدث تدقيق في نفس الوقت، ولكن الفرق المهم أن سجل التدقيق عقد ثابت: صيغته محددة، ولا يتغير بتغيير مستوى السجلات، ولا يحذف قبل مدته، ومحمي من التعديل، أما سجل التطبيق فأداة تشخيص يغيرها المطورون كما يحتاجون. ولذلك لا تقم بخلطهما في نفس الملف أو نفس الجدول، والسبب أن سياسة الاحتفاظ والصلاحيات لكل منهما مختلفة تماماً.
ماذا تسجل في سجل التدقيق؟
لا يوجد جواب واحد لكل نظام، وورقة OWASP للسجلات Logging Cheat Sheet تقول ذلك صراحة، وتحذر من أن قائمة عمياء تسجل كل شيء تنتج ضجيجاً يخفي المشكلات الحقيقية. ولكنها تذكر أحداثاً يجب تسجيلها دائماً حيث أمكن، ومنها: نجاح المصادقة Authentication وفشلها، وفشل التفويض Authorization، وفشل إدارة الجلسات، وتغييرات الإعدادات، واستخدام الوظائف عالية الخطورة مثل إضافة المستخدمين وحذفهم وتغيير الصلاحيات، وكل أفعال مديري النظام، واستخدام الحسابات المشتركة أو حساب الطوارئ Break-glass، والوصول إلى البيانات الحساسة، واستيراد البيانات وتصديرها، وإنشاء الكائنات المهمة وحذفها. وفي المصطلحات الحكومية الأمريكية فإن عائلة التدقيق والمساءلة Audit and Accountability واختصاراً AU في NIST SP 800-53 تبدأ من الضابط AU-2 الذي يطلب منك أن تحدد الأحداث التي تسجلها، ثم AU-3 الذي يحدد محتوى كل سجل: نوع الحدث، ومتى وقع، وأين وقع، ومصدره، ونتيجته، وهوية الأشخاص والكائنات المرتبطة به.
ومن هذين المصدرين، ومن الأسئلة التي تأتي فعلاً بعد الحوادث، فهذه القائمة العملية التي تبدأ بها في أي تطبيق:
- المصادقة Authentication: الدخول الناجح والفاشل، وتفعيل المصادقة الثنائية MFA وتعطيلها، وتغيير كلمة المرور وإعادة تعيينها، وإنشاء مفاتيح API والتوكنات وإلغاؤها. وتفاصيل الجلسات والتوكنات شرحناها في الجلسات وملفات تعريف الارتباط وJWT.
- الصلاحيات والأدوار: منح دور أو سحبه، وإضافة عضو إلى مجموعة، وتغيير سياسة صلاحيات، وكل قرار رفض Deny من محرك الصلاحيات، كما في دليل الصلاحيات المركزية.
- أفعال المديرين: كل ما يفعله حساب إداري، وخاصة انتحال شخصية مستخدم Impersonation من فريق الدعم.
- الوصول إلى البيانات الحساسة: فتح سجل مريض أو ملف موظف أو بيانات بطاقة، وليس فقط تعديلها، والسبب أن التسريب يبدأ بالقراءة.
- التصدير: تصدير CSV أو تقرير أو نسخة من البيانات، مع عدد الصفوف، فالتصدير الكبير هو الشكل الأشهر لخروج البيانات.
- تغييرات الإعدادات: إعدادات الأمان، وقواعد الجدار الناري، ومزود الهوية، ومدة الاحتفاظ بالسجلات نفسها.
- الحذف: حذف المستخدمين والمستأجرين والمشاريع والمستودعات والنسخ الاحتياطية.
- إيقاف التدقيق نفسه: تعطيل السجل أو تغيير وجهته أو تقليل مدته، وهذا أول ما يفعله من يريد أن يخفي شيئاً.
وفي المقابل فهناك ما لا يجب أن يدخل السجل أبداً، وورقة OWASP تذكر منه: كلمات المرور، ومعرفات الجلسات Session IDs والتوكنات، ومفاتيح التشفير، وسلاسل الاتصال بقواعد البيانات، وبيانات البطاقات والحسابات البنكية، والبيانات الشخصية الحساسة مثل الصحة والهوية الوطنية. والسبب أن سجل التدقيق يحتفظ به طويلاً ويقرؤه أشخاص كثيرون، فكل سر يدخل فيه يعيش سنة كاملة عند أشخاص لم يكن يجب أن يروه.
تشريح حدث التدقيق: ستة أسئلة في سطر واحد
الحدث الجيد يجيب وحده عن الأسئلة الستة دون أن تحتاج إلى البحث في نظام آخر: من، ولصالح من، فعل ماذا، بأي كائن، متى ومن أين، وهل كان مسموحاً ولماذا. والمثال التالي هو حدث منح الدور في قصتنا كما يكتبه التطبيق:
{
"event_id": "0199b8a2-6c3e-7a51-9d2f-3c4b5a6d7e8f",
"occurred_at": "2026-10-06T07:02:52.318Z",
"tenant_id": "acme",
"actor": {"type": "user", "id": "user:ali", "auth": "sso+mfa", "session": "sha256:4be1…"},
"on_behalf_of": null,
"action": "role.grant",
"target": {"type": "user", "id": "user:mona"},
"changes": {"role": ["viewer", "billing-admin"]},
"result": "success",
"authz": {"policy": "roles.manage", "decision": "allow"},
"reason": "ticket OPS-142",
"source": {"ip": "203.0.113.9", "user_agent": "Mozilla/5.0 (X11; Linux x86_64)", "service": "admin-api 4.12.0"},
"request_id": "req-81bc",
"prev_hash": "6723f1c9a7e1…",
"hash": "9102386c5bf1…"
}في الإعداد أعلاه لاحظ التالي:
actorهو الهوية الحقيقية التي قامت بالفعل، شخص أو حساب خدمة Service Account، وليس مستخدم قاعدة البيانات الذي يتصل به التطبيق، والسبب أن كل الطلبات تمر عبر مستخدم واحد للتطبيق فلا يميز بين الناس. ولا تضع معرف الجلسة كما هو، وإنما هاش له إن احتجت أن تربط أحداث نفس الجلسة، كما تنصح OWASP.on_behalf_ofيملأ عندما يتصرف شخص نيابة عن آخر، مثل موظف الدعم الذي ينتحل شخصية العميل Impersonation ليرى ما يراه، أو المدير الذي يفعل شيئاً بطلب من مستخدم. وبدونه سوف يبدو في السجل أن العميل نفسه صدر بياناته، وهذا ظلم له وتضليل للتحقيق.actionاسم ثابت من قائمة معروفة بصيغةobject.verbمثلrole.grantوinvoice.export، وليس جملة حرة، والسبب أنك سوف تبني عليه التنبيهات والتقارير، والجملة الحرة تتغير مع كل مطور.targetالكائن الذي وقع عليه الفعل بنوعه ومعرفه، وchangesأسماء الحقول التي تغيرت مع القيمة قبل وبعد، ولكن فقط للقيم غير الحساسة، وسوف نعود لهذا في قسم الخصوصية.tenant_idإلزامي في أي نظام SaaS، والسبب أن كل عميل يجب أن يرى سجله وحده، وأن أي استعلام على السجل يجب أن يفلتر بالمستأجر كما شرحنا في سياق المستأجر في مقال تعدد المستأجرين Multi-tenancy.occurred_atبتوقيت UTC مع حرف Z، ويضعه السيرفر وليس المتصفح، من ساعة متزامنة مع NTP، وإلا فلن تستطيع أن ترتب أحداثاً من عدة سيرفرات، كما في قسم مزامنة الوقت من دليل التاريخ والوقت.resultنجاح أو رفض أو خطأ، وتسجيل المرفوض لا يقل أهمية عن الناجح، فعشر محاولات مرفوضة لحذف فواتير قصة بحد ذاتها، وauthzيبين أي سياسة سمحت أو رفضت.reasonالمبرر الذي يكتبه الشخص أو رقم التذكرة، وهو ما يحول الحدث من «ماذا حدث» إلى «لماذا حدث»، وأنظمة الصلاحية المؤقتة في PAM تطلبه عادة قبل منح الصلاحية.request_idيربط هذا الحدث بسجلات التطبيق والتتبع لنفس الطلب، وهذا هو الجسر بين السجلين، فسجل التدقيق يقول ماذا حدث، وسجلات التطبيق تقول كيف حدث.prev_hashوhashيجعلان أي تعديل لاحق ظاهراً، وهذا موضوع القسم القادم بعد التالي.
والصورة التالية تبين نفس الحدث مقسماً على الأسئلة التي يجيب عنها:

أين تكتب الحدث: في التطبيق أم في قاعدة البيانات أم من التغييرات؟
هناك ثلاث طرق شائعة لإنتاج أحداث التدقيق، ولكل منها ما تراه وما لا تراه:
- أحداث من التطبيق Application-level Events: الكود يكتب الحدث عند كل فعل مهم. ونقاط القوة أنه الوحيد الذي يعرف الشخص الحقيقي، والمبرر، ومن ينتحل شخصية من، والفعل بمعناه في العمل (تصدير فواتير وليس 412 استعلام SELECT). ونقاط الضعف أن المطور قد ينسى أن يكتب الحدث في مسار جديد، وأن أي تعديل مباشر على قاعدة البيانات خارج التطبيق لا يظهر فيه.
- Triggers في قاعدة البيانات: دالة تعمل عند كل INSERT أو UPDATE أو DELETE على الجداول المهمة كما في CREATE TRIGGER. ونقاط القوة أنها لا تنسى، وتلتقط أيضاً التعديل اليدوي من psql. ونقاط الضعف أنها لا تعرف إلا مستخدم قاعدة البيانات، فكل التغييرات من التطبيق تظهر باسم
app، إلا إذا مرر التطبيق هوية الشخص في كل معاملة، مثلاً بالأمرSET LOCAL app.actor = 'user:ali'ثم قرأها ال Trigger بالدالةcurrent_setting('app.actor', true)، وأنها لا ترى القراءة إطلاقاً. - التقاط التغييرات Change Data Capture واختصاراً CDC: أداة مثل Debezium برخصة Apache-2.0 تقرأ سجل المعاملات WAL في PostgreSQL عبر الفك المنطقي Logical Decoding وتحول كل تغيير إلى حدث في Kafka أو غيره. ونقاط القوة أنها لا تضيف أي عبء على المعاملة نفسها، وترى كل تغيير مهما كان مصدره. ونقاط الضعف أنها تعطيك بيانات التغيير Change Data وليس سجل تدقيق: تعرف أن الصف تغير، ولا تعرف من غيره ولماذا.
والحل الأنسب في أغلب الأنظمة هو الجمع: أحداث من التطبيق للأفعال بمعناها في العمل ولهوية الشخص، تكتب في نفس المعاملة Transaction التي تغير البيانات حتى لا يحدث تغيير دون حدث أو حدث دون تغيير، ثم طبقة من قاعدة البيانات (Triggers أو pgAudit) تلتقط ما يتجاوز التطبيق، مثل مدير قاعدة بيانات يعدل صفاً بيده الساعة الثانية فجراً. وإذا كانت أحداث التطبيق ترسل إلى نظام آخر، فاكتبها أولاً في جدول داخل نفس المعاملة ثم انقلها، وهذا ما يسمى نمط Outbox، والسبب أن الإرسال المباشر إلى نظام خارجي داخل المعاملة قد ينجح والمعاملة تفشل، أو العكس.
جدول لا يقبل إلا الإضافة Append-only، وصلاحيات قاعدة البيانات
أبسط خطأ في سجلات التدقيق المخزنة في قاعدة البيانات أن يكون جدولها مثل أي جدول آخر، يملكه مستخدم التطبيق ويستطيع أن يفعل به كل شيء، وبالتالي فإن أي ثغرة SQL Injection أو أي مطور مستعجل أو أي مخترق وصل إلى التطبيق يستطيع أن يحذف أثره بالأمر DELETE. والحل يأتي من نظام الصلاحيات Privileges في PostgreSQL نفسه، وبثلاثة أدوار منفصلة:
- مالك الجدول audit_owner: دور لا يستطيع الدخول NOLOGIN، يملك الجدول والدوال، ولا يستخدمه التطبيق ولا الناس في العمل اليومي.
- التطبيق app: يملك صلاحية INSERT فقط، فلا يستطيع أن يقرأ السجل ولا أن يعدله ولا أن يحذفه.
- المراجع auditor: يملك SELECT فقط، فيقرأ ويتحقق ولا يكتب.
ثم نضيف فوق الصلاحيات Trigger يرفض UPDATE وDELETE وTRUNCATE حتى من المالك نفسه، والسبب أن المالك يملك كل الصلاحيات على جدوله تلقائياً، فنريد طبقة ثانية تمنع الخطأ البشري. وقد يتساءل البعض: هل هذا يكفي؟ لا، والسبب أن المستخدم الخارق Superuser في PostgreSQL يتجاوز فحص الصلاحيات، ويستطيع أن يعطل ال Triggers في جلسته بالإعداد session_replication_role الذي صمم أصلاً للنسخ المنطقي، ويستطيع أن يحذف ال Trigger نفسه. ولذلك فإن الجدول المحمي بالصلاحيات يحميك من التطبيق ومن الأخطاء، ولا يحميك من مدير قاعدة البيانات، وهذا ما تكشفه طبقتان بعده: سلسلة الهاش، والنسخة خارج السيرفر. والصورة التالية تبين الأدوار الأربعة كما سوف نراها في التجربة:

كيف تكشف العبث؟ سلسلة الهاش والمرساة الموقعة
الفكرة نفسها التي شرحناها في قسم سلسلة الكتل من مقال البلوكتشين، ولن نعيد شرحها هنا: كل صف في سجل التدقيق يحمل هاش SHA-256 للصف الذي قبله، ثم يحسب هاشه هو من محتواه ومن هاش السابق، فإذا عدل أحد صفاً قديماً لم يعد محتواه يطابق الهاش المحفوظ فيه، وإذا أعاد حساب هاشه انكسر الرابط مع الصف الذي بعده. والنقطة المهمة التي ذكرناها هناك أيضاً أن السلسلة تجعل التعديل ظاهراً Tamper Evident ولا تجعله مستحيلاً، فمن يملك قاعدة البيانات يستطيع أن يعيد حساب السلسلة كلها من الصف الذي عدله إلى آخرها في أجزاء من الثانية.
والذي يغلق هذه الثغرة هو المرساة Anchor: كل ساعة أو كل يوم تأخذ رقم آخر صف وهاشه، وتحفظهما في مكان لا يملكه مدير قاعدة البيانات، وأفضل من ذلك أن توقعهما بمفتاح يملكه فريق الأمن، أو أن ترسلهما إلى جهة ختم زمني Timestamping Authority بحسب RFC 3161، أو إلى سجل شفافية عام مثل Rekor الذي يقدم نفسه كسجل لا يقبل إلا الإضافة يستطيع أي أحد أن يتحقق منه. وعندها فإن من يعيد كتابة السلسلة كلها سوف يحصل على سلسلة صحيحة داخلياً، ولكنها لا تطابق المرساة التي يملكها غيره. وهذه هي نفس الفكرة التي تستخدمها AWS في التحقق من سلامة ملفات CloudTrail، إذ تكتب كل ساعة ملف خلاصة Digest فيه هاش SHA-256 لكل ملف سجل، وتوقعه بمفتاح RSA. والصورة التالية تبين المراحل الثلاث كما سوف نراها في التجربة بنفس الهاشات:

تجربة عملية: سجل تدقيق محمي في PostgreSQL 18
الآن سوف نبني كل ما سبق على PostgreSQL 18 داخل Docker: الأدوار الثلاثة، والجدول، وسلسلة الهاش، ثم نحاول أن نكسر السجل بكل الطرق ونرى ما الذي يمنعنا وما الذي يكشفنا، ثم نفعل pgAudit. وكل المخرجات أدناه حقيقية من التجربة.
الخطوة 1: تشغيل PostgreSQL وتثبيت pgAudit
نشغل PostgreSQL 18 على منفذ محلي فقط، ثم نثبت حزمة pgAudit من مستودع PGDG الموجود مسبقاً في ال Docker Image الرسمية:
docker run -d --name lab-audit-pg -e POSTGRES_PASSWORD=labpass -p 127.0.0.1:20090:5432 postgres:18
docker exec lab-audit-pg bash -c 'apt-get update -qq >/dev/null && apt-get install -y -qq postgresql-18-pgaudit >/dev/null 2>&1; dpkg -l | grep -i pgaudit; postgres --version'والمخرج سوف يكون كما يلي:
ii postgresql-18-pgaudit 18.0-3.pgdg13+1 amd64 PostgreSQL Audit Extension
postgres (PostgreSQL) 18.6 (Debian 18.6-1.pgdg13+2)ولاحظ أننا ثبتنا الحزمة داخل Container يعمل للتجربة فقط، أما في الإنتاج فتبني Docker Image خاصة بك فيها الحزمة، والسبب أن أي تثبيت داخل Container يعمل يضيع عند إعادة إنشائه.
الخطوة 2: الأدوار والجدول وسلسلة الهاش
الملف التالي ينشئ قاعدة بيانات shop، والأدوار الثلاثة، وجدول التدقيق، ودالة تحسب النص الذي يدخل في الهاش، وال Trigger الذي يربط كل صف بما قبله، وال Trigger الذي يمنع التعديل والحذف، ثم جدول عملاء فيه عمود حساس نستخدمه مع pgAudit. احفظه باسم 01-schema.sql:
CREATE DATABASE shop;
\c shop
-- three roles: the owner of the audit objects, the application, and the people who read the trail
CREATE ROLE audit_owner NOLOGIN;
CREATE ROLE app LOGIN PASSWORD 'app-pass';
CREATE ROLE auditor LOGIN PASSWORD 'auditor-pass';
CREATE SCHEMA audit AUTHORIZATION audit_owner;
SET ROLE audit_owner;
CREATE TABLE audit.audit_log (
id bigint PRIMARY KEY,
occurred_at timestamptz NOT NULL,
tenant_id text NOT NULL,
actor text NOT NULL,
on_behalf_of text,
action text NOT NULL,
target text NOT NULL,
result text NOT NULL CHECK (result IN ('success', 'denied', 'error')),
reason text,
source_ip inet,
request_id text,
details jsonb NOT NULL DEFAULT '{}',
prev_hash text NOT NULL,
hash text NOT NULL
);
-- the exact text that is hashed for one row (everything except the two hash columns)
CREATE FUNCTION audit.payload(r audit.audit_log) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT jsonb_build_object(
'id', r.id,
'ts', to_char(r.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'),
'tenant', r.tenant_id, 'actor', r.actor, 'on_behalf_of', r.on_behalf_of,
'action', r.action, 'target', r.target, 'result', r.result, 'reason', r.reason,
'ip', host(r.source_ip), 'request_id', r.request_id, 'details', r.details)::text
$$;
-- BEFORE INSERT: number the row, stamp the time, link it to the previous row's hash
CREATE FUNCTION audit.chain() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, audit AS $$
DECLARE last audit.audit_log;
BEGIN
PERFORM pg_advisory_xact_lock(hashtext('audit.audit_log')); -- one writer at a time
SELECT * INTO last FROM audit.audit_log ORDER BY id DESC LIMIT 1;
NEW.id := coalesce(last.id, 0) + 1;
NEW.occurred_at := now();
NEW.prev_hash := coalesce(last.hash, repeat('0', 64));
NEW.hash := encode(sha256(convert_to(NEW.prev_hash || audit.payload(NEW), 'UTF8')), 'hex');
RETURN NEW;
END $$;
-- even the owner cannot change or remove rows through normal SQL
CREATE FUNCTION audit.forbid() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only: % is not allowed', TG_OP;
END $$;
CREATE TRIGGER audit_chain BEFORE INSERT ON audit.audit_log FOR EACH ROW EXECUTE FUNCTION audit.chain();
CREATE TRIGGER audit_no_update_delete BEFORE UPDATE OR DELETE ON audit.audit_log FOR EACH ROW EXECUTE FUNCTION audit.forbid();
CREATE TRIGGER audit_no_truncate BEFORE TRUNCATE ON audit.audit_log FOR EACH STATEMENT EXECUTE FUNCTION audit.forbid();
-- permissions: the app may only add rows, the auditor may only read them
REVOKE ALL ON audit.audit_log FROM PUBLIC;
GRANT USAGE ON SCHEMA audit TO app, auditor;
GRANT INSERT ON audit.audit_log TO app;
GRANT SELECT ON audit.audit_log TO auditor;
RESET ROLE;
-- a business table with a sensitive column, owned by the app
CREATE TABLE public.customers (id int PRIMARY KEY, tenant_id text, name text, national_id text);
ALTER TABLE public.customers OWNER TO app;
INSERT INTO public.customers VALUES (1, 'acme', 'Customer One', '1012345678'), (2, 'acme', 'Customer Two', '1098765432');ثم ننفذه:
docker exec -i lab-audit-pg psql -U postgres -v ON_ERROR_STOP=1 -q < 01-schema.sql && echo OKوالمخرج سوف يكون OK. وفي الإعداد أعلاه لاحظ التالي:
- ال Trigger
chainهو الذي يضع رقم الصفidوالوقتoccurred_atوالهاشين، وليس التطبيق، فحتى لو أرسل التطبيق وقتاً قديماً أو هاشاً مزوراً فسوف يستبدل، وبهذا لا يستطيع التطبيق أن يؤرخ حدثاً بتاريخ سابق. - لم نستخدم
GENERATED ALWAYS AS IDENTITYللرقم، والسبب أن ال Sequence يعطي الرقم قبل أن ينتظر الصف دوره في القفل، فقد تأخذ معاملتان الرقمين 5 و6 ثم تكتب 6 قبل 5، فتنكسر السلسلة عند الترتيب بالرقم. لذلك نحسب الرقم داخل القفلpg_advisory_xact_lockالذي يجعل كاتباً واحداً فقط في كل لحظة، وهذا يحد من سرعة الكتابة، وهو مقبول لأحداث التدقيق التي تعد بالعشرات في الثانية وليس بالآلاف، أما إذا كان حجمك أكبر فاجعل السلسلة لكل مستأجر أو لكل دقيقة. - الدالة
chainمعرفة بالخيارSECURITY DEFINER، أي تعمل بصلاحيات مالكها audit_owner وليس بصلاحيات app، ولذلك تستطيع أن تقرأ آخر صف رغم أن التطبيق لا يملك SELECT، وتثبيتsearch_pathفيها ضروري حتى لا يستطيع أحد أن يضع دالة بنفس الاسم في Schema أخرى. - الدالة
payloadتحول الصف إلى نص JSON من نوعjsonb، والسبب أن jsonb يرتب المفاتيح دائماً بنفس الطريقة، والوقت يكتب بصيغة UTC ثابتة، فيعطي نفس الصف نفس النص في كل مرة، وهذا شرط أي تحقق من هاش. والدالةsha256موجودة في PostgreSQL نفسه كما في دوال البيانات الثنائية، فلا نحتاج إضافة pgcrypto. - الصف الأول لا يوجد قبله صف، فنضع مكان الهاش السابق 64 صفراً.
الخطوة 3: التطبيق يكتب أربعة أحداث
الآن نتصل بصفتنا التطبيق app ونكتب أحداث قصتنا: سارة تدخل، وعلي يمنح منى الدور، وموظف الدعم عمر يصدر الفواتير نيابة عن سارة، ومنى تحاول حذف فاتورة فترفض. احفظ الأوامر باسم 02-events.sql:
INSERT INTO audit.audit_log (tenant_id, actor, action, target, result, source_ip, request_id, details)
VALUES ('acme', 'user:sara', 'auth.login', 'session', 'success', '203.0.113.7', 'req-7f3a', '{"method": "passkey"}');
INSERT INTO audit.audit_log (tenant_id, actor, action, target, result, reason, source_ip, request_id, details)
VALUES ('acme', 'user:ali', 'role.grant', 'user:mona', 'success', 'ticket OPS-142', '203.0.113.9', 'req-81bc',
'{"before": {"role": "viewer"}, "after": {"role": "billing-admin"}}');
INSERT INTO audit.audit_log (tenant_id, actor, on_behalf_of, action, target, result, reason, source_ip, request_id, details)
VALUES ('acme', 'user:support-omar', 'user:sara', 'invoice.export', 'invoice:*', 'success', 'customer ticket SUP-77', '198.51.100.4', 'req-90d1',
'{"rows": 412, "format": "csv"}');
INSERT INTO audit.audit_log (tenant_id, actor, action, target, result, source_ip, request_id)
VALUES ('acme', 'user:mona', 'invoice.delete', 'invoice:1043', 'denied', '203.0.113.12', 'req-a2e0');ثم ننفذه بصفة التطبيق، ونقرأ النتيجة بصفة المراجع:
docker exec -i lab-audit-pg psql -U app -d shop -v ON_ERROR_STOP=1 < 02-events.sql
docker exec lab-audit-pg psql -U auditor -d shop -c "SELECT id, to_char(occurred_at AT TIME ZONE 'UTC','HH24:MI:SS') AS utc, actor, on_behalf_of, action, target, result, left(prev_hash,12) AS prev, left(hash,12) AS hash FROM audit.audit_log ORDER BY id;"والمخرج سوف يكون كما يلي:
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
id | utc | actor | on_behalf_of | action | target | result | prev | hash
----+----------+-------------------+--------------+----------------+--------------+---------+--------------+--------------
1 | 07:02:52 | user:sara | | auth.login | session | success | 000000000000 | 6723f1c9a7e1
2 | 07:02:52 | user:ali | | role.grant | user:mona | success | 6723f1c9a7e1 | 9102386c5bf1
3 | 07:02:52 | user:support-omar | user:sara | invoice.export | invoice:* | success | 9102386c5bf1 | bb28f22178ac
4 | 07:02:52 | user:mona | | invoice.delete | invoice:1043 | denied | bb28f22178ac | c352b8a01b00
(4 rows)لاحظ أن عمود prev في كل صف هو نفس عمود hash في الصف الذي قبله، وأن الحدث الثالث يقول بوضوح إن عمر هو من صدر 412 فاتورة نيابة عن سارة، وليس سارة نفسها، وأن الحدث الرابع المرفوض مسجل مثل الناجح تماماً.
الخطوة 4: نحاول التعديل والحذف
نكسر الأمر الآن حتى نفهمه. أولاً نحاول بصفة التطبيق أن نغير نتيجة الحدث المرفوض إلى نجاح، ثم أن نحذف حدث التصدير، ثم أن نقرأ السجل:
docker exec lab-audit-pg psql -U app -d shop -c "UPDATE audit.audit_log SET result = 'success' WHERE id = 4;"
docker exec lab-audit-pg psql -U app -d shop -c "DELETE FROM audit.audit_log WHERE id = 3;"
docker exec lab-audit-pg psql -U app -d shop -c "SELECT count(*) FROM audit.audit_log;"والمخرج سوف يكون كما يلي:
ERROR: permission denied for table audit_log
ERROR: permission denied for table audit_log
ERROR: permission denied for table audit_logالتطبيق لا يستطيع أن يعدل ولا أن يحذف ولا حتى أن يقرأ، وهذا يعني أن ثغرة في التطبيق لا تعطي المخترق سجل التدقيق ليقرأه أو ليمسح منه. والآن نجرب بصفة المالك audit_owner، الذي يملك الجدول وكل صلاحياته:
docker exec lab-audit-pg psql -U postgres -d shop -c "SET ROLE audit_owner;" -c "DELETE FROM audit.audit_log WHERE id = 3;"
docker exec lab-audit-pg psql -U postgres -d shop -c "SET ROLE audit_owner;" -c "TRUNCATE audit.audit_log;"والمخرج سوف يكون كما يلي:
SET
ERROR: audit_log is append-only: DELETE is not allowed
CONTEXT: PL/pgSQL function audit.forbid() line 3 at RAISE
SET
ERROR: audit_log is append-only: TRUNCATE is not allowed
CONTEXT: PL/pgSQL function audit.forbid() line 3 at RAISEالصلاحيات سمحت للمالك، ولكن ال Trigger أوقفه. ولاحظ أن المالك يستطيع أن يحذف ال Trigger بالأمر DROP TRIGGER ثم يحذف ما يريد، ولذلك فإن audit_owner دور لا يدخل به أحد، وأي تغيير في تعريف الجدول يجب أن يمر بمراجعة وأن يظهر هو نفسه في سجل تدقيق قاعدة البيانات، وهذا ما سوف يفعله pgAudit بعد قليل.
الخطوة 5: استعلام التحقق من السلسلة
الاستعلام التالي يعيد حساب الهاش لكل صف ويقارنه بالمحفوظ، ويقارن الهاش السابق المحفوظ بهاش الصف الذي قبله فعلاً. احفظه باسم verify.sql:
SELECT id, actor, action,
prev_hash = coalesce(lag(hash) OVER (ORDER BY id), repeat('0', 64)) AS link_ok,
hash = encode(sha256(convert_to(prev_hash || audit.payload(a), 'UTF8')), 'hex') AS hash_ok
FROM audit.audit_log a
ORDER BY id;ثم نشغله بصفة المراجع، ونطبع رقم آخر صف وهاشه كاملاً:
docker exec -i lab-audit-pg psql -U auditor -d shop < verify.sql
docker exec lab-audit-pg psql -U auditor -d shop -Atc "SELECT id || ' ' || hash FROM audit.audit_log ORDER BY id DESC LIMIT 1;"والمخرج سوف يكون كما يلي:
id | actor | action | link_ok | hash_ok
----+-------------------+----------------+---------+---------
1 | user:sara | auth.login | t | t
2 | user:ali | role.grant | t | t
3 | user:support-omar | invoice.export | t | t
4 | user:mona | invoice.delete | t | t
(4 rows)
4 c352b8a01b00f7c2b1b6b713f9b472e8c8d53de7c9eb27df67f4662ea560fd67السطر الأخير هو المرساة، ولن نتركه في قاعدة البيانات، وإنما نكتبه في ملف ويوقعه فريق الأمن بمفتاح لا يملكه مدير قاعدة البيانات. ولهذا نستخدم توقيع الملفات في ssh-keygen بالخيار -Y sign، داخل Container مؤقت من Alpine. احفظ السكربت التالي باسم anchor/run.sh:
set -e
apk add -q openssh-keygen
cd /work
ssh-keygen -q -t ed25519 -N '' -C 'audit-anchor@security-team' -f anchor_key
printf '2026-10-06T07:05:00Z shop.audit_log id=4 hash=c352b8a01b00f7c2b1b6b713f9b472e8c8d53de7c9eb27df67f4662ea560fd67\n' > anchor-2026-10-06.txt
ssh-keygen -Y sign -f anchor_key -n audit-anchor anchor-2026-10-06.txt
echo "security-team $(cat anchor_key.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I security-team -n audit-anchor -s anchor-2026-10-06.txt.sig < anchor-2026-10-06.txtdocker run --rm --name lab-audit-anchor -v "$PWD/anchor:/work" alpine:3.24 sh /work/run.shوالمخرج سوف يكون كما يلي:
Signing file anchor-2026-10-06.txt
Write signature to anchor-2026-10-06.txt.sig
Good "audit-anchor" signature for security-team with ED25519 key SHA256:QHcEguyjQPKeSlQV778oOlxcvaQehzvOKuQEHadwe+Qأنشأنا المفتاح هنا في نفس التجربة للتوضيح فقط، أما في الواقع فالمفتاح الخاص عند فريق الأمن وحده، ومهمة مجدولة عنده تقرأ آخر صف وتوقعه وتحفظ الملف والتوقيع في التخزين الخارجي الذي سوف نشرحه بعد التجربة.
الخطوة 6: المستخدم الخارق يعدل حدثاً
الآن نلعب دور مدير قاعدة البيانات الذي يملك الحساب postgres، ويريد أن يخفي أن علي هو من منح الصلاحية. فيعطل ال Triggers في جلسته ويغير اسم الفاعل، ثم نشغل استعلام التحقق:
docker exec lab-audit-pg psql -U postgres -d shop -c "SET session_replication_role = replica;" -c "UPDATE audit.audit_log SET actor = 'user:system' WHERE id = 2;"
docker exec -i lab-audit-pg psql -U auditor -d shop < verify.sqlوالمخرج سوف يكون كما يلي:
SET
UPDATE 1
id | actor | action | link_ok | hash_ok
----+-------------------+----------------+---------+---------
1 | user:sara | auth.login | t | t
2 | user:system | role.grant | t | f
3 | user:support-omar | invoice.export | t | t
4 | user:mona | invoice.delete | t | t
(4 rows)التعديل نجح لأن المستخدم الخارق يتجاوز الصلاحيات وال Triggers معطلة في جلسته، ولكن استعلام التحقق كشفه فوراً: الصف 2 لم يعد محتواه يطابق الهاش المحفوظ فيه، فقيمة hash_ok هي f. وإذا أعاد المعتدي حساب هاش هذا الصف وحده، فسوف ينكسر link_ok في الصف 3، لأنه ما زال يحمل الهاش القديم.
الخطوة 7: إعادة كتابة السلسلة كلها، والمرساة تكشفها
المعتدي الأذكى لا يكتفي بذلك، فيحذف حدث التصدير، ويعيد ترقيم ما بعده، ويعيد حساب كل الهاشات من الأول. احفظ الأوامر باسم rewrite.sql:
SET session_replication_role = replica; -- user triggers do not fire
DELETE FROM audit.audit_log WHERE id = 3; -- remove the export
UPDATE audit.audit_log SET id = 3 WHERE id = 4;
DO $$
DECLARE r audit.audit_log; prev text := repeat('0', 64);
BEGIN
FOR r IN SELECT * FROM audit.audit_log ORDER BY id LOOP
r.prev_hash := prev;
r.hash := encode(sha256(convert_to(prev || audit.payload(r), 'UTF8')), 'hex');
UPDATE audit.audit_log SET prev_hash = r.prev_hash, hash = r.hash WHERE id = r.id;
prev := r.hash;
END LOOP;
END $$;docker exec -i lab-audit-pg psql -U postgres -d shop < rewrite.sql
docker exec -i lab-audit-pg psql -U auditor -d shop < verify.sql
docker exec lab-audit-pg psql -U auditor -d shop -Atc "SELECT id || ' ' || hash FROM audit.audit_log ORDER BY id DESC LIMIT 1;"والمخرج سوف يكون كما يلي:
SET
DELETE 1
UPDATE 1
DO
id | actor | action | link_ok | hash_ok
----+-------------+----------------+---------+---------
1 | user:sara | auth.login | t | t
2 | user:system | role.grant | t | t
3 | user:mona | invoice.delete | t | t
(3 rows)
3 7b22105fc9508b33c583cf6eed65414480544c892bc9a33f91232097c1c4b1ecكل الصفوف صحيحة الآن، والجدول وحده لا يستطيع أن يخبرك بأي شيء، وهذا بالضبط ما قصدناه بأن السلسلة تجعل التعديل ظاهراً ولا تجعله مستحيلاً. ولكن المرساة الموقعة عند فريق الأمن تقول إن الحدث رقم 4 هاشه c352b8a0…، فنسأل الجدول عنه:
docker exec lab-audit-pg psql -U auditor -d shop -c "SELECT 4 AS anchored_id, coalesce((SELECT hash FROM audit.audit_log WHERE id = 4), 'missing') AS hash_now, (SELECT max(id) FROM audit.audit_log) AS last_id;"والمخرج سوف يكون كما يلي:
anchored_id | hash_now | last_id
-------------+----------+---------
4 | missing | 3
(1 row)الجدول فيه ثلاثة أحداث فقط، والمرساة الموقعة قبل التعديل تثبت أنه كان فيه أربعة على الأقل، وأن آخرها كان له هاش محدد، فإعادة الكتابة انكشفت. ولو كان المعتدي أعاد الترقيم إلى أربعة صفوف بحدث مزيف، لما طابق الهاش المرساة أيضاً. وفي الإعداد أعلاه لاحظ التالي:
- المرساة تحمي فقط ما قبلها، فالأحداث التي كتبت بعد آخر مرساة يستطيع المعتدي أن يعدلها دون أن يكشفه شيء، ولذلك اجعل الفترة بين المراسي قصيرة (ساعة مثلاً)، والأفضل أن تخرج الأحداث نفسها من السيرفر خلال ثوان كما في القسم التالي.
- المرساة لا تساوي شيئاً إذا كانت محفوظة في نفس قاعدة البيانات أو على نفس السيرفر، أو إذا كان المفتاح الذي يوقعها عند نفس مدير قاعدة البيانات.
- التحقق نفسه يجب أن يعمل تلقائياً كل يوم من خارج السيرفر، وأن ينبه عند أي f أو أي اختلاف مع المرساة، فالسلسلة التي لا يتحقق منها أحد لا تكشف شيئاً.
الخطوة 8: pgAudit يسجل من قرأ العمود الحساس
سجل التدقيق الذي بنيناه يعرف ما يقرر التطبيق أن يكتبه، ولكنه لا يرى مدير قاعدة البيانات الذي يفتح psql ويقرأ أرقام الهوية مباشرة، ولا يرى تغيير الصلاحيات داخل قاعدة البيانات. وهنا يأتي pgAudit، وهو إضافة برخصة PostgreSQL، تكتب سطور تدقيق في سجل PostgreSQL العادي، ولها إصدار لكل إصدار رئيسي من PostgreSQL، فالإصدار 18.x ل PostgreSQL 18 بحسب صفحتها. ولها نوعان من التسجيل:
- تسجيل الجلسة Session Audit Logging: بالإعداد
pgaudit.logتختار أصناف الأوامر Classes التي تسجل لكل الجلسات: READ للقراءة، وWRITE للكتابة، وROLE للصلاحيات والأدوار، وDDL لتغيير التعريفات، وFUNCTION وMISC وغيرها. - تسجيل الكائنات Object Audit Logging: بالإعداد
pgaudit.roleتحدد دوراً، وكل جدول أو عمود يملك هذا الدور صلاحية عليه يسجل كل وصول إليه، فتستطيع أن تسجل قراءة عمودnational_idوحده بدلاً من تسجيل كل SELECT في النظام.
نفعل الإضافة، ونضيف إلى بداية كل سطر في السجل المستخدم وقاعدة البيانات وعنوان العميل:
docker exec lab-audit-pg psql -U postgres -c "ALTER SYSTEM SET shared_preload_libraries = 'pgaudit';" -c "ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d %r ';"
docker restart lab-audit-pg
docker exec lab-audit-pg psql -U postgres -d shop -c "CREATE EXTENSION pgaudit;" -c "SELECT extversion FROM pg_extension WHERE extname='pgaudit';" -c "CREATE ROLE pgaudit_obj NOLOGIN;" -c "GRANT SELECT (national_id) ON public.customers TO pgaudit_obj;" -c "ALTER SYSTEM SET pgaudit.log = 'role, ddl';" -c "ALTER SYSTEM SET pgaudit.role = 'pgaudit_obj';" -c "SELECT pg_reload_conf();"والمخرج سوف يكون كما يلي:
ALTER SYSTEM
ALTER SYSTEM
CREATE EXTENSION
extversion
------------
18.0
(1 row)
CREATE ROLE
GRANT
ALTER SYSTEM
ALTER SYSTEM
pg_reload_conf
----------------
t
(1 row)الآن يقرأ التطبيق اسم العميل مرة، ثم اسمه ورقم هويته مرة، ثم يمنح مدير قاعدة البيانات المراجع صلاحية قراءة جدول العملاء، ونقرأ سجل PostgreSQL:
docker exec -e PGPASSWORD=app-pass lab-audit-pg psql -U app -d shop -h 127.0.0.1 -c "SELECT name FROM customers WHERE id = 1;" -c "SELECT name, national_id FROM customers WHERE id = 1;"
docker exec lab-audit-pg psql -U postgres -d shop -c "GRANT SELECT ON public.customers TO auditor;"
docker logs --since 1m lab-audit-pg 2>&1 | grep AUDITوالمخرج من السجل سوف يكون كما يلي:
2026-10-06 07:09:12.499 UTC [58] app@shop 127.0.0.1(55474) LOG: AUDIT: OBJECT,1,1,READ,SELECT,TABLE,public.customers,"SELECT name, national_id FROM customers WHERE id = 1",<not logged>
2026-10-06 07:09:37.529 UTC [66] postgres@shop [local] LOG: AUDIT: SESSION,1,1,ROLE,GRANT,TABLE,,GRANT SELECT ON public.customers TO auditor,<not logged>في المخرج أعلاه لاحظ التالي:
- الاستعلام الأول الذي قرأ الاسم فقط لم يسجل، لأن دور
pgaudit_objيملك صلاحية على العمودnational_idوحده، أما الثاني الذي لمس رقم الهوية فسجل بالنوع OBJECT والصنف READ، مع اسم الجدول ونص الاستعلام. - أمر GRANT سجل بالنوع SESSION والصنف ROLE، لأننا طلبنا تسجيل
role, ddlلكل الجلسات، وهكذا يظهر أي تغيير في الصلاحيات أو في تعريف جدول التدقيق نفسه. - الحقول بعد كلمة AUDIT بحسب صفحة pgAudit: نوع التسجيل، ورقم الأمر في الجلسة، ورقم الأمر الفرعي، والصنف، والأمر، ونوع الكائن، واسمه، ونص الأمر، ثم المعاملات Parameters وهي
<not logged>لأننا لم نفعلpgaudit.log_parameter، وهذا مقصود، فالمعاملات قد تحمل قيماً حساسة. - من فعل ومن أين لا يأتي من pgAudit، وإنما من
log_line_prefixالذي أضفناه:app@shop 127.0.0.1.
وهناك ثلاثة حدود تذكرها صفحة pgAudit نفسها ويجب أن تعرفها: أن التسجيل «أفضل جهد» Best-effort وليس جزءاً من المعاملة، فقد تفقد سطور إذا توقف السيرفر أو امتلأ قرص السجلات، وأنه لا يمكن تدقيق المستخدم الخارق بشكل موثوق، لأنه يستطيع أن يغير الإعدادات نفسها، وتقترح لذلك تقييد حسابات Superuser واستخدام إضافة set_user لرفع الصلاحية عند الحاجة فقط، وأنه يكتب في سجل PostgreSQL على نفس السيرفر، فيجب أن يخرج هذا السجل كما في القسم التالي.
إزالة التجربة
نحذف ال Container مع ال Volume المجهول الذي أنشأته Docker Image الخاصة ب PostgreSQL، بالاسم الدقيق فقط:
docker rm -fv lab-audit-pgواحذف المجلد anchor وملفات SQL من جهازك، فالمفتاح الذي فيه مفتاح تجربة فقط.
أخرج السجل من السيرفر إلى تخزين لا يحذف منه WORM
كل ما سبق يعيش على سيرفر واحد، ومن يصبح root على هذا السيرفر يستطيع في النهاية أن يمسح القرص كله، وقد ذكرنا في خريطة الأدلة من مقال التحقيق الرقمي أن السجلات التي خارج السيرفر هي الأصعب على المخترق أن يمسحها. والضابط AU-9(2) في NIST SP 800-53 يطلب نفس الشيء بلغته: أن تحفظ سجلات التدقيق في نظام أو مكون مختلف فعلياً عن النظام الذي تدققه، وAU-9(4) يطلب أن تكون إدارة التدقيق بيد مجموعة محدودة من أصحاب الصلاحيات. والطريقة العملية لها ثلاث خطوات:
- الإرسال خلال ثوان: وكيل على كل سيرفر يقرأ جدول التدقيق أو سجل PostgreSQL أو سجل auditd ويرسله عبر TLS إلى جامع Collector في حساب أو شبكة أخرى، والسبب أن المخترق يحتاج دقائق ليصل إلى root ويفكر في مسح آثاره، وفي هذه الدقائق تكون الأحداث قد خرجت. وطريقة الجمع المركزي نفسها والأدوات شرحناها في دليل السجلات.
- تخزين يكتب مرة ويقرأ كثيراً WORM: وهو تخزين الكائنات مع قفل الكائنات Object Lock، الذي شرحنا مكانه في قسم النسخ Versioning من مقال أنواع التخزين. وبحسب توثيق Object Lock في S3 فإن له وضعين: وضع الحوكمة Governance Mode الذي يسمح لمن يملك صلاحية خاصة أن يتجاوز القفل، ووضع الامتثال Compliance Mode الذي لا يستطيع فيه أي مستخدم، ولا حتى حساب root في AWS، أن يحذف النسخة أو يقصر مدتها حتى تنتهي. وهناك أيضاً الحجز القانوني Legal Hold الذي يمنع الحذف دون مدة محددة حتى يرفعه من يملك الصلاحية، وهو ما تحتاجه عندما يطلب منك الاحتفاظ بأدلة قضية أو تحقيق. وإذا كنت تستضيف التخزين بنفسك فانتبه أن الأدوات لا تدعمها كلها كما في جدول المقارنة في نفس المقال.
- الفصل بين المهام Separation of Duties: من يدير سيرفرات الإنتاج لا يملك صلاحية الحذف من مخزن السجلات، ومن يدير مخزن السجلات لا يملك root على الإنتاج، ومفتاح توقيع المراسي عند فريق ثالث أو على الأقل عند غير مدير قاعدة البيانات. والسبب بسيط: المدير الذي يستطيع أن يمسح أثر نفسه لا يحاسب.
والصورة التالية تبين هذا الترتيب:

الاحتفاظ والحجز القانوني Legal Hold
مدة الاحتفاظ قرار تكتبه في سياسة، ولا تتركه للإعداد الافتراضي لأي أداة، والسبب أن الإعدادات الافتراضية قصيرة غالباً، فمثلاً سجل التدقيق الجديد في Gitea يحتفظ بالأحداث 30 يوماً افتراضياً كما سوف نرى، والسؤال عن الصلاحية يأتي بعد ثلاثة أشهر. وفي السعودية تطلب الضوابط الأساسية للأمن السيبراني ألا تقل مدة الاحتفاظ بسجلات الأحداث عن 12 شهراً كما سوف نرى في قسم المعايير، وقد تطلب جهات أو قطاعات أخرى أكثر من ذلك. وفي المقابل فلا تحتفظ بالسجل إلى الأبد دون سبب، لأن فيه بيانات شخصية، وبعد انتهاء المدة يحذف بقاعدة دورة حياة Lifecycle مكتوبة، إلا ما كان عليه حجز قانوني.
الخصوصية في سجل التدقيق
سجل التدقيق نفسه بيانات شخصية: فيه من دخل ومتى ومن أي عنوان، وماذا قرأ. ولذلك فإن تصميمه يتبع نفس قواعد حماية البيانات، والضابط AU-3(3) في NIST SP 800-53 يطلب صراحة أن تحد من البيانات الشخصية في سجلات التدقيق إلى العناصر التي يحددها تقييم مخاطر الخصوصية. والقواعد العملية كما يلي:
- في
changesاكتب اسم الحقل الذي تغير، والقيمة قبل وبعد فقط إذا كانت غير حساسة مثل الدور والحالة، أما رقم الهوية والراتب والتشخيص الطبي فاكتب أن الحقل تغير دون القيمة، أو اكتب هاشاً لها مع ملح Salt سري إذا كنت تحتاج أن تعرف هل عادت القيمة نفسها. - عرف الأشخاص بمعرف داخلي ثابت مثل
user:57بدلاً من البريد أو الاسم، وهذا ما يسمى إخفاء الهوية الجزئي Pseudonymisation، فإذا طلب شخص حذف بياناته تبقى أحداثه في السجل دون أن تحمل اسمه، ويبقى الربط بين المعرف والاسم في جدول المستخدمين الذي يخضع لسياسة الحذف. - لا تسجل محتوى الطلب Request Body ولا الاستجابة كاملة إلا لأحداث محددة تحتاجها فعلاً، وهذا نفس ما سوف تراه في مستويات سجل Kubernetes.
- من يقرأ سجل التدقيق يجب أن يظهر هو أيضاً في سجل تدقيق، فقراءة سجل دخول موظف معين نوع من الوصول إلى بياناته.
المراجعة والتنبيه: السجل الذي لا يقرؤه أحد لا يحمي أحداً
أغلب الفرق تبني سجل التدقيق ثم لا تفتحه إلا بعد الحادثة، وعندها تكتشف أن حدثاً مهماً لم يكن يسجل منذ أشهر. والضابط AU-6 في NIST SP 800-53 يطلب مراجعة السجلات وتحليلها والإبلاغ عنها، ودليل NIST SP 800-92 لإدارة السجلات، ومسودة مراجعته الأولى المنشورة في أكتوبر 2023، يعاملان السجلات كدورة كاملة من التخطيط إلى التحليل وليس كملفات تتراكم. والتطبيق العملي له شقان:
- تنبيهات فورية على أحداث قليلة واضحة: منح دور إداري، واستخدام حساب الطوارئ، وتصدير كبير، وتعطيل MFA لمدير، وتغيير إعدادات التدقيق نفسها، وانقطاع وصول الأحداث من سيرفر، فالسيرفر الذي توقف عن الإرسال قد يكون سيرفراً عطل أحدهم سجلاته. وهذا عمل أنظمة إدارة المعلومات والأحداث الأمنية SIEM، ولها مقالات مخططة في قسم الأمن السيبراني منها Wazuh للمراقبة الأمنية.
- مراجعة دورية شهرية أو ربع سنوية: من منح أدواراً إدارية، ومن انتحل شخصية من، وهل لكل حدث حساس مبرر في
reason، وهل تحقق التحقق اليومي من السلسلة. وهذه المراجعة نفسها تسجل، لأنها ما سوف يطلبه منك المدقق كدليل.
سجلات التدقيق في المنصات
تطبيقك ليس المصدر الوحيد، فكل طبقة تحته لها سجل تدقيق يجيب عن سؤال لا يراه التطبيق، والصورة التالية تبين أهم المصادر:
![مخزن تدقيق مركزي لا يقبل إلا الإضافة وخارج السيرفر في الوسط، وحوله سبعة مصادر بأسهم متقطعة: التطبيق يجيب من فعل ماذا داخل العمل مثل role.grant user:mona، وPostgreSQL مع pgAudit من قرأ أو غير أي جدول، وLinux مع auditd من لمس /etc/passwd ومن شغل ماذا مع ausearch -k identity، وsudo مع تسجيل الجلسات ماذا رأى المدير وكتب كمستخدم root مع sudoreplay -l، وسجل تدقيق Kubernetes API من قرأ Secret ومن ربط دوراً بمستويات Metadata وRequestResponse، وGitea من غير حماية الفروع والمفاتيح مع [audit] RECORD_OUTPUT، وسجل حساب السحابة من فتح منفذاً ومن أنشأ مفتاحاً مثل CloudTrail](https://arabroot.io/content/images/2026/10/audit-trail-06-platform-map.webp)
Linux: auditd ونظام التدقيق في النواة
نظام التدقيق في Linux جزء من النواة Kernel، والبرنامج auditd من مشروع audit-userspace يستقبل أحداثه ويكتبها في /var/log/audit/audit.log، والأداة auditctl تحمل القواعد، وausearch وaureport تبحثان فيها. والميزة التي لا تجدها في سجلات النظام العادية هي حقل auid، وتعرفه صفحة auditctl بأنه المعرف الأصلي الذي دخل به المستخدم، أي أنه يبقى سارة حتى بعد أن تصبح root عبر sudo، فتعرف من خلف كل أمر نفذه root.
وقد حاولنا تشغيل auditd داخل Container من Debian 13 بالخيارات --privileged --pid=host --network=host، والمخرج كان كما يلي:
docker run -d --name lab-audit-auditd --privileged --pid=host --network=host debian:13-slim sleep infinity
docker exec lab-audit-auditd bash -c 'apt-get update -qq >/dev/null && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq auditd >/dev/null 2>&1; auditctl -s'Error sending status request (Operation not permitted)
There was an error while processing parametersوالسبب ليس في الصلاحيات، فال Container كان يملك كل ال Capabilities، والنواة مبنية مع CONFIG_AUDIT=y، وإنما في كود التدقيق في النواة نفسه الذي يرفض أوامر التحكم بالتدقيق من أي عملية ليست في فضاء أسماء العمليات الأول Initial PID Namespace، والتعليق في الكود يقول إن auditd وauditctl مدعومان في فضاء الأسماء الأول فقط في الوقت الحالي. وفي Docker Desktop حتى --pid=host يعطيك فضاء أسماء التوزيعة داخل WSL2 وليس فضاء النواة الأول. فحذفنا ال Container بالأمر docker rm -f lab-audit-auditd. لذلك فإن auditd يعمل على السيرفر نفسه وليس داخل Container، والقواعد التالية من التوثيق ولم نشغلها في هذه التجربة. ضعها في ملف /etc/audit/rules.d/50-audit-trail.rules على سيرفر Debian أو Ubuntu:
# who changed users, groups and passwords
-a always,exit -F arch=b64 -F path=/etc/passwd -F perm=wa -F key=identity
-a always,exit -F arch=b64 -F path=/etc/shadow -F perm=wa -F key=identity
-a always,exit -F arch=b64 -F path=/etc/group -F perm=wa -F key=identity
# who changed sudo rights
-a always,exit -F arch=b64 -F path=/etc/sudoers -F perm=wa -F key=privileged
-a always,exit -F arch=b64 -F dir=/etc/sudoers.d -F perm=wa -F key=privileged
# every program run as root by a person who logged in (not by system services)
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -F key=root-cmd
# lock the rules until the next reboot
-e 2ثم تحمل القواعد وتبحث عن الأحداث بالمفتاح، مع الخيار -i الذي يحول الأرقام إلى أسماء كما في صفحة ausearch:
sudo augenrules --load
sudo ausearch -k identity -i
sudo aureport --summaryفي الإعداد أعلاه لاحظ التالي:
- استخدمنا صيغة
-a always,exit -F path= -F perm=وليس الصيغة القديمة-w /etc/passwd -p wa، والسبب أن صفحة auditctl تصف صيغة-wبأنها قديمة Deprecated وأبطأ، وتنصح بتحويلها إلى صيغة System Call، كما في صفحة audit.rules. -F auid>=1000 -F auid!=unsetيحصر القاعدة في الأشخاص الذين دخلوا فعلاً، فالخدمات التي يشغلها النظام ليس لها auid، وتعرضunsetأو 4294967295 بحسب نفس الصفحة، وبدون هذا الشرط سوف يمتلئ السجل بأوامر الخدمات.-e 2يقفل القواعد، فأي محاولة لتغييرها تسجل وترفض، ولا تتغير إلا بإعادة تشغيل السيرفر، وهي آخر سطر دائماً بحسب صفحة auditctl، وإعادة التشغيل نفسها حدث يلاحظه أحد.- الأداة augenrules تجمع ملفات
rules.dفي ملف واحد بترتيب أسمائها، ولذلك يبدأ اسم الملف برقم. - قاعدة execve لكل أوامر root قد تنتج حجماً كبيراً على سيرفر مشغول، فجربها أولاً وراقب حجم السجل، وأرسله إلى الخارج عبر إضافة الإرسال البعيد التي تأتي مع نفس المشروع.
sudo وتسجيل الجلسات
auditd يخبرك أن سارة شغلت /usr/sbin/useradd كمستخدم root، ولكنه لا يريك ماذا رأت على الشاشة. وتسجيل جلسات sudo بالخيار log_output وإعادة تشغيلها بالأمر sudoreplay شرحناه مع تجربة كاملة في قسم تسجيل جلسات sudo من دليل IAM وPAM، ومعه نفس التحذير الذي نكرره هنا: التسجيلات المحفوظة على السيرفر يستطيع root أن يحذفها، فأرسلها إلى سيرفر مركزي، أو اجعل الدخول يمر ببوابة تسجل الجلسة قبل أن تصل إلى السيرفر.
PostgreSQL مع pgAudit
رأيناه في التجربة، والقاعدة العملية له: pgaudit.log = 'role, ddl' لكل قاعدة بيانات إنتاج لأن حجمه صغير وقيمته كبيرة، وتسجيل الكائنات Object Audit للأعمدة والجداول الحساسة فقط، وتجنب pgaudit.log = 'all' إلا لفترة تحقيق محددة، لأن تسجيل كل قراءة في قاعدة بيانات مشغولة يضاعف حجم السجلات ويبطئ القرص. وصفحة pgAudit نفسها تنصح بتسجيل الكائنات لتقليل الحجم بدلاً من تسجيل كل READ وWRITE.
سجل تدقيق Kubernetes API
كل ما يحدث في Kubernetes يمر عبر ال API Server، ولذلك فإن سجل تدقيقه يجيب عن كل سؤال تقريباً: من قرأ Secret، ومن ربط دوراً، ومن حذف Deployment. ولكنه معطل حتى تعطيه سياسة، فبحسب توثيق Kubernetes إذا لم يمرر الخيار --audit-policy-file فلا يسجل أي حدث. والسياسة قائمة قواعد، وأول قاعدة تطابق الطلب تحدد مستواه، والمستويات أربعة:
- None: لا يسجل الحدث.
- Metadata: من طلب، ومتى، وأي مورد، وأي فعل، دون محتوى الطلب أو الاستجابة.
- Request: البيانات الوصفية ومحتوى الطلب، دون محتوى الاستجابة.
- RequestResponse: البيانات الوصفية ومحتوى الطلب والاستجابة.
وكل طلب يمر بمراحل Stages أيضاً، منها RequestReceived عند استلامه وResponseComplete عند انتهاء الاستجابة، وقد تسجل الحدث في أكثر من مرحلة. والسياسة التالية هي التي استخدمناها، واحفظها باسم k8s/audit-policy.yaml:
apiVersion: audit.k8s.io/v1
kind: Policy
# do not write a second event when the request is only received
omitStages:
- "RequestReceived"
rules:
# Secrets and ConfigMaps: who touched them, never their content
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps"]
# RBAC changes: keep the full request body (who was given which role)
- level: Request
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
# read-only noise from the system is not useful here
- level: None
users: ["system:kube-proxy"]
verbs: ["watch"]
- level: None
nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version"]
# everything else: metadata only
- level: Metadataولاحظ أن قاعدة ال Secrets جاءت أولاً بمستوى Metadata، والسبب أن أول قاعدة مطابقة هي التي تحكم، ولو كانت عندك قاعدة RequestResponse عامة قبلها لدخلت قيم كلمات المرور إلى سجل التدقيق نفسه.
وشغلنا السياسة على k3s داخل Docker. وفي المحاولة الأولى توقف k3s برسالة kubelet is configured to not run on a host using cgroup v1، والسبب أن نواة Docker Desktop على جهازنا تستخدم cgroup v1، لذلك شغلنا أجزاء التحكم Control Plane وحدها بالخيار --disable-agent، وهذا يكفي لأن سجل التدقيق يكتبه ال API Server وليس ال Kubelet:
docker run -d --name lab-audit-k3s --privileged -p 127.0.0.1:20091:6443 \
-v "$PWD/k8s:/etc/lab-audit" rancher/k3s:v1.37.1-k3s1 server --disable-agent \
--disable=traefik,metrics-server,servicelb,local-storage,coredns \
--kube-apiserver-arg=audit-policy-file=/etc/lab-audit/audit-policy.yaml \
--kube-apiserver-arg=audit-log-path=/var/log/k8s-audit.log \
--kube-apiserver-arg=audit-log-maxage=30ثم ننشئ Secret بصفة المدير، ونحاول قراءته بصفة سارة عبر الانتحال --as، ثم نعطي سارة دور admin ونقرؤه مرة أخرى:
docker exec lab-audit-k3s sh -c 'kubectl create secret generic db-pass --from-literal=password=S3cr3t-Value; kubectl get secret db-pass --as=sara; kubectl create rolebinding sara-admin --clusterrole=admin --user=sara -n default; kubectl get secret db-pass --as=sara'والمخرج سوف يكون كما يلي:
secret/db-pass created
Error from server (Forbidden): secrets "db-pass" is forbidden: User "sara" cannot get resource "secrets" in API group "" in the namespace "default"
rolebinding.rbac.authorization.k8s.io/sara-admin created
NAME TYPE DATA AGE
db-pass Opaque 1 5sالآن نقرأ حدث المحاولة المرفوضة من سجل التدقيق، وكل حدث سطر JSON واحد، فنمرره إلى python -m json.tool لنقرأه:
docker exec lab-audit-k3s grep '"code":403' /var/log/k8s-audit.log | python -m json.toolوالمخرج سوف يكون كما يلي:
{
"kind": "Event",
"apiVersion": "audit.k8s.io/v1",
"level": "Metadata",
"auditID": "d48eacf3-b102-41bc-95ca-f59cf02cdfa4",
"stage": "ResponseComplete",
"requestURI": "/api/v1/namespaces/default/secrets/db-pass",
"verb": "get",
"user": {
"username": "system:admin",
"groups": [
"system:masters",
"system:authenticated"
],
"extra": {
"authentication.kubernetes.io/credential-id": [
"X509SHA256=45c946247549595e4c95694bedc6e001d55a50a3b4238c0bab912ec64f905450"
]
}
},
"impersonatedUser": {
"username": "sara",
"groups": [
"system:authenticated"
]
},
"authenticationMetadata": {
"impersonationConstraint": "impersonate:user-info"
},
"sourceIPs": [
"127.0.0.1"
],
"userAgent": "kubectl/v1.37.1+k3s1 (linux/amd64) kubernetes/356c025",
"objectRef": {
"resource": "secrets",
"namespace": "default",
"name": "db-pass",
"apiVersion": "v1"
},
"responseStatus": {
"metadata": {},
"status": "Failure",
"message": "secrets \"db-pass\" is forbidden: User \"sara\" cannot get resource \"secrets\" in API group \"\" in the namespace \"default\"",
"reason": "Forbidden",
"details": {
"name": "db-pass",
"kind": "secrets"
},
"code": 403
},
"requestReceivedTimestamp": "2026-10-06T08:11:36.579536Z",
"stageTimestamp": "2026-10-06T08:11:36.580515Z",
"annotations": {
"authorization.k8s.io/decision": "forbid",
"authorization.k8s.io/reason": ""
}
}في المخرج أعلاه لاحظ التالي:
- الحدث فيه
userوهو الهوية الحقيقية التي أرسلت الطلب (المدير بشهادته)، وimpersonatedUserوهو سارة، وهذا هو نفس حقلon_behalf_ofالذي صممناه في حدث التطبيق، فلا يختلط فعل المدير بفعل سارة. - المستوى Metadata كما طلبت القاعدة الأولى، فلا يوجد محتوى ال Secret في الحدث، وقد بحثنا في السجل كله عن القيمة
S3cr3t-Valueبالأمرgrep -cفكانت النتيجة 0. - القرار
forbidفيannotationsوالرمز 403، فالمحاولات المرفوضة مسجلة بنفس التفصيل. - حدث إنشاء ال RoleBinding جاء بمستوى Request، وفيه
requestObjectبال subjects وال roleRef كاملة، أي أنك تعرف بالضبط أن المستخدم sara أعطي الدور admin، وهذا ما تريده في كل تغيير صلاحيات. - الخيار
--audit-log-maxage=30يحذف الملفات الأقدم من 30 يوماً من السيرفر، وهذا مقبول فقط لأن نسخة يجب أن تخرج إلى الخارج قبل ذلك، إما بوكيل يقرأ الملف، أو بخلفية Webhook التي يرسل بها ال API Server الأحداث إلى عنوان خارجي كما في نفس التوثيق.
وفي الخدمات المدارة مثل EKS وGKE وAKS لا تملك ملفات ال API Server، وإنما تفعل سجلات التدقيق من إعدادات الخدمة وتصل إليها في نظام السجلات عند المزود. وبعد التجربة نحذف ال Container مع ال Volume المجهول، وال Docker Image التي سحبناها لها:
docker rm -fv lab-audit-k3s
docker rmi rancher/k3s:v1.37.1-k3s1Gitea وخادم Git
خادم Git يحمل الكود ومفاتيح النشر وقواعد حماية الفروع، والسؤال «من أزال حماية الفرع main قبل النشر؟» سؤال تدقيق حقيقي. وقد أضاف الإصدار 28.0.0 من Gitea سجل تدقيق مدمجاً يسجل الأحداث المتعلقة بالأمن، ويعرضها في إعدادات الإدارة والمؤسسات والمستودعات والمستخدمين، ويسمح بالتصفية بالفاعل والفعل والمصدر، والتصدير بصيغة JSONL. وبحسب نفس الإعلان فإنه معطل افتراضياً، ويفعل بالقسم [audit] والإعداد RECORD_OUTPUT = database، ويحتفظ بالأحداث 30 يوماً افتراضياً ويتغير ذلك بالإعداد RETENTION_DAYS، وأنه مع ميزة انتحال المستخدم الجديدة للمديرين يسجل الحسابين معاً. فإذا كنت تستخدم Gitea كما في دليل تثبيت Gitea، فبعد الترقية فعله وارفع مدة الاحتفاظ إلى ما تطلبه سياستك، وصدر الأحداث دورياً إلى المخزن الخارجي، لأن قاعدة بيانات Gitea يملكها نفس من يدير Gitea.
سجل حساب مزود السحابة
إذا كانت سيرفراتك عند مزود سحابة، فكل فعل في لوحة التحكم أو عبر ال API (فتح منفذ في الجدار الناري، وإنشاء مفتاح، وأخذ Snapshot، وحذف Volume) يسجل في سجل الحساب عند المزود، مثل CloudTrail في AWS وما يقابله عند المزودين الآخرين. وهذا السجل هو الوحيد الذي يرى ما يحدث فوق سيرفراتك، فالمخترق الذي يأخذ Snapshot لقرصك ويحمله لا يظهر في أي سجل داخل السيرفر. والقاعدة نفسها تنطبق: فعله في كل المناطق، وأرسله إلى حساب منفصل لا يملك مديرو الإنتاج حذفه، وفعل التحقق من سلامة الملفات إن كانت الخدمة تقدمه.
ماذا تطلب المعايير والأنظمة؟
هذا القسم للتوجيه فقط وليس استشارة قانونية أو تفسيراً رسمياً، فارجع دائماً إلى النص الرسمي وإلى الجهة المختصة في قطاعك.
ISO/IEC 27001:2022: الضابط 8.15 التسجيل Logging في الملحق A من ISO/IEC 27001:2022 يطلب أن تنتج السجلات التي تسجل الأنشطة والاستثناءات والأعطال والأحداث المهمة، وأن تحفظ وتحمى من العبث والوصول غير المصرح به، وأن تحلل، ومعه الضابط 8.16 مراقبة الأنشطة Monitoring Activities، والضابط 8.17 مزامنة الساعات Clock Synchronization. والمدقق الذي يراجع هذا الضابط سوف يسأل عادة: ما الأحداث التي تسجلها؟ وأين تحفظ؟ ومن يستطيع حذفها؟ ومن يراجعها؟ وكل ما في هذا المقال إجابة عن هذه الأسئلة.
الضوابط الأساسية للأمن السيبراني ECC: في الإصدار ECC-2:2024 من الهيئة الوطنية للأمن السيبراني فإن المكون الفرعي 2-12 إدارة سجلات الأحداث ومراقبة الأمن السيبراني يطلب كحد أدنى في الضابط 2-12-3: تفعيل سجلات الأحداث للأصول المعلوماتية الحساسة، وللحسابات ذات الصلاحيات الهامة والحساسة وللوصول عن بعد، وتحديد تقنيات إدارة المعلومات والأحداث الأمنية SIEM اللازمة لجمعها، والمراقبة المستمرة لها، ومدة احتفاظ لا تقل عن 12 شهراً. ونموذج سياسة إدارة السجلات الذي تنشره الهيئة يطلب أيضاً أن تحمى السجلات من التغيير والحذف والوصول غير المصرح به لدعم التحقيق الجنائي الرقمي. وتجد صفحة الضوابط الرسمية في موقع الهيئة.
نظام حماية البيانات الشخصية PDPL: سجل التدقيق الذي يحمل معرفات الأشخاص وعناوينهم وما قرؤوه يحتوي بيانات شخصية، فيخضع هو أيضاً لنظام حماية البيانات الشخصية ولوائحه، وهذا سبب إضافي لقواعد الخصوصية وإخفاء الهوية الجزئي ومدة الاحتفاظ المحددة التي ذكرناها.
الأخطاء الشائعة
- اعتبار سجلات التطبيق سجل تدقيق: تحذف بالتدوير، ويعدلها المديرون، ولا فاعل فيها، ويتغير مستواها مع كل إصدار.
- جدول تدقيق يملكه مستخدم التطبيق: أي ثغرة في التطبيق تصبح قادرة على مسح أثرها بالأمر DELETE.
- الفاعل هو مستخدم قاعدة البيانات: كل الأحداث باسم
app، فلا تعرف أي شخص ضغط الزر. - نسيان الانتحال Impersonation: موظف الدعم يتصرف كعميل، والسجل يقول إن العميل هو من فعل.
- تسجيل الأسرار: كلمات مرور وتوكنات وأرقام هوية كاملة في
changesأو في محتوى الطلب، وتعيش سنة عند كل من يقرأ السجل. - سلسلة هاش دون مرساة خارجية: من يملك قاعدة البيانات يعيد حساب السلسلة كلها كما رأينا في الخطوة 7.
- السجل على نفس السيرفر: من يصبح root يمسحه، فيجب أن تخرج نسخة خلال ثوان.
- مدير الإنتاج يملك حذف مخزن السجلات: لا يوجد فصل بين المهام، فالمتهم يستطيع أن يمسح الدليل.
- وقت من المتصفح أو بالتوقيت المحلي: لا تستطيع أن ترتب أحداثاً من عدة مصادر، ويستطيع المستخدم أن يرسل أي وقت يريد.
- عدم تسجيل المحاولات المرفوضة: عشر محاولات حذف مرفوضة هي غالباً أهم من الحذف الناجح.
- الاعتماد على الإعداد الافتراضي للاحتفاظ: 30 يوماً في Gitea وفي مثال Kubernetes، والسؤال يأتي بعد ثلاثة أشهر.
- سجل لا يقرؤه أحد: لا تنبيهات، ولا مراجعة، ولا تحقق يومي من السلسلة، فتكتشف بعد الحادثة أن حدثاً مهماً لم يكن يسجل.
قائمة تحقق
- هل لديك قائمة مكتوبة بالأفعال التي تسجل، تشمل المصادقة والصلاحيات وأفعال المديرين والانتحال والوصول إلى البيانات الحساسة والتصدير والإعدادات والحذف وإيقاف التدقيق نفسه؟
- هل كل حدث فيه الفاعل الحقيقي، ومن ينوب عنه، والفعل من قائمة ثابتة، والكائن، والنتيجة، والمبرر، والمستأجر، والوقت بتوقيت UTC من السيرفر، والعنوان، ومعرف الطلب؟
- هل يكتب الحدث في نفس معاملة التغيير، أو عبر Outbox إذا كان يرسل إلى نظام آخر؟
- هل التطبيق يملك INSERT فقط على جدول التدقيق، والمراجعون SELECT فقط، والمالك دور لا يدخل به أحد؟
- هل يمنع Trigger التعديل والحذف حتى من المالك، وهل حسابات Superuser محصورة في أقل عدد ممكن؟
- هل الصفوف مربوطة بسلسلة هاش، وهل تؤخذ مرساة موقعة كل ساعة أو كل يوم وتحفظ عند جهة لا يملكها مدير قاعدة البيانات؟
- هل يعمل التحقق من السلسلة ومن المرساة تلقائياً كل يوم، وينبه عند أي اختلاف؟
- هل تخرج الأحداث من السيرفر خلال ثوان إلى تخزين WORM مع Object Lock في حساب منفصل؟
- هل الفصل بين المهام مطبق: مدير الإنتاج لا يحذف من مخزن السجلات، ومدير مخزن السجلات لا يملك root على الإنتاج؟
- هل مدة الاحتفاظ مكتوبة في سياسة (12 شهراً على الأقل إذا كانت الضوابط الأساسية تنطبق عليك)، ومعها قاعدة حذف بعد المدة وإجراء للحجز القانوني؟
- هل خرجت كلمات المرور والتوكنات والأرقام الحساسة من السجل، والأشخاص معرفون بمعرف داخلي؟
- هل auditd مفعل على كل سيرفر بقواعد للهوية وsudo وأوامر root مع
-e 2، وهل تسجيل جلسات sudo يخرج إلى سيرفر مركزي؟ - هل pgAudit مفعل ب
role, ddlوبتسجيل الكائنات للأعمدة الحساسة؟ - هل سجل تدقيق Kubernetes مفعل بسياسة تجعل ال Secrets بمستوى Metadata فقط، وتغييرات RBAC بمستوى Request؟
- هل سجل تدقيق Gitea مفعل، وسجل حساب مزود السحابة مفعل ومرسل إلى حساب منفصل؟
- هل هناك تنبيهات فورية على الأحداث الحرجة، ومراجعة دورية مسجلة هي نفسها؟
مقالات مرتبطة
سجلات التطبيق والجمع المركزي، وإدارة الحسابات المميزة وتسجيل جلساتها، والتحقيق الرقمي عندما تحتاج هذه السجلات فعلاً، والمستأجر في كل حدث، وتخزين الكائنات وObject Lock، والصلاحيات التي يسجل السجل قراراتها:
دليلالسجلات Logging للمطورين: ماذا تسجل وماذا لا تسجلكيف تكتب سجلات Logs تساعدك على فهم ما حدث في الإنتاج، وما الذي لا يدخل السجل أبداً، وكيف تجمعها في مكان واحد.
دليلإدارة الهوية والوصول IAM والوصول المميز PAMمن يملك صلاحية root على سيرفراتك ومنذ متى، وكيف تضبط حسابات المديرين بصلاحيات مؤقتة وجلسات مسجلة.
دليلالتحقيق الرقمي DFIR للمبتدئين: احفظ الأدلة قبل أن تنظف السيرفرماذا تفعل عندما تشك أن سيرفرك مخترق، ولماذا تمسح إعادة التشغيل والتنظيف الأدلة التي تحتاجها لتعرف ما حدث.
دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.
دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.
دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- سجل التدقيق Audit Trail يجيب عن سؤال المسؤولية: من فعل ماذا، بأي كائن، ومتى، ومن أين، ولصالح من، وهل كان مسموحاً، ولماذا. وسجلات التطبيق لا تجيب عنه لأنها تحذف وتعدل ولا فاعل فيها.
- الحدث الجيد يحمل الفاعل الحقيقي ومن ينوب عنه، والفعل من قائمة ثابتة، والكائن، والتغيير دون قيم حساسة، والنتيجة والمبرر، والمستأجر، والوقت بتوقيت UTC من السيرفر، ومعرف الطلب.
- الحماية طبقات: صلاحيات تجعل التطبيق يضيف فقط، وTrigger يمنع التعديل حتى من المالك، وسلسلة هاش تكشف التعديل، ومرساة موقعة خارجية تكشف إعادة كتابة السلسلة، ونسخة تخرج من السيرفر خلال ثوان إلى تخزين WORM، وفصل بين المهام حتى لا يمسح أحد أثر نفسه.
- في التجربة رأينا التطبيق يرفض بالخطأ
permission denied، والمالك يوقفه ال Trigger، والمستخدم الخارق ينجح في التعديل فيكشفه استعلام التحقق، ثم ينجح في إعادة كتابة السلسلة كلها فتكشفه المرساة، ورأينا pgAudit يسجل قراءة عمود الهوية وحده، وسجل Kubernetes يسجل الانتحال والرفض دون قيمة ال Secret. - تطبيقك ليس المصدر الوحيد: auditd وsudo وpgAudit وسجل Kubernetes وGitea وسجل حساب السحابة، كل منها يجيب عن سؤال لا يراه غيره، ويجب أن تخرج كلها إلى نفس المخزن.
- الاحتفاظ والمراجعة جزء من التصميم: مدة مكتوبة في سياسة، وتنبيهات على أحداث قليلة واضحة، ومراجعة دورية تسجل هي نفسها.
وفي مقالات قادمة بإذن الله ضمن قسم المراقبة والسجلات Observability وقسم الحوكمة والامتثال GRC سوف نتحدث عن جمع الأدلة تلقائياً، والاستعداد للتدقيق Audit وماذا يطلب المدقق وكيف تجهزه، وفي قسم الأمن السيبراني عن Wazuh للمراقبة الأمنية.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 (Docker Desktop) مع PostgreSQL 18.6 وpgAudit 18.0 وk3s v1.37.1، ومحاولة تشغيل auditd 4.0.2 داخل Container من Debian 13 وتوثيق سبب فشلها، ومراجعة OWASP وNIST SP 800-53 وSP 800-92 وتوثيق Kubernetes وpgAudit وGitea 28 والضوابط الأساسية ECC-2:2024 من مصادرها.