نموذج متكامل: WordPress/AI — إضافة تسجيل ملفات إنتاجية

📊 2 مشاهدة

نواة تسجيل الملفات

توسيع إضافي — الطبقات الأربع المقترحة كاملة

سأكمل ما اقترحته: تكامل ClamAV، تنقية البيانات الحساسة، التدوير الزمني، والاختبارات الشاملة.


1. تكامل ClamAV عبر Docker (تكامل جاهز)

docker-compose.yml

yaml

version: "3.9"

services:
  clamav:
    image: clamav/clamav:stable
    container_name: clamav
    restart: unless-stopped
    volumes:
      - clamav-db:/var/lib/clamav
      - clamav-socket:/run/clamav
    ports:
      - "3310:3310"   # TCP (بديل عن socket)
    healthcheck:
      test: ["CMD", "clamdcheck.sh"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 3m
    environment:
      - CLAMAV_NO_MILTERD=true

  logger-app:
    build: .
    depends_on:
      clamav:
        condition: service_healthy
    volumes:
      - ./logs:/var/log/myapp
      - ./quarantine:/var/quarantine/myapp
      - clamav-socket:/run/clamav
    environment:
      CLAMAV_HOST: clamav
      CLAMAV_PORT: 3310
      CLAMAV_MODE: tcp

volumes:
  clamav-db:
  clamav-socket:

malwareScanner.js — نسخة داعمة لـ TCP و Socket

javascript

const net = require('net');
const fs = require('fs').promises;
const crypto = require('crypto');

class MalwareScanner {
  constructor({ enabled, mode = 'tcp', host = '127.0.0.1', port = 3310, socketPath, timeoutMs = 15000, quarantineDir }) {
    this.enabled = enabled;
    this.mode = mode;           // 'tcp' | 'socket'
    this.host = host;
    this.port = port;
    this.socketPath = socketPath;
    this.timeoutMs = timeoutMs;
    this.quarantineDir = quarantineDir;
  }

  async scanBuffer(buffer) {
    if (!this.enabled) return { clean: true, skipped: true };
    return this._scanClamd(buffer);
  }

  _scanClamd(buffer) {
    return new Promise((resolve, reject) => {
      const socket = this.mode === 'tcp'
        ? net.connect({ host: this.host, port: this.port })
        : net.connect({ path: this.socketPath });

      let response = '';
      let settled = false;

      const finish = (fn, arg) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        try { socket.destroy(); } catch {}
        fn(arg);
      };

      const timer = setTimeout(() => {
        finish(reject, new Error('SCAN_TIMEOUT'));
      }, this.timeoutMs);

      socket.on('connect', () => {
        // بروتوكول INSTREAM: chunks بحجم 4 بايت (big-endian) ثم النهاية chunk فارغ
        socket.write('zINSTREAM\0');
        const CHUNK = 64 * 1024;
        for (let i = 0; i < buffer.length; i += CHUNK) {
          const slice = buffer.subarray(i, Math.min(i + CHUNK, buffer.length));
          const len = Buffer.alloc(4);
          len.writeUInt32BE(slice.length, 0);
          socket.write(len);
          socket.write(slice);
        }
        const end = Buffer.alloc(4); // طول صفر = نهاية
        socket.write(end);
      });

      socket.on('data', (d) => { response += d.toString('utf8'); });
      socket.on('error', (e) => finish(reject, e));
      socket.on('close', () => {
        const text = response.trim();
        if (text.endsWith('OK')) return finish(resolve, { clean: true, engine: 'clamav' });
        if (text.includes('FOUND')) {
          return finish(resolve, { clean: false, engine: 'clamav', detail: text });
        }
        finish(reject, new Error(`SCAN_ERROR: ${text || 'رد فارغ'}`));
      });
    });
  }

  async quarantine(buffer, reason) {
    if (!this.quarantineDir) return null;
    await fs.mkdir(this.quarantineDir, { recursive: true, mode: 0o700 });
    const name = `${Date.now()}-${crypto.randomUUID()}.bin`;
    const full = `${this.quarantineDir}/${name}`;
    await fs.writeFile(full, buffer, { mode: 0o600 });
    await fs.writeFile(`${full}.meta.json`, JSON.stringify({
      reason,
      ts: new Date().toISOString(),
      sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
    }), { mode: 0o600 });
    return full;
  }
}

module.exports = { MalwareScanner };

الأداء: البروتوكول INSTREAM يتجنب كتابة الملف مؤقتاً على القرص، أسرع وأكثر أماناً من clamdscan --fdpass.


2. تنقية البيانات الحساسة (redactor.js)

القواعد المدمجة

javascript

const PATTERNS = [
  // كلمات مرور في URLs
  { name: 'url_password', re: /([?&](?:password|passwd|pwd|token|api[_-]?key)=)[^&\s]+/gi, replace: '$1[REDACTED]' },
  // JWT
  { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: '[JWT]' },
  // Bearer tokens
  { name: 'bearer', re: /\bBearer\s+[A-Za-z0-9._\-+/=]{20,}/gi, replace: 'Bearer [REDACTED]' },
  // AWS Access Key
  { name: 'aws_key', re: /\bAKIA[0-9A-Z]{16}\b/g, replace: '[AWS_KEY]' },
  // AWS Secret (سياق)
  { name: 'aws_secret', re: /(aws[_-]?secret[_-]?access[_-]?key["'\s:=]+)([A-Za-z0-9/+=]{40})/gi, replace: '$1[REDACTED]' },
  // GitHub tokens
  { name: 'gh_token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, replace: '[GH_TOKEN]' },
  // Slack tokens
  { name: 'slack', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replace: '[SLACK_TOKEN]' },
  // Stripe
  { name: 'stripe', re: /\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{20,}\b/g, replace: '[STRIPE_KEY]' },
  // بطاقات ائتمان (Luhn سيُطبَّق لاحقاً)
  { name: 'cc', re: /\b(?:\d[ -]*?){13,19}\b/g, replace: '[CARD]', validate: 'luhn' },
  // IBAN مبسط
  { name: 'iban', re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g, replace: '[IBAN]' },
  // بريد إلكتروني (اختياري)
  { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replace: '[EMAIL]' },
  // رقم هوية سعودي
  { name: 'saudi_id', re: /\b[12]\d{9}\b/g, replace: '[SAUDI_ID]' },
  // IPv4
  { name: 'ipv4', re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replace: '[IP]' },
  // رقم هاتف دولي
  { name: 'phone', re: /\+\d{1,3}[\s-]?\d{6,14}\b/g, replace: '[PHONE]' },
  // مفتاح خاص PEM
  { name: 'pem', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g, replace: '[PRIVATE_KEY]' },
  // كلمة مرور في JSON
  { name: 'json_password', re: /("(?:password|passwd|pwd|secret|token)"\s*:\s*")[^"]*(")/gi, replace: '$1[REDACTED]$2' },
];

function luhnValid(num) {
  const digits = num.replace(/\D/g, '');
  if (digits.length < 13 || digits.length > 19) return false;
  let sum = 0, alt = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let d = +digits[i];
    if (alt) { d *= 2; if (d > 9) d -= 9; }
    sum += d;
    alt = !alt;
  }
  return sum % 10 === 0;
}

class Redactor {
  constructor({ rules = PATTERNS, custom = [], enabled = true } = {}) {
    this.enabled = enabled;
    this.rules = [...rules, ...custom];
    this.stats = new Map();
  }

  redact(input) {
    if (!this.enabled || typeof input !== 'string') return input;
    let out = input;
    for (const rule of this.rules) {
      out = out.replace(rule.re, (...args) => {
        const match = args[0];
        // تحقق مخصص (Luhn للبطاقات)
        if (rule.validate === 'luhn' && !luhnValid(match)) return match;
        this.stats.set(rule.name, (this.stats.get(rule.name) || 0) + 1);
        if (typeof rule.replace === 'function') return rule.replace(...args);
        // استبدال يدعم $1
        if (rule.replace.includes('$')) {
          return rule.replace.replace(/\$(\d+)/g, (_, n) => args[+n] ?? '');
        }
        return rule.replace;
      });
    }
    return out;
  }

  // نسخة عميقة على كائنات
  redactObject(obj) {
    if (!this.enabled) return obj;
    if (typeof obj === 'string') return this.redact(obj);
    if (Array.isArray(obj)) return obj.map((v) => this.redactObject(v));
    if (obj && typeof obj === 'object') {
      const out = {};
      for (const [k, v] of Object.entries(obj)) {
        if (/password|secret|token|apikey|api_key/i.test(k)) out[k] = '[REDACTED]';
        else out[k] = this.redactObject(v);
      }
      return out;
    }
    return obj;
  }

  report() {
    return Object.fromEntries(this.stats);
  }
}

module.exports = { Redactor, PATTERNS, luhnValid };

الاستخدام داخل ProductionLogger.write

javascript

const { Redactor } = require('./redactor');

// في الـ constructor
this.redactor = new Redactor(config.redaction || {});

// قبل inspect
const text = typeof content === 'string' ? content : JSON.stringify(content);
const redacted = this.redactor.redact(text);
const raw = Buffer.from(redacted, 'utf8');

اختبارات

javascript

const { Redactor, luhnValid } = require('./logger/redactor');
const r = new Redactor();

test('يحجب JWT', () => {
  expect(r.redact('token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456'))
    .toContain('[JWT]');
});

test('يحجب بطاقة صحيحة (Luhn)', () => {
  expect(r.redact('card: 4111 1111 1111 1111')).toBe('card: [CARD]');
});

test('يبقي رقماً لا يجتاز Luhn', () => {
  expect(r.redact('num: 1234 5678 9012 3456')).toBe('num: 1234 5678 9012 3456');
});

test('يحجب كلمة مرور JSON', () => {
  expect(r.redact('{"password":"hunter2"}')).toBe('{"password":"[REDACTED]"}');
});

test('يحجب مفتاح AWS', () => {
  expect(r.redact('AKIAIOSFODNN7EXAMPLE')).toBe('[AWS_KEY]');
});

3. التدوير الزمني (timeRotator.js)

javascript

const fs = require('fs').promises;
const path = require('path');
const zlib = require('zlib');
const { promisify } = require('util');
const gzip = promisify(zlib.gzip);

// فاصل زمني: 'hourly' | 'daily' | 'weekly' | 'monthly'
function periodKey(date, interval) {
  const y = date.getUTCFullYear();
  const m = String(date.getUTCMonth() + 1).padStart(2, '0');
  const d = String(date.getUTCDate()).padStart(2, '0');
  const h = String(date.getUTCHours()).padStart(2, '0');
  switch (interval) {
    case 'hourly':  return `${y}-${m}-${d}-${h}`;
    case 'daily':   return `${y}-${m}-${d}`;
    case 'weekly': {
      const tmp = new Date(Date.UTC(y, date.getUTCMonth(), date.getUTCDate()));
      const day = tmp.getUTCDay() || 7;
      tmp.setUTCDate(tmp.getUTCDate() + 4 - day);
      const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
      const week = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7);
      return `${tmp.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
    }
    case 'monthly': return `${y}-${m}`;
    default: throw new Error(`INTERVAL_UNKNOWN: ${interval}`);
  }
}

class TimeRotator {
  constructor({ interval = 'daily', keepPeriods = 30, compress = true, timezoneOffsetMinutes = 0 }) {
    this.interval = interval;
    this.keepPeriods = keepPeriods;
    this.compress = compress;
    this.tzOffset = timezoneOffsetMinutes;
  }

  // اسم ملف يحوي الفترة الحالية: app.log → app.2026-10-04.log
  pathFor(baseName, date = new Date()) {
    const adjusted = new Date(date.getTime() + this.tzOffset * 60_000);
    const key = periodKey(adjusted, this.interval);
    const dir = path.dirname(baseName);
    const ext = path.extname(baseName);
    const stem = path.basename(baseName, ext);
    return path.join(dir, `${stem}.${key}${ext}`);
  }

  // يُنفَّذ دورياً (cron) أو عند الكتابة
  async rotateIfNeeded(baseName, date = new Date()) {
    const target = this.pathFor(baseName, date);
    try {
      await fs.access(target);
    } catch {
      // الملف الحالي للفترة الجديدة غير موجود — لا شيء لتدويره
      return null;
    }
    return target;
  }

  async prune(baseName) {
    const dir = path.dirname(baseName);
    const ext = path.extname(baseName);
    const stem = path.basename(baseName, ext);
    const re = new RegExp(`^${escapeRegex(stem)}\\.(.+?)${escapeRegex(ext)}(\\.gz)?$`);

    const entries = await fs.readdir(dir);
    const versions = [];
    for (const name of entries) {
      const m = re.exec(name);
      if (m) versions.push({ name, key: m[1], gz: !!m[2] });
    }
    versions.sort((a, b) => (a.key < b.key ? 1 : -1));

    // ادمج النسخ المضغوطة وغير المضغوطة لنفس الفترة
    const seen = new Set();
    const unique = versions.filter((v) => {
      const k = v.key;
      if (seen.has(k)) return false;
      seen.add(k);
      return true;
    });

    for (const old of unique.slice(this.keepPeriods)) {
      await fs.unlink(path.join(dir, old.name)).catch(() => {});
      // امسح أيضاً النسخة غير المضغوطة إن وُجدت
      await fs.unlink(path.join(dir, `${stem}.${old.key}${ext}`)).catch(() => {});
    }
  }

  async compressPeriod(baseName, key) {
    const dir = path.dirname(baseName);
    const ext = path.extname(baseName);
    const stem = path.basename(baseName, ext);
    const plain = path.join(dir, `${stem}.${key}${ext}`);
    const gz = plain + '.gz';
    try {
      const data = await fs.readFile(plain);
      const out = await gzip(data);
      await fs.writeFile(gz, out, { mode: 0o600 });
      await fs.unlink(plain);
      return gz;
    } catch (err) {
      if (err.code === 'ENOENT') return null;
      throw err;
    }
  }
}

function escapeRegex(s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

module.exports = { TimeRotator, periodKey };

ربطه بالـ ProductionLogger

javascript

// عند كل كتابة:
const currentPath = this.timeRotator.pathFor(filePath, new Date());
// استخدم currentPath بدلاً من filePath في كل العمليات

cron يومي

javascript

// cron.js
const cron = require('node-cron');
const { TimeRotator } = require('./logger/timeRotator');

const rotator = new TimeRotator({ interval: 'daily', keepPeriods: 30, compress: true });

cron.schedule('5 0 * * *', async () => {
  // اضغط الفترة المنتهية
  const yesterday = new Date(Date.now() - 86400000);
  const key = require('./logger/timeRotator').periodKey(yesterday, 'daily');
  await rotator.compressPeriod('/var/log/myapp/app.log', key);
  await rotator.prune('/var/log/myapp/app.log');
});

اختبارات

javascript

const { periodKey, TimeRotator } = require('./logger/timeRotator');

test('مفتاح يومي', () => {
  expect(periodKey(new Date('2026-10-04T12:00:00Z'), 'daily')).toBe('2026-10-04');
});

test('مفتاح شهري', () => {
  expect(periodKey(new Date('2026-10-04T12:00:00Z'), 'monthly')).toBe('2026-10');
});

test('مسار الفترة', () => {
  const r = new TimeRotator({ interval: 'daily' });
  expect(r.pathFor('/logs/app.log', new Date('2026-10-04T00:00:00Z')))
    .toBe('/logs/app.2026-10-04.log');
});

4. الاختبارات الشاملة

jest.config.js

javascript

module.exports = {
  testEnvironment: 'node',
  coverageThreshold: {
    global: { branches: 70, functions: 75, lines: 80, statements: 80 },
  },
  collectCoverageFrom: ['logger/**/*.js', '!logger/**/*.test.js'],
};

safePath.test.js

javascript

const { resolveSafePath, assertNoSymlink } = require('../logger/safePath');
const fs = require('fs');
const os = require('os');
const path = require('path');

describe('resolveSafePath', () => {
  const root = '/var/log/app';

  test.each([
    ['../etc/passwd', /PATH_TRAVERSAL/],
    ['a/../../b',      /PATH_TRAVERSAL/],
    ['/etc/passwd',    /PATH_INVALID|PATH_ESCAPE/],
    ['a\0b',           /PATH_INVALID/],
    ['',               /PATH_INVALID/],
    ['a b/c',          /PATH_INVALID/],
  ])('يرفض %s', (input, re) => {
    expect(() => resolveSafePath(root, input)).toThrow(re);
  });

  test('يقبل مساراً آمناً', () => {
    expect(resolveSafePath(root, '2026/10/app.log')).toBe('/var/log/app/2026/10/app.log');
  });

  test('يقبل العربية', () => {
    expect(resolveSafePath(root, 'سجلات/اليوم.log')).toBe('/var/log/app/سجلات/اليوم.log');
  });
});

describe('assertNoSymlink', () => {
  let tmp;
  beforeAll(async () => {
    tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safepath-'));
  });
  afterAll(async () => {
    await fs.promises.rm(tmp, { recursive: true, force: true });
  });

  test('يقبل ملفاً غير موجود', async () => {
    await expect(assertNoSymlink(path.join(tmp, 'nope.log'), tmp)).resolves.toBeUndefined();
  });

  test('يرفض رابطاً خارج الجذر', async () => {
    const outside = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'outside-'));
    const link = path.join(tmp, 'link.log');
    await fs.promises.symlink(path.join(outside, 'x.log'), link);
    await expect(assertNoSymlink(link, tmp)).rejects.toThrow(/SYMLINK_ESCAPE/);
    await fs.promises.rm(outside, { recursive: true, force: true });
  });
});

sizeGuard.test.js

javascript

const { SizeGuard } = require('../logger/sizeGuard');
const fs = require('fs');
const os = require('os');
const path = require('path');

describe('SizeGuard', () => {
  test('يرفض إدخالاً كبيراً', () => {
    const g = new SizeGuard({ maxEntryBytes: 10, maxFileBytes: 100, maxTotalBytes: 1000 });
    expect(() => g.checkEntrySize(11)).toThrow(/ENTRY_TOO_LARGE/);
    expect(() => g.checkEntrySize(10)).not.toThrow();
  });

  test('يقرر التدوير عند امتلاء الملف', async () => {
    const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sg-'));
    const file = path.join(dir, 'a.log');
    await fs.promises.writeFile(file, Buffer.alloc(50));
    const g = new SizeGuard({ maxEntryBytes: 100, maxFileBytes: 60, maxTotalBytes: 1000 });
    const res = await g.checkFileSize(file, 20);
    expect(res.rotate).toBe(true);
    await fs.promises.rm(dir, { recursive: true, force: true });
  });
});

contentInspector.test.js

javascript

const { inspect, detectMime, sanitizeText } = require('../logger/contentInspector');

test('يكتشف PNG', () => {
  const png = Buffer.from([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0,0,0,0]);
  expect(detectMime(png)).toBe('image/png');
});

test('يرفض ثنائياً غير معروف', () => {
  const junk = Buffer.from([0x01,0x02,0x03]);
  expect(() => inspect({ content: junk })).toThrow(/BINARY_REJECTED/);
});

test('ينظف ANSI', () => {
  expect(sanitizeText('a\x1B[31mb\x1B[0mc')).toBe('abc');
});

test('يرفض MIME متناقضاً', () => {
  const pdf = Buffer.from('%PDF-1.4\n...');
  expect(() => inspect({ content: pdf, declaredMime: 'text/plain' }))
    .toThrow(/MIME_MISMATCH/);
});

writer.test.js

javascript

const { atomicWrite, appendWithFsync, withLock } = require('../logger/writer');
const fs = require('fs');
const os = require('os');
const path = require('path');

describe('writer', () => {
  let dir;
  beforeEach(async () => { dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'w-')); });
  afterEach(async () => { await fs.promises.rm(dir, { recursive: true, force: true }); });

  test('atomicWrite', async () => {
    const f = path.join(dir, 'a.txt');
    await atomicWrite(f, Buffer.from('hello'));
    expect(await fs.promises.readFile(f, 'utf8')).toBe('hello');
  });

  test('appendWithFsync', async () => {
    const f = path.join(dir, 'a.txt');
    await appendWithFsync(f, Buffer.from('x'));
    await appendWithFsync(f, Buffer.from('y'));
    expect(await fs.promises.readFile(f, 'utf8')).toBe('xy');
  });

  test('withLock يمنع التداخل', async () => {
    const order = [];
    await Promise.all([
      withLock('k', async () => { order.push('a1'); await new Promise(r=>setTimeout(r,20)); order.push('a2'); }),
      withLock('k', async () => { order.push('b1'); order.push('b2'); }),
    ]);
    expect(order).toEqual(['a1','a2','b1','b2']);
  });
});

redactor.test.js (شامل)

javascript

const { Redactor, luhnValid } = require('../logger/redactor');

describe('Luhn', () => {
  test.each([
    ['4111111111111111', true],
    ['1234567890123456', false],
    ['378282246310005', true],
  ])('%s → %s', (n, expected) => {
    expect(luhnValid(n)).toBe(expected);
  });
});

describe('Redactor', () => {
  const r = new Redactor();

  test('JWT', () => {
    expect(r.redact('a eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature123 b'))
      .toContain('[JWT]');
  });

  test('AWS key', () => {
    expect(r.redact('AKIAIOSFODNN7EXAMPLE')).toBe('[AWS_KEY]');
  });

  test('PEM', () => {
    const pem = '-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----';
    expect(r.redact(pem)).toBe('[PRIVATE_KEY]');
  });

  test('redactObject عميق', () => {
    const out = r.redactObject({ user: 'a', password: 'x', nested: { token: 'y' } });
    expect(out).toEqual({ user: 'a', password: '[REDACTED]', nested: { token: '[REDACTED]' } });
  });

  test('عدّاد الإحصاءات', () => {
    const r2 = new Redactor();
    r2.redact('AKIAIOSFODNN7EXAMPLE and AKIAIOSFODNN7EXAMPL2');
    expect(r2.report().aws_key).toBe(2);
  });
});

integration.test.js — من البداية للنهاية

javascript

const { ProductionLogger } = require('../logger');
const fs = require('fs');
const os = require('os');
const path = require('path');

let root, quarantine;
beforeEach(async () => {
  root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pl-'));
  quarantine = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'q-'));
});
afterEach(async () => {
  await fs.promises.rm(root, { recursive: true, force: true });
  await fs.promises.rm(quarantine, { recursive: true, force: true });
});

function makeLogger(overrides = {}) {
  return new ProductionLogger({
    storagePath: root,
    limits: { maxEntryBytes: 1024, maxFileBytes: 4096, maxTotalBytes: 100_000 },
    malware: { enabled: false, quarantineDir: quarantine },
    rotation: { keep: 3, compress: true },
    ...overrides,
  });
}

test('كتابة بسيطة مع تدقيق', async () => {
  const log = makeLogger();
  const res = await log.write('app.log', 'hello\n', { append: true, source: 'test' });
  expect(res.bytes).toBe(6);
  const audit = await fs.promises.readFile(path.join(root, '_audit.log'), 'utf8');
  expect(audit).toMatch(/"result":"ok"/);
});

test('يرفض path traversal', async () => {
  const log = makeLogger();
  await expect(log.write('../evil.log', 'x')).rejects.toThrow(/PATH_TRAVERSAL/);
});

test('ينظف المحتوى الخطِر', async () => {
  const log = makeLogger();
  await log.write('app.log', 'a\x1B[31mb\x00c\n', { append: true });
  const out = await fs.promises.readFile(path.join(root, 'app.log'), 'utf8');
  expect(out).toBe('abc\n');
});

test('يطبق التنقية قبل الكتابة', async () => {
  const log = makeLogger({ redaction: { enabled: true } });
  await log.write('app.log', 'token=AKIAIOSFODNN7EXAMPLE\n', { append: true });
  const out = await fs.promises.readFile(path.join(root, 'app.log'), 'utf8');
  expect(out).toContain('[AWS_KEY]');
  expect(out).not.toContain('AKIAIOSFODNN7EXAMPLE');
});

test('يدوّر عند تجاوز الحجم', async () => {
  const log = makeLogger({
    limits: { maxEntryBytes: 4096, maxFileBytes: 100, maxTotalBytes: 10_000 },
  });
  for (let i = 0; i < 5; i++) {
    await log.write('app.log', 'x'.repeat(40) + '\n', { append: true });
  }
  const files = await fs.promises.readdir(root);
  expect(files.some((f) => /^app\.log\.\d{4}-/.test(f) || f === 'app.log')).toBe(true);
});

سكربت تشغيل

json

{
  "scripts": {
    "test": "jest --runInBand",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "lint": "eslint logger/**/*.js"
  }
}

5. مصفوفة تغطية نهائية

المجالالوحدةالاختبارات
تقييد المسارsafePath6 حالات + symlink
حدود الحجمsizeGuardإدخال + ملف + إجمالي
فحص المحتوىcontentInspectorMIME + تنظيف + رفض ثنائي
فحص الفيروساتmalwareScannerTCP/Socket + quarantine
MetadatametadataHandlerبصمة + undescribed
الكتابة الذريةwriteratomic + append + قفل
التحققverifierحجم + بصمة
التدويرrotator + timeRotatorحجم + فترة + ضغط + prune
التنقيةredactor14 قاعدة + Luhn + إحصاءات
التدقيقauditسجل لكل عملية
التكاملProductionLoggerN2E كامل

6. خطوات النشر المقترحة

  1. بناء الصورة:bashdocker compose build
  2. تشغيل ClamAV أولاً (تحميل قاعدة بيانات الفيروسات ~3 دقائق أول مرة):bashdocker compose up -d clamav docker compose logs -f clamav # حتى “Database loaded”
  3. تشغيل التطبيق:bashdocker compose up -d logger-app
  4. التنظيف الدوري: cron داخل الحاوية لتشغيل prune و compressPeriod.
  5. المراقبة: أضف /metrics يعرض redactor.report() وعدّادات الكتابة.

هل تريد الخطوة التالية:

  • لوحة مراقبة Prometheus + Grafana جاهزة؟
  • نظام تنبيهات (Slack/Email) عند MALWARE_DETECTED أو STORAGE_FULL؟
  • دعم التوقيع الرقمي على السجلات (signing chain / append-only)؟
  • تحويلها إلى مكتبة npm قابلة للنشر مع TypeScript definitions؟

This response is AI-generated, for reference only.

توسيع نهائي — الطبقات الأربع المتبقية كاملة

سأكمل: المراقبة، التنبيهات، التوقيع الرقمي، وتحويلها لمكتبة npm مع TypeScript.


1. لوحة Prometheus + Grafana

metrics.js — عدّادات Prometheus

javascript

const client = require('prom-client');

class Metrics {
  constructor({ enabled = true, prefix = 'logger_' } = {}) {
    this.enabled = enabled;
    if (!enabled) return;

    this.registry = new client.Registry();
    client.collectDefaultMetrics({ register: this.registry });

    const c = (name, help, labels = []) =>
      new client.Counter({ name: prefix + name, help, labelNames: labels, registers: [this.registry] });
    const g = (name, help, labels = []) =>
      new client.Gauge({ name: prefix + name, help, labelNames: labels, registers: [this.registry] });
    const h = (name, help, labels = [], buckets) =>
      new client.Histogram({ name: prefix + name, help, labelNames: labels, buckets, registers: [this.registry] });

    this.writesTotal     = c('writes_total', 'إجمالي الكتابات', ['result', 'mime']);
    this.bytesTotal      = c('bytes_total', 'إجمالي البايتات المكتوبة', ['result']);
    this.errorsTotal     = c('errors_total', 'إجمالي الأخطاء', ['code']);
    this.rotationsTotal  = c('rotations_total', 'عدد عمليات التدوير', ['kind']);
    this.malwareTotal    = c('malware_total', 'عدد الملفات الخبيثة المكتشفة', ['engine']);
    this.redactionsTotal = c('redactions_total', 'عدد عمليات التنقية', ['rule']);
    this.quarantineTotal = c('quarantine_total', 'عدد الملفات المعزولة');

    this.storageBytes    = g('storage_bytes', 'حجم التخزين الكلي');
    this.activeFiles     = g('active_files', 'عدد الملفات النشطة');
    this.scanDuration    = h('scan_duration_seconds', 'زمن فحص الفيروسات',
                             ['engine'], [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10, 30]);
    this.writeDuration   = h('write_duration_seconds', 'زمن الكتابة',
                             ['op'], [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1]);
  }

  async metricsText() {
    if (!this.enabled) return '';
    return this.registry.metrics();
  }

  contentType() {
    return this.enabled ? this.registry.contentType : 'text/plain';
  }
}

module.exports = { Metrics };

دمجها في ProductionLogger

javascript

// داخل write()
const t0 = process.hrtime.bigint();
try {
  // ... الفحوصات
  const scanT0 = process.hrtime.bigint();
  const scan = await this.scanner.scanBuffer(inspected.buffer);
  this.metrics?.scanDuration.labels(this.scanner.engine).observe(
    Number(process.hrtime.bigint() - scanT0) / 1e9
  );
  if (!scan.clean) this.metrics?.malwareTotal.labels(this.scanner.engine).inc();

  // ... الكتابة
  const dur = Number(process.hrtime.bigint() - t0) / 1e9;
  this.metrics?.writeDuration.labels(options.append ? 'append' : 'write').observe(dur);
  this.metrics?.writesTotal.labels('ok', meta.detectedMime).inc();
  this.metrics?.bytesTotal.labels('ok').inc(result.bytes);

  // عدّادات التنقية
  for (const [rule, n] of Object.entries(this.redactor.report())) {
    this.metrics?.redactionsTotal.labels(rule).inc(n);
  }
} catch (err) {
  this.metrics?.writesTotal.labels('error', 'unknown').inc();
  this.metrics?.errorsTotal.labels(err.code || err.message.split(':')[0]).inc();
  throw err;
}

خادم الـ /metrics المستقل

javascript

// metrics-server.js
const http = require('http');
const { Metrics } = require('./logger/metrics');

function startMetricsServer(metrics, { port = 9464, host = '0.0.0.0' } = {}) {
  const server = http.createServer(async (req, res) => {
    if (req.url === '/metrics') {
      const body = await metrics.metricsText();
      res.writeHead(200, { 'Content-Type': metrics.contentType() });
      res.end(body);
    } else if (req.url === '/healthz') {
      res.writeHead(200); res.end('ok');
    } else {
      res.writeHead(404); res.end();
    }
  });
  server.listen(port, host, () => {
    console.log(`Metrics on http://${host}:${port}/metrics`);
  });
  return server;
}

module.exports = { startMetricsServer };

prometheus.yml

yaml

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'logger'
    static_configs:
      - targets: ['logger-app:9464']

إضافة إلى docker-compose.yml

yaml

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prom-data:/prometheus
    ports: ["9090:9090"]

  grafana:
    image: grafana/grafana:latest
    depends_on: [prometheus]
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    ports: ["3000:3000"]

volumes:
  prom-data:
  grafana-data:

grafana/provisioning/datasources/prometheus.yml

yaml

apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

grafana/provisioning/dashboards/dashboards.yml

yaml

apiVersion: 1
providers:
  - name: 'logger'
    folder: ''
    type: file
    options:
      path: /var/lib/grafana/dashboards

grafana/dashboards/logger.json (مختصر)

json

{
  "title": "File Logger",
  "panels": [
    {
      "type": "timeseries",
      "title": "Writes / sec (ok vs error)",
      "targets": [
        { "expr": "rate(logger_writes_total{result=\"ok\"}[1m])", "legendFormat": "ok" },
        { "expr": "rate(logger_writes_total{result=\"error\"}[1m])", "legendFormat": "error" }
      ]
    },
    {
      "type": "stat",
      "title": "Storage bytes",
      "targets": [{ "expr": "logger_storage_bytes" }]
    },
    {
      "type": "timeseries",
      "title": "Scan duration p95",
      "targets": [
        { "expr": "histogram_quantile(0.95, rate(logger_scan_duration_seconds_bucket[5m]))" }
      ]
    },
    {
      "type": "stat",
      "title": "Malware detected (24h)",
      "targets": [
        { "expr": "increase(logger_malware_total[24h])" }
      ]
    }
  ]
}

قواعد التنبيه prometheus-alerts.yml

yaml

groups:
  - name: logger
    rules:
      - alert: HighErrorRate
        expr: rate(logger_writes_total{result="error"}[5m]) > 0.1
        for: 5m
        labels: { severity: warning }
        annotations:
          summary: "معدل أخطاء الكتابة مرتفع"

      - alert: MalwareDetected
        expr: increase(logger_malware_total[5m]) > 0
        labels: { severity: critical }
        annotations:
          summary: "تم كشف برمجية خبيثة"

      - alert: StorageNearFull
        expr: logger_storage_bytes / 2.0e9 > 0.9
        for: 10m
        labels: { severity: warning }
        annotations:
          summary: "التخزين يقترب من الامتلاء"

      - alert: ScanLatencyHigh
        expr: histogram_quantile(0.95, rate(logger_scan_duration_seconds_bucket[5m])) > 5
        for: 5m
        labels: { severity: warning }
        annotations:
          summary: "زمن فحص الفيروسات مرتفع"

2. نظام التنبيهات (Slack / Email / Webhook)

alerts.js

javascript

const https = require('https');
const http = require('http');
const { URL } = require('url');

class AlertManager {
  constructor({ channels = [], throttleMs = 60_000, logger = console } = {}) {
    this.channels = channels;
    this.throttleMs = throttleMs;
    this.logger = logger;
    this.lastSent = new Map(); // key → timestamp
  }

  _key(event) {
    return `${event.severity}:${event.code}`;
  }

  _shouldSend(event) {
    if (event.severity === 'critical') return true; // لا كتم للحرج
    const k = this._key(event);
    const last = this.lastSent.get(k) || 0;
    return Date.now() - last > this.throttleMs;
  }

  async send(event) {
    if (!this._shouldSend(event)) return { skipped: true };
    this.lastSent.set(this._key(event), Date.now());

    const results = await Promise.allSettled(
      this.channels.map((ch) => this._sendTo(ch, event))
    );
    results.forEach((r, i) => {
      if (r.status === 'rejected') {
        this.logger.error?.(`فشل إرسال تنبيه للقناة ${i}:`, r.reason?.message);
      }
    });
    return { sent: results.filter(r => r.status === 'fulfilled').length };
  }

  async _sendTo(channel, event) {
    if (channel.type === 'slack') return this._slack(channel, event);
    if (channel.type === 'webhook') return this._webhook(channel, event);
    if (channel.type === 'smtp') return this._smtp(channel, event);
    throw new Error(`CHANNEL_UNKNOWN: ${channel.type}`);
  }

  async _slack(ch, event) {
    const color = { info: '#36a64f', warning: '#ff9900', critical: '#ff0000' }[event.severity] || '#cccccc';
    const payload = {
      attachments: [{
        color,
        title: `[${event.severity.toUpperCase()}] ${event.title}`,
        text: event.message,
        fields: Object.entries(event.fields || {}).map(([k, v]) => ({
          title: k, value: String(v), short: true,
        })),
        ts: Math.floor(Date.now() / 1000),
      }],
    };
    return this._httpJson(ch.webhookUrl, 'POST', payload);
  }

  async _webhook(ch, event) {
    return this._httpJson(ch.url, ch.method || 'POST', event, ch.headers);
  }

  async _smtp(ch, event) {
    // استخدام nodemailer اختياري
    let nodemailer;
    try { nodemailer = require('nodemailer'); }
    catch { throw new Error('nodemailer غير مثبّت'); }

    const transport = nodemailer.createTransport({
      host: ch.host, port: ch.port, secure: ch.secure ?? true,
      auth: { user: ch.user, pass: ch.pass },
    });
    await transport.sendMail({
      from: ch.from || ch.user,
      to: ch.to,
      subject: `[${event.severity.toUpperCase()}] ${event.title}`,
      text: `${event.message}\n\n${JSON.stringify(event.fields || {}, null, 2)}`,
    });
  }

  _httpJson(url, method, body, headers = {}) {
    return new Promise((resolve, reject) => {
      const u = new URL(url);
      const data = JSON.stringify(body);
      const mod = u.protocol === 'https:' ? https : http;
      const req = mod.request({
        hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
        path: u.pathname + u.search, method,
        headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers },
        timeout: 10_000,
      }, (res) => {
        let buf = '';
        res.on('data', (d) => (buf += d));
        res.on('end', () => {
          if (res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode });
          else reject(new Error(`HTTP_${res.statusCode}: ${buf.slice(0, 200)}`));
        });
      });
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(new Error('ALERT_TIMEOUT')); });
      req.write(data);
      req.end();
    });
  }
}

module.exports = { AlertManager };

التكامل مع ProductionLogger

javascript

// في constructor:
this.alerts = new AlertManager(config.alerts || {});

// عند الأحداث:
await this.alerts.send({
  severity: 'critical',
  code: 'MALWARE_DETECTED',
  title: 'برمجية خبيثة',
  message: `تم كشف محتوى خبيث في ${relativePath}`,
  fields: { quarantine: q, engine: scan.engine, path: relativePath },
});

// STORAGE_FULL:
await this.alerts.send({
  severity: 'warning',
  code: 'STORAGE_FULL',
  title: 'التخزين ممتلئ',
  message: `الحجم الحالي ${total} بايت`,
  fields: { root: this.root },
});

مثال تهيئة

javascript

const logger = new ProductionLogger({
  storagePath: '/var/log/myapp',
  limits: { /* ... */ },
  alerts: {
    throttleMs: 60_000,
    channels: [
      { type: 'slack', webhookUrl: process.env.SLACK_WEBHOOK },
      { type: 'webhook', url: 'https://ops.example.com/hooks/logger', headers: { 'X-Token': process.env.OPS_TOKEN } },
      {
        type: 'smtp',
        host: 'smtp.example.com', port: 465, secure: true,
        user: process.env.SMTP_USER, pass: process.env.SMTP_PASS,
        from: 'logger@example.com', to: 'oncall@example.com',
      },
    ],
  },
});

3. التوقيع الرقمي وسلسلة التحقق (Append-Only)

الفكرة: كل سجل يُلحق به HMAC مبني على السجل السابق، فيصبح أي تعديل في المنتصف قابلاً للكشف.

signer.js

javascript

const crypto = require('crypto');
const fs = require('fs').promises;

class Signer {
  constructor({ key, algorithm = 'sha256' }) {
    if (!key) throw new Error('SIGNER_KEY_MISSING');
    this.key = Buffer.isBuffer(key) ? key : Buffer.from(key, 'hex');
    if (this.key.length < 32) throw new Error('SIGNER_KEY_TOO_SHORT (>=32 bytes)');
    this.algorithm = algorithm;
  }

  // سجل واحد في شكل سطر JSON + سطر HMAC
  signEntry(prevHmac, payload) {
    const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
    const mac = crypto.createHmac(this.algorithm, this.key)
      .update(prevHmac || Buffer.alloc(0))
      .update(data)
      .digest('hex');
    return mac;
  }

  // سطر مكتمل جاهز للكتابة
  formatLine(prevHmac, entry) {
    const payload = JSON.stringify(entry);
    const mac = this.signEntry(prevHmac, payload);
    return { line: `${payload}\n#mac:${mac}\n`, mac };
  }
}

class Verifier {
  constructor({ key, algorithm = 'sha256' }) {
    if (!key) throw new Error('VERIFIER_KEY_MISSING');
    this.key = Buffer.isBuffer(key) ? key : Buffer.from(key, 'hex');
    this.algorithm = algorithm;
  }

  // يعيد { valid, entries, firstBrokenAt }
  async verifyFile(filePath) {
    const raw = await fs.readFile(filePath, 'utf8');
    const lines = raw.split('\n');
    let prevHmac = null;
    let count = 0, firstBrokenAt = null;

    for (let i = 0; i < lines.length; i++) {
      const payload = lines[i];
      if (!payload || payload.startsWith('#mac:')) continue;
      const macLine = lines[i + 1] || '';
      if (!macLine.startsWith('#mac:')) {
        firstBrokenAt ??= { line: i + 1, reason: 'MISSING_MAC' };
        return { valid: false, entries: count, firstBrokenAt };
      }
      const expected = macLine.slice(5).trim();
      const computed = crypto.createHmac(this.algorithm, this.key)
        .update(prevHmac || Buffer.alloc(0))
        .update(Buffer.from(payload, 'utf8'))
        .digest('hex');

      if (computed !== expected) {
        firstBrokenAt ??= { line: i + 1, reason: 'MAC_MISMATCH' };
        return { valid: false, entries: count, firstBrokenAt };
      }
      prevHmac = expected;
      count++;
    }
    return { valid: true, entries: count, firstBrokenAt: null };
  }
}

module.exports = { Signer, Verifier };

قراءة آخر HMAC من ملف موجود

javascript

async function readLastHmac(filePath) {
  try {
    const fh = await fs.open(filePath, 'r');
    const stat = await fh.stat();
    const readSize = Math.min(stat.size, 4096);
    const buf = Buffer.alloc(readSize);
    await fh.read(buf, 0, readSize, stat.size - readSize);
    await fh.close();
    const text = buf.toString('utf8');
    const matches = [...text.matchAll(/#mac:([0-9a-f]+)/g)];
    return matches.length ? matches[matches.length - 1][1] : null;
  } catch (err) {
    if (err.code === 'ENOENT') return null;
    throw err;
  }
}

دمج التوقيع في ProductionLogger

javascript

// في constructor:
this.signer = config.signing?.enabled ? new Signer({ key: config.signing.key }) : null;

// داخل write:
if (this.signer) {
  const prev = await readLastHmac(filePath);
  const { line, mac } = this.signer.formatLine(prev, {
    ts: new Date().toISOString(),
    mime: meta.detectedMime,
    sha256: meta.hash,
    size: meta.size,
    data: redactedContent, // المحتوى بعد التنقية
  });
  buffer = Buffer.from(line, 'utf8');
}

اختبارات

javascript

const { Signer, Verifier } = require('../logger/signer');
const fs = require('fs');
const os = require('os');
const path = require('path');

describe('Signer/Verifier', () => {
  const key = 'a'.repeat(64);
  let dir, file;
  beforeEach(async () => {
    dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sign-'));
    file = path.join(dir, 'log.signed');
  });
  afterEach(async () => { await fs.promises.rm(dir, { recursive: true, force: true }); });

  test('سلسلة صحيحة', async () => {
    const s = new Signer({ key });
    let prev = null, out = '';
    for (let i = 0; i < 3; i++) {
      const { line, mac } = s.formatLine(prev, { msg: `line-${i}` });
      out += line; prev = mac;
    }
    await fs.promises.writeFile(file, out);
    const v = new Verifier({ key });
    expect(await v.verifyFile(file)).toMatchObject({ valid: true, entries: 3 });
  });

  test('يكشف التلاعب', async () => {
    const s = new Signer({ key });
    let prev = null, out = '';
    for (let i = 0; i < 3; i++) {
      const { line, mac } = s.formatLine(prev, { msg: `line-${i}` });
      out += line; prev = mac;
    }
    // العبث بسطر
    out = out.replace('line-1', 'EVIL');
    await fs.promises.writeFile(file, out);
    const v = new Verifier({ key });
    const r = await v.verifyFile(file);
    expect(r.valid).toBe(false);
    expect(r.firstBrokenAt.reason).toBe('MAC_MISMATCH');
  });

  test('مفتاح قصير مرفوض', () => {
    expect(() => new Signer({ key: 'ab' })).toThrow(/TOO_SHORT/);
  });
});

CLI للتحقق

javascript

#!/usr/bin/env node
// bin/verify.js
const { Verifier } = require('../logger/signer');
const files = process.argv.slice(2);

if (!process.env.LOGGER_SIGNING_KEY) {
  console.error('LOGGER_SIGNING_KEY غير معرّف');
  process.exit(2);
}
const v = new Verifier({ key: process.env.LOGGER_SIGNING_KEY });

(async () => {
  let failed = 0;
  for (const f of files) {
    const r = await v.verifyFile(f);
    if (r.valid) console.log(`✅ ${f} (${r.entries} سجل)`);
    else { console.error(`❌ ${f} → ${JSON.stringify(r.firstBrokenAt)}`); failed++; }
  }
  process.exit(failed ? 1 : 0);
})();

bash

chmod +x bin/verify.js
LOGGER_SIGNING_KEY=$(cat /etc/logger/key.hex) ./bin/verify.js /var/log/myapp/*.log

ملاحظة: لتوقيع أقوى ضد فك الارتباط، يمكن استخدام Ed25519 بدل HMAC. حينها تحتفظ بسجل المفتاح العام فقط للنشر، والمفتاح الخاص في HSM أو KMS.


4. تحويلها إلى مكتبة npm + TypeScript

package.json

json

{
  "name": "@company/file-logger",
  "version": "1.0.0",
  "description": "نواة تسجيل ملفات إنتاجية آمنة",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.js",
      "import": "./dist/index.mjs"
    },
    "./testing": {
      "types": "./dist/testing.d.ts",
      "default": "./dist/testing.js"
    }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "engines": { "node": ">=18" },
  "scripts": {
    "build": "tsc -p tsconfig.build.json && node scripts/build-esm.js",
    "test": "jest --runInBand",
    "test:cov": "jest --coverage",
    "lint": "eslint src/**/*.ts",
    "prepublishOnly": "npm run build && npm test"
  },
  "dependencies": {
    "prom-client": "^15.0.0"
  },
  "optionalDependencies": {
    "nodemailer": "^6.9.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@types/jest": "^29.0.0",
    "jest": "^29.0.0",
    "ts-jest": "^29.0.0",
    "typescript": "^5.3.0"
  }
}

tsconfig.json

json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

src/types.ts

typescript

export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';

export interface LimitsConfig {
  maxEntryBytes: number;
  maxFileBytes: number;
  maxTotalBytes: number;
}

export interface MalwareConfig {
  enabled: boolean;
  mode?: 'tcp' | 'socket';
  host?: string;
  port?: number;
  socketPath?: string;
  timeoutMs?: number;
  quarantineDir?: string;
}

export interface RotationConfig {
  keep: number;
  compress: boolean;
  /** 'size' | 'hourly' | 'daily' | 'weekly' | 'monthly' */
  interval?: 'size' | 'hourly' | 'daily' | 'weekly' | 'monthly';
  timezoneOffsetMinutes?: number;
}

export interface RedactionConfig {
  enabled: boolean;
  custom?: RedactionRule[];
}

export interface RedactionRule {
  name: string;
  re: RegExp;
  replace: string | ((...args: unknown[]) => string);
  validate?: 'luhn';
}

export interface SigningConfig {
  enabled: boolean;
  /** مفتاح hex بطول >= 64 محرف (32 بايت) */
  key?: string;
}

export type AlertChannel =
  | { type: 'slack'; webhookUrl: string }
  | { type: 'webhook'; url: string; method?: string; headers?: Record<string, string> }
  | { type: 'smtp'; host: string; port: number; secure?: boolean; user: string; pass: string; from?: string; to: string };

export interface AlertsConfig {
  channels: AlertChannel[];
  throttleMs?: number;
}

export interface MetricsConfig {
  enabled: boolean;
  prefix?: string;
  server?: { enabled: boolean; port?: number; host?: string };
}

export interface LoggerConfig {
  storagePath: string;
  limits: LimitsConfig;
  malware?: MalwareConfig;
  rotation?: RotationConfig;
  redaction?: RedactionConfig;
  signing?: SigningConfig;
  alerts?: AlertsConfig;
  metrics?: MetricsConfig;
  allowBinary?: boolean;
}

export interface WriteOptions {
  append?: boolean;
  mime?: string;
  source?: string;
  metadata?: Record<string, unknown>;
  level?: LogLevel;
}

export interface WriteResult {
  filePath: string;
  bytes: number;
  meta: {
    hash: string;
    size: number;
    detectedMime: string;
    declaredMime: string | null;
    source: string;
    ts: string;
    undescribed?: boolean;
  };
  rotated: boolean;
}

export interface AlertEvent {
  severity: 'info' | 'warning' | 'critical';
  code: string;
  title: string;
  message: string;
  fields?: Record<string, unknown>;
}

export interface VerifyResult {
  valid: boolean;
  entries: number;
  firstBrokenAt: { line: number; reason: string } | null;
}

src/index.ts — الصادرات العامة

typescript

export { ProductionLogger } from './productionLogger';
export { Metrics } from './metrics';
export { AlertManager } from './alerts';
export { Signer, Verifier } from './signer';
export { Redactor, luhnValid } from './redactor';
export { resolveSafePath, assertNoSymlink } from './safePath';
export { SizeGuard } from './sizeGuard';
export { inspect, detectMime, sanitizeText } from './contentInspector';
export { MalwareScanner } from './malwareScanner';
export { buildMetadata } from './metadataHandler';
export { atomicWrite, appendWithFsync, withLock } from './writer';
export { verifyWrite } from './verifier';
export { rotate, prune } from './rotator';
export { TimeRotator, periodKey } from './timeRotator';
export { Audit } from './audit';
export { startMetricsServer } from './metricsServer';
export type {
  LoggerConfig, LimitsConfig, MalwareConfig, RotationConfig,
  RedactionConfig, RedactionRule, SigningConfig, AlertsConfig,
  MetricsConfig, AlertChannel, WriteOptions, WriteResult,
  AlertEvent, VerifyResult, LogLevel,
} from './types';

src/productionLogger.ts — النسخة المكتوبة بـ TS

typescript

import * as path from 'path';
import * as crypto from 'crypto';
import { resolveSafePath, assertNoSymlink } from './safePath';
import { SizeGuard } from './sizeGuard';
import { inspect } from './contentInspector';
import { MalwareScanner } from './malwareScanner';
import { buildMetadata } from './metadataHandler';
import { atomicWrite, appendWithFsync, withLock } from './writer';
import { verifyWrite } from './verifier';
import { rotate } from './rotator';
import { TimeRotator } from './timeRotator';
import { Audit } from './audit';
import { Redactor } from './redactor';
import { Metrics } from './metrics';
import { AlertManager } from './alerts';
import { Signer } from './signer';
import type { LoggerConfig, WriteOptions, WriteResult } from './types';

export class ProductionLogger {
  private root: string;
  private size: SizeGuard;
  private scanner: MalwareScanner;
  private audit: Audit;
  private redactor: Redactor;
  private metrics?: Metrics;
  private alerts?: AlertManager;
  private signer?: Signer;
  private timeRotator?: TimeRotator;
  private rotation: { keep: number; compress: boolean };
  private allowBinary: boolean;

  constructor(config: LoggerConfig) {
    this.root = path.resolve(config.storagePath);
    this.size = new SizeGuard(config.limits);
    this.scanner = new MalwareScanner(config.malware ?? { enabled: false });
    this.audit = new Audit({ auditPath: path.join(this.root, '_audit.log') });
    this.redactor = new Redactor(config.redaction ?? { enabled: true });
    this.rotation = {
      keep: config.rotation?.keep ?? 5,
      compress: config.rotation?.compress ?? true,
    };
    this.allowBinary = !!config.allowBinary;

    if (config.metrics?.enabled) this.metrics = new Metrics(config.metrics);
    if (config.alerts) this.alerts = new AlertManager(config.alerts);
    if (config.signing?.enabled) this.signer = new Signer({ key: config.signing.key! });

    if (config.rotation?.interval && config.rotation.interval !== 'size') {
      this.timeRotator = new TimeRotator({
        interval: config.rotation.interval,
        keepPeriods: config.rotation.keep,
        compress: config.rotation.compress,
        timezoneOffsetMinutes: config.rotation.timezoneOffsetMinutes ?? 0,
      });
    }
  }

  async write(
    relativePath: string,
    content: string | Buffer | object,
    options: WriteOptions = {},
  ): Promise<WriteResult> {
    const requestId = crypto.randomUUID();
    const auditBase = { requestId, path: relativePath, op: options.append ? 'append' : 'write' };
    const t0 = process.hrtime.bigint();

    try {
      const filePathBase = resolveSafePath(this.root, relativePath);
      const filePath = this.timeRotator
        ? this.timeRotator.pathFor(filePathBase)
        : filePathBase;
      await assertNoSymlink(filePath, this.root);

      // 1. تسلسل + تنقية
      const text = typeof content === 'string'
        ? content
        : Buffer.isBuffer(content)
          ? content.toString('utf8')
          : JSON.stringify(content);
      const redacted = this.redactor.redact(text);
      const raw = Buffer.from(redacted, 'utf8');

      // 2. فحص المحتوى
      let inspected;
      try {
        inspected = inspect({ content: raw, declaredMime: options.mime });
      } catch (err) {
        if (this.allowBinary && (err as Error).message.startsWith('BINARY_REJECTED')) {
          inspected = { buffer: raw, mime: 'application/octet-stream', sanitized: false };
        } else throw err;
      }

      // 3. الحجم
      this.size.checkEntrySize(inspected.buffer.length);
      const fileState = await this.size.checkFileSize(filePath, inspected.buffer.length);

      // 4. الفيروسات
      const scanT0 = process.hrtime.bigint();
      const scan = await this.scanner.scanBuffer(inspected.buffer);
      this.metrics?.scanDuration.labels(this.scanner.engine).observe(
        Number(process.hrtime.bigint() - scanT0) / 1e9,
      );
      if (!scan.clean) {
        this.metrics?.malwareTotal.labels(this.scanner.engine).inc();
        const q = await this.scanner.quarantine(inspected.buffer, scan.detail ?? 'unknown');
        this.metrics?.quarantineTotal.inc();
        await this.alerts?.send({
          severity: 'critical',
          code: 'MALWARE_DETECTED',
          title: 'برمجية خبيثة',
          message: `تم كشف محتوى خبيث في ${relativePath}`,
          fields: { quarantine: q, engine: scan.engine },
        });
        await this.audit.record({ ...auditBase, result: 'malware', quarantine: q });
        throw Object.assign(new Error(`MALWARE_DETECTED: ${scan.detail}`), { code: 'MALWARE_DETECTED' });
      }

      // 5. metadata
      const meta = buildMetadata({
        buffer: inspected.buffer,
        declaredMime: options.mime,
        source: options.source,
        extra: options.metadata,
      });

      // 6. الكتابة
      const result = await withLock(filePath, async () => {
        if (fileState.rotate && !this.timeRotator) {
          this.metrics?.rotationsTotal.labels('size').inc();
          await rotate(filePath, this.rotation);
        }
        if (options.append) {
          await appendWithFsync(filePath, inspected.buffer);
          await verifyWrite(filePath, { appendBytes: fileState.currentSize + inspected.buffer.length });
        } else {
          await atomicWrite(filePath, inspected.buffer);
          await verifyWrite(filePath, { exactSize: inspected.buffer.length, sha256: meta.hash });
        }
        return { filePath, bytes: inspected.buffer.length, meta, rotated: fileState.rotate };
      });

      // 7. مقاييس + تدقيق
      const dur = Number(process.hrtime.bigint() - t0) / 1e9;
      this.metrics?.writeDuration.labels(options.append ? 'append' : 'write').observe(dur);
      this.metrics?.writesTotal.labels('ok', meta.detectedMime).inc();
      this.metrics?.bytesTotal.labels('ok').inc(result.bytes);

      const redactions = this.redactor.report();
      for (const [rule, n] of Object.entries(redactions)) {
        this.metrics?.redactionsTotal.labels(rule).inc(n);
      }

      await this.audit.record({ ...auditBase, result: 'ok', bytes: result.bytes });
      return result;
    } catch (err) {
      const code = (err as any).code ?? (err as Error).message.split(':')[0];
      this.metrics?.writesTotal.labels('error', 'unknown').inc();
      this.metrics?.errorsTotal.labels(code).inc();
      await this.audit.record({ ...auditBase, result: 'error', error: (err as Error).message });
      throw err;
    }
  }
}

README.md (مختصر)

markdown

# @company/file-logger

نواة تسجيل ملفات إنتاجية آمنة: تقييد مسار، حدود حجم، فحص محتوى، فحص فيروسات،
تنقية بيانات حساسة، توقيع رقمي، تدوير، مقاييس، وتنبيهات.

## التثبيت

```bash
npm i @company/file-logger
```

## الاستخدام

```ts
import { ProductionLogger } from '@company/file-logger';

const logger = new ProductionLogger({
  storagePath: '/var/log/myapp',
  limits: { maxEntryBytes: 1_048_576, maxFileBytes: 52_428_800, maxTotalBytes: 2_147_483_648 },
  malware: { enabled: true, mode: 'tcp', host: 'clamav', port: 3310, quarantineDir: '/var/quarantine' },
  rotation: { keep: 10, compress: true, interval: 'daily' },
  redaction: { enabled: true },
  signing: { enabled: true, key: process.env.LOGGER_SIGNING_KEY },
  metrics: { enabled: true, prefix: 'logger_' },
  alerts: { channels: [{ type: 'slack', webhookUrl: process.env.SLACK_WEBHOOK! }] },
});

await logger.write('app.log', { level: 'info', msg: 'hello' }, { append: true });
```

راجع `types.ts` لكل الإعدادات.

.npmignore

text

src/
tests/
*.test.ts
tsconfig*.json
jest.config.js
coverage/
.github/

5. مصفوفة نهائية شاملة

#الطبقةالوحدةالتغطية
1مسار آمنsafePathtraversal, symlink, unicode
2حدود حجمsizeGuardentry/file/total
3فحص محتوىcontentInspectorMIME, sanitize, binary
4فيروساتmalwareScannerTCP, INSTREAM, quarantine
5metadatametadataHandlerhash, undescribed
6كتابةwriteratomic, fsync, lock
7تحققverifiersize, hash
8تدوير حجمrotatorrename, gzip, prune
9تدوير زمنيtimeRotatorhourly/daily/weekly/monthly
10تنقيةredactor14 قاعدة + Luhn
11تدقيقauditسجل لكل عملية
12مقاييسmetricscounters/gauges/histograms
13تنبيهاتalertsSlack/Webhook/SMTP + throttle
14توقيعsignerHMAC chain + CLI تحقق
15مكتبةindex.tsTypeScript types + ESM/CJS

6. خارطة النشر النهائية

bash

# 1. بناء المكتبة
npm run build

# 2. اختبارات شاملة
npm test

# 3. تشغيل المنظومة كاملة
docker compose up -d

# 4. تحقق من الصحة
curl -s http://localhost:9464/metrics | head
curl -s http://localhost:9090/-/ready
open http://localhost:3000   # Grafana

# 5. تحقق من سلامة السجلات
LOGGER_SIGNING_KEY=$(cat /etc/logger/key.hex) ./bin/verify.js /var/log/myapp/*.log

بهذا أصبح لدينا منظومة تسجيل إنتاجية متكاملة: من النواة الآمنة إلى المراقبة والتنبيه والتوقيع والتوزيع كمكتبة.

هل تريد الخطوة التالية:

  • دعم Ed25519 + HSM/KMS للتوقيع؟
  • CDC / تدفق مستمر إلى Elasticsearch أو ClickHouse؟
  • سياسة احتفاظ مبنية على الوسوم (TTL tags) مع حذف تلقائي؟
  • نظام قوالب للسجلات (Structured logging schema)؟

الطبقات المتقدمة — Ed25519/HSM، CDC، TTL، وقوالب السجلات

سأكمل الطبقات الأربع: توقيع غير متماثل مع HSM/KMS، بث مستمر إلى Elasticsearch/ClickHouse، سياسة احتفاظ بـ TTL tags، ونظام قوالب منظمة.


1. التوقيع بـ Ed25519 + HSM/KMS

لماذا Ed25519 بدل HMAC؟

البُعدHMACEd25519
المفتاحمشترك (سريع)زوج (عام/خاص)
التحققيحتاج السربالمفتاح العام فقط
الفصللانعم — المُنتِج لا يستطيع التحقق؟ لا، لكن المُدقّق لا يحتاج السر
التزويرممكن إن سُرّب المفتاحأصعب — المفتاح الخاص معزول
الأداء~أسرعكافٍ جداً (~50k توقيع/ث)

src/signing/ed25519Signer.ts

typescript

import * as crypto from 'crypto';

export interface Ed25519SignerOptions {
  /** مفتاح خاص PEM (PKCS#8) */
  privateKeyPem?: string;
  /** أو مفتاح خاص خام 32 بايت hex */
  privateKeyHex?: string;
}

export class Ed25519Signer {
  private key: crypto.KeyObject;

  constructor(opts: Ed25519SignerOptions) {
    if (opts.privateKeyPem) {
      this.key = crypto.createPrivateKey(opts.privateKeyPem);
    } else if (opts.privateKeyHex) {
      const raw = Buffer.from(opts.privateKeyHex, 'hex');
      if (raw.length !== 32) throw new Error('ED25519_KEY_LENGTH: يجب 32 بايت');
      // بناء PKCS#8 من الخام (prefix ثابت لـ Ed25519)
      const der = Buffer.concat([
        Buffer.from('302e020100300506032b657004220420', 'hex'),
        raw,
      ]);
      this.key = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
    } else {
      throw new Error('ED25519_KEY_MISSING');
    }
  }

  /** يعيد التوقيع base64 + المفتاح العام base64 */
  sign(prevSignature: Buffer | null, payload: Buffer): { sig: string; prevSig: string } {
    const prefix = prevSignature ?? Buffer.alloc(0);
    const data = Buffer.concat([prefix, payload]);
    const sig = crypto.sign(null, data, this.key);
    return {
      sig: sig.toString('base64'),
      prevSig: prefix.toString('base64'),
    };
  }

  publicKeyPem(): string {
    return crypto.createPublicKey(this.key).export({ type: 'spki', format: 'pem' }) as string;
  }

  publicKeyBase64(): string {
    const der = crypto.createPublicKey(this.key).export({ type: 'spki', format: 'der' });
    return der.toString('base64');
  }
}

export class Ed25519Verifier {
  private key: crypto.KeyObject;
  private rawKey: Buffer;

  constructor(opts: { publicKeyPem?: string; publicKeyBase64?: string }) {
    if (opts.publicKeyPem) {
      this.key = crypto.createPublicKey(opts.publicKeyPem);
      const der = crypto.createPublicKey(this.key).export({ type: 'spki', format: 'der' });
      this.rawKey = der.subarray(der.length - 32);
    } else if (opts.publicKeyBase64) {
      const der = Buffer.from(opts.publicKeyBase64, 'base64');
      this.key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
      this.rawKey = der.subarray(der.length - 32);
    } else {
      throw new Error('ED25519_PUBKEY_MISSING');
    }
  }

  verify(prevSignature: Buffer | null, payload: Buffer, signature: Buffer): boolean {
    const prefix = prevSignature ?? Buffer.alloc(0);
    const data = Buffer.concat([prefix, payload]);
    return crypto.verify(null, data, this.key, signature);
  }

  publicKeyHex(): string {
    return this.rawKey.toString('hex');
  }
}

سجل التوقيع المرافق

كل سطر سجل يُلحق به سطر توقيع مستقل:

text

{"ts":"2026-10-04T...","msg":"hello","seq":1}
#sig:base64:<الـ base64 للتوقيع>
#prev:<base64 للتوقيع السابق أو "0"> 

HSM/KMS backend

واجهة موحّدة

typescript

// src/signing/kms/types.ts
export interface KmsBackend {
  sign(data: Buffer): Promise<Buffer>;
  publicKey(): Promise<{ pem?: string; base64?: string; hex?: string }>;
  info(): { provider: string; keyId?: string };
}

AWS KMS

typescript

// src/signing/kms/awsKms.ts
import { KMSClient, SignCommand, GetPublicKeyCommand, MessageType } from '@aws-sdk/client-kms';
import { KmsBackend } from './types';

export class AwsKmsBackend implements KmsBackend {
  private client: KMSClient;
  private keyId: string;
  private cachedPub?: { pem: string; base64: string; hex: string };

  constructor({ region, keyId }: { region: string; keyId: string }) {
    this.client = new KMSClient({ region });
    this.keyId = keyId;
  }

  async sign(data: Buffer): Promise<Buffer> {
    const out = await this.client.send(new SignCommand({
      KeyId: this.keyId,
      Message: data,
      MessageType: MessageType.RAW,
      SigningAlgorithm: 'ECDSA_SHA_256',
    }));
    if (!out.Signature) throw new Error('KMS_NO_SIGNATURE');
    return Buffer.from(out.Signature);
  }

  async publicKey() {
    if (this.cachedPub) return this.cachedPub;
    const out = await this.client.send(new GetPublicKeyCommand({ KeyId: this.keyId }));
    if (!out.PublicKey) throw new Error('KMS_NO_PUBKEY');
    const der = Buffer.from(out.PublicKey);
    const pem = `-----BEGIN PUBLIC KEY-----\n${der.toString('base64').match(/.{1,64}/g)!.join('\n')}\n-----END PUBLIC KEY-----\n`;
    const spki = der.subarray(der.length - 65);
    this.cachedPub = { pem, base64: der.toString('base64'), hex: spki.toString('hex') };
    return this.cachedPub;
  }

  info() { return { provider: 'aws-kms', keyId: this.keyId }; }
}

ملاحظة: KMS يدعم ECDSA_P256_SHA_256 و RSASSA_PSS_SHA_256 و ECDSA_P384_SHA_384. للتوقيع على كتل كبيرة، استخدم KMS Sign مع MessageType: DIGEST بعد حساب SHA-256.

PKCS#11 (SoftHSM / YubiHSM / CloudHSM)

typescript

// src/signing/kms/pkcs11.ts
import { KmsBackend } from './types';

// يعتمد على مكتبة pkcs11js (اختيارية)
export class Pkcs11Backend implements KmsBackend {
  private pkcs11: any;
  private session: any;
  private privateKeyHandle: any;
  private publicKeyPem: string;

  static async create({ library, slot, pin, keyLabel, pubkeyPem }: {
    library: string; slot: number; pin: string; keyLabel: string; pubkeyPem: string;
  }) {
    const pkcs11js = require('pkcs11js');
    const inst = new pkcs11js.PKCS11();
    inst.load(library);
    inst.C_Initialize();
    const session = inst.C_OpenSession(slot, pkcs11js.CKF_SERIAL_SESSION | pkcs11js.CKF_RW_SESSION);
    inst.C_Login(session, pkcs11js.CKU_USER, pin);

    const objs = inst.C_FindObjectsInit(session, [
      { type: pkcs11js.CKA_CLASS, value: pkcs11js.CKO_PRIVATE_KEY },
      { type: pkcs11js.CKA_LABEL, value: keyLabel },
    ]);
    const priv = inst.C_FindObjects(session);
    inst.C_FindObjectsFinal(session, objs);
    if (!priv.length) throw new Error('PKCS11_KEY_NOT_FOUND');

    const b = new Pkcs11Backend();
    b.pkcs11 = pkcs11js; b.session = session; b.privateKeyHandle = priv[0]; b.publicKeyPem = pubkeyPem;
    return b;
  }

  async sign(data: Buffer): Promise<Buffer> {
    // ECDSA على P-256: مخرجات PKCS#11 بصيغة raw r||s، نحوّلها إلى DER
    const mech = { mechanism: this.pkcs11.CKM_ECDSA };
    const raw = this.pkcs11.C_Sign(this.session, data, this.privateKeyHandle, mech);
    return Buffer.from(raw);
  }

  async publicKey() {
    return { pem: this.publicKeyPem };
  }

  info() { return { provider: 'pkcs11' }; }
}

HashiCorp Vault Transit

typescript

// src/signing/kms/vault.ts
import { KmsBackend } from './types';

export class VaultBackend implements KmsBackend {
  constructor(private opts: { addr: string; token: string; keyName: string }) {}

  async sign(data: Buffer): Promise<Buffer> {
    const digest = require('crypto').createHash('sha256').update(data).digest('base64');
    const res = await fetch(`${this.opts.addr}/v1/transit/sign/${this.opts.keyName}/sha2-256`, {
      method: 'POST',
      headers: { 'X-Vault-Token': this.opts.token, 'Content-Type': 'application/json' },
      body: JSON.stringify({ input: digest, prehashed: true }),
    });
    if (!res.ok) throw new Error(`VAULT_${res.status}`);
    const json = await res.json() as { data: { signature: string } };
    const sig = json.data.signature.split(':')[1];
    return Buffer.from(sig, 'base64');
  }

  async publicKey() {
    const res = await fetch(`${this.opts.addr}/v1/transit/keys/${this.opts.keyName}`, {
      headers: { 'X-Vault-Token': this.opts.token },
    });
    const json = await res.json() as any;
    return { pem: json.data.keys['1'].public_key };
  }

  info() { return { provider: 'vault-transit', keyId: this.opts.keyName }; }
}

موحّد

typescript

// src/signing/kms/index.ts
import { KmsBackend } from './types';
import { AwsKmsBackend } from './awsKms';
import { VaultBackend } from './vault';

export function createKmsBackend(cfg: any): KmsBackend {
  if (cfg.provider === 'aws-kms') return new AwsKmsBackend(cfg);
  if (cfg.provider === 'vault') return new VaultBackend(cfg);
  if (cfg.provider === 'pkcs11') throw new Error('استخدم Pkcs11Backend.create');
  throw new Error(`KMS_UNKNOWN: ${cfg.provider}`);
}

دمجها في ProductionLogger

typescript

// داخل constructor
if (config.signing?.kms) {
  this.kmsBackend = createKmsBackend(config.signing.kms);
} else if (config.signing?.ed25519) {
  this.ed25519Signer = new Ed25519Signer(config.signing.ed25519);
} else if (config.signing?.enabled) {
  this.hmacSigner = new Signer({ key: config.signing.key! });
}

// داخل write() عند التوقيع:
async _signChain(filePath: string, payload: Buffer): Promise<{ line: string }> {
  const prevSig = await readLastSignature(filePath);
  if (this.ed25519Signer) {
    const { sig } = this.ed25519Signer.sign(prevSig, payload);
    return { line: `${payload.toString('utf8')}\n#sig:${sig}\n#prev:${prevSig?.toString('base64') ?? '0'}\n` };
  }
  if (this.kmsBackend) {
    const prefix = prevSig ?? Buffer.alloc(0);
    const sig = await this.kmsBackend.sign(Buffer.concat([prefix, payload]));
    return { line: `${payload.toString('utf8')}\n#sig:${sig.toString('base64')}\n#prev:${prevSig?.toString('base64') ?? '0'}\n` };
  }
  if (this.hmacSigner) {
    const { line, mac } = this.hmacSigner.formatLine(prevSig?.toString('hex') ?? null, payload.toString('utf8'));
    return { line: `${payload.toString('utf8')}\n#mac:${mac}\n` };
  }
  return { line: payload.toString('utf8') + '\n' };
}

CLI التحقق بكل الأوضاع

typescript

#!/usr/bin/env node
// bin/verify-any.ts
import * as fs from 'fs/promises';
import { Ed25519Verifier } from '../src/signing/ed25519Signer';

async function verifyChain(filePath: string, verifier: (payload: Buffer, sig: Buffer, prev: Buffer | null) => boolean) {
  const text = await fs.readFile(filePath, 'utf8');
  const lines = text.split('\n');
  let prevSig: Buffer | null = null;
  let count = 0;

  for (let i = 0; i < lines.length; i++) {
    const payload = lines[i];
    if (!payload || payload.startsWith('#sig:') || payload.startsWith('#mac:') || payload.startsWith('#prev:')) continue;
    const sigLine = lines[i + 1] || '';
    if (!sigLine.startsWith('#sig:')) return { valid: false, count, reason: 'MISSING_SIG', at: i + 1 };

    const sig = Buffer.from(sigLine.slice(5).trim(), 'base64');
    if (!verifier(Buffer.from(payload, 'utf8'), sig, prevSig)) {
      return { valid: false, count, reason: 'SIG_INVALID', at: i + 1 };
    }
    prevSig = sig;
    count++;
    i += 1; // تجاوز سطر التوقيع
    if (lines[i + 1]?.startsWith('#prev:')) i += 1;
  }
  return { valid: true, count };
}

(async () => {
  const file = process.argv[2];
  const pub = process.env.ED25519_PUBKEY_PEM!;
  const v = new Ed25519Verifier({ publicKeyPem: pub });
  const r = await verifyChain(file, (p, s, prev) => v.verify(prev, p, s));
  console.log(r.valid ? `✅ ${r.count} سجل` : `❌ ${r.reason} عند السطر ${r.at}`);
  process.exit(r.valid ? 0 : 1);
})();

اختبارات Ed25519

typescript

import * as crypto from 'crypto';
import { Ed25519Signer, Ed25519Verifier } from '../src/signing/ed25519Signer';

describe('Ed25519', () => {
  let signer: Ed25519Signer, verifier: Ed25519Verifier;

  beforeAll(() => {
    const { privateKey } = crypto.generateKeyPairSync('ed25519');
    const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
    signer = new Ed25519Signer({ privateKeyPem: pem });
    verifier = new Ed25519Verifier({ publicKeyPem: signer.publicKeyPem() });
  });

  test('سلسلة صحيحة', () => {
    let prev: Buffer | null = null;
    const sigs: string[] = [];
    for (let i = 0; i < 5; i++) {
      const { sig } = signer.sign(prev, Buffer.from(`line-${i}`));
      prev = Buffer.from(sig, 'base64');
      sigs.push(sig);
    }
    // إعادة التحقق
    prev = null;
    for (let i = 0; i < 5; i++) {
      expect(verifier.verify(prev, Buffer.from(`line-${i}`), Buffer.from(sigs[i], 'base64'))).toBe(true);
      prev = Buffer.from(sigs[i], 'base64');
    }
  });

  test('يكشف التلاعب بترتيب السجلات', () => {
    const { sig: s0 } = signer.sign(null, Buffer.from('a'));
    const { sig: s1 } = signer.sign(Buffer.from(s0, 'base64'), Buffer.from('b'));
    // حاول تمرير b قبل a
    expect(verifier.verify(null, Buffer.from('b'), Buffer.from(s1, 'base64'))).toBe(false);
  });
});

2. CDC — بث مستمر إلى Elasticsearch / ClickHouse

المبدأ

Writer يكتب في ملف + في نفس الوقت يدفع السجلات إلى طابور داخلي ثم إلى الوجهات. لا حجب: إن فشلت وجهة، يُعاد المحاولة بـ exponential backoff.

src/cdc/pipeline.ts

typescript

import { EventEmitter } from 'events';

export interface Sink {
  name: string;
  send(batch: LogRecord[]): Promise<void>;
  health?(): Promise<boolean>;
  close?(): Promise<void>;
}

export interface LogRecord {
  ts: string;
  level?: string;
  msg?: string;
  path: string;
  mime: string;
  sha256: string;
  size: number;
  data: unknown;
  [k: string]: unknown;
}

interface PipelineOptions {
  sinks: Sink[];
  batchSize?: number;
  flushIntervalMs?: number;
  maxRetries?: number;
  maxQueueSize?: number;
}

export class CdcPipeline extends EventEmitter {
  private queue: LogRecord[] = [];
  private sinks: Sink[];
  private batchSize: number;
  private flushIntervalMs: number;
  private maxRetries: number;
  private maxQueueSize: number;
  private timer?: NodeJS.Timeout;
  private closed = false;
  private inflight = 0;

  constructor(opts: PipelineOptions) {
    super();
    this.sinks = opts.sinks;
    this.batchSize = opts.batchSize ?? 500;
    this.flushIntervalMs = opts.flushIntervalMs ?? 2000;
    this.maxRetries = opts.maxRetries ?? 5;
    this.maxQueueSize = opts.maxQueueSize ?? 100_000;
    this.timer = setInterval(() => this.flush().catch(() => {}), this.flushIntervalMs);
    this.timer.unref?.();
  }

  enqueue(record: LogRecord) {
    if (this.closed) return;
    if (this.queue.length >= this.maxQueueSize) {
      this.emit('overflow', { dropped: 1 });
      this.queue.shift(); // أسقط الأقدم
    }
    this.queue.push(record);
    if (this.queue.length >= this.batchSize) {
      this.flush().catch((e) => this.emit('error', e));
    }
  }

  async flush(): Promise<void> {
    if (!this.queue.length) return;
    const batch = this.queue.splice(0, this.batchSize);
    this.inflight++;
    try {
      await Promise.all(batch && this.sinks.map((s) => this._sendWithRetry(s, batch)));
      this.emit('flushed', { count: batch.length });
    } finally {
      this.inflight--;
    }
  }

  private async _sendWithRetry(sink: Sink, batch: LogRecord[]) {
    let attempt = 0;
    while (attempt <= this.maxRetries) {
      try {
        await sink.send(batch);
        return;
      } catch (err) {
        attempt++;
        if (attempt > this.maxRetries) {
          this.emit('sink_failed', { sink: sink.name, error: (err as Error).message, dropped: batch.length });
          return;
        }
        const backoff = Math.min(30_000, 200 * 2 ** attempt) + Math.random() * 200;
        await new Promise((r) => setTimeout(r, backoff));
      }
    }
  }

  async close() {
    this.closed = true;
    if (this.timer) clearInterval(this.timer);
    // flush حتى انتهاء الطابور
    while (this.queue.length || this.inflight) {
      await this.flush();
      await new Promise((r) => setTimeout(r, 50));
    }
    for (const s of this.sinks) await s.close?.();
  }
}

Sink: Elasticsearch

typescript

// src/cdc/sinks/elasticsearch.ts
import { Sink, LogRecord } from '../pipeline';

export class ElasticsearchSink implements Sink {
  name = 'elasticsearch';
  constructor(private opts: {
    nodes: string[];
    indexPrefix: string;
    username?: string;
    password?: string;
    apiKey?: string;
    flushBytes?: number;
  }) {}

  async send(batch: LogRecord[]): Promise<void> {
    const byIndex = new Map<string, LogRecord[]>();
    for (const r of batch) {
      const idx = `${this.opts.indexPrefix}-${r.ts.slice(0, 10)}`;
      if (!byIndex.has(idx)) byIndex.set(idx, []);
      byIndex.get(idx)!.push(r);
    }

    const body: string[] = [];
    for (const [idx, records] of byIndex) {
      for (const r of records) {
        body.push(JSON.stringify({ index: { _index: idx } }));
        body.push(JSON.stringify(r));
      }
    }

    const headers: Record<string, string> = { 'Content-Type': 'application/x-ndjson' };
    if (this.opts.apiKey) headers['Authorization'] = `ApiKey ${this.opts.apiKey}`;
    else if (this.opts.username) {
      headers['Authorization'] = 'Basic ' + Buffer.from(`${this.opts.username}:${this.opts.password}`).toString('base64');
    }

    const res = await fetch(`${this.opts.nodes[0]}/_bulk`, {
      method: 'POST',
      headers,
      body: body.join('\n') + '\n',
    });
    if (!res.ok) throw new Error(`ES_${res.status}: ${(await res.text()).slice(0, 200)}`);
    const json = await res.json() as { errors: boolean; items: any[] };
    if (json.errors) {
      const failed = json.items.filter((i) => i.index?.error);
      throw new Error(`ES_PARTIAL: ${failed.length} فشل`);
    }
  }

  async health() {
    try {
      const res = await fetch(`${this.opts.nodes[0]}/_cluster/health`);
      return res.ok;
    } catch { return false; }
  }
}

Sink: ClickHouse

typescript

// src/cdc/sinks/clickhouse.ts
import { Sink, LogRecord } from '../pipeline';

export class ClickHouseSink implements Sink {
  name = 'clickhouse';
  constructor(private opts: {
    url: string;       // http://host:8123
    database: string;
    table: string;     // logs
    username?: string;
    password?: string;
  }) {}

  async send(batch: LogRecord[]): Promise<void> {
    // صيغة JSONEachRow
    const body = batch.map((r) => JSON.stringify({
      ts: r.ts,
      level: r.level ?? 'info',
      msg: r.msg ?? '',
      path: r.path,
      mime: r.mime,
      sha256: r.sha256,
      size: r.size,
      data: typeof r.data === 'string' ? r.data : JSON.stringify(r.data),
    })).join('\n');

    const params = new URLSearchParams({
      query: `INSERT INTO ${this.opts.database}.${this.opts.table} FORMAT JSONEachRow`,
    });
    const auth = this.opts.username
      ? 'Basic ' + Buffer.from(`${this.opts.username}:${this.opts.password ?? ''}`).toString('base64')
      : undefined;

    const res = await fetch(`${this.opts.url}/?${params}`, {
      method: 'POST',
      headers: { ...(auth ? { Authorization: auth } : {}) },
      body,
    });
    if (!res.ok) throw new Error(`CH_${res.status}: ${(await res.text()).slice(0, 200)}`);
  }
}

DDL لـ ClickHouse

sql

CREATE TABLE IF NOT EXISTS logs.file_logs (
  ts        DateTime64(3, 'UTC'),
  level     LowCardinality(String),
  msg       String,
  path      String,
  mime      LowCardinality(String),
  sha256    FixedString(64),
  size      UInt32,
  data      String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (ts, path)
TTL ts + INTERVAL 90 DAY DELETE
SETTINGS index_granularity = 8192;

Sink: Kafka (لبث أوسع)

typescript

// src/cdc/sinks/kafka.ts
import { Sink, LogRecord } from '../pipeline';

export class KafkaSink implements Sink {
  name = 'kafka';
  private producer: any;

  constructor(private opts: { brokers: string[]; topic: string; clientId?: string }) {}

  private async ensure() {
    if (this.producer) return;
    const { Kafka } = require('kafkajs');
    const kafka = new Kafka({ clientId: this.opts.clientId ?? 'file-logger', brokers: this.opts.brokers });
    this.producer = kafka.producer({ idempotent: true, maxInFlightRequests: 5 });
    await this.producer.connect();
  }

  async send(batch: LogRecord[]) {
    await this.ensure();
    await this.producer.send({
      topic: this.opts.topic,
      messages: batch.map((r) => ({ key: r.path, value: JSON.stringify(r) })),
    });
  }

  async close() { await this.producer?.disconnect(); }
}

الدمج مع Writer

typescript

// في ProductionLogger.write() بعد نجاح الكتابة:
this.cdc?.enqueue({
  ts: new Date().toISOString(),
  level: options.level ?? 'info',
  msg: typeof content === 'object' ? (content as any).msg : String(content).slice(0, 500),
  path: relativePath,
  mime: meta.detectedMime,
  sha256: meta.hash,
  size: meta.size,
  data: redacted,
});

الإعداد

typescript

const logger = new ProductionLogger({
  // ...
  cdc: {
    sinks: [
      new ElasticsearchSink({
        nodes: ['http://elasticsearch:9200'],
        indexPrefix: 'logs-file',
        username: 'elastic', password: process.env.ES_PASS,
      }),
      new ClickHouseSink({
        url: 'http://clickhouse:8123',
        database: 'logs', table: 'file_logs',
        username: 'default', password: process.env.CH_PASS,
      }),
    ],
    batchSize: 1000,
    flushIntervalMs: 1000,
    maxRetries: 5,
  },
});

اختبار CDC

typescript

test('Batching + retry', async () => {
  const sent: any[] = [];
  let failFirst = true;
  const sink: Sink = {
    name: 'test',
    async send(batch) {
      if (failFirst) { failFirst = false; throw new Error('boom'); }
      sent.push(...batch);
    },
  };
  const p = new CdcPipeline({ sinks: [sink], batchSize: 2, flushIntervalMs: 20, maxRetries: 3 });
  p.enqueue({ ts: 't', path: 'a', mime: 'text/plain', sha256: 'h', size: 1, data: 'x' });
  p.enqueue({ ts: 't', path: 'a', mime: 'text/plain', sha256: 'h', size: 1, data: 'y' });
  await new Promise((r) => setTimeout(r, 1500));
  await p.close();
  expect(sent.length).toBe(2);
});

3. سياسة الاحتفاظ بـ TTL Tags

src/retention/tags.ts

typescript

export interface RetentionTag {
  /** مثال: "pii", "financial", "debug", "audit" */
  name: string;
  /** بالأيام */
  ttlDays: number;
  /** هل يُمنع الحذف مهما كان؟ */
  immutable?: boolean;
  /** الوصف */
  description?: string;
}

export const DEFAULT_TAGS: Record<string, RetentionTag> = {
  audit:      { name: 'audit',      ttlDays: 365 * 7, immutable: true, description: 'سجلات تدقيق' },
  financial:  { name: 'financial',  ttlDays: 365 * 5, description: 'سجلات مالية' },
  pii:        { name: 'pii',        ttlDays: 90,       description: 'بيانات شخصية' },
  debug:      { name: 'debug',      ttlDays: 7,        description: 'تنقيح' },
  security:   { name: 'security',   ttlDays: 365 * 2,  description: 'أمني' },
  default:    { name: 'default',    ttlDays: 30,       description: 'افتراضي' },
};

src/retention/manager.ts

typescript

import * as fs from 'fs/promises';
import * as path from 'path';
import { RetentionTag, DEFAULT_TAGS } from './tags';

export interface RetentionManagerOptions {
  storageRoot: string;
  tags?: Record<string, RetentionTag>;
  /** مرشّح: يستخرج الوسم من مسار الملف */
  classifier?: (relPath: string) => string;
  /** dryRun للحذف */
  dryRun?: boolean;
}

export class RetentionManager {
  private tags: Record<string, RetentionTag>;
  private classifier: (relPath: string) => string;
  private dryRun: boolean;

  constructor(private opts: RetentionManagerOptions) {
    this.tags = { ...DEFAULT_TAGS, ...(opts.tags ?? {}) };
    this.classifier = opts.classifier ?? ((p) => {
      const seg = p.split('/')[0];
      return this.tags[seg] ? seg : 'default';
    });
    this.dryRun = !!opts.dryRun;
  }

  /**
   * يمسح الملفات المنتهية. يعيد قائمة بما سيُحذف/حُذف.
   */
  async sweep(now = new Date()): Promise<{
    scanned: number; deleted: string[]; skippedImmutable: string[]; errors: { path: string; error: string }[];
  }> {
    const result = { scanned: 0, deleted: [] as string[], skippedImmutable: [] as string[], errors: [] as any[] };
    const root = path.resolve(this.opts.storageRoot);

    const walk = async (dir: string) => {
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const e of entries) {
        const full = path.join(dir, e.name);
        if (e.isDirectory()) {
          // حذف مجلد تدقيق مطلقاً
          if (e.name === '_audit' || e.name === '.audit') continue;
          await walk(full);
        } else if (e.isFile() && e.name !== '_audit.log') {
          result.scanned++;
          const rel = path.relative(root, full);
          const tagName = this.classifier(rel);
          const tag = this.tags[tagName] ?? this.tags.default;
          if (tag.immutable) { result.skippedImmutable.push(rel); continue; }

          try {
            const stat = await fs.stat(full);
            const ageDays = (now.getTime() - stat.mtimeMs) / 86_400_000;
            if (ageDays > tag.ttlDays) {
              if (!this.dryRun) await fs.unlink(full);
              result.deleted.push(rel);
            }
          } catch (err) {
            result.errors.push({ path: rel, error: (err as Error).message });
          }
        }
      }
    };
    await walk(root);
    return result;
  }

  /** يعيد خطة الحذف بدون تنفيذ */
  async plan(now = new Date()) {
    const m = new RetentionManager({ ...this.opts, dryRun: true });
    return m.sweep(now);
  }
}

CLI للحذف الدوري

typescript

#!/usr/bin/env node
// bin/retention.ts
import { RetentionManager } from '../src/retention/manager';

(async () => {
  const root = process.argv[2] || process.env.STORAGE_ROOT;
  const dryRun = process.argv.includes('--dry-run');
  if (!root) { console.error('استخدم: retention <root> [--dry-run]'); process.exit(2); }

  const m = new RetentionManager({
    storageRoot: root,
    dryRun,
    classifier: (rel) => {
      if (rel.includes('/pii/')) return 'pii';
      if (rel.includes('/audit/')) return 'audit';
      if (rel.includes('/debug/')) return 'debug';
      if (rel.includes('/financial/')) return 'financial';
      return 'default';
    },
  });

  const r = await m.sweep();
  console.log(JSON.stringify({
    mode: dryRun ? 'dry-run' : 'execute',
    scanned: r.scanned,
    deleted: r.deleted.length,
    skippedImmutable: r.skippedImmutable.length,
    errors: r.errors.length,
  }, null, 2));

  if (r.deleted.length && dryRun) {
    console.log('\nسيُحذف:');
    for (const f of r.deleted.slice(0, 20)) console.log('  -', f);
    if (r.deleted.length > 20) console.log(`  ... و${r.deleted.length - 20} أخرى`);
  }
})();

cron

bash

# حذف يومي 03:00
0 3 * * * /usr/local/bin/retention /var/log/myapp >> /var/log/retention.log 2>&1

اختبار

typescript

test('يحذف المنتهي ويتجاوز immutable', async () => {
  const dir = await fs.promises.mkdtemp('/tmp/ret-');
  await fs.promises.mkdir(`${dir}/pii`, { recursive: true });
  await fs.promises.mkdir(`${dir}/audit`, { recursive: true });
  const oldFile = `${dir}/pii/old.log`;
  const auditFile = `${dir}/audit/keep.log`;
  await fs.promises.writeFile(oldFile, 'x');
  await fs.promises.writeFile(auditFile, 'x');
  // اجعل mtime قديماً
  const old = new Date(Date.now() - 200 * 86_400_000);
  await fs.promises.utimes(oldFile, old, old);
  await fs.promises.utimes(auditFile, old, old);

  const m = new RetentionManager({ storageRoot: dir });
  const r = await m.sweep();
  expect(r.deleted).toContain('pii/old.log');
  expect(r.skippedImmutable).toContain('audit/keep.log');
  await fs.promises.access(auditFile); // موجود
  await expect(fs.promises.access(oldFile)).rejects.toThrow();
  await fs.promises.rm(dir, { recursive: true, force: true });
});

4. نظام قوالب السجلات المنظمة

src/schema/logger.ts

typescript

import { z } from 'zod';
import { EventEmitter } from 'events';

export const BaseFields = {
  ts: z.string().datetime(),
  level: z.enum(['debug', 'info', 'warn', 'error', 'fatal']),
  msg: z.string().max(4096),
  service: z.string().min(1).max(64),
  env: z.enum(['dev', 'staging', 'prod']),
  traceId: z.string().optional(),
  spanId: z.string().optional(),
  userId: z.string().optional(),
  requestId: z.string().optional(),
};

export class SchemaLogger extends EventEmitter {
  private schema: z.ZodObject<any>;
  private writers: ((record: any) => void | Promise<void>)[] = [];
  private defaults: Record<string, unknown>;
  private redactor?: (s: string) => string;
  private serialize: (o: unknown) => string;

  constructor(opts: {
    schema: z.ZodObject<any>;
    defaults?: Record<string, unknown>;
    redactor?: (s: string) => string;
    serialize?: (o: unknown) => string;
  }) {
    super();
    this.schema = opts.schema;
    this.defaults = opts.defaults ?? {};
    this.redactor = opts.redactor;
    this.serialize = opts.serialize ?? ((o) => JSON.stringify(o));
  }

  addWriter(fn: (record: any) => void | Promise<void>) { this.writers.push(fn); return this; }

  async log(level: keyof typeof BaseFields.level._def.values | string, msg: string, fields: Record<string, unknown> = {}) {
    const record = {
      ...this.defaults,
      ts: new Date().toISOString(),
      level,
      msg,
      ...fields,
    };
    const parsed = this.schema.safeParse(record);
    if (!parsed.success) {
      this.emit('invalid', { record, error: parsed.error });
      return;
    }
    let line = this.serialize(parsed.data);
    if (this.redactor) line = this.redactor(line);
    for (const w of this.writers) {
      try { await w(parsed.data); } catch (e) { this.emit('writer_error', e); }
    }
    this.emit('logged', parsed.data);
  }

  // اختصارات
  debug(msg: string, f?: Record<string, unknown>) { return this.log('debug', msg, f); }
  info(msg: string, f?: Record<string, unknown>)  { return this.log('info', msg, f); }
  warn(msg: string, f?: Record<string, unknown>)  { return this.log('warn', msg, f); }
  error(msg: string, f?: Record<string, unknown>) { return this.log('error', msg, f); }
  fatal(msg: string, f?: Record<string, unknown>) { return this.log('fatal', msg, f); }
}

سكيما موحّدة جاهزة

typescript

// src/schema/presets.ts
import { z } from 'zod';
import { BaseFields } from './logger';

export const HttpAccessSchema = z.object({
  ...BaseFields,
  kind: z.literal('http_access'),
  method: z.enum(['GET','POST','PUT','DELETE','PATCH','HEAD','OPTIONS']),
  path: z.string(),
  status: z.number().int().min(100).max(599),
  durationMs: z.number().nonnegative(),
  bytesSent: z.number().int().nonnegative().optional(),
  userAgent: z.string().max(512).optional(),
  ip: z.string().optional(),
});

export const AuthEventSchema = z.object({
  ...BaseFields,
  kind: z.literal('auth'),
  action: z.enum(['login','logout','refresh','mfa_challenge','mfa_success','password_change']),
  outcome: z.enum(['success','failure','locked']),
  reason: z.string().optional(),
  mfaMethod: z.enum(['totp','webauthn','sms','none']).optional(),
});

export const BusinessEventSchema = z.object({
  ...BaseFields,
  kind: z.literal('business'),
  event: z.string(),
  entity: z.string().optional(),
  entityId: z.string().optional(),
  amount: z.number().optional(),
  currency: z.string().length(3).optional(),
  metadata: z.record(z.unknown()).optional(),
});

export const AuditEventSchema = z.object({
  ...BaseFields,
  kind: z.literal('audit'),
  actor: z.string(),
  action: z.string(),
  target: z.string(),
  before: z.record(z.unknown()).optional(),
  after: z.record(z.unknown()).optional(),
  reason: z.string().optional(),
});

export const AnySchema = z.discriminatedUnion('kind', [
  HttpAccessSchema, AuthEventSchema, BusinessEventSchema, AuditEventSchema,
]);

الاستخدام + ربط Writer

typescript

import { SchemaLogger } from './schema/logger';
import { HttpAccessSchema } from './schema/presets';
import { ProductionLogger } from './productionLogger';

const fileLogger = new ProductionLogger({ /* ... */ });

const httpLog = new SchemaLogger({
  schema: HttpAccessSchema,
  defaults: { service: 'api-gateway', env: 'prod' },
  redactor: (s) => new Redactor().redact(s),
});

httpLog.addWriter(async (rec) => {
  await fileLogger.write(
    `http/${rec.ts.slice(0, 10)}.log`,
    JSON.stringify(rec) + '\n',
    { append: true, mime: 'application/json', source: 'http' },
  );
});

await httpLog.info('request served', {
  kind: 'http_access',
  method: 'GET',
  path: '/v1/users',
  status: 200,
  durationMs: 42,
  requestId: 'req-1',
});

قوالب JSON Schema للتحقق الخارجي

typescript

// src/schema/exportJsonSchema.ts
import { zodToJsonSchema } from 'zod-to-json-schema';
import { AnySchema } from './presets';
import * as fs from 'fs/promises';

export async function exportSchemas(outDir: string) {
  await fs.mkdir(outDir, { recursive: true });
  const schemas = {
    'http_access.schema.json': HttpAccessSchema,
    'auth_event.schema.json': AuthEventSchema,
    'business_event.schema.json': BusinessEventSchema,
    'audit_event.schema.json': AuditEventSchema,
    'any.schema.json': AnySchema,
  };
  for (const [name, s] of Object.entries(schemas)) {
    await fs.writeFile(`${outDir}/${name}`, JSON.stringify(zodToJsonSchema(s as any, name), null, 2));
  }
}

هذه الملفات تُستخدم في Elasticsearch Index Templates أو ClickHouse schema registry أو Kafka Schema Registry.

اختبارات Schema

typescript

import { SchemaLogger } from '../src/schema/logger';
import { HttpAccessSchema } from '../src/schema/presets';

test('يرفض حدثاً غير صالح', async () => {
  const log = new SchemaLogger({ schema: HttpAccessSchema, defaults: { service: 'x', env: 'prod' } });
  const events: any[] = [];
  log.on('invalid', (e) => events.push(e));
  await log.info('bad', { kind: 'http_access', method: 'GET', path: '/', status: 9999, durationMs: 1 });
  expect(events).toHaveLength(1);
});

test('يمرر حدثاً صالحاً', async () => {
  const log = new SchemaLogger({ schema: HttpAccessSchema, defaults: { service: 'x', env: 'prod' } });
  const records: any[] = [];
  log.addWriter((r) => { records.push(r); });
  await log.info('ok', { kind: 'http_access', method: 'GET', path: '/', status: 200, durationMs: 5 });
  expect(records).toHaveLength(1);
  expect(records[0].kind).toBe('http_access');
});

5. مصفوفة الطبقات الكاملة

#الطبقةالوحدةملاحظات
1–11(النواة الأساسية)…كما سابقاً
12مقاييسmetricsPrometheus
13تنبيهاتalertsSlack/Webhook/SMTP
14توقيع HMACsignerسلسلة
15Ed25519signing/ed25519Signerغير متماثل
16HSM/KMSsigning/kms/*AWS/Vault/PKCS#11
17CDCcdc/pipeline + sinksES/CH/Kafka
18TTL Retentionretention/managerوسوم + immutable
19Schemaschema/logger + presetsZod + JSON Schema
20مكتبة npmindex.tsCJS + ESM + TS

6. خريطة تشغيل شاملة

yaml

# docker-compose.full.yml (مختصر)
services:
  logger-app:
    environment:
      LOGGER_KMS_PROVIDER: aws-kms
      AWS_KMS_KEY_ID: ${KMS_KEY_ID}
      AWS_REGION: eu-west-1
      ELASTIC_NODES: http://elasticsearch:9200
      CLICKHOUSE_URL: http://clickhouse:8123
      KAFKA_BROKERS: kafka:9092

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
    environment: [discovery.type=single-node, xpack.security.enabled=false]

  clickhouse:
    image: clickhouse/clickhouse-server:24.3
    volumes: ["./clickhouse/init.sql:/docker-entrypoint-initdb.d/init.sql"]

  kafka:
    image: bitnami/kafka:3.7
    environment:
      KAFKA_CFG_NODE_ID: 0
      KAFKA_CFG_PROCESS_ROLES: controller,broker
      KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
      KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment: [MINIO_ROOT_USER=minio, MINIO_ROOT_PASSWORD=minio123]
    volumes: ["minio-data:/data"]

volumes:
  minio-data:

bash

# cron شامل
0 3 * * *    /usr/local/bin/retention /var/log/myapp
5 0 * * *    node /app/dist/cron/timeRotate.js
*/30 * * * * node /app/dist/cron/verifyChain.js /var/log/myapp/audit

7. خاتمة معمارية

النظام الآن كامل عبر الطبقات التالية:

text

                    ┌──────────────────────────────────────┐
                    │   SchemaLogger (Zod + Presets)       │
                    └──────────────┬───────────────────────┘
                                   │
                    ┌──────────────▼───────────────────────┐
                    │   ProductionLogger (النواة)          │
                    │   safePath → size → content → scan   │
                    │   → redact → sign → write → verify   │
                    └──┬────────┬─────────┬────────┬───────┘
                       │        │         │        │
              ┌────────▼──┐ ┌───▼─────┐ ┌─▼──────┐ ┌▼──────────┐
              │  Metrics  │ │ Alerts  │ │  CDC   │ │ Retention │
              │ Prometheus│ │ Sl/Wh/  │ │ ES/CH/ │ │  TTL Tags │
              │           │ │ Smtp    │ │ Kafka  │ │           │
              └───────────┘ └─────────┘ └────────┘ └───────────┘
                       │
              ┌────────▼─────────────────┐
              │  Ed25519 / HSM / KMS     │
              │  (توقيع + سلسلة تحقق)    │
              └──────────────────────────┘

هل تريد أن أُتبع هذا بـ:

  • دعم Multi-tenancy مع عزل كامل للتخزين والتوقيع لكل مستأجر؟
  • نظام تتبع موزع (OpenTelemetry) مع trace/span IDs مدمج؟
  • واجهة Admin (REST/gRPC) للاستعلام وإدارة الاحتفاظ؟
  • Helm chart للإنتاج Kubernetes مع HPA + PDB + NetworkPolicy؟

This response is AI-generated, for reference only.

نموذجًا متكاملًا (Production-Ready) لدالة معالجة وتسجيل الملفات بلغة php-wordpres-Ai-woo، يتضمن بناء السياق، التحقق الأمني، والمعالجة الصارمة لجميع الحالات:دعم Multi-tenancy مع عزل كامل للتخزين والتوقيع لكل مستأجر؟ نظام تتبع موزع (OpenTelemetry) مع trace/span IDs مدمج؟ واجهة Admin (REST/gRPC) للاستعلام وإدارة الاحتفاظ؟ Helm chart للإنتاج Kubernetes مع HPA + PDB + NetworkPolicy؟

نموذج متكامل: WordPress/WooCommerce/AI — إضافة تسجيل ملفات إنتاجية

سأبني إضافة WordPress كاملة (Plugin) تحوي كل الطبقات التي طلبها: Multi-tenancy، OpenTelemetry، Admin REST API، وHelm chart. البنية مقسّمة على ملفات PHP بشكل صارم (PSR-4-ish داخل الإضافة).


هيكل الإضافة

text

ai-file-logger/
├── ai-file-logger.php              # Bootstrap
├── composer.json
├── src/
│   ├── Plugin.php
│   ├── Support/
│   │   ├── Container.php
│   │   ├── Config.php
│   │   └── Logger.php
│   ├── Security/
│   │   ├── PathGuard.php
│   │   ├── SizeGuard.php
│   │   ├── ContentInspector.php
│   │   ├── MalwareScanner.php
│   │   └── Redactor.php
│   ├── Tenancy/
│   │   ├── TenantResolver.php
│   │   ├── TenantContext.php
│   │   └── TenantStorage.php
│   ├── Signing/
│   │   ├── SignerInterface.php
│   │   ├── Ed25519Signer.php
│   │   ├── HmacSigner.php
│   │   └── VerifyChain.php
│   ├── Telemetry/
│   │   ├── Tracer.php
│   │   ├── Span.php
│   │   └── Exporter.php
│   ├── Storage/
│   │   ├── Writer.php
│   │   ├── Verifier.php
│   │   └── Rotator.php
│   ├── Retention/
│   │   └── RetentionManager.php
│   ├── Cdc/
│   │   ├── Pipeline.php
│   │   └── Sinks/
│   │       ├── ElasticsearchSink.php
│   │       └── ClickHouseSink.php
│   ├── Rest/
│   │   ├── AdminController.php
│   │   └── Auth.php
│   ├── Cli/
│   │   ├── SweepCommand.php
│   │   └── VerifyCommand.php
│   └── Integrations/
│       ├── WooCommerceHooks.php
│       ├── AiEvents.php
│       └── WordPressAudit.php
├── assets/
├── helm/
│   ├── Chart.yaml
│   ├── values.yaml
│   └── templates/
│       ├── deployment.yaml
│       ├── service.yaml
│       ├── hpa.yaml
│       ├── pdb.yaml
│       ├── networkpolicy.yaml
│       ├── secret.yaml
│       ├── configmap.yaml
│       ├── serviceaccount.yaml
│       └── ingress.yaml
└── tests/

1. ملف Bootstrap

php

<?php
/**
 * Plugin Name: AI File Logger
 * Description: تسجيل ملفات إنتاجي آمن: Multi-tenancy، توقيع، OpenTelemetry، REST API.
 * Version:     1.0.0
 * Requires PHP: 8.1
 * Author:      Your Co
 * License:     GPL-2.0-or-later
 * Text Domain: ai-file-logger
 */

declare(strict_types=1);

if (!defined('ABSPATH')) exit;

define('AIFL_VERSION', '1.0.0');
define('AIFL_FILE', __FILE__);
define('AIFL_DIR', __DIR__);

require_once AIFL_DIR . '/vendor/autoload.php';

register_activation_hook(__FILE__, [\AIFL\Plugin::class, 'activate']);
register_deactivation_hook(__FILE__, [\AIFL\Plugin::class, 'deactivate']);

add_action('plugins_loaded', static function () {
    \AIFL\Plugin::instance()->boot();
});

composer.json

json

{
  "name": "yourco/ai-file-logger",
  "type": "wordpress-plugin",
  "require": {
    "php": ">=8.1",
    "ext-json": "*",
    "ext-openssl": "*",
    "ext-sodium": "*",
    "guzzlehttp/guzzle": "^7.8",
    "psr/log": "^3.0"
  },
  "require-dev": {
    "phpunit/phpunit": "^10.0",
    "squizlabs/php_codesniffer": "^3.9",
    "phpstan/phpstan": "^1.11"
  },
  "autoload": {
    "psr-4": { "AIFL\\": "src/" }
  }
}

2. Plugin.php — الواجهة الرئيسية

php

<?php
declare(strict_types=1);

namespace AIFL;

use AIFL\Support\{Container, Config, Logger};
use AIFL\Tenancy\{TenantResolver, TenantContext, TenantStorage};
use AIFL\Security\{PathGuard, SizeGuard, ContentInspector, MalwareScanner, Redactor};
use AIFL\Signing\{Ed25519Signer, HmacSigner};
use AIFL\Telemetry\Tracer;
use AIFL\Storage\{Writer, Verifier, Rotator};
use AIFL\Retention\RetentionManager;
use AIFL\Cdc\Pipeline;
use AIFL\Rest\AdminController;
use AIFL\Cli\{SweepCommand, VerifyCommand};
use AIFL\Integrations\{WooCommerceHooks, AiEvents, WordPressAudit};

final class Plugin
{
    private static ?self $instance = null;
    private Container $c;

    public static function instance(): self
    {
        return self::$instance ??= new self();
    }

    public function boot(): void
    {
        $this->c = new Container();
        $this->registerServices();

        // REST API
        add_action('rest_api_init', [$this->c->get(AdminController::class), 'registerRoutes']);

        // WP-CLI
        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::add_command('aifl sweep',  $this->c->get(SweepCommand::class));
            \WP_CLI::add_command('aifl verify', $this->c->get(VerifyCommand::class));
        }

        // Integrations
        if (class_exists('WooCommerce')) {
            $this->c->get(WooCommerceHooks::class)->register();
        }
        $this->c->get(AiEvents::class)->register();
        $this->c->get(WordPressAudit::class)->register();

        do_action('aifl_booted', $this->c);
    }

    public static function activate(): void
    {
        $root = self::storageRoot();
        if (!is_dir($root)) {
            wp_mkdir_p($root);
            @chmod($root, 0700);
        }
        // حماية المسار عبر .htaccess
        $htaccess = $root . '/.htaccess';
        if (!file_exists($htaccess)) {
            file_put_contents($htaccess, "Require all denied\nDeny from all\n");
        }
        // جدول فهرس (اختياري)
        global $wpdb;
        $table = $wpdb->prefix . 'aifl_index';
        $charset = $wpdb->get_charset_collate();
        $sql = "CREATE TABLE IF NOT EXISTS $table (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            tenant_id VARCHAR(64) NOT NULL,
            rel_path VARCHAR(512) NOT NULL,
            sha256 CHAR(64) NOT NULL,
            bytes BIGINT UNSIGNED NOT NULL,
            mime VARCHAR(128) NOT NULL,
            tag VARCHAR(32) NOT NULL DEFAULT 'default',
            signed TINYINT(1) NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL,
            INDEX tenant_created (tenant_id, created_at),
            INDEX sha (sha256)
        ) $charset;";
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }

    public static function deactivate(): void
    {
        // لا نحذف البيانات تلقائياً
    }

    public static function storageRoot(): string
    {
        return defined('AIFL_STORAGE_ROOT')
            ? AIFL_STORAGE_ROOT
            : WP_CONTENT_DIR . '/aifl-storage';
    }

    private function registerServices(): void
    {
        $c = $this->c;

        // Support
        $c->singleton(Config::class, fn() => new Config());
        $c->singleton(Logger::class, fn($c) => new Logger($c->get(Config::class)->get('log_level', 'info')));

        // Telemetry
        $c->singleton(Tracer::class, fn($c) => new Tracer(
            $c->get(Config::class)->get('otel.endpoint'),
            $c->get(Config::class)->get('otel.service_name', 'ai-file-logger')
        ));

        // Tenancy
        $c->singleton(TenantResolver::class, fn($c) => new TenantResolver(
            $c->get(Config::class)->get('tenancy.mode', 'blog_id')
        ));
        $c->singleton(TenantStorage::class, fn($c) => new TenantStorage(
            self::storageRoot(),
            $c->get(TenantResolver::class)
        ));
        $c->scoped(TenantContext::class, fn($c) => new TenantContext(
            $c->get(TenantResolver::class),
            $c->get(TenantStorage::class)
        ));

        // Security
        $c->singleton(PathGuard::class, fn() => new PathGuard());
        $c->singleton(SizeGuard::class, fn($c) => new SizeGuard(
            $c->get(Config::class)->get('limits.entry_bytes', 1_048_576),
            $c->get(Config::class)->get('limits.file_bytes', 52_428_800),
            $c->get(Config::class)->get('limits.total_bytes', 2_147_483_648)
        ));
        $c->singleton(ContentInspector::class, fn() => new ContentInspector());
        $c->singleton(MalwareScanner::class, fn($c) => new MalwareScanner(
            $c->get(Config::class)->get('malware.enabled', false),
            $c->get(Config::class)->get('malware.mode', 'tcp'),
            $c->get(Config::class)->get('malware.host', '127.0.0.1'),
            (int)$c->get(Config::class)->get('malware.port', 3310),
            $c->get(Config::class)->get('malware.socket_path'),
            (int)$c->get(Config::class)->get('malware.timeout_ms', 15000)
        ));
        $c->singleton(Redactor::class, fn() => new Redactor(true));

        // Signing (per-tenant — نحلّها داخل TenantContext)
        $c->scoped(Ed25519Signer::class, fn($c) => new Ed25519Signer(
            $c->get(TenantContext::class)->signingKey()
        ));
        $c->scoped(HmacSigner::class, fn($c) => new HmacSigner(
            $c->get(TenantContext::class)->hmacKey()
        ));

        // Storage
        $c->scoped(Writer::class, fn($c) => new Writer(
            $c->get(TenantContext::class)->storagePath(),
            $c->get(PathGuard::class),
            $c->get(SizeGuard::class),
            $c->get(ContentInspector::class),
            $c->get(MalwareScanner::class),
            $c->get(Redactor::class),
            $c->get(Ed25519Signer::class),
            $c->get(Logger::class),
            $c->get(Tracer::class)
        ));
        $c->scoped(Verifier::class, fn($c) => new Verifier(
            $c->get(TenantContext::class)->storagePath(),
            $c->get(Ed25519Signer::class)
        ));
        $c->scoped(Rotator::class, fn($c) => new Rotator(
            $c->get(TenantContext::class)->storagePath()
        ));
        $c->scoped(RetentionManager::class, fn($c) => new RetentionManager(
            $c->get(TenantContext::class)->storagePath()
        ));

        // CDC
        $c->singleton(Pipeline::class, fn($c) => new Pipeline(
            $c->get(Config::class)->get('cdc.sinks', []),
            $c->get(Logger::class)
        ));

        // REST + CLI + Integrations
        $c->singleton(AdminController::class, fn($c) => new AdminController(
            $c, $c->get(TenantResolver::class), $c->get(Logger::class)
        ));
        $c->singleton(SweepCommand::class, fn($c) => new SweepCommand($c));
        $c->singleton(VerifyCommand::class, fn($c) => new VerifyCommand($c));
        $c->singleton(WooCommerceHooks::class, fn($c) => new WooCommerceHooks($c));
        $c->singleton(AiEvents::class, fn($c) => new AiEvents($c));
        $c->singleton(WordPressAudit::class, fn($c) => new WordPressAudit($c));
    }
}

3. Multi-Tenancy — عزل كامل

TenantResolver.php

php

<?php
declare(strict_types=1);

namespace AIFL\Tenancy;

final class TenantResolver
{
    public function __construct(private string $mode = 'blog_id') {}

    /**
     * يعيد معرّفاً آمناً للمستأجر (لا يحتوي محارف خطرة).
     */
    public function currentId(): string
    {
        $raw = match ($this->mode) {
            'blog_id'   => (string)get_current_blog_id(),
            'user_id'   => (string)get_current_user_id(),
            'header'    => $this->fromHeader(),
            'constant'  => defined('AIFL_TENANT_ID') ? (string)AIFL_TENANT_ID : 'default',
            default     => 'default',
        };
        return $this->sanitize($raw);
    }

    private function fromHeader(): string
    {
        $h = $_SERVER['HTTP_X_TENANT_ID'] ?? '';
        if (!is_string($h) || $h === '') return 'default';
        // تحقق: فقط [a-zA-Z0-9_-]{1,64}
        if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $h)) {
            throw new \RuntimeException('AIFL_TENANT_INVALID');
        }
        return $h;
    }

    private function sanitize(string $raw): string
    {
        $clean = preg_replace('/[^a-zA-Z0-9_-]/', '', $raw) ?? '';
        if ($clean === '') $clean = 'default';
        return substr($clean, 0, 64);
    }
}

TenantStorage.php

php

<?php
declare(strict_types=1);

namespace AIFL\Tenancy;

final class TenantStorage
{
    public function __construct(
        private string $root,
        private TenantResolver $resolver
    ) {
        $this->root = rtrim($root, '/');
    }

    /**
     * مسار معزول لكل مستأجر، مع فحص يمنع الهروب من الجذر.
     */
    public function pathFor(string $tenantId, string $sub = ''): string
    {
        $tenantId = $this->assertSafe($tenantId);
        $base = $this->root . '/tenants/' . $tenantId;
        if ($sub === '') return $base;

        // منع traversal
        $sub = str_replace('\\', '/', $sub);
        if (str_contains($sub, "\0") || preg_match('#(^|/)\.\.(/|$)#', $sub)) {
            throw new \RuntimeException('AIFL_PATH_TRAVERSAL');
        }
        $target = $base . '/' . ltrim($sub, '/');
        $real = $this->normalize($target);
        $baseNorm = $this->normalize($base);
        if (!str_starts_with($real, $baseNorm . '/') && $real !== $baseNorm) {
            throw new \RuntimeException('AIFL_PATH_ESCAPE');
        }
        return $real;
    }

    public function signingKeyPath(string $tenantId, string $kind = 'ed25519'): string
    {
        return $this->pathFor($tenantId, '.keys/' . $kind . '.key');
    }

    public function hmacKeyPath(string $tenantId): string
    {
        return $this->pathFor($tenantId, '.keys/hmac.key');
    }

    public function ensureTenant(string $tenantId): void
    {
        $base = $this->pathFor($tenantId);
        if (!is_dir($base)) {
            wp_mkdir_p($base);
            @chmod($base, 0700);
        }
        $keys = $this->pathFor($tenantId, '.keys');
        if (!is_dir($keys)) {
            wp_mkdir_p($keys);
            @chmod($keys, 0700);
        }
        // منع الوصول عبر الويب
        $ht = $base . '/.htaccess';
        if (!file_exists($ht)) {
            file_put_contents($ht, "Require all denied\nDeny from all\n");
        }
    }

    private function assertSafe(string $id): string
    {
        if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $id)) {
            throw new \RuntimeException('AIFL_TENANT_INVALID');
        }
        return $id;
    }

    private function normalize(string $p): string
    {
        // تبسيط المسار دون استخدام realpath (لأن الملف قد لا يوجد بعد)
        $parts = [];
        foreach (explode('/', $p) as $seg) {
            if ($seg === '' || $seg === '.') continue;
            if ($seg === '..') array_pop($parts);
            else $parts[] = $seg;
        }
        return '/' . implode('/', $parts);
    }
}

TenantContext.php

php

<?php
declare(strict_types=1);

namespace AIFL\Tenancy;

final class TenantContext
{
    private ?string $tenantId = null;
    private ?string $storagePath = null;

    public function __construct(
        private TenantResolver $resolver,
        private TenantStorage $storage
    ) {}

    public function id(): string
    {
        return $this->tenantId ??= $this->resolver->currentId();
    }

    public function storagePath(): string
    {
        if ($this->storagePath === null) {
            $this->storage->ensureTenant($this->id());
            $this->storagePath = $this->storage->pathFor($this->id());
        }
        return $this->storagePath;
    }

    /**
     * يعيد مفتاح Ed25519 الخاص بالمستأجر (ينشئه إن لم يوجد).
     * @return array{private:string, public:string}
     */
    public function signingKey(): array
    {
        $path = $this->storage->signingKeyPath($this->id());
        if (file_exists($path)) {
            $data = file_get_contents($path);
            if ($data === false) throw new \RuntimeException('AIFL_KEY_READ');
            $json = json_decode($data, true);
            if (!is_array($json) || !isset($json['private'], $json['public'])) {
                throw new \RuntimeException('AIFL_KEY_CORRUPT');
            }
            return $json;
        }

        if (!function_exists('sodium_crypto_sign_keypair')) {
            throw new \RuntimeException('AIFL_SODIUM_REQUIRED');
        }
        $kp = sodium_crypto_sign_keypair();
        $sk = sodium_crypto_sign_secretkey($kp);
        $pk = sodium_crypto_sign_publickey($kp);
        $out = [
            'private' => base64_encode($sk),
            'public'  => base64_encode($pk),
        ];
        file_put_contents($path, json_encode($out, JSON_UNESCAPED_SLASHES), LOCK_EX);
        @chmod($path, 0600);
        return $out;
    }

    public function hmacKey(): string
    {
        $path = $this->storage->hmacKeyPath($this->id());
        if (file_exists($path)) {
            $k = file_get_contents($path);
            if ($k === false || strlen($k) < 32) throw new \RuntimeException('AIFL_HMAC_KEY');
            return $k;
        }
        $k = random_bytes(64);
        file_put_contents($path, $k, LOCK_EX);
        @chmod($path, 0600);
        return $k;
    }

    /** للاختبارات */
    public function overrideTenant(string $id): void
    {
        $this->tenantId = $id;
        $this->storagePath = null;
    }
}

نقطة العزل: كل مستأجر يحصل على مفتاح Ed25519 مستقل → توقيعات معزولة، ومسار مستقل، ومفتاح HMAC مستقل.


4. Security Layer

PathGuard.php

php

<?php
declare(strict_types=1);

namespace AIFL\Security;

final class PathGuard
{
    public function resolve(string $root, string $relative): string
    {
        if ($relative === '' || str_contains($relative, "\0")) {
            throw new \RuntimeException('AIFL_PATH_INVALID');
        }
        $root = rtrim(str_replace('\\', '/', $root), '/');
        $rel = str_replace('\\', '/', $relative);

        // ارفض المطلق
        if (str_starts_with($rel, '/')) {
            throw new \RuntimeException('AIFL_PATH_ABSOLUTE');
        }
        // رفض .. و.
        foreach (explode('/', $rel) as $seg) {
            if ($seg === '..' || $seg === '.') {
                throw new \RuntimeException('AIFL_PATH_TRAVERSAL');
            }
            if ($seg !== '' && !preg_match('/^[A-Za-z0-9._\-]+$/u', $seg)) {
                throw new \RuntimeException('AIFL_PATH_SEGMENT:' . $seg);
            }
        }
        $full = $root . '/' . $rel;

        // فحص symlink
        $parent = dirname($full);
        while (!is_dir($parent) && $parent !== '/' && $parent !== '') {
            $parent = dirname($parent);
        }
        $realParent = realpath($parent);
        $realRoot = realpath($root);
        if ($realRoot === false || $realParent === false) {
            throw new \RuntimeException('AIFL_PATH_UNRESOLVED');
        }
        if (!str_starts_with($realParent, $realRoot)) {
            throw new \RuntimeException('AIFL_PATH_ESCAPE');
        }
        return $full;
    }
}

SizeGuard.php

php

<?php
declare(strict_types=1);

namespace AIFL\Security;

final class SizeGuard
{
    public function __construct(
        private int $maxEntryBytes,
        private int $maxFileBytes,
        private int $maxTotalBytes
    ) {}

    public function checkEntry(int $bytes): void
    {
        if ($bytes > $this->maxEntryBytes) {
            throw new \RuntimeException("AIFL_ENTRY_TOO_LARGE:{$bytes}");
        }
    }

    public function checkFile(string $path, int $incoming): bool
    {
        $current = is_file($path) ? (int)filesize($path) : 0;
        if ($current + $incoming > $this->maxFileBytes) return true; // rotate
        return false;
    }

    public function checkTotal(string $root): void
    {
        $total = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($it as $f) {
            if ($f->isFile()) $total += $f->getSize();
        }
        if ($total > $this->maxTotalBytes) {
            throw new \RuntimeException("AIFL_STORAGE_FULL:{$total}");
        }
    }
}

ContentInspector.php

php

<?php
declare(strict_types=1);

namespace AIFL\Security;

final class ContentInspector
{
    private const MAGIC = [
        'image/png'        => "\x89PNG",
        'image/jpeg'       => "\xFF\xD8\xFF",
        'application/pdf'  => '%PDF-',
        'application/zip'  => "PK\x03\x04",
        'application/gzip' => "\x1F\x8B",
    ];

    private const ANSI = "/\x1B\[[0-9;]*[A-Za-z]/";
    private const CTRL = "/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/";

    public function inspect(string $buffer, ?string $declaredMime = null, bool $allowBinary = false): array
    {
        $mime = $this->detectMime($buffer);

        if ($declaredMime && $declaredMime !== $mime && $mime !== 'text/plain') {
            throw new \RuntimeException("AIFL_MIME_MISMATCH:{$declaredMime}!={$mime}");
        }
        if ($mime === 'application/octet-stream' && !$allowBinary) {
            throw new \RuntimeException('AIFL_BINARY_REJECTED');
        }
        if ($mime === 'text/plain') {
            $buffer = $this->sanitize($buffer);
        }
        return ['buffer' => $buffer, 'mime' => $mime];
    }

    public function detectMime(string $buf): string
    {
        foreach (self::MAGIC as $mime => $sig) {
            if (str_starts_with($buf, $sig)) return $mime;
        }
        // هل النص UTF-8 صالح؟
        if (preg_match('//u', $buf) && mb_check_encoding($buf, 'UTF-8')) {
            return 'text/plain';
        }
        return 'application/octet-stream';
    }

    public function sanitize(string $s): string
    {
        $s = preg_replace(self::ANSI, '', $s) ?? $s;
        $s = preg_replace(self::CTRL, '', $s) ?? $s;
        return str_replace(["\r\n", "\r"], "\n", $s);
    }
}

MalwareScanner.php

php

<?php
declare(strict_types=1);

namespace AIFL\Security;

final class MalwareScanner
{
    public function __construct(
        private bool $enabled,
        private string $mode,
        private string $host,
        private int $port,
        private ?string $socketPath,
        private int $timeoutMs
    ) {}

    public function scan(string $buffer): array
    {
        if (!$this->enabled) return ['clean' => true, 'skipped' => true];
        return $this->scanClamd($buffer);
    }

    private function scanClamd(string $buffer): array
    {
        $errno = 0; $errstr = '';
        $addr = $this->mode === 'tcp'
            ? "tcp://{$this->host}:{$this->port}"
            : "unix://{$this->socketPath}";
        $sock = @stream_socket_client($addr, $errno, $errstr, $this->timeoutMs / 1000);
        if (!$sock) throw new \RuntimeException("AIFL_SCAN_CONNECT:{$errstr}");

        stream_set_timeout($sock, (int)ceil($this->timeoutMs / 1000));
        fwrite($sock, "zINSTREAM\0");

        $chunkSize = 64 * 1024;
        $len = strlen($buffer);
        for ($i = 0; $i < $len; $i += $chunkSize) {
            $chunk = substr($buffer, $i, $chunkSize);
            fwrite($sock, pack('N', strlen($chunk)) . $chunk);
        }
        fwrite($sock, pack('N', 0));

        $resp = stream_get_contents($sock) ?: '';
        fclose($sock);

        $resp = trim($resp);
        if (str_ends_with($resp, 'OK')) return ['clean' => true, 'engine' => 'clamav'];
        if (str_contains($resp, 'FOUND')) return ['clean' => false, 'engine' => 'clamav', 'detail' => $resp];
        throw new \RuntimeException("AIFL_SCAN_ERROR:{$resp}");
    }

    public function quarantine(string $buffer, string $reason, string $quarantineDir): string
    {
        if (!is_dir($quarantineDir)) {
            wp_mkdir_p($quarantineDir);
            @chmod($quarantineDir, 0700);
        }
        $name = gmdate('Ymd\THis\Z') . '-' . bin2hex(random_bytes(8)) . '.bin';
        $path = $quarantineDir . '/' . $name;
        file_put_contents($path, $buffer, LOCK_EX);
        @chmod($path, 0600);
        file_put_contents($path . '.meta.json', wp_json_encode([
            'reason'    => $reason,
            'ts'        => gmdate('c'),
            'sha256'    => hash('sha256', $buffer),
        ]), LOCK_EX);
        return $path;
    }
}

Redactor.php

php

<?php
declare(strict_types=1);

namespace AIFL\Security;

final class Redactor
{
    /** @var array<int,array{name:string,re:string,replace:string}> */
    private array $rules;
    private array $stats = [];

    public function __construct(bool $enabled = true, array $customRules = [])
    {
        $this->enabled = $enabled;
        $this->rules = array_merge(self::defaultRules(), $customRules);
    }

    private bool $enabled;

    public function redact(string $input): string
    {
        if (!$this->enabled) return $input;
        $out = $input;
        foreach ($this->rules as $r) {
            $out = (string)preg_replace_callback($r['re'], function ($m) use ($r) {
                if (($r['validate'] ?? null) === 'luhn' && !$this->luhn($m[0])) return $m[0];
                $this->stats[$r['name']] = ($this->stats[$r['name']] ?? 0) + 1;
                return $r['replace'];
            }, $out);
        }
        return $out;
    }

    public function stats(): array { return $this->stats; }

    private function luhn(string $num): bool
    {
        $digits = preg_replace('/\D/', '', $num);
        $len = strlen($digits);
        if ($len < 13 || $len > 19) return false;
        $sum = 0; $alt = false;
        for ($i = $len - 1; $i >= 0; $i--) {
            $d = (int)$digits[$i];
            if ($alt) { $d *= 2; if ($d > 9) $d -= 9; }
            $sum += $d; $alt = !$alt;
        }
        return $sum % 10 === 0;
    }

    private static function defaultRules(): array
    {
        return [
            ['name' => 'url_password',  're' => '/([?&](?:password|passwd|pwd|token|api[_-]?key)=)[^&\s]+/i', 'replace' => '$1[REDACTED]'],
            ['name' => 'jwt',           're' => '/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/', 'replace' => '[JWT]'],
            ['name' => 'bearer',        're' => '/\bBearer\s+[A-Za-z0-9._\-+\/=]{20,}/i', 'replace' => 'Bearer [REDACTED]'],
            ['name' => 'aws_key',       're' => '/\bAKIA[0-9A-Z]{16}\b/', 'replace' => '[AWS_KEY]'],
            ['name' => 'gh_token',      're' => '/\bgh[pousr]_[A-Za-z0-9]{36,}\b/', 'replace' => '[GH_TOKEN]'],
            ['name' => 'slack',         're' => '/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/', 'replace' => '[SLACK]'],
            ['name' => 'stripe',        're' => '/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b/', 'replace' => '[STRIPE]'],
            ['name' => 'cc',            're' => '/\b(?:\d[ -]*?){13,19}\b/', 'replace' => '[CARD]', 'validate' => 'luhn'],
            ['name' => 'pem',           're' => '/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/', 'replace' => '[PRIVATE_KEY]'],
            ['name' => 'json_password', 're' => '/("(?:password|passwd|pwd|secret|token)"\s*:\s*")[^"]*(")/i', 'replace' => '$1[REDACTED]$2'],
            ['name' => 'saudi_id',      're' => '/\b[12]\d{9}\b/', 'replace' => '[SAUDI_ID]'],
            ['name' => 'email',         're' => '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/', 'replace' => '[EMAIL]'],
            ['name' => 'ipv4',          're' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', 'replace' => '[IP]'],
            ['name' => 'phone',         're' => '/\+\d{1,3}[\s-]?\d{6,14}\b/', 'replace' => '[PHONE]'],
        ];
    }
}

5. Signing (per-tenant Ed25519 / HMAC)

Ed25519Signer.php

php

<?php
declare(strict_types=1);

namespace AIFL\Signing;

final class Ed25519Signer implements SignerInterface
{
    private string $secretKey;
    private string $publicKey;

    /** @param array{private:string,public:string} $keypair */
    public function __construct(array $keypair)
    {
        $sk = base64_decode($keypair['private'], true);
        $pk = base64_decode($keypair['public'], true);
        if ($sk === false || $pk === false || strlen($sk) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
            throw new \RuntimeException('AIFL_ED25519_KEY_INVALID');
        }
        $this->secretKey = $sk;
        $this->publicKey = $pk;
    }

    /** يعيد التوقيع base64 على (prevSig || payload) */
    public function sign(?string $prevSignature, string $payload): string
    {
        $prefix = $prevSignature !== null ? $prevSignature : '';
        $sig = sodium_crypto_sign_detached($prefix . $payload, $this->secretKey);
        return base64_encode($sig);
    }

    public function verify(?string $prevSignature, string $payload, string $signature): bool
    {
        $sig = base64_decode($signature, true);
        if ($sig === false) return false;
        $prefix = $prevSignature !== null ? $prevSignature : '';
        return sodium_crypto_sign_verify_detached($sig, $prefix . $payload, $this->publicKey);
    }

    public function publicKey(): string { return base64_encode($this->publicKey); }
}

HmacSigner.php

php

<?php
declare(strict_types=1);

namespace AIFL\Signing;

final class HmacSigner implements SignerInterface
{
    public function __construct(private string $key)
    {
        if (strlen($key) < 32) throw new \RuntimeException('AIFL_HMAC_KEY_SHORT');
    }

    public function sign(?string $prevSignature, string $payload): string
    {
        $prefix = $prevSignature !== null ? base64_decode($prevSignature, true) : '';
        return base64_encode(hash_hmac('sha256', ($prefix ?: '') . $payload, $this->key, true));
    }

    public function verify(?string $prevSignature, string $payload, string $signature): bool
    {
        return hash_equals($this->sign($prevSignature, $payload), $signature);
    }

    public function publicKey(): string { return ''; } // HMAC لا يملك مفتاحاً عاماً
}

VerifyChain.php

php

<?php
declare(strict_types=1);

namespace AIFL\Signing;

final class VerifyChain
{
    public function __construct(private SignerInterface $signer) {}

    /**
     * ملفّ بنيته:
     *   JSON payload
     *   #sig:<base64>
     *   #prev:<base64|0>
     * يعيد ['valid'=>bool,'entries'=>int,'firstBrokenAt'=>?int,'reason'=>?string]
     */
    public function verifyFile(string $path): array
    {
        if (!is_readable($path)) {
            return ['valid' => false, 'entries' => 0, 'firstBrokenAt' => null, 'reason' => 'UNREADABLE'];
        }
        $fh = fopen($path, 'rb');
        $prev = null; $entries = 0; $lineNo = 0;
        while (($line = fgets($fh)) !== false) {
            $lineNo++;
            $line = rtrim($line, "\r\n");
            if ($line === '' || str_starts_with($line, '#sig:') || str_starts_with($line, '#prev:')) continue;

            $sigLine = fgets($fh);
            if ($sigLine === false || !str_starts_with($sigLine, '#sig:')) {
                fclose($fh);
                return ['valid' => false, 'entries' => $entries, 'firstBrokenAt' => $lineNo, 'reason' => 'MISSING_SIG'];
            }
            $sig = trim(substr($sigLine, 5));

            // سطر prev اختياري
            $pos = ftell($fh);
            $prevLine = fgets($fh);
            if ($prevLine !== false && str_starts_with($prevLine, '#prev:')) {
                // تجاهل القيمة، فنحن نبني السلسلة داخلياً
            } else {
                fseek($fh, $pos);
            }

            if (!$this->signer->verify($prev, $line, $sig)) {
                fclose($fh);
                return ['valid' => false, 'entries' => $entries, 'firstBrokenAt' => $lineNo, 'reason' => 'SIG_INVALID'];
            }
            $prev = base64_decode($sig, true) ?: null;
            $entries++;
        }
        fclose($fh);
        return ['valid' => true, 'entries' => $entries, 'firstBrokenAt' => null, 'reason' => null];
    }
}

6. OpenTelemetry (بدون SDK ثقيل — OTLP/HTTP مباشر)

Tracer.php

php

<?php
declare(strict_types=1);

namespace AIFL\Telemetry;

final class Tracer
{
    private ?string $endpoint;
    private string $serviceName;
    private array $resource;
    private ?string $traceId = null;
    private ?string $spanId = null;

    public function __construct(?string $endpoint, string $serviceName, array $resource = [])
    {
        $this->endpoint = $endpoint;
        $this->serviceName = $serviceName;
        $this->resource = $resource + [
            'service.name'    => $serviceName,
            'service.version' => defined('AIFL_VERSION') ? AIFL_VERSION : '0.0.0',
            'deployment.environment' => defined('WP_ENV') ? WP_ENV : 'production',
        ];
    }

    public function startRootSpan(string $name, array $attributes = []): Span
    {
        $traceId = bin2hex(random_bytes(16));
        $spanId  = bin2hex(random_bytes(8));
        $this->traceId = $traceId;
        $this->spanId  = $spanId;
        return new Span($this, $traceId, $spanId, null, $name, $attributes);
    }

    public function currentTraceId(): ?string { return $this->traceId; }
    public function currentSpanId(): ?string  { return $this->spanId; }

    public function export(Span $span): void
    {
        if (!$this->endpoint) return;
        $payload = [
            'resourceSpans' => [[
                'resource' => [
                    'attributes' => $this->kvList($this->resource),
                ],
                'scopeSpans' => [[
                    'scope' => ['name' => 'aifl', 'version' => '1.0.0'],
                    'spans' => [$span->toOtlp()],
                ]],
            ]],
        ];
        // إرسال غير حاجب
        $this->post($this->endpoint . '/v1/traces', $payload);
    }

    private function kvList(array $assoc): array
    {
        $out = [];
        foreach ($assoc as $k => $v) {
            $out[] = ['key' => (string)$k, 'value' => $this->anyValue($v)];
        }
        return $out;
    }

    private function anyValue(mixed $v): array
    {
        return match (true) {
            is_bool($v)   => ['boolValue' => $v],
            is_int($v)    => ['intValue' => (string)$v],
            is_float($v)  => ['doubleValue' => $v],
            is_array($v)  => ['arrayValue' => ['values' => array_map(fn($x) => $this->anyValue($x), $v)]],
            default       => ['stringValue' => (string)$v],
        };
    }

    private function post(string $url, array $body): void
    {
        if (function_exists('wp_remote_post')) {
            wp_remote_post($url, [
                'timeout'  => 2,
                'blocking' => false,
                'headers'  => ['Content-Type' => 'application/json'],
                'body'     => wp_json_encode($body),
            ]);
            return;
        }
        // fallback بدون WP
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($body),
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_TIMEOUT => 2,
            CURLOPT_RETURNTRANSFER => true,
        ]);
        curl_exec($ch);
        curl_close($ch);
    }
}

Span.php

php

<?php
declare(strict_types=1);

namespace AIFL\Telemetry;

final class Span
{
    private int $startNs;
    private ?int $endNs = null;
    private array $events = [];
    private array $attributes;

    public function __construct(
        private Tracer $tracer,
        private string $traceId,
        private string $spanId,
        private ?string $parentSpanId,
        private string $name,
        array $attributes = []
    ) {
        $this->startNs = (int)(microtime(true) * 1e9);
        $this->attributes = $attributes;
    }

    public function setAttribute(string $k, mixed $v): self
    {
        $this->attributes[$k] = $v;
        return $this;
    }

    public function addEvent(string $name, array $attrs = []): self
    {
        $this->events[] = [
            'name' => $name,
            'timeUnixNano' => (string)(int)(microtime(true) * 1e9),
            'attributes' => $attrs,
        ];
        return $this;
    }

    public function end(): void
    {
        if ($this->endNs !== null) return;
        $this->endNs = (int)(microtime(true) * 1e9);
        $this->tracer->export($this);
    }

    public function toOtlp(): array
    {
        return [
            'traceId' => $this->traceId,
            'spanId'  => $this->spanId,
            'parentSpanId' => $this->parentSpanId ?? '',
            'name'    => $this->name,
            'kind'    => 1, // INTERNAL
            'startTimeUnixNano' => (string)$this->startNs,
            'endTimeUnixNano'   => (string)($this->endNs ?? $this->startNs),
            'attributes' => array_map(
                fn($k, $v) => ['key' => $k, 'value' => ['stringValue' => is_scalar($v) ? (string)$v : json_encode($v)]],
                array_keys($this->attributes),
                array_values($this->attributes)
            ),
            'events' => array_map(fn($e) => [
                'name' => $e['name'],
                'timeUnixNano' => $e['timeUnixNano'],
                'attributes' => array_map(
                    fn($k, $v) => ['key' => $k, 'value' => ['stringValue' => is_scalar($v) ? (string)$v : json_encode($v)]],
                    array_keys($e['attributes']),
                    array_values($e['attributes'])
                ),
            ], $this->events),
        ];
    }
}

ملاحظة: هذا OTEL مبسّط عبر OTLP/HTTP — بلا SDK ثقيل. في الإنتاج يمكن استبداله بـ open-telemetry/sdk إن أردت full spec.


7. Storage — Writer/Verifier/Rotator

Writer.php

php

<?php
declare(strict_types=1);

namespace AIFL\Storage;

use AIFL\Security\{PathGuard, SizeGuard, ContentInspector, MalwareScanner, Redactor};
use AIFL\Signing\SignerInterface;
use AIFL\Support\Logger;
use AIFL\Telemetry\Tracer;

final class Writer
{
    public function __construct(
        private string $root,
        private PathGuard $pathGuard,
        private SizeGuard $sizeGuard,
        private ContentInspector $inspector,
        private MalwareScanner $scanner,
        private Redactor $redactor,
        private SignerInterface $signer,
        private Logger $logger,
        private Tracer $tracer
    ) {}

    /**
     * @return array{filePath:string,bytes:int,sha256:string,mime:string,rotated:bool}
     */
    public function write(string $relativePath, string $content, array $opts = []): array
    {
        $span = $this->tracer->startRootSpan('aifl.write', [
            'path' => $relativePath,
            'append' => (bool)($opts['append'] ?? false),
        ]);

        try {
            $filePath = $this->pathGuard->resolve($this->root, $relativePath);
            $dir = dirname($filePath);
            if (!is_dir($dir)) {
                wp_mkdir_p($dir);
                @chmod($dir, 0700);
            }

            // تنقية
            $redacted = $this->redactor->redact($content);
            $raw = $redacted;

            // فحص محتوى
            $inspected = $this->inspector->inspect($raw, $opts['mime'] ?? null, (bool)($opts['allow_binary'] ?? false));
            $buffer = $inspected['buffer'];
            $mime   = $inspected['mime'];

            // حدود حجم
            $this->sizeGuard->checkEntry(strlen($buffer));
            $shouldRotate = $this->sizeGuard->checkFile($filePath, strlen($buffer));
            try { $this->sizeGuard->checkTotal($this->root); }
            catch (\RuntimeException $e) {
                if (!str_starts_with($e->getMessage(), 'AIFL_STORAGE_FULL')) throw $e;
                $this->logger->warning('التخزين يقترب من الامتلاء', ['root' => $this->root]);
            }

            // فحص فيروسات
            $scanT0 = microtime(true);
            $scan = $this->scanner->scan($buffer);
            $span->addEvent('scan.done', [
                'clean' => $scan['clean'] ? '1' : '0',
                'duration_ms' => (string)round((microtime(true) - $scanT0) * 1000, 2),
            ]);
            if (empty($scan['clean'])) {
                $q = $this->scanner->quarantine($buffer, $scan['detail'] ?? 'unknown', $this->root . '/.quarantine');
                $span->setAttribute('quarantine', $q);
                $this->logger->error('برمجية خبيثة', ['path' => $relativePath, 'quarantine' => $q]);
                throw new \RuntimeException('AIFL_MALWARE_DETECTED');
            }

            // توقيع السلسلة
            $prevSig = $this->readLastSignature($filePath);
            $sha = hash('sha256', $buffer);
            $envelope = $this->encodeEntry($opts['level'] ?? 'info', $buffer, $sha, $mime, $opts);
            $sig = $this->signer->sign($prevSig, $envelope);
            $record = $envelope . "\n#sig:{$sig}\n#prev:" . ($prevSig ?? '0') . "\n";

            $rotated = false;
            if ($shouldRotate && !($opts['no_rotate'] ?? false)) {
                $this->rotate($filePath);
                $rotated = true;
            }

            // كتابة ذرية/ملحقة
            $mode = ($opts['append'] ?? false) ? 'ab' : 'wb';
            $tmp = null;
            if ($mode === 'wb') {
                $tmp = $filePath . '.' . bin2hex(random_bytes(6)) . '.tmp';
                $mode = 'xb';
                $target = $tmp;
            } else {
                $target = $filePath;
            }
            $fh = fopen($target, $mode);
            if (!$fh) throw new \RuntimeException('AIFL_WRITE_OPEN');
            try {
                fwrite($fh, $record);
                fflush($fh);
                if (function_exists('fsync')) @fsync($fh);
            } finally { fclose($fh); }
            @chmod($target, 0600);
            if ($tmp !== null) {
                if (!rename($tmp, $filePath)) {
                    @unlink($tmp);
                    throw new \RuntimeException('AIFL_WRITE_RENAME');
                }
            }

            // تحقق
            if ($tmp === null) {
                clearstatcache(true, $filePath);
            }
            $size = filesize($filePath);
            if ($size === false || $size < strlen($record)) {
                throw new \RuntimeException('AIFL_VERIFY_SIZE');
            }

            // فهرس
            $this->index($relativePath, $sha, $size, $mime, $opts['tag'] ?? 'default');

            $span->setAttribute('bytes', strlen($record));
            $span->setAttribute('sha256', $sha);
            $span->end();

            return [
                'filePath' => $filePath,
                'bytes'    => strlen($record),
                'sha256'   => $sha,
                'mime'     => $mime,
                'rotated'  => $rotated,
            ];
        } catch (\Throwable $e) {
            $span->setAttribute('error', 'true');
            $span->setAttribute('error.message', $e->getMessage());
            $span->end();
            throw $e;
        }
    }

    private function encodeEntry(string $level, string $content, string $sha, string $mime, array $opts): string
    {
        $entry = [
            'ts'     => gmdate('c'),
            'level'  => $level,
            'sha256' => $sha,
            'size'   => strlen($content),
            'mime'   => $mime,
            'source' => $opts['source'] ?? 'unknown',
            'tenant' => $opts['tenant'] ?? null,
            'traceId' => $this->tracer->currentTraceId(),
            'spanId'  => $this->tracer->currentSpanId(),
            'data'   => $this->maybeJson($content),
        ];
        return wp_json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    private function maybeJson(string $content): mixed
    {
        $decoded = json_decode($content, true);
        return json_last_error() === JSON_ERROR_NONE ? $decoded : $content;
    }

    private function readLastSignature(string $filePath): ?string
    {
        if (!is_file($filePath) || filesize($filePath) === 0) return null;
        $fh = fopen($filePath, 'rb');
        if (!$fh) return null;
        $size = filesize($filePath);
        $read = min($size, 8192);
        fseek($fh, -$read, SEEK_END);
        $tail = fread($fh, $read) ?: '';
        fclose($fh);
        if (preg_match_all('/#sig:([A-Za-z0-9+\/=]+)/', $tail, $m) && $m[1]) {
            return end($m[1]);
        }
        return null;
    }

    private function rotate(string $filePath): void
    {
        if (!is_file($filePath)) return;
        $ts = gmdate('Ymd\THis\Z');
        $rotated = $filePath . '.' . $ts;
        if (!rename($filePath, $rotated)) return;
        // gzip
        $data = file_get_contents($rotated);
        if ($data !== false) {
            file_put_contents($rotated . '.gz', gzencode($data, 6), LOCK_EX);
            @chmod($rotated . '.gz', 0600);
            @unlink($rotated);
        }
        // prune
        $this->prune($filePath, 10);
    }

    private function prune(string $filePath, int $keep): void
    {
        $dir = dirname($filePath);
        $base = basename($filePath);
        $files = glob($dir . '/' . $base . '.*.gz') ?: [];
        rsort($files);
        foreach (array_slice($files, $keep) as $old) @unlink($old);
    }

    private function index(string $rel, string $sha, int $bytes, string $mime, string $tag): void
    {
        if (!function_exists('get_current_blog_id')) return;
        global $wpdb;
        $table = $wpdb->prefix . 'aifl_index';
        $wpdb->insert($table, [
            'tenant_id'  => (string)get_current_blog_id(),
            'rel_path'   => $rel,
            'sha256'     => $sha,
            'bytes'      => $bytes,
            'mime'       => $mime,
            'tag'        => $tag,
            'signed'     => 1,
            'created_at' => gmdate('Y-m-d H:i:s'),
        ], ['%s','%s','%s','%d','%s','%s','%d','%s']);
    }
}

Verifier.php

php

<?php
declare(strict_types=1);

namespace AIFL\Storage;

use AIFL\Signing\{SignerInterface, VerifyChain};

final class Verifier
{
    public function __construct(
        private string $root,
        private SignerInterface $signer
    ) {}

    public function verify(string $relativePath): array
    {
        $path = $this->root . '/' . ltrim($relativePath, '/');
        if (!is_file($path)) return ['valid' => false, 'reason' => 'NOT_FOUND'];
        return (new VerifyChain($this->signer))->verifyFile($path);
    }
}

Rotator.php

php

<?php
declare(strict_types=1);

namespace AIFL\Storage;

final class Rotator
{
    public function __construct(private string $root, private int $keep = 10) {}

    public function sweep(string $relativePath): array
    {
        $file = $this->root . '/' . ltrim($relativePath, '/');
        $dir = dirname($file);
        $base = basename($file);
        $pruned = [];
        $files = glob($dir . '/' . $base . '.*.gz') ?: [];
        rsort($files);
        foreach (array_slice($files, $this->keep) as $old) {
            if (@unlink($old)) $pruned[] = basename($old);
        }
        return $pruned;
    }
}

8. Retention — TTL Tags

RetentionManager.php

php

<?php
declare(strict_types=1);

namespace AIFL\Retention;

final class RetentionManager
{
    public const DEFAULT_TAGS = [
        'audit'     => ['ttl' => 365 * 7, 'immutable' => true],
        'financial' => ['ttl' => 365 * 5, 'immutable' => false],
        'security'  => ['ttl' => 365 * 2, 'immutable' => false],
        'pii'       => ['ttl' => 90,       'immutable' => false],
        'debug'     => ['ttl' => 7,        'immutable' => false],
        'default'   => ['ttl' => 30,       'immutable' => false],
    ];

    /** @param array<string,array{ttl:int,immutable?:bool}> $tags */
    public function __construct(
        private string $root,
        private array $tags = [],
        private bool $dryRun = false
    ) {
        $this->tags = $tags + self::DEFAULT_TAGS;
    }

    public function sweep(?int $now = null): array
    {
        $now ??= time();
        $deleted = []; $skipped = []; $errors = [];
        $ri = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($ri as $file) {
            /** @var \SplFileInfo $file */
            if (!$file->isFile()) continue;
            $rel = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
            $tagName = $this->classify($rel);
            $tag = $this->tags[$tagName] ?? $this->tags['default'];
            if (!empty($tag['immutable'])) { $skipped[] = $rel; continue; }

            $ageDays = (int)floor(($now - $file->getMTime()) / 86400);
            if ($ageDays > $tag['ttl']) {
                if (!$this->dryRun && !@unlink($file->getPathname())) {
                    $errors[] = ['path' => $rel, 'error' => 'UNLINK'];
                } else {
                    $deleted[] = $rel;
                }
            }
        }
        return ['scanned' => iterator_count($ri), 'deleted' => $deleted, 'skippedImmutable' => $skipped, 'errors' => $errors];
    }

    private function classify(string $rel): string
    {
        $first = explode('/', $rel)[0];
        return isset($this->tags[$first]) ? $first : 'default';
    }
}

9. REST Admin API

AdminController.php

php

<?php
declare(strict_types=1);

namespace AIFL\Rest;

use AIFL\Support\{Container, Logger};
use AIFL\Tenancy\TenantResolver;
use AIFL\Storage\Verifier;
use AIFL\Retention\RetentionManager;

final class AdminController
{
    public function __construct(
        private Container $c,
        private TenantResolver $resolver,
        private Logger $logger
    ) {}

    public function registerRoutes(): void
    {
        register_rest_route('aifl/v1', '/verify', [
            'methods'             => 'POST',
            'permission_callback' => [$this, 'canManage'],
            'args' => [
                'path' => ['required' => true, 'type' => 'string'],
            ],
            'callback' => [$this, 'verify'],
        ]);

        register_rest_route('aifl/v1', '/retention/sweep', [
            'methods'             => 'POST',
            'permission_callback' => [$this, 'canManage'],
            'args' => [
                'dry_run' => ['type' => 'boolean', 'default' => true],
            ],
            'callback' => [$this, 'sweep'],
        ]);

        register_rest_route('aifl/v1', '/tenants', [
            'methods'             => 'GET',
            'permission_callback' => [$this, 'canManage'],
            'callback' => [$this, 'listTenants'],
        ]);

        register_rest_route('aifl/v1', '/logs', [
            'methods'             => 'GET',
            'permission_callback' => [$this, 'canManage'],
            'args' => [
                'tenant'  => ['type' => 'string'],
                'from'    => ['type' => 'string'],
                'to'      => ['type' => 'string'],
                'limit'   => ['type' => 'integer', 'default' => 100, 'maximum' => 1000],
                'offset'  => ['type' => 'integer', 'default' => 0],
            ],
            'callback' => [$this, 'listLogs'],
        ]);

        register_rest_route('aifl/v1', '/rotate', [
            'methods'             => 'POST',
            'permission_callback' => [$this, 'canManage'],
            'args' => [ 'path' => ['required' => true, 'type' => 'string'] ],
            'callback' => [$this, 'rotate'],
        ]);
    }

    public function canManage(): bool
    {
        return current_user_can('manage_options');
    }

    public function verify(\WP_REST_Request $req): \WP_REST_Response
    {
        $path = (string)$req->get_param('path');
        if (!preg_match('#^[A-Za-z0-9._/\-]+$#', $path) || str_contains($path, '..')) {
            return new \WP_REST_Response(['error' => 'INVALID_PATH'], 400);
        }
        try {
            $v = new Verifier(
                $this->c->get(\AIFL\Tenancy\TenantContext::class)->storagePath(),
                $this->c->get(\AIFL\Signing\Ed25519Signer::class)
            );
            return new \WP_REST_Response($v->verify($path), 200);
        } catch (\Throwable $e) {
            return new \WP_REST_Response(['error' => $e->getMessage()], 500);
        }
    }

    public function sweep(\WP_REST_Request $req): \WP_REST_Response
    {
        $dry = (bool)$req->get_param('dry_run');
        try {
            $m = new RetentionManager(
                $this->c->get(\AIFL\Tenancy\TenantContext::class)->storagePath(),
                [],
                $dry
            );
            return new \WP_REST_Response($m->sweep(), 200);
        } catch (\Throwable $e) {
            return new \WP_REST_Response(['error' => $e->getMessage()], 500);
        }
    }

    public function listTenants(): \WP_REST_Response
    {
        $root = \AIFL\Plugin::storageRoot() . '/tenants';
        if (!is_dir($root)) return new \WP_REST_Response([], 200);
        $tenants = array_values(array_filter(scandir($root) ?: [], fn($n) => $n !== '.' && $n !== '..' && is_dir("$root/$n")));
        return new \WP_REST_Response(array_map(fn($t) => ['tenant' => $t], $tenants), 200);
    }

    public function listLogs(\WP_REST_Request $req): \WP_REST_Response
    {
        global $wpdb;
        $table = $wpdb->prefix . 'aifl_index';
        $tenant = (string)($req->get_param('tenant') ?? get_current_blog_id());
        $from = (string)($req->get_param('from') ?? '1970-01-01');
        $to   = (string)($req->get_param('to')   ?? '9999-12-31');
        $limit  = (int)$req->get_param('limit');
        $offset = (int)$req->get_param('offset');

        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT rel_path, sha256, bytes, mime, tag, signed, created_at
             FROM $table
             WHERE tenant_id = %s AND created_at BETWEEN %s AND %s
             ORDER BY created_at DESC LIMIT %d OFFSET %d",
            $tenant, $from, $to, $limit, $offset
        ), ARRAY_A);

        return new \WP_REST_Response(['tenant' => $tenant, 'count' => count($rows), 'rows' => $rows], 200);
    }

    public function rotate(\WP_REST_Request $req): \WP_REST_Response
    {
        $path = (string)$req->get_param('path');
        if (!preg_match('#^[A-Za-z0-9._/\-]+$#', $path) || str_contains($path, '..')) {
            return new \WP_REST_Response(['error' => 'INVALID_PATH'], 400);
        }
        try {
            $r = new \AIFL\Storage\Rotator(
                $this->c->get(\AIFL\Tenancy\TenantContext::class)->storagePath()
            );
            $pruned = $r->sweep($path);
            return new \WP_REST_Response(['pruned' => $pruned], 200);
        } catch (\Throwable $e) {
            return new \WP_REST_Response(['error' => $e->getMessage()], 500);
        }
    }
}

تأمين REST

php

// داخل Plugin::boot() — لتقييد REST على HTTPS
add_filter('rest_authentication_errors', function ($result) {
    if (!empty($result)) return $result;
    if (!is_ssl() && !defined('AIFL_ALLOW_HTTP')) {
        return new \WP_Error('aifl_https_required', 'HTTPS مطلوب', ['status' => 403]);
    }
    return $result;
});

10. Integrations (WordPress / WooCommerce / AI)

WooCommerceHooks.php

php

<?php
declare(strict_types=1);

namespace AIFL\Integrations;

use AIFL\Support\Container;

final class WooCommerceHooks
{
    public function __construct(private Container $c) {}

    public function register(): void
    {
        add_action('woocommerce_checkout_order_processed', [$this, 'orderCreated'], 10, 3);
        add_action('woocommerce_order_status_changed',     [$this, 'orderStatus'],  10, 4);
        add_action('woocommerce_payment_complete',         [$this, 'paymentComplete'], 10, 1);
        add_action('woocommerce_order_refunded',           [$this, 'refunded'], 10, 2);
    }

    public function orderCreated(int $orderId, array $postedData, $order): void
    {
        $this->log('financial', 'wc_order_created', [
            'order_id' => $orderId,
            'total'    => (float)$order->get_total(),
            'currency' => $order->get_currency(),
            'items'    => count($order->get_items()),
        ]);
    }

    public function orderStatus(int $orderId, string $from, string $to, $order): void
    {
        $this->log('audit', 'wc_order_status', [
            'order_id' => $orderId,
            'from'     => $from,
            'to'       => $to,
        ]);
    }

    public function paymentComplete(int $orderId): void
    {
        $this->log('financial', 'wc_payment_complete', ['order_id' => $orderId]);
    }

    public function refunded(int $orderId, int $refundId): void
    {
        $this->log('financial', 'wc_refund', ['order_id' => $orderId, 'refund_id' => $refundId]);
    }

    private function log(string $tag, string $event, array $data): void
    {
        try {
            $writer = $this->c->get(\AIFL\Storage\Writer::class);
            $writer->write(
                $tag . '/' . gmdate('Y-m-d') . '.log',
                wp_json_encode(['event' => $event] + $data),
                ['append' => true, 'source' => 'woocommerce', 'tag' => $tag, 'level' => 'info']
            );
        } catch (\Throwable $e) {
            $this->c->get(\AIFL\Support\Logger::class)->error('WooCommerce log failed', ['error' => $e->getMessage()]);
        }
    }
}

AiEvents.php

php

<?php
declare(strict_types=1);

namespace AIFL\Integrations;

use AIFL\Support\Container;

final class AiEvents
{
    public function __construct(private Container $c) {}

    public function register(): void
    {
        // تكامل عام مع أي مزود AI عبر هوك مخصص
        add_action('aifl_ai_event', [$this, 'handle'], 10, 2);

        // OpenAI plugin
        add_action('open_ai_request_completed', [$this, 'openAiRequest'], 10, 2);
        // Anthropic
        add_action('anthropic_request_completed', [$this, 'anthropicRequest'], 10, 2);
    }

    public function handle(string $type, array $payload): void
    {
        $this->write('ai', ['type' => $type] + $payload);
    }

    public function openAiRequest(array $response, array $request): void
    {
        $this->write('ai', [
            'provider' => 'openai',
            'model'    => $request['model'] ?? null,
            'tokens_in'  => $response['usage']['prompt_tokens'] ?? null,
            'tokens_out' => $response['usage']['completion_tokens'] ?? null,
            'status'     => $response['status'] ?? null,
            // لا نحتفظ بالمحتوى الخام — فقط ملخص
            'prompt_preview' => mb_substr((string)($request['prompt'] ?? ''), 0, 200),
        ]);
    }

    public function anthropicRequest(array $response, array $request): void
    {
        $this->write('ai', [
            'provider' => 'anthropic',
            'model'    => $request['model'] ?? null,
            'tokens_in'  => $response['usage']['input_tokens'] ?? null,
            'tokens_out' => $response['usage']['output_tokens'] ?? null,
        ]);
    }

    private function write(string $tag, array $data): void
    {
        try {
            $this->c->get(\AIFL\Storage\Writer::class)->write(
                $tag . '/' . gmdate('Y-m-d') . '.log',
                wp_json_encode($data),
                ['append' => true, 'source' => 'ai', 'tag' => 'pii', 'level' => 'info']
            );
        } catch (\Throwable $e) {
            $this->c->get(\AIFL\Support\Logger::class)->error('AI log failed', ['error' => $e->getMessage()]);
        }
    }
}

WordPressAudit.php

php

<?php
declare(strict_types=1);

namespace AIFL\Integrations;

use AIFL\Support\Container;

final class WordPressAudit
{
    public function __construct(private Container $c) {}

    public function register(): void
    {
        add_action('wp_login',               [$this, 'login'], 10, 2);
        add_action('wp_login_failed',        [$this, 'loginFailed'], 10, 1);
        add_action('user_register',          [$this, 'userRegister'], 10, 1);
        add_action('profile_update',         [$this, 'profileUpdate'], 10, 1);
        add_action('delete_user',            [$this, 'deleteUser'], 10, 1);
        add_action('updated_option',         [$this, 'optionUpdate'], 10, 3);
        add_action('activated_plugin',       [$this, 'pluginActivate'], 10, 2);
    }

    public function login(string $login, \WP_User $user): void
    {
        $this->audit('auth.login', ['user' => $login, 'id' => $user->ID]);
    }

    public function loginFailed(string $login): void
    {
        $this->audit('auth.login_failed', ['user' => $login]);
    }

    public function userRegister(int $userId): void
    {
        $this->audit('user.register', ['user_id' => $userId]);
    }

    public function profileUpdate(int $userId): void
    {
        $this->audit('user.update', ['user_id' => $userId]);
    }

    public function deleteUser(int $userId): void
    {
        $this->audit('user.delete', ['user_id' => $userId]);
    }

    public function optionUpdate(string $option, mixed $old, mixed $new): void
    {
        if (in_array($option, ['siteurl', 'home', 'admin_email', 'users_can_register'], true)) {
            $this->audit('option.update', ['option' => $option, 'old' => is_scalar($old) ? $old : null, 'new' => is_scalar($new) ? $new : null]);
        }
    }

    public function pluginActivate(string $plugin, bool $network): void
    {
        $this->audit('plugin.activate', ['plugin' => $plugin, 'network' => $network]);
    }

    private function audit(string $event, array $data): void
    {
        try {
            $this->c->get(\AIFL\Storage\Writer::class)->write(
                'audit/' . gmdate('Y-m-d') . '.log',
                wp_json_encode(['event' => $event] + $data),
                ['append' => true, 'source' => 'wordpress', 'tag' => 'audit', 'level' => 'info']
            );
        } catch (\Throwable $e) {
            $this->c->get(\AIFL\Support\Logger::class)->error('Audit failed', ['error' => $e->getMessage()]);
        }
    }
}

11. Container — بسيط وواضح

Container.php

php

<?php
declare(strict_types=1);

namespace AIFL\Support;

final class Container
{
    /** @var array<string,callable> */
    private array $factories = [];
    /** @var array<string,mixed> */
    private array $instances = [];
    /** @var array<string,bool> */
    private array $scoped = [];

    public function singleton(string $id, callable $factory): void
    {
        $this->factories[$id] = $factory;
    }

    public function scoped(string $id, callable $factory): void
    {
        $this->factories[$id] = $factory;
        $this->scoped[$id] = true;
    }

    public function get(string $id): mixed
    {
        if (isset($this->instances[$id]) && empty($this->scoped[$id])) {
            return $this->instances[$id];
        }
        if (!isset($this->factories[$id])) {
            throw new \RuntimeException("AIFL_CONTAINER_UNKNOWN:{$id}");
        }
        $obj = ($this->factories[$id])($this);
        if (empty($this->scoped[$id])) {
            $this->instances[$id] = $obj;
        }
        return $obj;
    }

    public function reset(): void
    {
        $this->instances = [];
    }
}

Config.php

php

<?php
declare(strict_types=1);

namespace AIFL\Support;

final class Config
{
    private array $data;

    public function __construct()
    {
        $defaults = [
            'log_level' => 'info',
            'tenancy'   => ['mode' => 'blog_id'],
            'limits'    => [
                'entry_bytes' => 1_048_576,
                'file_bytes'  => 52_428_800,
                'total_bytes' => 2_147_483_648,
            ],
            'malware'   => [
                'enabled'     => (bool)getenv('AIFL_MALWARE_ENABLED'),
                'mode'        => getenv('AIFL_MALWARE_MODE') ?: 'tcp',
                'host'        => getenv('AIFL_MALWARE_HOST') ?: '127.0.0.1',
                'port'        => (int)(getenv('AIFL_MALWARE_PORT') ?: 3310),
                'socket_path' => getenv('AIFL_MALWARE_SOCKET') ?: null,
                'timeout_ms'  => 15_000,
            ],
            'otel' => [
                'endpoint'     => getenv('OTEL_EXPORTER_OTLP_ENDPOINT') ?: null,
                'service_name' => 'ai-file-logger',
            ],
            'cdc' => ['sinks' => []],
        ];
        $this->data = apply_filters('aifl_config', $defaults);
    }

    public function get(string $key, mixed $default = null): mixed
    {
        $parts = explode('.', $key);
        $cur = $this->data;
        foreach ($parts as $p) {
            if (!is_array($cur) || !array_key_exists($p, $cur)) return $default;
            $cur = $cur[$p];
        }
        return $cur;
    }
}

Logger.php

php

<?php
declare(strict_types=1);

namespace AIFL\Support;

final class Logger
{
    private const LEVELS = ['debug' => 10, 'info' => 20, 'warning' => 30, 'error' => 40];

    public function __construct(private string $minLevel = 'info') {}

    public function debug(string $msg, array $ctx = []): void  { $this->log('debug', $msg, $ctx); }
    public function info(string $msg, array $ctx = []): void   { $this->log('info', $msg, $ctx); }
    public function warning(string $msg, array $ctx = []): void { $this->log('warning', $msg, $ctx); }
    public function error(string $msg, array $ctx = []): void   { $this->log('error', $msg, $ctx); }

    private function log(string $level, string $msg, array $ctx): void
    {
        if ((self::LEVELS[$level] ?? 0) < (self::LEVELS[$this->minLevel] ?? 20)) return;
        if (function_exists('error_log')) {
            error_log('[AIFL][' . $level . '] ' . $msg . ' ' . wp_json_encode($ctx));
        }
    }
}

12. Helm Chart — إنتاج Kubernetes

helm/Chart.yaml

yaml

apiVersion: v2
name: ai-file-logger
description: WordPress AI File Logger with multi-tenancy, signing, and OpenTelemetry
type: application
version: 1.0.0
appVersion: "1.0.0"

helm/values.yaml

yaml

replicaCount: 3

image:
  repository: ghcr.io/yourco/ai-file-logger
  tag: "1.0.0"
  pullPolicy: IfNotPresent

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations: {}
  name: ""

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "9464"
  prometheus.io/path: "/metrics"

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits:   { cpu: 1000m, memory: 1Gi }

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

pdb:
  enabled: true
  minAvailable: 2

networkPolicy:
  enabled: true
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: ingress-nginx
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              name: observability
      ports:
        - { protocol: TCP, port: 4318 }
    - to:
        - podSelector:
            matchLabels:
              app: clamav
      ports:
        - { protocol: TCP, port: 3310 }

persistence:
  enabled: true
  storageClass: ""
  size: 50Gi
  accessModes: [ReadWriteOnce]

env:
  AIFL_MALWARE_ENABLED: "true"
  AIFL_MALWARE_MODE: "tcp"
  AIFL_MALWARE_HOST: "clamav.observability.svc.cluster.local"
  AIFL_MALWARE_PORT: "3310"
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability.svc.cluster.local:4318"
  OTEL_SERVICE_NAME: "ai-file-logger"

envFromSecrets:
  - ai-file-logger-secrets

livenessProbe:
  httpGet: { path: /healthz, port: http }
  initialDelaySeconds: 20
  periodSeconds: 15
readinessProbe:
  httpGet: { path: /readyz, port: http }
  initialDelaySeconds: 10
  periodSeconds: 10

nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: ai-file-logger

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: "8m"
  hosts:
    - host: logger.example.com
      paths:
        - { path: /, pathType: Prefix }
  tls:
    - secretName: ai-file-logger-tls
      hosts: [logger.example.com]

helm/templates/deployment.yaml

yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "ai-file-logger.fullname" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels: {{- include "ai-file-logger.selectorLabels" . | nindent 6 }}
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels: {{- include "ai-file-logger.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "ai-file-logger.serviceAccountName" . }}
      securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext: {{- toYaml .Values.securityContext | nindent 12 }}
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metrics, containerPort: 9464 }
          envFrom:
            - configMapRef: { name: {{ include "ai-file-logger.fullname" . }}-config }
            {{- range .Values.envFromSecrets }}
            - secretRef: { name: {{ . }} }
            {{- end }}
          livenessProbe: {{- toYaml .Values.livenessProbe | nindent 12 }}
          readinessProbe: {{- toYaml .Values.readinessProbe | nindent 12 }}
          resources: {{- toYaml .Values.resources | nindent 12 }}
          volumeMounts:
            - { name: storage, mountPath: /var/log/myapp }
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: storage
          {{- if .Values.persistence.enabled }}
          persistentVolumeClaim: { claimName: {{ include "ai-file-logger.fullname" . }}-data }
          {{- else }}
          emptyDir: {}
          {{- end }}
        - { name: tmp, emptyDir: {} }
      {{- with .Values.nodeSelector }}
      nodeSelector: {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations: {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity: {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.topologySpreadConstraints }}
      topologySpreadConstraints: {{- toYaml . | nindent 8 }}
      {{- end }}

helm/templates/hpa.yaml

yaml

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "ai-file-logger.fullname" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "ai-file-logger.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} }
    - type: Resource
      resource:
        name: memory
        target: { type: Utilization, averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies: [{ type: Percent, value: 50, periodSeconds: 60 }]
    scaleUp:
      stabilizationWindowSeconds: 30
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]
{{- end }}

helm/templates/pdb.yaml

yaml

{{- if .Values.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "ai-file-logger.fullname" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
spec:
  minAvailable: {{ .Values.pdb.minAvailable }}
  selector:
    matchLabels: {{- include "ai-file-logger.selectorLabels" . | nindent 6 }}
{{- end }}

helm/templates/networkpolicy.yaml

yaml

{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: {{ include "ai-file-logger.fullname" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
spec:
  podSelector:
    matchLabels: {{- include "ai-file-logger.selectorLabels" . | nindent 6 }}
  policyTypes: [Ingress, Egress]
  ingress: {{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
  egress:
    # DNS
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    {{- toYaml .Values.networkPolicy.egress | nindent 4 }}
{{- end }}

helm/templates/secret.yaml

yaml

apiVersion: v1
kind: Secret
metadata:
  name: {{ include "ai-file-logger.fullname" . }}-secrets
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
type: Opaque
stringData:
  # لا تضع السر هنا مباشرة — استخدم external-secrets أو sealed-secrets في الإنتاج
  AIFL_KMS_PROVIDER: {{ .Values.kms.provider | quote }}
  AIFL_KMS_KEY_ID:   {{ .Values.kms.keyId   | quote }}

helm/templates/configmap.yaml

yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "ai-file-logger.fullname" . }}-config
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
data:
  {{- range $k, $v := .Values.env }}
  {{ $k }}: {{ $v | quote }}
  {{- end }}

helm/templates/serviceaccount.yaml

yaml

{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "ai-file-logger.serviceAccountName" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
  {{- with .Values.serviceAccount.annotations }}
  annotations: {{- toYaml . | nindent 4 }}
  {{- end }}
automountServiceAccountToken: false
{{- end }}

helm/templates/ingress.yaml

yaml

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "ai-file-logger.fullname" . }}
  labels: {{- include "ai-file-logger.labels" . | nindent 4 }}
  annotations: {{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  tls: {{- toYaml .Values.ingress.tls | nindent 4 }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "ai-file-logger.fullname" $ }}
                port: { number: {{ $.Values.service.port }} }
          {{- end }}
    {{- end }}
{{- end }}

helm/templates/pvc.yaml

yaml

{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: {{ include "ai-file-logger.fullname" . }}-data
spec:
  accessModes: {{- toYaml .Values.persistence.accessModes | nindent 4 }}
  resources:
    requests:
      storage: {{ .Values.persistence.size }}
  {{- if .Values.persistence.storageClass }}
  storageClassName: {{ .Values.persistence.storageClass }}
  {{- end }}
{{- end }}

helm/templates/_helpers.tpl

yaml

{{- define "ai-file-logger.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "ai-file-logger.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name (include "ai-file-logger.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}

{{- define "ai-file-logger.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ include "ai-file-logger.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}

{{- define "ai-file-logger.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ai-file-logger.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{- define "ai-file-logger.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "ai-file-logger.fullname" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}

النشر

bash

helm upgrade --install logger ./helm \
  --namespace logging --create-namespace \
  --set image.tag=1.0.0 \
  --set kms.provider=aws-kms \
  --set kms.keyId=$KMS_KEY_ID

13. اختبارات أساسية

tests/PathGuardTest.php

php

<?php
declare(strict_types=1);

use AIFL\Security\PathGuard;
use PHPUnit\Framework\TestCase;

final class PathGuardTest extends TestCase
{
    private PathGuard $g;
    protected function setUp(): void { $this->g = new PathGuard(); }

    public static function badPaths(): array
    {
        return [
            ['../etc/passwd'],
            ['/etc/passwd'],
            ['a/../../b'],
            ['a\0b'],
            [''],
        ];
    }

    /** @dataProvider badPaths */
    public function testRejects(string $path): void
    {
        $this->expectException(\RuntimeException::class);
        $this->g->resolve('/tmp/root', $path);
    }

    public function testAccepts(): void
    {
        $dir = sys_get_temp_dir() . '/aifl-' . uniqid();
        mkdir($dir, 0700, true);
        $out = $this->g->resolve($dir, 'logs/app.log');
        $this->assertStringStartsWith($dir, $out);
        rmdir($dir);
    }
}

tests/RedactorTest.php

php

<?php
declare(strict_types=1);

use AIFL\Security\Redactor;
use PHPUnit\Framework\TestCase;

final class RedactorTest extends TestCase
{
    public function testJwt(): void
    {
        $r = new Redactor();
        $out = $r->redact('token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc');
        $this->assertStringContainsString('[JWT]', $out);
    }

    public function testAwsKey(): void
    {
        $r = new Redactor();
        $this->assertSame('[AWS_KEY]', $r->redact('AKIAIOSFODNN7EXAMPLE'));
    }

    public function testLuhnCard(): void
    {
        $r = new Redactor();
        $this->assertSame('card: [CARD]', $r->redact('card: 4111 1111 1111 1111'));
    }

    public function testNonLuhnKept(): void
    {
        $r = new Redactor();
        $this->assertSame('num: 1234 5678 9012 3456', $r->redact('num: 1234 5678 9012 3456'));
    }
}

tests/TenantStorageTest.php

php

<?php
declare(strict_types=1);

use AIFL\Tenancy\{TenantResolver, TenantStorage};
use PHPUnit\Framework\TestCase;

final class TenantStorageTest extends TestCase
{
    public function testIsolation(): void
    {
        $root = sys_get_temp_dir() . '/aifl-tenants-' . uniqid();
        mkdir($root, 0700, true);
        $s = new TenantStorage($root, new TenantResolver('constant'));
        $a = $s->pathFor('tenantA', 'logs/2026.log');
        $b = $s->pathFor('tenantB', 'logs/2026.log');
        $this->assertNotSame($a, $b);
        $this->assertStringContainsString('/tenants/tenantA/', $a);
        $this->assertStringContainsString('/tenants/tenantB/', $b);
    }

    public function testTraversalRejected(): void
    {
        $root = sys_get_temp_dir() . '/aifl-x-' . uniqid();
        mkdir($root, 0700, true);
        $s = new TenantStorage($root, new TenantResolver('constant'));
        $this->expectException(\RuntimeException::class);
        $s->pathFor('tenantA', '../tenantB/secret.log');
    }
}

14. خريطة الطبقات النهائية

#الطبقةالملفملاحظات
1Bootstrapai-file-logger.phpPHP 8.1+، autoload
2ContainerSupport/Container.phpsingleton + scoped
3ConfigSupport/Config.phpenv + filter
4Multi-tenancyTenancy/*مسار + مفتاح معزول
5Path guardSecurity/PathGuard.phptraversal + symlink
6Size guardSecurity/SizeGuard.phpentry/file/total
7ContentSecurity/ContentInspector.phpMIME + sanitize
8MalwareSecurity/MalwareScanner.phpINSTREAM
9RedactorSecurity/Redactor.php14 قاعدة + Luhn
10SigningSigning/Ed25519Signer.phpsodium
11HMACSigning/HmacSigner.phpبديل
12VerifyChainSigning/VerifyChain.phpسلسلة
13OpenTelemetryTelemetry/Tracer.phpOTLP/HTTP
14WriterStorage/Writer.phpatomic + fsync + lock
15VerifierStorage/Verifier.phphash + سلسلة
16RotatorStorage/Rotator.phpحجم + prune
17RetentionRetention/RetentionManager.phpTTL tags
18RESTRest/AdminController.phpverify/sweep/logs/rotate
19CLICli/*wp-cli
20IntegrationsIntegrations/*Woo/AI/WP
21Helmhelm/HPA/PDB/NetPol

15. ملاحظات WordPress/WooCommerce الخاصة

  1. أذونات الملفات: WordPress يعمل عادةً بـ user www-data. تأكد أن AIFL_STORAGE_ROOT خارج wp-content/uploads العام، أو استخدم .htaccess مع Require all denied.
  2. Multisite: tenancy.mode = blog_id يعزل تلقائياً لكل مدونة.
  3. WooCommerce HPOS: استخدم wc_get_orders() لا get_posts() لتوافق HPOS.
  4. AI plugins: لا تخزّن الـ prompts/الردود كاملة — استخدم redactor + preview 200 حرف.
  5. GDPR: وسوم pii بـ TTL 90 يوم افتراضي، مع توفير wp aifl sweep دوري.
  6. الاتصال بـ ClamAV: استخدم stream_socket_client مع Unix socket داخل نفس Pod إن أمكن (أسرع).
  7. التوقيع: مفاتيح Ed25519 لكل مدونة تُخزّن في .keys/ بأذونات 0600 — لا تُعرض عبر الويب أبداً.
  8. OTEL: wp_remote_post غير حاجب (blocking=false) لمنع تأخير الطلبات.
  9. REST auth: current_user_can('manage_options') + HTTPS إلزامي.
  10. Helm: readOnlyRootFilesystem: true + emptyDir للـ /tmp + PVC للـ storage.

هل تريد أن أُتبع هذا بـ:

  • اختبارات PHPUnit شاملة مع WordPress test suite (WP_UnitTestCase)؟
  • مخطط قاعدة بيانات لـ CDC مباشر من MySQL binlog إلى Elasticsearch؟
  • تكامل AWS KMS/HSM عبر PHP SDK داخل نفس الإضافة؟
  • لوحة إدارة React داخل wp-admin للاستعلام عن السجلات والاحتفاظ؟

This

  • دعم Multi-tenancy مع عزل كامل للتخزين والتوقيع لكل مستأجر؟
  • نظام تتبع موزع (OpenTelemetry) مع trace/span IDs مدمج؟
  • واجهة Admin (REST/gRPC) للاستعلام وإدارة الاحتفاظ؟
  • Helm chart للإنتاج Kubernetes مع HPA + PDB + NetworkPolicy؟
Scroll to Top

🤖 موصى به لك بناءً على اهتماماتك