ktg-plugin-marketplace/plugins/llm-security/scanners/toxic-flow-analyzer.mjs

690 lines
24 KiB
JavaScript

// toxic-flow-analyzer.mjs — TFA scanner: Lethal Trifecta Detection
// Post-processing correlator that detects when tool/permission combinations
// create exfiltration chains. Runs LAST in scan-orchestrator — receives
// output from all 7 prior scanners as priorResults.
// Zero external dependencies.
//
// "Lethal trifecta" (Willison / Invariant Labs):
// 1. Agent exposed to UNTRUSTED INPUT (prompt injection surface)
// 2. Agent has access to SENSITIVE DATA via tools
// 3. An EXFILTRATION SINK exists (HTTP, email, file write to public paths)
//
// Three phases:
// Phase 1 — Component Inventory: build capability matrix from plugin frontmatter
// Phase 2 — Trifecta Classification: classify each component's 3 legs
// Phase 3 — Trifecta Detection: find dangerous combinations, apply mitigations
//
// OWASP mappings: ASI01, ASI02, ASI05, MCP1, MCP3, LLM01, LLM02, LLM06
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { readTextFile } from './lib/file-discovery.mjs';
import { parseFrontmatter, classifyPluginFile } from './lib/yaml-frontmatter.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
const SCANNER = 'TFA';
// ---------------------------------------------------------------------------
// Tool classification sets
// ---------------------------------------------------------------------------
/** Tools that expose the component to untrusted/external input. */
const INPUT_SURFACE_TOOLS = new Set(['Bash']);
/** Tools that grant read access to potentially sensitive data. */
const DATA_ACCESS_TOOLS = new Set(['Read', 'Glob', 'Grep']);
/** Tools that can send data outside the process boundary. */
const EXFIL_SINK_TOOLS = new Set(['Bash', 'WebFetch', 'WebSearch']);
/** Tools that allow spawning sub-agents (indirect capability escalation). */
const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
// ---------------------------------------------------------------------------
// Keyword classification sets
// ---------------------------------------------------------------------------
/** Body/description keywords indicating untrusted input exposure. */
const INPUT_KEYWORDS = [
'$arguments', 'user input', 'user-provided', 'untrusted',
'tool_input', 'user_input', 'remote', 'url', 'github url',
];
/** Body/description keywords indicating sensitive data handling. */
const SENSITIVE_KEYWORDS = [
'secret', 'credential', 'token', 'key', 'password', 'auth',
'.env', '.ssh', '.aws', 'keychain', 'vault', 'certificate',
];
/** Body/description keywords indicating network/exfil operations. */
const EXFIL_KEYWORDS = [
'fetch', 'http', 'webhook', 'upload', 'send', 'curl',
'network', 'api', 'endpoint', 'transfer', 'exfil',
];
// ---------------------------------------------------------------------------
// Hook guard patterns — known hooks that mitigate exfil paths
// ---------------------------------------------------------------------------
const EXFIL_GUARD_HOOKS = [
'pre-bash-destructive',
'post-mcp-verify',
'pre-install-supply-chain',
];
const INPUT_GUARD_HOOKS = [
'pre-prompt-inject-scan',
];
// ---------------------------------------------------------------------------
// Phase 1: Component Inventory
// ---------------------------------------------------------------------------
/**
* @typedef {Object} ComponentNode
* @property {string} name
* @property {'command'|'agent'|'skill'|'unknown'} type
* @property {string[]} tools
* @property {string} description - lowercase
* @property {string} body - lowercase full content
* @property {string} file - relative path
* @property {boolean} hasInputSurface
* @property {boolean} hasDataAccess
* @property {boolean} hasExfilSink
* @property {string[]} inputEvidence
* @property {string[]} accessEvidence
* @property {string[]} exfilEvidence
*/
/**
* Build component inventory from plugin frontmatter.
* @param {string} targetPath
* @returns {Promise<ComponentNode[]>}
*/
async function buildComponentInventory(targetPath) {
const components = [];
const COMPONENT_DIRS = ['commands', 'agents', 'skills'];
for (const dir of COMPONENT_DIRS) {
const absDir = join(targetPath, dir);
if (!existsSync(absDir)) continue;
let entries;
try {
entries = await readdir(absDir, { withFileTypes: true });
} catch { continue; }
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const absFile = join(absDir, entry.name);
const relFile = `${dir}/${entry.name}`;
const content = await readTextFile(absFile);
if (!content) continue;
const fm = parseFrontmatter(content);
if (!fm) continue;
const type = classifyPluginFile(relFile, fm);
const rawTools = fm.tools || fm.allowed_tools || fm['allowed-tools'] || [];
const tools = Array.isArray(rawTools)
? rawTools.map(t => String(t).trim()).filter(Boolean)
: String(rawTools).split(',').map(t => t.trim()).filter(Boolean);
const description = typeof fm.description === 'string' ? fm.description.toLowerCase() : '';
components.push({
name: fm.name || entry.name.replace(/\.md$/, ''),
type,
tools,
description,
body: content.toLowerCase(),
file: relFile,
hasInputSurface: false,
hasDataAccess: false,
hasExfilSink: false,
inputEvidence: [],
accessEvidence: [],
exfilEvidence: [],
});
}
}
return components;
}
/**
* Detect active hook guards from hooks.json.
* @param {string} targetPath
* @returns {Promise<string[]>} List of active guard script basenames (lowercase)
*/
async function detectActiveGuards(targetPath) {
const guards = [];
const hooksPath = join(targetPath, 'hooks', 'hooks.json');
if (!existsSync(hooksPath)) return guards;
try {
const raw = await readFile(hooksPath, 'utf-8');
const config = JSON.parse(raw);
const hooksRoot = config.hooks || config;
if (typeof hooksRoot !== 'object' || Array.isArray(hooksRoot)) return guards;
for (const descriptors of Object.values(hooksRoot)) {
if (!Array.isArray(descriptors)) continue;
for (const descriptor of descriptors) {
const innerHooks = descriptor.hooks;
if (!Array.isArray(innerHooks)) continue;
for (const hookEntry of innerHooks) {
if (hookEntry.type !== 'command' || typeof hookEntry.command !== 'string') continue;
const cmd = hookEntry.command.toLowerCase();
guards.push(cmd);
}
}
}
} catch {
// Malformed hooks.json — no guards detected
}
return guards;
}
/**
* Check if MCP servers are configured (presence = additional tool surface).
* @param {string} targetPath
* @returns {Promise<boolean>}
*/
async function hasMcpServers(targetPath) {
const mcpJsonPath = join(targetPath, '.mcp.json');
if (existsSync(mcpJsonPath)) {
try {
const raw = await readFile(mcpJsonPath, 'utf-8');
const config = JSON.parse(raw);
return Object.keys(config.mcpServers || config.servers || {}).length > 0;
} catch { /* ignore */ }
}
return false;
}
// ---------------------------------------------------------------------------
// Phase 2: Trifecta Classification
// ---------------------------------------------------------------------------
/**
* Classify each component's 3 trifecta legs using tools, keywords, and scanner findings.
* Mutates components in place.
* @param {ComponentNode[]} components
* @param {Record<string, object>} priorResults - keyed by scanner short name
* @param {boolean} mcpPresent
*/
function classifyTrifectaLegs(components, priorResults, mcpPresent) {
for (const comp of components) {
// --- Leg 1: Untrusted Input Surface ---
// Tool-based: Bash can read stdin, env vars, pipe input
if (comp.tools.some(t => INPUT_SURFACE_TOOLS.has(t))) {
comp.hasInputSurface = true;
comp.inputEvidence.push('Bash tool (stdin/env access)');
}
// Commands with $ARGUMENTS = direct user input
if (comp.type === 'command' && comp.body.includes('$arguments')) {
comp.hasInputSurface = true;
comp.inputEvidence.push('$ARGUMENTS in command body');
}
// Keyword-based
for (const kw of INPUT_KEYWORDS) {
if (comp.description.includes(kw) || comp.body.includes(kw)) {
comp.hasInputSurface = true;
comp.inputEvidence.push(`keyword "${kw}"`);
break;
}
}
// MCP presence = additional input surface for all components
if (mcpPresent) {
comp.hasInputSurface = true;
if (!comp.inputEvidence.some(e => e.includes('MCP'))) {
comp.inputEvidence.push('MCP servers configured (tool input surface)');
}
}
// --- Leg 2: Sensitive Data Access ---
if (comp.tools.some(t => DATA_ACCESS_TOOLS.has(t))) {
comp.hasDataAccess = true;
const matched = comp.tools.filter(t => DATA_ACCESS_TOOLS.has(t));
comp.accessEvidence.push(`tools: ${matched.join(', ')}`);
}
// Bash can cat/find/grep files
if (comp.tools.includes('Bash')) {
comp.hasDataAccess = true;
if (!comp.accessEvidence.some(e => e.includes('Bash'))) {
comp.accessEvidence.push('Bash tool (cat/find/grep capable)');
}
}
for (const kw of SENSITIVE_KEYWORDS) {
if (comp.description.includes(kw) || comp.body.includes(kw)) {
comp.hasDataAccess = true;
comp.accessEvidence.push(`keyword "${kw}"`);
break;
}
}
// --- Leg 3: Exfiltration Sink ---
if (comp.tools.some(t => EXFIL_SINK_TOOLS.has(t))) {
comp.hasExfilSink = true;
const matched = comp.tools.filter(t => EXFIL_SINK_TOOLS.has(t));
comp.exfilEvidence.push(`tools: ${matched.join(', ')}`);
}
// Delegation tools = indirect exfil (can spawn agents with Bash)
if (comp.tools.some(t => DELEGATION_TOOLS.has(t))) {
comp.hasExfilSink = true;
const matched = comp.tools.filter(t => DELEGATION_TOOLS.has(t));
comp.exfilEvidence.push(`delegation: ${matched.join(', ')} (can spawn capable sub-agents)`);
}
for (const kw of EXFIL_KEYWORDS) {
if (comp.description.includes(kw) || comp.body.includes(kw)) {
comp.hasExfilSink = true;
comp.exfilEvidence.push(`keyword "${kw}"`);
break;
}
}
}
// --- Enrich from prior scanner results ---
enrichFromPriorResults(components, priorResults);
}
/**
* Enrich component classifications using prior scanner findings.
* Maps findings back to components by file path containment.
* @param {ComponentNode[]} components
* @param {Record<string, object>} priorResults
*/
function enrichFromPriorResults(components, priorResults) {
if (!priorResults) return;
// TNT (taint-tracer): LLM01 findings confirm injection surfaces, LLM02 confirm exfil
const taintFindings = priorResults.taint?.findings || [];
for (const f of taintFindings) {
if (!f.file) continue;
for (const comp of components) {
if (!fileMatchesComponent(f.file, comp)) continue;
if (f.owasp === 'LLM01') {
comp.hasInputSurface = true;
addUniqueEvidence(comp.inputEvidence, `TNT: ${f.title}`);
}
if (f.owasp === 'LLM02') {
comp.hasExfilSink = true;
addUniqueEvidence(comp.exfilEvidence, `TNT: ${f.title}`);
}
}
}
// NET (network-mapper): suspicious domains confirm exfil endpoints
const netFindings = priorResults.network?.findings || [];
for (const f of netFindings) {
if (f.severity !== 'high' && f.severity !== 'critical') continue;
if (!f.file) continue;
for (const comp of components) {
if (!fileMatchesComponent(f.file, comp)) continue;
comp.hasExfilSink = true;
addUniqueEvidence(comp.exfilEvidence, `NET: ${f.title}`);
}
}
// ENT (entropy): high-entropy strings may indicate hardcoded secrets
const entFindings = priorResults.entropy?.findings || [];
for (const f of entFindings) {
if (f.severity !== 'high' && f.severity !== 'critical') continue;
if (!f.file) continue;
for (const comp of components) {
if (!fileMatchesComponent(f.file, comp)) continue;
comp.hasDataAccess = true;
addUniqueEvidence(comp.accessEvidence, `ENT: ${f.title}`);
}
}
// UNI (unicode): hidden Unicode confirms injection payloads
const uniFindings = priorResults.unicode?.findings || [];
for (const f of uniFindings) {
if (!f.file) continue;
for (const comp of components) {
if (!fileMatchesComponent(f.file, comp)) continue;
comp.hasInputSurface = true;
addUniqueEvidence(comp.inputEvidence, `UNI: ${f.title}`);
}
}
// PRM (permission-mapper): excessive agency findings strengthen classification
const prmFindings = priorResults.permission?.findings || [];
for (const f of prmFindings) {
if (!f.file) continue;
for (const comp of components) {
if (!fileMatchesComponent(f.file, comp)) continue;
// PRM findings about dangerous tool combos strengthen all legs
if (f.severity === 'high') {
comp.hasExfilSink = true;
addUniqueEvidence(comp.exfilEvidence, `PRM: ${f.title}`);
}
}
}
}
/**
* Check if a finding's file path belongs to a component's directory.
* @param {string} findingFile - Relative path from finding
* @param {ComponentNode} comp
* @returns {boolean}
*/
function fileMatchesComponent(findingFile, comp) {
// Direct match
if (findingFile === comp.file) return true;
// Same directory (e.g., finding in commands/scan.md matches component commands/scan)
const compDir = comp.file.replace(/\/[^/]+$/, '/');
return findingFile.startsWith(compDir);
}
/**
* Add evidence string only if not already present (avoid duplicates).
* @param {string[]} arr
* @param {string} evidence
*/
function addUniqueEvidence(arr, evidence) {
if (!arr.some(e => e === evidence)) {
arr.push(evidence);
}
}
// ---------------------------------------------------------------------------
// Phase 3: Trifecta Detection & Scoring
// ---------------------------------------------------------------------------
/**
* Detect trifecta patterns and generate findings.
* @param {ComponentNode[]} components
* @param {string[]} activeGuards - Hook command strings
* @returns {object[]} Array of finding objects
*/
function detectTrifectas(components, activeGuards) {
const findings = [];
const hasExfilGuard = activeGuards.some(g =>
EXFIL_GUARD_HOOKS.some(h => g.includes(h))
);
const hasInputGuard = activeGuards.some(g =>
INPUT_GUARD_HOOKS.some(h => g.includes(h))
);
// --- Direct trifectas: all 3 legs in one component ---
for (const comp of components) {
if (!comp.hasInputSurface || !comp.hasDataAccess || !comp.hasExfilSink) continue;
let severity = SEVERITY.CRITICAL;
// Mitigation: if hooks guard the exfil or input path, downgrade
if (hasExfilGuard && hasInputGuard) {
severity = SEVERITY.MEDIUM;
} else if (hasExfilGuard || hasInputGuard) {
severity = SEVERITY.HIGH;
}
findings.push(finding({
scanner: SCANNER,
severity,
title: `Lethal trifecta: ${comp.name} (${comp.type})`,
description:
`Component "${comp.name}" has all three legs of the lethal trifecta: ` +
`untrusted input surface, sensitive data access, and an exfiltration sink. ` +
`A successful prompt injection targeting this component could read sensitive ` +
`data and exfiltrate it in a single chain.` +
(hasExfilGuard || hasInputGuard
? ` Mitigated by active hook guards (severity reduced).`
: ` No hook guards detected for this chain.`),
file: comp.file,
evidence: formatTrifectaEvidence(comp),
owasp: 'ASI01, ASI02, ASI05',
recommendation:
'Apply principle of least privilege: separate read-only analysis from ' +
'write/network capabilities into distinct components. Add hook guards ' +
'(pre-bash-destructive, pre-prompt-inject-scan) to mitigate injection + exfil paths.',
}));
}
// --- Cross-component trifectas: 2 legs in one, 3rd in another ---
const twoLeg = components.filter(c =>
(c.hasInputSurface && c.hasDataAccess && !c.hasExfilSink) ||
(c.hasInputSurface && !c.hasDataAccess && c.hasExfilSink) ||
(!c.hasInputSurface && c.hasDataAccess && c.hasExfilSink)
);
// Components that complete the missing leg
const inputSources = components.filter(c => c.hasInputSurface);
const dataAccessors = components.filter(c => c.hasDataAccess);
const exfilSinks = components.filter(c => c.hasExfilSink);
const reportedCrossPairs = new Set();
for (const comp of twoLeg) {
// Already reported as direct trifecta?
if (comp.hasInputSurface && comp.hasDataAccess && comp.hasExfilSink) continue;
let complementary = [];
let missingLeg = '';
if (!comp.hasInputSurface) {
complementary = inputSources.filter(c => c !== comp);
missingLeg = 'input surface';
} else if (!comp.hasDataAccess) {
complementary = dataAccessors.filter(c => c !== comp);
missingLeg = 'data access';
} else {
complementary = exfilSinks.filter(c => c !== comp);
missingLeg = 'exfil sink';
}
if (complementary.length === 0) continue;
// Only report the most significant complementary component (avoid finding flood)
const bestMatch = complementary[0];
const pairKey = [comp.name, bestMatch.name].sort().join('|');
if (reportedCrossPairs.has(pairKey)) continue;
reportedCrossPairs.add(pairKey);
let severity = SEVERITY.HIGH;
if (hasExfilGuard && hasInputGuard) {
severity = SEVERITY.LOW;
} else if (hasExfilGuard || hasInputGuard) {
severity = SEVERITY.MEDIUM;
}
findings.push(finding({
scanner: SCANNER,
severity,
title: `Cross-component trifecta: ${comp.name} + ${bestMatch.name}`,
description:
`Components "${comp.name}" and "${bestMatch.name}" together complete the lethal trifecta. ` +
`"${comp.name}" provides ${describeLegsCovered(comp)} while "${bestMatch.name}" ` +
`provides the missing ${missingLeg}. If an attacker can influence both components ` +
`(e.g., via prompt injection propagating through delegation), this chain enables ` +
`data exfiltration.` +
(hasExfilGuard || hasInputGuard
? ` Mitigated by active hook guards.`
: ''),
file: comp.file,
evidence:
`${comp.name}: ${describeLegsCovered(comp)} | ` +
`${bestMatch.name}: ${missingLeg} via ${describeLegsEvidence(bestMatch, missingLeg)}`,
owasp: 'ASI01, ASI02, ASI05',
recommendation:
'Reduce tool surface on components that complete trifecta chains. ' +
'Ensure hook guards cover all exfiltration and injection paths. ' +
'Consider whether delegation between these components can be restricted.',
}));
}
// --- Project-level trifecta: all 3 legs exist somewhere ---
const hasAnyInput = components.some(c => c.hasInputSurface);
const hasAnyAccess = components.some(c => c.hasDataAccess);
const hasAnyExfil = components.some(c => c.hasExfilSink);
if (hasAnyInput && hasAnyAccess && hasAnyExfil) {
// Only emit this if we haven't already reported direct or cross-component trifectas
const directCount = findings.filter(f => f.title.startsWith('Lethal trifecta:')).length;
const crossCount = findings.filter(f => f.title.startsWith('Cross-component')).length;
if (directCount === 0 && crossCount === 0) {
let severity = SEVERITY.MEDIUM;
if (hasExfilGuard && hasInputGuard) {
severity = SEVERITY.LOW;
}
findings.push(finding({
scanner: SCANNER,
severity,
title: 'Project-level trifecta: all three legs present',
description:
'This project contains components with untrusted input surfaces, ' +
'sensitive data access, and exfiltration sinks — but no single component ' +
'or pair completes the full chain. The trifecta exists at the project level, ' +
'which is a lower risk but still worth monitoring. A multi-hop attack chain ' +
'through delegation or shared state could connect these legs.',
file: null,
evidence:
`Input: ${inputSources.map(c => c.name).slice(0, 3).join(', ')} | ` +
`Access: ${dataAccessors.map(c => c.name).slice(0, 3).join(', ')} | ` +
`Exfil: ${exfilSinks.map(c => c.name).slice(0, 3).join(', ')}`,
owasp: 'ASI01, ASI02',
recommendation:
'Monitor for capability escalation through delegation chains. ' +
'Ensure hook guards are active for injection and exfiltration paths.',
}));
}
}
return findings;
}
// ---------------------------------------------------------------------------
// Evidence formatting helpers
// ---------------------------------------------------------------------------
/**
* Format all 3 legs of evidence for a component into a compact string.
* @param {ComponentNode} comp
* @returns {string}
*/
function formatTrifectaEvidence(comp) {
const parts = [];
if (comp.inputEvidence.length > 0) {
parts.push(`Input: ${comp.inputEvidence.slice(0, 2).join('; ')}`);
}
if (comp.accessEvidence.length > 0) {
parts.push(`Access: ${comp.accessEvidence.slice(0, 2).join('; ')}`);
}
if (comp.exfilEvidence.length > 0) {
parts.push(`Exfil: ${comp.exfilEvidence.slice(0, 2).join('; ')}`);
}
return parts.join(' | ');
}
/**
* Describe which legs a component covers.
* @param {ComponentNode} comp
* @returns {string}
*/
function describeLegsCovered(comp) {
const legs = [];
if (comp.hasInputSurface) legs.push('input surface');
if (comp.hasDataAccess) legs.push('data access');
if (comp.hasExfilSink) legs.push('exfil sink');
return legs.join(' + ');
}
/**
* Describe evidence for a specific leg on a component.
* @param {ComponentNode} comp
* @param {string} leg - 'input surface' | 'data access' | 'exfil sink'
* @returns {string}
*/
function describeLegsEvidence(comp, leg) {
switch (leg) {
case 'input surface': return comp.inputEvidence[0] || 'inferred';
case 'data access': return comp.accessEvidence[0] || 'inferred';
case 'exfil sink': return comp.exfilEvidence[0] || 'inferred';
default: return 'unknown';
}
}
// ---------------------------------------------------------------------------
// Plugin detection (reused from permission-mapper pattern)
// ---------------------------------------------------------------------------
/**
* Check if targetPath is a Claude Code plugin.
* @param {string} targetPath
* @returns {boolean}
*/
function isPlugin(targetPath) {
if (existsSync(join(targetPath, '.claude-plugin', 'plugin.json'))) return true;
if (existsSync(join(targetPath, 'plugin.json'))) return true;
if (existsSync(join(targetPath, 'plugin.fixture.json'))) return true;
if (existsSync(join(targetPath, 'commands'))) return true;
return false;
}
// ---------------------------------------------------------------------------
// Public scanner entry point
// ---------------------------------------------------------------------------
/**
* Scan a target path for lethal trifecta patterns.
*
* This scanner is a post-processing correlator: it reads plugin component
* frontmatter to build a capability inventory, then uses prior scanner
* findings to detect dangerous tool/permission combinations.
*
* @param {string} targetPath - Absolute path to scan
* @param {object} discovery - Pre-computed file discovery (used for file count only)
* @param {Record<string, object>} [priorResults] - Output from prior scanners
* @returns {Promise<object>} Scanner result envelope
*/
export async function scan(targetPath, discovery, priorResults) {
const start = Date.now();
// Skip non-plugin targets — TFA analyzes plugin structure
if (!isPlugin(targetPath)) {
return scannerResult('toxic-flow', 'skipped', [], 0, Date.now() - start);
}
try {
// Phase 1: Component Inventory
const components = await buildComponentInventory(targetPath);
if (components.length === 0) {
return scannerResult('toxic-flow', 'ok', [], 0, Date.now() - start);
}
const activeGuards = await detectActiveGuards(targetPath);
const mcpPresent = await hasMcpServers(targetPath);
// Phase 2: Trifecta Classification
classifyTrifectaLegs(components, priorResults || {}, mcpPresent);
// Phase 3: Trifecta Detection
const findings = detectTrifectas(components, activeGuards);
const filesScanned = components.length;
return scannerResult('toxic-flow', 'ok', findings, filesScanned, Date.now() - start);
} catch (err) {
return scannerResult(
'toxic-flow', 'error', [], 0, Date.now() - start, err.message
);
}
}