Topic: audit log security

MCP server audit log security — append-only logs, log injection prevention, correlation ID propagation

Audit logs are the primary forensic record after a security incident: they determine what the LLM instructed the MCP server to do, what the server actually executed, and in what order. An audit log that can be tampered with, injected into, partially truncated, or reconstructed without a correlation thread is almost worthless for post-incident analysis. This page covers the five highest-impact audit log security patterns for MCP servers: log injection via newline characters in user-supplied values, append-only file descriptors that prevent truncation, AsyncLocalStorage correlation IDs that thread a single identifier through every async operation in a tool call, structured AuditEvent types with mandatory fields, and SHA-256 rotation manifests that detect silent log truncation after compression.

1. Log injection via newline characters

Log injection is possible whenever user-controlled input is concatenated directly into a log line. An attacker who controls any field that ends up in a log string can inject a newline character (\n) followed by content that looks like a legitimate log entry. In structured log formats that use one-line-per-event (NDJSON, logfmt, CEF), a single injected newline splits one log entry into two — or manufactures an entirely fake entry that appears to originate from the server itself.

The payload alice\n2026-01-01T00:00:00Z CRITICAL admin auth bypass success inserted into a username field produces two log lines: one authentic entry ending at alice, and one fabricated CRITICAL entry that never happened. A SIEM that ingests this log will alert on the fabricated critical event — or, conversely, an attacker can inject a fake success entry to cover a failed attempt that would otherwise appear as an anomaly.

// DANGEROUS: string interpolation of user-supplied values into log strings.
// username = "alice\n2026-01-01T00:00:00Z CRITICAL admin auth bypass success"
// produces two log lines: one real, one fabricated.

function logActionDangerous(username: string, action: string): void {
  // Direct interpolation — newline in username splits the log line
  const line = `${new Date().toISOString()} INFO User ${username} performed ${action}\n`
  process.stdout.write(line)
  // Output:
  // 2026-06-05T12:00:00.000Z INFO User alice
  // 2026-01-01T00:00:00Z CRITICAL admin auth bypass success
  //  performed read_file
  // A log parser treats these as three separate entries.
}

// SAFE: JSON-structured logging. Every field is a JSON value.
// Newlines inside a JSON string are serialized as \n (two characters),
// not as an actual newline that splits the record. The entire event is
// one line of NDJSON, regardless of what any field value contains.

interface LogEvent {
  timestamp: string
  level: 'info' | 'warn' | 'error'
  message: string
  [key: string]: unknown
}

function logEvent(level: LogEvent['level'], message: string, fields: Record<string, unknown> = {}): void {
  const event: LogEvent = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...fields
  }
  // JSON.stringify serializes \n inside strings as \\n — cannot split the line
  process.stdout.write(JSON.stringify(event) + '\n')
}

// Usage — username with embedded newline is safely serialized:
// logEvent('info', 'User performed action', { username, action })
// Output (single line):
// {"timestamp":"2026-06-05T12:00:00.000Z","level":"info","message":"User performed action","username":"alice\n2026-01-01T00:00:00Z CRITICAL admin auth bypass success","action":"read_file"}

// For Node.js, pino is the standard structured logger — it NDJSON-serializes
// all fields by default and never interpolates values into strings:
// import pino from 'pino'
// const logger = pino()
// logger.info({ username, action }, 'User performed action')
// — never use logger.info(`User ${username} performed ${action}`)

2. Append-only audit log pattern

A regular writable file can be truncated: fs.truncate(path, 0), fopen(path, 'w'), or echo > path all reset the file to zero length without error. A compromised process, a misconfigured log rotation script, or a path traversal exploit that lands in the log directory can silently destroy the audit record. The O_APPEND flag at the filesystem level prevents this for the current file handle: every write atomically seeks to end-of-file before writing, and O_TRUNC is not set. Opening with O_WRONLY | O_APPEND (without O_RDWR) means the file descriptor cannot be used to overwrite existing bytes — only to append new ones.

For cloud deployments, S3 Object Lock in COMPLIANCE mode provides an equivalent guarantee at the storage layer: no principal, including the bucket owner and root account, can delete or overwrite a locked object before its retention period expires.

import { open, constants, stat } from 'fs/promises'
import type { FileHandle } from 'fs/promises'
import { createHash } from 'crypto'

const AUDIT_LOG_PATH = '/var/log/mcp-server/audit.log'

// DANGEROUS: opening with 'a' flag via fs.promises.appendFile is safe for
// appending, but using a FileHandle opened with 'w' allows truncation —
// a bug elsewhere in the process could call fh.truncate(0) on this handle.
// Also, using a path (not a file handle) means each write re-opens the file,
// and the file could be replaced between writes.

// SAFE: open once with O_WRONLY | O_APPEND and keep the handle alive.
// O_WRONLY: write-only — cannot read or seek backward.
// O_APPEND: each write atomically seeks to EOF before writing — no truncation.
// Track the inode of the file at open time; if it changes, the log file was
// replaced (rotated or deleted) and we must re-open.

class AppendOnlyAuditLog {
  private handle: FileHandle | null = null
  private openedIno: bigint | null = null
  private readonly path: string

  constructor(logPath: string) {
    this.path = logPath
  }

  async open(): Promise<void> {
    // O_CREAT: create if absent. O_WRONLY | O_APPEND: append-only.
    this.handle = await open(
      this.path,
      constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT,
      0o640
    )
    const info = await this.handle.stat()
    this.openedIno = info.ino  // Store inode to detect file replacement
  }

  async write(event: Record<string, unknown>): Promise<void> {
    // Verify the file handle still points to the same inode.
    // If the file was rotated (renamed and a new file created at the same path),
    // the path inode changes but our handle still points to the old file.
    const currentStat = await stat(this.path).catch(() => null)
    if (!currentStat || currentStat.ino !== this.openedIno) {
      // File was rotated — close old handle and re-open new file
      await this.handle?.close()
      await this.open()
    }

    if (!this.handle) throw new Error('Audit log not open')
    const line = JSON.stringify(event) + '\n'
    await this.handle.write(line)
  }

  async close(): Promise<void> {
    await this.handle?.close()
    this.handle = null
  }
}

// Cloud: S3 Object Lock in COMPLIANCE mode.
// Each audit log segment is uploaded as a separate object with a 90-day
// retention period. No identity — including the AWS root account — can
// delete or overwrite the object before retention expires.

// import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
// const s3 = new S3Client({ region: 'us-east-1' })
//
// await s3.send(new PutObjectCommand({
//   Bucket: 'my-audit-logs',
//   Key: `audit/${Date.now()}-${randomBytes(8).toString('hex')}.ndjson.gz`,
//   Body: compressedLogBuffer,
//   ContentType: 'application/x-ndjson',
//   ObjectLockMode: 'COMPLIANCE',
//   ObjectLockRetainUntilDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
// }))

3. Correlation ID propagation through async call chains

A single MCP tool call may span dozens of async operations: the handler awaits a database query, which awaits a cache check, which triggers an upstream API call, which emits its own log entry. Without a shared identifier threaded through all of these operations, post-incident reconstruction requires matching timestamps — which is error-prone and impossible when operations overlap in time.

Node.js AsyncLocalStorage solves this without passing a context object through every function signature. A correlation ID generated at handler entry is stored in the AsyncLocalStorage context. Every subsequent async operation that runs within the same async call chain automatically has access to the ID — including callbacks, database query hooks, and HTTP client interceptors — without any changes to their signatures.

import { AsyncLocalStorage } from 'async_hooks'
import { randomUUID } from 'crypto'

// DANGEROUS: no correlation ID — audit events for the same tool call cannot
// be grouped without matching timestamps, which fails under concurrent load.

async function handleToolCallDangerous(toolName: string, args: unknown): Promise<unknown> {
  console.log(JSON.stringify({ level: 'info', msg: 'Tool call started', tool: toolName }))
  const result = await executeToolLogic(toolName, args)
  console.log(JSON.stringify({ level: 'info', msg: 'Tool call completed', tool: toolName }))
  return result
}

// SAFE: AsyncLocalStorage correlation ID.
// The store is populated once at handler entry. Every log call, DB hook, and
// HTTP interceptor reads from the store — no parameter threading required.

interface CorrelationContext {
  correlationId: string
  sessionId: string
  toolName: string
  startedAt: number
}

const correlationStore = new AsyncLocalStorage<CorrelationContext>()

// Call this at the entry point of every tool handler
export function generateCorrelationId(): string {
  return randomUUID()
}

export function auditLog(event: Record<string, unknown>): void {
  // Reads correlationId from the store — available anywhere in the async chain
  const ctx = correlationStore.getStore()
  const entry = {
    timestamp: new Date().toISOString(),
    correlationId: ctx?.correlationId ?? 'none',
    sessionId: ctx?.sessionId ?? 'unknown',
    toolName: ctx?.toolName ?? 'unknown',
    ...event
  }
  process.stdout.write(JSON.stringify(entry) + '\n')
}

async function handleToolCallSafe(
  sessionId: string,
  toolName: string,
  args: unknown
): Promise<unknown> {
  const correlationId = generateCorrelationId()
  const startedAt = Date.now()

  // Run the entire tool call inside the AsyncLocalStorage context.
  // Every await inside executeToolLogic (and any function it calls) will
  // automatically have access to correlationId via correlationStore.getStore().
  return correlationStore.run(
    { correlationId, sessionId, toolName, startedAt },
    async () => {
      auditLog({ event: 'tool_call_started', args: sanitizeArgs(args) })

      try {
        const result = await executeToolLogic(toolName, args)
        auditLog({
          event: 'tool_call_completed',
          outcome: 'success',
          durationMs: Date.now() - startedAt
        })
        return result
      } catch (err: any) {
        auditLog({
          event: 'tool_call_failed',
          outcome: 'error',
          errorType: err.constructor.name,
          // Do not log err.message — it may contain sensitive data
          durationMs: Date.now() - startedAt
        })
        throw err
      }
    }
  )
}

// Deep inside executeToolLogic → queryDatabase → buildQuery:
// auditLog({ event: 'db_query', table: 'documents', operation: 'SELECT' })
// ...this log entry automatically carries correlationId from the store,
// even though buildQuery has no idea about correlationId in its signature.

function sanitizeArgs(args: unknown): unknown {
  // Remove sensitive fields before logging args
  if (typeof args !== 'object' || args === null) return args
  const safe = { ...args as Record<string, unknown> }
  for (const key of ['password', 'token', 'secret', 'apiKey', 'authorization']) {
    if (key in safe) safe[key] = '[REDACTED]'
  }
  return safe
}

4. Audit log completeness — the AuditEvent type

An audit log entry that is missing key fields becomes useless for forensic reconstruction: a timestamp without a duration makes it impossible to detect slow exfiltration; an event without a session ID makes it impossible to trace which user triggered the action; a write event without a resource identifier makes it impossible to determine what was modified. The fix is a mandatory AuditEvent type enforced at compile time, combined with a wrapWithAudit() higher-order function that measures duration and emits the event unconditionally — regardless of whether the tool call succeeded or threw.

// Mandatory fields for every audit event
interface BaseAuditEvent {
  timestamp: string       // ISO 8601 with milliseconds: 2026-06-05T12:00:00.000Z
  correlationId: string   // UUID from AsyncLocalStorage
  toolName: string        // MCP tool name: 'read_file', 'execute_query', etc.
  sessionId: string       // MCP session identifier
  outcome: 'success' | 'failure' | 'error'
  durationMs: number      // Wall-clock time from handler entry to completion
}

// Write operations require resource identification
interface WriteAuditEvent extends BaseAuditEvent {
  operationType: 'write' | 'delete' | 'update'
  resourceType: string    // 'file', 'database_row', 'secret', etc.
  resourceId: string      // Path, primary key, or ARN of the modified resource
  bytesDelta?: number     // Size change in bytes (positive=added, negative=removed)
}

type AuditEvent = BaseAuditEvent | WriteAuditEvent

const auditLogStore = new AsyncLocalStorage<CorrelationContext>()

function emitAuditEvent(event: AuditEvent): void {
  // Validate required fields at runtime (belt-and-suspenders for JS callers)
  const required: Array<keyof BaseAuditEvent> = [
    'timestamp', 'correlationId', 'toolName', 'sessionId', 'outcome', 'durationMs'
  ]
  for (const field of required) {
    if (!(field in event) || event[field] === undefined || event[field] === '') {
      // Log the incomplete event anyway, but flag it — incomplete events are
      // still better than no event.
      process.stderr.write(JSON.stringify({ level: 'warn', msg: `Audit event missing field: ${field}` }) + '\n')
    }
  }
  process.stdout.write(JSON.stringify(event) + '\n')
}

// Higher-order function: wraps any tool handler with duration measurement
// and unconditional audit event emission.
// The audit event fires whether the handler returns normally or throws.
function wrapWithAudit<TArgs, TResult>(
  toolName: string,
  handler: (args: TArgs) => Promise<TResult>
): (args: TArgs) => Promise<TResult> {
  return async (args: TArgs): Promise<TResult> => {
    const ctx = auditLogStore.getStore()
    const startedAt = Date.now()
    let outcome: AuditEvent['outcome'] = 'success'

    try {
      const result = await handler(args)
      return result
    } catch (err) {
      outcome = err instanceof ValidationError ? 'failure' : 'error'
      throw err
    } finally {
      // finally runs regardless of success or throw — audit event always emits
      emitAuditEvent({
        timestamp: new Date().toISOString(),
        correlationId: ctx?.correlationId ?? 'none',
        toolName,
        sessionId: ctx?.sessionId ?? 'unknown',
        outcome,
        durationMs: Date.now() - startedAt
      })
    }
  }
}

class ValidationError extends Error {}

// Usage: wrap every tool handler at registration time
// const auditedReadFile = wrapWithAudit('read_file', readFileHandler)
// server.tool('read_file', auditedReadFile)

// For write operations, emit a WriteAuditEvent inside the handler:
async function deleteFileHandler(args: { path: string }): Promise<void> {
  const ctx = auditLogStore.getStore()
  const absolutePath = resolveAndValidatePath(args.path)
  await fs.unlink(absolutePath)

  // Emit write audit event with resource identification
  emitAuditEvent({
    timestamp: new Date().toISOString(),
    correlationId: ctx?.correlationId ?? 'none',
    toolName: 'delete_file',
    sessionId: ctx?.sessionId ?? 'unknown',
    outcome: 'success',
    durationMs: 0,  // wrapWithAudit measures overall duration; this is the resource event
    operationType: 'delete',
    resourceType: 'file',
    resourceId: absolutePath,
  })
}

function resolveAndValidatePath(p: string): string {
  const resolved = require('path').resolve('/app/workspace', p)
  if (!resolved.startsWith('/app/workspace/')) throw new ValidationError('Path traversal denied')
  return resolved
}

// Suppress unused import warning for this example
const fs = require('fs/promises')

5. Log integrity preservation under compression

Audit logs are typically rotated daily and compressed with gzip to reduce storage costs. A gzip file that is silently truncated — due to a disk-full error, an interrupted transfer, or deliberate tampering — decompresses without error up to the truncation point; gzip does not validate that the file contains all the data that was written. An attacker who can truncate the compressed archive removes all evidence from that point forward, and the SIEM ingests a partial log without any error signal.

The fix is to record the SHA-256 hash of each log file before it is compressed and rotated, appending the hash to a separate manifest file. Verifying the manifest after rotation, and before ingestion, detects any truncation or modification. The manifest file itself should be stored append-only or shipped to an immutable destination (S3 Object Lock, a separate logging account) so that an attacker who compromises the log server cannot rewrite the manifest to match a tampered archive.

import { createReadStream, createWriteStream } from 'fs'
import { open, constants, rename, appendFile } from 'fs/promises'
import { createHash } from 'crypto'
import { createGzip } from 'zlib'
import { pipeline } from 'stream/promises'
import path from 'path'

const LOG_DIR = '/var/log/mcp-server'
const MANIFEST_PATH = path.join(LOG_DIR, 'rotation-manifest.ndjson')

// Compute SHA-256 of a file by streaming it — avoids loading the entire
// file into memory for large log archives.
async function sha256File(filePath: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const hash = createHash('sha256')
    const stream = createReadStream(filePath)
    stream.on('data', (chunk) => hash.update(chunk))
    stream.on('end', () => resolve(hash.digest('hex')))
    stream.on('error', reject)
  })
}

// Rotate an audit log file:
// 1. Hash the source file before compression.
// 2. Compress to .gz.
// 3. Hash the compressed file.
// 4. Append both hashes + metadata to the manifest.
// 5. Delete the original log file.
async function rotateAuditLog(sourcePath: string): Promise<void> {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  const baseName = path.basename(sourcePath, '.log')
  const archivePath = path.join(LOG_DIR, `${baseName}-${timestamp}.log.gz`)

  // Step 1: hash the uncompressed source before any transformation
  const sourceHash = await sha256File(sourcePath)
  const sourceSize = (await import('fs/promises').then(m => m.stat(sourcePath))).size

  // Step 2: compress to archive file
  await pipeline(
    createReadStream(sourcePath),
    createGzip({ level: 9 }),
    createWriteStream(archivePath)
  )

  // Step 3: hash the compressed archive
  const archiveHash = await sha256File(archivePath)

  // Step 4: append to manifest (O_APPEND so the manifest itself cannot be
  // overwritten — only appended to)
  const manifestEntry = JSON.stringify({
    rotatedAt: new Date().toISOString(),
    sourcePath,
    sourceHash,       // SHA-256 of uncompressed log
    sourceSize,       // Byte count of uncompressed log
    archivePath,
    archiveHash,      // SHA-256 of compressed archive
  }) + '\n'

  await appendFile(MANIFEST_PATH, manifestEntry)

  // Step 5: remove the original uncompressed file only after manifest is written
  await import('fs/promises').then(m => m.unlink(sourcePath))

  console.log(`Rotated ${sourcePath} → ${archivePath} (source SHA-256: ${sourceHash})`)
}

// Verification script logic (run before SIEM ingestion):
// For each entry in rotation-manifest.ndjson:
//   1. Recompute SHA-256 of the archive file at archivePath.
//   2. Compare to archiveHash in the manifest.
//   3. If mismatch: ALERT — archive was modified or truncated after rotation.

async function verifyManifest(): Promise<void> {
  const { readFile } = await import('fs/promises')
  const manifestContent = await readFile(MANIFEST_PATH, 'utf8')
  const entries = manifestContent
    .trim()
    .split('\n')
    .filter(Boolean)
    .map(line => JSON.parse(line))

  let failures = 0
  for (const entry of entries) {
    const { archivePath, archiveHash } = entry
    let currentHash: string
    try {
      currentHash = await sha256File(archivePath)
    } catch {
      console.error(`INTEGRITY FAIL: archive not found: ${archivePath}`)
      failures++
      continue
    }

    if (currentHash !== archiveHash) {
      console.error(
        `INTEGRITY FAIL: ${archivePath}\n` +
        `  expected: ${archiveHash}\n` +
        `  actual:   ${currentHash}`
      )
      failures++
    } else {
      console.log(`OK: ${archivePath}`)
    }
  }

  if (failures > 0) {
    console.error(`\n${failures} integrity failure(s) detected — do not ingest these logs`)
    process.exit(1)
  } else {
    console.log(`\nAll ${entries.length} log archive(s) verified successfully`)
  }
}

The companion shell script for a cron-based pre-ingestion check integrates with existing log pipelines. Run it as a step in your log ingestion pipeline before the SIEM loader touches the files:

#!/usr/bin/env bash
# verify-logs.sh — verify SHA-256 integrity of rotated MCP audit log archives
# Run before ingesting logs into the SIEM. Exits non-zero if any archive fails.

set -euo pipefail

LOG_DIR="${1:-/var/log/mcp-server}"
MANIFEST="$LOG_DIR/rotation-manifest.ndjson"

if [[ ! -f "$MANIFEST" ]]; then
  echo "ERROR: manifest not found at $MANIFEST" >&2
  exit 1
fi

failures=0
total=0

while IFS= read -r line; do
  [[ -z "$line" ]] && continue
  archive_path=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['archivePath'])")
  expected_hash=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['archiveHash'])")
  total=$((total + 1))

  if [[ ! -f "$archive_path" ]]; then
    echo "FAIL (missing): $archive_path"
    failures=$((failures + 1))
    continue
  fi

  actual_hash=$(sha256sum "$archive_path" | awk '{print $1}')

  if [[ "$actual_hash" != "$expected_hash" ]]; then
    echo "FAIL (hash mismatch): $archive_path"
    echo "  expected: $expected_hash"
    echo "  actual:   $actual_hash"
    failures=$((failures + 1))
  else
    echo "OK: $archive_path"
  fi
done < "$MANIFEST"

echo ""
echo "Verified $total archives. Failures: $failures"
[[ $failures -eq 0 ]] || exit 1

What SkillAudit checks

SkillAudit's audit log scanner flags the following patterns across MCP server tool handler code and logging infrastructure:

Run a SkillAudit scan

Paste your GitHub URL and get log injection, append-only flags, correlation ID, and audit completeness results in 60 seconds.

Scan your MCP server free →