llm-security/scanners/lib/mcp-description-cache.mjs
Kjell Tore Guttormsen 207385fbbe fix(llm-security): scanner robustness — ReDoS, MCP-stdout DoS, redirect loop, atomic writes (#24,#53,#31,#25,#51)
#24 the HTML-obfuscation injection patterns had overlapping unbounded runs plus a required closing quote, so a non-closing input backtracked O(N^2) (~28.7s at the 512KB cap); quantifiers bounded, pathological input now 4ms. #53 mcp-live-inspect buffered MCP-server stdout via readline with no cap, so a hostile stdio server could exhaust memory / throw an uncaught RangeError; replaced with manual line buffering capped at 4MB that rejects pending RPCs and destroys stdout. #31 vsix-fetch's same-host redirect follower had no depth cap (loop hang); added depth>=5 cap mirroring the sibling fetcher.

#25/#51 mcp-description-cache and skill-registry wrote JSON via bare writeFileSync (non-atomic: concurrent load-modify-save loses updates, a torn read silently yields an empty registry); both now write a temp file then renameSync. Suite 1931/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
2026-07-18 10:15:11 +02:00

422 lines
14 KiB
JavaScript

// mcp-description-cache.mjs — Cache MCP tool descriptions and detect drift.
// Zero external dependencies.
//
// Purpose:
// MCP servers can change tool descriptions between sessions (rug-pull, MCP05).
// This module caches the first-seen description for each tool and alerts when
// a subsequent invocation delivers a description that has drifted significantly
// (Levenshtein distance > 10% of original length).
//
// v7.3.0 (E14): Adds a sticky baseline per tool so cumulative drift across
// many small updates can be detected. Each entry now carries:
// - description: latest description
// - firstSeen / lastSeen: timestamps
// - baseline: { description, seenAt } — immutable until clearBaseline()
// - history: [{ description, seenAt, distance }] — last 10 drift events (FIFO)
// Cumulative drift = levenshtein(current, baseline.description) / max(|current|, |baseline|)
// When cumulative >= cumulative_drift_threshold (default 0.25), emit advisory.
// Baseline survives TTL purge so slow-burn drift is preserved.
//
// Storage: ~/.cache/llm-security/mcp-descriptions.json
// TTL: 7 days per entry — but entries with a baseline survive purge.
//
// OWASP: MCP05 (Tool Description Manipulation / Rug Pull)
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { levenshtein } from './string-utils.mjs';
import { getPolicyValue } from './policy-loader.mjs';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CACHE_DIR = join(homedir(), '.cache', 'llm-security');
const CACHE_FILE = join(CACHE_DIR, 'mcp-descriptions.json');
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const DRIFT_THRESHOLD = 0.10; // 10% Levenshtein distance per-update
const CUMULATIVE_DRIFT_THRESHOLD_DEFAULT = 0.25; // 25% baseline drift
const HISTORY_CAP = 10;
// ---------------------------------------------------------------------------
// Cache structure (v7.3.0)
// ---------------------------------------------------------------------------
// {
// "mcp__server__tool": {
// "description": "latest description text",
// "firstSeen": 1712345678000,
// "lastSeen": 1712345678000,
// "hash": "sha256-prefix (optional, for quick equality check)",
// "baseline": { "description": "...", "seenAt": 1712345678000 },
// "history": [
// { "description": "...", "seenAt": 1712345678000, "distance": 12 }
// ]
// }
// }
/**
* Resolve cache file path. Env-var override useful for testing the hook
* without polluting the user's real cache.
* @param {object} opts
* @returns {string}
*/
function resolveCacheFile(opts) {
if (opts.cacheFile) return opts.cacheFile;
if (process.env.LLM_SECURITY_MCP_CACHE_FILE) return process.env.LLM_SECURITY_MCP_CACHE_FILE;
return CACHE_FILE;
}
/**
* Migrate a legacy v7.2.0 cache entry to the v7.3.0 shape.
* Discriminator: presence of `history` array. v7.2.0 entries have neither
* `baseline` nor `history`; v7.3.0 entries always have `history` (even if
* empty). After `clearBaseline()` an entry has `history` but no `baseline`,
* which is NOT a legacy entry and must NOT be re-seeded here — the next
* `checkDescriptionDrift` call will seed baseline from the incoming
* description instead.
* Idempotent — running on a v7.3.0 entry is a no-op.
* @param {object} entry
* @returns {object}
*/
function migrateEntry(entry) {
if (!Array.isArray(entry.history)) {
// v7.2.0 → v7.3.0: seed baseline from current description and add history
if (!entry.baseline) {
entry.baseline = {
description: entry.description,
seenAt: entry.firstSeen,
};
}
entry.history = [];
}
return entry;
}
/**
* Load the cache from disk. Purges entries older than TTL — but entries with
* a `baseline` survive purge, so cumulative-drift detection persists across
* the 7-day window.
* Returns empty object if file doesn't exist or is corrupt.
* Migrates v7.2.0 entries on the fly.
* @param {object} [opts] - Options for testing
* @param {string} [opts.cacheFile] - Override cache file path
* @param {number} [opts.now] - Override current time
* @returns {Record<string, object>}
*/
export function loadCache(opts = {}) {
const cacheFile = resolveCacheFile(opts);
const now = opts.now ?? Date.now();
if (!existsSync(cacheFile)) return {};
try {
const raw = readFileSync(cacheFile, 'utf-8');
const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return {};
const cleaned = {};
for (const [key, entry] of Object.entries(data)) {
if (entry && typeof entry === 'object' && typeof entry.lastSeen === 'number') {
const stale = now - entry.lastSeen > TTL_MS;
if (!stale || entry.baseline) {
cleaned[key] = migrateEntry(entry);
}
}
}
return cleaned;
} catch {
return {};
}
}
/**
* Save the cache to disk. Creates the cache directory if needed.
* @param {Record<string, object>} cache
* @param {object} [opts]
* @param {string} [opts.cacheFile] - Override cache file path
*/
export function saveCache(cache, opts = {}) {
const cacheFile = resolveCacheFile(opts);
const dir = dirname(cacheFile);
try {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Atomic write (v7.8.3 #25): temp file + rename so a concurrent
// load-modify-save never reads a torn file (which parses as {} and
// silently drops every baseline).
const tmpPath = `${cacheFile}.tmp-${process.pid}-${Date.now()}`;
writeFileSync(tmpPath, JSON.stringify(cache, null, 2), 'utf-8');
renameSync(tmpPath, cacheFile);
} catch {
// Silently fail — drift detection is advisory, not critical
}
}
/**
* Resolve the cumulative-drift threshold (0..1).
* Order: opts.cumulativeThreshold → policy.json (mcp.cumulative_drift_threshold) → default.
* @param {object} opts
* @returns {number}
*/
function resolveCumulativeThreshold(opts) {
if (typeof opts.cumulativeThreshold === 'number') return opts.cumulativeThreshold;
try {
const v = getPolicyValue('mcp', 'cumulative_drift_threshold', CUMULATIVE_DRIFT_THRESHOLD_DEFAULT);
return typeof v === 'number' ? v : CUMULATIVE_DRIFT_THRESHOLD_DEFAULT;
} catch {
return CUMULATIVE_DRIFT_THRESHOLD_DEFAULT;
}
}
/**
* Check a tool description against the cached version.
*
* First call for a tool: caches the description AND seeds the baseline,
* returns no drift.
* Subsequent calls:
* - per-update drift: levenshtein(current, previous) / |previous| > 10%
* - cumulative drift: levenshtein(current, baseline) / max(|current|, |baseline|) >= 25%
*
* @param {string} toolName - Full tool name (e.g. "mcp__tavily__tavily_search")
* @param {string} description - Current tool description
* @param {object} [opts] - Options for testing
* @param {string} [opts.cacheFile] - Override cache file path
* @param {number} [opts.now] - Override current time
* @param {number} [opts.cumulativeThreshold] - Override cumulative threshold (0..1)
* @returns {{
* drift: boolean,
* detail: string|null,
* distance: number,
* threshold: number,
* cached: string|null,
* baselineDriftPct: number,
* perUpdateDriftPct: number,
* cumulative: { drifted: boolean, distance: number, threshold: number, detail: string|null, baseline: string|null }
* }}
*/
export function checkDescriptionDrift(toolName, description, opts = {}) {
const now = opts.now ?? Date.now();
const noDrift = {
drift: false,
detail: null,
distance: 0,
threshold: 0,
cached: null,
baselineDriftPct: 0,
perUpdateDriftPct: 0,
cumulative: { drifted: false, distance: 0, threshold: 0, detail: null, baseline: null },
};
if (!toolName || !description || typeof description !== 'string') {
return noDrift;
}
const cumulativeThreshold = resolveCumulativeThreshold(opts);
const cache = loadCache(opts);
const existing = cache[toolName];
if (!existing) {
// First time seeing this tool — cache it and seed the baseline
cache[toolName] = {
description,
firstSeen: now,
lastSeen: now,
baseline: { description, seenAt: now },
history: [],
};
saveCache(cache, opts);
return {
...noDrift,
threshold: DRIFT_THRESHOLD,
cumulative: { drifted: false, distance: 0, threshold: cumulativeThreshold, detail: null, baseline: description },
};
}
// Defensive — entries from loadCache are already migrated, but be paranoid
migrateEntry(existing);
// Baseline was explicitly cleared (clearBaseline) — re-seed from the
// incoming description so the next legitimate MCP version becomes the
// new baseline.
if (!existing.baseline) {
existing.description = description;
existing.lastSeen = now;
existing.baseline = { description, seenAt: now };
saveCache(cache, opts);
return {
...noDrift,
threshold: DRIFT_THRESHOLD,
cached: null,
cumulative: { drifted: false, distance: 0, threshold: cumulativeThreshold, detail: null, baseline: description },
};
}
// Update lastSeen
existing.lastSeen = now;
// Quick equality check
if (existing.description === description) {
saveCache(cache, opts);
return {
...noDrift,
threshold: DRIFT_THRESHOLD,
cached: existing.description,
cumulative: {
drifted: false,
distance: 0,
threshold: cumulativeThreshold,
detail: null,
baseline: existing.baseline.description,
},
};
}
// Per-update Levenshtein distance (vs previous description)
const perDist = levenshtein(existing.description, description);
const perBaseLen = Math.max(existing.description.length, 1);
const perRatio = perDist / perBaseLen;
const perDrifted = perRatio > DRIFT_THRESHOLD;
// Cumulative Levenshtein distance (vs baseline)
const cumDist = levenshtein(existing.baseline.description, description);
const cumDenom = Math.max(existing.baseline.description.length, description.length, 1);
const cumRatio = cumDist / cumDenom;
const cumDrifted = cumRatio >= cumulativeThreshold;
// Push event into history (FIFO, capped)
existing.history.push({ description, seenAt: now, distance: perDist });
if (existing.history.length > HISTORY_CAP) {
existing.history.splice(0, existing.history.length - HISTORY_CAP);
}
const cachedDesc = existing.description;
// Update current description (baseline stays put)
existing.description = description;
saveCache(cache, opts);
let perDetail = null;
if (perDrifted) {
const pct = Math.round(perRatio * 100);
perDetail =
`Tool "${toolName}" description changed by ${pct}% (${perDist} edits / ${perBaseLen} chars). ` +
`Threshold: ${Math.round(DRIFT_THRESHOLD * 100)}%. This may indicate a rug-pull attack (OWASP MCP05).`;
}
let cumDetail = null;
if (cumDrifted) {
const pct = Math.round(cumRatio * 100);
cumDetail =
`Tool "${toolName}" cumulative description drift ${pct}% from baseline (${cumDist} edits). ` +
`Threshold: ${Math.round(cumulativeThreshold * 100)}%. ` +
`Slow-burn rug-pull may evade per-update detection (OWASP MCP05).`;
}
return {
drift: perDrifted,
detail: perDetail,
distance: perDist,
threshold: DRIFT_THRESHOLD,
cached: cachedDesc,
baselineDriftPct: cumRatio,
perUpdateDriftPct: perRatio,
cumulative: {
drifted: cumDrifted,
distance: cumDist,
threshold: cumulativeThreshold,
detail: cumDetail,
baseline: existing.baseline.description,
},
};
}
/**
* Extract MCP server name from a tool name.
* Convention: mcp__<server>__<tool>
* @param {string} toolName
* @returns {string|null}
*/
export function extractMcpServer(toolName) {
if (!toolName?.startsWith('mcp__')) return null;
const parts = toolName.split('__');
// mcp__server__tool → parts = ['mcp', 'server', 'tool']
return parts.length >= 3 ? parts[1] : null;
}
/**
* Clear the entire cache (for testing).
* @param {object} [opts]
* @param {string} [opts.cacheFile] - Override cache file path
*/
export function clearCache(opts = {}) {
saveCache({}, opts);
}
/**
* Clear the baseline slot for one tool (or all tools when toolName omitted).
* Preserves description, firstSeen, lastSeen, and history. After clearing,
* the next checkDescriptionDrift call will re-seed the baseline from the
* current description.
*
* @param {string} [toolName] - Specific tool, or omit to clear all baselines
* @param {object} [opts]
* @param {string} [opts.cacheFile] - Override cache file path
* @returns {{ cleared: number, tools: string[] }}
*/
export function clearBaseline(toolName, opts = {}) {
const cache = loadCache(opts);
const cleared = [];
if (toolName) {
if (cache[toolName] && cache[toolName].baseline) {
delete cache[toolName].baseline;
cleared.push(toolName);
}
} else {
for (const [key, entry] of Object.entries(cache)) {
if (entry && entry.baseline) {
delete entry.baseline;
cleared.push(key);
}
}
}
saveCache(cache, opts);
return { cleared: cleared.length, tools: cleared };
}
/**
* Read-only baseline listing (for the reset CLI's --list mode).
* @param {object} [opts]
* @returns {Array<{ tool: string, baseline: string, seenAt: number, lastSeen: number, history: number }>}
*/
export function listBaselines(opts = {}) {
const cache = loadCache(opts);
const out = [];
for (const [tool, entry] of Object.entries(cache)) {
if (entry && entry.baseline) {
out.push({
tool,
baseline: entry.baseline.description,
seenAt: entry.baseline.seenAt,
lastSeen: entry.lastSeen,
history: Array.isArray(entry.history) ? entry.history.length : 0,
});
}
}
return out;
}
// ---------------------------------------------------------------------------
// Exported constants (for testing)
// ---------------------------------------------------------------------------
export {
TTL_MS,
DRIFT_THRESHOLD,
CUMULATIVE_DRIFT_THRESHOLD_DEFAULT,
HISTORY_CAP,
CACHE_DIR,
CACHE_FILE,
};