Claude skill logging and observability exposure
Logging is how skill authors debug in production — and how attackers exfiltrate data without touching the skill’s primary output. Claude skills that log tool arguments at INFO level send user queries and document content to SIEM indexers and log aggregators accessible to anyone with log-read permissions. Skills that include session tokens in log lines enable log-based session hijacking. Skills that emit nanosecond-precision timing data leak cryptographic side-channels. Skills with no log rotation exhaust disk and crash the host process. These four patterns form the SA-SKL-LOG family.
Every console.log, console.error, or structured logger call in a Claude skill writes to the MCP host’s stderr stream, which is forwarded to whatever log pipeline the operator has configured — local files, Datadog, Splunk, AWS CloudWatch, or a shared SIEM platform. In hosted, multi-tenant deployments the same log stream carries data from every user’s tool invocations. Unlike tool result content (which stays in the LLM conversation), log entries are often retained for weeks or months for debugging purposes, indexed in full-text search, and readable by any operator or developer with log access. A single console.log('Tool called:', JSON.stringify(args)) line at the top of a tool handler writes every argument the LLM passed — document content, user queries, email drafts, file paths — to that persistent, searchable, widely-accessible store.
SA-SKL-LOG-001: tool arguments logged verbatim expose user content to structured log pipelines
The most common pattern is a debugging line added at the start of a tool handler that logs all incoming arguments. The developer adds it during local development to see what the LLM is passing, deploys with it still enabled, and never revisits it because the skill “works.” In production, this line writes the complete tool arguments to the log stream for every invocation. Arguments carry document content (the LLM passed a 50,000-word document to the summarize tool), user queries (the search term the user typed), email draft content, or structured data including names and account numbers. This data flows into log aggregators where it is indexed, retained, and accessible to anyone with log-read access — a far wider audience than the encrypted conversation the user expected their data to stay in.
// VULNERABLE: verbatim argument logging at INFO level
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new Server({ name: 'doc-skill', version: '1.0.0' });
server.setRequestHandler('tools/call', async (req) => {
const { name, arguments: args } = req.params;
// Dumps all tool arguments to stderr on every call
console.log(`Tool called: ${name}`, JSON.stringify(args));
// ^ args may contain: document content, user queries, email drafts,
// file paths, personal names — all written to log aggregator at INFO
if (name === 'summarize_document') {
const { content } = args; // may be thousands of words of user content
return { content: [{ type: 'text', text: await summarize(content) }] };
}
});
// ALSO VULNERABLE: structured logger with full args object
import pino from 'pino';
const log = pino();
server.setRequestHandler('tools/call', async (req) => {
log.info({ toolName: req.params.name, args: req.params.arguments }, 'tool invoked');
// ^ pino serializes entire args object; Datadog/Splunk indexes every field
// ^ full-text search for "john.smith@company.com" returns all tool invocations
// where that email appeared in any argument value
});
// ALSO VULNERABLE: debug flag that stays true in npm package
const DEBUG = process.env.DEBUG_SKILL ?? true; // ← default true — all users get logging
server.setRequestHandler('tools/call', async (req) => {
if (DEBUG) console.log('Args:', req.params.arguments); // every user's data logged
});
// SECURE: log only non-sensitive metadata — tool name and argument key names, never values
server.setRequestHandler('tools/call', async (req) => {
const { name, arguments: args } = req.params;
const argKeys = args ? Object.keys(args) : [];
// Log shape (safe: just key names) not content (unsafe: values may contain PII)
console.log(`[tool] ${name} keys=[${argKeys.join(',')}]`);
// If content-length is useful for performance debugging, log only the length
if (args?.content) {
console.log(`[tool] ${name} content_length=${String(args.content).length}`);
}
});
// DETECTION (SkillAudit):
// Search for console.log/console.error/logger calls inside tools/call handler
// with arguments object or individual argument values interpolated
const verbatimArgLog = searchAST(
'CallExpression[callee=/console\\.(log|error|warn|info)/] inside tools/call handler ' +
'where arguments reference req.params.arguments or destructured arg values'
);
if (verbatimArgLog.length > 0) → HIGH // SA-SKL-LOG-001
DEBUG defaults are a production vulnerability: process.env.DEBUG_SKILL ?? true means every install logs unless the user explicitly opts out. Skills distributed via npm are installed on machines where operators do not set obscure env vars. Log-level defaults must be false or 'error' only; opt-in verbose logging, not opt-in silence.
console.log inside tools/call handler referencing argument values → HIGH.SA-SKL-LOG-002: session tokens or request IDs in log output enable log-based session hijacking
Skills that correlate log events across a session often include the MCP request ID, a session token, or an API key fragment in log lines. The intent is to filter all log events for one session when debugging. The consequence is that any attacker with log read access — a developer, a devops engineer, a compromised SIEM account, or an overpermissioned log aggregator API key — can extract live session tokens from the log stream and replay them against the skill or the upstream API. Unlike a compromised database, a log exfiltration delivers tokens that are still valid at the time of theft. Bearer tokens for APIs like GitHub, Anthropic, or Stripe that appeared in log lines during debugging sessions may remain valid for hours or days after the log entry was written.
// VULNERABLE: request ID and Authorization header fragment logged together
server.setRequestHandler('tools/call', async (req) => {
const requestId = req.params._meta?.requestId;
const authHeader = req.params._meta?.authorization;
console.log(`[${requestId}] Processing tool call, auth=${authHeader?.slice(0, 20)}...`);
// ^ First 20 chars of "Bearer ghp_ABCDEF..." may be sufficient to identify
// and guess the full token (base62 tokens have limited entropy per prefix)
// ^ requestId in same line creates a correlation key for session reconstruction
});
// ALSO VULNERABLE: session token logged on error paths
async function callUpstreamAPI(token, query) {
try {
return await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json());
} catch (err) {
// Logs full token on network error — common in debugging sessions
console.error(`API call failed for token ${token}:`, err.message);
// ^ Full live token in log line; log aggregator indexes it permanently
throw err;
}
}
// ALSO VULNERABLE: structured log with auth context
import pino from 'pino';
const log = pino();
server.setRequestHandler('tools/call', async (req) => {
log.info({
requestId: req.id,
sessionToken: req.params._meta?.sessionToken, // ← full token in structured field
tool: req.params.name
}, 'tool dispatched');
// ^ SIEM query: { sessionToken: * } returns all active sessions
});
// SECURE: log correlation IDs derived from tokens, never the tokens themselves
import crypto from 'crypto';
function tokenCorrelationId(token) {
// One-way hash — sufficient for log correlation, useless for replay
return crypto.createHash('sha256').update(token).digest('hex').slice(0, 12);
}
server.setRequestHandler('tools/call', async (req) => {
const token = req.params._meta?.sessionToken;
const corrId = token ? tokenCorrelationId(token) : 'anon';
console.log(`[${corrId}] tool=${req.params.name}`);
// Correlation possible; token not recoverable from log entry
});
// DETECTION (SkillAudit):
// Find log calls where the logged value is derived from _meta.authorization,
// _meta.sessionToken, process.env.*_KEY, or process.env.*_TOKEN
const tokenInLog = searchAST(
'log calls inside tools/call where arguments include ' +
'process.env.*KEY or process.env.*TOKEN or _meta.authorization or _meta.sessionToken'
);
if (tokenInLog.length > 0) → CRITICAL // SA-SKL-LOG-002
Log aggregators are not secrets managers: SIEM platforms like Splunk, Datadog, and CloudWatch index log entries for full-text search and retain them for compliance periods (often 90 days to 7 years). A token written to a log line in November will be searchable in March. Even if the token has expired, the log entry proves the token existed and may reveal the scope or permissions it held — useful for social engineering or CVE cross-referencing.
process.env.*TOKEN, *KEY, or _meta.authorization → CRITICAL.SA-SKL-LOG-003: high-precision timing in log output enables side-channel reconstruction
Skills that log timestamps around cryptographic operations, conditional branches on secret comparisons, or database queries with indexed secret columns introduce timing side-channels recoverable from log analysis. Node.js’s Date.now() has millisecond resolution, performance.now() has microsecond resolution, and process.hrtime.bigint() has nanosecond resolution. When a skill logs the start and end time of an HMAC comparison or a database lookup keyed on a secret token, an attacker with log access can compute the execution duration and infer whether a secret matched without making any direct API calls. This attack is relevant when the skill handles multi-user scenarios where one user can submit candidate tokens while observing log timing data that correlates with another user’s session state.
// VULNERABLE: high-resolution timing around secret-key comparison
import crypto from 'crypto';
async function verifyWebhookSignature(payload, signature, secret) {
const start = process.hrtime.bigint(); // nanosecond precision
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const elapsed = process.hrtime.bigint() - start;
// Logs nanosecond duration of HMAC computation alongside signature
console.log(`[webhook] sig_valid=${valid} duration_ns=${elapsed} sig_prefix=${signature.slice(0,8)}`);
// ^ timingSafeEqual is constant-time, but the surrounding code is not:
// HMAC computation duration varies with CPU load and secret key schedule.
// Attacker collects 10,000 log entries; shorter durations correlate with
// correct secret guesses via cache timing on HMAC key expansion.
return valid;
}
// ALSO VULNERABLE: timing around DB lookup keyed on token
async function lookupSession(token) {
const t0 = performance.now();
const row = await db.get('SELECT * FROM sessions WHERE token = ?', token);
const t1 = performance.now();
console.log(`[session] found=${!!row} lookup_ms=${(t1 - t0).toFixed(3)}`);
// ^ DB indexed on token: hit vs. miss has different timing
// ^ Attacker observes that valid tokens produce shorter lookups (index hit)
// vs. invalid tokens (index miss → full scan)
return row;
}
// ALSO VULNERABLE: branch timing leak through conditional log path
async function checkPermission(userId, resource, secretPart) {
const t = Date.now();
const allowed = secretPart === process.env.ADMIN_KEY; // ← non-constant-time string compare
const elapsed = Date.now() - t;
if (elapsed > 0) {
// Log entry exists only on slower (matching prefix) comparisons
// Absence of log entry reveals non-match; presence reveals partial match
console.log(`[auth] permission check elapsed ${elapsed}ms`);
}
return allowed;
}
// SECURE: log aggregate counts, not per-call durations for sensitive operations
let webhookChecks = 0, webhookFails = 0;
async function verifyWebhookSignatureSafe(payload, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(signature.padEnd(64, '0')),
Buffer.from(expected.padEnd(64, '0'))
);
// Count only — no per-call timing
webhookChecks++;
if (!valid) webhookFails++;
// Emit aggregate metrics periodically, not per-call
if (webhookChecks % 1000 === 0) {
console.log(`[webhook] checks=${webhookChecks} fail_rate=${(webhookFails/webhookChecks).toFixed(4)}`);
}
return valid;
}
// DETECTION (SkillAudit):
// Find log calls that include performance.now(), process.hrtime.bigint(), or Date.now() delta
// inside or within 5 lines of crypto operations or DB queries keyed on secret values
const timingInLog = searchAST(
'log call within 10 lines of crypto.createHmac, crypto.timingSafeEqual, or db.get/db.query ' +
'where log arguments include hrtime, performance.now(), or elapsed time variable'
);
if (timingInLog.length > 0) → MEDIUM // SA-SKL-LOG-003
timingSafeEqual is not enough: crypto.timingSafeEqual only eliminates timing variance in the comparison itself. Surrounding code — HMAC key expansion, Base64 decoding, memory allocation — still varies. Logging the total elapsed time of a function that calls timingSafeEqual captures that surrounding variance, defeating the constant-time comparison. Never log durations of authentication or cryptographic operations, even when using constant-time primitives.
process.hrtime or performance.now() delta in log calls adjacent to crypto.* operations → MEDIUM.SA-SKL-LOG-004: log rotation absent causes disk exhaustion DoS
Skills that write to local log files — via fs.createWriteStream, pino with a file destination, or winston with a file transport — without configuring rotation accumulate unbounded log data. In long-running MCP host processes serving many users, a verbose skill can produce tens of megabytes of logs per hour. Without rotation and maximum-file-size limits, the log file grows until the disk is full. When the disk fills, the Node.js process cannot allocate buffers for network I/O, crashes on the next fs.write call, or hangs on log flush — denying service to all users on the host. An attacker aware of the log verbosity can deliberately trigger high-volume tool calls to accelerate disk exhaustion, turning log misconfiguration into a targeted DoS primitive. Even without attacker involvement, a long-running skill in a Docker container with a limited volume mount will naturally DoS itself within days.
// VULNERABLE: fs.createWriteStream without rotation — unbounded growth
import fs from 'fs';
import path from 'path';
const logStream = fs.createWriteStream(
path.join(process.cwd(), 'skill.log'),
{ flags: 'a' } // append — never truncated, never rotated
);
server.setRequestHandler('tools/call', async (req) => {
logStream.write(`${new Date().toISOString()} tool=${req.params.name} args=${JSON.stringify(req.params.arguments)}\n`);
// ^ 500 tool calls/hour × 2KB average args = 1MB/hour = 24MB/day = 720MB/month
// ^ Docker container with 1GB data volume: DoS in ~5 weeks without any attacker
});
// ALSO VULNERABLE: pino file destination without rotation
import pino from 'pino';
const log = pino(pino.destination('./skill.log'));
// pino.destination does not rotate; must be wrapped with pino-roll or similar
// ALSO VULNERABLE: winston with file transport, no maxsize/maxFiles
import winston from 'winston';
const logger = winston.createLogger({
transports: [
new winston.transports.File({
filename: 'skill.log'
// Missing: maxsize, maxFiles, tailable
})
]
});
// ^ With no maxsize/maxFiles: single file grows without bound
// SECURE: winston file transport with rotation limits
import winston from 'winston';
const logger = winston.createLogger({
level: 'error', // only errors — not INFO verbosity
transports: [
new winston.transports.File({
filename: 'skill.log',
maxsize: 10 * 1024 * 1024, // 10MB max per file
maxFiles: 5, // keep only 5 rotated files (50MB total cap)
tailable: true
})
]
});
// ALSO SECURE: pino with pino-roll for time-based rotation
import pino from 'pino';
import { build } from 'pino-roll';
const transport = await build('./skill-log', {
frequency: 'daily',
mkdir: true,
size: '10m', // rotate at 10MB regardless of time
limit: { count: 7 } // keep 7 files
});
const log = pino({ level: 'warn' }, transport);
// DETECTION (SkillAudit):
// Find fs.createWriteStream to a .log file without piping through a rotate stream
const unboundedStream = searchAST(
'fs.createWriteStream(*.log) not wrapped in rotating-file-stream or pino-roll or similar'
);
// Find winston File transport without maxsize or maxFiles
const winstonUnbound = searchAST(
'new winston.transports.File({...}) where maxsize is absent or maxFiles is absent'
);
// Find pino.destination without rotation wrapper
const pinoUnbound = searchAST(
'pino.destination(...) not wrapped in pino-roll build()'
);
if (unboundedStream || winstonUnbound || pinoUnbound) → HIGH // SA-SKL-LOG-004
stderr is not safe from disk exhaustion: MCP host processes like Claude Code pipe skill stderr to their own log pipeline. If the skill emits at high volume to stderr, the host’s log file (not the skill’s) fills the disk. The host process then crashes, taking every skill it hosts with it — a multi-tenant DoS from a single verbose skill. Even when the skill writes only to stderr (no file), log-level discipline (WARN/ERROR only) and argument redaction are essential.
fs.createWriteStream, pino.destination, or winston.transports.File without rotation config → HIGH.SA-SKL-LOG summary
| ID | Pattern | Severity | Impact |
|---|---|---|---|
| SA-SKL-LOG-001 | Tool arguments logged verbatim at INFO level | HIGH | User document content and PII written to SIEM-indexed log pipelines |
| SA-SKL-LOG-002 | Session tokens or API keys in log output | CRITICAL | Log-read access converts directly to active session hijacking |
| SA-SKL-LOG-003 | High-precision timing logged near crypto operations | MEDIUM | Log-accessible timing side-channel for secret comparison attacks |
| SA-SKL-LOG-004 | Log rotation absent on file transports | HIGH | Disk exhaustion DoS crashes MCP host and all co-hosted skills |
How SkillAudit detects SA-SKL-LOG patterns
SkillAudit’s static analysis phase uses AST traversal to locate console.log, console.error, pino, and winston logger calls inside MCP tools/call request handlers. For each log call, it inspects the logged values: direct references to req.params.arguments, destructured argument variables, or interpolated argument values trigger SA-SKL-LOG-001. Log calls that include process.env.*TOKEN, process.env.*KEY, or _meta.authorization in their arguments trigger SA-SKL-LOG-002. High-resolution timing — process.hrtime.bigint(), performance.now(), or Date.now() delta variables — logged within 10 lines of crypto.* calls or database queries trigger SA-SKL-LOG-003. File transport configuration (Winston, pino-destination, fs.createWriteStream) missing rotation parameters triggers SA-SKL-LOG-004.
Run a free SkillAudit scan on your Claude skill or MCP server to check all four SA-SKL-LOG patterns alongside SA-SKL-ENV credential exposure, SA-SKL-TLS certificate validation bypass, SA-SKL-SRL unsafe deserialization, and SA-SKL-RCL resource lifecycle leakage in a single 60-second report.