mcp-vulnerabilities 1.0.0__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_vulnerabilities/__init__.py +51 -0
- mcp_vulnerabilities/audit/__init__.py +14 -0
- mcp_vulnerabilities/audit/fixer.py +164 -0
- mcp_vulnerabilities/audit/matcher.py +493 -0
- mcp_vulnerabilities/audit/parsers.py +179 -0
- mcp_vulnerabilities/audit/reporters.py +150 -0
- mcp_vulnerabilities/catalog.py +148 -0
- mcp_vulnerabilities/cli.py +800 -0
- mcp_vulnerabilities/converters/__init__.py +17 -0
- mcp_vulnerabilities/converters/cve_json5.py +246 -0
- mcp_vulnerabilities/converters/ghsa.py +217 -0
- mcp_vulnerabilities/converters/markdown_cve.py +334 -0
- mcp_vulnerabilities/converters/nvd.py +251 -0
- mcp_vulnerabilities/converters/osv_dev.py +206 -0
- mcp_vulnerabilities/converters/verity.py +120 -0
- mcp_vulnerabilities/deduplicator.py +275 -0
- mcp_vulnerabilities/discovery/__init__.py +8 -0
- mcp_vulnerabilities/discovery/enricher.py +97 -0
- mcp_vulnerabilities/discovery/github_curated.py +103 -0
- mcp_vulnerabilities/discovery/glama.py +74 -0
- mcp_vulnerabilities/discovery/npm.py +80 -0
- mcp_vulnerabilities/discovery/orchestrator.py +112 -0
- mcp_vulnerabilities/discovery/pypi.py +85 -0
- mcp_vulnerabilities/discovery/registries.py +105 -0
- mcp_vulnerabilities/discovery/smithery.py +85 -0
- mcp_vulnerabilities/feed.py +223 -0
- mcp_vulnerabilities/filter.py +158 -0
- mcp_vulnerabilities/models.py +346 -0
- mcp_vulnerabilities/notifier.py +124 -0
- mcp_vulnerabilities/pipeline.py +776 -0
- mcp_vulnerabilities/probe/__init__.py +0 -0
- mcp_vulnerabilities/probe/client.py +83 -0
- mcp_vulnerabilities/probe/fingerprinter.py +104 -0
- mcp_vulnerabilities/probe/transport.py +135 -0
- mcp_vulnerabilities/site_builder.py +137 -0
- mcp_vulnerabilities/snapshot.py +328 -0
- mcp_vulnerabilities/state.py +116 -0
- mcp_vulnerabilities/transitive.py +296 -0
- mcp_vulnerabilities/validator.py +186 -0
- mcp_vulnerabilities-1.0.0.dist-info/METADATA +110 -0
- mcp_vulnerabilities-1.0.0.dist-info/RECORD +44 -0
- mcp_vulnerabilities-1.0.0.dist-info/WHEEL +5 -0
- mcp_vulnerabilities-1.0.0.dist-info/entry_points.txt +2 -0
- mcp_vulnerabilities-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Open Source Vulnerability (OSV v1.6.0) ingestion and normalization engine."""
|
|
2
|
+
|
|
3
|
+
from mcp_vulnerabilities.converters.ghsa import GhsaConverter
|
|
4
|
+
from mcp_vulnerabilities.converters.markdown_cve import MarkdownAdvisoryConverter
|
|
5
|
+
from mcp_vulnerabilities.converters.nvd import NvdConverter
|
|
6
|
+
from mcp_vulnerabilities.converters.osv_dev import OsvDevConverter
|
|
7
|
+
from mcp_vulnerabilities.converters.verity import VerityCatalogConverter
|
|
8
|
+
from mcp_vulnerabilities.deduplicator import OsvDeduplicator
|
|
9
|
+
from mcp_vulnerabilities.models import (
|
|
10
|
+
AffectedPackage,
|
|
11
|
+
DatabaseSpecificMcp,
|
|
12
|
+
EventSpec,
|
|
13
|
+
OsvVulnerability,
|
|
14
|
+
PackageSpec,
|
|
15
|
+
RangeSpec,
|
|
16
|
+
RangeType,
|
|
17
|
+
ReferenceSpec,
|
|
18
|
+
ReferenceType,
|
|
19
|
+
SeveritySpec,
|
|
20
|
+
SeverityType,
|
|
21
|
+
build_purl,
|
|
22
|
+
parse_purl,
|
|
23
|
+
)
|
|
24
|
+
from mcp_vulnerabilities.pipeline import McpVulnerabilityPipeline, PipelineResult
|
|
25
|
+
from mcp_vulnerabilities.validator import OsvValidationError, OsvValidator
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AffectedPackage",
|
|
29
|
+
"DatabaseSpecificMcp",
|
|
30
|
+
"EventSpec",
|
|
31
|
+
"GhsaConverter",
|
|
32
|
+
"MarkdownAdvisoryConverter",
|
|
33
|
+
"McpVulnerabilityPipeline",
|
|
34
|
+
"NvdConverter",
|
|
35
|
+
"OsvDeduplicator",
|
|
36
|
+
"OsvDevConverter",
|
|
37
|
+
"OsvValidationError",
|
|
38
|
+
"OsvValidator",
|
|
39
|
+
"OsvVulnerability",
|
|
40
|
+
"PackageSpec",
|
|
41
|
+
"PipelineResult",
|
|
42
|
+
"RangeSpec",
|
|
43
|
+
"RangeType",
|
|
44
|
+
"ReferenceSpec",
|
|
45
|
+
"ReferenceType",
|
|
46
|
+
"SeveritySpec",
|
|
47
|
+
"SeverityType",
|
|
48
|
+
"VerityCatalogConverter",
|
|
49
|
+
"build_purl",
|
|
50
|
+
"parse_purl",
|
|
51
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Audit subpackage for MCP client configs and installed packages."""
|
|
2
|
+
|
|
3
|
+
from .fixer import ClientConfigFixer
|
|
4
|
+
from .matcher import AuditFinding, AuditReport, VulnerabilityMatcher
|
|
5
|
+
from .parsers import ClientConfigParser, DiscoveredClientServer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AuditFinding",
|
|
9
|
+
"AuditReport",
|
|
10
|
+
"ClientConfigFixer",
|
|
11
|
+
"ClientConfigParser",
|
|
12
|
+
"DiscoveredClientServer",
|
|
13
|
+
"VulnerabilityMatcher",
|
|
14
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Automated remediation for vulnerable MCP servers in client configurations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
from .matcher import VulnerabilityMatcher, AuditFinding
|
|
15
|
+
from .parsers import ClientConfigParser
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClientConfigFixer:
|
|
21
|
+
"""Identifies and remediates vulnerable MCP servers in client configurations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, matcher: VulnerabilityMatcher):
|
|
24
|
+
self.matcher = matcher
|
|
25
|
+
|
|
26
|
+
def _calculate_minimum_safe_version(self, findings: list[AuditFinding]) -> str | None:
|
|
27
|
+
"""Calculates the highest fixed version among all findings."""
|
|
28
|
+
if not findings:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
max_version: Version | None = None
|
|
32
|
+
best_version_str: str | None = None
|
|
33
|
+
|
|
34
|
+
for f in findings:
|
|
35
|
+
if not f.fixed_version:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
v = Version(f.fixed_version)
|
|
40
|
+
if max_version is None or v > max_version:
|
|
41
|
+
max_version = v
|
|
42
|
+
best_version_str = f.fixed_version
|
|
43
|
+
except InvalidVersion:
|
|
44
|
+
# If we can't parse semver, just take the first one or continue
|
|
45
|
+
if max_version is None:
|
|
46
|
+
best_version_str = f.fixed_version
|
|
47
|
+
|
|
48
|
+
return best_version_str
|
|
49
|
+
|
|
50
|
+
def fix_config(self, config_path: Path, dry_run: bool = False) -> dict[str, Any]:
|
|
51
|
+
"""Fixes a client configuration file by replacing vulnerable versions."""
|
|
52
|
+
if not config_path.exists():
|
|
53
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
54
|
+
|
|
55
|
+
servers = ClientConfigParser.parse_file(config_path)
|
|
56
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
57
|
+
data = json.load(f)
|
|
58
|
+
|
|
59
|
+
report = self.matcher.audit_servers(servers)
|
|
60
|
+
|
|
61
|
+
# Group findings by server name
|
|
62
|
+
findings_by_server: dict[str, list[AuditFinding]] = {}
|
|
63
|
+
for f in report.findings:
|
|
64
|
+
findings_by_server.setdefault(f.server_name, []).append(f)
|
|
65
|
+
|
|
66
|
+
changes_made = []
|
|
67
|
+
|
|
68
|
+
# We need to find the specific mcpServers key or use root
|
|
69
|
+
raw_servers = data.get("mcpServers") or data.get("mcp_servers") or data.get("servers")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if not isinstance(raw_servers, dict):
|
|
73
|
+
if all(isinstance(v, dict) and ("command" in v or "args" in v) for v in data.values()):
|
|
74
|
+
raw_servers = data
|
|
75
|
+
|
|
76
|
+
else:
|
|
77
|
+
return {"status": "no_servers_found"}
|
|
78
|
+
|
|
79
|
+
for server in servers:
|
|
80
|
+
findings = findings_by_server.get(server.server_name)
|
|
81
|
+
if not findings:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
safe_version = self._calculate_minimum_safe_version(findings)
|
|
85
|
+
if not safe_version:
|
|
86
|
+
logger.warning(f"No safe version found for {server.server_name}, skipping.")
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# If the current version is already >= safe_version (e.g. unpinned), skip
|
|
90
|
+
try:
|
|
91
|
+
if server.version and Version(server.version) >= Version(safe_version):
|
|
92
|
+
continue
|
|
93
|
+
except InvalidVersion:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
old_version = server.version or "unpinned"
|
|
97
|
+
|
|
98
|
+
# Find the args in the original dict
|
|
99
|
+
srv_dict = raw_servers.get(server.server_name)
|
|
100
|
+
if not srv_dict or not isinstance(srv_dict, dict):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
args = srv_dict.get("args", [])
|
|
104
|
+
new_args = list(args)
|
|
105
|
+
|
|
106
|
+
# We need to rewrite the args
|
|
107
|
+
# Simple approach: find the arg that contains the package name and old version
|
|
108
|
+
for i, arg in enumerate(new_args):
|
|
109
|
+
if server.package_name in str(arg):
|
|
110
|
+
# Rewrite this arg based on ecosystem
|
|
111
|
+
if server.ecosystem == "npm":
|
|
112
|
+
if "@" in server.package_name and server.package_name.startswith("@"):
|
|
113
|
+
# scoped package
|
|
114
|
+
new_args[i] = f"{server.package_name}@{safe_version}"
|
|
115
|
+
else:
|
|
116
|
+
new_args[i] = f"{server.package_name}@{safe_version}"
|
|
117
|
+
elif server.ecosystem == "PyPI":
|
|
118
|
+
# We use == for pipx/uvx
|
|
119
|
+
if "==" in arg or ">=" in arg:
|
|
120
|
+
new_args[i] = re.sub(r"(==|>=|<=|>|<|~=|@).*", f"=={safe_version}", arg)
|
|
121
|
+
else:
|
|
122
|
+
new_args[i] = f"{arg}=={safe_version}"
|
|
123
|
+
elif server.ecosystem == "Docker":
|
|
124
|
+
if ":" in arg:
|
|
125
|
+
pkg = arg.rsplit(":", 1)[0]
|
|
126
|
+
new_args[i] = f"{pkg}:{safe_version}"
|
|
127
|
+
else:
|
|
128
|
+
new_args[i] = f"{arg}:{safe_version}"
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
srv_dict["args"] = new_args
|
|
132
|
+
changes_made.append({
|
|
133
|
+
"server_name": server.server_name,
|
|
134
|
+
"package_name": server.package_name,
|
|
135
|
+
"old_version": old_version,
|
|
136
|
+
"new_version": safe_version
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if not changes_made:
|
|
140
|
+
return {"status": "no_changes_needed", "changes": []}
|
|
141
|
+
|
|
142
|
+
if not dry_run:
|
|
143
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
144
|
+
backup_path = config_path.with_name(f"{config_path.name}.bak.{timestamp}")
|
|
145
|
+
shutil.copy2(config_path, backup_path)
|
|
146
|
+
|
|
147
|
+
# Atomically write
|
|
148
|
+
tmp_path = config_path.with_name(f"{config_path.name}.tmp")
|
|
149
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
150
|
+
json.dump(data, f, indent=2)
|
|
151
|
+
f.write("\n")
|
|
152
|
+
os.replace(tmp_path, config_path)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"status": "success",
|
|
156
|
+
"changes": changes_made,
|
|
157
|
+
"backup_path": str(backup_path)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"status": "dry_run",
|
|
162
|
+
"changes": changes_made
|
|
163
|
+
}
|
|
164
|
+
|