feat(llm-security-copilot): port llm-security v5.1.0 to GitHub Copilot CLI
Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
901bf0ae12
commit
f418a8fe08
169 changed files with 37631 additions and 0 deletions
630
plugins/llm-security-copilot/scanners/permission-mapper.mjs
Normal file
630
plugins/llm-security-copilot/scanners/permission-mapper.mjs
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
// permission-mapper.mjs — PRM scanner: permission mismatches, excessive agency, ghost hooks
|
||||
// Detects: purpose vs tools mismatch, dangerous tool combos, ghost hooks,
|
||||
// haiku on sensitive agents, overprivileged agents, undocumented permissions.
|
||||
// OWASP LLM06 — Excessive Agency
|
||||
// Zero dependencies (Node.js builtins only).
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter, classifyPluginFile } from './lib/yaml-frontmatter.mjs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const SCANNER = 'PRM';
|
||||
const OWASP = 'LLM06';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Description keywords that signal read-only intent.
|
||||
const READ_ONLY_INTENT_KEYWORDS = [
|
||||
'scan', 'analyze', 'analyse', 'audit', 'assess', 'check', 'review',
|
||||
'evaluate', 'inspect', 'report', 'detect', 'monitor', 'observe',
|
||||
];
|
||||
|
||||
// Tools that carry write / side-effect capability.
|
||||
const WRITE_TOOLS = new Set(['Write', 'Edit']);
|
||||
const BASH_TOOL = 'Bash';
|
||||
|
||||
// Description keywords that imply network / exfiltration risk when paired with Bash.
|
||||
const NETWORK_KEYWORDS = [
|
||||
'fetch', 'download', 'upload', 'send', 'webhook', 'api', 'http',
|
||||
'post', 'request', 'endpoint', 'network', 'transfer',
|
||||
];
|
||||
|
||||
// Indicators that an agent deals with sensitive operations.
|
||||
const SENSITIVE_KEYWORDS = [
|
||||
'security', 'secret', 'credential', 'auth', 'permission', 'deploy',
|
||||
'token', 'key', 'password', 'certificate', 'vault',
|
||||
];
|
||||
|
||||
// Maximum tolerated tool count before flagging as overprivileged.
|
||||
const MAX_TOOLS_INFO_THRESHOLD = 6;
|
||||
|
||||
// Tool that allows a component to spawn sub-agents.
|
||||
const DELEGATION_TOOL = 'Task';
|
||||
|
||||
// Subdirectories of a plugin that contain components with frontmatter.
|
||||
const COMPONENT_DIRS = ['commands', 'agents', 'skills'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decide whether targetPath looks like a Claude Code plugin.
|
||||
* Accepts plugins with either a plugin.json manifest or at least one .md file
|
||||
* with frontmatter in a commands/ directory.
|
||||
*
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isPlugin(targetPath) {
|
||||
// Check for canonical manifest location (includes .fixture variant for test fixtures).
|
||||
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;
|
||||
|
||||
// Check for at least one command .md with frontmatter.
|
||||
const commandsDir = join(targetPath, 'commands');
|
||||
if (!existsSync(commandsDir)) return false;
|
||||
|
||||
try {
|
||||
const { readdir } = await import('node:fs/promises');
|
||||
const entries = await readdir(commandsDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.md')) continue;
|
||||
const content = await readTextFile(join(commandsDir, entry));
|
||||
if (content && parseFrontmatter(content)) return true;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable directory — not a plugin we can scan.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission matrix builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {Object} ComponentEntry
|
||||
* @property {string} component - Logical name (frontmatter name or filename)
|
||||
* @property {'command'|'agent'|'skill'|'unknown'} type
|
||||
* @property {string[]} tools - Normalised tool list
|
||||
* @property {string} description
|
||||
* @property {string} model - Model name or empty string
|
||||
* @property {string} file - Relative file path
|
||||
* @property {string} absFile - Absolute file path
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collect all component entries from the plugin's component directories.
|
||||
*
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<ComponentEntry[]>}
|
||||
*/
|
||||
async function buildPermissionMatrix(targetPath) {
|
||||
const matrix = [];
|
||||
const { readdir } = await import('node:fs/promises');
|
||||
|
||||
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()) continue;
|
||||
// Accept .md and .fixture.md (test fixtures)
|
||||
if (!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; // Skip files without frontmatter — not a component.
|
||||
|
||||
const type = classifyPluginFile(relFile, fm);
|
||||
|
||||
// Normalise tools: accept both `tools` (agents) and `allowed-tools` / `allowed_tools` (commands).
|
||||
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);
|
||||
|
||||
matrix.push({
|
||||
component: fm.name || entry.name.replace(/\.md$/, ''),
|
||||
type,
|
||||
tools,
|
||||
description: typeof fm.description === 'string' ? fm.description.toLowerCase() : '',
|
||||
model: typeof fm.model === 'string' ? fm.model.toLowerCase() : '',
|
||||
file: relFile,
|
||||
absFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** True if description suggests the component is read-only. */
|
||||
function hasReadOnlyIntent(description) {
|
||||
return READ_ONLY_INTENT_KEYWORDS.some(kw => description.includes(kw));
|
||||
}
|
||||
|
||||
/** True if the tool list contains any write-capable tool. */
|
||||
function hasWriteTools(tools) {
|
||||
return tools.some(t => WRITE_TOOLS.has(t));
|
||||
}
|
||||
|
||||
/** True if the tool list contains Bash. */
|
||||
function hasBash(tools) {
|
||||
return tools.includes(BASH_TOOL);
|
||||
}
|
||||
|
||||
/** True if description mentions network-related operations. */
|
||||
function hasNetworkIntent(description) {
|
||||
return NETWORK_KEYWORDS.some(kw => description.includes(kw));
|
||||
}
|
||||
|
||||
/** True if description or tools suggest sensitive / security operations. */
|
||||
function isSensitiveComponent(entry) {
|
||||
if (SENSITIVE_KEYWORDS.some(kw => entry.description.includes(kw))) return true;
|
||||
// Bash on any component can touch the host — treat as mildly sensitive.
|
||||
if (hasBash(entry.tools)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Purpose vs Tools Mismatch (HIGH)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flag components that declare read-only intent but include write-capable tools.
|
||||
* Bash alone is acceptable for scanners (they need to run analysis commands).
|
||||
* Write or Edit on a read-only-intent component → HIGH.
|
||||
*/
|
||||
function checkPurposeToolsMismatch(matrix) {
|
||||
const findings = [];
|
||||
|
||||
for (const entry of matrix) {
|
||||
if (!hasReadOnlyIntent(entry.description)) continue;
|
||||
if (!hasWriteTools(entry.tools)) continue;
|
||||
|
||||
const offendingTools = entry.tools.filter(t => WRITE_TOOLS.has(t));
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.HIGH,
|
||||
title: `Read-only intent with write tools: ${entry.component}`,
|
||||
description:
|
||||
`Component "${entry.component}" (${entry.type}) has a description implying read-only ` +
|
||||
`operation (contains: ${READ_ONLY_INTENT_KEYWORDS.filter(kw => entry.description.includes(kw)).join(', ')}) ` +
|
||||
`but is granted write-capable tools: ${offendingTools.join(', ')}. ` +
|
||||
`This violates the principle of least privilege — a scanner/auditor should not be able to ` +
|
||||
`modify files on disk. Grant only Read, Glob, Grep, and Bash (for running analysis commands).`,
|
||||
file: entry.file,
|
||||
evidence: `tools: [${entry.tools.join(', ')}]`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Remove ${offendingTools.join(', ')} from ${entry.file}. ` +
|
||||
`If content modification is genuinely required, rename/redescribe the component to ` +
|
||||
`reflect its mutating intent (e.g. "fix", "patch", "remediate").`,
|
||||
}));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Dangerous Tool Combinations (HIGH / MEDIUM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bash + Write + Edit together → HIGH (can download and overwrite arbitrary files).
|
||||
* Bash + network-related description → MEDIUM (potential exfiltration vector).
|
||||
*/
|
||||
function checkDangerousToolCombos(matrix) {
|
||||
const findings = [];
|
||||
|
||||
for (const entry of matrix) {
|
||||
const hasBashTool = hasBash(entry.tools);
|
||||
const hasWriteOrEdit = hasWriteTools(entry.tools);
|
||||
const hasEdit = entry.tools.includes('Edit');
|
||||
const hasWrite = entry.tools.includes('Write');
|
||||
|
||||
// HIGH: Bash + Write + Edit together without clear justification for code modification.
|
||||
if (hasBashTool && hasWrite && hasEdit) {
|
||||
// Allow if description clearly describes code modification (e.g. "fix", "patch", "generate").
|
||||
const modificationWords = ['fix', 'patch', 'generate', 'create', 'write', 'modify', 'update', 'implement', 'refactor'];
|
||||
const justified = modificationWords.some(w => entry.description.includes(w));
|
||||
|
||||
if (!justified) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.HIGH,
|
||||
title: `Full-access tool triple on ${entry.component}: Bash + Write + Edit`,
|
||||
description:
|
||||
`Component "${entry.component}" (${entry.type}) holds Bash, Write, and Edit simultaneously ` +
|
||||
`without a description that justifies code modification. This combination allows ` +
|
||||
`arbitrary command execution combined with unrestricted file creation and editing — ` +
|
||||
`effectively full host access. An attacker who compromises this component's prompt ` +
|
||||
`can execute any shell command and persist output to disk.`,
|
||||
file: entry.file,
|
||||
evidence: `tools: [${entry.tools.join(', ')}]`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Reduce the tool set to the minimum necessary. Separate read-and-run concerns ` +
|
||||
`(Bash + Read/Glob/Grep) from write concerns (Edit/Write) into distinct components, ` +
|
||||
`or add a description that clearly explains why all three are required.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// MEDIUM: Bash + network-related description (potential exfiltration).
|
||||
if (hasBashTool && hasNetworkIntent(entry.description)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Bash + network intent on ${entry.component}`,
|
||||
description:
|
||||
`Component "${entry.component}" (${entry.type}) has Bash access and its description ` +
|
||||
`references network operations (${NETWORK_KEYWORDS.filter(kw => entry.description.includes(kw)).join(', ')}). ` +
|
||||
`Bash can invoke curl, wget, nc, or similar utilities. Combined with network intent, ` +
|
||||
`this is a plausible exfiltration vector if the component is prompt-injected.`,
|
||||
file: entry.file,
|
||||
evidence: `tools includes Bash; description: "${entry.description.slice(0, 120)}"`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`If network access is required, document the exact endpoints and protocols in the ` +
|
||||
`description. Consider using a dedicated MCP tool with constrained scope instead of ` +
|
||||
`open Bash. Add pre-bash-destructive hook coverage for exfil patterns.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Ghost Hooks (MEDIUM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read hooks/hooks.json and verify that every referenced script file actually exists.
|
||||
* Missing scripts → MEDIUM (declared enforcement that isn't enforced).
|
||||
* Scripts outside the plugin directory → MEDIUM (unusual path, possible tampering).
|
||||
*/
|
||||
async function checkGhostHooks(targetPath) {
|
||||
const findings = [];
|
||||
// Check standard path and .fixture variant (test fixtures).
|
||||
let hooksJsonPath = join(targetPath, 'hooks', 'hooks.json');
|
||||
if (!existsSync(hooksJsonPath)) {
|
||||
hooksJsonPath = join(targetPath, 'hooks', 'hooks.fixture.json');
|
||||
}
|
||||
|
||||
if (!existsSync(hooksJsonPath)) return findings;
|
||||
|
||||
let hooksConfig;
|
||||
try {
|
||||
const raw = await readFile(hooksJsonPath, 'utf-8');
|
||||
hooksConfig = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
// Malformed hooks.json — not a ghost hook issue, but worth noting.
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: 'hooks/hooks.json is not valid JSON',
|
||||
description:
|
||||
`hooks/hooks.json could not be parsed (${err.message}). ` +
|
||||
`Malformed hook configuration means hook enforcement is silently disabled — ` +
|
||||
`all hooks declared in this file will not run.`,
|
||||
file: 'hooks/hooks.json',
|
||||
owasp: OWASP,
|
||||
recommendation: 'Fix the JSON syntax error in hooks/hooks.json.',
|
||||
}));
|
||||
return findings;
|
||||
}
|
||||
|
||||
// The hooks object is keyed by event name (PreToolUse, PostToolUse, etc.).
|
||||
// Each value is an array of hook descriptor objects, each of which has a `hooks` array
|
||||
// whose entries may have a `command` string.
|
||||
const hooksRoot = hooksConfig.hooks || hooksConfig;
|
||||
if (typeof hooksRoot !== 'object' || Array.isArray(hooksRoot)) return findings;
|
||||
|
||||
for (const [eventName, descriptors] of Object.entries(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;
|
||||
|
||||
// Extract the script path from the command string.
|
||||
// Handles: "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
|
||||
// "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
|
||||
// "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.mjs"
|
||||
const commandStr = hookEntry.command;
|
||||
|
||||
// Replace the plugin root placeholder with the actual target path.
|
||||
const resolved = commandStr
|
||||
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, targetPath)
|
||||
.replace(/\$CLAUDE_PLUGIN_ROOT/g, targetPath);
|
||||
|
||||
// Split on whitespace and find the first argument that looks like a file path.
|
||||
const parts = resolved.trim().split(/\s+/);
|
||||
// Skip interpreter tokens (bash, node, sh).
|
||||
const interpreters = new Set(['bash', 'node', 'sh', 'zsh']);
|
||||
const scriptPath = parts.find(
|
||||
p => !interpreters.has(p) && (p.startsWith('/') || p.includes('/'))
|
||||
);
|
||||
|
||||
if (!scriptPath) continue;
|
||||
|
||||
// Check existence.
|
||||
if (!existsSync(scriptPath)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Ghost hook: script not found for ${eventName} event`,
|
||||
description:
|
||||
`hooks/hooks.json declares a ${eventName} hook with command "${commandStr}" ` +
|
||||
`but the resolved script path "${scriptPath}" does not exist on disk. ` +
|
||||
`This is a ghost hook — the hook is registered but the enforcement script is missing. ` +
|
||||
`Any security control this hook was meant to provide is not active.`,
|
||||
file: 'hooks/hooks.json',
|
||||
evidence: `command: "${commandStr}"`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Either create the missing script at "${scriptPath}", update the command path ` +
|
||||
`in hooks.json to point to the correct location, or remove the ghost hook entry.`,
|
||||
}));
|
||||
} else {
|
||||
// Script exists — check if it's outside the plugin directory (unusual).
|
||||
if (!scriptPath.startsWith(targetPath)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Hook script outside plugin directory: ${eventName}`,
|
||||
description:
|
||||
`hooks/hooks.json references a script at "${scriptPath}" which is outside ` +
|
||||
`the plugin directory "${targetPath}". Hooks that depend on external scripts ` +
|
||||
`create a supply-chain dependency — if that external path is modified or deleted, ` +
|
||||
`the hook silently fails. It may also indicate an attempt to load shared code ` +
|
||||
`that could be tampered with by another plugin.`,
|
||||
file: 'hooks/hooks.json',
|
||||
evidence: `command: "${commandStr}"`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Move the script into the plugin's own hooks/scripts/ directory and update ` +
|
||||
`the path in hooks.json to use \${CLAUDE_PLUGIN_ROOT}.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Haiku on Sensitive Agents (MEDIUM)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flag agents that use the haiku model for sensitive operations.
|
||||
* Haiku is the smallest/cheapest model and may lack the reasoning capability
|
||||
* to correctly apply security policies.
|
||||
*/
|
||||
function checkHaikuOnSensitiveAgents(matrix) {
|
||||
const findings = [];
|
||||
|
||||
for (const entry of matrix) {
|
||||
if (entry.type !== 'agent') continue;
|
||||
if (!entry.model.includes('haiku')) continue;
|
||||
if (!isSensitiveComponent(entry)) continue;
|
||||
|
||||
const triggerKeywords = [
|
||||
...SENSITIVE_KEYWORDS.filter(kw => entry.description.includes(kw)),
|
||||
...(hasBash(entry.tools) ? ['Bash'] : []),
|
||||
];
|
||||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Haiku model on sensitive agent: ${entry.component}`,
|
||||
description:
|
||||
`Agent "${entry.component}" uses the haiku model but is configured for sensitive ` +
|
||||
`operations (indicators: ${triggerKeywords.join(', ')}). ` +
|
||||
`Haiku is optimised for speed and cost — it may not reliably enforce nuanced ` +
|
||||
`security policies, correctly interpret ambiguous instructions, or resist ` +
|
||||
`prompt injection targeting its smaller context window.`,
|
||||
file: entry.file,
|
||||
evidence: `model: haiku; tools: [${entry.tools.join(', ')}]`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Upgrade to sonnet or opus for agents that handle security-sensitive operations, ` +
|
||||
`credentials, deployment, or unrestricted shell access.`,
|
||||
}));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Overprivileged Agents (MEDIUM / INFO)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flag agents with an unusually large tool set or with the Task + Bash combination
|
||||
* (which allows delegating unrestricted shell execution to sub-agents).
|
||||
*/
|
||||
function checkOverprivilegedAgents(matrix) {
|
||||
const findings = [];
|
||||
|
||||
for (const entry of matrix) {
|
||||
// Skill and command files can legitimately have many tools; focus on agents.
|
||||
if (entry.type !== 'agent') continue;
|
||||
|
||||
// INFO: More than MAX_TOOLS_INFO_THRESHOLD tools — worth noting.
|
||||
if (entry.tools.length > MAX_TOOLS_INFO_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.INFO,
|
||||
title: `Broad tool surface on agent: ${entry.component} (${entry.tools.length} tools)`,
|
||||
description:
|
||||
`Agent "${entry.component}" has ${entry.tools.length} tools: [${entry.tools.join(', ')}]. ` +
|
||||
`A large tool set increases the blast radius of a successful prompt injection — ` +
|
||||
`the agent can take more actions than may be intended. ` +
|
||||
`Review whether each tool is genuinely required for this agent's role.`,
|
||||
file: entry.file,
|
||||
evidence: `tools: [${entry.tools.join(', ')}]`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Audit each tool against the agent's stated purpose. Remove any tool not required ` +
|
||||
`for the primary function. Consider splitting the agent into focused sub-agents ` +
|
||||
`if multiple distinct capabilities are needed.`,
|
||||
}));
|
||||
}
|
||||
|
||||
// MEDIUM: Task + Bash → can delegate unrestricted shell execution.
|
||||
if (entry.tools.includes(DELEGATION_TOOL) && hasBash(entry.tools)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
title: `Delegation + Bash on agent: ${entry.component}`,
|
||||
description:
|
||||
`Agent "${entry.component}" has both Task (sub-agent spawning) and Bash. ` +
|
||||
`This allows the agent to create sub-agents that inherit or escalate its Bash ` +
|
||||
`access, enabling indirect, multi-hop command execution that may bypass ` +
|
||||
`per-agent restrictions. If this agent is prompt-injected, an attacker can ` +
|
||||
`spin up an arbitrarily capable sub-agent chain.`,
|
||||
file: entry.file,
|
||||
evidence: `tools includes Task and Bash`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`If delegation is required, create a dedicated orchestrator agent that has Task ` +
|
||||
`but NOT Bash. Let leaf agents have Bash without Task. Enforce this separation ` +
|
||||
`at the component boundary.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check: Missing Permissions Documentation (LOW)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flag components that have tools but no description explaining why those tools are needed.
|
||||
* An empty or very short description provides no justification for the granted capabilities.
|
||||
*/
|
||||
function checkMissingPermissionsDoc(matrix) {
|
||||
const findings = [];
|
||||
|
||||
for (const entry of matrix) {
|
||||
if (entry.tools.length === 0) continue;
|
||||
// Description shorter than 30 chars offers essentially no justification.
|
||||
if (entry.description.length >= 30) continue;
|
||||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.LOW,
|
||||
title: `No tool justification in description: ${entry.component}`,
|
||||
description:
|
||||
`Component "${entry.component}" (${entry.type}) is granted ${entry.tools.length} ` +
|
||||
`tool(s) — [${entry.tools.join(', ')}] — but its description ("${entry.description}") ` +
|
||||
`is too short to explain why those tools are needed. ` +
|
||||
`Without documented justification, reviewers cannot verify that the tool set is appropriate.`,
|
||||
file: entry.file,
|
||||
evidence: `description length: ${entry.description.length} chars`,
|
||||
owasp: OWASP,
|
||||
recommendation:
|
||||
`Add a meaningful description that explains the component's purpose and why each ` +
|
||||
`tool is required. This makes security review possible and deters scope creep.`,
|
||||
}));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan a target path for permission mismatches and excessive agency.
|
||||
*
|
||||
* @param {string} targetPath - Absolute path to the plugin or directory to scan.
|
||||
* @param {object} [discovery] - Pre-computed discovery result (optional, not used here).
|
||||
* @returns {Promise<object>} Scanner result envelope.
|
||||
*/
|
||||
export async function scan(targetPath, discovery) {
|
||||
const start = Date.now();
|
||||
|
||||
// --- Plugin detection ---
|
||||
const pluginDetected = await isPlugin(targetPath);
|
||||
if (!pluginDetected) {
|
||||
return scannerResult(SCANNER, 'skipped', [], 0, Date.now() - start);
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
let filesScanned = 0;
|
||||
|
||||
try {
|
||||
// --- Build permission matrix ---
|
||||
const matrix = await buildPermissionMatrix(targetPath);
|
||||
filesScanned = matrix.length;
|
||||
|
||||
// --- Run all checks ---
|
||||
findings.push(...checkPurposeToolsMismatch(matrix));
|
||||
findings.push(...checkDangerousToolCombos(matrix));
|
||||
findings.push(...checkHaikuOnSensitiveAgents(matrix));
|
||||
findings.push(...checkOverprivilegedAgents(matrix));
|
||||
findings.push(...checkMissingPermissionsDoc(matrix));
|
||||
|
||||
// --- Ghost hook check (reads hooks.json separately) ---
|
||||
const ghostFindings = await checkGhostHooks(targetPath);
|
||||
findings.push(...ghostFindings);
|
||||
if (existsSync(join(targetPath, 'hooks', 'hooks.json')) ||
|
||||
existsSync(join(targetPath, 'hooks', 'hooks.fixture.json'))) filesScanned += 1;
|
||||
|
||||
} catch (err) {
|
||||
return scannerResult(
|
||||
SCANNER,
|
||||
'error',
|
||||
findings,
|
||||
filesScanned,
|
||||
Date.now() - start,
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
|
||||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue