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.
@@ -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__"]
@@ -0,0 +1,4 @@
1
+ from defectdojo_mcp.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
@@ -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