mcp-capdiff 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/CHANGELOG.md +17 -0
- {mcp_capdiff-0.1.2/src/mcp_capdiff.egg-info → mcp_capdiff-0.1.3}/PKG-INFO +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/precision-matrix.md +3 -3
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/releasing.md +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/versioning.md +1 -1
- mcp_capdiff-0.1.3/fixtures/benchmark_semantics/server.py +88 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/__init__.py +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/source/python.py +90 -13
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3/src/mcp_capdiff.egg-info}/PKG-INFO +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/SOURCES.txt +1 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/scan.json +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/scan.sarif.json +1 -1
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_real_world_regressions.py +40 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_reporters.py +2 -2
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/LICENSE +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/MANIFEST.in +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/README.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/SECURITY.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/action.yml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/policy.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/report-schema.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP001.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP002.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP003.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP004.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP005.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP007.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP010.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP016.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/MCP017.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/rules/README.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/docs/v0.1-scope.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/.github/workflows/mcp-audit.yml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/README.md +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/mcp-audit.yaml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/pyproject.toml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/approval_removed/after.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/approval_removed/before.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/after.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/before.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/destructive_with_approval/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/destructive_without_approval/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/fastmcp_async_annotations/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_guard_after_read/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_helper_restricted/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_restricted/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/filesystem_unrestricted/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/local_filesystem_operations/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/postgresql_read_only/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/filesystem.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/state.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/safe_server/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sensitive_read_only/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sensitive_to_network/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_direct/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_dynamic_no_shell/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/shell_safe_allowlist/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sql_destructive/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/sqlite_read_only/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_allowlisted/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_arbitrary/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_guard_after_request/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/url_hostname_unguarded/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/vulnerable_server/server.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/mcp-audit.yaml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/pyproject.toml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/setup.cfg +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/__main__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/diff.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/analysis/scan.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/cli/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/cli/main.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/capability.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/finding.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/models/report.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/evaluator.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/policy/loader.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/json.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/manifest.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/sarif.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/reporters/terminal.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/rules/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/rules/engine.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_audit/scanners/source/__init__.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/dependency_links.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/entry_points.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/requires.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/src/mcp_capdiff.egg-info/top_level.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/diff.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/manifest.yaml +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/golden/terminal.txt +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_demo_scenarios.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_diff.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_discovery.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_golden.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_policy.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_rule_accuracy.py +0 -0
- {mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/tests/test_scan.py +0 -0
|
@@ -4,6 +4,23 @@ All notable changes follow Keep a Changelog. Releases use semantic versioning fo
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.3] - 2026-10-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Stop ordinary string, datetime, and other object `.replace()` calls from being classified as filesystem writes; retain detection for `os.replace(...)` and statically known `Path.replace(...)` calls.
|
|
12
|
+
- Detect model-controlled interactive process execution through `pexpect.spawn(...)` and `pexpect.spawnu(...)`.
|
|
13
|
+
- Require action verbs for semantic external-write inference so nouns such as `email` and `message` do not turn list/read tools into writes.
|
|
14
|
+
- Prefer read-only tool names and explicit negation over incidental destructive words in descriptions while retaining destructive SQL detection for execution tools.
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
|
|
18
|
+
- 71 automated tests, including positive controls for shell execution, filesystem replacement, destructive actions, and external writes.
|
|
19
|
+
- `metabase-mcp` and `mcp_agent_mail`: string and datetime replacement no longer produce MCP002 findings.
|
|
20
|
+
- `interactive-terminal-mcp`: `spawn_process` is classified as shell execution and produces MCP001 evidence at `pexpect.spawn(...)`.
|
|
21
|
+
- `MCP-PostgreSQL-Ops` and `winremote-mcp`: the read-only statistics tool and GUI `Type` tool no longer produce MCP004/MCP010 findings.
|
|
22
|
+
- The 14-repository benchmark covered 888 discovered tools; interprocedural network propagation remains deferred for a later release.
|
|
23
|
+
|
|
7
24
|
## [0.1.2] - 2026-10-01
|
|
8
25
|
|
|
9
26
|
### Fixed
|
|
@@ -4,8 +4,8 @@ The release gate requires positive, negative, guarded, and edge coverage for the
|
|
|
4
4
|
|
|
5
5
|
| Rule | True positive | True negative | Guarded safe | Tricky edge |
|
|
6
6
|
| --- | --- | --- | --- | --- |
|
|
7
|
-
| MCP001 | `shell_direct` | `safe_server` | `shell_safe_allowlist` | `shell_dynamic_no_shell`
|
|
8
|
-
| MCP002 | `filesystem_unrestricted` | `sensitive_read_only` | `filesystem_restricted` | `filesystem_guard_after_read` rejects a late guard |
|
|
7
|
+
| MCP001 | `shell_direct` | `safe_server` | `shell_safe_allowlist` | `shell_dynamic_no_shell` and `benchmark_semantics` detect dynamic subprocess and pexpect execution |
|
|
8
|
+
| MCP002 | `filesystem_unrestricted` | `sensitive_read_only` | `filesystem_restricted` | `filesystem_guard_after_read` rejects a late guard while generic `.replace()` calls remain non-filesystem operations |
|
|
9
9
|
| MCP003 | `url_arbitrary` | fixed URL in demo baseline | `url_allowlisted` | hostname mention and guard-after-request remain findings |
|
|
10
10
|
| MCP005 | `sensitive_to_network` | `sensitive_read_only` | constrained source-only server | root corpus proves independent MCP contexts are isolated |
|
|
11
11
|
| MCP016 | network expansion diff | unchanged diff | allowlist-added demo resolves risk | a new high-impact tool is an escalation |
|
|
@@ -17,4 +17,4 @@ MCP004, MCP007, and MCP010 also have paired approved/bounded and unapproved/unbo
|
|
|
17
17
|
|
|
18
18
|
Discovery is separately benchmarked by `fastmcp_async_annotations`, which must find both plain async `@mcp.tool()` and `@mcp.tool(annotations=ToolAnnotations(...))` decorators. Missing targets, unreadable trees, parse failures, and unexpected zero-tool scans fail closed.
|
|
19
19
|
|
|
20
|
-
Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes,
|
|
20
|
+
Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes, local directory creation, `pexpect.spawn`, typed `Path.replace`, and ordinary string/datetime `.replace()` calls. MCP005 requires an actual outbound network destination; local writes and semantic-only action names cannot become exfiltration sinks. Read/list/status tools and incidental destructive words in documentation are negative cases for side-effect inference.
|
|
@@ -19,7 +19,7 @@ Install the wheel into a clean environment and verify `mcp-audit --version`, a p
|
|
|
19
19
|
|
|
20
20
|
## Publish
|
|
21
21
|
|
|
22
|
-
1. Create and push the matching Git tag, such as `v0.1.
|
|
22
|
+
1. Create and push the matching Git tag, such as `v0.1.3`.
|
|
23
23
|
2. Publish the matching GitHub Release. This automatically runs `release.yml`.
|
|
24
24
|
3. Verify `pipx install mcp-capdiff` in a clean environment.
|
|
25
25
|
4. Run the tagged Action from the demo repository and confirm SARIF appears in Code Scanning.
|
|
@@ -4,7 +4,7 @@ MCP Audit maintains four independent versions:
|
|
|
4
4
|
|
|
5
5
|
| Contract | Current | Change policy |
|
|
6
6
|
| --- | --- | --- |
|
|
7
|
-
| CLI/package | `0.1.
|
|
7
|
+
| CLI/package | `0.1.3` | Semantic versioning; pre-1.0 minor releases may change CLI behavior |
|
|
8
8
|
| Ruleset | `0.1` | Minor adds or materially changes rules; patch corrects implementation without intended semantic change |
|
|
9
9
|
| JSON report schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
|
|
10
10
|
| Manifest schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pexpect
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("benchmark-semantics")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
def list_tables(description: str) -> str:
|
|
14
|
+
"""List database tables without modifying them."""
|
|
15
|
+
return description.replace("|", "\\|")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
def get_message_time() -> str:
|
|
20
|
+
"""Read the current message timestamp."""
|
|
21
|
+
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def spawn_process(command: str) -> str:
|
|
26
|
+
"""Spawn an interactive command."""
|
|
27
|
+
return str(pexpect.spawn(command))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def spawn_unicode_process(command: str) -> str:
|
|
32
|
+
"""Spawn an interactive Unicode command."""
|
|
33
|
+
return str(pexpect.spawnu(command))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def get_vacuum_analyze_stats() -> str:
|
|
38
|
+
"""Retrieve read-only delete and revoke statistics; does not execute destructive operations."""
|
|
39
|
+
return "stats"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool(name="Type")
|
|
43
|
+
def type_text(text: str) -> str:
|
|
44
|
+
"""Type text into the GUI. Clear existing content first with Ctrl+A, Delete."""
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def list_email_tags() -> list[str]:
|
|
50
|
+
"""List email tags without changing them."""
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def delete_comment(comment_id: str) -> str:
|
|
56
|
+
"""Delete a remote comment."""
|
|
57
|
+
return comment_id
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def send_email(message: str) -> str:
|
|
62
|
+
"""Send an email message."""
|
|
63
|
+
return message
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def send_status(status: str) -> str:
|
|
68
|
+
"""Send a status notification."""
|
|
69
|
+
return status
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
def delete_history(history_id: str) -> str:
|
|
74
|
+
"""Delete remote history."""
|
|
75
|
+
return history_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
def replace_path(source: str, destination: str) -> str:
|
|
80
|
+
path = Path(source)
|
|
81
|
+
path.replace(destination)
|
|
82
|
+
return destination
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
def replace_path_with_os(source: str, destination: str) -> str:
|
|
87
|
+
os.replace(source, destination)
|
|
88
|
+
return destination
|
|
@@ -318,7 +318,7 @@ def _tool_from_function(
|
|
|
318
318
|
location=_function_location(file_path, node),
|
|
319
319
|
)
|
|
320
320
|
)
|
|
321
|
-
if _looks_destructive(
|
|
321
|
+
if _looks_destructive(name, description):
|
|
322
322
|
capability.destructive = True
|
|
323
323
|
capability.side_effect = "destructive_action"
|
|
324
324
|
analyzer.evidence.append(
|
|
@@ -364,6 +364,11 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
364
364
|
self.file_path = file_path
|
|
365
365
|
self.source_text = source_text
|
|
366
366
|
self.parameter_names = {parameter.name for parameter in parameters}
|
|
367
|
+
self.path_objects = {
|
|
368
|
+
parameter.name
|
|
369
|
+
for parameter in parameters
|
|
370
|
+
if parameter.annotation and parameter.annotation.rsplit(".", 1)[-1] == "Path"
|
|
371
|
+
}
|
|
367
372
|
self.path_helpers = path_helpers
|
|
368
373
|
self.aliases: dict[str, set[str]] = {}
|
|
369
374
|
self.host_guarded_parameters: set[str] = set()
|
|
@@ -387,14 +392,21 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
387
392
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
388
393
|
roots = self._parameter_roots(node.value)
|
|
389
394
|
for target in node.targets:
|
|
390
|
-
if isinstance(target, ast.Name)
|
|
391
|
-
|
|
395
|
+
if isinstance(target, ast.Name):
|
|
396
|
+
if roots:
|
|
397
|
+
self.aliases[target.id] = roots
|
|
398
|
+
if _is_path_expression(node.value, self.path_objects):
|
|
399
|
+
self.path_objects.add(target.id)
|
|
392
400
|
self.generic_visit(node)
|
|
393
401
|
|
|
394
402
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
395
403
|
roots = self._parameter_roots(node.value)
|
|
396
|
-
if isinstance(node.target, ast.Name)
|
|
397
|
-
|
|
404
|
+
if isinstance(node.target, ast.Name):
|
|
405
|
+
if roots:
|
|
406
|
+
self.aliases[node.target.id] = roots
|
|
407
|
+
annotation = ast.unparse(node.annotation).rsplit(".", 1)[-1]
|
|
408
|
+
if annotation == "Path" or _is_path_expression(node.value, self.path_objects):
|
|
409
|
+
self.path_objects.add(node.target.id)
|
|
398
410
|
self.generic_visit(node)
|
|
399
411
|
|
|
400
412
|
def visit_For(self, node: ast.For) -> None:
|
|
@@ -426,6 +438,11 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
426
438
|
elif call_name.startswith("subprocess.") and _has_model_controlled_executable(node, self._parameter_roots):
|
|
427
439
|
self.shell_execution = True
|
|
428
440
|
self._record(node, f"{call_name}(...) uses a model-controlled executable or interpreter payload.", "execution")
|
|
441
|
+
if call_name in {"pexpect.spawn", "pexpect.spawnu"} and _has_model_controlled_executable(
|
|
442
|
+
node, self._parameter_roots
|
|
443
|
+
):
|
|
444
|
+
self.shell_execution = True
|
|
445
|
+
self._record(node, f"{call_name}(...) starts a model-controlled interactive process.", "execution")
|
|
429
446
|
open_access = _file_open_access(node, call_name)
|
|
430
447
|
if open_access is not None:
|
|
431
448
|
reads, writes = open_access
|
|
@@ -459,7 +476,9 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
459
476
|
"shutil.copyfile",
|
|
460
477
|
"shutil.move",
|
|
461
478
|
"shutil.rmtree",
|
|
462
|
-
} or call_name.endswith((".mkdir", ".unlink", ".rename"
|
|
479
|
+
} or call_name.endswith((".mkdir", ".unlink", ".rename")) or _is_path_replace(
|
|
480
|
+
node, call_name, self.path_objects
|
|
481
|
+
):
|
|
463
482
|
self.filesystem_write = True
|
|
464
483
|
self.filesystem_operation_lines.append(node.lineno)
|
|
465
484
|
self._mark_inline_path_guard(node)
|
|
@@ -610,6 +629,37 @@ def _file_open_access(node: ast.Call, call_name: str) -> tuple[bool, bool] | Non
|
|
|
610
629
|
return reads, writes
|
|
611
630
|
|
|
612
631
|
|
|
632
|
+
def _is_path_expression(node: ast.AST | None, path_objects: set[str]) -> bool:
|
|
633
|
+
if node is None:
|
|
634
|
+
return False
|
|
635
|
+
if isinstance(node, ast.Name):
|
|
636
|
+
return node.id in path_objects
|
|
637
|
+
if isinstance(node, ast.Call):
|
|
638
|
+
call_name = _call_name(node.func)
|
|
639
|
+
if call_name in {"Path", "pathlib.Path"}:
|
|
640
|
+
return True
|
|
641
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in {
|
|
642
|
+
"absolute",
|
|
643
|
+
"expanduser",
|
|
644
|
+
"resolve",
|
|
645
|
+
"with_name",
|
|
646
|
+
"with_stem",
|
|
647
|
+
"with_suffix",
|
|
648
|
+
}:
|
|
649
|
+
return _is_path_expression(node.func.value, path_objects)
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _is_path_replace(node: ast.Call, call_name: str, path_objects: set[str]) -> bool:
|
|
654
|
+
if call_name in {"os.replace", "Path.replace", "pathlib.Path.replace"}:
|
|
655
|
+
return True
|
|
656
|
+
return (
|
|
657
|
+
isinstance(node.func, ast.Attribute)
|
|
658
|
+
and node.func.attr == "replace"
|
|
659
|
+
and _is_path_expression(node.func.value, path_objects)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
613
663
|
def _has_model_controlled_executable(node: ast.Call, roots_for) -> bool:
|
|
614
664
|
if not node.args:
|
|
615
665
|
return False
|
|
@@ -694,7 +744,11 @@ def _function_location(file_path: Path, node: ast.FunctionDef | ast.AsyncFunctio
|
|
|
694
744
|
|
|
695
745
|
|
|
696
746
|
def _semantic_tokens(text: str) -> set[str]:
|
|
697
|
-
return set(
|
|
747
|
+
return set(_semantic_words(text))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _semantic_words(text: str) -> list[str]:
|
|
751
|
+
return re.findall(r"[a-z0-9]+", text.lower())
|
|
698
752
|
|
|
699
753
|
|
|
700
754
|
def _highest_sensitivity(data_classes: set[str]) -> str:
|
|
@@ -705,11 +759,34 @@ def _highest_sensitivity(data_classes: set[str]) -> str:
|
|
|
705
759
|
|
|
706
760
|
|
|
707
761
|
def _looks_external_write(text: str) -> bool:
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
)
|
|
762
|
+
words = _semantic_words(text)
|
|
763
|
+
if words and words[0] in {"get", "list", "read", "search", "show", "monitor"}:
|
|
764
|
+
return False
|
|
765
|
+
return bool(set(words) & {"send", "forward", "publish", "post", "notify", "upload"})
|
|
712
766
|
|
|
713
767
|
|
|
714
|
-
def _looks_destructive(
|
|
715
|
-
|
|
768
|
+
def _looks_destructive(name: str, description: str) -> bool:
|
|
769
|
+
name_words = _semantic_words(name)
|
|
770
|
+
name_tokens = set(name_words)
|
|
771
|
+
if name_words and name_words[0] in {"get", "list", "read", "search", "show", "monitor"}:
|
|
772
|
+
return False
|
|
773
|
+
destructive = {"delete", "drop", "remove", "revoke", "terminate", "destroy"}
|
|
774
|
+
if name_tokens & destructive:
|
|
775
|
+
return True
|
|
776
|
+
normalized_description = " ".join(description.lower().split())
|
|
777
|
+
if any(
|
|
778
|
+
phrase in normalized_description
|
|
779
|
+
for phrase in {"does not execute", "doesn't execute", "read-only", "strictly prohibited", "monitor only"}
|
|
780
|
+
):
|
|
781
|
+
return False
|
|
782
|
+
declares_destructive_action = bool(
|
|
783
|
+
re.match(
|
|
784
|
+
r"^(?:this tool\s+)?(?:delete|deletes|drop|drops|remove|removes|revoke|revokes|terminate|terminates|destroy|destroys)\b",
|
|
785
|
+
normalized_description,
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
execution_tool_describes_destructive_sql = bool(
|
|
789
|
+
name_tokens & {"execute", "run", "apply"}
|
|
790
|
+
and _semantic_tokens(normalized_description) & destructive
|
|
791
|
+
)
|
|
792
|
+
return declares_destructive_action or execution_tool_describes_destructive_sql
|
|
@@ -34,6 +34,7 @@ examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py
|
|
|
34
34
|
examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py
|
|
35
35
|
examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py
|
|
36
36
|
examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py
|
|
37
|
+
fixtures/benchmark_semantics/server.py
|
|
37
38
|
fixtures/destructive_with_approval/server.py
|
|
38
39
|
fixtures/destructive_without_approval/server.py
|
|
39
40
|
fixtures/fastmcp_async_annotations/server.py
|
|
@@ -50,3 +50,43 @@ def test_sqlite_reads_are_not_writes_or_exfiltration_sinks() -> None:
|
|
|
50
50
|
report = scan_path(ROOT / "fixtures/sqlite_read_only")
|
|
51
51
|
assert all(tool.capability.side_effect == "none" for tool in report.tools)
|
|
52
52
|
assert not {"MCP004", "MCP005"} & {finding.rule_id for finding in report.findings}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_generic_replace_calls_are_not_filesystem_writes() -> None:
|
|
56
|
+
report = scan_path(ROOT / "fixtures/benchmark_semantics")
|
|
57
|
+
tools = {tool.name: tool for tool in report.tools}
|
|
58
|
+
assert tools["list_tables"].capability.filesystem == "none"
|
|
59
|
+
assert tools["get_message_time"].capability.filesystem == "none"
|
|
60
|
+
assert tools["replace_path"].capability.side_effect == "local_write"
|
|
61
|
+
assert tools["replace_path_with_os"].capability.side_effect == "local_write"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_pexpect_spawn_is_model_controlled_execution() -> None:
|
|
65
|
+
report = scan_path(ROOT / "fixtures/benchmark_semantics")
|
|
66
|
+
tools = {tool.name: tool for tool in report.tools}
|
|
67
|
+
assert tools["spawn_process"].capability.execution == "shell"
|
|
68
|
+
assert tools["spawn_unicode_process"].capability.execution == "shell"
|
|
69
|
+
findings = {
|
|
70
|
+
item.tool: item for item in report.findings if item.rule_id == "MCP001"
|
|
71
|
+
}
|
|
72
|
+
assert findings["spawn_process"].evidence[0].snippet == "pexpect.spawn(command)"
|
|
73
|
+
assert findings["spawn_unicode_process"].evidence[0].snippet == "pexpect.spawnu(command)"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_read_only_context_suppresses_destructive_lexical_matches() -> None:
|
|
77
|
+
report = scan_path(ROOT / "fixtures/benchmark_semantics")
|
|
78
|
+
destructive_tools = {
|
|
79
|
+
finding.tool for finding in report.findings if finding.rule_id in {"MCP004", "MCP010"}
|
|
80
|
+
}
|
|
81
|
+
assert "get_vacuum_analyze_stats" not in destructive_tools
|
|
82
|
+
assert "Type" not in destructive_tools
|
|
83
|
+
assert "delete_comment" in destructive_tools
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_external_write_inference_requires_an_action_verb() -> None:
|
|
87
|
+
report = scan_path(ROOT / "fixtures/benchmark_semantics")
|
|
88
|
+
tools = {tool.name: tool for tool in report.tools}
|
|
89
|
+
assert tools["list_email_tags"].capability.side_effect == "none"
|
|
90
|
+
assert tools["send_email"].capability.side_effect == "external_write"
|
|
91
|
+
assert tools["send_status"].capability.side_effect == "external_write"
|
|
92
|
+
assert tools["delete_history"].capability.side_effect == "destructive_action"
|
|
@@ -15,7 +15,7 @@ def test_json_schema_has_stable_version_and_structured_evidence() -> None:
|
|
|
15
15
|
payload = scan_path(ROOT / "fixtures/url_arbitrary").as_dict()
|
|
16
16
|
assert payload["schema_version"] == "1.0"
|
|
17
17
|
assert payload["report_type"] == "scan"
|
|
18
|
-
assert payload["versions"] == {"cli": "0.1.
|
|
18
|
+
assert payload["versions"] == {"cli": "0.1.3", "rules": "0.1"}
|
|
19
19
|
evidence = payload["findings"][0]["evidence"][0]
|
|
20
20
|
assert set(evidence) == {"kind", "message", "location", "snippet"}
|
|
21
21
|
assert payload["tools"][0]["context"] == "server.py:mcp"
|
|
@@ -31,7 +31,7 @@ def test_terminal_finding_answers_risk_and_remediation() -> None:
|
|
|
31
31
|
def test_sarif_contains_source_region_snippet_and_help() -> None:
|
|
32
32
|
payload = json.loads(render_sarif(scan_path(ROOT / "fixtures/url_arbitrary")))
|
|
33
33
|
run = payload["runs"][0]
|
|
34
|
-
assert run["tool"]["driver"]["semanticVersion"] == "0.1.
|
|
34
|
+
assert run["tool"]["driver"]["semanticVersion"] == "0.1.3"
|
|
35
35
|
assert run["tool"]["driver"]["properties"]["rulesVersion"] == "0.1"
|
|
36
36
|
result = next(item for item in run["results"] if item["ruleId"] == "MCP003")
|
|
37
37
|
region = result["locations"][0]["physicalLocation"]["region"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/examples/vulnerable-fastmcp/.github/workflows/mcp-audit.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{mcp_capdiff-0.1.2 → mcp_capdiff-0.1.3}/fixtures/programmatic_registration/fixture_tools/state.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|