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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ console.log(report.passed); // boolean — use as CI gate
- **Overprivileged tools** — tools granted broader permissions than their declared function requires
- **Missing or weak authentication** — unauthenticated transports, missing token validation
- **Prompt injection surface** — tool descriptions or output paths susceptible to injection
- **Tool poisoning** — hidden/invisible Unicode characters in tool names and descriptions (ASCII smuggling via Unicode Tags, bidirectional overrides, zero-width characters) that a human reviewer can't see but the model reads in full
- **Unsafe defaults** — insecure transport defaults, verbose error exposure, CORS wildcards

---
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,64 @@ describe('TOOL_DESC_INJECTION rule', () => {
});
});

// ─── HIDDEN_TOOL_METADATA ─────────────────────────────────────────────────────

describe('HIDDEN_TOOL_METADATA rule', () => {
const ZWSP = String.fromCodePoint(0x200b); // zero-width space
const RLO = String.fromCodePoint(0x202e); // right-to-left override

it('fires for a smuggled ASCII payload in the description (Unicode Tags block)', () => {
// An invisible tag-encoded fragment appended to a benign description.
const hidden = String.fromCodePoint(0xe0069, 0xe0067, 0xe006e); // tag i, g, n
const config: ParsedMcpConfig = {
tools: [{ name: 'reader', description: `Reads a file.${hidden}` }],
};
const findings = runInjectionRule(RuleId.HIDDEN_TOOL_METADATA, config);
expect(findings).toHaveLength(1);
expect(findings[0].ruleId).toBe(RuleId.HIDDEN_TOOL_METADATA);
expect(findings[0].severity).toBe(Severity.HIGH);
expect(findings[0].evidence).toContain('description');
expect(findings[0].evidence).toContain('U+E0069');
});

it('fires for a zero-width space hidden inside an otherwise benign description', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'reader', description: `Reads${ZWSP}a file from disk.` }],
};
const findings = runInjectionRule(RuleId.HIDDEN_TOOL_METADATA, config);
expect(findings).toHaveLength(1);
expect(findings[0].evidence).toContain('U+200B');
});

it('fires for a bidirectional override (Trojan Source) in the tool name', () => {
const config: ParsedMcpConfig = {
tools: [{ name: `safe${RLO}reversed`, description: 'A tool.' }],
};
const findings = runInjectionRule(RuleId.HIDDEN_TOOL_METADATA, config);
expect(findings).toHaveLength(1);
expect(findings[0].evidence).toContain('name');
expect(findings[0].evidence).toContain('U+202E');
});

it('does not fire for a benign description with ordinary whitespace', () => {
const config: ParsedMcpConfig = {
tools: [
{ name: 'read-file', description: 'Reads a file from disk\n\tand returns its content.' },
],
};
const findings = runInjectionRule(RuleId.HIDDEN_TOOL_METADATA, config);
expect(findings).toHaveLength(0);
});

it('does not echo the raw hidden characters, only U+XXXX code points', () => {
const config: ParsedMcpConfig = {
tools: [{ name: 'reader', description: `Reads${ZWSP}a file.` }],
};
const findings = runInjectionRule(RuleId.HIDDEN_TOOL_METADATA, config);
expect(findings[0].evidence).not.toContain(ZWSP);
});
});

// ─── UNSAFE_TOOL_OUTPUT_PATH ─────────────────────────────────────────────────

describe('UNSAFE_TOOL_OUTPUT_PATH rule', () => {
Expand Down
117 changes: 117 additions & 0 deletions src/rules/injection-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,73 @@ const INJECTION_PATTERNS = [

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

/**
* Non-printable / invisible Unicode characters that have no legitimate place in
* a tool name or description, grouped by the attack technique they enable.
*
* These are the mechanism behind MCP "tool poisoning": text that a human
* reviewer cannot see, but which the model reads in full, is smuggled into the
* metadata presented to the LLM. The visible description looks benign while
* hidden characters carry reordered text or an encoded ASCII payload.
*/
const HIDDEN_CHAR_CATEGORIES: Array<{ label: string; test: (cp: number) => boolean }> = [
{
// U+E0000–U+E007F, the Unicode Tags block — used to encode an entire
// invisible ASCII payload. Essentially never legitimate in plain text.
label: 'Unicode Tag (ASCII smuggling)',
test: (cp) => cp >= 0xe0000 && cp <= 0xe007f,
},
{
// Trojan-Source style reordering controls: the rendered order differs from
// the logical order the model consumes.
label: 'bidirectional override',
test: (cp) =>
cp === 0x061c || // ARABIC LETTER MARK
cp === 0x200e || // LEFT-TO-RIGHT MARK
cp === 0x200f || // RIGHT-TO-LEFT MARK
(cp >= 0x202a && cp <= 0x202e) || // LRE RLE PDF LRO RLO
(cp >= 0x2066 && cp <= 0x2069), // LRI RLI FSI PDI
},
{
// Zero-width and other invisible formatting characters — used to break up
// keywords (evading text-based rules) or hide content outright.
label: 'zero-width / invisible formatting',
test: (cp) =>
cp === 0x00ad || // SOFT HYPHEN
cp === 0x180e || // MONGOLIAN VOWEL SEPARATOR
(cp >= 0x200b && cp <= 0x200d) || // ZWSP ZWNJ ZWJ
(cp >= 0x2060 && cp <= 0x2064) || // WORD JOINER + invisible math operators
(cp >= 0x206a && cp <= 0x206f) || // deprecated format controls
cp === 0xfeff || // ZERO WIDTH NO-BREAK SPACE (BOM)
(cp >= 0xfff9 && cp <= 0xfffb), // interlinear annotation anchors
},
{
// C0/C1 control characters, excluding the ordinary whitespace a
// description may legitimately contain (tab, newline, carriage return).
label: 'control character',
test: (cp) =>
(cp <= 0x001f && cp !== 0x09 && cp !== 0x0a && cp !== 0x0d) ||
(cp >= 0x007f && cp <= 0x009f),
},
];

/** Render a code point as a `U+XXXX` label (never echoes the raw hidden char). */
function formatCodePoint(cp: number): string {
return 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
}

/** Collect every hidden character in `text` with the category it belongs to. */
function findHiddenChars(text: string): Array<{ cp: number; label: string }> {
const hits: Array<{ cp: number; label: string }> = [];
for (const ch of text) {
const cp = ch.codePointAt(0);
if (cp === undefined) continue;
const category = HIDDEN_CHAR_CATEGORIES.find((c) => c.test(cp));
if (category) hits.push({ cp, label: category.label });
}
return hits;
}

export const injectionRules: Rule[] = [
{
id: RuleId.TOOL_DESC_INJECTION,
Expand Down Expand Up @@ -44,6 +111,56 @@ export const injectionRules: Rule[] = [
},
},

{
id: RuleId.HIDDEN_TOOL_METADATA,
severity: Severity.HIGH,
title: 'Hidden Characters in Tool Metadata',
check(config: ParsedMcpConfig): Finding[] {
const findings: Finding[] = [];
for (const tool of config.tools ?? []) {
const fields: Array<{ field: string; value?: string }> = [
{ field: 'name', value: tool.name },
{ field: 'description', value: tool.description },
];

const parts: string[] = [];
const labels = new Set<string>();
const affectedFields: string[] = [];

for (const { field, value } of fields) {
if (!value) continue;
const hits = findHiddenChars(value);
if (hits.length === 0) continue;
affectedFields.push(field);
hits.forEach((h) => labels.add(h.label));
const codePoints = [...new Set(hits.map((h) => formatCodePoint(h.cp)))].slice(0, 5);
parts.push(`${field}: ${hits.length} hidden char(s) [${codePoints.join(', ')}]`);
}

if (parts.length === 0) continue;

findings.push({
ruleId: RuleId.HIDDEN_TOOL_METADATA,
severity: Severity.HIGH,
title: 'Hidden Characters in Tool Metadata',
description:
`Tool "${tool.name}" has non-printable or invisible Unicode characters ` +
`(${[...labels].join(', ')}) in its ${affectedFields.join(' and ')}. ` +
'Such characters are invisible to a human reviewer but read in full by the model, ' +
'and are the core mechanism of MCP "tool poisoning" attacks — hidden instructions or ' +
'smuggled ASCII that can hijack the model while the visible text looks benign.',
evidence: parts.join('; '),
remediation:
'Remove all non-printable and invisible Unicode characters from tool names and ' +
'descriptions. Metadata presented to an LLM should contain only visible, printable text. ' +
'Reject tool definitions from untrusted MCP servers that include hidden characters.',
docsUrl: 'https://hailbytes.com/mcp/docs/rules/HIDDEN_TOOL_METADATA',
});
}
return findings;
},
},

{
id: RuleId.UNSAFE_TOOL_OUTPUT_PATH,
severity: Severity.CRITICAL,
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum RuleId {
WEAK_API_KEY = 'WEAK_API_KEY',
MISSING_TLS = 'MISSING_TLS',
TOOL_DESC_INJECTION = 'TOOL_DESC_INJECTION',
HIDDEN_TOOL_METADATA = 'HIDDEN_TOOL_METADATA',
UNSAFE_TOOL_OUTPUT_PATH = 'UNSAFE_TOOL_OUTPUT_PATH',
WILDCARD_CORS = 'WILDCARD_CORS',
VERBOSE_ERRORS = 'VERBOSE_ERRORS',
Expand Down
Loading