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.
@@ -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