|
| 1 | +package checks |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "regexp" |
| 6 | + "strings" |
| 7 | + |
| 8 | + "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect" |
| 9 | +) |
| 10 | + |
| 11 | +// Shadowing is a HARD check that flags cross-server tool impersonation and |
| 12 | +// reference (FR — shadowing). Two distinct attack shapes: |
| 13 | +// |
| 14 | +// 1. Name collision: a DISTINCTIVE tool name exposed by two different servers |
| 15 | +// (one impersonating the other so an agent calls the wrong one). |
| 16 | +// 2. Cross-server reference: a tool whose description names a DISTINCTIVE tool |
| 17 | +// that lives on a different server (steering the agent's tool selection). |
| 18 | +// |
| 19 | +// To hold near-zero FP, both shapes require the name to be distinctive: generic |
| 20 | +// verbs ("search", "get", "list") collide across servers all the time and are |
| 21 | +// never flagged. A tool referencing its OWN name is also ignored. |
| 22 | +type Shadowing struct{} |
| 23 | + |
| 24 | +// ID implements detect.Check. |
| 25 | +func (*Shadowing) ID() string { return "shadowing.cross_server" } |
| 26 | + |
| 27 | +// commonNames are generic tool names whose collision/reference across servers is |
| 28 | +// ordinary and must never be treated as shadowing. |
| 29 | +var commonNames = map[string]struct{}{ |
| 30 | + "search": {}, "get": {}, "list": {}, "read": {}, "write": {}, "fetch": {}, |
| 31 | + "query": {}, "run": {}, "exec": {}, "call": {}, "create": {}, "update": {}, |
| 32 | + "delete": {}, "add": {}, "remove": {}, "find": {}, "open": {}, "close": {}, |
| 33 | + "send": {}, "load": {}, "save": {}, "echo": {}, "ping": {}, "status": {}, |
| 34 | + "help": {}, "info": {}, "scan": {}, "check": {}, "test": {}, |
| 35 | +} |
| 36 | + |
| 37 | +// distinctiveName reports whether a tool name is specific enough that a |
| 38 | +// cross-server collision/reference is suspicious rather than coincidental. |
| 39 | +// Distinctive = reasonably long and not a bare common verb. |
| 40 | +func distinctiveName(name string) bool { |
| 41 | + n := strings.ToLower(strings.TrimSpace(name)) |
| 42 | + if len(n) < 6 { |
| 43 | + return false |
| 44 | + } |
| 45 | + if _, common := commonNames[n]; common { |
| 46 | + return false |
| 47 | + } |
| 48 | + return true |
| 49 | +} |
| 50 | + |
| 51 | +// Inspect implements detect.Check. Cross-tool reasoning uses the RegistryView |
| 52 | +// indexes built once per scan. |
| 53 | +func (c *Shadowing) Inspect(tool detect.ToolView, reg detect.RegistryView) []detect.Signal { |
| 54 | + if !distinctiveName(tool.Name) { |
| 55 | + // Still allow this tool to reference OTHER distinctive tools, so only |
| 56 | + // the collision branch is gated on the tool's own name. |
| 57 | + return c.referenceSignals(tool, reg) |
| 58 | + } |
| 59 | + |
| 60 | + var sigs []detect.Signal |
| 61 | + |
| 62 | + // 1. Name collision across servers. |
| 63 | + for _, other := range reg.ToolsByName[tool.Name] { |
| 64 | + if other.Server != tool.Server { |
| 65 | + sigs = append(sigs, detect.Signal{ |
| 66 | + CheckID: c.ID(), |
| 67 | + Tier: detect.TierHard, |
| 68 | + ThreatType: detect.ThreatToolPoisoning, |
| 69 | + Confidence: 0.85, |
| 70 | + Evidence: detect.CapEvidence(fmt.Sprintf("tool %q also exposed by server %q", tool.Name, other.Server)), |
| 71 | + Detail: fmt.Sprintf("Distinctive tool name %q collides with server %q — possible impersonation.", tool.Name, other.Server), |
| 72 | + }) |
| 73 | + break // one collision signal is enough |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + sigs = append(sigs, c.referenceSignals(tool, reg)...) |
| 78 | + return sigs |
| 79 | +} |
| 80 | + |
| 81 | +// wordRe extracts identifier-like tokens (incl. snake_case / camelCase words) |
| 82 | +// from a description for reference matching. |
| 83 | +var wordRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_]{5,}`) |
| 84 | + |
| 85 | +// referenceSignals flags a description that names a distinctive tool living on a |
| 86 | +// different server. A reference to the tool's own name is ignored. |
| 87 | +func (c *Shadowing) referenceSignals(tool detect.ToolView, reg detect.RegistryView) []detect.Signal { |
| 88 | + tokens := wordRe.FindAllString(tool.Description, -1) |
| 89 | + seen := make(map[string]struct{}) |
| 90 | + var sigs []detect.Signal |
| 91 | + for _, tok := range tokens { |
| 92 | + if tok == tool.Name { |
| 93 | + continue // self-reference |
| 94 | + } |
| 95 | + if _, dup := seen[tok]; dup { |
| 96 | + continue |
| 97 | + } |
| 98 | + owners, ok := reg.ToolsByName[tok] |
| 99 | + if !ok || !distinctiveName(tok) { |
| 100 | + continue |
| 101 | + } |
| 102 | + // Only flag when the referenced tool lives on a DIFFERENT server. |
| 103 | + onOtherServer := false |
| 104 | + for _, o := range owners { |
| 105 | + if o.Server != tool.Server { |
| 106 | + onOtherServer = true |
| 107 | + break |
| 108 | + } |
| 109 | + } |
| 110 | + if !onOtherServer { |
| 111 | + continue |
| 112 | + } |
| 113 | + seen[tok] = struct{}{} |
| 114 | + sigs = append(sigs, detect.Signal{ |
| 115 | + CheckID: c.ID(), |
| 116 | + Tier: detect.TierHard, |
| 117 | + ThreatType: detect.ThreatToolPoisoning, |
| 118 | + Confidence: 0.85, |
| 119 | + Evidence: detect.CapEvidence(fmt.Sprintf("description references cross-server tool %q", tok)), |
| 120 | + Detail: fmt.Sprintf("Tool %q description steers the agent toward another server's tool %q.", tool.Name, tok), |
| 121 | + }) |
| 122 | + } |
| 123 | + return sigs |
| 124 | +} |
0 commit comments