ktg-plugin-marketplace/plugins/llm-security/scanners/permission-mapper.mjs

630 lines
25 KiB
JavaScript

// 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);
}