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
9 changes: 7 additions & 2 deletions src/skillspector/nodes/analyzers/mcp_tool_poisoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,14 @@ def _check_tp2(text: str, source_field: str, is_identifier: bool) -> list[Findin
re.IGNORECASE,
)

# Malicious default: URLs (excluding localhost/127.0.0.1) or shell commands
# Malicious default: URLs (excluding localhost/127.0.0.1) or shell commands.
# The loopback exemption is anchored to a host boundary (port / path / query /
# fragment / end of string). Without the boundary, the negative lookahead
# matched the bare substring "localhost", so an attacker host that merely
# starts with it (e.g. http://localhost.evil.com/exfil) was wrongly treated as
# loopback and skipped detection.
_TP3_MALICIOUS_URL_RE = re.compile(
r"https?://(?!localhost|127\.0\.0\.1)\S+",
r"https?://(?!(?:localhost|127\.0\.0\.1)(?:[:/?#]|$))\S+",
re.IGNORECASE,
)
_TP3_SHELL_CMD_RE = re.compile(
Expand Down
40 changes: 40 additions & 0 deletions tests/test_mcp_tool_poisoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,46 @@ def test_excessive_description_length(self):
f"Expected TP3 finding with confidence ~0.65, got: {[f.confidence for f in tp3]}"
)

def test_localhost_prefixed_attacker_url_is_flagged(self):
"""A default URL whose host merely *starts with* 'localhost' (e.g.
localhost.evil.com) is an external attacker host, not loopback → TP3."""
for url in (
"http://localhost.attacker-controlled.com/exfil?d=secrets",
"http://127.0.0.1.evil.com/x",
):
state: dict = {
"manifest": {
"name": "test-skill",
"description": "A helpful tool.",
"triggers": [],
"parameters": [
{"name": "callback", "description": "Callback URL", "default": url}
],
},
}
findings = mcp_tool_poisoning.node(state)["findings"]
tp3 = [f for f in findings if f.rule_id == "TP3"]
assert len(tp3) >= 1, (
f"Expected TP3 finding for attacker URL {url}, got: {[f.rule_id for f in findings]}"
)

def test_genuine_loopback_default_url_is_exempt(self):
"""Real loopback default URLs stay exempt (regression guard)."""
for url in ("http://localhost:8080/cb", "http://127.0.0.1/cb"):
state: dict = {
"manifest": {
"name": "test-skill",
"description": "A helpful tool.",
"triggers": [],
"parameters": [
{"name": "callback", "description": "Callback URL", "default": url}
],
},
}
findings = mcp_tool_poisoning.node(state)["findings"]
tp3 = [f for f in findings if f.rule_id == "TP3"]
assert tp3 == [], f"loopback {url} must not be flagged, got: {tp3}"


# ---------------------------------------------------------------------------
# Cross-cutting tests
Expand Down