defectdojo-mcp-server 0.1.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.
- defectdojo_mcp/__init__.py +10 -0
- defectdojo_mcp/__main__.py +4 -0
- defectdojo_mcp/app.py +165 -0
- defectdojo_mcp/cli.py +200 -0
- defectdojo_mcp/client.py +290 -0
- defectdojo_mcp/config.py +138 -0
- defectdojo_mcp/http_auth.py +68 -0
- defectdojo_mcp/prompts.py +58 -0
- defectdojo_mcp/server.py +66 -0
- defectdojo_mcp/shaping.py +379 -0
- defectdojo_mcp/tools/__init__.py +6 -0
- defectdojo_mcp/tools/common.py +44 -0
- defectdojo_mcp/tools/read.py +667 -0
- defectdojo_mcp/tools/write.py +510 -0
- defectdojo_mcp/untrusted.py +204 -0
- defectdojo_mcp/uploads.py +179 -0
- defectdojo_mcp_server-0.1.0.dist-info/METADATA +288 -0
- defectdojo_mcp_server-0.1.0.dist-info/RECORD +21 -0
- defectdojo_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- defectdojo_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
- defectdojo_mcp_server-0.1.0.dist-info/licenses/LICENSE +11 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""MCP server for DefectDojo."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("defectdojo-mcp-server")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only when running from a raw source tree
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
defectdojo_mcp/app.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Shared state and helpers used by every tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from mcp.server.mcpserver import Context
|
|
11
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
12
|
+
|
|
13
|
+
from defectdojo_mcp.client import DojoAPIError, DojoClient
|
|
14
|
+
from defectdojo_mcp.config import Settings
|
|
15
|
+
|
|
16
|
+
Transport = Literal["stdio", "http"]
|
|
17
|
+
HttpAuth = Literal["token", "passthrough"]
|
|
18
|
+
|
|
19
|
+
PASSTHROUGH_HEADER = "x-defectdojo-api-key"
|
|
20
|
+
|
|
21
|
+
# Test type rows can be created through the API, so names from that fallback
|
|
22
|
+
# source are only returned when they look like ordinary parser names.
|
|
23
|
+
_PLAIN_NAME = re.compile(r"^[A-Za-z0-9 ._()/+&:'-]{1,100}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def key_from_headers(headers: Mapping[str, str] | None) -> str | None:
|
|
27
|
+
"""Pull a caller-supplied DefectDojo key out of request headers."""
|
|
28
|
+
if not headers:
|
|
29
|
+
return None
|
|
30
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
31
|
+
direct = (lowered.get(PASSTHROUGH_HEADER) or "").strip()
|
|
32
|
+
if direct:
|
|
33
|
+
return direct
|
|
34
|
+
auth = (lowered.get("authorization") or "").strip()
|
|
35
|
+
scheme, _, value = auth.partition(" ")
|
|
36
|
+
if scheme.lower() in {"token", "bearer"} and value.strip():
|
|
37
|
+
return value.strip()
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class App:
|
|
43
|
+
settings: Settings
|
|
44
|
+
client: DojoClient
|
|
45
|
+
transport: Transport = "stdio"
|
|
46
|
+
http_auth: HttpAuth | None = None
|
|
47
|
+
_scan_types: list[str] | None = field(default=None, init=False, repr=False)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def passthrough(self) -> bool:
|
|
51
|
+
return self.transport == "http" and self.http_auth == "passthrough"
|
|
52
|
+
|
|
53
|
+
def api_key(self, ctx: Context | None) -> str:
|
|
54
|
+
if self.passthrough:
|
|
55
|
+
key = key_from_headers(ctx.headers if ctx is not None else None)
|
|
56
|
+
if not key:
|
|
57
|
+
raise ToolError(
|
|
58
|
+
"No DefectDojo API key was sent with this request. In pass-through mode, send it in the "
|
|
59
|
+
"X-DefectDojo-Api-Key header or as Authorization: Token <key>."
|
|
60
|
+
)
|
|
61
|
+
return key
|
|
62
|
+
if not self.settings.api_key:
|
|
63
|
+
raise ToolError("DEFECTDOJO_API_KEY is not configured on this server.")
|
|
64
|
+
return self.settings.api_key
|
|
65
|
+
|
|
66
|
+
async def scan_types(self, ctx: Context | None) -> tuple[list[str], str]:
|
|
67
|
+
"""Scan type names the instance can import, with the source they came from.
|
|
68
|
+
|
|
69
|
+
The import endpoint's OpenAPI enum lists every parser. Older instances
|
|
70
|
+
without that enum fall back to the test_types table, which only holds
|
|
71
|
+
types that were used before.
|
|
72
|
+
"""
|
|
73
|
+
if self._scan_types is not None:
|
|
74
|
+
return self._scan_types, "openapi"
|
|
75
|
+
api_key = self.api_key(ctx)
|
|
76
|
+
try:
|
|
77
|
+
schema = await self.client.openapi_schema(api_key)
|
|
78
|
+
except DojoAPIError as exc:
|
|
79
|
+
raise ToolError(exc.message) from exc
|
|
80
|
+
names = _scan_type_enum(schema)
|
|
81
|
+
if names:
|
|
82
|
+
self._scan_types = names
|
|
83
|
+
return names, "openapi"
|
|
84
|
+
data = await self.get(ctx, "test_types/", {"limit": 1000}, resource="test types")
|
|
85
|
+
rows = data.get("results", []) if isinstance(data, dict) else []
|
|
86
|
+
found = sorted({str(r.get("name")) for r in rows if isinstance(r, dict) and r.get("name")})
|
|
87
|
+
return [n for n in found if _PLAIN_NAME.match(n)], "test_types"
|
|
88
|
+
|
|
89
|
+
async def get(
|
|
90
|
+
self, ctx: Context | None, path: str, params: Mapping[str, Any] | None = None, resource: str | None = None
|
|
91
|
+
) -> Any:
|
|
92
|
+
try:
|
|
93
|
+
return await self.client.get(path, api_key=self.api_key(ctx), params=params, resource=resource)
|
|
94
|
+
except DojoAPIError as exc:
|
|
95
|
+
raise ToolError(exc.message) from exc
|
|
96
|
+
|
|
97
|
+
async def post(self, ctx: Context | None, path: str, body: Any, resource: str | None = None) -> Any:
|
|
98
|
+
try:
|
|
99
|
+
return await self.client.post(path, api_key=self.api_key(ctx), json_body=body, resource=resource)
|
|
100
|
+
except DojoAPIError as exc:
|
|
101
|
+
raise ToolError(exc.message) from exc
|
|
102
|
+
|
|
103
|
+
async def patch(self, ctx: Context | None, path: str, body: Any, resource: str | None = None) -> Any:
|
|
104
|
+
try:
|
|
105
|
+
return await self.client.patch(path, api_key=self.api_key(ctx), json_body=body, resource=resource)
|
|
106
|
+
except DojoAPIError as exc:
|
|
107
|
+
raise ToolError(exc.message) from exc
|
|
108
|
+
|
|
109
|
+
async def multipart(
|
|
110
|
+
self,
|
|
111
|
+
ctx: Context | None,
|
|
112
|
+
path: str,
|
|
113
|
+
data: Mapping[str, Any],
|
|
114
|
+
files: list[tuple[str, tuple[str, bytes, str]]],
|
|
115
|
+
resource: str | None = None,
|
|
116
|
+
) -> Any:
|
|
117
|
+
try:
|
|
118
|
+
return await self.client.request(
|
|
119
|
+
"POST", path, api_key=self.api_key(ctx), data=data, files=files, resource=resource
|
|
120
|
+
)
|
|
121
|
+
except DojoAPIError as exc:
|
|
122
|
+
raise ToolError(exc.message) from exc
|
|
123
|
+
|
|
124
|
+
async def page(
|
|
125
|
+
self,
|
|
126
|
+
ctx: Context | None,
|
|
127
|
+
path: str,
|
|
128
|
+
params: Mapping[str, Any],
|
|
129
|
+
limit: int,
|
|
130
|
+
offset: int,
|
|
131
|
+
shaper: Callable[[dict[str, Any]], dict[str, Any]],
|
|
132
|
+
resource: str,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""Fetch one page of a list endpoint and shape the results."""
|
|
135
|
+
data = await self.get(ctx, path, {**params, "limit": limit, "offset": offset}, resource=resource)
|
|
136
|
+
if isinstance(data, list):
|
|
137
|
+
results, count, has_next = data, len(data), False
|
|
138
|
+
elif isinstance(data, dict):
|
|
139
|
+
results = data.get("results") or []
|
|
140
|
+
count = data.get("count", len(results))
|
|
141
|
+
has_next = bool(data.get("next"))
|
|
142
|
+
else:
|
|
143
|
+
raise ToolError("DefectDojo returned an unexpected response shape for a list request.")
|
|
144
|
+
shaped = [shaper(r) for r in results if isinstance(r, dict)]
|
|
145
|
+
return {
|
|
146
|
+
"count": count,
|
|
147
|
+
"offset": offset,
|
|
148
|
+
"limit": limit,
|
|
149
|
+
"returned": len(shaped),
|
|
150
|
+
"next_offset": offset + len(results) if has_next else None,
|
|
151
|
+
"results": shaped,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _scan_type_enum(schema: dict[str, Any] | None) -> list[str]:
|
|
156
|
+
if not schema:
|
|
157
|
+
return []
|
|
158
|
+
try:
|
|
159
|
+
prop = schema["components"]["schemas"]["ImportScanRequest"]["properties"]["scan_type"]
|
|
160
|
+
except (KeyError, TypeError):
|
|
161
|
+
return []
|
|
162
|
+
values = prop.get("enum") if isinstance(prop, dict) else None
|
|
163
|
+
if not isinstance(values, list):
|
|
164
|
+
return []
|
|
165
|
+
return sorted({str(v) for v in values if isinstance(v, str) and _PLAIN_NAME.match(v)})
|
defectdojo_mcp/cli.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ipaddress
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from defectdojo_mcp import __version__
|
|
13
|
+
from defectdojo_mcp.config import ConfigError, Settings
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("defectdojo_mcp")
|
|
19
|
+
|
|
20
|
+
LOOPBACK_NAMES = {"localhost"}
|
|
21
|
+
|
|
22
|
+
EPILOG = """\
|
|
23
|
+
environment:
|
|
24
|
+
DEFECTDOJO_URL base URL of your DefectDojo instance (required)
|
|
25
|
+
DEFECTDOJO_API_KEY API v2 key (required, except in --http-auth passthrough)
|
|
26
|
+
DEFECTDOJO_VERIFY_SSL true (default), false, or a CA bundle path
|
|
27
|
+
DEFECTDOJO_TIMEOUT request timeout in seconds (default 30)
|
|
28
|
+
DEFECTDOJO_ALLOW_WRITES true to register write tools (default false)
|
|
29
|
+
DEFECTDOJO_IMPORT_DIRS folders scan files may be read from (unset = no file path imports)
|
|
30
|
+
DEFECTDOJO_MAX_UPLOAD_MB largest scan file to upload (default 25)
|
|
31
|
+
DEFECTDOJO_MCP_AUTH_TOKEN bearer token clients must send in HTTP token mode
|
|
32
|
+
DEFECTDOJO_MCP_ALLOWED_HOSTS extra Host header values allowed in HTTP mode
|
|
33
|
+
|
|
34
|
+
docs: https://github.com/DefectDojo/defectdojo-mcp
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RedactingFilter(logging.Filter):
|
|
39
|
+
"""Replace secret values in any log record before it is written."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, secrets: Sequence[str]) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.secrets = [s for s in secrets if s and len(s) >= 4]
|
|
44
|
+
|
|
45
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
46
|
+
if not self.secrets:
|
|
47
|
+
return True
|
|
48
|
+
message = record.getMessage()
|
|
49
|
+
redacted = message
|
|
50
|
+
for secret in self.secrets:
|
|
51
|
+
redacted = redacted.replace(secret, "[REDACTED]")
|
|
52
|
+
if redacted != message:
|
|
53
|
+
record.msg = redacted
|
|
54
|
+
record.args = None
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def configure_logging(level: str, secrets: Sequence[str]) -> None:
|
|
59
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
60
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
|
61
|
+
handler.addFilter(RedactingFilter(secrets))
|
|
62
|
+
root = logging.getLogger()
|
|
63
|
+
for existing in list(root.handlers):
|
|
64
|
+
root.removeHandler(existing)
|
|
65
|
+
root.addHandler(handler)
|
|
66
|
+
root.setLevel(level)
|
|
67
|
+
# httpx logs every request URL at INFO. URLs carry no secrets, but keep the noise down.
|
|
68
|
+
logging.getLogger("httpx").setLevel(max(logging.WARNING, logging.getLevelName(level)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_loopback(host: str) -> bool:
|
|
72
|
+
if host.lower() in LOOPBACK_NAMES:
|
|
73
|
+
return True
|
|
74
|
+
try:
|
|
75
|
+
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
|
76
|
+
except ValueError:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
LOOPBACK_HOSTS = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
|
|
81
|
+
LOOPBACK_ORIGINS = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def transport_security(settings: Settings, host: str) -> TransportSecuritySettings | None:
|
|
85
|
+
"""Host and Origin checks against DNS rebinding.
|
|
86
|
+
|
|
87
|
+
Always on for a loopback bind, whatever its spelling (the SDK only turns
|
|
88
|
+
them on by itself for "127.0.0.1", "localhost" and "::1"), and on for any
|
|
89
|
+
bind once DEFECTDOJO_MCP_ALLOWED_HOSTS is set. Loopback clients such as a
|
|
90
|
+
local inspector keep working when extra hosts are added.
|
|
91
|
+
"""
|
|
92
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
93
|
+
|
|
94
|
+
loopback = is_loopback(host)
|
|
95
|
+
if not loopback and not settings.allowed_hosts:
|
|
96
|
+
return None
|
|
97
|
+
hosts = list(settings.allowed_hosts)
|
|
98
|
+
origins = [f"https://{h}" for h in settings.allowed_hosts] + [f"http://{h}" for h in settings.allowed_hosts]
|
|
99
|
+
if loopback:
|
|
100
|
+
hosts += LOOPBACK_HOSTS
|
|
101
|
+
origins += LOOPBACK_ORIGINS
|
|
102
|
+
return TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=hosts, allowed_origins=origins)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog="defectdojo-mcp",
|
|
108
|
+
description="MCP server for DefectDojo. Read-only unless DEFECTDOJO_ALLOW_WRITES=true.",
|
|
109
|
+
epilog=EPILOG,
|
|
110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--transport", choices=("stdio", "http"), default="stdio", help="stdio (default) or streamable HTTP"
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind address (default 127.0.0.1)")
|
|
117
|
+
parser.add_argument("--port", type=int, default=8000, help="HTTP port (default 8000)")
|
|
118
|
+
parser.add_argument("--path", default="/mcp", help="HTTP endpoint path (default /mcp)")
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--http-auth",
|
|
121
|
+
choices=("token", "passthrough"),
|
|
122
|
+
default="token",
|
|
123
|
+
help="HTTP client auth: a shared bearer token (default) or each caller's own DefectDojo key",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--log-level", default="WARNING", choices=("DEBUG", "INFO", "WARNING", "ERROR"), help="log level (stderr)"
|
|
127
|
+
)
|
|
128
|
+
return parser
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
132
|
+
args = build_parser().parse_args(argv)
|
|
133
|
+
passthrough = args.transport == "http" and args.http_auth == "passthrough"
|
|
134
|
+
try:
|
|
135
|
+
settings = Settings.from_env(require_api_key=not passthrough)
|
|
136
|
+
except ConfigError as exc:
|
|
137
|
+
print(f"defectdojo-mcp: {exc}", file=sys.stderr)
|
|
138
|
+
return 2
|
|
139
|
+
|
|
140
|
+
configure_logging(args.log_level, settings.secrets())
|
|
141
|
+
|
|
142
|
+
from defectdojo_mcp.server import build_server
|
|
143
|
+
|
|
144
|
+
if args.transport == "stdio":
|
|
145
|
+
mcp, _ = build_server(settings, transport="stdio")
|
|
146
|
+
mcp.run("stdio")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
return run_http(args, settings)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_http(args: argparse.Namespace, settings: Settings) -> int:
|
|
153
|
+
import uvicorn
|
|
154
|
+
|
|
155
|
+
from defectdojo_mcp.http_auth import AuthMiddleware
|
|
156
|
+
from defectdojo_mcp.server import build_server
|
|
157
|
+
|
|
158
|
+
if not 1 <= args.port <= 65535:
|
|
159
|
+
print("defectdojo-mcp: --port must be between 1 and 65535", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
if args.http_auth == "token" and not settings.http_auth_token:
|
|
162
|
+
print(
|
|
163
|
+
"defectdojo-mcp: HTTP mode needs DEFECTDOJO_MCP_AUTH_TOKEN (clients send it as a bearer token), "
|
|
164
|
+
"or use --http-auth passthrough so each client sends its own DefectDojo API key.\n"
|
|
165
|
+
'Generate a token with: python -c "import secrets; print(secrets.token_urlsafe(32))"',
|
|
166
|
+
file=sys.stderr,
|
|
167
|
+
)
|
|
168
|
+
return 2
|
|
169
|
+
if args.http_auth == "token" and settings.http_auth_token and len(settings.http_auth_token) < 16:
|
|
170
|
+
print("defectdojo-mcp: DEFECTDOJO_MCP_AUTH_TOKEN must be at least 16 characters.", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
if args.http_auth == "passthrough" and settings.api_key:
|
|
173
|
+
logger.warning("Pass-through mode ignores DEFECTDOJO_API_KEY. Each client must send its own key.")
|
|
174
|
+
|
|
175
|
+
loopback = is_loopback(args.host)
|
|
176
|
+
security = transport_security(settings, args.host)
|
|
177
|
+
if not loopback:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"Listening on %s, which is not a loopback address. Put the server behind TLS and set "
|
|
180
|
+
"DEFECTDOJO_MCP_ALLOWED_HOSTS to the public host name.",
|
|
181
|
+
args.host,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
mcp, _ = build_server(settings, transport="http", http_auth=args.http_auth)
|
|
185
|
+
# Leave room for base64 encoded uploads plus the JSON-RPC envelope.
|
|
186
|
+
body_limit = (settings.max_upload_bytes * 4) // 3 + 1024 * 1024
|
|
187
|
+
starlette_app = mcp.streamable_http_app(
|
|
188
|
+
streamable_http_path=args.path,
|
|
189
|
+
max_request_body_size=body_limit,
|
|
190
|
+
transport_security=security,
|
|
191
|
+
host=args.host,
|
|
192
|
+
)
|
|
193
|
+
wrapped = AuthMiddleware(starlette_app, args.http_auth, settings.http_auth_token)
|
|
194
|
+
print(
|
|
195
|
+
f"defectdojo-mcp {__version__} listening on http://{args.host}:{args.port}{args.path} "
|
|
196
|
+
f"(auth: {args.http_auth}, writes: {'on' if settings.allow_writes else 'off'})",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
uvicorn.run(wrapped, host=args.host, port=args.port, log_level=args.log_level.lower(), access_log=False)
|
|
200
|
+
return 0
|
defectdojo_mcp/client.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Small async client for the DefectDojo REST API v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import anyio
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from defectdojo_mcp import __version__
|
|
15
|
+
from defectdojo_mcp.config import Settings
|
|
16
|
+
from defectdojo_mcp.untrusted import wrap
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
21
|
+
MAX_RETRIES = 2
|
|
22
|
+
MAX_RETRY_WAIT = 5.0
|
|
23
|
+
|
|
24
|
+
Params = Mapping[str, Any]
|
|
25
|
+
FileTuple = tuple[str, bytes, str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MAYBE_APPLIED = " The change may still have been applied, so check DefectDojo before trying again."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DojoAPIError(Exception):
|
|
32
|
+
"""An error talking to DefectDojo, with a message safe to show to the model."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.message = message
|
|
37
|
+
self.status = status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def redact(text: str, secrets: Sequence[str]) -> str:
|
|
41
|
+
for secret in secrets:
|
|
42
|
+
if secret and len(secret) >= 4:
|
|
43
|
+
text = text.replace(secret, "[REDACTED]")
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clean_params(params: Params | None) -> dict[str, str] | None:
|
|
48
|
+
"""Drop None values and turn booleans and lists into the strings DefectDojo expects."""
|
|
49
|
+
if not params:
|
|
50
|
+
return None
|
|
51
|
+
out: dict[str, str] = {}
|
|
52
|
+
for key, value in params.items():
|
|
53
|
+
if value is None:
|
|
54
|
+
continue
|
|
55
|
+
if isinstance(value, bool):
|
|
56
|
+
out[key] = "true" if value else "false"
|
|
57
|
+
elif isinstance(value, (list, tuple, set)):
|
|
58
|
+
if not value:
|
|
59
|
+
continue
|
|
60
|
+
out[key] = ",".join(str(v) for v in value)
|
|
61
|
+
else:
|
|
62
|
+
out[key] = str(value)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _error_detail(response: httpx.Response) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
body = response.json()
|
|
69
|
+
except (json.JSONDecodeError, ValueError):
|
|
70
|
+
text = response.text.strip()
|
|
71
|
+
return wrap(text, 500) if text else None
|
|
72
|
+
if isinstance(body, dict) and set(body) == {"detail"}:
|
|
73
|
+
return wrap(body["detail"], 500)
|
|
74
|
+
return wrap(json.dumps(body, ensure_ascii=False), 1500)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
78
|
+
raw = response.headers.get("Retry-After")
|
|
79
|
+
if raw is None:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return max(0.0, float(raw))
|
|
83
|
+
except ValueError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DojoClient:
|
|
88
|
+
"""Thin wrapper around httpx.AsyncClient.
|
|
89
|
+
|
|
90
|
+
The API key is passed per call so that HTTP pass-through mode can use the
|
|
91
|
+
caller's own DefectDojo key. Redirects are never followed, which keeps the
|
|
92
|
+
Authorization header from leaking to another host.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
retry_base_delay = 0.5
|
|
96
|
+
|
|
97
|
+
def __init__(self, settings: Settings, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
98
|
+
self.settings = settings
|
|
99
|
+
self._http = httpx.AsyncClient(
|
|
100
|
+
base_url=settings.api_base + "/",
|
|
101
|
+
timeout=httpx.Timeout(settings.timeout),
|
|
102
|
+
verify=settings.verify_ssl,
|
|
103
|
+
follow_redirects=False,
|
|
104
|
+
headers={"Accept": "application/json", "User-Agent": f"defectdojo-mcp/{__version__}"},
|
|
105
|
+
transport=transport,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def aclose(self) -> None:
|
|
109
|
+
await self._http.aclose()
|
|
110
|
+
|
|
111
|
+
def _secrets(self, api_key: str | None) -> list[str]:
|
|
112
|
+
return [*self.settings.secrets(), *([api_key] if api_key else [])]
|
|
113
|
+
|
|
114
|
+
async def request(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
api_key: str | None,
|
|
120
|
+
params: Params | None = None,
|
|
121
|
+
json_body: Any = None,
|
|
122
|
+
data: Mapping[str, Any] | None = None,
|
|
123
|
+
files: Sequence[tuple[str, FileTuple]] | None = None,
|
|
124
|
+
resource: str | None = None,
|
|
125
|
+
) -> Any:
|
|
126
|
+
if not api_key:
|
|
127
|
+
raise DojoAPIError("No DefectDojo API key is available for this request.")
|
|
128
|
+
path = path.lstrip("/")
|
|
129
|
+
headers = {"Authorization": f"Token {api_key}"}
|
|
130
|
+
idempotent = method.upper() == "GET"
|
|
131
|
+
attempt = 0
|
|
132
|
+
while True:
|
|
133
|
+
try:
|
|
134
|
+
response = await self._http.request(
|
|
135
|
+
method,
|
|
136
|
+
path,
|
|
137
|
+
params=_clean_params(params),
|
|
138
|
+
json=json_body,
|
|
139
|
+
data=data,
|
|
140
|
+
files=files,
|
|
141
|
+
headers=headers,
|
|
142
|
+
)
|
|
143
|
+
except httpx.TimeoutException as exc:
|
|
144
|
+
if idempotent and attempt < MAX_RETRIES:
|
|
145
|
+
attempt += 1
|
|
146
|
+
await anyio.sleep(self.retry_base_delay * attempt)
|
|
147
|
+
continue
|
|
148
|
+
message = f"Timed out after {self.settings.timeout:g}s waiting for DefectDojo."
|
|
149
|
+
if idempotent or isinstance(exc, httpx.ConnectTimeout):
|
|
150
|
+
message += " Try a narrower query or raise DEFECTDOJO_TIMEOUT."
|
|
151
|
+
else:
|
|
152
|
+
# The request was sent, so DefectDojo may have finished the write after we gave up.
|
|
153
|
+
message += MAYBE_APPLIED + " Raise DEFECTDOJO_TIMEOUT for large imports."
|
|
154
|
+
raise DojoAPIError(message) from exc
|
|
155
|
+
except httpx.HTTPError as exc:
|
|
156
|
+
if idempotent and attempt < MAX_RETRIES:
|
|
157
|
+
attempt += 1
|
|
158
|
+
await anyio.sleep(self.retry_base_delay * attempt)
|
|
159
|
+
continue
|
|
160
|
+
message = redact(
|
|
161
|
+
f"Could not reach DefectDojo at {self.settings.url} ({type(exc).__name__}). "
|
|
162
|
+
"Check DEFECTDOJO_URL, network access and TLS settings.",
|
|
163
|
+
self._secrets(api_key),
|
|
164
|
+
)
|
|
165
|
+
if not idempotent and not isinstance(exc, httpx.ConnectError):
|
|
166
|
+
message += MAYBE_APPLIED
|
|
167
|
+
raise DojoAPIError(message) from exc
|
|
168
|
+
|
|
169
|
+
if response.status_code in RETRY_STATUSES and idempotent and attempt < MAX_RETRIES:
|
|
170
|
+
attempt += 1
|
|
171
|
+
wait = _retry_after(response)
|
|
172
|
+
delay = min(wait if wait is not None else self.retry_base_delay * attempt, MAX_RETRY_WAIT)
|
|
173
|
+
logger.info("DefectDojo answered %s, retrying in %.1fs", response.status_code, delay)
|
|
174
|
+
await anyio.sleep(delay)
|
|
175
|
+
continue
|
|
176
|
+
return self._handle(response, api_key, resource, idempotent)
|
|
177
|
+
|
|
178
|
+
def _handle(self, response: httpx.Response, api_key: str, resource: str | None, idempotent: bool = True) -> Any:
|
|
179
|
+
status = response.status_code
|
|
180
|
+
secrets = self._secrets(api_key)
|
|
181
|
+
if 200 <= status < 300:
|
|
182
|
+
if status == 204 or not response.content:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
return response.json()
|
|
186
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
187
|
+
raise DojoAPIError(
|
|
188
|
+
"DefectDojo returned a response that is not JSON. "
|
|
189
|
+
"Check that DEFECTDOJO_URL points at the DefectDojo base URL.",
|
|
190
|
+
status,
|
|
191
|
+
) from exc
|
|
192
|
+
|
|
193
|
+
detail = _error_detail(response)
|
|
194
|
+
what = resource or "object"
|
|
195
|
+
if 300 <= status < 400:
|
|
196
|
+
message = (
|
|
197
|
+
f"DefectDojo answered with a redirect ({status}). Check that DEFECTDOJO_URL uses the right "
|
|
198
|
+
"scheme and host, for example https:// instead of http://."
|
|
199
|
+
)
|
|
200
|
+
elif status == 400:
|
|
201
|
+
message = "DefectDojo rejected the request as invalid (400)."
|
|
202
|
+
if detail:
|
|
203
|
+
message += f" Details: {detail}"
|
|
204
|
+
elif status == 401:
|
|
205
|
+
message = "DefectDojo did not accept the API key (401). It may be wrong, revoked or expired."
|
|
206
|
+
elif status == 403:
|
|
207
|
+
message = (
|
|
208
|
+
"Permission denied (403). The API key is invalid or its user lacks permission for this "
|
|
209
|
+
f"{what} or action."
|
|
210
|
+
)
|
|
211
|
+
if detail:
|
|
212
|
+
message += f" Details: {detail}"
|
|
213
|
+
elif status == 404:
|
|
214
|
+
message = f"Not found (404): the {what} does not exist or the API key's user cannot see it."
|
|
215
|
+
elif status == 405:
|
|
216
|
+
message = "This DefectDojo version does not support this operation (405)."
|
|
217
|
+
elif status == 413:
|
|
218
|
+
message = "The DefectDojo server rejected the upload as too large (413)."
|
|
219
|
+
elif status == 429:
|
|
220
|
+
wait = _retry_after(response)
|
|
221
|
+
hint = f" Retry after {wait:g} seconds." if wait is not None else " Wait a moment and try again."
|
|
222
|
+
message = "DefectDojo is rate limiting requests (429)." + hint
|
|
223
|
+
elif status >= 500:
|
|
224
|
+
message = (
|
|
225
|
+
f"DefectDojo had a server error ({status}). Try again later, or check the DefectDojo logs "
|
|
226
|
+
"if it keeps happening."
|
|
227
|
+
)
|
|
228
|
+
if not idempotent:
|
|
229
|
+
message += MAYBE_APPLIED
|
|
230
|
+
else:
|
|
231
|
+
message = f"DefectDojo returned an unexpected status ({status})."
|
|
232
|
+
if detail:
|
|
233
|
+
message += f" Details: {detail}"
|
|
234
|
+
raise DojoAPIError(redact(message, secrets), status)
|
|
235
|
+
|
|
236
|
+
async def get(
|
|
237
|
+
self, path: str, *, api_key: str | None, params: Params | None = None, resource: str | None = None
|
|
238
|
+
) -> Any:
|
|
239
|
+
return await self.request("GET", path, api_key=api_key, params=params, resource=resource)
|
|
240
|
+
|
|
241
|
+
async def post(self, path: str, *, api_key: str | None, json_body: Any = None, resource: str | None = None) -> Any:
|
|
242
|
+
return await self.request("POST", path, api_key=api_key, json_body=json_body, resource=resource)
|
|
243
|
+
|
|
244
|
+
async def patch(self, path: str, *, api_key: str | None, json_body: Any = None, resource: str | None = None) -> Any:
|
|
245
|
+
return await self.request("PATCH", path, api_key=api_key, json_body=json_body, resource=resource)
|
|
246
|
+
|
|
247
|
+
async def raw_get(self, url: str, *, headers: Mapping[str, str] | None = None) -> httpx.Response:
|
|
248
|
+
"""Unauthenticated GET used only for best-effort edition detection."""
|
|
249
|
+
return await self._http.get(url, headers=dict(headers or {}))
|
|
250
|
+
|
|
251
|
+
async def openapi_schema(self, api_key: str) -> dict[str, Any] | None:
|
|
252
|
+
"""Download the full OpenAPI schema, or None if the instance does not serve it."""
|
|
253
|
+
try:
|
|
254
|
+
data = await self.get("oa3/schema/", api_key=api_key, params={"format": "json"}, resource="API schema")
|
|
255
|
+
except DojoAPIError as exc:
|
|
256
|
+
if exc.status in {401, 403}:
|
|
257
|
+
raise
|
|
258
|
+
return None
|
|
259
|
+
return data if isinstance(data, dict) else None
|
|
260
|
+
|
|
261
|
+
async def schema_version(self, api_key: str | None) -> str | None:
|
|
262
|
+
"""Read info.version from the OpenAPI schema without downloading all of it.
|
|
263
|
+
|
|
264
|
+
drf-spectacular writes "openapi" and "info" first, so the first few
|
|
265
|
+
kilobytes are enough. Returns None if the schema is unavailable.
|
|
266
|
+
"""
|
|
267
|
+
if not api_key:
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
async with self._http.stream(
|
|
271
|
+
"GET",
|
|
272
|
+
"oa3/schema/",
|
|
273
|
+
params={"format": "json"},
|
|
274
|
+
headers={"Authorization": f"Token {api_key}"},
|
|
275
|
+
) as response:
|
|
276
|
+
if response.status_code != 200:
|
|
277
|
+
return None
|
|
278
|
+
buf = b""
|
|
279
|
+
async for chunk in response.aiter_bytes():
|
|
280
|
+
buf += chunk
|
|
281
|
+
if len(buf) >= 16384:
|
|
282
|
+
break
|
|
283
|
+
except httpx.HTTPError:
|
|
284
|
+
return None
|
|
285
|
+
return _extract_info_version(buf.decode("utf-8", errors="replace"))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_info_version(text: str) -> str | None:
|
|
289
|
+
match = re.search(r'"info"\s*:\s*\{.*?"version"\s*:\s*"([^"]{1,64})"', text, re.DOTALL)
|
|
290
|
+
return match.group(1) if match else None
|