690 lines
24 KiB
JavaScript
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
|
|
);
|
|
}
|
|
}
|