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 +42 -0
- vulnotes/_utils.py +65 -0
- vulnotes/_version.py +1 -0
- vulnotes/client.py +228 -0
- vulnotes/exceptions.py +122 -0
- vulnotes/resources/__init__.py +31 -0
- vulnotes/resources/_base.py +67 -0
- vulnotes/resources/ai.py +110 -0
- vulnotes/resources/api_keys.py +17 -0
- vulnotes/resources/attachments.py +75 -0
- vulnotes/resources/comments.py +98 -0
- vulnotes/resources/companies.py +77 -0
- vulnotes/resources/findings.py +62 -0
- vulnotes/resources/images.py +85 -0
- vulnotes/resources/notes.py +71 -0
- vulnotes/resources/planning.py +210 -0
- vulnotes/resources/report_templates.py +52 -0
- vulnotes/resources/reports.py +293 -0
- vulnotes/resources/snapshots.py +35 -0
- vulnotes/resources/vulnerabilities.py +82 -0
- vulnotes/resources/vulnerability_templates.py +132 -0
- vulnotes-0.1.0.dist-info/METADATA +166 -0
- vulnotes-0.1.0.dist-info/RECORD +25 -0
- vulnotes-0.1.0.dist-info/WHEEL +4 -0
- vulnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
vulnotes/resources/ai.py
ADDED
|
@@ -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")
|