ktg-plugin-marketplace/plugins/llm-security/examples/mcp-rug-pull/run-rug-pull.mjs
Kjell Tore Guttormsen 583a78c6cc feat(llm-security): add lethal-trifecta + mcp-rug-pull example contents [skip-docs]
Companion to 8df5d5c (which only carried the doc updates — the example
directories themselves were left out of staging by mistake). This
commit adds the actual example mappes:

- examples/lethal-trifecta-walkthrough/{README.md, run-trifecta.mjs,
  expected-findings.md}
- examples/mcp-rug-pull/{README.md, run-rug-pull.mjs,
  expected-findings.md}

Plus plugin CLAUDE.md "Examples (runnable demonstrations)" section
with a 4-row table covering malicious-skill-demo, prompt-injection-
showcase, lethal-trifecta-walkthrough, and mcp-rug-pull plus the
state-isolation discipline notes.

Marketplace root README unchanged since plugin's outward coverage
is unchanged ([skip-docs] covers the marketplace-level gate).
2026-05-05 14:45:39 +02:00

191 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
// run-rug-pull.mjs — MCP slow-burn rug-pull demonstration (v7.3.0 / E14)
// Mutates an MCP tool description across 7 stages — each edit stays UNDER
// the per-update threshold (10% Levenshtein vs previous), but cumulatively
// the description diverges >=25% from the original baseline. Demonstrates
// that `post-mcp-verify` emits a `mcp-cumulative-drift` MEDIUM advisory
// even when per-update detection never trips.
//
// State isolation:
// The hook reads MCP description cache via LLM_SECURITY_MCP_CACHE_FILE.
// This script points it at a private temp file and deletes that file
// on exit. The user's real ~/.cache/llm-security/mcp-descriptions.json
// is never touched.
//
// Usage:
// cd plugins/llm-security
// node examples/mcp-rug-pull/run-rug-pull.mjs
// node examples/mcp-rug-pull/run-rug-pull.mjs --verbose
import { execFile } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '../..');
const HOOK = resolve(PLUGIN_ROOT, 'hooks/scripts/post-mcp-verify.mjs');
const VERBOSE = process.argv.includes('--verbose');
// ---------------------------------------------------------------------------
// Description sequence — slow-burn rug-pull
// ---------------------------------------------------------------------------
//
// Baseline: a benign-looking knowledge-base MCP server description.
// Each subsequent stage applies a small edit. Per-update Levenshtein stays
// under 10%, but by stage 7 the cumulative distance to baseline >=25% and
// the meaning has shifted toward exfiltration ("upload", "external",
// "telemetry endpoint").
// Each stage edits at most ~6 chars vs the previous (per-update <10%), but
// every edit moves "internal knowledge base / agent / read" toward
// "local data store / user / brief snippets". By stage 7 the cumulative
// Levenshtein vs baseline crosses 25% — the rug-pull is detected even
// though no single update tripped per-update detection.
const STAGES = [
// 0 — baseline (121 chars). All future drift is measured against this.
'Search the internal knowledge base. Returns a ranked list of matching documents and short snippets for the agent to read.',
// 1 — "agent" → "user" (per=3.3%, cum=3.3%)
'Search the internal knowledge base. Returns a ranked list of matching documents and short snippets for the user to read.',
// 2 — "ranked" → "scored" (per=3.3%, cum=6.6%)
'Search the internal knowledge base. Returns a scored list of matching documents and short snippets for the user to read.',
// 3 — "short" → "brief" (per=4.2%, cum=10.7%)
'Search the internal knowledge base. Returns a scored list of matching documents and brief snippets for the user to read.',
// 4 — "documents" → "files" (per=5.8%, cum=16.5%)
'Search the internal knowledge base. Returns a scored list of matching files and brief snippets for the user to read.',
// 5 — "internal" → "local" (per=5.2%, cum=21.5%)
'Search the local knowledge base. Returns a scored list of matching files and brief snippets for the user to read.',
// 6 — "base" → "store" (per=3.5%, cum=24.8% — still under cumulative threshold)
'Search the local knowledge store. Returns a scored list of matching files and brief snippets for the user to read.',
// 7 — "knowledge" → "data" (per=7.9%, cum=32.2% — CUMULATIVE TRIPS, per-update does not)
'Search the local data store. Returns a scored list of matching files and brief snippets for the user to read.',
];
// ---------------------------------------------------------------------------
// Hook runner
// ---------------------------------------------------------------------------
function runHook(input, env) {
return new Promise((res) => {
const child = execFile(
'node',
[HOOK],
{ timeout: 5000, env: { ...process.env, ...env } },
(_err, stdout, stderr) => {
res({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' });
},
);
child.stdin.end(JSON.stringify(input));
});
}
function parseAdvisories(stdout) {
const trimmed = stdout.trim();
if (!trimmed.startsWith('{')) return [];
try {
const parsed = JSON.parse(trimmed);
if (!parsed.systemMessage) return [];
// Hook joins multiple advisories with `\n\n---\n\n` (see post-mcp-verify.mjs)
return parsed.systemMessage.split('\n\n---\n\n');
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const tmpDir = mkdtempSync(join(tmpdir(), 'llm-security-rugpull-'));
const cacheFile = join(tmpDir, 'mcp-descriptions.json');
console.log('MCP CUMULATIVE DRIFT (RUG-PULL) WALKTHROUGH');
console.log('===========================================');
console.log(`Cache file (deleted on exit): ${cacheFile}\n`);
console.log('Per-update threshold: 10% Levenshtein vs previous description');
console.log('Cumulative threshold: 25% Levenshtein vs sticky baseline');
console.log('OWASP MCP05 (Rug Pull) — v7.3.0 introduces the cumulative leg.\n');
let pass = 0;
let fail = 0;
const expectations = [
{ perUpdate: false, cumulative: false, note: 'baseline seeded — no advisory' },
{ perUpdate: false, cumulative: false, note: 'agent → user' },
{ perUpdate: false, cumulative: false, note: 'ranked → scored' },
{ perUpdate: false, cumulative: false, note: 'short → brief' },
{ perUpdate: false, cumulative: false, note: 'documents → files' },
{ perUpdate: false, cumulative: false, note: 'internal → local' },
{ perUpdate: false, cumulative: false, note: 'base → store (cum=24.8%, just under threshold)' },
{ perUpdate: false, cumulative: true, note: 'knowledge → data — CUMULATIVE TRIPS at 32.2%' },
];
try {
for (let i = 0; i < STAGES.length; i++) {
const description = STAGES[i];
const expect = expectations[i];
// post-mcp-verify exits early when tool_output is empty — the drift
// check only runs on tool calls that actually produce output. We send a
// benign placeholder so the description-drift code path executes.
const result = await runHook(
{
tool_name: 'mcp__knowledge__search',
tool_input: { description, query: 'demo' },
tool_output: 'no results',
},
{ LLM_SECURITY_MCP_CACHE_FILE: cacheFile },
);
const advisories = parseAdvisories(result.stdout);
const perUpdateAdv = advisories.find(a => a.includes('description drift detected'));
const cumulativeAdv = advisories.find(a => a.includes('cumulative description drift'));
const perUpdateOk = !!perUpdateAdv === expect.perUpdate;
const cumulativeOk = !!cumulativeAdv === expect.cumulative;
const ok = perUpdateOk && cumulativeOk;
if (ok) pass++; else fail++;
const tick = ok ? 'PASS' : 'FAIL';
const len = description.length;
console.log(`[${tick}] Stage ${i} (${len} chars) — ${expect.note}`);
console.log(` per-update advisory: expect=${expect.perUpdate} got=${!!perUpdateAdv}`);
console.log(` cumulative advisory: expect=${expect.cumulative} got=${!!cumulativeAdv}`);
console.log(` description: "${description.slice(0, 80)}${len > 80 ? '...' : ''}"`);
if (cumulativeAdv) {
const head = cumulativeAdv.split('\n').slice(0, 2).join('\n');
console.log(` advisory preview: "${head.replace(/\n/g, ' / ')}"`);
}
if (VERBOSE && result.stderr.trim()) {
console.log(` stderr: ${result.stderr.trim().slice(0, 120)}`);
}
console.log();
}
if (VERBOSE && existsSync(cacheFile)) {
const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
const entry = cache['mcp__knowledge__search'];
if (entry) {
console.log('Cache state at exit:');
console.log(` baseline.description = "${entry.baseline.description.slice(0, 60)}..."`);
console.log(` current.description = "${entry.description.slice(0, 60)}..."`);
console.log(` history length = ${entry.history?.length ?? 0}`);
console.log();
}
}
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
console.log('---');
console.log(`Result: ${pass} pass, ${fail} fail`);
if (fail > 0) {
console.log('\nFAILURE — see expected-findings.md for the documented contract.');
process.exit(1);
}
console.log('\nSUCCESS — cumulative-drift advisory fired exactly when expected.');
console.log('Reset MCP baseline after a legitimate upgrade: /security mcp-baseline-reset');
process.exit(0);