// 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} */ 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} 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} */ 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} 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} 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} [priorResults] - Output from prior scanners * @returns {Promise} 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 ); } }