feat: initial open marketplace with llm-security, config-audit, ultraplan-local
This commit is contained in:
commit
f93d6abdae
380 changed files with 65935 additions and 0 deletions
193
plugins/llm-security/scanners/lib/mcp-description-cache.mjs
Normal file
193
plugins/llm-security/scanners/lib/mcp-description-cache.mjs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// 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).
|
||||
//
|
||||
// Storage: ~/.cache/llm-security/mcp-descriptions.json
|
||||
// TTL: 7 days per entry (stale entries purged on load).
|
||||
//
|
||||
// OWASP: MCP05 (Tool Description Manipulation / Rug Pull)
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { levenshtein } from './string-utils.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 relative to original length
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache structure
|
||||
// ---------------------------------------------------------------------------
|
||||
// {
|
||||
// "mcp__server__tool": {
|
||||
// "description": "original description text",
|
||||
// "firstSeen": 1712345678000,
|
||||
// "lastSeen": 1712345678000,
|
||||
// "hash": "sha256-prefix (optional, for quick equality check)"
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Load the cache from disk. Purges stale entries (older than TTL).
|
||||
* Returns empty object if file doesn't exist or is corrupt.
|
||||
* @param {object} [opts] - Options for testing
|
||||
* @param {string} [opts.cacheFile] - Override cache file path
|
||||
* @param {number} [opts.now] - Override current time
|
||||
* @returns {Record<string, { description: string, firstSeen: number, lastSeen: number }>}
|
||||
*/
|
||||
export function loadCache(opts = {}) {
|
||||
const cacheFile = opts.cacheFile ?? CACHE_FILE;
|
||||
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 {};
|
||||
|
||||
// Purge stale entries
|
||||
const cleaned = {};
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
if (entry && typeof entry === 'object' && typeof entry.lastSeen === 'number') {
|
||||
if (now - entry.lastSeen <= TTL_MS) {
|
||||
cleaned[key] = 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 = opts.cacheFile ?? CACHE_FILE;
|
||||
const dir = dirname(cacheFile);
|
||||
|
||||
try {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(cacheFile, JSON.stringify(cache, null, 2), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail — drift detection is advisory, not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a tool description against the cached version.
|
||||
*
|
||||
* First call for a tool: caches the description, returns no drift.
|
||||
* Subsequent calls: compares via Levenshtein distance.
|
||||
*
|
||||
* @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
|
||||
* @returns {{ drift: boolean, detail: string|null, distance: number, threshold: number, cached: 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 };
|
||||
|
||||
if (!toolName || !description || typeof description !== 'string') {
|
||||
return noDrift;
|
||||
}
|
||||
|
||||
const cache = loadCache(opts);
|
||||
const existing = cache[toolName];
|
||||
|
||||
if (!existing) {
|
||||
// First time seeing this tool — cache it
|
||||
cache[toolName] = {
|
||||
description,
|
||||
firstSeen: now,
|
||||
lastSeen: now,
|
||||
};
|
||||
saveCache(cache, opts);
|
||||
return noDrift;
|
||||
}
|
||||
|
||||
// Update lastSeen
|
||||
existing.lastSeen = now;
|
||||
|
||||
// Quick equality check
|
||||
if (existing.description === description) {
|
||||
saveCache(cache, opts);
|
||||
return noDrift;
|
||||
}
|
||||
|
||||
// Compute Levenshtein distance
|
||||
const dist = levenshtein(existing.description, description);
|
||||
const baseLen = Math.max(existing.description.length, 1);
|
||||
const ratio = dist / baseLen;
|
||||
const threshold = DRIFT_THRESHOLD;
|
||||
|
||||
if (ratio > threshold) {
|
||||
// Drift detected — update cache to new description (the description has changed)
|
||||
const cachedDesc = existing.description;
|
||||
existing.description = description;
|
||||
saveCache(cache, opts);
|
||||
|
||||
const pct = Math.round(ratio * 100);
|
||||
return {
|
||||
drift: true,
|
||||
detail: `Tool "${toolName}" description changed by ${pct}% (${dist} edits / ${baseLen} chars). ` +
|
||||
`Threshold: ${Math.round(threshold * 100)}%. This may indicate a rug-pull attack (OWASP MCP05).`,
|
||||
distance: dist,
|
||||
threshold,
|
||||
cached: cachedDesc,
|
||||
};
|
||||
}
|
||||
|
||||
// Minor change below threshold — update cache silently
|
||||
existing.description = description;
|
||||
saveCache(cache, opts);
|
||||
return { drift: false, detail: null, distance: dist, threshold, cached: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported constants (for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
export { TTL_MS, DRIFT_THRESHOLD, CACHE_DIR, CACHE_FILE };
|
||||
Loading…
Add table
Add a link
Reference in a new issue