@unchainedshop/events
Version:
Event emitter abstraction layer for the Unchained Engine
417 lines (416 loc) • 17 kB
JavaScript
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);
}