نواة تسجيل الملفات
توسيع إضافي — الطبقات الأربع المقترحة كاملة
سأكمل ما اقترحته: تكامل 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. مصفوفة تغطية نهائية
| المجال | الوحدة | الاختبارات |
|---|---|---|
| تقييد المسار | safePath | 6 حالات + symlink |
| حدود الحجم | sizeGuard | إدخال + ملف + إجمالي |
| فحص المحتوى | contentInspector | MIME + تنظيف + رفض ثنائي |
| فحص الفيروسات | malwareScanner | TCP/Socket + quarantine |
| Metadata | metadataHandler | بصمة + undescribed |
| الكتابة الذرية | writer | atomic + append + قفل |
| التحقق | verifier | حجم + بصمة |
| التدوير | rotator + timeRotator | حجم + فترة + ضغط + prune |
| التنقية | redactor | 14 قاعدة + Luhn + إحصاءات |
| التدقيق | audit | سجل لكل عملية |
| التكامل | ProductionLogger | N2E كامل |
6. خطوات النشر المقترحة
- بناء الصورة:bashdocker compose build
- تشغيل ClamAV أولاً (تحميل قاعدة بيانات الفيروسات ~3 دقائق أول مرة):bashdocker compose up -d clamav docker compose logs -f clamav # حتى “Database loaded”
- تشغيل التطبيق:bashdocker compose up -d logger-app
- التنظيف الدوري: cron داخل الحاوية لتشغيل
pruneوcompressPeriod. - المراقبة: أضف
/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 | مسار آمن | safePath | traversal, symlink, unicode |
| 2 | حدود حجم | sizeGuard | entry/file/total |
| 3 | فحص محتوى | contentInspector | MIME, sanitize, binary |
| 4 | فيروسات | malwareScanner | TCP, INSTREAM, quarantine |
| 5 | metadata | metadataHandler | hash, undescribed |
| 6 | كتابة | writer | atomic, fsync, lock |
| 7 | تحقق | verifier | size, hash |
| 8 | تدوير حجم | rotator | rename, gzip, prune |
| 9 | تدوير زمني | timeRotator | hourly/daily/weekly/monthly |
| 10 | تنقية | redactor | 14 قاعدة + Luhn |
| 11 | تدقيق | audit | سجل لكل عملية |
| 12 | مقاييس | metrics | counters/gauges/histograms |
| 13 | تنبيهات | alerts | Slack/Webhook/SMTP + throttle |
| 14 | توقيع | signer | HMAC chain + CLI تحقق |
| 15 | مكتبة | index.ts | TypeScript 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؟
| البُعد | HMAC | Ed25519 |
|---|---|---|
| المفتاح | مشترك (سريع) | زوج (عام/خاص) |
| التحقق | يحتاج السر | بالمفتاح العام فقط |
| الفصل | لا | نعم — المُنتِج لا يستطيع التحقق؟ لا، لكن المُدقّق لا يحتاج السر |
| التزوير | ممكن إن سُرّب المفتاح | أصعب — المفتاح الخاص معزول |
| الأداء | ~أسرع | كافٍ جداً (~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 | مقاييس | metrics | Prometheus |
| 13 | تنبيهات | alerts | Slack/Webhook/SMTP |
| 14 | توقيع HMAC | signer | سلسلة |
| 15 | Ed25519 | signing/ed25519Signer | غير متماثل |
| 16 | HSM/KMS | signing/kms/* | AWS/Vault/PKCS#11 |
| 17 | CDC | cdc/pipeline + sinks | ES/CH/Kafka |
| 18 | TTL Retention | retention/manager | وسوم + immutable |
| 19 | Schema | schema/logger + presets | Zod + JSON Schema |
| 20 | مكتبة npm | index.ts | CJS + 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. خريطة الطبقات النهائية
| # | الطبقة | الملف | ملاحظات |
|---|---|---|---|
| 1 | Bootstrap | ai-file-logger.php | PHP 8.1+، autoload |
| 2 | Container | Support/Container.php | singleton + scoped |
| 3 | Config | Support/Config.php | env + filter |
| 4 | Multi-tenancy | Tenancy/* | مسار + مفتاح معزول |
| 5 | Path guard | Security/PathGuard.php | traversal + symlink |
| 6 | Size guard | Security/SizeGuard.php | entry/file/total |
| 7 | Content | Security/ContentInspector.php | MIME + sanitize |
| 8 | Malware | Security/MalwareScanner.php | INSTREAM |
| 9 | Redactor | Security/Redactor.php | 14 قاعدة + Luhn |
| 10 | Signing | Signing/Ed25519Signer.php | sodium |
| 11 | HMAC | Signing/HmacSigner.php | بديل |
| 12 | VerifyChain | Signing/VerifyChain.php | سلسلة |
| 13 | OpenTelemetry | Telemetry/Tracer.php | OTLP/HTTP |
| 14 | Writer | Storage/Writer.php | atomic + fsync + lock |
| 15 | Verifier | Storage/Verifier.php | hash + سلسلة |
| 16 | Rotator | Storage/Rotator.php | حجم + prune |
| 17 | Retention | Retention/RetentionManager.php | TTL tags |
| 18 | REST | Rest/AdminController.php | verify/sweep/logs/rotate |
| 19 | CLI | Cli/* | wp-cli |
| 20 | Integrations | Integrations/* | Woo/AI/WP |
| 21 | Helm | helm/ | HPA/PDB/NetPol |
15. ملاحظات WordPress/WooCommerce الخاصة
- أذونات الملفات: WordPress يعمل عادةً بـ user
www-data. تأكد أنAIFL_STORAGE_ROOTخارجwp-content/uploadsالعام، أو استخدم.htaccessمعRequire all denied. - Multisite:
tenancy.mode = blog_idيعزل تلقائياً لكل مدونة. - WooCommerce HPOS: استخدم
wc_get_orders()لاget_posts()لتوافق HPOS. - AI plugins: لا تخزّن الـ prompts/الردود كاملة — استخدم redactor + preview 200 حرف.
- GDPR: وسوم
piiبـ TTL 90 يوم افتراضي، مع توفيرwp aifl sweepدوري. - الاتصال بـ ClamAV: استخدم
stream_socket_clientمع Unix socket داخل نفس Pod إن أمكن (أسرع). - التوقيع: مفاتيح Ed25519 لكل مدونة تُخزّن في
.keys/بأذونات0600— لا تُعرض عبر الويب أبداً. - OTEL:
wp_remote_postغير حاجب (blocking=false) لمنع تأخير الطلبات. - REST auth:
current_user_can('manage_options')+ HTTPS إلزامي. - 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؟