UNPKG

@unchainedshop/events

Version:

Event emitter abstraction layer for the Unchained Engine

417 lines (416 loc) 17 kB
import { createHash } from 'node:crypto'; import { mkdir, readFile, appendFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { createLogger } from '@unchainedshop/logger'; import { OCSF_CLASS, OCSF_CATEGORY, OCSF_SEVERITY, OCSF_STATUS, OCSF_AUTH_ACTIVITY, OCSF_ACCOUNT_ACTIVITY, OCSF_API_ACTIVITY, } from "./ocsf-types.js"; export * from "./ocsf-types.js"; const logger = createLogger('unchained:audit'); const OCSF_VERSION = '1.4.0'; const PRODUCT_NAME = 'Unchained Engine'; const PRODUCT_VERSION = '4.5'; const PRODUCT_VENDOR = 'Unchained'; const GENESIS_HASH = '0'.repeat(64); export class AuditLog { dir; collectorUrl; collectorHeaders; batchSize; flushIntervalMs; lastEvent = null; writeLock = Promise.resolve(); initialized = false; pendingEvents = []; flushTimer; constructor(config = {}) { this.dir = config.directory || './audit-logs'; this.collectorUrl = config.collectorUrl; this.collectorHeaders = config.collectorHeaders || {}; this.batchSize = config.batchSize || 10; this.flushIntervalMs = config.flushIntervalMs || 5000; } getFilePath() { const date = new Date().toISOString().slice(0, 10); return join(this.dir, `audit-${date}.jsonl`); } async init() { if (this.initialized) return; await mkdir(this.dir, { recursive: true }); try { const files = (await readdir(this.dir)).filter((f) => f.endsWith('.jsonl')).sort(); for (let i = files.length - 1; i >= 0; i--) { const content = await readFile(join(this.dir, files[i]), 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); if (lines.length > 0) { const parsed = JSON.parse(lines[lines.length - 1]); if (parsed.unmapped?.hash) { this.lastEvent = parsed; break; } } } } catch { } if (this.collectorUrl && !this.flushTimer) { this.flushTimer = setInterval(() => this.flushToCollector(), this.flushIntervalMs); } this.initialized = true; } computeHash(event) { const { unmapped, ...rest } = event; const toHash = { ...rest, unmapped: unmapped ? { seq: unmapped.seq, prev_hash: unmapped.prev_hash } : undefined, }; const data = JSON.stringify(toHash, Object.keys(toHash).sort()); return createHash('sha256').update(data, 'utf8').digest('hex'); } createMetadata(uid) { return { version: OCSF_VERSION, product: { name: PRODUCT_NAME, version: PRODUCT_VERSION, vendor_name: PRODUCT_VENDOR, }, uid, }; } createUser(userId, userName) { return { uid: userId, name: userName, email_addr: userName?.includes('@') ? userName : undefined, }; } createEndpoint(ip) { if (!ip) return undefined; return { ip }; } async writeEvent(event) { const result = this.writeLock.then(async () => { await this.init(); const prevHash = this.lastEvent?.unmapped?.hash || GENESIS_HASH; const seq = (this.lastEvent?.unmapped?.seq || 0) + 1; const eventWithChain = { ...event, unmapped: { seq, prev_hash: prevHash, hash: '', }, }; const hash = this.computeHash(eventWithChain); eventWithChain.unmapped.hash = hash; const line = JSON.stringify(eventWithChain); await appendFile(this.getFilePath(), line + '\n', 'utf-8'); this.lastEvent = eventWithChain; if (this.collectorUrl) { this.pendingEvents.push(eventWithChain); if (this.pendingEvents.length >= this.batchSize) { this.flushToCollector().catch((err) => logger.error(`Failed to flush to collector: ${err.message}`)); } } logger.debug(`Audit: ${event.message || 'Event'} [class=${event.class_uid}] seq=${seq}`); return eventWithChain.metadata.uid; }); this.writeLock = result; return result; } async flushToCollector() { if (!this.collectorUrl || this.pendingEvents.length === 0) return; const events = [...this.pendingEvents]; this.pendingEvents = []; try { const response = await fetch(this.collectorUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.collectorHeaders, }, body: JSON.stringify({ events }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } logger.debug(`Flushed ${events.length} events to collector`); } catch (err) { this.pendingEvents = [...events, ...this.pendingEvents]; throw err; } } matches(event, query) { if (query.classUids?.length && !query.classUids.includes(event.class_uid)) return false; if (query.activityIds?.length && !query.activityIds.includes(event.activity_id)) return false; const userId = 'user' in event ? event.user?.uid : undefined; const actorId = 'actor' in event ? event.actor?.user?.uid : undefined; if (query.userId && userId !== query.userId && actorId !== query.userId) return false; if (query.success !== undefined) { const isSuccess = event.status_id === OCSF_STATUS.SUCCESS; if (query.success !== isSuccess) return false; } if (query.startTime || query.endTime) { const ts = new Date(event.time); if (query.startTime && ts < query.startTime) return false; if (query.endTime && ts > query.endTime) return false; } return true; } async logAuthentication(input) { const activityId = input.activity; const activityMessages = { [OCSF_AUTH_ACTIVITY.LOGON]: 'User Login', [OCSF_AUTH_ACTIVITY.LOGOFF]: 'User Logout', }; const event = { category_uid: OCSF_CATEGORY.IDENTITY_ACCESS_MGMT, class_uid: OCSF_CLASS.AUTHENTICATION, type_uid: OCSF_CLASS.AUTHENTICATION * 100 + activityId, activity_id: activityId, severity_id: input.success === false ? OCSF_SEVERITY.HIGH : OCSF_SEVERITY.INFORMATIONAL, status_id: input.success === false ? OCSF_STATUS.FAILURE : OCSF_STATUS.SUCCESS, time: Date.now(), message: input.message || activityMessages[activityId] || 'Authentication Event', metadata: this.createMetadata(crypto.randomUUID()), user: this.createUser(input.userId, input.userName), src_endpoint: this.createEndpoint(input.remoteAddress), is_mfa: input.isMfa, auth_protocol: input.authProtocol, session: input.sessionId ? { uid: input.sessionId } : undefined, }; return this.writeEvent(event); } async logAccountChange(input) { const activityId = input.activity; const activityMessages = { [OCSF_ACCOUNT_ACTIVITY.CREATE]: 'User Created', [OCSF_ACCOUNT_ACTIVITY.DELETE]: 'User Deleted', [OCSF_ACCOUNT_ACTIVITY.PASSWORD_CHANGE]: 'Password Changed', [OCSF_ACCOUNT_ACTIVITY.PASSWORD_RESET]: 'Password Reset', [OCSF_ACCOUNT_ACTIVITY.ATTACH_POLICY]: 'User Roles Changed', [OCSF_ACCOUNT_ACTIVITY.MFA_ENABLE]: 'MFA Enabled', [OCSF_ACCOUNT_ACTIVITY.MFA_DISABLE]: 'MFA Disabled', }; const event = { category_uid: OCSF_CATEGORY.IDENTITY_ACCESS_MGMT, class_uid: OCSF_CLASS.ACCOUNT_CHANGE, type_uid: OCSF_CLASS.ACCOUNT_CHANGE * 100 + activityId, activity_id: activityId, severity_id: input.success === false ? OCSF_SEVERITY.HIGH : OCSF_SEVERITY.INFORMATIONAL, status_id: input.success === false ? OCSF_STATUS.FAILURE : OCSF_STATUS.SUCCESS, time: Date.now(), message: input.message || activityMessages[activityId] || 'Account Change', metadata: this.createMetadata(crypto.randomUUID()), user: this.createUser(input.userId, input.userName), actor: input.actorUserId ? { user: this.createUser(input.actorUserId, input.actorUserName), session: input.sessionId ? { uid: input.sessionId } : undefined, } : undefined, src_endpoint: this.createEndpoint(input.remoteAddress), }; return this.writeEvent(event); } async logApiActivity(input) { const activityId = input.activity; const activityMessages = { [OCSF_API_ACTIVITY.CREATE]: 'Resource Created', [OCSF_API_ACTIVITY.READ]: 'Resource Read', [OCSF_API_ACTIVITY.UPDATE]: 'Resource Updated', [OCSF_API_ACTIVITY.DELETE]: 'Resource Deleted', [OCSF_API_ACTIVITY.CHECKOUT]: 'Order Checkout', [OCSF_API_ACTIVITY.PAYMENT]: 'Payment Processed', [OCSF_API_ACTIVITY.REFUND]: 'Refund Processed', [OCSF_API_ACTIVITY.EXPORT]: 'Data Exported', [OCSF_API_ACTIVITY.IMPORT]: 'Data Imported', [OCSF_API_ACTIVITY.ACCESS_DENIED]: 'Access Denied', }; const isAccessDenied = activityId === OCSF_API_ACTIVITY.ACCESS_DENIED; const severity = isAccessDenied || input.success === false ? OCSF_SEVERITY.HIGH : OCSF_SEVERITY.INFORMATIONAL; const event = { category_uid: OCSF_CATEGORY.APPLICATION_ACTIVITY, class_uid: OCSF_CLASS.API_ACTIVITY, type_uid: OCSF_CLASS.API_ACTIVITY * 100 + activityId, activity_id: activityId, severity_id: severity, status_id: input.success === false ? OCSF_STATUS.FAILURE : OCSF_STATUS.SUCCESS, time: Date.now(), message: input.message || activityMessages[activityId] || 'API Activity', metadata: this.createMetadata(crypto.randomUUID()), actor: { user: this.createUser(input.userId, input.userName), session: input.sessionId ? { uid: input.sessionId } : undefined, }, api: { operation: input.operation, response: input.responseCode ? { code: input.responseCode } : undefined, }, src_endpoint: this.createEndpoint(input.remoteAddress), http_request: input.httpMethod || input.path ? { http_method: input.httpMethod, url: input.path ? { path: input.path } : undefined, } : undefined, }; return this.writeEvent(event); } async find(query = {}) { await this.init(); const results = []; const limit = query.limit || 100; const offset = query.offset || 0; let skipped = 0; try { const files = (await readdir(this.dir)).filter((f) => f.endsWith('.jsonl')).sort(); for (let i = files.length - 1; i >= 0 && results.length < limit; i--) { const content = await readFile(join(this.dir, files[i]), 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); for (let j = lines.length - 1; j >= 0 && results.length < limit; j--) { try { const event = JSON.parse(lines[j]); if (!this.matches(event, query)) continue; if (skipped < offset) { skipped++; continue; } results.push(event); } catch { } } } } catch { } return results; } async count(query = {}) { await this.init(); let count = 0; try { const files = (await readdir(this.dir)).filter((f) => f.endsWith('.jsonl')).sort(); for (const file of files) { const content = await readFile(join(this.dir, file), 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); for (const line of lines) { try { const event = JSON.parse(line); if (this.matches(event, query)) count++; } catch { } } } } catch { } return count; } async getFailedLogins(params) { const events = await this.find({ classUids: [OCSF_CLASS.AUTHENTICATION], activityIds: [OCSF_AUTH_ACTIVITY.LOGON], userId: params.userId, success: false, startTime: params.since, }); if (params.remoteAddress) { return events.filter((e) => { const authEvent = e; return authEvent.src_endpoint?.ip === params.remoteAddress; }).length; } return events.length; } async verify() { await this.init(); let entries = 0; let verified = 0; let prevHash = GENESIS_HASH; let prevSeq = 0; try { const files = (await readdir(this.dir)).filter((f) => f.endsWith('.jsonl')).sort(); for (const file of files) { const content = await readFile(join(this.dir, file), 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); for (const line of lines) { entries++; let event; try { event = JSON.parse(line); } catch { return { valid: false, entries, verified, error: `Parse error at entry ${entries}` }; } if (!event.unmapped) { return { valid: false, entries, verified, error: `Missing hash chain at entry ${entries}` }; } if (event.unmapped.seq !== prevSeq + 1) { return { valid: false, entries, verified, error: `Sequence gap at ${event.unmapped.seq}` }; } if (event.unmapped.prev_hash !== prevHash) { return { valid: false, entries, verified, error: `Chain broken at seq ${event.unmapped.seq}`, }; } const computedHash = this.computeHash(event); if (computedHash !== event.unmapped.hash) { return { valid: false, entries, verified, error: `Hash mismatch at seq ${event.unmapped.seq}`, }; } verified++; prevHash = event.unmapped.hash; prevSeq = event.unmapped.seq; } } } catch (e) { return { valid: false, entries, verified, error: `Read error: ${e}` }; } return { valid: true, entries, verified }; } async close() { if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = undefined; } if (this.collectorUrl && this.pendingEvents.length > 0) { try { await this.flushToCollector(); } catch (err) { logger.error(`Failed to flush pending events on close: ${err.message}`); } } await this.writeLock; this.initialized = false; this.lastEvent = null; } } export function createAuditLog(config) { if (typeof config === 'string') { return new AuditLog({ directory: config }); } return new AuditLog(config); }