mcp-riskmap 0.1.2__py3-none-any.whl
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_riskmap/__init__.py +5 -0
- mcp_riskmap/analyzers/__init__.py +1 -0
- mcp_riskmap/analyzers/common.py +35 -0
- mcp_riskmap/analyzers/config.py +124 -0
- mcp_riskmap/analyzers/js_source.py +66 -0
- mcp_riskmap/analyzers/python_source.py +81 -0
- mcp_riskmap/analyzers/repo_hygiene.py +29 -0
- mcp_riskmap/cli.py +87 -0
- mcp_riskmap/models.py +62 -0
- mcp_riskmap/redaction.py +18 -0
- mcp_riskmap/reporters/__init__.py +1 -0
- mcp_riskmap/reporters/json_reporter.py +9 -0
- mcp_riskmap/reporters/markdown.py +28 -0
- mcp_riskmap/reporters/sarif.py +88 -0
- mcp_riskmap/reporters/table.py +33 -0
- mcp_riskmap/rules/__init__.py +1 -0
- mcp_riskmap/rules/registry.py +54 -0
- mcp_riskmap/scanner.py +73 -0
- mcp_riskmap-0.1.2.dist-info/METADATA +221 -0
- mcp_riskmap-0.1.2.dist-info/RECORD +24 -0
- mcp_riskmap-0.1.2.dist-info/WHEEL +5 -0
- mcp_riskmap-0.1.2.dist-info/entry_points.txt +2 -0
- mcp_riskmap-0.1.2.dist-info/licenses/LICENSE +21 -0
- mcp_riskmap-0.1.2.dist-info/top_level.txt +1 -0
mcp_riskmap/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""File analyzers used by the scanner."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SUPPRESSION_RE = re.compile(r"mcp-riskmap:\s*ignore(?:\s+([A-Z0-9_, -]+))?", re.IGNORECASE)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def relative_path(root: Path, path: Path) -> str:
|
|
10
|
+
try:
|
|
11
|
+
return path.relative_to(root).as_posix()
|
|
12
|
+
except ValueError:
|
|
13
|
+
return path.as_posix()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_text(path: Path) -> str:
|
|
17
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_suppressed(lines: list[str], line_index: int, rule_id: str) -> bool:
|
|
21
|
+
comment_lines = [lines[line_index]]
|
|
22
|
+
if line_index > 0:
|
|
23
|
+
comment_lines.append(lines[line_index - 1])
|
|
24
|
+
return any(_suppresses_rule(line, rule_id) for line in comment_lines)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _suppresses_rule(line: str, rule_id: str) -> bool:
|
|
28
|
+
match = SUPPRESSION_RE.search(line)
|
|
29
|
+
if not match:
|
|
30
|
+
return False
|
|
31
|
+
raw_rules = match.group(1)
|
|
32
|
+
if not raw_rules:
|
|
33
|
+
return True
|
|
34
|
+
rules = {rule.strip().upper() for rule in re.split(r"[,\s]+", raw_rules) if rule.strip()}
|
|
35
|
+
return "ALL" in rules or rule_id.upper() in rules
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp_riskmap.analyzers.common import relative_path, read_text
|
|
9
|
+
from mcp_riskmap.models import Finding
|
|
10
|
+
|
|
11
|
+
CONFIG_NAMES = {
|
|
12
|
+
"mcp.json",
|
|
13
|
+
"mcp.config.json",
|
|
14
|
+
"claude_desktop_config.json",
|
|
15
|
+
"settings.json",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
SECRET_KEY_RE = re.compile(r"(api[_-]?key|token|secret|password|credential)", re.IGNORECASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_candidate(path: Path) -> bool:
|
|
22
|
+
return path.name.lower() in CONFIG_NAMES or ".mcp" in path.name.lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyze_config(root: Path, path: Path) -> list[Finding]:
|
|
26
|
+
text = read_text(path)
|
|
27
|
+
if "mcpServers" not in text and "mcp_servers" not in text:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
findings: list[Finding] = []
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(text)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
return findings
|
|
35
|
+
|
|
36
|
+
servers = _server_entries(data)
|
|
37
|
+
for server_name, server in servers:
|
|
38
|
+
command = str(server.get("command", ""))
|
|
39
|
+
args = [str(arg) for arg in server.get("args", [])]
|
|
40
|
+
command_line = " ".join([command, *args]).lower()
|
|
41
|
+
path_text = relative_path(root, path)
|
|
42
|
+
|
|
43
|
+
if _looks_like_shell_wrapper(command, args):
|
|
44
|
+
findings.append(
|
|
45
|
+
Finding(
|
|
46
|
+
rule_id="MCP-CONFIG-SHELL",
|
|
47
|
+
title="MCP server starts through a shell wrapper",
|
|
48
|
+
severity="high",
|
|
49
|
+
message=f"MCP server '{server_name}' starts through a shell-capable command.",
|
|
50
|
+
path=path_text,
|
|
51
|
+
line=_line_for(text, command),
|
|
52
|
+
remediation="Pin the server executable directly and avoid cmd, powershell, bash, or sh wrappers for untrusted configs.",
|
|
53
|
+
evidence=" ".join([command, *args])[:240],
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if "curl " in command_line and ("| sh" in command_line or "| iex" in command_line):
|
|
58
|
+
findings.append(
|
|
59
|
+
Finding(
|
|
60
|
+
rule_id="MCP-CONFIG-REMOTE-INSTALL",
|
|
61
|
+
title="MCP config pipes remote content into an interpreter",
|
|
62
|
+
severity="critical",
|
|
63
|
+
message=f"MCP server '{server_name}' downloads and executes remote content.",
|
|
64
|
+
path=path_text,
|
|
65
|
+
line=_line_for(text, "curl"),
|
|
66
|
+
remediation="Replace remote install pipelines with pinned packages, checksums, or reviewed local scripts.",
|
|
67
|
+
evidence=" ".join([command, *args])[:240],
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
env = server.get("env")
|
|
72
|
+
if isinstance(env, dict):
|
|
73
|
+
secret_keys = sorted(key for key in env if SECRET_KEY_RE.search(str(key)))
|
|
74
|
+
if secret_keys:
|
|
75
|
+
findings.append(
|
|
76
|
+
Finding(
|
|
77
|
+
rule_id="MCP-CONFIG-SECRET-ENV",
|
|
78
|
+
title="MCP config passes secret-like environment variables",
|
|
79
|
+
severity="medium",
|
|
80
|
+
message=f"MCP server '{server_name}' passes secret-like environment keys: {', '.join(secret_keys)}.",
|
|
81
|
+
path=path_text,
|
|
82
|
+
line=_line_for(text, secret_keys[0]),
|
|
83
|
+
remediation="Pass only the minimum required environment variables and document why each secret is needed.",
|
|
84
|
+
evidence=", ".join(secret_keys),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if command.lower() == "npx" and any(arg == "-y" for arg in args):
|
|
89
|
+
findings.append(
|
|
90
|
+
Finding(
|
|
91
|
+
rule_id="MCP-CONFIG-NPX-LATEST",
|
|
92
|
+
title="MCP config allows non-interactive npx package execution",
|
|
93
|
+
severity="medium",
|
|
94
|
+
message=f"MCP server '{server_name}' runs npx with automatic yes.",
|
|
95
|
+
path=path_text,
|
|
96
|
+
line=_line_for(text, "npx"),
|
|
97
|
+
remediation="Pin package versions and review the resolved package before using npx -y in shared configs.",
|
|
98
|
+
evidence=" ".join([command, *args])[:240],
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return findings
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _server_entries(data: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
106
|
+
servers = data.get("mcpServers") or data.get("mcp_servers") or {}
|
|
107
|
+
if not isinstance(servers, dict):
|
|
108
|
+
return []
|
|
109
|
+
return [(str(name), server) for name, server in servers.items() if isinstance(server, dict)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _looks_like_shell_wrapper(command: str, args: list[str]) -> bool:
|
|
113
|
+
shell_commands = {"cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "bash", "sh"}
|
|
114
|
+
if Path(command).name.lower() in shell_commands:
|
|
115
|
+
return True
|
|
116
|
+
shell_flags = {"/c", "-c", "-command", "/command"}
|
|
117
|
+
return any(arg.lower() in shell_flags for arg in args)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _line_for(text: str, needle: str) -> int:
|
|
121
|
+
for index, line in enumerate(text.splitlines(), start=1):
|
|
122
|
+
if needle and needle in line:
|
|
123
|
+
return index
|
|
124
|
+
return 1
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mcp_riskmap.analyzers.common import is_suppressed, relative_path, read_text
|
|
6
|
+
from mcp_riskmap.models import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def analyze_javascript(root: Path, path: Path) -> list[Finding]:
|
|
10
|
+
text = read_text(path)
|
|
11
|
+
rel = relative_path(root, path)
|
|
12
|
+
child_process_context = "child_process" in text or "node:child_process" in text
|
|
13
|
+
findings: list[Finding] = []
|
|
14
|
+
|
|
15
|
+
lines = text.splitlines()
|
|
16
|
+
for line_index, line in enumerate(lines):
|
|
17
|
+
line_number = line_index + 1
|
|
18
|
+
stripped = line.strip()
|
|
19
|
+
if not stripped or stripped.startswith("//"):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
# mcp-riskmap: ignore PY-EVAL-EXEC
|
|
23
|
+
if child_process_context and ("exec(" in stripped or ".exec(" in stripped) and not is_suppressed(lines, line_index, "JS-CHILD-PROCESS-EXEC"):
|
|
24
|
+
findings.append(
|
|
25
|
+
Finding(
|
|
26
|
+
rule_id="JS-CHILD-PROCESS-EXEC",
|
|
27
|
+
title="JavaScript tool invokes child_process.exec",
|
|
28
|
+
severity="high",
|
|
29
|
+
message="A JavaScript tool handler can pass input through a shell.",
|
|
30
|
+
path=rel,
|
|
31
|
+
line=line_number,
|
|
32
|
+
remediation="Use execFile or spawn with an argument array and validate allowed commands.",
|
|
33
|
+
evidence=stripped[:240],
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if "spawn(" in stripped and "shell: true" in stripped and not is_suppressed(lines, line_index, "JS-SPAWN-SHELL"):
|
|
38
|
+
findings.append(
|
|
39
|
+
Finding(
|
|
40
|
+
rule_id="JS-SPAWN-SHELL",
|
|
41
|
+
title="JavaScript tool invokes spawn with shell enabled",
|
|
42
|
+
severity="high",
|
|
43
|
+
message="A JavaScript tool handler enables shell execution.",
|
|
44
|
+
path=rel,
|
|
45
|
+
line=line_number,
|
|
46
|
+
remediation="Disable shell mode and pass command arguments as an array.",
|
|
47
|
+
evidence=stripped[:240],
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# mcp-riskmap: ignore TOOL-DESCRIPTION-INJECTION
|
|
52
|
+
if ("ignore previous" in stripped.lower() or "system prompt" in stripped.lower()) and not is_suppressed(lines, line_index, "TOOL-DESCRIPTION-INJECTION"):
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
rule_id="TOOL-DESCRIPTION-INJECTION",
|
|
56
|
+
title="Tool text contains prompt-injection-like wording",
|
|
57
|
+
severity="medium",
|
|
58
|
+
message="Tool text includes phrases often used to override model instructions.",
|
|
59
|
+
path=rel,
|
|
60
|
+
line=line_number,
|
|
61
|
+
remediation="Keep tool descriptions factual and remove instructions that target the model control plane.",
|
|
62
|
+
evidence=stripped[:240],
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return findings
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcp_riskmap.analyzers.common import is_suppressed, relative_path, read_text
|
|
7
|
+
from mcp_riskmap.models import Finding
|
|
8
|
+
|
|
9
|
+
SHELL_TRUE_RE = re.compile(r"subprocess\.(run|call|Popen|check_call|check_output)\s*\([^)]*shell\s*=\s*True")
|
|
10
|
+
EVAL_EXEC_RE = re.compile(r"\b(eval|exec)\s*\(")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def analyze_python(root: Path, path: Path) -> list[Finding]:
|
|
14
|
+
findings: list[Finding] = []
|
|
15
|
+
rel = relative_path(root, path)
|
|
16
|
+
lines = read_text(path).splitlines()
|
|
17
|
+
for line_index, line in enumerate(lines):
|
|
18
|
+
line_number = line_index + 1
|
|
19
|
+
stripped = line.strip()
|
|
20
|
+
if not stripped or stripped.startswith("#"):
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
if SHELL_TRUE_RE.search(stripped) and not is_suppressed(lines, line_index, "PY-SHELL-TRUE"):
|
|
24
|
+
findings.append(
|
|
25
|
+
Finding(
|
|
26
|
+
rule_id="PY-SHELL-TRUE",
|
|
27
|
+
title="Python tool invokes subprocess with shell=True",
|
|
28
|
+
severity="high",
|
|
29
|
+
message="A Python tool handler can pass input through a shell.",
|
|
30
|
+
path=rel,
|
|
31
|
+
line=line_number,
|
|
32
|
+
remediation="Use subprocess with an argument list and validate allowed commands or paths before execution.",
|
|
33
|
+
evidence=stripped[:240],
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# mcp-riskmap: ignore PY-OS-SYSTEM
|
|
38
|
+
if "os.system(" in stripped and not is_suppressed(lines, line_index, "PY-OS-SYSTEM"):
|
|
39
|
+
findings.append(
|
|
40
|
+
Finding(
|
|
41
|
+
rule_id="PY-OS-SYSTEM",
|
|
42
|
+
title="Python tool invokes os.system",
|
|
43
|
+
severity="high",
|
|
44
|
+
message="A Python tool handler invokes a command through the system shell.",
|
|
45
|
+
path=rel,
|
|
46
|
+
line=line_number,
|
|
47
|
+
remediation="Replace os.system with subprocess argument lists and explicit allowlists.",
|
|
48
|
+
evidence=stripped[:240],
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if EVAL_EXEC_RE.search(stripped) and not is_suppressed(lines, line_index, "PY-EVAL-EXEC"):
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
rule_id="PY-EVAL-EXEC",
|
|
56
|
+
title="Python tool evaluates dynamic code",
|
|
57
|
+
severity="high",
|
|
58
|
+
message="A Python tool handler uses eval or exec.",
|
|
59
|
+
path=rel,
|
|
60
|
+
line=line_number,
|
|
61
|
+
remediation="Replace dynamic evaluation with parsed data structures and explicit dispatch tables.",
|
|
62
|
+
evidence=stripped[:240],
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# mcp-riskmap: ignore TOOL-DESCRIPTION-INJECTION
|
|
67
|
+
if ("ignore previous" in stripped.lower() or "system prompt" in stripped.lower()) and not is_suppressed(lines, line_index, "TOOL-DESCRIPTION-INJECTION"):
|
|
68
|
+
findings.append(
|
|
69
|
+
Finding(
|
|
70
|
+
rule_id="TOOL-DESCRIPTION-INJECTION",
|
|
71
|
+
title="Tool text contains prompt-injection-like wording",
|
|
72
|
+
severity="medium",
|
|
73
|
+
message="Tool text includes phrases often used to override model instructions.",
|
|
74
|
+
path=rel,
|
|
75
|
+
line=line_number,
|
|
76
|
+
remediation="Keep tool descriptions factual and remove instructions that target the model control plane.",
|
|
77
|
+
evidence=stripped[:240],
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return findings
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mcp_riskmap.models import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def analyze_repo_hygiene(root: Path) -> list[Finding]:
|
|
9
|
+
findings: list[Finding] = []
|
|
10
|
+
checks = [
|
|
11
|
+
("AGENTS.md", "REPO-MISSING-AGENTS", "Repository is missing AGENTS.md", "Add AGENTS.md with build, test, review, and security guidance for coding agents."),
|
|
12
|
+
("SECURITY.md", "REPO-MISSING-SECURITY", "Repository is missing SECURITY.md", "Add SECURITY.md with vulnerability reporting and supported version guidance."),
|
|
13
|
+
("LICENSE", "REPO-MISSING-LICENSE", "Repository is missing LICENSE", "Add an OSI-approved license such as MIT or Apache-2.0."),
|
|
14
|
+
]
|
|
15
|
+
for filename, rule_id, title, remediation in checks:
|
|
16
|
+
if not (root / filename).exists() and not (root / f"{filename}.md").exists():
|
|
17
|
+
findings.append(
|
|
18
|
+
Finding(
|
|
19
|
+
rule_id=rule_id,
|
|
20
|
+
title=title,
|
|
21
|
+
severity="low",
|
|
22
|
+
message=title,
|
|
23
|
+
path=".",
|
|
24
|
+
line=1,
|
|
25
|
+
remediation=remediation,
|
|
26
|
+
evidence=filename,
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
return findings
|
mcp_riskmap/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcp_riskmap import __version__
|
|
8
|
+
from mcp_riskmap.models import SEVERITY_ORDER, ScanResult
|
|
9
|
+
from mcp_riskmap.reporters.json_reporter import render_json
|
|
10
|
+
from mcp_riskmap.reporters.markdown import render_markdown
|
|
11
|
+
from mcp_riskmap.reporters.sarif import render_sarif
|
|
12
|
+
from mcp_riskmap.reporters.table import render_table
|
|
13
|
+
from mcp_riskmap.scanner import ScanInputError, scan_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = _build_parser()
|
|
18
|
+
args = parser.parse_args(argv)
|
|
19
|
+
if args.version:
|
|
20
|
+
print(f"mcp-riskmap {__version__}")
|
|
21
|
+
return 0
|
|
22
|
+
if args.command == "scan":
|
|
23
|
+
return _scan(args)
|
|
24
|
+
parser.print_help()
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="mcp-riskmap",
|
|
31
|
+
description="Static MCP and agent-tool repository risk scanner.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("--version", action="store_true", help="Print the package version and exit.")
|
|
34
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
35
|
+
scan = subparsers.add_parser("scan", help="Scan a repository or directory.")
|
|
36
|
+
scan.add_argument("path", nargs="?", default=".", help="Path to scan.")
|
|
37
|
+
scan.add_argument(
|
|
38
|
+
"--format",
|
|
39
|
+
choices=["table", "json", "markdown", "sarif"],
|
|
40
|
+
default="table",
|
|
41
|
+
help="Output format.",
|
|
42
|
+
)
|
|
43
|
+
scan.add_argument("--output", help="Write report to a file instead of stdout.")
|
|
44
|
+
scan.add_argument(
|
|
45
|
+
"--exclude",
|
|
46
|
+
action="append",
|
|
47
|
+
default=[],
|
|
48
|
+
help="Exclude a relative path glob from scanning. Can be passed more than once.",
|
|
49
|
+
)
|
|
50
|
+
scan.add_argument(
|
|
51
|
+
"--fail-on",
|
|
52
|
+
choices=list(SEVERITY_ORDER),
|
|
53
|
+
help="Return exit code 1 when any finding is at or above this severity.",
|
|
54
|
+
)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _scan(args: argparse.Namespace) -> int:
|
|
59
|
+
try:
|
|
60
|
+
result = scan_path(args.path, exclude_patterns=args.exclude)
|
|
61
|
+
except ScanInputError as exc:
|
|
62
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
|
|
65
|
+
rendered = render_result(result, args.format)
|
|
66
|
+
if args.output:
|
|
67
|
+
Path(args.output).write_text(rendered + "\n", encoding="utf-8")
|
|
68
|
+
else:
|
|
69
|
+
print(rendered)
|
|
70
|
+
|
|
71
|
+
if args.fail_on and result.count_at_or_above(args.fail_on):
|
|
72
|
+
return 1
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_result(result: ScanResult, report_format: str) -> str:
|
|
77
|
+
if report_format == "json":
|
|
78
|
+
return render_json(result)
|
|
79
|
+
if report_format == "markdown":
|
|
80
|
+
return render_markdown(result)
|
|
81
|
+
if report_format == "sarif":
|
|
82
|
+
return render_sarif(result)
|
|
83
|
+
return render_table(result)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main(sys.argv[1:]))
|
mcp_riskmap/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp_riskmap.redaction import redact_text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SEVERITY_ORDER = {
|
|
11
|
+
"info": 0,
|
|
12
|
+
"low": 1,
|
|
13
|
+
"medium": 2,
|
|
14
|
+
"high": 3,
|
|
15
|
+
"critical": 4,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Finding:
|
|
21
|
+
rule_id: str
|
|
22
|
+
title: str
|
|
23
|
+
severity: str
|
|
24
|
+
message: str
|
|
25
|
+
path: str
|
|
26
|
+
line: int = 1
|
|
27
|
+
remediation: str = ""
|
|
28
|
+
evidence: str = ""
|
|
29
|
+
|
|
30
|
+
def as_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"rule_id": self.rule_id,
|
|
33
|
+
"title": self.title,
|
|
34
|
+
"severity": self.severity,
|
|
35
|
+
"message": self.message,
|
|
36
|
+
"path": self.path,
|
|
37
|
+
"line": self.line,
|
|
38
|
+
"remediation": self.remediation,
|
|
39
|
+
"evidence": redact_text(self.evidence),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ScanResult:
|
|
45
|
+
root: Path
|
|
46
|
+
findings: list[Finding] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def count_at_or_above(self, severity: str) -> int:
|
|
49
|
+
threshold = SEVERITY_ORDER[severity]
|
|
50
|
+
return sum(1 for finding in self.findings if SEVERITY_ORDER[finding.severity] >= threshold)
|
|
51
|
+
|
|
52
|
+
def as_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"root": str(self.root),
|
|
55
|
+
"summary": {
|
|
56
|
+
"findings": len(self.findings),
|
|
57
|
+
"critical": self.count_at_or_above("critical"),
|
|
58
|
+
"high_or_above": self.count_at_or_above("high"),
|
|
59
|
+
"medium_or_above": self.count_at_or_above("medium"),
|
|
60
|
+
},
|
|
61
|
+
"findings": [finding.as_dict() for finding in self.findings],
|
|
62
|
+
}
|
mcp_riskmap/redaction.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
SECRET_VALUE_RE = re.compile(
|
|
6
|
+
r"(?i)\b(token|api[_-]?key|secret|password|credential|authorization)\b"
|
|
7
|
+
r"(\s*[:=]\s*|/)"
|
|
8
|
+
r"([^\s&|,'\"]+)"
|
|
9
|
+
)
|
|
10
|
+
BEARER_RE = re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{8,}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact_text(value: str) -> str:
|
|
14
|
+
if not value:
|
|
15
|
+
return value
|
|
16
|
+
redacted = SECRET_VALUE_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", value)
|
|
17
|
+
redacted = BEARER_RE.sub("Bearer [REDACTED]", redacted)
|
|
18
|
+
return redacted
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Report renderers for scan results."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp_riskmap.models import ScanResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_markdown(result: ScanResult) -> str:
|
|
7
|
+
lines = [
|
|
8
|
+
"# MCP Riskmap Report",
|
|
9
|
+
"",
|
|
10
|
+
f"- Root: `{result.root}`",
|
|
11
|
+
f"- Findings: {len(result.findings)}",
|
|
12
|
+
f"- High or above: {result.count_at_or_above('high')}",
|
|
13
|
+
"",
|
|
14
|
+
]
|
|
15
|
+
if not result.findings:
|
|
16
|
+
lines.append("No findings.")
|
|
17
|
+
return "\n".join(lines)
|
|
18
|
+
|
|
19
|
+
lines.extend(["| Severity | Rule | Location | Message |", "| --- | --- | --- | --- |"])
|
|
20
|
+
for finding in result.findings:
|
|
21
|
+
location = f"{finding.path}:{finding.line}"
|
|
22
|
+
message = finding.message.replace("|", "\\|")
|
|
23
|
+
lines.append(f"| {finding.severity} | `{finding.rule_id}` | `{location}` | {message} |")
|
|
24
|
+
|
|
25
|
+
lines.extend(["", "## Remediation"])
|
|
26
|
+
for finding in result.findings:
|
|
27
|
+
lines.append(f"- `{finding.rule_id}` at `{finding.path}:{finding.line}`: {finding.remediation}")
|
|
28
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from mcp_riskmap.models import ScanResult
|
|
7
|
+
from mcp_riskmap.redaction import redact_text
|
|
8
|
+
from mcp_riskmap.rules.registry import RULES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_sarif(result: ScanResult) -> str:
|
|
12
|
+
rules = {}
|
|
13
|
+
sarif_results = []
|
|
14
|
+
for finding in result.findings:
|
|
15
|
+
metadata = RULES.metadata_for(finding.rule_id)
|
|
16
|
+
rules[finding.rule_id] = {
|
|
17
|
+
"id": finding.rule_id,
|
|
18
|
+
"name": finding.title,
|
|
19
|
+
"shortDescription": {"text": finding.title},
|
|
20
|
+
"fullDescription": {"text": finding.remediation or finding.message},
|
|
21
|
+
"helpUri": metadata.help_uri,
|
|
22
|
+
"help": {"text": metadata.description},
|
|
23
|
+
"defaultConfiguration": {"level": _level_for(finding.severity)},
|
|
24
|
+
"properties": {
|
|
25
|
+
"precision": metadata.precision,
|
|
26
|
+
"security-severity": metadata.security_severity,
|
|
27
|
+
"tags": list(metadata.tags),
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
fingerprint = _fingerprint_for(finding)
|
|
31
|
+
sarif_results.append(
|
|
32
|
+
{
|
|
33
|
+
"ruleId": finding.rule_id,
|
|
34
|
+
"level": _level_for(finding.severity),
|
|
35
|
+
"message": {"text": finding.message},
|
|
36
|
+
"locations": [
|
|
37
|
+
{
|
|
38
|
+
"physicalLocation": {
|
|
39
|
+
"artifactLocation": {"uri": finding.path},
|
|
40
|
+
"region": {"startLine": max(finding.line, 1)},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"partialFingerprints": {
|
|
45
|
+
"primaryLocationLineHash": fingerprint,
|
|
46
|
+
},
|
|
47
|
+
"properties": {
|
|
48
|
+
"severity": finding.severity,
|
|
49
|
+
"evidence": redact_text(finding.evidence),
|
|
50
|
+
"remediation": finding.remediation,
|
|
51
|
+
"precision": metadata.precision,
|
|
52
|
+
"security-severity": metadata.security_severity,
|
|
53
|
+
"tags": list(metadata.tags),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
sarif = {
|
|
59
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
60
|
+
"version": "2.1.0",
|
|
61
|
+
"runs": [
|
|
62
|
+
{
|
|
63
|
+
"tool": {
|
|
64
|
+
"driver": {
|
|
65
|
+
"name": "mcp-riskmap",
|
|
66
|
+
"informationUri": "https://github.com/vawkdh-job/mcp-riskmap",
|
|
67
|
+
"rules": list(rules.values()),
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"automationDetails": {"id": "mcp-riskmap"},
|
|
71
|
+
"results": sarif_results,
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
return json.dumps(sarif, indent=2, sort_keys=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _level_for(severity: str) -> str:
|
|
79
|
+
if severity in {"critical", "high"}:
|
|
80
|
+
return "error"
|
|
81
|
+
if severity == "medium":
|
|
82
|
+
return "warning"
|
|
83
|
+
return "note"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fingerprint_for(finding) -> str:
|
|
87
|
+
source = f"{finding.rule_id}\0{finding.path}\0{finding.line}\0{finding.message}"
|
|
88
|
+
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mcp_riskmap.models import ScanResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_table(result: ScanResult) -> str:
|
|
7
|
+
if not result.findings:
|
|
8
|
+
return f"No findings for {result.root}"
|
|
9
|
+
|
|
10
|
+
rows = [("SEVERITY", "RULE", "LOCATION", "MESSAGE")]
|
|
11
|
+
for finding in result.findings:
|
|
12
|
+
rows.append(
|
|
13
|
+
(
|
|
14
|
+
finding.severity.upper(),
|
|
15
|
+
finding.rule_id,
|
|
16
|
+
f"{finding.path}:{finding.line}",
|
|
17
|
+
finding.message,
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
widths = [min(max(len(row[index]) for row in rows), 48) for index in range(4)]
|
|
22
|
+
rendered = []
|
|
23
|
+
for index, row in enumerate(rows):
|
|
24
|
+
rendered.append(" ".join(_fit(value, widths[col]) for col, value in enumerate(row)))
|
|
25
|
+
if index == 0:
|
|
26
|
+
rendered.append(" ".join("-" * width for width in widths))
|
|
27
|
+
return "\n".join(rendered)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fit(value: str, width: int) -> str:
|
|
31
|
+
if len(value) <= width:
|
|
32
|
+
return value.ljust(width)
|
|
33
|
+
return value[: max(width - 1, 1)] + "…"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rule registry package."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DOCS_BASE_URL = "https://github.com/vawkdh-job/mcp-riskmap/blob/main/docs/rules.md"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RuleMetadata:
|
|
11
|
+
rule_id: str
|
|
12
|
+
description: str
|
|
13
|
+
precision: str
|
|
14
|
+
security_severity: str
|
|
15
|
+
tags: tuple[str, ...]
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def help_uri(self) -> str:
|
|
19
|
+
return f"{DOCS_BASE_URL}#{self.anchor}"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def anchor(self) -> str:
|
|
23
|
+
return self.rule_id.lower().replace("_", "-")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuleCatalog(dict[str, RuleMetadata]):
|
|
27
|
+
def metadata_for(self, rule_id: str) -> RuleMetadata:
|
|
28
|
+
return self.get(
|
|
29
|
+
rule_id,
|
|
30
|
+
_rule(rule_id, "Unregistered mcp-riskmap rule.", "medium", "5.0", ("mcp-riskmap",)),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rule(rule_id: str, description: str, precision: str, security_severity: str, tags: tuple[str, ...]) -> RuleMetadata:
|
|
35
|
+
return RuleMetadata(rule_id, description, precision, security_severity, tags)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
RULES = RuleCatalog(
|
|
39
|
+
{
|
|
40
|
+
"MCP-CONFIG-SHELL": _rule("MCP-CONFIG-SHELL", "MCP config starts a server through a shell wrapper.", "high", "8.0", ("security", "mcp", "command-execution")),
|
|
41
|
+
"MCP-CONFIG-REMOTE-INSTALL": _rule("MCP-CONFIG-REMOTE-INSTALL", "MCP config downloads and executes remote content.", "high", "9.0", ("security", "mcp", "supply-chain")),
|
|
42
|
+
"MCP-CONFIG-SECRET-ENV": _rule("MCP-CONFIG-SECRET-ENV", "MCP config passes secret-like environment variables.", "medium", "6.5", ("security", "mcp", "secrets")),
|
|
43
|
+
"MCP-CONFIG-NPX-LATEST": _rule("MCP-CONFIG-NPX-LATEST", "MCP config runs npx -y without an explicit review gate.", "medium", "5.5", ("security", "mcp", "supply-chain")),
|
|
44
|
+
"PY-SHELL-TRUE": _rule("PY-SHELL-TRUE", "Python source uses subprocess with shell=True.", "high", "8.0", ("security", "python", "command-execution")),
|
|
45
|
+
"PY-OS-SYSTEM": _rule("PY-OS-SYSTEM", "Python source uses os.system.", "high", "8.0", ("security", "python", "command-execution")),
|
|
46
|
+
"PY-EVAL-EXEC": _rule("PY-EVAL-EXEC", "Python source uses eval or exec.", "high", "8.0", ("security", "python", "code-injection")),
|
|
47
|
+
"JS-CHILD-PROCESS-EXEC": _rule("JS-CHILD-PROCESS-EXEC", "JavaScript source uses child_process.exec.", "high", "8.0", ("security", "javascript", "command-execution")),
|
|
48
|
+
"JS-SPAWN-SHELL": _rule("JS-SPAWN-SHELL", "JavaScript source uses spawn with shell enabled.", "high", "8.0", ("security", "javascript", "command-execution")),
|
|
49
|
+
"TOOL-DESCRIPTION-INJECTION": _rule("TOOL-DESCRIPTION-INJECTION", "Tool text contains prompt-injection-like wording.", "medium", "6.0", ("security", "mcp", "prompt-injection")),
|
|
50
|
+
"REPO-MISSING-AGENTS": _rule("REPO-MISSING-AGENTS", "Repository does not define agent guidance.", "high", "3.0", ("repository-hygiene", "agents")),
|
|
51
|
+
"REPO-MISSING-SECURITY": _rule("REPO-MISSING-SECURITY", "Repository does not define vulnerability reporting guidance.", "high", "4.0", ("repository-hygiene", "security-policy")),
|
|
52
|
+
"REPO-MISSING-LICENSE": _rule("REPO-MISSING-LICENSE", "Repository does not define an open-source license.", "high", "2.0", ("repository-hygiene", "license")),
|
|
53
|
+
}
|
|
54
|
+
)
|
mcp_riskmap/scanner.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcp_riskmap.analyzers.common import relative_path
|
|
8
|
+
from mcp_riskmap.analyzers.config import analyze_config, is_candidate as is_config_candidate
|
|
9
|
+
from mcp_riskmap.analyzers.js_source import analyze_javascript
|
|
10
|
+
from mcp_riskmap.analyzers.python_source import analyze_python
|
|
11
|
+
from mcp_riskmap.analyzers.repo_hygiene import analyze_repo_hygiene
|
|
12
|
+
from mcp_riskmap.models import Finding, ScanResult
|
|
13
|
+
|
|
14
|
+
SKIP_DIRS = {".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "node_modules", "dist", "build"}
|
|
15
|
+
JS_SUFFIXES = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScanInputError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scan_path(path: str | Path, exclude_patterns: Sequence[str] | None = None) -> ScanResult:
|
|
23
|
+
root = Path(path).resolve()
|
|
24
|
+
if not root.exists():
|
|
25
|
+
raise ScanInputError(f"scan target does not exist: {root}")
|
|
26
|
+
if not root.is_file() and not root.is_dir():
|
|
27
|
+
raise ScanInputError(f"scan target is not a file or directory: {root}")
|
|
28
|
+
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
excludes = tuple(_normalize_pattern(pattern) for pattern in (exclude_patterns or ()) if pattern)
|
|
31
|
+
|
|
32
|
+
for file_path in _iter_files(root, excludes):
|
|
33
|
+
suffix = file_path.suffix.lower()
|
|
34
|
+
if is_config_candidate(file_path):
|
|
35
|
+
findings.extend(analyze_config(root, file_path))
|
|
36
|
+
if suffix == ".py":
|
|
37
|
+
findings.extend(analyze_python(root, file_path))
|
|
38
|
+
elif suffix in JS_SUFFIXES:
|
|
39
|
+
findings.extend(analyze_javascript(root, file_path))
|
|
40
|
+
|
|
41
|
+
findings.extend(analyze_repo_hygiene(root))
|
|
42
|
+
findings.sort(key=lambda finding: (finding.path, finding.line, finding.rule_id))
|
|
43
|
+
return ScanResult(root=root, findings=findings)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iter_files(root: Path, exclude_patterns: Sequence[str]):
|
|
47
|
+
if root.is_file():
|
|
48
|
+
if not _is_excluded(root, root, exclude_patterns):
|
|
49
|
+
yield root
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
for candidate in root.rglob("*"):
|
|
53
|
+
if candidate.is_dir():
|
|
54
|
+
continue
|
|
55
|
+
if any(part in SKIP_DIRS for part in candidate.parts):
|
|
56
|
+
continue
|
|
57
|
+
if _is_excluded(root, candidate, exclude_patterns):
|
|
58
|
+
continue
|
|
59
|
+
yield candidate
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_excluded(root: Path, candidate: Path, exclude_patterns: Sequence[str]) -> bool:
|
|
63
|
+
if not exclude_patterns:
|
|
64
|
+
return False
|
|
65
|
+
rel = relative_path(root, candidate)
|
|
66
|
+
return any(fnmatch(rel, pattern) or fnmatch(candidate.name, pattern) for pattern in exclude_patterns)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize_pattern(pattern: str) -> str:
|
|
70
|
+
normalized = pattern.replace("\\", "/").strip()
|
|
71
|
+
if normalized.endswith("/"):
|
|
72
|
+
return f"{normalized}**"
|
|
73
|
+
return normalized
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-riskmap
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Local-first static auditor for risky MCP and agent-tool repository patterns.
|
|
5
|
+
Author: mcp-riskmap contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 mcp-riskmap contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/vawkdh-job/mcp-riskmap
|
|
29
|
+
Project-URL: Repository, https://github.com/vawkdh-job/mcp-riskmap
|
|
30
|
+
Project-URL: Issues, https://github.com/vawkdh-job/mcp-riskmap/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/vawkdh-job/mcp-riskmap/blob/main/CHANGELOG.md
|
|
32
|
+
Keywords: mcp,security,agent,static-analysis,sarif,github-actions,code-scanning
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Environment :: Console
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Topic :: Security
|
|
42
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
43
|
+
Requires-Python: >=3.10
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
License-File: LICENSE
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# mcp-riskmap
|
|
49
|
+
|
|
50
|
+
[](https://github.com/vawkdh-job/mcp-riskmap/actions/workflows/ci.yml)
|
|
51
|
+
[](https://github.com/vawkdh-job/mcp-riskmap/actions/workflows/mcp-riskmap.yml)
|
|
52
|
+
[](https://github.com/vawkdh-job/mcp-riskmap/releases)
|
|
53
|
+
[](LICENSE)
|
|
54
|
+
|
|
55
|
+
`mcp-riskmap` is a local-first static auditor for MCP and agent-tool repositories. It looks for risky MCP configuration, shell-enabled tool handlers, prompt-injection-like tool descriptions, and missing maintainer guidance without starting untrusted MCP servers.
|
|
56
|
+
|
|
57
|
+
This project is intentionally small and conservative. It is designed for maintainers who want a quick review signal in local development, pull requests, and GitHub Code Scanning.
|
|
58
|
+
|
|
59
|
+
## Why this exists
|
|
60
|
+
|
|
61
|
+
MCP servers often expose tools that can touch files, shells, networks, credentials, or local developer state. Reference servers and community examples are useful, but each maintainer still needs a threat model and basic safeguards before sharing configs or accepting tool changes.
|
|
62
|
+
|
|
63
|
+
`mcp-riskmap` focuses on static signals that are cheap to review:
|
|
64
|
+
|
|
65
|
+
- MCP config that starts through `cmd`, `powershell`, `bash`, or `sh`
|
|
66
|
+
- Remote install pipelines such as `curl ... | sh` or `curl ... | iex`
|
|
67
|
+
- Secret-like environment variables passed into MCP servers
|
|
68
|
+
- Python `subprocess(..., shell=True)`, `os.system`, `eval`, and `exec`
|
|
69
|
+
- JavaScript `child_process.exec` and `spawn(..., { shell: true })`
|
|
70
|
+
- Tool text that looks like model-control prompt injection
|
|
71
|
+
- Missing `AGENTS.md`, `SECURITY.md`, or `LICENSE`
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
From a checkout:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m pip install -e .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
From GitHub:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m pip install "git+https://github.com/vawkdh-job/mcp-riskmap.git@v0.1.2"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For development without installing:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
$env:PYTHONPATH = "src"
|
|
91
|
+
python -m mcp_riskmap.cli scan examples/unsafe-mcp-server
|
|
92
|
+
python -m mcp_riskmap.cli --version
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
mcp-riskmap scan .
|
|
99
|
+
mcp-riskmap scan . --format json
|
|
100
|
+
mcp-riskmap scan . --format markdown --output report.md
|
|
101
|
+
mcp-riskmap scan . --format sarif --output results.sarif --fail-on high
|
|
102
|
+
mcp-riskmap scan . --exclude "examples/**" --exclude "tests/**"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--fail-on high` returns exit code `1` when at least one finding is high or critical.
|
|
106
|
+
|
|
107
|
+
Use `--exclude` for reviewed fixture directories, generated output, or intentionally unsafe examples that should not block CI.
|
|
108
|
+
|
|
109
|
+
## Examples
|
|
110
|
+
|
|
111
|
+
- `examples/unsafe-mcp-server/` contains intentionally risky MCP config and tool-handler patterns for scanner demonstrations.
|
|
112
|
+
- `examples/safe-mcp-server/` contains a safer file-read pattern using a resolved base directory boundary check.
|
|
113
|
+
|
|
114
|
+
## Example output
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
SEVERITY RULE LOCATION MESSAGE
|
|
118
|
+
-------- ------------------------- ------------ ------------------------------------------------
|
|
119
|
+
CRITICAL MCP-CONFIG-REMOTE-INSTALL mcp.json:5 MCP server 'unsafe-demo' downloads and executes...
|
|
120
|
+
HIGH PY-SHELL-TRUE server.py:6 A Python tool handler can pass input through a shell.
|
|
121
|
+
HIGH JS-CHILD-PROCESS-EXEC server.js:4 A JavaScript tool handler can pass input through a shell.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Output formats
|
|
125
|
+
|
|
126
|
+
- `table`: compact terminal output
|
|
127
|
+
- `json`: automation-friendly structured output
|
|
128
|
+
- `markdown`: issue and release-note friendly report
|
|
129
|
+
- `sarif`: GitHub Code Scanning compatible output
|
|
130
|
+
|
|
131
|
+
Structured outputs redact secret-like evidence values before writing JSON or SARIF.
|
|
132
|
+
|
|
133
|
+
## Reviewed suppressions
|
|
134
|
+
|
|
135
|
+
If a maintainer reviews a finding and accepts the risk, add a narrow suppression on the same line or the previous line:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# mcp-riskmap: ignore PY-SHELL-TRUE
|
|
139
|
+
subprocess.run(command, shell=True)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use rule-specific suppressions where possible. `mcp-riskmap: ignore` suppresses all rules on the next line and should be reserved for generated or documented fixture code.
|
|
143
|
+
|
|
144
|
+
## GitHub Action
|
|
145
|
+
|
|
146
|
+
This repository includes a composite GitHub Action:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
name: mcp-riskmap
|
|
150
|
+
|
|
151
|
+
on:
|
|
152
|
+
pull_request:
|
|
153
|
+
push:
|
|
154
|
+
branches: [main]
|
|
155
|
+
|
|
156
|
+
jobs:
|
|
157
|
+
scan:
|
|
158
|
+
runs-on: ubuntu-latest
|
|
159
|
+
permissions:
|
|
160
|
+
contents: read
|
|
161
|
+
security-events: write
|
|
162
|
+
steps:
|
|
163
|
+
- uses: actions/checkout@v4
|
|
164
|
+
- uses: vawkdh-job/mcp-riskmap@v0.1.2
|
|
165
|
+
with:
|
|
166
|
+
path: .
|
|
167
|
+
format: sarif
|
|
168
|
+
output: mcp-riskmap.sarif
|
|
169
|
+
fail-on: high
|
|
170
|
+
exclude: |
|
|
171
|
+
examples/**
|
|
172
|
+
tests/**
|
|
173
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
174
|
+
if: always()
|
|
175
|
+
with:
|
|
176
|
+
sarif_file: mcp-riskmap.sarif
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Safety stance
|
|
180
|
+
|
|
181
|
+
`mcp-riskmap` does not execute MCP servers. It reads files and reports static findings. That means it will miss runtime-only behavior, but it is safer for quick review of unknown configs and pull requests.
|
|
182
|
+
|
|
183
|
+
## Why static only?
|
|
184
|
+
|
|
185
|
+
Some MCP scanners inspect live tool descriptions by starting configured servers. That can be useful, but it is risky when a reviewer is looking at an unknown repository or pull request. `mcp-riskmap` is meant to run earlier in the review flow: it reads files, flags obvious risk, and produces review artifacts without executing commands from the target project.
|
|
186
|
+
|
|
187
|
+
## Compared with dynamic scanners
|
|
188
|
+
|
|
189
|
+
`mcp-riskmap` is not a replacement for dynamic MCP inspection. It is a first-pass guardrail for maintainers who need quick review signals in CI and code review.
|
|
190
|
+
|
|
191
|
+
| Area | mcp-riskmap | Dynamic scanners |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| Starts scanned MCP servers | No | Often yes |
|
|
194
|
+
| Safe for unknown PRs | Designed for this | Depends on sandboxing |
|
|
195
|
+
| CI/SARIF friendly | Yes | Depends on tool |
|
|
196
|
+
| Runtime behavior coverage | Limited | Better |
|
|
197
|
+
| Static source/config review | Primary focus | Varies |
|
|
198
|
+
|
|
199
|
+
## Current limitations
|
|
200
|
+
|
|
201
|
+
- Rules are conservative regex/static checks, not full taint analysis.
|
|
202
|
+
- The scanner does not inspect live MCP tool responses.
|
|
203
|
+
- Secret detection is key-name based and does not do high-entropy scanning.
|
|
204
|
+
- JavaScript and Python analyzers focus on common high-risk patterns first.
|
|
205
|
+
|
|
206
|
+
## Roadmap
|
|
207
|
+
|
|
208
|
+
- Add more MCP client config locations.
|
|
209
|
+
- Detect unsafe filesystem writes and path traversal candidates.
|
|
210
|
+
- Add rule severity profiles.
|
|
211
|
+
- Add Semgrep-compatible pattern export.
|
|
212
|
+
|
|
213
|
+
See [ROADMAP.md](ROADMAP.md) for issue-sized milestones.
|
|
214
|
+
|
|
215
|
+
## OpenAI Codex for OSS fit
|
|
216
|
+
|
|
217
|
+
This project is intended to be maintained as an open-source security and maintainer automation tool. It includes tests, CI, SARIF output, examples, security docs, contribution guidance, AGENTS.md, and tagged releases.
|
|
218
|
+
|
|
219
|
+
Codex/API credits would be useful for reviewing rule changes, generating regression tests, triaging issues, improving documentation, and producing release notes. AI output should be reviewed by maintainers before merge.
|
|
220
|
+
|
|
221
|
+
See [docs/codex-for-oss.md](docs/codex-for-oss.md) for application-specific maintainer workflow notes.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
mcp_riskmap/__init__.py,sha256=ggnlgtwgdVr7IX8FqL_iEuoAx3hf8SfjdNdAvkU6_Mo,107
|
|
2
|
+
mcp_riskmap/cli.py,sha256=tLYrRdzM98XLtTZ1udPLtNeM951NUcgYZgShlkdm8ck,2761
|
|
3
|
+
mcp_riskmap/models.py,sha256=MVlmDUuw2vq3CaIQlPM2GBp6qk6ou6nSMI2W-PEDFPo,1621
|
|
4
|
+
mcp_riskmap/redaction.py,sha256=V4mbosjNsH0A_lmFw1kZgnkKJit47o7pzd2TjIgu2UY,521
|
|
5
|
+
mcp_riskmap/scanner.py,sha256=uUi5iNd87HL0ayTuj2KLjsUmmWy7YhALFmAws5DP6Qk,2725
|
|
6
|
+
mcp_riskmap/analyzers/__init__.py,sha256=qYT7KzmU-lF9QIjddd8sjLeUm9BCF86CQxRz9yzcy5A,42
|
|
7
|
+
mcp_riskmap/analyzers/common.py,sha256=5kRoxTvvljE0fs_mgY7CVQ5fkYFtp8oZ01vwnpvOErM,1049
|
|
8
|
+
mcp_riskmap/analyzers/config.py,sha256=25nWr4c4ZXaHaBOEn8CGRya-XOCAal5Ml5dan3U-0bo,4999
|
|
9
|
+
mcp_riskmap/analyzers/js_source.py,sha256=ZZXBQjgXfRcY4Ge6o8svqrnsgQupKH3Mz2f1ulJ_5iU,2907
|
|
10
|
+
mcp_riskmap/analyzers/python_source.py,sha256=54XGtXNO9uLqJ_HowWTXHrZKbqKi5hginBUNZLRJyFk,3549
|
|
11
|
+
mcp_riskmap/analyzers/repo_hygiene.py,sha256=H2kdce5gUxNvhXMHbHe2E_K0Xlq7hBEDIp_tAkI3Ftg,1212
|
|
12
|
+
mcp_riskmap/reporters/__init__.py,sha256=FFClu4EdsinJvgjsv644_34bWdKjj-BtxLJVVYU2sak,41
|
|
13
|
+
mcp_riskmap/reporters/json_reporter.py,sha256=dP9NbqzONENBLrv8THT-OEdN1scLZiIGDPZfZERpbsg,203
|
|
14
|
+
mcp_riskmap/reporters/markdown.py,sha256=tEj4Q_uko8CrWzBcOSsqBt1i_BMuTuaVMXKTL6d-Ozc,991
|
|
15
|
+
mcp_riskmap/reporters/sarif.py,sha256=_JKBLiUDLtpawYzA-8hYc-eysNrznOa83SusnNiLkv4,3077
|
|
16
|
+
mcp_riskmap/reporters/table.py,sha256=yCd_T19fpYmnomQe9ui0aNrrYME9gkqRqBVVldlfc2A,1024
|
|
17
|
+
mcp_riskmap/rules/__init__.py,sha256=9aiPn8WMJN104nwXPDG61scCYr3Ckw3WEyFyC4euh20,29
|
|
18
|
+
mcp_riskmap/rules/registry.py,sha256=iS1lW73vj1csjTescBg_PMdGoV8gIUgfzFPlCGlwJaU,3174
|
|
19
|
+
mcp_riskmap-0.1.2.dist-info/licenses/LICENSE,sha256=uX1ihazSovakOH230fz1aIKZjSUoSR24OI4VYACj_Uw,1081
|
|
20
|
+
mcp_riskmap-0.1.2.dist-info/METADATA,sha256=2eXzgphbHADAjIzwhOg7JQ_y5YpoxrJoJ4Kg0pfil6o,9435
|
|
21
|
+
mcp_riskmap-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
22
|
+
mcp_riskmap-0.1.2.dist-info/entry_points.txt,sha256=C_JZaF1-Xen4aYmmZRXdLlcE7j9TpfbH_XSSWYI7evM,53
|
|
23
|
+
mcp_riskmap-0.1.2.dist-info/top_level.txt,sha256=5mDuPI_3ugnancUhEuy4wthZwLC95wgXHv415tf1fRw,12
|
|
24
|
+
mcp_riskmap-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcp-riskmap contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_riskmap
|