الكتابات/tutorial/2026/08
Tutorial4 أغسطس 2026·18 دقيقة

الاستعداد للإبلاغ عن خرق البيانات: كيف تُوثّق لحظة العلم وتلتزم بالـ72 ساعة

مهلة الـ72 ساعة تبدأ من لحظة علمك بالخرق — وهي لحظة يعجز أغلب الفرق عن تحديدها بعد وقوعها. يبني هذا الدليل جرد البيانات الشخصية، وسجلّ حوادث مُحصَّنًا ضد التلاعب، ورصدًا للقراءات الجماعية، وتوليدًا للعناصر الأربعة التي يجب أن يتضمّنها الإشعار، بلغة TypeScript على Next.js و PostgreSQL.

يوم أكّدت إنترمارشيه التسريب الذي طال 287,605 عميلًا من مستخدمي خدمة Drive، لم يكن السؤال في كثير من الفرق التقنية «كيف يحدث هذا؟» بل «هل نستطيع نحن أن نجيب خلال 72 ساعة؟». وهو سؤال هندسي بقدر ما هو سؤال امتثال، وجوابه يُعدّ قبل الحادثة لا بعدها.

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

ما يطلبه النظام، في جملة واحدة

تفرض المادة 33 من النظام الأوروبي إشعار الجهة الرقابية خلال 72 ساعة من لحظة العلم. ويفرض نظام حماية البيانات الشخصية السعودي النافذة نفسها تجاه سدايا، دون حدّ أدنى لجسامة الحادثة. أمّا القانون التونسي 2004-63 فلا يفرض أيّ مهلة للإشعار — لكن إن كان بين مستخدميك مقيمون في الاتحاد الأوروبي، فالنظام الأوروبي ينطبق عليك مهما كان مقرّ شركتك.

ولحساب المهلة وفق الجهات المعنية، نشرنا حاسبة مهلة الإبلاغ عن تسريب البيانات. وبقيّة هذا الدليل هي ما يجب أن يكون جاهزًا حتى يكون لذلك الحساب قيمة.

1. جرد البيانات الشخصية، مكتوبًا كشيفرة

يجب أن يصف الإشعار «فئات الأشخاص المتأثرين وعددهم التقريبي». وإن كان جردك جدول بيانات آخر تحديث له قبل ثمانية عشر شهرًا، فستكلّفك هذه الجملة يومًا كاملًا.

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

// lib/privacy/inventory.ts
export type Sensitivity = 'identifier' | 'contact' | 'financial' | 'special';
 
export type PersonalField = {
  table: string;
  column: string;
  sensitivity: Sensitivity;
  /** سبب وجوده أصلًا — وهو ما يُسأل عنه عند التدقيق. */
  purpose: string;
};
 
export const PERSONAL_DATA: PersonalField[] = [
  { table: 'users', column: 'email',         sensitivity: 'contact',    purpose: 'المصادقة وإشعارات الطلبات' },
  { table: 'users', column: 'phone',         sensitivity: 'contact',    purpose: 'إشعار التوصيل' },
  { table: 'users', column: 'birth_date',    sensitivity: 'identifier', purpose: 'التحقّق من السنّ في المنتجات المنظَّمة' },
  { table: 'orders', column: 'ship_address', sensitivity: 'contact',    purpose: 'التوصيل' },
  { table: 'loyalty', column: 'card_number', sensitivity: 'identifier', purpose: 'برنامج الولاء' },
];
 
/** الجداول التي تحوي حقلًا شخصيًا واحدًا على الأقل. */
export const PERSONAL_TABLES = [...new Set(PERSONAL_DATA.map((f) => f.table))];

والاختبار الذي يمنع تقادم الجرد يقارن الإعلان بالمخطّط الفعلي، فيُسقط أيّ عمود يبدو شخصيًا ولم يُعلَن:

// lib/privacy/inventory.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PERSONAL_DATA } from './inventory';
import { query } from '@/lib/db/pool';
 
const SUSPECT = /(email|phone|tel|address|birth|dob|iban|card|name|passport|national_id)/i;
 
test('كل عمود يبدو شخصيًا معلَن في الجرد', async () => {
  const { rows } = await query<{ table_name: string; column_name: string }>(
    `SELECT table_name, column_name FROM information_schema.columns
      WHERE table_schema = 'public'`,
  );
  const declared = new Set(PERSONAL_DATA.map((f) => `${f.table}.${f.column}`));
  const undeclared = rows
    .filter((r) => SUSPECT.test(r.column_name))
    .map((r) => `${r.table_name}.${r.column_name}`)
    .filter((k) => !declared.has(k));
 
  assert.deepEqual(undeclared, [], `أعمدة غير معلَنة: ${undeclared.join(', ')}`);
});

وقد جُعل اختبارًا لا سكربتًا عن قصد: فعمود customer_phone يُضاف مساء الخميس ينبغي أن يُسقط التكامل المستمر، لا أن ينام حتى التدقيق التالي.

2. توثيق لحظة العلم

هذه هي القطعة التي لا يملكها أحد، وهي وحدها التي تحدّد بداية المهلة.

يتحدّث النظام الأوروبي عن قدر معقول من اليقين بأنّ حادثة أمنية مسّت بيانات شخصية. فالتنبيه الآلي غير المُتحقَّق منه لا يُشغّل المهلة، والتأكيد البشري يُشغّلها. لذا سجّل اللحظتين منفصلتين، واجعل السجلّ محصَّنًا ضد التلاعب — فالسجلّ الذي يمكن إعادة كتابته لا يثبت شيئًا.

-- migrations/incident_log.sql
CREATE TABLE incident_log (
  id            bigserial PRIMARY KEY,
  occurred_at   timestamptz NOT NULL DEFAULT now(),
  kind          text NOT NULL CHECK (kind IN ('signal', 'confirmation', 'notification', 'note')),
  summary       text NOT NULL,
  actor         text NOT NULL,
  detail        jsonb NOT NULL DEFAULT '{}',
  -- التسلسل: كل سطر يختم السطر الذي قبله.
  prev_hash     text,
  hash          text NOT NULL
);
 
-- لا تعديل ولا حذف: سجلّ حوادث أُعيدت كتابته لا قيمة له.
CREATE RULE incident_log_no_update AS ON UPDATE TO incident_log DO INSTEAD NOTHING;
CREATE RULE incident_log_no_delete AS ON DELETE TO incident_log DO INSTEAD NOTHING;

ويُحسب التسلسل في التطبيق:

// lib/privacy/incident-log.ts
import { createHash } from 'node:crypto';
import { query } from '@/lib/db/pool';
 
export type IncidentKind = 'signal' | 'confirmation' | 'notification' | 'note';
 
/**
 * يُلحق سطرًا مختومًا على سابقه.
 *
 * `signal` أي أنّ شيئًا رُصد. و`confirmation` أي أنّ إنسانًا أثبت أنّ بيانات
 * شخصية قد مُسَّت فعلًا. والثاني هو ما يُشغّل الـ72 ساعة، ولهذا لا يمكن أن
 * يشتركا في النوع نفسه.
 */
export async function record(
  kind: IncidentKind,
  summary: string,
  actor: string,
  detail: Record<string, unknown> = {},
) {
  const { rows } = await query<{ hash: string }>(
    `SELECT hash FROM incident_log ORDER BY id DESC LIMIT 1`,
  );
  const prev = rows[0]?.hash ?? null;
  const at = new Date().toISOString();
  const hash = createHash('sha256')
    .update(`${prev ?? ''}|${at}|${kind}|${summary}|${actor}|${JSON.stringify(detail)}`)
    .digest('hex');
 
  await query(
    `INSERT INTO incident_log (occurred_at, kind, summary, actor, detail, prev_hash, hash)
     VALUES ($1, $2, $3, $4, $5, $6, $7)`,
    [at, kind, summary, actor, JSON.stringify(detail), prev, hash],
  );
  return { at, hash };
}
 
/** اللحظة التي تُشغّل المهلة: أول تأكيد بشري. */
export async function awarenessMoment(): Promise<Date | null> {
  const { rows } = await query<{ occurred_at: Date }>(
    `SELECT occurred_at FROM incident_log
      WHERE kind = 'confirmation' ORDER BY id ASC LIMIT 1`,
  );
  return rows[0]?.occurred_at ?? null;
}

والتحقّق من سلامة السلسلة يكون بإعادة حساب التجزئات؛ فأيّ سطر عُدّل لاحقًا يكسر التسلسل ابتداءً منه.

3. رصد ما يستحقّ إشارة

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

// lib/privacy/detect.ts
import { query } from '@/lib/db/pool';
import { PERSONAL_TABLES } from './inventory';
import { record } from './incident-log';
 
/** كم يتجاوز حجمُ القراءة خطَّ الأساس حتى يصير إشارة. */
const ANOMALY_FACTOR = 10;
 
export async function scanForBulkReads(windowMinutes = 15) {
  const { rows } = await query<{ actor: string; table_name: string; reads: number; baseline: number }>(
    `WITH recent AS (
       SELECT actor, table_name, count(*)::int AS reads
         FROM data_access_log
        WHERE at > now() - ($1 || ' minutes')::interval
          AND table_name = ANY($2)
        GROUP BY actor, table_name
     ), norm AS (
       SELECT actor, table_name,
              (count(*) / 96.0) AS baseline   -- المتوسط لكل نافذة على مدى 24 ساعة
         FROM data_access_log
        WHERE at > now() - interval '24 hours'
          AND table_name = ANY($2)
        GROUP BY actor, table_name
     )
     SELECT r.actor, r.table_name, r.reads, COALESCE(n.baseline, 0) AS baseline
       FROM recent r LEFT JOIN norm n USING (actor, table_name)
      WHERE r.reads > GREATEST(COALESCE(n.baseline, 0) * $3, 500)`,
    [windowMinutes, PERSONAL_TABLES, ANOMALY_FACTOR],
  );
 
  for (const r of rows) {
    // إشارة لا تأكيد: فهي لا تُشغّل المهلة.
    await record('signal', `قراءة جماعية على ${r.table_name}`, 'detector', {
      actor: r.actor, reads: r.reads, baseline: Math.round(r.baseline),
    });
  }
  return rows;
}

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

4. إنتاج محتوى الإشعار

العناصر الأربعة المطلوبة هي نفسها في الأنظمة الثلاثة، فالأولى توليدها:

// lib/privacy/report.ts
import { query } from '@/lib/db/pool';
import { awarenessMoment } from './incident-log';
import { PERSONAL_DATA } from './inventory';
 
export async function draftNotification(affectedTables: string[]) {
  const awareness = await awarenessMoment();
  if (!awareness) throw new Error('لا تأكيد مسجَّل: المهلة لم تبدأ بعد.');
 
  const { rows: [{ count }] } = await query<{ count: string }>(
    `SELECT count(DISTINCT user_id)::text AS count FROM data_access_log
      WHERE table_name = ANY($1) AND at >= $2`,
    [affectedTables, awareness],
  );
 
  const categories = PERSONAL_DATA
    .filter((f) => affectedTables.includes(f.table))
    .map((f) => `${f.column} (${f.sensitivity})`);
 
  return {
    awareness,                                   // بداية المهلة
    deadline: new Date(awareness.getTime() + 72 * 3_600_000),
    description: `وصول غير مصرّح به إلى الجداول: ${affectedTables.join(', ')}.`,
    affectedCount: Number(count),
    categories,
    // تقدير العواقب وبيان الإجراءات يبقيان بقلم إنسان: فهما تقديران، والجملة
    // المولَّدة تجعلهما بلا سند.
    consequences: null,
    measures: null,
  };
}

ولاحظ ما لم يُولَّد. فالحقلان الأخيران — تقدير العواقب المحتملة وما اتُّخذ من إجراءات — تقديران. وملؤهما آليًا يُنتج نصًّا معقولًا وأجوف، وهو بالضبط ما تلتقطه الجهة الرقابية.

5. تدرّب عليها

خطة استجابة لم تُنفَّذ قطّ هي مجرّد فرضية. والتمرين يُختصر في أمر واحد:

// scripts/breach-drill.ts
import { record, awarenessMoment } from '@/lib/privacy/incident-log';
import { draftNotification } from '@/lib/privacy/report';
 
const started = Date.now();
await record('signal', '[تمرين] رُصدت قراءة جماعية', 'drill');
await record('confirmation', '[تمرين] تأكّد المساس بالبيانات', 'drill');
 
const draft = await draftNotification(['users', 'orders']);
console.log(`لحظة العلم   : ${draft.awareness.toISOString()}`);
console.log(`آخر موعد     : ${draft.deadline.toISOString()}`);
console.log(`عدد المتأثرين: ${draft.affectedCount}`);
console.log(`الفئات       : ${draft.categories.join(', ')}`);
console.log(`أُنتجت المسوّدة في ${Math.round((Date.now() - started) / 1000)} ثانية`);

والرقم المهمّ هو الأخير. فإن كان يُقاس بالثواني، فمهلة الـ72 ساعة متاحة كاملةً للقرارات. وإن كان يُقاس بالأيام فهي ليست كذلك — وهذا ما كان ينبغي اكتشافه في يوم لا يحترق فيه شيء.

الخلاصة

  • المهلة تبدأ من لحظة العلم، وهذا يستلزم القدرة على توثيقها: افصل الإشارة عن التأكيد، واجعل السجلّ محصَّنًا ضد التلاعب.
  • جرد البيانات الشخصية يُصان بـاختبار في التكامل المستمر، لا بمراجعة سنوية.
  • سجّل القراءات لا الكتابات وحدها: فبغير ذلك يبقى عدد المتأثرين تخمينًا.
  • لا تُولّد التقديرات. ولّد الوقائع.

ولحساب المهلة المنطبقة على حالتك، الأداة هنا: حاسبة مهلة الإبلاغ عن تسريب البيانات.