The shipped signature catalog + how to add one. See docs/GUARD.md for the runtime model.
The category column is each signature's own
categoryfield insrc/guard/signatures.ts. Those strings (OWASP-MCP-1,OWASP-MCP-2, …) are the obsolete OWASP MCP Top 10 v0.1 numbering, kept only as stable internal tags — they are NOT the current classification. The pinned MCP01–MCP10 mapping every emitted finding actually carries lives insrc/guard/owasp.tsand is documented in OWASP mapping.
| id | category | severity | target | description |
|---|---|---|---|---|
discovery-metadata-injection |
OWASP-MCP-1 | critical | discovery_metadata | Heuristic instruction patterns in skill frontmatter/_meta and resource discovery metadata, including <important>/<system> and instruction-shaped text. Metadata-only: no archive/content verification, publisher authentication, consent or activation enforcement. Legitimate instructions can match; see coverage. |
owasp-mcp-2-instruction-injection-in-response |
OWASP-MCP-2 | critical | tool_response | Imperative instructions in tool response content (Ignore previous / Disregard prior / Forget all / You are now in developer mode / <|system|>) |
owasp-mcp-7-path-exfil-in-args |
OWASP-MCP-7 | high | tool_call_args | Sensitive file paths in tool arguments (.ssh / .aws/credentials / .env / id_rsa / .gnupg / .kube/config) |
owasp-mcp-1-tool-description-injection |
OWASP-MCP-1 | critical | tool_description | Instruction-shaped text in tool descriptions (poisoning / rug-pull patterns) |
owasp-mcp-1-tool-annotation-injection |
OWASP-MCP-1 | critical | tool_annotations | TODOS #16. Instruction-shaped text in a tool's annotations (title, or any custom field a server adds — annotations is an unconstrained JSON object) — the same tool-poisoning class as the description signature above, carried via the annotations extension surface instead. Reuses the same patterns/block-capable carrier. |
owasp-mcp-2-instruction-injection-in-resource |
OWASP-MCP-2 | critical | resource_content | Imperative instructions in retrieved resources/read content (warn-and-forward — retrieved data) |
owasp-mcp-2-instruction-injection-in-prompt |
OWASP-MCP-2 | critical | prompt_content | Imperative instructions in a server-provided prompts/get template (warn-and-forward — retrieved data) |
owasp-mcp-1-initialize-instruction-injection |
OWASP-MCP-1 | critical | initialize_instructions | Instruction-shaped text in initialize instructions / serverInfo (line-jumping, block-capable pre-invocation context) |
credential-phishing-wallet-solicitation |
MCP-CREDENTIAL-PHISHING | critical | prompt_content | Server-initiated prompt soliciting a crypto-wallet seed/recovery phrase, mnemonic, or wallet private key (drainer phishing) |
credential-phishing-financial-solicitation |
MCP-CREDENTIAL-PHISHING | critical | prompt_content | Server-initiated prompt soliciting a card CVV/CVC, SSN, or card/bank PIN (financial phishing) |
exfil-param-in-schema |
OWASP-MCP-1 | critical | tool_description | Structural (no regex): a tools/list tool declares an input-schema parameter named with the context-exfil sigil convention (_system_prompt_, _conversation_history_, _chain_of_thought_, _reasoning_trace_, _context_window_, _exfil*) — the model auto-fills it, leaking context. Blocks the server's tool list at advertisement time. Emitted by detectExfilParams (a property-KEY walker), not the regex engine — the catalog entry carries empty patterns so the id is muteable/listable. |
credential-egress-in-response |
MCP-CREDENTIAL-EXFIL | high (→ warn) | tool_response | F10 credential DLP. High-confidence, prefix-anchored credential material in a tool response (PEM private key, GitHub classic + fine-grained PAT, GitLab, OpenAI, Anthropic, Stripe, Google, npm, Slack token, AWS access-key id) — a data-loss / credential-egress signal. WARN-tier (forwarded, not blocked): a secrets-manager/auth tool legitimately returns credentials → promote-to-block per policy. Only STRUCTURAL shapes (no prose FP); AWS's docs AKIAIOSFODNN7EXAMPLE excluded; generic Bearer disclosure is covered separately below (TODOS #53); bare-JWT/40-char-base64 (no anchor at all) remain deferred to the suspect tier. redact: true keeps the caught secret out of the event log and message. |
generic-bearer-token-disclosure |
MCP-CREDENTIAL-EXFIL | high (→ warn) | tool_response | TODOS #53, the generic-Bearer half of the "suspect tier" named above, now shipped. Closes CVE-2026-25650 (MCP-Salesforce get_record), where an unchecked argument returns the live client's own Authorization: Bearer <session token> header verbatim. Typically no vendor prefix, so it is a separate, lower-confidence, independently-muteable signature — the pattern is reused UNMODIFIED from scanner/patterns.ts's registry-sweep-validated fix for the 164-FP "Bearer token" prose incident (requires ≥20 token chars AND a digit after Bearer ). An earlier version widened the character class with ! to match Salesforce's own <org-id>!<signature> token format (the CVE's literal PoC); a pre-merge adversarial review found that widening false-positives on real benign text the unwidened pattern never matched, so it was reverted — the CVE's own exact PoC token is a known, accepted miss (see TODOS #53), and a vendor-prefixed token disclosed with a Bearer prefix may also (harmlessly) co-fire alongside the sibling signature above. redact: true. |
shell-metachar-in-identifier-arg |
MCP-COMMAND-INJECTION | critical | tool_call_args | Structural (no regex on its own): a tools/call argument whose KEY canonicalizes to a bare identifier/path suffix (id, number, num, path, slug, uuid, identifier, namespace — name deliberately excluded, see TODOS #50) has a VALUE containing shell-metacharacter/command-substitution syntax ($(...), backtick, ;, &&). Motivated by two real CVEs (CVE-2025-53818, CVE-2026-25546) that splice such an argument unescaped into a shell exec(). Key-scoped so a legitimate shell/exec-style tool's own free-text command argument is never flagged. Emitted by detectShellMetacharArgs (a key+value walker over params.arguments); also reuses inspectTagEncoded to catch a payload concealed via Unicode TAG-block "ASCII smuggling" in a scoped value, run unconditionally alongside the plain match so a visible AND a separately-concealed occurrence in one value are both reported (TODOS #55, closed). base64 decode-and-rescan is deliberately NOT run — parity with the main catalog, which doesn't decode base64 on tool_call_args either (F10 Detector-B's threat model is a server encoding a payload into its own response, not an argument value the calling agent sends). |
query-control-syntax-in-identifier-arg |
MCP-QUERY-INJECTION | critical | tool_call_args | Structural (no regex on its own): a tools/call argument whose KEY canonicalizes to a table/column/field/collection/database/schema/index/view/dataset noun or a scalar-id suffix (id, identifier, uuid, slug — name alone deliberately excluded, same reasoning as #50) has a VALUE containing query-language control syntax — a pipe followed by a query verb (| project, | take, ...), a statement separator followed by a DDL/DML keyword, a .drop management command, or a whitespace-anchored line comment (--, //; anchored so a URI scheme's :// or a version-suffixed name's -- doesn't false-block). Motivated by CVE-2026-33980 (adx-mcp-server), which f-string-interpolates a table_name argument into a live KQL query with no escaping — the advisory's own PoC uses pipe re-scoping plus a trailing // comment to exfiltrate columns, and a sibling PoC uses .drop table to destructively drop tables. Key-scoped so a legitimate query-builder tool's own free-text query/filter argument is never flagged. Shares its argument-tree walker (tool-call-args-walk.ts) with shell-metachar-in-identifier-arg, including its TAG-block decode-and-rescan reuse (TODOS #55, closed); relaxLeadingAnchor's relaxation is a no-op for this signature's own (?:^|\s)-shaped anchors (a different literal string than the catalog's LEADING_ANCHOR), so a concealed comment-marker payload with NO whitespace anywhere is a known, accepted miss — the CVE's own PoC hits the unanchored pipe-rescope pattern instead. |
cli-flag-injection-in-identifier-arg |
MCP-ARGUMENT-INJECTION | critical | tool_call_args | Structural (no regex on its own): a tools/call argument whose KEY canonicalizes to a bare namespace or opaque-identifier suffix (namespace, id, identifier, uuid, slug — name deliberately excluded) has a VALUE containing a whitespace-anchored ---prefixed CLI flag token (--address=0.0.0.0). Motivated by CVE-2026-39884 (mcp-server-kubernetes port_forward), which whitespace-splits a resourceName/namespace argument into a shell command with no array-based escaping. name was included in an earlier version of this detector but a pre-merge adversarial review measured five real false-positive classes it produced (ticket/PR/task titles mentioning a flag, compound *_name CLI-passthrough fields, freeform cloud-resource "Name" tags with an appended note, npm's own <script> -- <flags> convention, descriptive filenames) — the accepted cost is that the advisory's own literal PoC (via resourceName) is no longer caught; the same vulnerable code path is still caught via namespace (TODOS #57). Shares its argument-tree walker (tool-call-args-walk.ts) with the two signatures above, including its TAG-block decode-and-rescan reuse (TODOS #55, closed) — the anchor caveat noted for the query-control signature above doesn't weaken THIS one's CVE coverage, since the flag's own whitespace anchor is load-bearing for the wrapped tool's command.split(" ") vulnerability in the first place. |
renderer-code-execution-in-response |
MCP-RENDERER-CODE-EXECUTION | high (→ warn) | tool_response | TODOS #54. Closes CVE-2025-68669 and CVE-2026-22793 (both nanbingxyz/5ire): an <img onerror=...> tag, a <script> body, or a ```mermaid/```echarts `` diagram fence calling the client's privileged electron.mcp.activate(/`electron.mcp.addServer(` IPC bridge, achieving RCE. A pre-merge adversarial review of an earlier, broader design (28 CONFIRMED findings) found that gating on generic escape-hatch tokens (`child_process`, `require(`, `exec(`, `spawn(`, `eval(`, `new Function(`) false-positived hard on ordinary JS documentation/tutorials/CTF-writeups, and that an ungated diagram-fence shape's premise — "legitimate chart content never contains a function definition" — is false for ECharts specifically (formatter callbacks persisted via `new Function`, option data computed via an IIFE, are both standard documented idioms). Narrowed to require the literal, disclosed bridge call in ALL THREE shapes instead — an honest, CVE-grounded tripwire rather than a speculative net (a different client's own differently-named bridge, or HTML-entity/bracket-notation obfuscation of the same call, are accepted, documented evasion gaps, the same "tripwire not defense" scope as `exfil-param-in-schema`). The same review also caught and fixed two regex-correctness bugs (an unquoted-value branch that could bleed into an adjacent attribute; a `<script>`-open matcher not quote-aware, which could push a real call past the body-scan budget) and a missing `redact: true` that would otherwise leak an adjacent real secret verbatim into the event log. Severity is `high`/warn rather than block: a documentation/CVE-lookup tool can legitimately return prose quoting this exact literal call. |
tool-name-confusable-duplicate |
OWASP-MCP-1 | high (→ warn) | tool_description | Structural (no regex): one tools/list advertises two tools whose NAMES canonicalize to the same form (blank-filler fold + NFKC + default-ignorable strip + confusable fold + edge-whitespace trim + case-fold) but whose raw spellings differ — format_code beside Format_Code or Cyrillic fоrmat_code. This is the co-existence half of the TODOS #58 look-alike residual: keying drift by the raw name let a poisoned definition arrive under a twin and be filed as a brand-new tool. WARN, not block: a case-only pair is legal under SEP-986 (names are case-sensitive) and a block on a tools/list disables the server's entire tool surface, so the enforcing half is the canonical drift/pin keying in the relay, which BLOCKS the stateful replace form. Colliding names fall back to RAW keying (and an exact-only pin lookup) for the rest of the session, so the two tools keep independent baselines and neither can poison the other's — they are never dropped from inspection, because a dropped collision group takes the incumbent with it and would let one throwaway case-variant disarm drift for a real tool. Emitted by detectConfusableToolNames. |
tool-name-deceptive-characters |
OWASP-MCP-1 | high (→ warn) | tool_description | Structural (no regex): a tool NAME that contains an invisible or blank-width character, or MIXES Latin letters with another script. Invisible is the whole Unicode Default_Ignorable_Code_Point property (zero-width, bidi incl. U+061C, the combining grapheme joiner U+034F, every variation selector, the Hangul fillers, the tag block, …) plus the blank Braille cell U+2800, with no carve-outs — SEP-986 names are ASCII, so an emoji presentation selector in a name is as deceptive as any other. The complement to the fold above, not a duplicate of it: the confusable table is a scoped Cyrillic/Greek subset, so out-of-table look-alikes like ԝrite_file (Armenian U+051D) or ɡet_user (U+0261) survive canonicalization but cannot survive a mixed-script test, because impersonating an ASCII name requires sitting among ASCII letters. A name written wholly in one non-Latin script, or containing a space or slash, impersonates nothing and is NOT flagged (an earlier charset-based rule flagged real servers — NetApp's "Create CIFS Share", tasks/get, Han-script names — and was narrowed in v0.35.0). WARN only, so an out-of-table twin, or one with a blank-width filler (U+3164, U+FFA0, U+2800) inside the name, is reported and forwarded where an in-table or zero-width one blocks through the relay's canonical drift keying: such a filler folds to a space and shows as a visible gap, so format<filler>_code is a different key from format_code. At either end of a name a filler or a space renders as nothing and is trimmed from the canonical key, so that twin blocks (measured on the relay, #114). Before v0.35.0 a tool's name was not an inspected carrier at all: tool_description extracts [description, title, inputSchema] and tool_annotations extracts annotations. |
Every id above (plus the non-catalog emitters below) also carries an OWASP MCP
Top 10 (beta) pin — backlog #71, src/guard/owasp.ts — classifying the finding
against the commit pinned in docs/owasp-mcp-mapping.md as pinned (with a
category), unknown (not yet classified), or unpinnable (a guard-health
signal, evaluated and found not to correspond to any category). It is not a
column here because it's a many-to-few mapping better read as a table keyed
the other way (see docs/owasp-mcp-mapping.md's coverage map); mcpm guard list-signatures prints it per signature (owasp : MCP03 @ 165fe0f7, or
unknown @ 165fe0f7 / unpinnable @ 165fe0f7), and --json includes the
full {status, id?, ref} object.
Plus four entries that carry no patterns — they are emitted by dedicated passes
rather than by a content regex, and exist in the catalog so their ids are
recognized by guard mute, guard list-signatures, and policy overrides:
hidden-chars-in-metadata(OWASP-MCP-1; tool_description / tool_annotations / initialize_instructions) — the H2 presence detector. It flags every codepoint in Unicode'sDefault_Ignorable_Code_Pointproperty (the codepoints a renderer draws as nothing: zero-width, bidi, soft hyphen, word joiners, the combining grapheme joiner, the Arabic letter mark, Hangul fillers, Khmer and Mongolian format characters, every variation selector, the tag block, …) plus C0/C1 controls, DEL and ANSI ESC. The same property drives what the match pipeline strips and whattool-name-deceptive-charactersflags, so the three cannot drift apart again (each used to carry its own hand-written list and each missed 4,018–4,025 of the property's 4,174 codepoints on Node 24.20.0 / Unicode 17.0 —#114). Because the property also covers ordinary emoji plumbing, the detector carves out what it can validate from the neighbouring codepoints: a ZWJ between two emoji components, the three RGI subdivision flags, and a single VS15 or VS16 after an Extended_Pictographic base (✨+ U+FE0F,©+ U+FE0F) or VS16 in a keycap (1+ U+FE0F + U+20E3). Any other selector after an emoji (each of the other 258 would carry about a byte), a run of two or more selectors, a selector after an ASCII letter, and a selector after a Han ideograph or math symbol are flagged (the last two are deliberate: see the CHANGELOG entry for the measurement). Every carve-out check looks at one codepoint either side, so cost stays linear in the leaf. Scoped to metadata on purpose — an invisible character in a fetched log, source file, or email is common and benign, so scanning retrieved data for the whole class would be an FP factory.unicode-tag-concealment(OWASP-MCP-1; the carriers H2 skips — tool_response / tool_call_args / resource_content / prompt_content, and sampling_prompt by re-tagging) — the tag-block-only presence floor. The tag block is one of two parts of the hidden-character class that are safe to scan in retrieved data (the other is a variation-selector run, below), because it differs in FREQUENCY: outside an emoji subdivision flag it does not occur in real text. Every OTHER invisible codepoint (U+034F, U+061C, fillers, …) is stripped before matching on these carriers but not reported: it turns up routinely in fetched data. (Note the frequency argument, not a deprecation one — U+E0020–U+E007F were un-deprecated in Unicode 9.0 to carry emoji tag sequences; only U+E0001 LANGUAGE TAG remains deprecated.)high→ warn, so on retrieved data it annotates and forwards. Disjoint fromhidden-chars-in-metadataby carrier, so a tag character is reported once.variation-selector-concealment(OWASP-MCP-1; the same carriers asunicode-tag-concealment— everything H2 skips) — the variation-selector-run presence floor ("emoji smuggling": one visible base character followed by a run of the 260 variation selectors, invisible everywhere while a decoder reads one byte per selector). Fires on two or more consecutive selectors and nothing else: no standardized variation sequence has two in a row, whereas a single emoji VS16, a keycap or a CJK ideographic sequence is exactly what retrieved data is full of.high→ warn. Known gaps, by design: one selector interleaved after each of many visible characters is not a run and passes; and there is no decode-and-rescan pass —inspectTagEncodedcan recover a tag payload because a tag codepoint is an ASCII letter, whereas a selector run encodes bytes under a convention the attacker chooses, so this floor says something was concealed and never what it says. On the metadata carriers the same runs are reported byhidden-chars-in-metadata, so a character is reported under one id only.guard-inspection-truncated(MCP-GUARD-INTEGRITY; emitted on whichever carrier was truncated) — raised when the leaf-walk budget is exhausted, i.e. the guard did not finish reading the frame.critical, so it rides the normal carrier policy: blocks on block-capable carriers, clamps to warn on retrieved-data ones. Budget exhaustion used to fail open (a silent return), which was a complete detection bypass — ~73 KB of junk padding hid a critical injection. Fixed in v0.26.0; this entry is the visible half of that fix.
Note on Unicode tag-block concealment ("ASCII smuggling"). U+E0020–U+E007E are a shadow copy of printable ASCII, so a payload written in them renders as nothing while a model that decodes tag codepoints reads it plainly (arXiv 2607.05744, which reported the technique beating string-matching sanitizers in 4 of 8 tests). The guard handles it in two passes with different jobs. (1)
inspectTagEncodeddecodes the tag characters back to ASCII and re-runs the carrier's own signatures, so a concealed payload is judged by what it SAYS — this is what keepssampling_promptblock-tier: a TAG-encoded wallet-seed solicitation reachescredential-phishing-wallet-solicitationand is blocked with a reply to the server. All tag characters in a leaf decode as ONE payload, because decoding each run separately would let an attacker interleave a visible character so no single run spelled a matchable phrase. (2)unicode-tag-concealmentis the floor beneath it, for a payload that is concealed but matches no signature. Unlike the base64 pass, tag-decoded findings are not clamped to warn: base64 is everywhere in benign data, which makes a decoded match weak evidence, whereas a tag run that decodes to a signature-matching phrase is stronger evidence than the same phrase in plaintext — concealment is not something benign content does. The two passes compose (base64 wrapping a tag-encoded payload is caught, at the base64 layer's warn tier); base64-of-base64 still evades, unchanged. Before v0.28.0 this class was a detection HOLE and not merely an FP risk:PATTERN_BREAKERSstrips tag characters before matching, so a fully encoded phrase was erased rather than revealed, and presence detection ran on metadata only — every other carrier scored zero findings.How the decoded pass avoids re-reporting text you can already read (TODOS
#34). Pass (1) must not block an article that merely quotes an attack phrase, so a decoded finding is dropped when the same finding is visible without decoding. That comparison was originally keyed on the match's rendered text, and both sides of it are attacker-written: a visible phrase preceded by any character outside[\s.,;:!?]matches the relaxed pattern without being reported itself, and cancelled the concealed payload --blockbecamewarn. It is now an occurrence count against a masked copy of the same segment (each concealed character replaced by NUL): a match whose literals came from concealed text is absent from the masked view and survives as surplus, while a visible phrase appears in both views and cancels itself however often it is repeated. Decoys cannot inflate the masked side, because each one adds to both. Counting also sidesteps a trap that killed two earlier designs -- a positional comparison needs the two views to share a coordinate space, and NFKC can compose a decoded character with a following combining mark, shrinking one view and not the other. Occurrences are counted exactly. An earlier version stopped at 256 matches per pattern and had to interpret the cap, which has no answer that is right in both directions: treating it as concealed fabricated ablockon a benign prompt-injection dataset, and treating it as visible let 256 decoys suppress a real payload. Counting costs one map operation per match and the scanned string is already bounded by the 64 KB head+tail window, so the bound bought nothing; what remains is an unreachable runaway backstop for a hypothetical future pattern short enough to match tens of thousands of times.
Note on the invisible-character definition (
#114). "Renders as nothing" used to be three hand-written lists — the match pipeline's strip, the metadata presence detector and the tool-name check — and all three named the same six families and missed most of the Unicode property.ig<U+034F>nore previous instructionsscored zero findings on a block-capable carrier while the ZWSP spelling blocked; a poisoned tool renamed with U+061C (Arabic letter mark) was forwarded as a brand-new tool on the live relay, reopening#58; and a run of variation selectors after one emoji hid a payload with no presence floor anywhere. All three now derive from\p{Default_Ignorable_Code_Point}through one shared definition (DEFAULT_IGNORABLE_CLASSinpatterns.ts), so a newly assigned invisible codepoint is covered by a Node/Unicode upgrade. Three consequences worth knowing. (1) Blank-width fillers fold to a space, not to nothing (see step 3 below); the zero-advance conjoining fillers U+115F/U+1160 are stripped like the rest. (2) Emoji plumbing is carved out of the metadata detector by validating the base (Extended_Pictographic or keycap) and the selector (VS15/VS16 only): a run, any other selector after an emoji, or a selector after an ASCII letter, is flagged. (3) A blank-width filler inside a tool name produces a different canonical key than the trusted tool, so the relay files a poisoned twin as a new tool and the result is a warn (tool-name-deceptive-characters), not the block every other invisible codepoint gets; at either end of a name it is trimmed and the twin blocks. The scanner's install-timezero-width characters (obfuscation)label (src/scanner/patterns.ts) is a fourth hand list feeding the registry trust score and is deliberately unchanged: widening it without the carve-outs would lower scores for every registry description containing an emoji.
Note on the emoji subdivision flag carve-out. 🏴 is built from tag characters, so before v0.28.0 any server whose description carried an England/Scotland/Wales flag raised a poisoning warning on every
tools/list— a live false positive in a zero-FP detector. The carve-out validates the whole sequence and is RGI-exact (gbeng/gbsct/gbwlsonly). Both properties are load-bearing. A per-character flank test — the shape of the existing ZWJ carve-out — would let an attacker write 🏴 + TAG(payload) and suppress detection wholesale; a shape-based whole-sequence rule is barely better, since "ignore" is six lowercase letters, so 🏴+TAG("ignore")+CANCEL is well-formed and such sequences chain to spell anything. Nothing legitimate is lost: a non-RGI sequence renders as a bare black banner. Fixtures pin both the FP and the chaining bypass, and benign emoji-tag-sequence fixtures had to be added for the zero-FP claim to mean anything — the prior corpus contained no tag codepoints at all, so a tag-block detector would have scored 0 FP against it by vacuity.
Note on
exfil-param-in-schema(F5). Deny-tier and zero-FP by design: only the underscore-wrapped sigil form is matched (_system_prompt_), because a block on atools/listframe disables the server's entire tool surface — so a false positive would brick a legit server. Bare names a real tool may use (system_prompt,messages,reasoning) and agent-framework runtime slots (_context_,_memory_,_thinking_) are excluded. It is a tripwire for the documented HiddenLayer/CyberArk convention — a renamed parameter (systemPrompt,sys_prompt,context_dump) evades it; the broader description-cross-check and bare-name SUSPECT tier are deferred (FP-laden). Keys are normalized (NFKC + confusable-fold + separator/camelCase canonicalization) before matching, so homoglyph/zero-width/_systemPrompt_variants still match.
Note on the two
MCP-CREDENTIAL-PHISHINGsignatures. They targetprompt_contentbut their real value is on the server-initiated path:inspectServerInitiated(run-inner.ts) wraps asampling/createMessageorelicitation/createrequest into a syntheticprompts/getframe, scans it, and re-tags any finding to the block-capablesampling_promptcarrier — so a credential-phishing prompt is blocked (error routed back to the server), while the same string in a passive retrievedprompts/gettemplate stays warn-only. Both patterns are solicitation-anchored (an imperative verb + the credential noun) so a benign mention in replayed conversation history or field-name prose does not fire, and[\s-]*separators keep a zero-width-split evasion (seedphrase) matching afterPATTERN_BREAKERSstrips the separator. Generic api-key/password/token solicitation is intentionally out of scope (a server collecting its own config secret is the common legitimate case); OTP/verification-code is deferred (self-pairing is indistinguishable from relay-phishing without provenance).
Plus the runtime drift detectors (schema-drift, schema-drift-cosmetic, schema-drift-in-session) — emitted by the relay, not by the signature engine. Drift is classified per changed field (H4): a description-only change is schema-drift-cosmetic (severity high → warn, forwarded — the parallel tool_description pattern scan still blocks any regex-detectable injection on the same frame, since the relay takes the MAX action); a schema or annotations change — or any pre-H4 pin with no stored field hashes — is schema-drift (critical → block). A server→client notifications/tools/list_changed arms a single-shot re-validation so an announced upgrade is classified against the pin rather than tripping the same-session guard. Cosmetic warn is bounded by the pattern-engine regex floor: paraphrased poisoning that the patterns miss can be forwarded with only the drift warning. No model-based judge is part of this pipeline.
Not signatures: confine + orig-hash spawn events. The
--confineOS-sandbox primitive (F1, released in v0.16.0) adds no OWASP signatures — the catalog above is unchanged by confine. It emits relay/spawn events (not detection signatures) toguard-events.jsonl: categoryCONFINE—confine-applied,confine-hash-mismatch,confine-marker-stripped,confine-profile-missing,confine-backend-missing,confine-marker-malformed; plus categoryRELAY—orig-hash-mismatch(the wrap marker's--orig-hashverified at spawn, #108). These reason about spawn-time enrollment/integrity, not JSON-RPC frame content, so they are outside the signature engine.
- critical → block by default — clamped to warn on the warn-only
retrieved-data carriers (
resource_content,prompt_content), and on any finding recovered from a base64-decoded leaf (decoded: true) - high → warn by default (forwards traffic; promote to block via policy)
- medium / low → pass —
severityToAction(src/guard/patterns.ts) maps them topass, i.e. they contribute no action of their own.log_onlyis not a severity mapping: it is one of the four actions a policy override can set for a signature id
Policy overrides in ~/.mcpm/guard-policy.yaml can promote, demote, or mute any signature per-id. See docs/POLICY.md.
For every JSON-RPC message:
- Extract the subtree matching each signature's
target:tool_response→result.content,result.structuredContent, and JSON-RPC error objects (only when present) — errors are scanned to prevent injection evasion via error messages. Scope note (#21): matches ANY JSON-RPC response carrying these fields, not only replies totools/call— a response has nomethodto gate on, so aresources/read/prompts/getreply that happens to also carrycontent/structuredContent/erroris scanned here too, on top of its own narrower carrier (resource_content/prompt_content). Intentional over-coverage, not a bug.tool_call_args→params.argumentsoftools/calltool_description→result.tools[*].description,result.tools[*].title, and fullresult.tools[*].inputSchemaincluding nested property descriptions/enums (only when present) — inputSchema is scanned because poison can hide in parameter descriptionstool_annotations→result.tools[*].annotations
- Walk every string leaf in the subtree. The walk is not depth-bounded — the
depth cap of 32 was removed in v0.26.0 because a payload nested deeper than 32 in
structuredContentwent uninspected. It is bounded instead by a total-visit budget ofMAX_LEAF_WALK_NODES= 100,000 nodes (src/guard/patterns.ts), and exhausting that budget emitsguard-inspection-truncatedatcritical, marking incomplete inspection. By default it blocks on block-capable carriers and warns/forwards on retrieved resource/prompt carriers; policy overrides still apply. - Fold the blank-width fillers (U+3164, U+FFA0 and the blank Braille cell U+2800) to a space because they render as a gap between words, then NFKC-normalize the leaf, then strip every
Default_Ignorable_Code_Point(zero-width, bidi, soft hyphen, the combining grapheme joiner, the zero-advance conjoining fillers U+115F/U+1160, variation selectors, the tag block, …), then fold the scoped Cyrillic/Greek confusables (anti-evasion). The order is load-bearing: NFKC maps U+3164 and U+FFA0 onto U+1160, which the strip removes, so folding after NFKC would fuse the words either side. A blank-width filler inside a word therefore splits it (ig<U+3164>noredoes not match, just asig norenever has) — it is visible width, not a spelling of the word — and the metadata carriers still flag U+3164 and U+FFA0 themselves (U+2800 is real Braille and not default-ignorable, so only the tool-name check and the match fold treat it as a filler). - Test each signature's regex patterns against the normalized leaf.
- First match per signature wins (no double-counting).
- Decode-and-rescan (F10 Detector-B) — on the server-returned-data carriers (
tool_response,resource_content,prompt_content) only, each leaf's bounded base64/base64url runs are decoded and the SAME carrier's signatures re-run on the decoded text to detect supported encoded patterns within the limits below. Every finding recovered this way is taggeddecodedand is warn-only by default (the decode is heuristic); an explicit policy override can promote it to block, as described in policy semantics. Guardrails: only runs that decode to printable text are rescanned (binary blobs — images/audio/gzip/hashes — are dropped, preserving the deferred-blob decision); ≤64 decode attempts/leaf (theBuffer.frombound), of which ≤8 texty decodes are rescanned, all bounded to the 64 KB head+tail window; no hidden-char scan on decoded bytes. Blind spots (by design): double/nested encoding is not re-decoded (one round only); an attacker who controls a response can still hide a payload behind ≥8 base64 blobs that themselves decode to text, or past the 64th candidate (non-texty/binary padding does not work); percent- and hex-encoding are deferred (URL/hash candidate volume is huge and the in-response carrier is rare); non-Latin UTF-8 payloads fall below the printable-ASCII gate. Acatalogcaveat: prefix/phrase anchoring limits false-positive risk without guaranteeing zero false positives — do NOT add a loose generic-secret or entropy rule (it would false-positive on decoded benign text).
interface Signature {
readonly id: string; // "owasp-mcp-N-short-name"
readonly category: string; // "OWASP-MCP-N"
readonly severity: "critical" | "high" | "medium" | "low";
readonly description: string; // human-readable
readonly target: // see src/guard/types.ts SignatureTarget
| "tool_response" | "tool_call_args" | "tool_description" | "tool_annotations"
| "discovery_metadata" | "resource_content" | "prompt_content" | "initialize_instructions" | "sampling_prompt";
readonly patterns: readonly RegExp[]; // NFKC-tolerant regexes; whitespace via [\s-]* (zero-width-evasion safe)
readonly remediation: string; // actionable string; shown to user on block
readonly redact?: boolean; // when true the match IS a secret: the excerpt
// becomes "‹redacted N-char secret›" so it never
// reaches guard-events.jsonl or the warn message
}When you write a new regex, validate it against these evasion shapes:
- NFKC fold: does
pattern.test("string".normalize("NFKC"))match for full-width Latin variants? - Invisible-character insertion: does the pattern still match with U+200B / U+200C / U+200D — or any other default-ignorable codepoint such as U+034F or U+061C — between key words? (Guard strips the whole property before matching, so the answer should always be yes; a blank filler such as U+3164 becomes a space instead, so it must not sit inside a word you expect to match.)
- Whitespace alternation: use
[\s]+for word separators — literal spaces are bypassed by newline / tab / multi-space. - Vocabulary synonyms: include "ignore", "disregard", "forget" (or whichever set is canonical for the attack class).
- ReDoS safety: no nested quantifiers (
(.*ignore.*)+is a footgun). Test against a 100KB pathological input — should complete in < 1ms.
## Signature: <id>
**Category:** OWASP-MCP-N (or a descriptive class, e.g. MCP-CREDENTIAL-PHISHING, when the OWASP v0.1 numbering doesn't cleanly map)
**Severity:** critical | high | medium | low
**Target:** tool_response | tool_call_args | tool_description | tool_annotations | discovery_metadata | resource_content | prompt_content | initialize_instructions | sampling_prompt
**Attack vector:** <one paragraph; cite public disclosure URL if applicable>
**Regex patterns:**
```
/regex-1/i
/regex-2/i
```
**Remediation text shown on block:**
> <copy of the remediation string>
**Fixture coverage:**
- `src/guard/__tests__/fixtures/mcptox/attacks/<name>.json` — must trigger
- `src/guard/__tests__/fixtures/mcptox/benign/<adjacent-benign>.json` — must NOT trigger (or extend the corpus to cover the FP risk)
**Anti-evasion checklist:** (paste each item with ✓/✗)
1. NFKC fold:
2. Invisible-character insertion:
3. Whitespace alternation:
4. Vocabulary synonyms:
5. ReDoS safety:- The attack class is already covered. Extend an existing pattern instead.
- The attack is too specific to one server. Avoid a global pattern that flags unrelated servers. Current policy overrides are global per signature id, not per-server exceptions.
- The pattern would false-positive on benign content. Validate against the FP-rate corpus (
src/guard/__tests__/fixtures/legitimate-corpus/). - The pattern requires a model to disambiguate. Record the unsupported case and its evidence. A model-based judge is not implemented or committed as the remedy.
The shipped set is vendored at src/guard/signatures.ts. Each pin in ~/.mcpm/pins.json records the signature_list_version active at capture time (owasp-mcp-top-10@v0.5.0). Bumping the version is a normal release operation; users see signature changes in the CHANGELOG.
Separate signature repo + signature signing infrastructure are deferred (V2 / until update cadence requires faster releases than @getmcpm/cli's normal cycle).