pyattackforge 0.0.1__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.
- pyattackforge/__init__.py +5 -0
- pyattackforge/cache.py +33 -0
- pyattackforge/client.py +155 -0
- pyattackforge/config.py +61 -0
- pyattackforge/exceptions.py +21 -0
- pyattackforge/resources/__init__.py +23 -0
- pyattackforge/resources/assets.py +39 -0
- pyattackforge/resources/base.py +33 -0
- pyattackforge/resources/findings.py +655 -0
- pyattackforge/resources/notes.py +139 -0
- pyattackforge/resources/projects.py +154 -0
- pyattackforge/resources/reports.py +20 -0
- pyattackforge/resources/users.py +59 -0
- pyattackforge/resources/writeups.py +79 -0
- pyattackforge/transport.py +134 -0
- pyattackforge-0.0.1.dist-info/METADATA +162 -0
- pyattackforge-0.0.1.dist-info/RECORD +20 -0
- pyattackforge-0.0.1.dist-info/WHEEL +5 -0
- pyattackforge-0.0.1.dist-info/licenses/LICENSE +661 -0
- pyattackforge-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Resource: findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from ..cache import TTLCache
|
|
10
|
+
from .base import BaseResource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FindingsResource(BaseResource):
|
|
14
|
+
"""Findings (vulnerabilities) API resource wrapper."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, transport) -> None: # type: ignore[override]
|
|
17
|
+
super().__init__(transport)
|
|
18
|
+
self._cache = TTLCache(default_ttl=120.0)
|
|
19
|
+
|
|
20
|
+
def create_vulnerability(self, payload: Dict[str, Any]) -> Any:
|
|
21
|
+
payload = self._apply_finding_defaults(payload)
|
|
22
|
+
return self._post("/api/ss/vulnerability", json=payload)
|
|
23
|
+
|
|
24
|
+
def create_vulnerability_bulk(self, payload: Dict[str, Any]) -> Any:
|
|
25
|
+
payload = self._apply_finding_defaults(payload)
|
|
26
|
+
return self._post("/api/ss/vulnerability/bulk", json=payload)
|
|
27
|
+
|
|
28
|
+
def create_vulnerability_with_library(self, payload: Dict[str, Any]) -> Any:
|
|
29
|
+
payload = self._apply_finding_defaults(payload)
|
|
30
|
+
return self._post("/api/ss/vulnerability-with-library", json=payload)
|
|
31
|
+
|
|
32
|
+
def get_vulnerabilities(self, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
33
|
+
return self._get("/api/ss/vulnerabilities", params=params)
|
|
34
|
+
|
|
35
|
+
def get_vulnerability(self, vulnerability_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
36
|
+
return self._get(f"/api/ss/vulnerability/{vulnerability_id}", params=params)
|
|
37
|
+
|
|
38
|
+
def get_project_vulnerabilities(self, project_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
39
|
+
return self._get(f"/api/ss/project/{project_id}/vulnerabilities", params=params)
|
|
40
|
+
|
|
41
|
+
def get_project_vulnerabilities_all(self, project_id: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
42
|
+
items: List[Dict[str, Any]] = []
|
|
43
|
+
params = dict(params or {})
|
|
44
|
+
skip = int(params.get("skip", 0))
|
|
45
|
+
limit = int(params.get("limit", 500))
|
|
46
|
+
while True:
|
|
47
|
+
params.update({"skip": skip, "limit": limit})
|
|
48
|
+
data = self.get_project_vulnerabilities(project_id, params=params)
|
|
49
|
+
page = self._extract_list(data, ["vulnerabilities"]) # best-effort
|
|
50
|
+
if not page:
|
|
51
|
+
break
|
|
52
|
+
items.extend(page)
|
|
53
|
+
if len(page) < limit:
|
|
54
|
+
break
|
|
55
|
+
skip += limit
|
|
56
|
+
return items
|
|
57
|
+
|
|
58
|
+
def find_project_vulnerability_by_title(
|
|
59
|
+
self, project_id: str, title: str, *, include_pending: bool = True
|
|
60
|
+
) -> Optional[Dict[str, Any]]:
|
|
61
|
+
desired = self._normalize_title(title)
|
|
62
|
+
findings = self.get_project_vulnerabilities_all(project_id)
|
|
63
|
+
if include_pending:
|
|
64
|
+
pending = self.get_project_vulnerabilities_all(project_id, params={"pendingVulnerabilities": True})
|
|
65
|
+
if pending:
|
|
66
|
+
seen: set = set()
|
|
67
|
+
merged: List[Dict[str, Any]] = []
|
|
68
|
+
for finding in findings + pending:
|
|
69
|
+
key = finding.get("vulnerability_id") or finding.get("id")
|
|
70
|
+
if key in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(key)
|
|
73
|
+
merged.append(finding)
|
|
74
|
+
findings = merged
|
|
75
|
+
for finding in findings:
|
|
76
|
+
candidate = self._extract_title(finding)
|
|
77
|
+
if candidate and self._normalize_title(candidate) == desired:
|
|
78
|
+
return finding
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def find_project_vulnerability_by_library_id(
|
|
82
|
+
self, project_id: str, library_id: str, *, include_pending: bool = True
|
|
83
|
+
) -> Optional[Dict[str, Any]]:
|
|
84
|
+
if not library_id:
|
|
85
|
+
return None
|
|
86
|
+
findings = self.get_project_vulnerabilities_all(project_id)
|
|
87
|
+
if include_pending:
|
|
88
|
+
pending = self.get_project_vulnerabilities_all(project_id, params={"pendingVulnerabilities": True})
|
|
89
|
+
if pending:
|
|
90
|
+
seen: set = set()
|
|
91
|
+
merged: List[Dict[str, Any]] = []
|
|
92
|
+
for finding in findings + pending:
|
|
93
|
+
key = finding.get("vulnerability_id") or finding.get("id")
|
|
94
|
+
if key in seen:
|
|
95
|
+
continue
|
|
96
|
+
seen.add(key)
|
|
97
|
+
merged.append(finding)
|
|
98
|
+
findings = merged
|
|
99
|
+
for finding in findings:
|
|
100
|
+
candidate = self._find_first_value(
|
|
101
|
+
finding,
|
|
102
|
+
(
|
|
103
|
+
"library_id",
|
|
104
|
+
"libraryId",
|
|
105
|
+
"vulnerability_library_id",
|
|
106
|
+
"vulnerabilityLibraryId",
|
|
107
|
+
"vulnerability_library_issue_id",
|
|
108
|
+
"vulnerabilityLibraryIssueId",
|
|
109
|
+
"writeup_id",
|
|
110
|
+
"writeupId",
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
if candidate == library_id:
|
|
114
|
+
return finding
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def get_vulnerabilities_by_asset_name(self, asset_name: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
118
|
+
params = dict(params or {})
|
|
119
|
+
params["name"] = asset_name
|
|
120
|
+
return self._get("/api/ss/vulnerabilities/asset", params=params)
|
|
121
|
+
|
|
122
|
+
def update_vulnerability(self, vulnerability_id: str, payload: Dict[str, Any]) -> Any:
|
|
123
|
+
if "project_id" in payload and "projectId" not in payload:
|
|
124
|
+
payload = dict(payload)
|
|
125
|
+
payload["projectId"] = payload.pop("project_id")
|
|
126
|
+
return self._put(f"/api/ss/vulnerability/{vulnerability_id}", json=payload)
|
|
127
|
+
|
|
128
|
+
def update_vulnerability_with_library(self, vulnerability_id: str, payload: Dict[str, Any]) -> Any:
|
|
129
|
+
if "project_id" in payload and "projectId" not in payload:
|
|
130
|
+
payload = dict(payload)
|
|
131
|
+
payload["projectId"] = payload.pop("project_id")
|
|
132
|
+
return self._put(f"/api/ss/vulnerability-with-library/{vulnerability_id}", json=payload)
|
|
133
|
+
|
|
134
|
+
def update_vulnerability_slas(self, payload: Dict[str, Any]) -> Any:
|
|
135
|
+
return self._put("/api/ss/vulnerabilities/sla", json=payload)
|
|
136
|
+
|
|
137
|
+
def update_linked_projects_on_vulnerabilities(self, payload: Dict[str, Any]) -> Any:
|
|
138
|
+
return self._put("/api/ss/vulnerabilities/projects", json=payload)
|
|
139
|
+
|
|
140
|
+
def get_vulnerability_revision_history(self, vulnerability_id: str) -> Any:
|
|
141
|
+
return self._get(f"/api/ss/vulnerability/{vulnerability_id}/revision-history")
|
|
142
|
+
|
|
143
|
+
def upload_vulnerability_evidence(
|
|
144
|
+
self,
|
|
145
|
+
vulnerability_id: str,
|
|
146
|
+
file_path: str,
|
|
147
|
+
*,
|
|
148
|
+
keep_last: Optional[int] = 2,
|
|
149
|
+
project_id: Optional[str] = None,
|
|
150
|
+
dedupe: bool = False,
|
|
151
|
+
) -> Dict[str, Any]:
|
|
152
|
+
"""
|
|
153
|
+
Upload evidence to a vulnerability.
|
|
154
|
+
|
|
155
|
+
keep_last defaults to 2 (FIFO, keep most recent). Set keep_last=None to disable FIFO.
|
|
156
|
+
If dedupe=True, skip upload when an existing evidence entry matches the filename.
|
|
157
|
+
"""
|
|
158
|
+
if not os.path.isfile(file_path):
|
|
159
|
+
raise FileNotFoundError(file_path)
|
|
160
|
+
file_name = os.path.basename(file_path)
|
|
161
|
+
resolved_project_id = project_id
|
|
162
|
+
if keep_last is not None and project_id is None:
|
|
163
|
+
resolved_project_id = self._resolve_project_id_for_vulnerability(vulnerability_id)
|
|
164
|
+
if dedupe:
|
|
165
|
+
entries = self._get_vulnerability_evidence_entries(
|
|
166
|
+
vulnerability_id, project_id=resolved_project_id
|
|
167
|
+
)
|
|
168
|
+
match = self._find_matching_evidence_entry(entries, file_name)
|
|
169
|
+
if match is not None:
|
|
170
|
+
result: Dict[str, Any] = {"action": "noop", "existing": match}
|
|
171
|
+
if keep_last is not None:
|
|
172
|
+
deletions = self._enforce_vulnerability_evidence_fifo(
|
|
173
|
+
vulnerability_id, keep_last, project_id=resolved_project_id
|
|
174
|
+
)
|
|
175
|
+
result["deleted"] = deletions
|
|
176
|
+
return result
|
|
177
|
+
with open(file_path, "rb") as handle:
|
|
178
|
+
upload_result = self._post_files(
|
|
179
|
+
f"/api/ss/vulnerability/{vulnerability_id}/evidence",
|
|
180
|
+
files={"file": (file_name, handle)},
|
|
181
|
+
)
|
|
182
|
+
if keep_last is None:
|
|
183
|
+
return {"upload": upload_result}
|
|
184
|
+
deletions = self._enforce_vulnerability_evidence_fifo(
|
|
185
|
+
vulnerability_id, keep_last, project_id=resolved_project_id
|
|
186
|
+
)
|
|
187
|
+
return {"upload": upload_result, "deleted": deletions}
|
|
188
|
+
|
|
189
|
+
def download_vulnerability_evidence(self, vulnerability_id: str, file_name: str) -> Any:
|
|
190
|
+
return self._get(f"/api/ss/vulnerability/{vulnerability_id}/evidence/{file_name}")
|
|
191
|
+
|
|
192
|
+
def delete_vulnerability_evidence(self, vulnerability_id: str, file_name: str) -> Any:
|
|
193
|
+
return self._delete(f"/api/ss/vulnerability/{vulnerability_id}/evidence/{file_name}")
|
|
194
|
+
|
|
195
|
+
def upsert_finding_by_title(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
project_id: str,
|
|
199
|
+
title: str,
|
|
200
|
+
affected_assets: Sequence[Any],
|
|
201
|
+
create_payload: Dict[str, Any],
|
|
202
|
+
update_payload: Optional[Dict[str, Any]] = None,
|
|
203
|
+
use_library: bool = False,
|
|
204
|
+
validate_asset_agnostic: bool = True,
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Deduplicate findings by title (case-insensitive, trimmed) within a project.
|
|
208
|
+
If a matching finding exists, append missing affected assets and update.
|
|
209
|
+
Otherwise, create a new finding using the provided payload.
|
|
210
|
+
When validate_asset_agnostic is True, reject payloads that embed asset names
|
|
211
|
+
outside of affected_assets.
|
|
212
|
+
"""
|
|
213
|
+
if validate_asset_agnostic:
|
|
214
|
+
asset_names = [str(value) for value in affected_assets if isinstance(value, str)]
|
|
215
|
+
if asset_names:
|
|
216
|
+
self.assert_asset_agnostic(create_payload, asset_names, enabled=True)
|
|
217
|
+
if update_payload:
|
|
218
|
+
self.assert_asset_agnostic(update_payload, asset_names, enabled=True)
|
|
219
|
+
normalized_title = self._normalize_title(title)
|
|
220
|
+
findings = self.get_project_vulnerabilities_all(project_id)
|
|
221
|
+
pending = self.get_project_vulnerabilities_all(project_id, params={"pendingVulnerabilities": True})
|
|
222
|
+
if pending:
|
|
223
|
+
seen: set = set()
|
|
224
|
+
merged: List[Dict[str, Any]] = []
|
|
225
|
+
for finding in findings + pending:
|
|
226
|
+
key = finding.get("vulnerability_id") or finding.get("id")
|
|
227
|
+
if key in seen:
|
|
228
|
+
continue
|
|
229
|
+
seen.add(key)
|
|
230
|
+
merged.append(finding)
|
|
231
|
+
findings = merged
|
|
232
|
+
match = None
|
|
233
|
+
for finding in findings:
|
|
234
|
+
candidate = self._extract_title(finding)
|
|
235
|
+
if candidate and self._normalize_title(candidate) == normalized_title:
|
|
236
|
+
match = finding
|
|
237
|
+
break
|
|
238
|
+
if not match:
|
|
239
|
+
if use_library:
|
|
240
|
+
return {"action": "create", "result": self.create_vulnerability_with_library(create_payload)}
|
|
241
|
+
return {"action": "create", "result": self.create_vulnerability(create_payload)}
|
|
242
|
+
|
|
243
|
+
existing_assets = self._extract_asset_names_from_finding(match)
|
|
244
|
+
new_assets = self._extract_asset_names(affected_assets)
|
|
245
|
+
missing = sorted(new_assets - existing_assets)
|
|
246
|
+
if not missing:
|
|
247
|
+
return {"action": "noop", "existing": match}
|
|
248
|
+
|
|
249
|
+
merged = sorted(existing_assets.union(new_assets))
|
|
250
|
+
asset_ids = self._resolve_project_asset_ids(project_id, merged)
|
|
251
|
+
payload_assets = []
|
|
252
|
+
for name in merged:
|
|
253
|
+
asset_id = asset_ids.get(name)
|
|
254
|
+
if asset_id:
|
|
255
|
+
payload_assets.append({"assetId": asset_id})
|
|
256
|
+
else:
|
|
257
|
+
payload_assets.append({"assetName": name})
|
|
258
|
+
payload = {"affected_assets": payload_assets}
|
|
259
|
+
if update_payload:
|
|
260
|
+
payload.update(update_payload)
|
|
261
|
+
result = self.update_vulnerability(match.get("vulnerability_id") or match.get("id"), payload)
|
|
262
|
+
return {"action": "update", "result": result, "added_assets": missing}
|
|
263
|
+
|
|
264
|
+
def _normalize_title(self, title: str) -> str:
|
|
265
|
+
return (title or "").strip().lower()
|
|
266
|
+
|
|
267
|
+
def _apply_finding_defaults(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
268
|
+
if not isinstance(payload, dict):
|
|
269
|
+
return payload
|
|
270
|
+
config = getattr(self._transport, "_config", None)
|
|
271
|
+
new_payload = dict(payload)
|
|
272
|
+
|
|
273
|
+
if "is_visible" not in new_payload and "isVisible" not in new_payload:
|
|
274
|
+
default_visible = getattr(config, "default_findings_visible", None)
|
|
275
|
+
if default_visible is not None:
|
|
276
|
+
new_payload["is_visible"] = bool(default_visible)
|
|
277
|
+
|
|
278
|
+
substatus_key = getattr(config, "default_findings_substatus_key", None)
|
|
279
|
+
substatus_value = getattr(config, "default_findings_substatus_value", None)
|
|
280
|
+
if substatus_key and substatus_value:
|
|
281
|
+
new_payload = self._apply_substatus_default(new_payload, substatus_key, substatus_value)
|
|
282
|
+
|
|
283
|
+
return new_payload
|
|
284
|
+
|
|
285
|
+
def _apply_substatus_default(self, payload: Dict[str, Any], key: str, value: str) -> Dict[str, Any]:
|
|
286
|
+
fields_key = None
|
|
287
|
+
if "custom_fields" in payload:
|
|
288
|
+
fields_key = "custom_fields"
|
|
289
|
+
elif "vulnerability_custom_fields" in payload:
|
|
290
|
+
fields_key = "vulnerability_custom_fields"
|
|
291
|
+
else:
|
|
292
|
+
fields_key = "custom_fields"
|
|
293
|
+
|
|
294
|
+
fields = payload.get(fields_key)
|
|
295
|
+
if not isinstance(fields, list):
|
|
296
|
+
fields = []
|
|
297
|
+
# If already present, do not override.
|
|
298
|
+
for entry in fields:
|
|
299
|
+
if isinstance(entry, dict) and entry.get("key") == key:
|
|
300
|
+
return payload
|
|
301
|
+
|
|
302
|
+
new_payload = dict(payload)
|
|
303
|
+
new_fields = [entry for entry in fields if isinstance(entry, dict)]
|
|
304
|
+
new_fields.append({"key": key, "value": value})
|
|
305
|
+
new_payload[fields_key] = new_fields
|
|
306
|
+
return new_payload
|
|
307
|
+
|
|
308
|
+
def _extract_title(self, finding: Dict[str, Any]) -> Optional[str]:
|
|
309
|
+
for key in ("vulnerability_title", "title", "vulnerability", "name"):
|
|
310
|
+
value = finding.get(key)
|
|
311
|
+
if isinstance(value, str) and value.strip():
|
|
312
|
+
return value
|
|
313
|
+
if isinstance(value, dict):
|
|
314
|
+
for nested_key in ("vulnerability_title", "title", "name"):
|
|
315
|
+
nested_value = value.get(nested_key)
|
|
316
|
+
if isinstance(nested_value, str) and nested_value.strip():
|
|
317
|
+
return nested_value
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
def _extract_asset_names(self, assets: Sequence[Any]) -> set:
|
|
321
|
+
names = set()
|
|
322
|
+
for item in assets:
|
|
323
|
+
if isinstance(item, str):
|
|
324
|
+
if item.strip():
|
|
325
|
+
names.add(item.strip())
|
|
326
|
+
continue
|
|
327
|
+
if not isinstance(item, dict):
|
|
328
|
+
continue
|
|
329
|
+
for key in ("assetName", "name"):
|
|
330
|
+
value = item.get(key)
|
|
331
|
+
if isinstance(value, str) and value.strip():
|
|
332
|
+
names.add(value.strip())
|
|
333
|
+
asset_obj = item.get("asset")
|
|
334
|
+
if isinstance(asset_obj, dict):
|
|
335
|
+
value = asset_obj.get("name")
|
|
336
|
+
if isinstance(value, str) and value.strip():
|
|
337
|
+
names.add(value.strip())
|
|
338
|
+
return names
|
|
339
|
+
|
|
340
|
+
def _extract_asset_names_from_finding(self, finding: Dict[str, Any]) -> set:
|
|
341
|
+
raw = finding.get("vulnerability_affected_assets") or finding.get("affected_assets") or []
|
|
342
|
+
names = self._extract_asset_names(raw if isinstance(raw, list) else [])
|
|
343
|
+
single = finding.get("vulnerability_affected_asset_name") or finding.get("affected_asset_name")
|
|
344
|
+
if isinstance(single, str) and single.strip():
|
|
345
|
+
names.add(single.strip())
|
|
346
|
+
return names
|
|
347
|
+
|
|
348
|
+
def _extract_list(self, data: Any, keys: Iterable[str]) -> List[Dict[str, Any]]:
|
|
349
|
+
if isinstance(data, list):
|
|
350
|
+
return [item for item in data if isinstance(item, dict)]
|
|
351
|
+
if isinstance(data, dict):
|
|
352
|
+
for key in keys:
|
|
353
|
+
value = data.get(key)
|
|
354
|
+
if isinstance(value, list):
|
|
355
|
+
return [item for item in value if isinstance(item, dict)]
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
def _resolve_project_asset_ids(self, project_id: str, asset_names: Sequence[str]) -> Dict[str, str]:
|
|
359
|
+
if not asset_names:
|
|
360
|
+
return {}
|
|
361
|
+
try:
|
|
362
|
+
project = self._get(f"/api/ss/project/{project_id}")
|
|
363
|
+
except Exception:
|
|
364
|
+
return {}
|
|
365
|
+
if isinstance(project, dict) and isinstance(project.get("data"), dict):
|
|
366
|
+
project = project["data"]
|
|
367
|
+
if isinstance(project, dict) and isinstance(project.get("project"), dict):
|
|
368
|
+
project = project["project"]
|
|
369
|
+
if not isinstance(project, dict):
|
|
370
|
+
return {}
|
|
371
|
+
details = project.get("project_scope_details")
|
|
372
|
+
if not isinstance(details, list):
|
|
373
|
+
return {}
|
|
374
|
+
mapping: Dict[str, str] = {}
|
|
375
|
+
for entry in details:
|
|
376
|
+
if not isinstance(entry, dict):
|
|
377
|
+
continue
|
|
378
|
+
name = entry.get("name")
|
|
379
|
+
asset_id = entry.get("asset_id") or entry.get("assetId")
|
|
380
|
+
if isinstance(name, str) and isinstance(asset_id, str):
|
|
381
|
+
mapping[name] = asset_id
|
|
382
|
+
return mapping
|
|
383
|
+
|
|
384
|
+
def _enforce_vulnerability_evidence_fifo(
|
|
385
|
+
self, vulnerability_id: str, keep_last: int, *, project_id: Optional[str] = None
|
|
386
|
+
) -> List[str]:
|
|
387
|
+
if keep_last <= 0:
|
|
388
|
+
return []
|
|
389
|
+
entries: List[Any] = []
|
|
390
|
+
resolved_project_id = project_id
|
|
391
|
+
if project_id:
|
|
392
|
+
vulns = self.get_project_vulnerabilities_all(project_id, params={"pendingVulnerabilities": True})
|
|
393
|
+
for entry in vulns:
|
|
394
|
+
if (entry.get("vulnerability_id") or entry.get("id")) == vulnerability_id:
|
|
395
|
+
entries = self._extract_evidence_entries(entry)
|
|
396
|
+
break
|
|
397
|
+
if not entries and project_id is None:
|
|
398
|
+
resolved_project_id = self._resolve_project_id_for_vulnerability(vulnerability_id)
|
|
399
|
+
if resolved_project_id:
|
|
400
|
+
vulns = self.get_project_vulnerabilities_all(
|
|
401
|
+
resolved_project_id, params={"pendingVulnerabilities": True}
|
|
402
|
+
)
|
|
403
|
+
for entry in vulns:
|
|
404
|
+
if (entry.get("vulnerability_id") or entry.get("id")) == vulnerability_id:
|
|
405
|
+
entries = self._extract_evidence_entries(entry)
|
|
406
|
+
break
|
|
407
|
+
if not entries:
|
|
408
|
+
vuln = self.get_vulnerability(vulnerability_id)
|
|
409
|
+
entries = self._extract_evidence_entries(vuln)
|
|
410
|
+
ordered = self._sort_entries(entries)
|
|
411
|
+
if len(ordered) <= keep_last:
|
|
412
|
+
return []
|
|
413
|
+
to_delete = ordered[: len(ordered) - keep_last]
|
|
414
|
+
deleted = []
|
|
415
|
+
for entry in to_delete:
|
|
416
|
+
file_name = self._extract_file_name(entry)
|
|
417
|
+
if not file_name:
|
|
418
|
+
continue
|
|
419
|
+
self.delete_vulnerability_evidence(vulnerability_id, file_name)
|
|
420
|
+
deleted.append(file_name)
|
|
421
|
+
return deleted
|
|
422
|
+
|
|
423
|
+
def _extract_evidence_entries(self, vuln: Any) -> List[Any]:
|
|
424
|
+
if not isinstance(vuln, dict):
|
|
425
|
+
return []
|
|
426
|
+
if isinstance(vuln.get("data"), dict):
|
|
427
|
+
vuln = vuln.get("data")
|
|
428
|
+
if isinstance(vuln.get("vulnerability"), dict):
|
|
429
|
+
vuln = vuln.get("vulnerability")
|
|
430
|
+
for key in (
|
|
431
|
+
"evidence",
|
|
432
|
+
"evidence_files",
|
|
433
|
+
"vulnerability_evidence",
|
|
434
|
+
"vulnerability_evidence_files",
|
|
435
|
+
"files",
|
|
436
|
+
"attachments",
|
|
437
|
+
):
|
|
438
|
+
value = vuln.get(key)
|
|
439
|
+
if isinstance(value, list):
|
|
440
|
+
return value
|
|
441
|
+
return []
|
|
442
|
+
|
|
443
|
+
def _get_vulnerability_evidence_entries(
|
|
444
|
+
self, vulnerability_id: str, *, project_id: Optional[str] = None
|
|
445
|
+
) -> List[Any]:
|
|
446
|
+
entries: List[Any] = []
|
|
447
|
+
resolved_project_id = project_id
|
|
448
|
+
if project_id:
|
|
449
|
+
vulns = self.get_project_vulnerabilities_all(project_id, params={"pendingVulnerabilities": True})
|
|
450
|
+
for entry in vulns:
|
|
451
|
+
if (entry.get("vulnerability_id") or entry.get("id")) == vulnerability_id:
|
|
452
|
+
entries = self._extract_evidence_entries(entry)
|
|
453
|
+
break
|
|
454
|
+
if not entries and project_id is None:
|
|
455
|
+
resolved_project_id = self._resolve_project_id_for_vulnerability(vulnerability_id)
|
|
456
|
+
if resolved_project_id:
|
|
457
|
+
vulns = self.get_project_vulnerabilities_all(
|
|
458
|
+
resolved_project_id, params={"pendingVulnerabilities": True}
|
|
459
|
+
)
|
|
460
|
+
for entry in vulns:
|
|
461
|
+
if (entry.get("vulnerability_id") or entry.get("id")) == vulnerability_id:
|
|
462
|
+
entries = self._extract_evidence_entries(entry)
|
|
463
|
+
break
|
|
464
|
+
if entries:
|
|
465
|
+
return entries
|
|
466
|
+
vuln = self.get_vulnerability(vulnerability_id)
|
|
467
|
+
return self._extract_evidence_entries(vuln)
|
|
468
|
+
|
|
469
|
+
def _find_matching_evidence_entry(self, entries: List[Any], original_name: str) -> Optional[Any]:
|
|
470
|
+
normalized = original_name.strip()
|
|
471
|
+
if not normalized:
|
|
472
|
+
return None
|
|
473
|
+
for entry in entries:
|
|
474
|
+
if self._matches_file_name(entry, normalized):
|
|
475
|
+
return entry
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
def _matches_file_name(self, entry: Any, original_name: str) -> bool:
|
|
479
|
+
if isinstance(entry, str):
|
|
480
|
+
return entry.strip() == original_name
|
|
481
|
+
if not isinstance(entry, dict):
|
|
482
|
+
return False
|
|
483
|
+
candidates = (
|
|
484
|
+
entry.get("storage_name"),
|
|
485
|
+
entry.get("storageName"),
|
|
486
|
+
entry.get("full_name"),
|
|
487
|
+
entry.get("fullName"),
|
|
488
|
+
entry.get("file"),
|
|
489
|
+
entry.get("fileName"),
|
|
490
|
+
entry.get("file_name"),
|
|
491
|
+
entry.get("file_name_custom"),
|
|
492
|
+
entry.get("alternative_name"),
|
|
493
|
+
entry.get("original_name"),
|
|
494
|
+
entry.get("filename"),
|
|
495
|
+
entry.get("name"),
|
|
496
|
+
)
|
|
497
|
+
for candidate in candidates:
|
|
498
|
+
if isinstance(candidate, str) and candidate.strip() == original_name:
|
|
499
|
+
return True
|
|
500
|
+
path = entry.get("path")
|
|
501
|
+
if isinstance(path, str) and path.endswith(original_name):
|
|
502
|
+
return True
|
|
503
|
+
return False
|
|
504
|
+
|
|
505
|
+
def _extract_file_name(self, entry: Any) -> Optional[str]:
|
|
506
|
+
if isinstance(entry, str) and entry.strip():
|
|
507
|
+
return entry
|
|
508
|
+
if isinstance(entry, dict):
|
|
509
|
+
for key in (
|
|
510
|
+
"storage_name",
|
|
511
|
+
"storageName",
|
|
512
|
+
"full_name",
|
|
513
|
+
"fullName",
|
|
514
|
+
"file",
|
|
515
|
+
"fileName",
|
|
516
|
+
"file_name",
|
|
517
|
+
"file_name_custom",
|
|
518
|
+
"alternative_name",
|
|
519
|
+
"filename",
|
|
520
|
+
"name",
|
|
521
|
+
"path",
|
|
522
|
+
):
|
|
523
|
+
value = entry.get(key)
|
|
524
|
+
if isinstance(value, str) and value.strip():
|
|
525
|
+
return value
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
def _sort_entries(self, entries: List[Any]) -> List[Any]:
|
|
529
|
+
if not entries:
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
def parse_time(value: Any) -> Optional[float]:
|
|
533
|
+
if not isinstance(value, str):
|
|
534
|
+
return None
|
|
535
|
+
try:
|
|
536
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
537
|
+
except ValueError:
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
with_timestamp = []
|
|
541
|
+
for entry in entries:
|
|
542
|
+
if isinstance(entry, dict):
|
|
543
|
+
for key in ("created", "created_at", "uploaded", "uploaded_at", "timestamp"):
|
|
544
|
+
ts = parse_time(entry.get(key))
|
|
545
|
+
if ts is not None:
|
|
546
|
+
with_timestamp.append((ts, entry))
|
|
547
|
+
break
|
|
548
|
+
else:
|
|
549
|
+
with_timestamp.append((None, entry))
|
|
550
|
+
if all(ts is None for ts, _ in with_timestamp):
|
|
551
|
+
return entries
|
|
552
|
+
return [entry for ts, entry in sorted(with_timestamp, key=lambda pair: pair[0] or 0.0)]
|
|
553
|
+
|
|
554
|
+
def extract_linked_testcase_ids(self, vulnerability: Any) -> set:
|
|
555
|
+
vuln = self._unwrap_vulnerability(vulnerability)
|
|
556
|
+
linked = (
|
|
557
|
+
vuln.get("linked_testcases")
|
|
558
|
+
or vuln.get("linkedTestcases")
|
|
559
|
+
or vuln.get("linked_testcase_ids")
|
|
560
|
+
or vuln.get("linkedTestcaseIds")
|
|
561
|
+
or vuln.get("vulnerability_testcases")
|
|
562
|
+
or vuln.get("vulnerabilityTestcases")
|
|
563
|
+
or []
|
|
564
|
+
)
|
|
565
|
+
ids = set()
|
|
566
|
+
if not isinstance(linked, list):
|
|
567
|
+
return ids
|
|
568
|
+
for item in linked:
|
|
569
|
+
if isinstance(item, str):
|
|
570
|
+
ids.add(item)
|
|
571
|
+
elif isinstance(item, dict):
|
|
572
|
+
value = item.get("id") or item.get("testcase_id")
|
|
573
|
+
if isinstance(value, str):
|
|
574
|
+
ids.add(value)
|
|
575
|
+
return ids
|
|
576
|
+
|
|
577
|
+
def assert_asset_agnostic(
|
|
578
|
+
self, payload: Dict[str, Any], asset_names: Sequence[str], *, enabled: bool = True
|
|
579
|
+
) -> None:
|
|
580
|
+
"""
|
|
581
|
+
Ensure asset names are not present in any string fields except affected_assets.
|
|
582
|
+
"""
|
|
583
|
+
if not enabled:
|
|
584
|
+
return
|
|
585
|
+
needles = [name.lower() for name in asset_names if isinstance(name, str)]
|
|
586
|
+
|
|
587
|
+
def walk(value: Any, path: str) -> None:
|
|
588
|
+
if isinstance(value, dict):
|
|
589
|
+
for key, item in value.items():
|
|
590
|
+
if key == "affected_assets":
|
|
591
|
+
continue
|
|
592
|
+
next_path = f"{path}.{key}" if path else key
|
|
593
|
+
walk(item, next_path)
|
|
594
|
+
elif isinstance(value, list):
|
|
595
|
+
for idx, item in enumerate(value):
|
|
596
|
+
walk(item, f"{path}[{idx}]")
|
|
597
|
+
elif isinstance(value, str):
|
|
598
|
+
lower = value.lower()
|
|
599
|
+
for needle in needles:
|
|
600
|
+
if needle and needle in lower:
|
|
601
|
+
raise ValueError(f"Payload field '{path}' contains asset name '{needle}'")
|
|
602
|
+
|
|
603
|
+
walk(payload, "")
|
|
604
|
+
|
|
605
|
+
def _unwrap_vulnerability(self, data: Any) -> Dict[str, Any]:
|
|
606
|
+
if isinstance(data, dict) and isinstance(data.get("data"), dict):
|
|
607
|
+
data = data["data"]
|
|
608
|
+
if isinstance(data, dict) and isinstance(data.get("vulnerability"), dict):
|
|
609
|
+
data = data["vulnerability"]
|
|
610
|
+
return data if isinstance(data, dict) else {}
|
|
611
|
+
|
|
612
|
+
def _resolve_project_id_for_vulnerability(self, vulnerability_id: str) -> Optional[str]:
|
|
613
|
+
vuln = self.get_vulnerability(vulnerability_id)
|
|
614
|
+
project_id = self._find_first_value(vuln, ("project_id", "projectId"))
|
|
615
|
+
if project_id:
|
|
616
|
+
return project_id
|
|
617
|
+
project = self._find_first_dict(vuln, "project")
|
|
618
|
+
if isinstance(project, dict):
|
|
619
|
+
candidate = project.get("id") or project.get("project_id") or project.get("projectId")
|
|
620
|
+
if isinstance(candidate, str) and candidate:
|
|
621
|
+
return candidate
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
def _find_first_value(self, data: Any, keys: Iterable[str]) -> Optional[str]:
|
|
625
|
+
if isinstance(data, dict):
|
|
626
|
+
for key in keys:
|
|
627
|
+
value = data.get(key)
|
|
628
|
+
if isinstance(value, str) and value:
|
|
629
|
+
return value
|
|
630
|
+
for value in data.values():
|
|
631
|
+
found = self._find_first_value(value, keys)
|
|
632
|
+
if found:
|
|
633
|
+
return found
|
|
634
|
+
elif isinstance(data, list):
|
|
635
|
+
for item in data:
|
|
636
|
+
found = self._find_first_value(item, keys)
|
|
637
|
+
if found:
|
|
638
|
+
return found
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
def _find_first_dict(self, data: Any, key: str) -> Optional[Dict[str, Any]]:
|
|
642
|
+
if isinstance(data, dict):
|
|
643
|
+
value = data.get(key)
|
|
644
|
+
if isinstance(value, dict):
|
|
645
|
+
return value
|
|
646
|
+
for child in data.values():
|
|
647
|
+
found = self._find_first_dict(child, key)
|
|
648
|
+
if found:
|
|
649
|
+
return found
|
|
650
|
+
elif isinstance(data, list):
|
|
651
|
+
for item in data:
|
|
652
|
+
found = self._find_first_dict(item, key)
|
|
653
|
+
if found:
|
|
654
|
+
return found
|
|
655
|
+
return None
|