vulnotes 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.
vulnotes/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Official Python SDK for the Vulnotes API.
2
+
3
+ Usage:
4
+ >>> from vulnotes import VulnotesClient
5
+ >>> client = VulnotesClient("https://acme.vulnotes.app", api_key="...")
6
+ >>> report = client.reports.create("External pentest Q3")
7
+ >>> client.findings.add(report["_id"], {"title": "Open SSH port"})
8
+ """
9
+
10
+ from ._version import __version__
11
+ from .client import VulnotesClient
12
+ from .exceptions import (
13
+ APIConnectionError,
14
+ APIStatusError,
15
+ APITimeoutError,
16
+ AuthenticationError,
17
+ BadRequestError,
18
+ ConflictError,
19
+ NotFoundError,
20
+ PermissionDeniedError,
21
+ RateLimitError,
22
+ ServerError,
23
+ UnprocessableEntityError,
24
+ VulnotesError,
25
+ )
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ "VulnotesClient",
30
+ "VulnotesError",
31
+ "APIConnectionError",
32
+ "APITimeoutError",
33
+ "APIStatusError",
34
+ "BadRequestError",
35
+ "AuthenticationError",
36
+ "PermissionDeniedError",
37
+ "NotFoundError",
38
+ "ConflictError",
39
+ "UnprocessableEntityError",
40
+ "RateLimitError",
41
+ "ServerError",
42
+ ]
vulnotes/_utils.py ADDED
@@ -0,0 +1,65 @@
1
+ """Internal helpers shared by the resource modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import io
7
+ import mimetypes
8
+ import os
9
+ from typing import Any, Union
10
+
11
+ # A file argument may be a filesystem path, raw bytes, an open file object,
12
+ # or a (filename, fileobj_or_bytes) / (filename, fileobj_or_bytes, content_type) tuple.
13
+ FileTypes = Union[str, "os.PathLike[str]", bytes, io.IOBase, tuple]
14
+
15
+
16
+ def omit_none(mapping: dict[str, Any]) -> dict[str, Any]:
17
+ """Drop keys whose value is ``None`` (the API treats absent fields as unchanged)."""
18
+ return {k: v for k, v in mapping.items() if v is not None}
19
+
20
+
21
+ def iso(value: str | _dt.date | _dt.datetime | None) -> str | None:
22
+ """Convert a date/datetime to an ISO-8601 string; pass strings and None through."""
23
+ if value is None or isinstance(value, str):
24
+ return value
25
+ if isinstance(value, _dt.datetime):
26
+ return value.isoformat()
27
+ if isinstance(value, _dt.date):
28
+ return value.isoformat()
29
+ raise TypeError(f"expected str, date or datetime, got {type(value).__name__}")
30
+
31
+
32
+ def _with_content_type(filename: str, payload: Any) -> tuple:
33
+ """Attach a guessed Content-Type so server-side upload filters (which
34
+ check the part's MIME type, not the filename) accept the file."""
35
+ content_type = mimetypes.guess_type(filename)[0]
36
+ if content_type:
37
+ return (filename, payload, content_type)
38
+ return (filename, payload)
39
+
40
+
41
+ def prepare_file(file: FileTypes, default_name: str = "upload") -> tuple:
42
+ """Normalize a user-supplied file argument into a (filename, payload[, type]) tuple
43
+ suitable for the ``files=`` parameter of requests."""
44
+ if isinstance(file, tuple):
45
+ if len(file) == 2:
46
+ return _with_content_type(file[0], file[1])
47
+ return file # already (filename, fileobj, content_type)
48
+ if isinstance(file, bytes):
49
+ return _with_content_type(default_name, file)
50
+ if isinstance(file, (str, os.PathLike)):
51
+ path = os.fspath(file)
52
+ return _with_content_type(os.path.basename(path), open(path, "rb"))
53
+ # file-like object
54
+ name = getattr(file, "name", None)
55
+ if isinstance(name, str) and name and not name.startswith("<"):
56
+ return _with_content_type(os.path.basename(name), file)
57
+ return _with_content_type(default_name, file)
58
+
59
+
60
+ def write_bytes(content: bytes, path: str | os.PathLike[str]) -> str:
61
+ """Write binary content to *path* and return the path as a string."""
62
+ path = os.fspath(path)
63
+ with open(path, "wb") as fh:
64
+ fh.write(content)
65
+ return path
vulnotes/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
vulnotes/client.py ADDED
@@ -0,0 +1,228 @@
1
+ """The Vulnotes API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import requests
9
+ from requests.adapters import HTTPAdapter
10
+ from urllib3.util.retry import Retry
11
+
12
+ from ._version import __version__
13
+ from .exceptions import (
14
+ APIConnectionError,
15
+ APITimeoutError,
16
+ VulnotesError,
17
+ error_for_status,
18
+ )
19
+ from .resources.ai import AI
20
+ from .resources.api_keys import APIKeys
21
+ from .resources.attachments import Attachments
22
+ from .resources.comments import Comments
23
+ from .resources.companies import Companies
24
+ from .resources.findings import Findings
25
+ from .resources.images import Images
26
+ from .resources.notes import Notes
27
+ from .resources.planning import Planning
28
+ from .resources.report_templates import ReportTemplates
29
+ from .resources.reports import Reports
30
+ from .resources.snapshots import Snapshots
31
+ from .resources.vulnerabilities import Vulnerabilities
32
+ from .resources.vulnerability_templates import VulnerabilityTemplates
33
+
34
+ __all__ = ["VulnotesClient"]
35
+
36
+ _RETRYABLE_METHODS = frozenset(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"])
37
+
38
+
39
+ class VulnotesClient:
40
+ """Client for the Vulnotes REST API.
41
+
42
+ Authentication uses an API key (created in **Settings → API Keys** inside
43
+ Vulnotes) sent via the ``X-API-Key`` header. The key carries an explicit
44
+ set of permissions and can never exceed what its owner may do.
45
+
46
+ Args:
47
+ base_url: Root URL of your Vulnotes instance, e.g.
48
+ ``https://acme.vulnotes.app``. A trailing ``/api`` is added
49
+ automatically if not present. Falls back to the ``VULNOTES_URL``
50
+ environment variable.
51
+ api_key: Your API key. Falls back to the ``VULNOTES_API_KEY``
52
+ environment variable.
53
+ timeout: Default per-request timeout in seconds (connect + read).
54
+ verify_ssl: Set to ``False`` to skip TLS certificate verification
55
+ (only for lab instances with self-signed certificates).
56
+ max_retries: Automatic retries for idempotent requests that fail with
57
+ a connection error or a 429/502/503/504 status.
58
+ session: Optionally supply a pre-configured ``requests.Session``.
59
+
60
+ Example:
61
+ >>> from vulnotes import VulnotesClient
62
+ >>> client = VulnotesClient("https://acme.vulnotes.app", api_key="...")
63
+ >>> for report in client.reports.iter():
64
+ ... print(report["title"])
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ base_url: str | None = None,
70
+ api_key: str | None = None,
71
+ *,
72
+ timeout: float = 30.0,
73
+ verify_ssl: bool = True,
74
+ max_retries: int = 3,
75
+ session: requests.Session | None = None,
76
+ ) -> None:
77
+ base_url = base_url or os.environ.get("VULNOTES_URL")
78
+ api_key = api_key or os.environ.get("VULNOTES_API_KEY")
79
+ if not base_url:
80
+ raise VulnotesError(
81
+ "No base_url provided. Pass it to VulnotesClient(...) or set the "
82
+ "VULNOTES_URL environment variable."
83
+ )
84
+ if not api_key:
85
+ raise VulnotesError(
86
+ "No api_key provided. Create one in Settings → API Keys, then pass "
87
+ "it to VulnotesClient(...) or set the VULNOTES_API_KEY environment variable."
88
+ )
89
+
90
+ base_url = base_url.rstrip("/")
91
+ if not base_url.endswith("/api"):
92
+ base_url += "/api"
93
+ self.base_url = base_url
94
+ self.timeout = timeout
95
+
96
+ self._session = session or requests.Session()
97
+ self._session.verify = verify_ssl
98
+ self._session.headers["X-API-Key"] = api_key
99
+ self._session.headers["User-Agent"] = f"vulnotes-python/{__version__}"
100
+ if max_retries:
101
+ retry = Retry(
102
+ total=max_retries,
103
+ connect=max_retries,
104
+ read=max_retries,
105
+ status=max_retries,
106
+ backoff_factor=0.5,
107
+ status_forcelist=(429, 502, 503, 504),
108
+ allowed_methods=_RETRYABLE_METHODS,
109
+ raise_on_status=False,
110
+ )
111
+ adapter = HTTPAdapter(max_retries=retry)
112
+ self._session.mount("https://", adapter)
113
+ self._session.mount("http://", adapter)
114
+
115
+ # Resource namespaces
116
+ self.companies = Companies(self)
117
+ self.templates = ReportTemplates(self)
118
+ self.vulnerability_templates = VulnerabilityTemplates(self)
119
+ self.vulnerabilities = Vulnerabilities(self)
120
+ self.reports = Reports(self)
121
+ self.findings = Findings(self)
122
+ self.snapshots = Snapshots(self)
123
+ self.comments = Comments(self)
124
+ self.notes = Notes(self)
125
+ self.images = Images(self)
126
+ self.attachments = Attachments(self)
127
+ self.ai = AI(self)
128
+ self.planning = Planning(self)
129
+ self.api_keys = APIKeys(self)
130
+
131
+ # ── low-level request machinery ──────────────────────────────────────
132
+
133
+ def request(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ *,
138
+ params: dict[str, Any] | None = None,
139
+ json: Any = None,
140
+ data: dict[str, Any] | None = None,
141
+ files: dict[str, Any] | None = None,
142
+ raw: bool = False,
143
+ timeout: float | None = None,
144
+ ) -> Any:
145
+ """Perform an HTTP request against the API.
146
+
147
+ This is the escape hatch for endpoints not (yet) wrapped by a resource
148
+ method: ``client.request("GET", "/reports")``.
149
+
150
+ Returns the parsed JSON body, raw ``bytes`` when ``raw=True``, or
151
+ ``None`` for empty responses. Some endpoints wrap their payload in a
152
+ ``{"success": true, "data": ...}`` envelope; those are transparently
153
+ unwrapped so every method returns the entity itself. Raises a subclass
154
+ of :class:`~vulnotes.exceptions.VulnotesError` on failure.
155
+ """
156
+ url = self.base_url + (path if path.startswith("/") else "/" + path)
157
+ try:
158
+ resp = self._session.request(
159
+ method,
160
+ url,
161
+ params=params,
162
+ json=json,
163
+ data=data,
164
+ files=files,
165
+ timeout=timeout if timeout is not None else self.timeout,
166
+ )
167
+ except requests.Timeout as exc:
168
+ raise APITimeoutError(f"Request to {url} timed out") from exc
169
+ except requests.RequestException as exc:
170
+ raise APIConnectionError(f"Could not reach {url}: {exc}") from exc
171
+
172
+ if not resp.ok:
173
+ body: Any = None
174
+ try:
175
+ body = resp.json()
176
+ except ValueError:
177
+ pass
178
+ if isinstance(body, dict):
179
+ message = body.get("message") or body.get("error") or resp.text
180
+ else:
181
+ message = resp.text or resp.reason
182
+ raise error_for_status(resp.status_code, str(message), body=body, response=resp)
183
+
184
+ if raw:
185
+ return resp.content
186
+ if resp.status_code == 204 or not resp.content:
187
+ return None
188
+ content_type = resp.headers.get("Content-Type", "")
189
+ if "application/json" in content_type:
190
+ payload = resp.json()
191
+ # Normalize the {"success": true, "data": ...} envelope some
192
+ # endpoints use, so callers always get the entity itself.
193
+ # Paginated envelopes carry a "pagination" key and stay intact.
194
+ if (
195
+ isinstance(payload, dict)
196
+ and "data" in payload
197
+ and set(payload) <= {"success", "data", "message"}
198
+ ):
199
+ return payload["data"]
200
+ return payload
201
+ return resp.content
202
+
203
+ def get(self, path: str, **kwargs: Any) -> Any:
204
+ return self.request("GET", path, **kwargs)
205
+
206
+ def post(self, path: str, **kwargs: Any) -> Any:
207
+ return self.request("POST", path, **kwargs)
208
+
209
+ def put(self, path: str, **kwargs: Any) -> Any:
210
+ return self.request("PUT", path, **kwargs)
211
+
212
+ def patch(self, path: str, **kwargs: Any) -> Any:
213
+ return self.request("PATCH", path, **kwargs)
214
+
215
+ def delete(self, path: str, **kwargs: Any) -> Any:
216
+ return self.request("DELETE", path, **kwargs)
217
+
218
+ # ── lifecycle ────────────────────────────────────────────────────────
219
+
220
+ def close(self) -> None:
221
+ """Release the underlying HTTP connection pool."""
222
+ self._session.close()
223
+
224
+ def __enter__(self) -> VulnotesClient:
225
+ return self
226
+
227
+ def __exit__(self, *exc_info: Any) -> None:
228
+ self.close()
vulnotes/exceptions.py ADDED
@@ -0,0 +1,122 @@
1
+ """Exception hierarchy for the Vulnotes SDK.
2
+
3
+ All exceptions raised by the SDK inherit from :class:`VulnotesError`, so a
4
+ single ``except vulnotes.VulnotesError`` catches everything.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ __all__ = [
12
+ "VulnotesError",
13
+ "APIConnectionError",
14
+ "APITimeoutError",
15
+ "APIStatusError",
16
+ "BadRequestError",
17
+ "AuthenticationError",
18
+ "PermissionDeniedError",
19
+ "NotFoundError",
20
+ "ConflictError",
21
+ "UnprocessableEntityError",
22
+ "RateLimitError",
23
+ "ServerError",
24
+ ]
25
+
26
+
27
+ class VulnotesError(Exception):
28
+ """Base class for every error raised by this SDK."""
29
+
30
+
31
+ class APIConnectionError(VulnotesError):
32
+ """The API could not be reached (DNS failure, refused connection, TLS error...)."""
33
+
34
+
35
+ class APITimeoutError(APIConnectionError):
36
+ """The request timed out."""
37
+
38
+
39
+ class APIStatusError(VulnotesError):
40
+ """The API returned a non-2xx HTTP status.
41
+
42
+ Attributes:
43
+ status_code: The HTTP status code.
44
+ message: Human-readable error message extracted from the response body.
45
+ body: The parsed JSON error body when available, otherwise ``None``.
46
+ response: The underlying ``requests.Response`` object.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ status_code: int,
52
+ message: str,
53
+ *,
54
+ body: Any | None = None,
55
+ response: Any = None,
56
+ ) -> None:
57
+ self.status_code = status_code
58
+ self.message = message
59
+ self.body = body
60
+ self.response = response
61
+ super().__init__(f"[{status_code}] {message}")
62
+
63
+
64
+ class BadRequestError(APIStatusError):
65
+ """400 - the request was malformed or failed validation."""
66
+
67
+
68
+ class AuthenticationError(APIStatusError):
69
+ """401 - the API key is missing, invalid, revoked, or its owner is disabled."""
70
+
71
+
72
+ class PermissionDeniedError(APIStatusError):
73
+ """403 - the API key lacks the permission required by this endpoint."""
74
+
75
+
76
+ class NotFoundError(APIStatusError):
77
+ """404 - the resource does not exist or is not visible to the key's owner.
78
+
79
+ Note: Vulnotes intentionally returns 404 (not 403) for reports outside the
80
+ owner's visibility, so their existence is not disclosed.
81
+ """
82
+
83
+
84
+ class ConflictError(APIStatusError):
85
+ """409 - the request conflicts with the current state of the resource."""
86
+
87
+
88
+ class UnprocessableEntityError(APIStatusError):
89
+ """422 - the request was well-formed but semantically invalid."""
90
+
91
+
92
+ class RateLimitError(APIStatusError):
93
+ """429 - too many requests."""
94
+
95
+
96
+ class ServerError(APIStatusError):
97
+ """5xx - the server failed to process the request."""
98
+
99
+
100
+ _STATUS_MAP = {
101
+ 400: BadRequestError,
102
+ 401: AuthenticationError,
103
+ 403: PermissionDeniedError,
104
+ 404: NotFoundError,
105
+ 409: ConflictError,
106
+ 422: UnprocessableEntityError,
107
+ 429: RateLimitError,
108
+ }
109
+
110
+
111
+ def error_for_status(
112
+ status_code: int,
113
+ message: str,
114
+ *,
115
+ body: Any | None = None,
116
+ response: Any = None,
117
+ ) -> APIStatusError:
118
+ """Build the most specific :class:`APIStatusError` subclass for a status code."""
119
+ cls = _STATUS_MAP.get(status_code)
120
+ if cls is None:
121
+ cls = ServerError if status_code >= 500 else APIStatusError
122
+ return cls(status_code, message, body=body, response=response)
@@ -0,0 +1,31 @@
1
+ from .ai import AI
2
+ from .api_keys import APIKeys
3
+ from .attachments import Attachments
4
+ from .comments import Comments
5
+ from .companies import Companies
6
+ from .findings import Findings
7
+ from .images import Images
8
+ from .notes import Notes
9
+ from .planning import Planning
10
+ from .report_templates import ReportTemplates
11
+ from .reports import Reports
12
+ from .snapshots import Snapshots
13
+ from .vulnerabilities import Vulnerabilities
14
+ from .vulnerability_templates import VulnerabilityTemplates
15
+
16
+ __all__ = [
17
+ "AI",
18
+ "APIKeys",
19
+ "Attachments",
20
+ "Comments",
21
+ "Companies",
22
+ "Findings",
23
+ "Images",
24
+ "Notes",
25
+ "Planning",
26
+ "ReportTemplates",
27
+ "Reports",
28
+ "Snapshots",
29
+ "Vulnerabilities",
30
+ "VulnerabilityTemplates",
31
+ ]
@@ -0,0 +1,67 @@
1
+ """Shared plumbing for resource namespaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import TYPE_CHECKING, Any, Callable
7
+
8
+ if TYPE_CHECKING:
9
+ from ..client import VulnotesClient
10
+
11
+ # The API returns list endpoints in two shapes: a plain JSON array when no
12
+ # pagination parameters are supplied, or a paginated envelope
13
+ # {"success": true, "data": [...], "pagination": {"page", "limit", "total",
14
+ # "totalPages", "hasNextPage", ...}} when `page` or `limit` is present.
15
+ JSON = Any
16
+
17
+
18
+ class Resource:
19
+ def __init__(self, client: VulnotesClient) -> None:
20
+ self._client = client
21
+
22
+
23
+ def paginate(
24
+ fetch: Callable[..., JSON],
25
+ *,
26
+ limit: int = 100,
27
+ **kwargs: Any,
28
+ ) -> Iterator[dict[str, Any]]:
29
+ """Iterate every item of a paginated list endpoint, fetching pages lazily.
30
+
31
+ ``fetch`` must accept ``page`` and ``limit`` keyword arguments and return
32
+ either a plain list or a paginated envelope.
33
+ """
34
+ page = 1
35
+ while True:
36
+ result = fetch(page=page, limit=limit, **kwargs)
37
+ if isinstance(result, list):
38
+ # Server ignored pagination and returned everything at once.
39
+ yield from result
40
+ return
41
+ items: list[dict[str, Any]] = result.get("data") or []
42
+ yield from items
43
+ if not items:
44
+ return
45
+ # Pagination metadata lives under "pagination"; fall back to
46
+ # top-level keys for endpoints using the older flat envelope.
47
+ meta = result.get("pagination")
48
+ if not isinstance(meta, dict):
49
+ meta = result
50
+ has_next = meta.get("hasNextPage")
51
+ if has_next is not None:
52
+ if not has_next:
53
+ return
54
+ else:
55
+ total_pages: int | None = meta.get("totalPages")
56
+ if total_pages is None or page >= total_pages:
57
+ return
58
+ page += 1
59
+
60
+
61
+ def page_params(page: int | None, limit: int | None) -> dict[str, Any]:
62
+ params: dict[str, Any] = {}
63
+ if page is not None:
64
+ params["page"] = page
65
+ if limit is not None:
66
+ params["limit"] = limit
67
+ return params
@@ -0,0 +1,110 @@
1
+ """AI-assisted features. Permissions: ``rw:vulnerabilities`` or ``rw:reports``
2
+ depending on the endpoint (noted per method)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from .._utils import FileTypes, omit_none, prepare_file
10
+ from ._base import Resource
11
+
12
+
13
+ class AI(Resource):
14
+ def generate_vulnerability(
15
+ self,
16
+ request_description: str,
17
+ category: str,
18
+ language: str,
19
+ fields: list[dict[str, Any]],
20
+ ) -> dict[str, Any]:
21
+ """Generate vulnerability content from a description. Requires ``rw:vulnerabilities``."""
22
+ return self._client.post(
23
+ "/ai/vulnerability/new",
24
+ json={
25
+ "request_description": request_description,
26
+ "category": category,
27
+ "language": language,
28
+ "fields": fields,
29
+ },
30
+ )
31
+
32
+ def translate_vulnerability(self, message: dict[str, Any]) -> dict[str, Any]:
33
+ """Translate vulnerability content. Requires ``rw:vulnerabilities``."""
34
+ return self._client.post("/ai/vulnerability/translate", json={"message": message})
35
+
36
+ def generate_report_content(
37
+ self,
38
+ report_id: str,
39
+ variable_key: str,
40
+ *,
41
+ user_prompt: str | None = None,
42
+ vuln_template_id: str | None = None,
43
+ ) -> dict[str, Any]:
44
+ """Generate content for a report variable. Requires ``rw:reports``."""
45
+ body = omit_none(
46
+ {
47
+ "variableKey": variable_key,
48
+ "userPrompt": user_prompt,
49
+ "vulnTemplateId": vuln_template_id,
50
+ }
51
+ )
52
+ return self._client.post(f"/ai/report/{report_id}/generate-content", json=body)
53
+
54
+ def improve_report_content(
55
+ self,
56
+ report_id: str,
57
+ existing_content: str,
58
+ *,
59
+ variable_key: str | None = None,
60
+ improvement_type: str | None = None,
61
+ ) -> dict[str, Any]:
62
+ """Improve existing report content. Requires ``rw:reports``."""
63
+ body = omit_none(
64
+ {
65
+ "existingContent": existing_content,
66
+ "variableKey": variable_key,
67
+ "improvementType": improvement_type,
68
+ }
69
+ )
70
+ return self._client.post(f"/ai/report/{report_id}/improve-content", json=body)
71
+
72
+ def generate_finding_from_images(
73
+ self,
74
+ report_id: str,
75
+ images: Sequence[FileTypes],
76
+ *,
77
+ description: str | None = None,
78
+ redacted_images: str | None = None,
79
+ vuln_template_id: str | None = None,
80
+ timeout: float | None = 300.0,
81
+ ) -> dict[str, Any]:
82
+ """Generate a finding from screenshot evidence. Requires ``rw:reports``.
83
+
84
+ Args:
85
+ images: Screenshots (paths, bytes, file objects, or
86
+ ``(filename, fileobj)`` tuples).
87
+ description: Extra context for the generation.
88
+ redacted_images: Redaction metadata as expected by the API.
89
+ vuln_template_id: Vulnerability template guiding the output fields.
90
+ """
91
+ data = omit_none(
92
+ {
93
+ "description": description,
94
+ "redactedImages": redacted_images,
95
+ "vulnTemplateId": vuln_template_id,
96
+ }
97
+ )
98
+ prepared = [prepare_file(img, default_name="image.png") for img in images]
99
+ try:
100
+ files = [("images", p) for p in prepared]
101
+ return self._client.post(
102
+ f"/ai/report/{report_id}/generate-finding",
103
+ data=data,
104
+ files=files,
105
+ timeout=timeout,
106
+ )
107
+ finally:
108
+ for p in prepared:
109
+ if hasattr(p[1], "close"):
110
+ p[1].close()
@@ -0,0 +1,17 @@
1
+ """Introspection of the API key making the request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ._base import Resource
8
+
9
+
10
+ class APIKeys(Resource):
11
+ def me(self) -> dict[str, Any]:
12
+ """Describe the API key making the request: its name, owner, and the
13
+ effective permissions it currently holds.
14
+
15
+ Useful to discover at runtime which endpoints the key may call.
16
+ """
17
+ return self._client.get("/api-keys/me")