Skip to content
Merged
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
16 changes: 13 additions & 3 deletions src/skillspector/nodes/analyzers/mcp_rug_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,16 @@ def _get_parameters_map(
# ---------------------------------------------------------------------------


def _operand_has_version_pin(line_remainder: str) -> bool:
"""Return whether a version pin is attached to the matched package operand.

Only the operand's own token counts, so a version on a later argument or on a
neighboring command on the same line does not pin this package.
"""
operand_suffix = re.split(r"\s", line_remainder, maxsplit=1)[0]
return _VERSION_PIN_RE.search(operand_suffix) is not None


def _check_rp1(
manifest: dict,
file_cache: dict[str, str],
Expand All @@ -223,7 +233,7 @@ def _check_rp1(
if line_end == -1:
line_end = len(content)
line_remainder = content[m.end() : min(line_end, m.end() + 256)]
if _VERSION_PIN_RE.search(full_match) or _VERSION_PIN_RE.search(line_remainder):
if _VERSION_PIN_RE.search(full_match) or _operand_has_version_pin(line_remainder):
continue
line_num = _find_line(content, m.start())
budget.emit(
Expand Down Expand Up @@ -258,7 +268,7 @@ def _check_rp1(
if line_end == -1:
line_end = len(content)
line_remainder = content[m.end() : min(line_end, m.end() + 256)]
if _VERSION_PIN_RE.search(full_match) or _VERSION_PIN_RE.search(line_remainder):
if _VERSION_PIN_RE.search(full_match) or _operand_has_version_pin(line_remainder):
continue
line_num = _find_line(content, m.start())
budget.emit(
Expand Down Expand Up @@ -291,7 +301,7 @@ def _check_rp1(
if line_end == -1:
line_end = len(content)
line_remainder = content[m.end() : min(line_end, m.end() + 256)]
if _VERSION_PIN_RE.search(full_match) or _VERSION_PIN_RE.search(line_remainder):
if _VERSION_PIN_RE.search(full_match) or _operand_has_version_pin(line_remainder):
continue
pkg = m.group(1)
if "mcp" not in pkg.lower():
Expand Down
26 changes: 26 additions & 0 deletions tests/test_mcp_rug_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ def test_rp1_npx_pinned_no_finding():
assert len(rp1) == 0


def test_rp1_unrelated_version_pin_does_not_suppress():
"""A pin on another argument or command on the same line does not pin the package."""
for content, expected in (
("npx evil-package --label helper@1.2.3\n", "npx evil-package"),
("npx @scope/mcp-server http://localhost:3000/sse\n", "npx @scope/mcp-server"),
("npx @scope/server-a && npx @scope/server-b@1.2.3\n", "npx @scope/server-a"),
("uvx my-mcp-server --with helper==1.2.3\n", "uvx my-mcp-server"),
("pip install my-mcp-server other-package==1.2.3\n", "pip install my-mcp-server"),
):
result = node(_state(file_cache={"setup.sh": content}))
rp1 = [f for f in result["findings"] if f.rule_id == "RP1"]
assert [f.matched_text for f in rp1] == [expected], content


def test_rp1_version_pin_attached_to_package_no_finding():
"""A pin attached to the package operand still counts when other arguments follow."""
for content in (
"npx -y @scope/mcp-server@1.2.3 --label helper\n",
"npx -p @scope/mcp-server@1.2.3 mcp-server\n",
"uvx my-mcp-server==1.2.3 --host 127.0.0.1:8000\n",
"pip install my-mcp-server[cli]==1.2.3\n",
):
result = node(_state(file_cache={"setup.sh": content}))
assert not [f for f in result["findings"] if f.rule_id == "RP1"], content


def test_rp1_uvx_unpinned():
"""RP1 detects uvx without ==version."""
result = node(
Expand Down
Loading