All files / src/utils auditLogger.ts

100% Statements 55/55
100% Branches 43/43
100% Functions 16/16
100% Lines 54/54

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452                                                                                                                                      14x 14x 14x 14x 14x                                                                                                                                                                                       105x 105x 105x 105x 105x       105x 93x   12x         4x       8x                                 213x     213x               213x 15x   213x 213x                                                         5x 5x                                                   95x                                             53x                                             47x 47x 47x                         5x 5x 5x                                 5x 5x                         96x 96x               153x       6x             15x     15x     15x 12x           3x 3x 1x         213x 214x 214x 214x     214x     2x 1x         1x                                 14x                       51x    
/**
 * Audit Logger for GDPR compliance and security monitoring.
 *
 * **Intelligence Perspective:** Audit trails enable accountability analysis and
 * access pattern intelligence—essential for data governance in political data systems.
 *
 * **Business Perspective:** GDPR audit compliance is a prerequisite for enterprise
 * customers and EU institutional partnerships requiring demonstrable data governance.
 *
 * **Marketing Perspective:** GDPR-compliant audit logging is a trust signal for
 * EU-focused customers and differentiates against non-compliant alternatives.
 *
 * ISMS Policy: AU-002 (Audit Logging and Monitoring), GDPR Articles 5, 17, 30
 *
 * Logs all access to personal data (MEP information) for audit trails
 * and regulatory compliance.
 *
 * @module utils/auditLogger
 * @since 0.8.0
 */
 
import type {
  AuditFilter,
  AuditLogEntry,
  AuditLoggerOptions,
  AuditSink,
  AuthToken,
} from './auditSink.js';
import {
  DEFAULT_SENSITIVE_KEYS,
  MemoryAuditSink,
  RetentionPolicy,
  sanitizeParams,
  StderrAuditSink,
} from './auditSink.js';
 
// Re-export the shared data model for backward compatibility.
export type { AuditLogEntry } from './auditSink.js';
 
// Re-export the pluggable-sink public API so consumers only need one import.
export type {
  AuditFilter,
  AuditLoggerOptions,
  AuditSink,
  AuthToken,
} from './auditSink.js';
export {
  DEFAULT_SENSITIVE_KEYS,
  FileAuditSink,
  MemoryAuditSink,
  RetentionPolicy,
  sanitizeParams,
  StderrAuditSink,
  StructuredJsonSink,
} from './auditSink.js';
export type { FileAuditSinkOptions } from './auditSink.js';
 
/**
 * Typed log levels for structured audit events.
 *
 * | Level   | Use case |
 * |---------|----------|
 * | `DEBUG` | Verbose trace information (dev only) |
 * | `INFO`  | Normal data-access events |
 * | `WARN`  | Recoverable anomalies |
 * | `ERROR` | Failed operations |
 */
export enum LogLevel {
  DEBUG = 'DEBUG',
  INFO = 'INFO',
  WARN = 'WARN',
  ERROR = 'ERROR',
}
 
/**
 * Structured audit event used for MCP tool call tracking.
 *
 * Designed to be serialised to JSON for append-only log sinks
 * (CloudWatch, Elasticsearch, etc.).
 */
export interface AuditEvent {
  /** Severity level of the event */
  level: LogLevel;
  /** ISO-8601 timestamp */
  timestamp: string;
  /** Action / tool name */
  action: string;
  /** MCP tool name (if the event was triggered by a tool call) */
  toolName?: string;
  /** Sanitised tool input parameters */
  params?: Record<string, unknown>;
  /** Outcome metadata */
  result?: {
    count?: number;
    success: boolean;
    error?: string;
  };
  /** Wall-clock duration of the operation in milliseconds */
  duration?: number;
}
 
// ---------------------------------------------------------------------------
// AuditLogger
// ---------------------------------------------------------------------------
 
/**
 * GDPR-compliant audit logger with pluggable sinks, parameter sanitisation,
 * data retention enforcement, and access-controlled log retrieval.
 *
 * ## Pluggable sinks
 * By default the logger writes to an in-memory buffer (queryable via
 * `getLogs()`) and to `stderr` (MCP-compatible).  Pass a `sinks` option to
 * replace the default stderr sink with your own destinations
 * (e.g. `FileAuditSink`, `StructuredJsonSink`).
 *
 * ## Parameter sanitisation
 * All `params` objects are passed through `sanitizeParams()` before storage.
 * Only **top-level** keys matching `sensitiveKeys` (default:
 * `DEFAULT_SENSITIVE_KEYS`) are replaced by `'[REDACTED]'` to prevent PII
 * leakage into audit trails. Nested objects/arrays are **not** recursively
 * sanitised; callers must avoid placing PII in nested structures or
 * pre-sanitise such data before logging.
 *
 * ## Data retention
 * When `retentionMs` is set, `getLogs()` automatically filters out entries
 * older than the configured maximum age (GDPR Article 5(1)(e)).
 *
 * ## Access control
 * When `requiredAuthToken` is set, `getLogs()`, `queryLogs()`, `clear()`, and
 * `eraseByUser()` throw if the caller does not supply the correct token.
 *
 * @example Basic usage (backward-compatible)
 * ```typescript
 * auditLogger.logDataAccess('get_meps', { country: 'SE' }, 5, 85);
 * const entries = auditLogger.getLogs();
 * ```
 *
 * @example With file sink and 30-day retention
 * ```typescript
 * const requiredAuthToken = process.env['AUDIT_TOKEN'];
 * if (!requiredAuthToken) {
 *   throw new Error(
 *     'AUDIT_TOKEN environment variable must be set for audit log access control',
 *   );
 * }
 *
 * const logger = new AuditLogger({
 *   sinks: [new FileAuditSink({ filePath: '/var/log/ep-mcp-audit.ndjson' })],
 *   retentionMs: 30 * 24 * 60 * 60 * 1000,
 *   requiredAuthToken,
 * });
 * ```
 *
 * @since 0.8.0
 */
export class AuditLogger {
  private readonly memorySink: MemoryAuditSink;
  private readonly extraSinks: readonly AuditSink[];
  private readonly sensitiveKeys: readonly string[];
  private readonly retentionPolicy: RetentionPolicy | undefined;
  private readonly requiredAuthToken: AuthToken | undefined;
 
  constructor(options?: AuditLoggerOptions) {
    this.memorySink = new MemoryAuditSink();
    this.extraSinks = options?.sinks ?? [new StderrAuditSink()];
    this.sensitiveKeys = options?.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
    this.retentionPolicy = AuditLogger.buildRetentionPolicy(options?.retentionMs);
    this.requiredAuthToken = options?.requiredAuthToken;
  }
 
  private static buildRetentionPolicy(retentionMs: number | undefined): RetentionPolicy | undefined {
    if (retentionMs === undefined) {
      return undefined;
    }
    if (
      typeof retentionMs !== 'number' ||
      !Number.isFinite(retentionMs) ||
      retentionMs <= 0
    ) {
      throw new Error(
        `Invalid retentionMs: ${String(retentionMs)}. retentionMs must be a finite positive number of milliseconds.`,
      );
    }
    return new RetentionPolicy(retentionMs);
  }
 
  /**
   * Logs an audit event to the in-memory store and all configured sinks.
   *
   * Parameter values matching `sensitiveKeys` are automatically replaced by
   * `'[REDACTED]'` before storage.
   *
   * @param entry - Audit log entry without a timestamp (generated automatically)
   *
   * @security Writes to sinks only (not stdout, which is reserved for MCP).
   *   Per ISMS Policy AU-002, all MCP tool calls must be audit-logged.
   * @since 0.8.0
   */
  log(entry: Omit<AuditLogEntry, 'timestamp'>): void {
    const sanitized =
      entry.params !== undefined
        ? sanitizeParams(entry.params, this.sensitiveKeys)
        : undefined;
    const fullEntry: AuditLogEntry = {
      ...entry,
      ...(sanitized !== undefined ? { params: sanitized } : {}),
      timestamp: new Date(),
    };
    // Prune expired entries before writing to prevent unbounded memory growth
    // and ensure PII is not retained past the configured retention window
    // (GDPR Art. 5(1)(e) — Storage limitation principle).
    if (this.retentionPolicy !== undefined) {
      this.pruneExpiredEntries(this.retentionPolicy);
    }
    this.memorySink.write(fullEntry);
    this.writeSinks(fullEntry);
  }
 
  /**
   * Log an MCP tool call as an audit record.
   *
   * The tool's `params` are sanitised before being wrapped in the entry so
   * that PII in top-level tool parameter keys is redacted. Nested objects are
   * not recursively sanitised.
   *
   * @param toolName  - Name of the MCP tool that was invoked
   * @param params    - Tool input parameters (sanitised automatically)
   * @param success   - Whether the tool call completed without error
   * @param duration  - Optional wall-clock duration in milliseconds
   * @param error     - Optional error message if the call failed
   * @since 0.8.0
   */
  logToolCall(
    toolName: string,
    params: Record<string, unknown>,
    success: boolean,
    duration?: number,
    error?: string
  ): void {
    // Sanitize the tool params object before wrapping so that PII in
    // top-level tool parameter keys is redacted. The outer log() call will
    // also sanitize the top-level params object, but the 'tool' key is not in
    // sensitiveKeys, so the already-sanitized inner params pass through
    // unchanged.
    const sanitizedToolParams = sanitizeParams(params, this.sensitiveKeys);
    this.log({
      action: 'tool_call',
      params: { tool: { name: toolName, params: sanitizedToolParams } },
      result: {
        success,
        ...(error !== undefined && { error }),
      },
      ...(duration !== undefined && { duration }),
    });
  }
 
  /**
   * Logs a successful data-access event (e.g. a query returning records).
   *
   * @param action   - Action name (e.g. `'get_meps'`, `'get_committee_meetings'`)
   * @param params   - Query parameters (sanitised automatically)
   * @param count    - Number of records returned
   * @param duration - Optional wall-clock duration in milliseconds
   * @since 0.8.0
   */
  logDataAccess(
    action: string,
    params: Record<string, unknown>,
    count: number,
    duration?: number
  ): void {
    this.log({
      action,
      params,
      result: { count, success: true },
      ...(duration !== undefined && { duration }),
    });
  }
 
  /**
   * Logs a failed operation as an audit error event.
   *
   * @param action   - Action name
   * @param params   - Parameters supplied to the failed operation (sanitised)
   * @param error    - Human-readable error message (must not contain secrets)
   * @param duration - Optional wall-clock duration in milliseconds
   * @since 0.8.0
   */
  logError(
    action: string,
    params: Record<string, unknown>,
    error: string,
    duration?: number
  ): void {
    this.log({
      action,
      params,
      result: { success: false, error },
      ...(duration !== undefined && { duration }),
    });
  }
 
  /**
   * Returns a snapshot of all in-memory audit log entries, optionally filtered
   * by the configured data-retention policy.
   *
   * When `requiredAuthToken` was set in the constructor, `authorization` must
   * match; otherwise an `Error` is thrown.
   *
   * @param authorization - Authorization token (required when configured)
   * @returns Entries ordered oldest-first, filtered by retention policy
   *
   * @security When `requiredAuthToken` is configured, this method is access-
   *   controlled. Do not expose the returned entries through public APIs.
   * @since 0.8.0
   */
  getLogs(authorization?: AuthToken): AuditLogEntry[] {
    this.checkAuthorization(authorization);
    const entries = this.memorySink.query({});
    return this.retentionPolicy !== undefined
      ? this.retentionPolicy.enforce(entries)
      : entries;
  }
 
  /**
   * Queries the in-memory log using a filter.
   *
   * @param filter - Field-based filter to apply
   * @param authorization - Authorization token (required when configured)
   * @since 0.9.0
   */
  queryLogs(filter: AuditFilter, authorization?: AuthToken): AuditLogEntry[] {
    this.checkAuthorization(authorization);
    const entries = this.memorySink.query(filter);
    return this.retentionPolicy !== undefined
      ? this.retentionPolicy.enforce(entries)
      : entries;
  }
 
  /**
   * Removes all audit entries associated with `userId` from in-memory storage.
   *
   * **GDPR Article 17 — Right to Erasure.**  Only removes entries from the
   * in-memory `MemoryAuditSink`; entries already flushed to persistent sinks
   * (files, SIEM, etc.) must be erased separately via those sinks.
   *
   * @param userId       - The user whose entries should be erased
   * @param authorization - Authorization token (required when configured)
   * @since 0.9.0
   */
  eraseByUser(userId: string, authorization?: AuthToken): void {
    this.checkAuthorization(authorization);
    this.memorySink.eraseByUser(userId);
  }
 
  /**
   * Clears all in-memory audit log entries.
   *
   * **For testing only.** Clearing audit logs in production violates ISMS
   * Policy AU-002 and GDPR Article 30.
   *
   * @param authorization - Authorization token (required when configured)
   * @since 0.8.0
   */
  clear(authorization?: AuthToken): void {
    this.checkAuthorization(authorization);
    this.memorySink.clear(authorization);
  }
 
  // --------------------------------------------------------------------------
  // Private helpers
  // --------------------------------------------------------------------------
 
  private checkAuthorization(authorization?: AuthToken): void {
    if (
      this.requiredAuthToken !== undefined &&
      authorization !== this.requiredAuthToken
    ) {
      throw new Error(
        'Unauthorized: missing or invalid authorization token'
      );
    }
  }
 
  private pruneExpiredEntries(policy: RetentionPolicy): void {
    const all = this.memorySink.query({});
    // Use policy.enforce() so that a single cutoff timestamp is computed once
    // rather than calling Date.now() for every entry via isExpired().
    const fresh = policy.enforce(all);
 
    // If nothing expired, avoid unnecessary buffer rebuild.
    if (fresh.length === all.length) {
      return;
    }
 
    // Rebuild the in-memory buffer with only non-expired entries.
    // This correctly handles users who have both fresh and expired entries —
    // their fresh entries are preserved while only expired ones are dropped.
    this.memorySink.clear(undefined);
    for (const entry of fresh) {
      this.memorySink.write(entry);
    }
  }
 
  private writeSinks(entry: AuditLogEntry): void {
    for (const sink of this.extraSinks) {
      try {
        const result = sink.write(entry);
        const thenable = result as
          | { then?: (onFulfilled?: unknown, onRejected?: (reason: unknown) => void) => unknown }
          | undefined;
        if (thenable && typeof thenable.then === 'function') {
          // Fire-and-forget async sinks; surface errors to stderr so they are
          // observable without blocking the calling code path.
          void thenable.then(undefined, (err: unknown) => {
            console.error('[AuditLogger] Async sink write failed:', err);
          });
        }
      } catch (err: unknown) {
        // Ensure synchronous sink failures do not break the caller.
        console.error('[AuditLogger] Sync sink write failed:', err);
      }
    }
  }
}
 
// ---------------------------------------------------------------------------
// Global singleton
// ---------------------------------------------------------------------------
 
/**
 * Global audit logger instance.
 *
 * Uses default options: in-memory buffer + stderr output, no access control,
 * no retention policy.  Override by creating a new `AuditLogger` instance
 * with the desired {@link AuditLoggerOptions}.
 */
export const auditLogger = new AuditLogger();
 
/**
 * Extract a safe, human-readable error message from an unknown caught value.
 * Returns `error.message` for Error instances; `'Unknown error'` otherwise.
 * Avoids `String(error)` which can produce `"[object Object]"` for non-Error
 * throws and may expose custom `toString()` output unexpectedly.
 *
 * @param error - Caught error value (unknown type from catch clause)
 * @returns Human-readable error message safe for audit logging
 */
export function toErrorMessage(error: unknown): string {
  return error instanceof Error ? error.message : 'Unknown error';
}