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
690
plugins/llm-security/scanners/toxic-flow-analyzer.mjs
Normal file
690
plugins/llm-security/scanners/toxic-flow-analyzer.mjs
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue