mcp-capdiff 0.1.1__tar.gz → 0.1.2__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.1 → mcp_capdiff-0.1.2}/CHANGELOG.md +16 -0
- {mcp_capdiff-0.1.1/src/mcp_capdiff.egg-info → mcp_capdiff-0.1.2}/PKG-INFO +1 -1
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/precision-matrix.md +2 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/releasing.md +1 -1
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/versioning.md +1 -1
- mcp_capdiff-0.1.2/fixtures/filesystem_helper_restricted/server.py +32 -0
- mcp_capdiff-0.1.2/fixtures/local_filesystem_operations/server.py +31 -0
- mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/__init__.py +1 -0
- mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/filesystem.py +23 -0
- mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/state.py +3 -0
- mcp_capdiff-0.1.2/fixtures/programmatic_registration/server.py +10 -0
- mcp_capdiff-0.1.2/fixtures/sqlite_read_only/server.py +18 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/__init__.py +1 -1
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/rules/engine.py +7 -6
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/source/python.py +299 -25
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2/src/mcp_capdiff.egg-info}/PKG-INFO +1 -1
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/SOURCES.txt +8 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/scan.json +1 -1
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/scan.sarif.json +1 -1
- mcp_capdiff-0.1.2/tests/test_real_world_regressions.py +52 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_reporters.py +2 -2
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/LICENSE +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/MANIFEST.in +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/README.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/SECURITY.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/action.yml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/policy.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/report-schema.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP001.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP002.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP003.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP004.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP005.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP007.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP010.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP016.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP017.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/README.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/v0.1-scope.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/.github/workflows/mcp-audit.yml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/README.md +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/mcp-audit.yaml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/pyproject.toml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/approval_removed/after.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/approval_removed/before.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/after.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/before.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/destructive_with_approval/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/destructive_without_approval/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/fastmcp_async_annotations/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_guard_after_read/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_restricted/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_unrestricted/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/postgresql_read_only/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/safe_server/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sensitive_read_only/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sensitive_to_network/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_direct/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_dynamic_no_shell/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_safe_allowlist/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sql_destructive/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_allowlisted/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_arbitrary/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_guard_after_request/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_hostname_unguarded/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/vulnerable_server/server.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/mcp-audit.yaml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/pyproject.toml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/setup.cfg +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/__main__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/diff.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/scan.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/cli/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/cli/main.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/capability.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/finding.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/report.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/evaluator.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/loader.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/json.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/manifest.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/sarif.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/terminal.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/rules/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/source/__init__.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/dependency_links.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/entry_points.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/requires.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/top_level.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/diff.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/manifest.yaml +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/terminal.txt +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_demo_scenarios.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_diff.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_discovery.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_golden.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_policy.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_rule_accuracy.py +0 -0
- {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_scan.py +0 -0
|
@@ -4,6 +4,22 @@ All notable changes follow Keep a Changelog. Releases use semantic versioning fo
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.2] - 2026-10-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Discover tools registered with `mcp.tool()(function)`, including functions imported from scanned project modules; unresolved static registrations now fail closed.
|
|
12
|
+
- Require a concrete outbound network destination before MCP005 can classify a tool as an exfiltration sink.
|
|
13
|
+
- Propagate approved-root guarantees from path-validation helpers such as `_resolve(path)` to later filesystem operations.
|
|
14
|
+
- Classify `open(..., "w")`, directory creation, and local filesystem mutations as local writes instead of reads or external writes.
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
|
|
18
|
+
- `python-fastmcp-server`: 3 programmatically registered tools discovered instead of a zero-tool result.
|
|
19
|
+
- `sqlite-explorer-fastmcp-mcp-server`: 3 read-only tools, 0 side-effect or MCP005 findings.
|
|
20
|
+
- `obsidian-mcp`: helper-scoped filesystem tools recognized as allowlisted; only 3 findings remain.
|
|
21
|
+
- `smart_terminal_mcp`: critical execution findings preserved, write-mode evidence corrected, and no MCP005 paths remain.
|
|
22
|
+
|
|
7
23
|
## [0.1.1] - 2026-10-01
|
|
8
24
|
|
|
9
25
|
### Added
|
|
@@ -16,3 +16,5 @@ MCP004, MCP007, and MCP010 also have paired approved/bounded and unapproved/unbo
|
|
|
16
16
|
`postgresql_read_only` protects the semantic classifier from matching `post` inside `PostgreSQL`; `sql_destructive` proves that actual `DELETE`/`DROP` semantics still produce MCP004 and MCP010 with relevant evidence.
|
|
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
|
+
|
|
20
|
+
Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes, and local directory creation. MCP005 requires an actual outbound network destination; local writes and semantic-only action names cannot become exfiltration sinks.
|
|
@@ -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.2`.
|
|
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.2` | 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,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ROOT = Path("vault").resolve()
|
|
7
|
+
mcp = FastMCP("filesystem-helper-restricted")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve(relative_path: str) -> Path:
|
|
11
|
+
candidate = (ROOT / relative_path).resolve()
|
|
12
|
+
try:
|
|
13
|
+
candidate.relative_to(ROOT)
|
|
14
|
+
except ValueError as exc:
|
|
15
|
+
raise ValueError("path escapes the approved root") from exc
|
|
16
|
+
return candidate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool()
|
|
20
|
+
def read_note(path: str) -> str:
|
|
21
|
+
return _resolve(path).read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def search_content(folder: str) -> list[str]:
|
|
26
|
+
base = _resolve(folder)
|
|
27
|
+
return [path.read_text(encoding="utf-8") for path in base.rglob("*.md")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def delete_note(path: str) -> None:
|
|
32
|
+
_resolve(path).unlink()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("local-filesystem-operations")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@mcp.tool()
|
|
10
|
+
def read_file(file: str) -> str:
|
|
11
|
+
with open(file, "r") as handle:
|
|
12
|
+
return handle.read()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
def write_file(file: str, content: str) -> str:
|
|
17
|
+
with open(file, "w") as handle:
|
|
18
|
+
handle.write(content)
|
|
19
|
+
return file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def create_folder(folder: str) -> str:
|
|
24
|
+
os.makedirs(folder)
|
|
25
|
+
return folder
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@mcp.tool()
|
|
29
|
+
def send_message(message: str) -> str:
|
|
30
|
+
"""Prepare a message locally without any network destination."""
|
|
31
|
+
return message
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Functions registered as MCP tools by the fixture server."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ALLOWED_ROOTS = [str(Path("data").resolve())]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _validate_path(path: str) -> str:
|
|
10
|
+
resolved = os.path.realpath(path)
|
|
11
|
+
for root in ALLOWED_ROOTS:
|
|
12
|
+
prefix = root if root.endswith(os.sep) else root + os.sep
|
|
13
|
+
if resolved == root or resolved.startswith(prefix):
|
|
14
|
+
return resolved
|
|
15
|
+
raise ValueError("path is outside allowed roots")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_write_file(path: str, operation: Literal["read", "write"], content: str = "") -> str:
|
|
19
|
+
resolved = _validate_path(path)
|
|
20
|
+
if operation == "read":
|
|
21
|
+
return Path(resolved).read_text(encoding="utf-8")
|
|
22
|
+
Path(resolved).write_text(content, encoding="utf-8")
|
|
23
|
+
return resolved
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
mcp = FastMCP("sqlite-read-only")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
def read_query(query: str) -> list[dict]:
|
|
9
|
+
"""Execute a read-only query on the Messages database."""
|
|
10
|
+
cursor.execute(query)
|
|
11
|
+
return cursor.fetchall()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
def list_tables() -> list[str]:
|
|
16
|
+
"""List tables from the Messages database."""
|
|
17
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
18
|
+
return [row[0] for row in cursor.fetchall()]
|
|
@@ -115,11 +115,7 @@ def _tool_findings(tool: Tool) -> list[Finding]:
|
|
|
115
115
|
def _cross_tool_findings(tools: list[Tool]) -> list[Finding]:
|
|
116
116
|
findings: list[Finding] = []
|
|
117
117
|
sensitive_sources = [tool for tool in tools if tool.capability.data_access and tool.capability.sensitivity != "public"]
|
|
118
|
-
external_sinks = [
|
|
119
|
-
tool
|
|
120
|
-
for tool in tools
|
|
121
|
-
if tool.capability.network == "unrestricted_outbound" or tool.capability.side_effect in {"external_write", "public_action"}
|
|
122
|
-
]
|
|
118
|
+
external_sinks = [tool for tool in tools if _has_external_destination(tool)]
|
|
123
119
|
for source in sensitive_sources:
|
|
124
120
|
for sink in external_sinks:
|
|
125
121
|
if source.name == sink.name or source.context != sink.context:
|
|
@@ -152,12 +148,17 @@ def _cross_tool_findings(tools: list[Tool]) -> list[Finding]:
|
|
|
152
148
|
],
|
|
153
149
|
path=[source.name, "agent_context", sink.name],
|
|
154
150
|
data_classification=data_classes,
|
|
155
|
-
destination=sink.capability.network_destination
|
|
151
|
+
destination=sink.capability.network_destination,
|
|
156
152
|
)
|
|
157
153
|
)
|
|
158
154
|
return findings
|
|
159
155
|
|
|
160
156
|
|
|
157
|
+
def _has_external_destination(tool: Tool) -> bool:
|
|
158
|
+
destination = tool.capability.network_destination
|
|
159
|
+
return tool.capability.network != "none" and bool(destination and destination != "none")
|
|
160
|
+
|
|
161
|
+
|
|
161
162
|
def _evidence(tool: Tool, kinds: set[str]) -> list[Evidence]:
|
|
162
163
|
return [item for item in tool.evidence if item.kind in kinds]
|
|
163
164
|
|
|
@@ -25,25 +25,76 @@ NETWORK_WRITES = {
|
|
|
25
25
|
"httpx.post", "httpx.put", "httpx.patch", "httpx.delete",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
type FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
|
|
29
|
+
type SourceUnit = tuple[str, ast.Module, str, set[str], dict[str, FunctionNode]]
|
|
30
|
+
|
|
28
31
|
|
|
29
32
|
def scan_python_sources(root: Path) -> list[Tool]:
|
|
30
33
|
files = _python_files(root)
|
|
31
|
-
|
|
34
|
+
units: dict[Path, SourceUnit] = {}
|
|
35
|
+
module_index: dict[str, Path] = {}
|
|
36
|
+
|
|
32
37
|
for file_path in files:
|
|
33
|
-
|
|
34
|
-
source_text = file_path.read_text(encoding="utf-8")
|
|
35
|
-
tree = ast.parse(source_text, filename=str(file_path))
|
|
36
|
-
except SyntaxError as exc:
|
|
37
|
-
raise ValueError(f"cannot parse Python source {file_path}:{exc.lineno}: {exc.msg}") from exc
|
|
38
|
-
except UnicodeDecodeError as exc:
|
|
39
|
-
raise ValueError(f"Python source is not UTF-8: {file_path}") from exc
|
|
40
|
-
except OSError as exc:
|
|
41
|
-
raise OSError(exc.errno, f"cannot read Python source {file_path}: {exc.strerror}", str(file_path)) from exc
|
|
38
|
+
source_text, tree = _parse_source(file_path)
|
|
42
39
|
context_path = file_path.name if root.is_file() else str(file_path.relative_to(root))
|
|
43
|
-
|
|
40
|
+
functions = {
|
|
41
|
+
node.name: node
|
|
42
|
+
for node in tree.body
|
|
43
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
44
|
+
}
|
|
45
|
+
units[file_path] = (source_text, tree, context_path, _path_guard_helpers(tree), functions)
|
|
46
|
+
for module_name in _module_names(root, file_path):
|
|
47
|
+
module_index[module_name] = file_path
|
|
48
|
+
|
|
49
|
+
tools: list[Tool] = []
|
|
50
|
+
for file_path, (source_text, tree, context_path, path_helpers, _) in units.items():
|
|
51
|
+
visitor = FastMCPVisitor(file_path, source_text, context_path, path_helpers)
|
|
44
52
|
visitor.visit(tree)
|
|
45
53
|
tools.extend(visitor.tools)
|
|
46
|
-
|
|
54
|
+
|
|
55
|
+
for file_path, unit in units.items():
|
|
56
|
+
source_text, tree, context_path, _, _ = unit
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if not isinstance(node, ast.Call):
|
|
59
|
+
continue
|
|
60
|
+
registration = _tool_registration(node)
|
|
61
|
+
if registration is None:
|
|
62
|
+
continue
|
|
63
|
+
decorator, target = registration
|
|
64
|
+
resolved = _resolve_registered_function(target, file_path, units, module_index, root)
|
|
65
|
+
if resolved is None:
|
|
66
|
+
snippet = ast.get_source_segment(source_text, node) or ast.unparse(node)
|
|
67
|
+
raise ValueError(f"cannot resolve registered MCP tool in {file_path}:{node.lineno}: {snippet}")
|
|
68
|
+
target_file, function = resolved
|
|
69
|
+
target_source, _, target_context, target_helpers, _ = units[target_file]
|
|
70
|
+
tools.append(
|
|
71
|
+
_tool_from_function(
|
|
72
|
+
target_file,
|
|
73
|
+
target_source,
|
|
74
|
+
target_context,
|
|
75
|
+
function,
|
|
76
|
+
path_helpers=target_helpers,
|
|
77
|
+
registered_decorator=decorator,
|
|
78
|
+
registered_context=_registered_tool_context(context_path, decorator),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
unique: dict[tuple[str, str, str], Tool] = {}
|
|
83
|
+
for tool in tools:
|
|
84
|
+
unique[(tool.source, tool.context, tool.name)] = tool
|
|
85
|
+
return list(unique.values())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_source(file_path: Path) -> tuple[str, ast.Module]:
|
|
89
|
+
try:
|
|
90
|
+
source_text = file_path.read_text(encoding="utf-8")
|
|
91
|
+
return source_text, ast.parse(source_text, filename=str(file_path))
|
|
92
|
+
except SyntaxError as exc:
|
|
93
|
+
raise ValueError(f"cannot parse Python source {file_path}:{exc.lineno}: {exc.msg}") from exc
|
|
94
|
+
except UnicodeDecodeError as exc:
|
|
95
|
+
raise ValueError(f"Python source is not UTF-8: {file_path}") from exc
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
raise OSError(exc.errno, f"cannot read Python source {file_path}: {exc.strerror}", str(file_path)) from exc
|
|
47
98
|
|
|
48
99
|
|
|
49
100
|
def _python_files(root: Path) -> list[Path]:
|
|
@@ -73,16 +124,138 @@ def _python_files(root: Path) -> list[Path]:
|
|
|
73
124
|
return sorted(files)
|
|
74
125
|
|
|
75
126
|
|
|
127
|
+
def _module_names(root: Path, file_path: Path) -> set[str]:
|
|
128
|
+
relative = Path(file_path.name) if root.is_file() else file_path.relative_to(root)
|
|
129
|
+
parts = list(relative.with_suffix("").parts)
|
|
130
|
+
if parts and parts[-1] == "__init__":
|
|
131
|
+
parts.pop()
|
|
132
|
+
if not parts:
|
|
133
|
+
return set()
|
|
134
|
+
names = {".".join(parts)}
|
|
135
|
+
if parts[0] in {"src", "lib"} and len(parts) > 1:
|
|
136
|
+
names.add(".".join(parts[1:]))
|
|
137
|
+
return names
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tool_registration(node: ast.Call) -> tuple[ast.Call, ast.AST] | None:
|
|
141
|
+
if not isinstance(node.func, ast.Call) or not node.args:
|
|
142
|
+
return None
|
|
143
|
+
decorator = node.func
|
|
144
|
+
target = decorator.func
|
|
145
|
+
if isinstance(target, ast.Attribute) and target.attr == "tool":
|
|
146
|
+
return decorator, node.args[0]
|
|
147
|
+
if isinstance(target, ast.Name) and target.id in {"tool", "mcp_tool"}:
|
|
148
|
+
return decorator, node.args[0]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_registered_function(
|
|
153
|
+
target: ast.AST,
|
|
154
|
+
registration_file: Path,
|
|
155
|
+
units: dict[Path, SourceUnit],
|
|
156
|
+
module_index: dict[str, Path],
|
|
157
|
+
root: Path,
|
|
158
|
+
) -> tuple[Path, FunctionNode] | None:
|
|
159
|
+
_, tree, _, _, local_functions = units[registration_file]
|
|
160
|
+
if isinstance(target, ast.Name) and target.id in local_functions:
|
|
161
|
+
return registration_file, local_functions[target.id]
|
|
162
|
+
|
|
163
|
+
current_modules = _module_names(root, registration_file)
|
|
164
|
+
current_module = min(current_modules, key=lambda value: (value.count("."), len(value)), default="")
|
|
165
|
+
imported_functions: dict[str, tuple[str, str]] = {}
|
|
166
|
+
module_aliases: dict[str, str] = {}
|
|
167
|
+
for statement in tree.body:
|
|
168
|
+
if isinstance(statement, ast.ImportFrom):
|
|
169
|
+
module_name = _absolute_import_module(current_module, statement.module, statement.level)
|
|
170
|
+
for alias in statement.names:
|
|
171
|
+
imported_functions[alias.asname or alias.name] = (module_name, alias.name)
|
|
172
|
+
elif isinstance(statement, ast.Import):
|
|
173
|
+
for alias in statement.names:
|
|
174
|
+
module_aliases[alias.asname or alias.name.split(".")[0]] = alias.name
|
|
175
|
+
|
|
176
|
+
module_name = ""
|
|
177
|
+
function_name = ""
|
|
178
|
+
if isinstance(target, ast.Name) and target.id in imported_functions:
|
|
179
|
+
module_name, function_name = imported_functions[target.id]
|
|
180
|
+
elif isinstance(target, ast.Attribute):
|
|
181
|
+
qualified = _call_name(target)
|
|
182
|
+
if "." in qualified:
|
|
183
|
+
owner, function_name = qualified.rsplit(".", 1)
|
|
184
|
+
module_name = module_aliases.get(owner, owner)
|
|
185
|
+
if not module_name or not function_name:
|
|
186
|
+
return None
|
|
187
|
+
target_file = module_index.get(module_name)
|
|
188
|
+
if target_file is None:
|
|
189
|
+
suffix_matches = {
|
|
190
|
+
path
|
|
191
|
+
for indexed_name, path in module_index.items()
|
|
192
|
+
if indexed_name.endswith(f".{module_name}")
|
|
193
|
+
}
|
|
194
|
+
if len(suffix_matches) == 1:
|
|
195
|
+
target_file = suffix_matches.pop()
|
|
196
|
+
if target_file is None:
|
|
197
|
+
return None
|
|
198
|
+
function = units[target_file][4].get(function_name)
|
|
199
|
+
return (target_file, function) if function is not None else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _absolute_import_module(current_module: str, imported_module: str | None, level: int) -> str:
|
|
203
|
+
if level == 0:
|
|
204
|
+
return imported_module or ""
|
|
205
|
+
package = current_module.split(".")[:-1]
|
|
206
|
+
keep = max(0, len(package) - (level - 1))
|
|
207
|
+
prefix = package[:keep]
|
|
208
|
+
if imported_module:
|
|
209
|
+
prefix.extend(imported_module.split("."))
|
|
210
|
+
return ".".join(prefix)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _registered_tool_context(context_path: str, decorator: ast.Call) -> str:
|
|
214
|
+
target = decorator.func
|
|
215
|
+
if isinstance(target, ast.Attribute) and target.attr == "tool":
|
|
216
|
+
owner = _call_name(target.value) or "mcp"
|
|
217
|
+
return f"{context_path}:{owner}"
|
|
218
|
+
return context_path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _path_guard_helpers(tree: ast.Module) -> set[str]:
|
|
222
|
+
helpers: set[str] = set()
|
|
223
|
+
for node in tree.body:
|
|
224
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
225
|
+
continue
|
|
226
|
+
text = ast.unparse(node).lower()
|
|
227
|
+
has_resolution = any(token in text for token in {".resolve(", "realpath(", "abspath("})
|
|
228
|
+
has_boundary_check = any(token in text for token in {"relative_to(", "is_relative_to("}) or (
|
|
229
|
+
".startswith(" in text
|
|
230
|
+
and any(token in text for token in {"root", "vault", "base", "directory", "workspace", "allowed"})
|
|
231
|
+
)
|
|
232
|
+
has_rejection = any(isinstance(child, ast.Raise) for child in ast.walk(node)) or any(
|
|
233
|
+
token in text for token in {"outside allowed", "path_violation", "path escapes", "return none"}
|
|
234
|
+
)
|
|
235
|
+
if has_resolution and has_boundary_check and has_rejection:
|
|
236
|
+
helpers.add(node.name)
|
|
237
|
+
return helpers
|
|
238
|
+
|
|
239
|
+
|
|
76
240
|
class FastMCPVisitor(ast.NodeVisitor):
|
|
77
|
-
def __init__(self, file_path: Path, source_text: str, context_path: str) -> None:
|
|
241
|
+
def __init__(self, file_path: Path, source_text: str, context_path: str, path_helpers: set[str]) -> None:
|
|
78
242
|
self.file_path = file_path
|
|
79
243
|
self.source_text = source_text
|
|
80
244
|
self.context_path = context_path
|
|
245
|
+
self.path_helpers = path_helpers
|
|
81
246
|
self.tools: list[Tool] = []
|
|
82
247
|
|
|
83
248
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
84
249
|
if _is_tool_function(node):
|
|
85
|
-
self.tools.append(
|
|
250
|
+
self.tools.append(
|
|
251
|
+
_tool_from_function(
|
|
252
|
+
self.file_path,
|
|
253
|
+
self.source_text,
|
|
254
|
+
self.context_path,
|
|
255
|
+
node,
|
|
256
|
+
path_helpers=self.path_helpers,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
86
259
|
self.generic_visit(node)
|
|
87
260
|
|
|
88
261
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
@@ -103,11 +276,15 @@ def _tool_from_function(
|
|
|
103
276
|
source_text: str,
|
|
104
277
|
context_path: str,
|
|
105
278
|
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
279
|
+
*,
|
|
280
|
+
path_helpers: set[str] | None = None,
|
|
281
|
+
registered_decorator: ast.Call | None = None,
|
|
282
|
+
registered_context: str | None = None,
|
|
106
283
|
) -> Tool:
|
|
107
|
-
name = _tool_name(node)
|
|
284
|
+
name = _tool_name(node, registered_decorator)
|
|
108
285
|
description = ast.get_docstring(node) or ""
|
|
109
286
|
parameters = [_parameter(arg, node) for arg in node.args.args if arg.arg not in {"self", "cls"}]
|
|
110
|
-
analyzer = FunctionAnalyzer(file_path, source_text, parameters)
|
|
287
|
+
analyzer = FunctionAnalyzer(file_path, source_text, parameters, path_helpers or set())
|
|
111
288
|
analyzer.visit(node)
|
|
112
289
|
capability = Capability()
|
|
113
290
|
lower_text = f"{name.lower()} {description.lower()}"
|
|
@@ -132,7 +309,7 @@ def _tool_from_function(
|
|
|
132
309
|
capability.network_destination = analyzer.network_destination
|
|
133
310
|
if analyzer.network_write:
|
|
134
311
|
capability.side_effect = "external_write"
|
|
135
|
-
if _looks_external_write(
|
|
312
|
+
if _looks_external_write(name):
|
|
136
313
|
capability.side_effect = "external_write"
|
|
137
314
|
analyzer.evidence.append(
|
|
138
315
|
Evidence(
|
|
@@ -168,7 +345,7 @@ def _tool_from_function(
|
|
|
168
345
|
return Tool(
|
|
169
346
|
name=name,
|
|
170
347
|
source=f"{file_path}:{node.lineno}",
|
|
171
|
-
context=_tool_context(context_path, node),
|
|
348
|
+
context=registered_context or _tool_context(context_path, node),
|
|
172
349
|
description=description,
|
|
173
350
|
parameters=parameters,
|
|
174
351
|
capability=capability,
|
|
@@ -177,10 +354,17 @@ def _tool_from_function(
|
|
|
177
354
|
|
|
178
355
|
|
|
179
356
|
class FunctionAnalyzer(ast.NodeVisitor):
|
|
180
|
-
def __init__(
|
|
357
|
+
def __init__(
|
|
358
|
+
self,
|
|
359
|
+
file_path: Path,
|
|
360
|
+
source_text: str,
|
|
361
|
+
parameters: list[Parameter],
|
|
362
|
+
path_helpers: set[str],
|
|
363
|
+
) -> None:
|
|
181
364
|
self.file_path = file_path
|
|
182
365
|
self.source_text = source_text
|
|
183
366
|
self.parameter_names = {parameter.name for parameter in parameters}
|
|
367
|
+
self.path_helpers = path_helpers
|
|
184
368
|
self.aliases: dict[str, set[str]] = {}
|
|
185
369
|
self.host_guarded_parameters: set[str] = set()
|
|
186
370
|
self.path_guarded_parameters: set[str] = set()
|
|
@@ -192,6 +376,7 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
192
376
|
self.network_call = False
|
|
193
377
|
self.network_write = False
|
|
194
378
|
self.path_guard_lines: list[int] = []
|
|
379
|
+
self.inline_path_guard_lines: list[int] = []
|
|
195
380
|
self.host_guard_lines: list[int] = []
|
|
196
381
|
self.filesystem_operation_lines: list[int] = []
|
|
197
382
|
self.network_call_lines: list[int] = []
|
|
@@ -206,8 +391,31 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
206
391
|
self.aliases[target.id] = roots
|
|
207
392
|
self.generic_visit(node)
|
|
208
393
|
|
|
394
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
395
|
+
roots = self._parameter_roots(node.value)
|
|
396
|
+
if isinstance(node.target, ast.Name) and roots:
|
|
397
|
+
self.aliases[node.target.id] = roots
|
|
398
|
+
self.generic_visit(node)
|
|
399
|
+
|
|
400
|
+
def visit_For(self, node: ast.For) -> None:
|
|
401
|
+
roots = self._parameter_roots(node.iter)
|
|
402
|
+
if isinstance(node.target, ast.Name) and roots:
|
|
403
|
+
self.aliases[node.target.id] = roots
|
|
404
|
+
self.generic_visit(node)
|
|
405
|
+
|
|
209
406
|
def visit_Call(self, node: ast.Call) -> None:
|
|
210
407
|
call_name = _call_name(node.func)
|
|
408
|
+
if call_name.rsplit(".", 1)[-1] in self.path_helpers:
|
|
409
|
+
roots = set().union(*(self._parameter_roots(argument) for argument in node.args))
|
|
410
|
+
roots.update(
|
|
411
|
+
set().union(*(self._parameter_roots(keyword.value) for keyword in node.keywords))
|
|
412
|
+
if node.keywords
|
|
413
|
+
else set()
|
|
414
|
+
)
|
|
415
|
+
if roots:
|
|
416
|
+
self.path_guard_lines.append(node.lineno)
|
|
417
|
+
self.path_guarded_parameters.update(roots)
|
|
418
|
+
self._record(node, f"Tool input is constrained by approved-root helper {call_name}(...).", "filesystem_guard")
|
|
211
419
|
if call_name in {"eval", "exec", "compile"}:
|
|
212
420
|
self.arbitrary_code = True
|
|
213
421
|
self._record(node, f"Dynamic {call_name}(...) executes model-controlled code.", "execution")
|
|
@@ -218,13 +426,43 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
218
426
|
elif call_name.startswith("subprocess.") and _has_model_controlled_executable(node, self._parameter_roots):
|
|
219
427
|
self.shell_execution = True
|
|
220
428
|
self._record(node, f"{call_name}(...) uses a model-controlled executable or interpreter payload.", "execution")
|
|
221
|
-
|
|
429
|
+
open_access = _file_open_access(node, call_name)
|
|
430
|
+
if open_access is not None:
|
|
431
|
+
reads, writes = open_access
|
|
432
|
+
if reads:
|
|
433
|
+
self.filesystem_read = True
|
|
434
|
+
self._record(node, "A filesystem read is reachable from this MCP tool.", "filesystem")
|
|
435
|
+
if writes:
|
|
436
|
+
self.filesystem_write = True
|
|
437
|
+
self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
|
|
438
|
+
self.filesystem_operation_lines.append(node.lineno)
|
|
439
|
+
self._mark_inline_path_guard(node)
|
|
440
|
+
if call_name in {"read_text", "read_bytes", "Path.read_text", "Path.read_bytes"} or call_name.endswith((".read_text", ".read_bytes")):
|
|
222
441
|
self.filesystem_read = True
|
|
223
442
|
self.filesystem_operation_lines.append(node.lineno)
|
|
443
|
+
self._mark_inline_path_guard(node)
|
|
224
444
|
self._record(node, "A filesystem read is reachable from this MCP tool.", "filesystem")
|
|
225
445
|
if call_name in {"write_text", "write_bytes", "Path.write_text", "Path.write_bytes"} or call_name.endswith((".write_text", ".write_bytes")):
|
|
226
446
|
self.filesystem_write = True
|
|
227
447
|
self.filesystem_operation_lines.append(node.lineno)
|
|
448
|
+
self._mark_inline_path_guard(node)
|
|
449
|
+
self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
|
|
450
|
+
if call_name in {
|
|
451
|
+
"os.mkdir",
|
|
452
|
+
"os.makedirs",
|
|
453
|
+
"os.remove",
|
|
454
|
+
"os.unlink",
|
|
455
|
+
"os.rename",
|
|
456
|
+
"os.replace",
|
|
457
|
+
"shutil.copy",
|
|
458
|
+
"shutil.copy2",
|
|
459
|
+
"shutil.copyfile",
|
|
460
|
+
"shutil.move",
|
|
461
|
+
"shutil.rmtree",
|
|
462
|
+
} or call_name.endswith((".mkdir", ".unlink", ".rename", ".replace")):
|
|
463
|
+
self.filesystem_write = True
|
|
464
|
+
self.filesystem_operation_lines.append(node.lineno)
|
|
465
|
+
self._mark_inline_path_guard(node)
|
|
228
466
|
self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
|
|
229
467
|
if call_name.endswith((".fetchone", ".fetchall", ".fetchmany")) or _is_select_query(node, call_name):
|
|
230
468
|
self.database_read = True
|
|
@@ -264,7 +502,23 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
264
502
|
|
|
265
503
|
@property
|
|
266
504
|
def path_guard(self) -> bool:
|
|
267
|
-
return
|
|
505
|
+
return bool(self.path_guard_lines and self.filesystem_operation_lines) and all(
|
|
506
|
+
any(guard < operation for guard in self.path_guard_lines)
|
|
507
|
+
or operation in self.inline_path_guard_lines
|
|
508
|
+
for operation in self.filesystem_operation_lines
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _mark_inline_path_guard(self, operation: ast.Call) -> None:
|
|
512
|
+
for child in ast.walk(operation):
|
|
513
|
+
if not isinstance(child, ast.Call) or child is operation:
|
|
514
|
+
continue
|
|
515
|
+
call_name = _call_name(child.func)
|
|
516
|
+
if call_name.rsplit(".", 1)[-1] not in self.path_helpers:
|
|
517
|
+
continue
|
|
518
|
+
roots = set().union(*(self._parameter_roots(argument) for argument in child.args))
|
|
519
|
+
if roots:
|
|
520
|
+
self.inline_path_guard_lines.append(operation.lineno)
|
|
521
|
+
self.path_guarded_parameters.update(roots)
|
|
268
522
|
|
|
269
523
|
def _parameter_roots(self, node: ast.AST | None) -> set[str]:
|
|
270
524
|
if node is None:
|
|
@@ -295,8 +549,9 @@ class FunctionAnalyzer(ast.NodeVisitor):
|
|
|
295
549
|
)
|
|
296
550
|
|
|
297
551
|
|
|
298
|
-
def _tool_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
299
|
-
|
|
552
|
+
def _tool_name(node: ast.FunctionDef | ast.AsyncFunctionDef, registered_decorator: ast.Call | None = None) -> str:
|
|
553
|
+
decorators = ([registered_decorator] if registered_decorator is not None else []) + list(node.decorator_list)
|
|
554
|
+
for decorator in decorators:
|
|
300
555
|
if isinstance(decorator, ast.Call):
|
|
301
556
|
for keyword in decorator.keywords:
|
|
302
557
|
if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
|
|
@@ -339,6 +594,22 @@ def _keyword_value(node: ast.Call, name: str) -> ast.AST | None:
|
|
|
339
594
|
return next((keyword.value for keyword in node.keywords if keyword.arg == name), None)
|
|
340
595
|
|
|
341
596
|
|
|
597
|
+
def _file_open_access(node: ast.Call, call_name: str) -> tuple[bool, bool] | None:
|
|
598
|
+
if call_name != "open" and not call_name.endswith(".open"):
|
|
599
|
+
return None
|
|
600
|
+
mode_index = 1 if call_name == "open" else 0
|
|
601
|
+
mode_node = node.args[mode_index] if len(node.args) > mode_index else _keyword_value(node, "mode")
|
|
602
|
+
if mode_node is None:
|
|
603
|
+
mode = "r"
|
|
604
|
+
elif isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str):
|
|
605
|
+
mode = mode_node.value
|
|
606
|
+
else:
|
|
607
|
+
return True, True
|
|
608
|
+
writes = any(flag in mode for flag in "wax") or "+" in mode
|
|
609
|
+
reads = "r" in mode or "+" in mode or not writes
|
|
610
|
+
return reads, writes
|
|
611
|
+
|
|
612
|
+
|
|
342
613
|
def _has_model_controlled_executable(node: ast.Call, roots_for) -> bool:
|
|
343
614
|
if not node.args:
|
|
344
615
|
return False
|
|
@@ -397,7 +668,7 @@ def _is_host_allowlist_guard(node: ast.AST) -> bool:
|
|
|
397
668
|
def _is_path_root_guard(node: ast.AST) -> bool:
|
|
398
669
|
text = ast.unparse(node).lower()
|
|
399
670
|
return (".parents" in text or "relative_to(" in text or "is_relative_to(" in text) and any(
|
|
400
|
-
token in text for token in {"root", "base", "directory", "workspace"}
|
|
671
|
+
token in text for token in {"root", "vault", "base", "directory", "workspace", "allowed"}
|
|
401
672
|
)
|
|
402
673
|
|
|
403
674
|
|
|
@@ -434,7 +705,10 @@ def _highest_sensitivity(data_classes: set[str]) -> str:
|
|
|
434
705
|
|
|
435
706
|
|
|
436
707
|
def _looks_external_write(text: str) -> bool:
|
|
437
|
-
return bool(
|
|
708
|
+
return bool(
|
|
709
|
+
_semantic_tokens(text)
|
|
710
|
+
& {"send", "email", "slack", "webhook", "publish", "post", "message", "notify", "upload"}
|
|
711
|
+
)
|
|
438
712
|
|
|
439
713
|
|
|
440
714
|
def _looks_destructive(text: str) -> bool:
|
|
@@ -38,9 +38,15 @@ fixtures/destructive_with_approval/server.py
|
|
|
38
38
|
fixtures/destructive_without_approval/server.py
|
|
39
39
|
fixtures/fastmcp_async_annotations/server.py
|
|
40
40
|
fixtures/filesystem_guard_after_read/server.py
|
|
41
|
+
fixtures/filesystem_helper_restricted/server.py
|
|
41
42
|
fixtures/filesystem_restricted/server.py
|
|
42
43
|
fixtures/filesystem_unrestricted/server.py
|
|
44
|
+
fixtures/local_filesystem_operations/server.py
|
|
43
45
|
fixtures/postgresql_read_only/server.py
|
|
46
|
+
fixtures/programmatic_registration/server.py
|
|
47
|
+
fixtures/programmatic_registration/fixture_tools/__init__.py
|
|
48
|
+
fixtures/programmatic_registration/fixture_tools/filesystem.py
|
|
49
|
+
fixtures/programmatic_registration/fixture_tools/state.py
|
|
44
50
|
fixtures/safe_server/server.py
|
|
45
51
|
fixtures/sensitive_read_only/server.py
|
|
46
52
|
fixtures/sensitive_to_network/server.py
|
|
@@ -48,6 +54,7 @@ fixtures/shell_direct/server.py
|
|
|
48
54
|
fixtures/shell_dynamic_no_shell/server.py
|
|
49
55
|
fixtures/shell_safe_allowlist/server.py
|
|
50
56
|
fixtures/sql_destructive/server.py
|
|
57
|
+
fixtures/sqlite_read_only/server.py
|
|
51
58
|
fixtures/url_allowlisted/server.py
|
|
52
59
|
fixtures/url_arbitrary/server.py
|
|
53
60
|
fixtures/url_guard_after_request/server.py
|
|
@@ -88,6 +95,7 @@ tests/test_diff.py
|
|
|
88
95
|
tests/test_discovery.py
|
|
89
96
|
tests/test_golden.py
|
|
90
97
|
tests/test_policy.py
|
|
98
|
+
tests/test_real_world_regressions.py
|
|
91
99
|
tests/test_reporters.py
|
|
92
100
|
tests/test_rule_accuracy.py
|
|
93
101
|
tests/test_scan.py
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from mcp_audit.analysis.scan import scan_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_programmatic_imported_tools_are_discovered() -> None:
|
|
14
|
+
report = scan_path(ROOT / "fixtures/programmatic_registration", require_tools=True)
|
|
15
|
+
assert {tool.name for tool in report.tools} == {"read_write_file", "state"}
|
|
16
|
+
assert {tool.context for tool in report.tools} == {"server.py:mcp"}
|
|
17
|
+
filesystem_tool = next(tool for tool in report.tools if tool.name == "read_write_file")
|
|
18
|
+
assert filesystem_tool.capability.filesystem == "allowlisted_files"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_unresolved_programmatic_registration_fails_closed(tmp_path: Path) -> None:
|
|
22
|
+
(tmp_path / "server.py").write_text(
|
|
23
|
+
"from fastmcp import FastMCP\nmcp = FastMCP()\nmcp.tool()(missing_tool)\n",
|
|
24
|
+
encoding="utf-8",
|
|
25
|
+
)
|
|
26
|
+
with pytest.raises(ValueError, match="cannot resolve registered MCP tool"):
|
|
27
|
+
scan_path(tmp_path, require_tools=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_path_containment_helper_bounds_filesystem_tools() -> None:
|
|
31
|
+
report = scan_path(ROOT / "fixtures/filesystem_helper_restricted")
|
|
32
|
+
assert {tool.capability.filesystem for tool in report.tools} == {"allowlisted_files"}
|
|
33
|
+
assert not {"MCP002", "MCP007"} & {finding.rule_id for finding in report.findings}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_local_writes_are_not_external_sinks() -> None:
|
|
37
|
+
report = scan_path(ROOT / "fixtures/local_filesystem_operations")
|
|
38
|
+
tools = {tool.name: tool for tool in report.tools}
|
|
39
|
+
assert tools["read_file"].capability.side_effect == "none"
|
|
40
|
+
assert tools["write_file"].capability.side_effect == "local_write"
|
|
41
|
+
assert tools["write_file"].capability.data_access == set()
|
|
42
|
+
assert tools["create_folder"].capability.side_effect == "local_write"
|
|
43
|
+
assert tools["send_message"].capability.side_effect == "external_write"
|
|
44
|
+
assert "MCP005" not in {finding.rule_id for finding in report.findings}
|
|
45
|
+
write_evidence = [item.message for item in tools["write_file"].evidence if item.kind == "filesystem"]
|
|
46
|
+
assert write_evidence == ["A filesystem write is reachable from this MCP tool."]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_sqlite_reads_are_not_writes_or_exfiltration_sinks() -> None:
|
|
50
|
+
report = scan_path(ROOT / "fixtures/sqlite_read_only")
|
|
51
|
+
assert all(tool.capability.side_effect == "none" for tool in report.tools)
|
|
52
|
+
assert not {"MCP004", "MCP005"} & {finding.rule_id for finding in report.findings}
|
|
@@ -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.2", "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.2"
|
|
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.1 → mcp_capdiff-0.1.2}/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
|
|
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
|