Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,44 @@ describe('UNSAFE_TOOL_OUTPUT_PATH rule', () => {
const findings = runInjectionRule(RuleId.UNSAFE_TOOL_OUTPUT_PATH, config);
expect(findings).toHaveLength(1);
});

// Traversal bypass: a benign-looking prefix that escapes into a system dir
it('fires for a path-traversal payload resolving into /etc', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'writer', outputPath: '/var/app/../../etc/passwd' }],
};
const findings = runInjectionRule(RuleId.UNSAFE_TOOL_OUTPUT_PATH, config);
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe(Severity.CRITICAL);
// Evidence surfaces both the literal and the resolved path.
expect(findings[0].evidence).toContain('/var/app/../../etc/passwd');
expect(findings[0].evidence).toContain('/etc/passwd');
});

it('fires for a deeper traversal payload resolving into /proc', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'writer', outputPath: '/app/data/../../../proc/self/mem' }],
};
const findings = runInjectionRule(RuleId.UNSAFE_TOOL_OUTPUT_PATH, config);
expect(findings).toHaveLength(1);
});

it('fires for redundant same-dir traversal (/etc/../etc/passwd)', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'writer', outputPath: '/etc/../etc/passwd' }],
};
const findings = runInjectionRule(RuleId.UNSAFE_TOOL_OUTPUT_PATH, config);
expect(findings).toHaveLength(1);
});

// False-positive guard: traversal that stays inside a safe app directory.
it('does not fire when traversal resolves to a safe directory', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'writer', outputPath: '/app/tmp/../data/output.json' }],
};
const findings = runInjectionRule(RuleId.UNSAFE_TOOL_OUTPUT_PATH, config);
expect(findings).toHaveLength(0);
});
});

// ─── Full scan() integration tests ───────────────────────────────────────────
Expand Down
34 changes: 27 additions & 7 deletions src/rules/injection-rules.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path';
import { Finding, RuleId, Severity } from '../types.js';
import { ParsedMcpConfig } from '../parser.js';
import { Rule } from './index.js';
Expand All @@ -14,6 +15,16 @@ const INJECTION_PATTERNS = [

const UNSAFE_OUTPUT_DIRS = ['/etc', '/proc', '/sys', '/boot', '/root', '/dev'];

/**
* Collapse `.`/`..` traversal segments to the path's true target, so a payload
* like `/var/app/../../etc/passwd` is matched as the `/etc/passwd` it resolves
* to rather than passing as a benign-looking `/var/...` string. POSIX semantics
* are used regardless of host OS — these system directories are POSIX paths.
*/
function resolveOutputPath(p: string): string {
return path.posix.normalize(p);
}

export const injectionRules: Rule[] = [
{
id: RuleId.TOOL_DESC_INJECTION,
Expand Down Expand Up @@ -52,19 +63,28 @@ export const injectionRules: Rule[] = [
const findings: Finding[] = [];
for (const tool of config.tools ?? []) {
if (!tool.outputPath) continue;
const isUnsafe = UNSAFE_OUTPUT_DIRS.some((dir) => {
const p = tool.outputPath!;
return p === dir || p.startsWith(dir + '/');
});
// Resolve traversal first so `/var/app/../../etc/passwd` is judged by
// where it actually lands, not by its benign-looking literal prefix.
const resolved = resolveOutputPath(tool.outputPath);
const isUnsafe = UNSAFE_OUTPUT_DIRS.some(
(dir) => resolved === dir || resolved.startsWith(dir + '/')
);
if (isUnsafe) {
// When traversal changed the path, show both forms so the reader sees
// the bypass; otherwise keep the original single-value evidence.
const pathEvidence =
resolved === tool.outputPath
? `outputPath: "${tool.outputPath}"`
: `outputPath: "${tool.outputPath}" (resolves to "${resolved}")`;
findings.push({
ruleId: RuleId.UNSAFE_TOOL_OUTPUT_PATH,
severity: Severity.CRITICAL,
title: 'Tool Output Path Points to System Directory',
description:
`Tool "${tool.name}" writes output to "${tool.outputPath}", ` +
'which is a sensitive system directory. Writing to these paths can compromise the host OS.',
evidence: `tool: ${tool.name}, outputPath: "${tool.outputPath}"`,
`Tool "${tool.name}" writes output to "${tool.outputPath}"` +
(resolved === tool.outputPath ? '' : ` (which resolves to "${resolved}")`) +
', which is a sensitive system directory. Writing to these paths can compromise the host OS.',
evidence: `tool: ${tool.name}, ${pathEvidence}`,
remediation:
'Restrict tool output paths to application-owned directories. ' +
'Never allow tools to write to /etc, /proc, /sys, or other system paths.',
Expand Down
Loading