pyattackforge 0.1.3__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -1,24 +1,22 @@
1
- """
2
- PyAttackForge
3
-
4
- A lightweight Python library for interacting with the AttackForge API.
5
- """
6
-
7
- """
8
- PyAttackForge is free software: you can redistribute it and/or modify
9
- it under the terms of the GNU Affero General Public License as published by
10
- the Free Software Foundation, either version 3 of the License, or
11
- (at your option) any later version.
12
-
13
- PyAttackForge is distributed in the hope that it will be useful,
14
- but WITHOUT ANY WARRANTY; without even the implied warranty of
15
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
- GNU Affero General Public License for more details.
17
-
18
- You should have received a copy of the GNU Affero General Public License
19
- along with this program. If not, see <https://www.gnu.org/licenses/>.
20
- """
21
-
22
- from .client import PyAttackForgeClient
23
-
24
- __all__ = ["PyAttackForgeClient"]
1
+ """
2
+ PyAttackForge
3
+
4
+ A lightweight Python library for interacting with the AttackForge API.
5
+
6
+ PyAttackForge is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ PyAttackForge is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+ """
19
+
20
+ from .client import PyAttackForgeClient
21
+
22
+ __all__ = ["PyAttackForgeClient"]
pyattackforge/client.py CHANGED
@@ -31,7 +31,7 @@ class PyAttackForgeClient:
31
31
  Supports dry-run mode for testing without making real API calls.
32
32
  """
33
33
 
34
- def upsert_finding_for_project(
34
+ def upsert_finding_for_project( # noqa: C901
35
35
  self,
36
36
  project_id: str,
37
37
  title: str,
@@ -87,20 +87,29 @@ class PyAttackForgeClient:
87
87
  asset_names = []
88
88
  for asset in affected_assets:
89
89
  name = asset["name"] if isinstance(asset, dict) and "name" in asset else asset
90
- asset_obj = self.get_asset_by_name(name)
91
- #if not asset_obj:
92
- # try:
93
- # asset_obj = self.create_asset({"name": name})
94
- # except Exception as e:
95
- # raise RuntimeError(f"Asset '{name}' does not exist and could not be created: {e}")
90
+ self.get_asset_by_name(name)
91
+ # if not asset_obj:
92
+ # try:
93
+ # asset_obj = self.create_asset({"name": name})
94
+ # except Exception as e:
95
+ # raise RuntimeError(f"Asset '{name}' does not exist and could not be created: {e}")
96
96
  asset_names.append(name)
97
97
 
98
98
  # Fetch all findings for the project
99
99
  findings = self.get_findings_for_project(project_id)
100
- print(f"[DEBUG] get_findings_for_project({project_id}) returned {len(findings)} findings:")
100
+ logger.debug(
101
+ "Found %s findings for project %s",
102
+ len(findings),
103
+ project_id
104
+ )
101
105
  for f in findings:
102
- print(f" - id={f.get('vulnerability_id')}, title={f.get('vulnerability_title')}, steps_to_reproduce={f.get('vulnerability_steps_to_reproduce')}")
103
- print(f" FULL FINDING: {f}")
106
+ logger.debug(
107
+ "Finding id=%s title=%s steps=%s",
108
+ f.get("vulnerability_id"),
109
+ f.get("vulnerability_title"),
110
+ f.get("vulnerability_steps_to_reproduce"),
111
+ )
112
+ logger.debug("Finding payload: %s", f)
104
113
  match = None
105
114
  for f in findings:
106
115
  if f.get("vulnerability_title") == title:
@@ -189,6 +198,68 @@ class PyAttackForgeClient:
189
198
  "result": result,
190
199
  }
191
200
 
201
+ def _list_project_findings(
202
+ self,
203
+ project_id: str,
204
+ params: Optional[Dict[str, Any]] = None,
205
+ ) -> List[Dict[str, Any]]:
206
+ """
207
+ Internal helper to fetch findings for a project with optional query params.
208
+ """
209
+ if not project_id:
210
+ raise ValueError("Missing required field: project_id")
211
+ resp = self._request(
212
+ "get",
213
+ f"/api/ss/project/{project_id}/vulnerabilities",
214
+ params=params or {},
215
+ )
216
+ if resp.status_code != 200:
217
+ raise RuntimeError(f"Failed to fetch findings: {resp.text}")
218
+ data = resp.json()
219
+ if isinstance(data, dict) and "vulnerabilities" in data:
220
+ findings = data.get("vulnerabilities") or []
221
+ elif isinstance(data, list):
222
+ findings = data
223
+ else:
224
+ findings = []
225
+ return findings if isinstance(findings, list) else []
226
+
227
+ def get_findings(
228
+ self,
229
+ project_id: str,
230
+ page: int = 1,
231
+ limit: int = 100,
232
+ priority: Optional[str] = None,
233
+ ) -> List[Dict[str, Any]]:
234
+ """
235
+ Backwards-compatible listing of findings with optional pagination.
236
+
237
+ Args:
238
+ project_id (str): The project ID.
239
+ page (int, optional): 1-based page number. Defaults to 1.
240
+ limit (int, optional): Page size. Defaults to 100.
241
+ priority (str, optional): Filter by priority.
242
+
243
+ Returns:
244
+ list: Page of finding/vulnerability dicts.
245
+ """
246
+ if page < 1:
247
+ raise ValueError("page must be >= 1")
248
+ if limit < 1:
249
+ raise ValueError("limit must be >= 1")
250
+ params: Dict[str, Any] = {
251
+ "skip": (page - 1) * limit,
252
+ "limit": limit,
253
+ "page": page,
254
+ }
255
+ if priority:
256
+ params["priority"] = priority
257
+ findings = self._list_project_findings(project_id, params=params)
258
+ if len(findings) > limit:
259
+ start = (page - 1) * limit
260
+ findings = findings[start:start + limit]
261
+ return findings
262
+
192
263
  def get_findings_for_project(self, project_id: str, priority: Optional[str] = None) -> list:
193
264
  """
194
265
  Fetch all findings/vulnerabilities for a given project.
@@ -200,20 +271,8 @@ class PyAttackForgeClient:
200
271
  Returns:
201
272
  list: List of finding/vulnerability dicts.
202
273
  """
203
- params = {}
204
- if priority:
205
- params["priority"] = priority
206
- resp = self._request("get", f"/api/ss/project/{project_id}/vulnerabilities", params=params)
207
- if resp.status_code != 200:
208
- raise RuntimeError(f"Failed to fetch findings: {resp.text}")
209
- # The response may have a "vulnerabilities" key or be a list directly
210
- data = resp.json()
211
- if isinstance(data, dict) and "vulnerabilities" in data:
212
- return data["vulnerabilities"]
213
- elif isinstance(data, list):
214
- return data
215
- else:
216
- return []
274
+ params = {"priority": priority} if priority else None
275
+ return self._list_project_findings(project_id, params=params)
217
276
 
218
277
  def get_vulnerability(self, vulnerability_id: str) -> Dict[str, Any]:
219
278
  """
@@ -235,6 +294,50 @@ class PyAttackForgeClient:
235
294
  return data["vulnerability"]
236
295
  return data
237
296
 
297
+ def update_finding(
298
+ self,
299
+ vulnerability_id: str,
300
+ project_id: Optional[str] = None,
301
+ affected_assets: Optional[list] = None,
302
+ notes: Optional[list] = None,
303
+ **kwargs
304
+ ) -> Dict[str, Any]:
305
+ """
306
+ Update an existing finding/vulnerability with the provided fields.
307
+
308
+ Args:
309
+ vulnerability_id (str): The vulnerability ID to update.
310
+ project_id (str, optional): Project ID when required by the API.
311
+ affected_assets (list, optional): List of asset names or dicts with 'name'/'assetName'.
312
+ notes (list, optional): Notes payload to set.
313
+ **kwargs: Any additional fields accepted by the AttackForge API.
314
+
315
+ Returns:
316
+ dict: API response body.
317
+ """
318
+ if not vulnerability_id:
319
+ raise ValueError("Missing required field: vulnerability_id")
320
+ payload: Dict[str, Any] = {}
321
+ if project_id:
322
+ payload["project_id"] = project_id
323
+ if affected_assets is not None:
324
+ asset_names = [
325
+ a.get("assetName") if isinstance(a, dict) and "assetName" in a
326
+ else a.get("name") if isinstance(a, dict) and "name" in a
327
+ else a
328
+ for a in affected_assets
329
+ ]
330
+ payload["affected_assets"] = [{"assetName": n} for n in asset_names if n]
331
+ if notes is not None:
332
+ payload["notes"] = notes
333
+ for key, value in (kwargs or {}).items():
334
+ if value is not None:
335
+ payload[key] = value
336
+ resp = self._request("put", f"/api/ss/vulnerability/{vulnerability_id}", json_data=payload)
337
+ if resp.status_code not in (200, 201):
338
+ raise RuntimeError(f"Failed to update finding: {resp.text}")
339
+ return resp.json()
340
+
238
341
  def add_note_to_finding(
239
342
  self,
240
343
  vulnerability_id: str,
@@ -293,6 +396,88 @@ class PyAttackForgeClient:
293
396
  raise RuntimeError(f"Failed to add note: {resp.text}")
294
397
  return resp.json()
295
398
 
399
+ def link_vulnerability_to_testcases(
400
+ self,
401
+ vulnerability_id: str,
402
+ testcase_ids: List[str],
403
+ project_id: Optional[str] = None,
404
+ ) -> Dict[str, Any]:
405
+ """
406
+ Link a vulnerability to one or more testcases.
407
+
408
+ Args:
409
+ vulnerability_id (str): The vulnerability ID.
410
+ testcase_ids (list): List of testcase IDs to link.
411
+ project_id (str, optional): Project ID if required by the API.
412
+
413
+ Returns:
414
+ dict: API response.
415
+ """
416
+ if not vulnerability_id:
417
+ raise ValueError("Missing required field: vulnerability_id")
418
+ if not testcase_ids:
419
+ raise ValueError("testcase_ids must contain at least one ID")
420
+ payload: Dict[str, Any] = {
421
+ "linked_testcases": testcase_ids,
422
+ }
423
+ if project_id:
424
+ payload["project_id"] = project_id
425
+ resp = self._request(
426
+ "put",
427
+ f"/api/ss/vulnerability/{vulnerability_id}",
428
+ json_data=payload,
429
+ )
430
+ if resp.status_code not in (200, 201):
431
+ raise RuntimeError(f"Failed to link vulnerability to testcases: {resp.text}")
432
+ return resp.json()
433
+
434
+ def get_testcases(self, project_id: str) -> List[Dict[str, Any]]:
435
+ """
436
+ Retrieve testcases for a project.
437
+
438
+ Args:
439
+ project_id (str): Project ID.
440
+
441
+ Returns:
442
+ list: List of testcase dicts.
443
+ """
444
+ if not project_id:
445
+ raise ValueError("Missing required field: project_id")
446
+ resp = self._request("get", f"/api/ss/project/{project_id}/testcases")
447
+ if resp.status_code not in (200, 201):
448
+ raise RuntimeError(f"Failed to fetch testcases: {resp.text}")
449
+ data = resp.json()
450
+ if isinstance(data, dict) and "testcases" in data:
451
+ return data.get("testcases", [])
452
+ if isinstance(data, list):
453
+ return data
454
+ return []
455
+
456
+ def get_testcase(self, project_id: str, testcase_id: str) -> Optional[Dict[str, Any]]:
457
+ """
458
+ Retrieve a single testcase by ID.
459
+
460
+ Args:
461
+ project_id (str): Project ID.
462
+ testcase_id (str): Testcase ID.
463
+
464
+ Returns:
465
+ dict or None: Testcase details if found, else None.
466
+ """
467
+ if not project_id:
468
+ raise ValueError("Missing required field: project_id")
469
+ if not testcase_id:
470
+ raise ValueError("Missing required field: testcase_id")
471
+ resp = self._request("get", f"/api/ss/project/{project_id}/testcase/{testcase_id}")
472
+ if resp.status_code == 404:
473
+ return None
474
+ if resp.status_code not in (200, 201):
475
+ raise RuntimeError(f"Failed to fetch testcase: {resp.text}")
476
+ data = resp.json()
477
+ if isinstance(data, dict) and "testcase" in data:
478
+ return data["testcase"]
479
+ return data if isinstance(data, dict) else None
480
+
296
481
  def upload_finding_evidence(self, vulnerability_id: str, file_path: str) -> Dict[str, Any]:
297
482
  """
298
483
  Upload evidence to a finding/vulnerability.
@@ -363,6 +548,47 @@ class PyAttackForgeClient:
363
548
  raise RuntimeError(f"Testcase evidence upload failed: {resp.text}")
364
549
  return resp.json()
365
550
 
551
+ def add_note_to_testcase(
552
+ self,
553
+ project_id: str,
554
+ testcase_id: str,
555
+ note: str,
556
+ status: Optional[str] = None
557
+ ) -> Dict[str, Any]:
558
+ """
559
+ Create a testcase note via the dedicated note endpoint, optionally updating status via update_testcase.
560
+
561
+ Args:
562
+ project_id (str): Project ID.
563
+ testcase_id (str): Testcase ID.
564
+ note (str): Note text to set in the details field.
565
+ status (str, optional): Status to set (e.g., "Tested").
566
+
567
+ Returns:
568
+ dict: API response.
569
+ """
570
+ if not project_id:
571
+ raise ValueError("Missing required field: project_id")
572
+ if not testcase_id:
573
+ raise ValueError("Missing required field: testcase_id")
574
+ if not note:
575
+ raise ValueError("Missing required field: note")
576
+ endpoint = f"/api/ss/project/{project_id}/testcase/{testcase_id}/note"
577
+ payload: Dict[str, Any] = {"note": note, "note_type": "PLAINTEXT"}
578
+ resp = self._request("post", endpoint, json_data=payload)
579
+ if resp.status_code not in (200, 201):
580
+ raise RuntimeError(f"Failed to add testcase note: {resp.text}")
581
+ result = resp.json()
582
+
583
+ # Optionally update status using update_testcase
584
+ if status:
585
+ try:
586
+ self.update_testcase(project_id, testcase_id, {"status": status})
587
+ except Exception:
588
+ # If status update fails, still return note creation response
589
+ pass
590
+ return result
591
+
366
592
  def assign_findings_to_testcase(
367
593
  self,
368
594
  project_id: str,
@@ -429,6 +655,53 @@ class PyAttackForgeClient:
429
655
  raise RuntimeError(f"Failed to update testcase: {resp.text}")
430
656
  return resp.json()
431
657
 
658
+ def add_findings_to_testcase(
659
+ self,
660
+ project_id: str,
661
+ testcase_id: str,
662
+ vulnerability_ids: List[str],
663
+ additional_fields: Optional[Dict[str, Any]] = None
664
+ ) -> Dict[str, Any]:
665
+ """
666
+ Fetch a testcase, merge existing linked vulnerabilities with the provided list, and update it.
667
+
668
+ Args:
669
+ project_id (str): The project ID.
670
+ testcase_id (str): The testcase ID.
671
+ vulnerability_ids (list): List of vulnerability IDs to add.
672
+ additional_fields (dict, optional): Extra fields to include (e.g., status).
673
+
674
+ Returns:
675
+ dict: API response from the update.
676
+ """
677
+ if not project_id:
678
+ raise ValueError("Missing required field: project_id")
679
+ if not testcase_id:
680
+ raise ValueError("Missing required field: testcase_id")
681
+ if not vulnerability_ids:
682
+ raise ValueError("vulnerability_ids must contain at least one ID")
683
+
684
+ testcases = self.get_testcases(project_id)
685
+ testcase = next((t for t in testcases if t.get("id") == testcase_id), None)
686
+ if not testcase:
687
+ raise RuntimeError(f"Testcase '{testcase_id}' not found in project '{project_id}'")
688
+
689
+ existing_raw = testcase.get("linked_vulnerabilities", []) or []
690
+ existing_ids: List[str] = []
691
+ for item in existing_raw:
692
+ if isinstance(item, dict) and item.get("id"):
693
+ existing_ids.append(item["id"])
694
+ elif isinstance(item, str):
695
+ existing_ids.append(item)
696
+
697
+ return self.assign_findings_to_testcase(
698
+ project_id=project_id,
699
+ testcase_id=testcase_id,
700
+ vulnerability_ids=vulnerability_ids,
701
+ existing_linked_vulnerabilities=existing_ids,
702
+ additional_fields=additional_fields,
703
+ )
704
+
432
705
  def __init__(self, api_key: str, base_url: str = "https://demo.attackforge.com", dry_run: bool = False):
433
706
  """
434
707
  Initialize the PyAttackForgeClient.
@@ -550,14 +823,14 @@ class PyAttackForgeClient:
550
823
 
551
824
  def create_asset(self, asset_data: Dict[str, Any]) -> Dict[str, Any]:
552
825
  pass
553
- #resp = self._request("post", "/api/ss/library/asset", json_data=asset_data)
554
- #if resp.status_code in (200, 201):
555
- # asset = resp.json()
556
- # self._asset_cache = None # Invalidate cache
557
- # return asset
558
- #if "Asset Already Exists" in resp.text:
559
- # return self.get_asset_by_name(asset_data["name"])
560
- #raise RuntimeError(f"Asset creation failed: {resp.text}")
826
+ # resp = self._request("post", "/api/ss/library/asset", json_data=asset_data)
827
+ # if resp.status_code in (200, 201):
828
+ # asset = resp.json()
829
+ # self._asset_cache = None # Invalidate cache
830
+ # return asset
831
+ # if "Asset Already Exists" in resp.text:
832
+ # return self.get_asset_by_name(asset_data["name"])
833
+ # raise RuntimeError(f"Asset creation failed: {resp.text}")
561
834
 
562
835
  def get_project_by_name(self, name: str) -> Optional[Dict[str, Any]]:
563
836
  params = {
@@ -653,6 +926,7 @@ class PyAttackForgeClient:
653
926
  writeup_id: str,
654
927
  priority: str,
655
928
  affected_assets: Optional[list] = None,
929
+ linked_testcases: Optional[list] = None,
656
930
  **kwargs
657
931
  ) -> Dict[str, Any]:
658
932
  """
@@ -663,6 +937,7 @@ class PyAttackForgeClient:
663
937
  writeup_id (str): The writeup/library ID.
664
938
  priority (str): The priority.
665
939
  affected_assets (list, optional): List of affected asset objects or names.
940
+ linked_testcases (list, optional): List of testcase IDs to link.
666
941
  **kwargs: Additional fields.
667
942
 
668
943
  Returns:
@@ -684,6 +959,8 @@ class PyAttackForgeClient:
684
959
  for asset in affected_assets
685
960
  ]
686
961
  payload["affected_assets"] = [{"assetName": n} for n in asset_names]
962
+ if linked_testcases:
963
+ payload["linked_testcases"] = linked_testcases
687
964
  payload.update(kwargs)
688
965
  resp = self._request("post", "/api/ss/vulnerability-with-library", json_data=payload)
689
966
  if resp.status_code in (200, 201):
@@ -749,9 +1026,9 @@ class PyAttackForgeClient:
749
1026
  name = asset["assetName"] if isinstance(asset, dict) and "assetName" in asset \
750
1027
  else asset["name"] if isinstance(asset, dict) and "name" in asset \
751
1028
  else asset
752
- asset_obj = self.get_asset_by_name(name)
753
- #if not asset_obj:
754
- # asset_obj = self.create_asset({"name": name})
1029
+ self.get_asset_by_name(name)
1030
+ # if not asset_obj:
1031
+ # asset_obj = self.create_asset({"name": name})
755
1032
  asset_names.append(name)
756
1033
  # Ensure all assets are in project scope
757
1034
  scope = self.get_project_scope(project_id)
@@ -808,7 +1085,6 @@ class PyAttackForgeClient:
808
1085
  )
809
1086
  return result
810
1087
 
811
-
812
1088
  def create_vulnerability_old(
813
1089
  self,
814
1090
  project_id: str,
@@ -906,6 +1182,7 @@ class PyAttackForgeClient:
906
1182
  return resp.json()
907
1183
  raise RuntimeError(f"Vulnerability creation failed: {resp.text}")
908
1184
 
1185
+
909
1186
  class DummyResponse:
910
1187
  def __init__(self) -> None:
911
1188
  self.status_code = 200
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyattackforge
3
- Version: 0.1.3
3
+ Version: 0.1.8
4
4
  Summary: Python wrapper for the AttackForge API
5
5
  Home-page: https://github.com/Tantalum-Labs/PyAttackForge
6
6
  Author: Shane S
@@ -40,6 +40,7 @@ A lightweight Python library for interacting with the AttackForge API.
40
40
  - Create findings from existing writeups by passing a `writeup_id`
41
41
  - Upload evidence to findings or testcases
42
42
  - Update/assign testcases to link findings or add notes
43
+ - Link vulnerabilities to testcases via the client
43
44
  - Dry-run mode for testing
44
45
 
45
46
  ---
@@ -112,14 +113,20 @@ client.create_vulnerability(
112
113
 
113
114
  ### Creating a finding from an existing writeup
114
115
 
115
- If you already have a writeup/library entry and just need to create a finding bound to it, you can either pass `writeup_id` to `create_vulnerability` (as above) or call `create_finding_from_writeup` directly:
116
+ If you already have a writeup/library entry and just need to create a finding bound to it, you can either pass `writeup_id` to `create_vulnerability` (as above) or call `create_finding_from_writeup` directly. Prefer the 24-character writeup id (`id` / `_id`); if only a numeric `reference_id` is available, use that. You can also specify the library key (e.g., `approved_writeups`, `Main Vulnerabilities`):
116
117
 
117
118
  ```python
118
119
  client.create_finding_from_writeup(
119
120
  project_id="abc123",
120
- writeup_id="68e92c7a821c05c8405a8003",
121
+ writeup_id="68e92c7a821c05c8405a8003", # writeup id
122
+ library="approved_writeups", # optional: library key/name
121
123
  priority="High",
122
- affected_assets=[{"name": "ssh-prod-1"}]
124
+ affected_assets=[{"name": "ssh-prod-1"}],
125
+ linked_testcases=["5e8017d2e1385f0c58e8f4f8"], # optional: link testcases at creation
126
+ likelihood_of_exploitation=5,
127
+ steps_to_reproduce="1. Do something\n2. Observe result",
128
+ notes=[{"note": "Created via API", "type": "PLAINTEXT"}],
129
+ tags=["automation"]
123
130
  )
124
131
  ```
125
132
 
@@ -152,21 +159,49 @@ client.add_note_to_finding(
152
159
 
153
160
  Add a note/update to a testcase (PUT to the testcase endpoint):
154
161
  ```python
155
- client.update_testcase(
162
+ client.add_note_to_testcase(
156
163
  project_id="abc123",
157
164
  testcase_id="5e8017d2e1385f0c58e8f4f8",
158
- update_fields={
159
- "details": "Observed during retest on 2025-09-19."
160
- }
165
+ note="Observed during retest on 2025-09-19.",
166
+ status="Tested" # optional
161
167
  )
162
168
  ```
163
169
 
164
- Associate findings to a testcase (merges with existing linked vulnerabilities if provided):
170
+ Associate findings to a testcase:
165
171
  ```python
166
172
  client.assign_findings_to_testcase(
167
173
  project_id="abc123",
168
174
  testcase_id="5e8017d2e1385f0c58e8f4f8",
169
- vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"]
175
+ vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"],
176
+ additional_fields={"status": "Tested"} # optional
177
+ )
178
+ ```
179
+ Or link from the vulnerability side using its update endpoint:
180
+ ```python
181
+ client.link_vulnerability_to_testcases(
182
+ vulnerability_id="69273ef0f4a7c85d03930667",
183
+ testcase_ids=["5e8017d2e1385f0c58e8f4f8"],
184
+ project_id="abc123", # optional
185
+ )
186
+ ```
187
+
188
+ Fetch project testcases:
189
+ ```python
190
+ testcases = client.get_testcases("abc123")
191
+ ```
192
+
193
+ Fetch a single testcase (if supported in your tenant):
194
+ ```python
195
+ testcase = client.get_testcase("abc123", "5e8017d2e1385f0c58e8f4f8")
196
+ ```
197
+
198
+ Merge and add findings to a testcase in one call:
199
+ ```python
200
+ client.add_findings_to_testcase(
201
+ project_id="abc123",
202
+ testcase_id="5e8017d2e1385f0c58e8f4f8",
203
+ vulnerability_ids=["69273ef0f4a7c85d03930667"],
204
+ additional_fields={"status": "Tested"} # optional
170
205
  )
171
206
  ```
172
207
 
@@ -212,32 +247,7 @@ See the source code for full details and docstrings.
212
247
  - `create_vulnerability(
213
248
  project_id: str,
214
249
  title: str,
215
- affected_asset_name: str,
216
- priority: str,
217
- likelihood_of_exploitation: int,
218
- description: str,
219
- attack_scenario: str,
220
- remediation_recommendation: str,
221
- steps_to_reproduce: str,
222
- tags: Optional[list] = None,
223
- notes: Optional[list] = None,
224
- is_zeroday: bool = False,
225
- is_visible: bool = True,
226
- import_to_library: Optional[str] = None,
227
- import_source: Optional[str] = None,
228
- import_source_id: Optional[str] = None,
229
- custom_fields: Optional[list] = None,
230
- linked_testcases: Optional[list] = None,
231
- custom_tags: Optional[list] = None,
232
- ) -> dict`
233
-
234
- See the source code for full details and docstrings.
235
-
236
- ---
237
- - `create_vulnerability(
238
- project_id: str,
239
- title: str,
240
- affected_asset_name: str,
250
+ affected_assets: list,
241
251
  priority: str,
242
252
  likelihood_of_exploitation: int,
243
253
  description: str,
@@ -254,7 +264,21 @@ See the source code for full details and docstrings.
254
264
  custom_fields: Optional[list] = None,
255
265
  linked_testcases: Optional[list] = None,
256
266
  custom_tags: Optional[list] = None,
267
+ writeup_custom_fields: Optional[list] = None,
257
268
  ) -> dict`
269
+ - `create_finding_from_writeup(project_id: str, writeup_id: str, priority: str, affected_assets: Optional[list] = None, linked_testcases: Optional[list] = None, **kwargs) -> dict`
270
+ - `get_findings_for_project(project_id: str, priority: Optional[str] = None) -> list`
271
+ - `upsert_finding_for_project(...)`
272
+ - `get_vulnerability(vulnerability_id: str) -> dict`
273
+ - `add_note_to_finding(vulnerability_id: str, note: Any, note_type: str = "PLAINTEXT") -> dict`
274
+ - `upload_finding_evidence(vulnerability_id: str, file_path: str) -> dict`
275
+ - `upload_testcase_evidence(project_id: str, testcase_id: str, file_path: str) -> dict`
276
+ - `get_testcases(project_id: str) -> list`
277
+ - `get_testcase(project_id: str, testcase_id: str) -> dict or None`
278
+ - `link_vulnerability_to_testcases(vulnerability_id: str, testcase_ids: List[str], project_id: Optional[str] = None) -> dict`
279
+ - `assign_findings_to_testcase(project_id: str, testcase_id: str, vulnerability_ids: List[str], existing_linked_vulnerabilities: Optional[List[str]] = None, additional_fields: Optional[Dict[str, Any]] = None) -> dict`
280
+ - `add_findings_to_testcase(project_id: str, testcase_id: str, vulnerability_ids: List[str], additional_fields: Optional[Dict[str, Any]] = None) -> dict`
281
+ - `add_note_to_testcase(project_id: str, testcase_id: str, note: str, status: Optional[str] = None) -> dict`
258
282
 
259
283
  See the source code for full details and docstrings.
260
284
 
@@ -0,0 +1,8 @@
1
+ pyattackforge/__init__.py,sha256=r4FT68O03ZuyQJ70RLwlLDqp7AqNTTcMm-FLU4zHwIU,807
2
+ pyattackforge/client.py,sha256=GW80YWF462HPC_hhcGiIYylb0j--6qNOs1qM4Sp6Lx0,48924
3
+ pyattackforge/prev_client.py,sha256=Wu-5BVCJbLkWBsxjQmL3nZoyWqAqOACKM9ScXfl6C5o,14277
4
+ pyattackforge-0.1.8.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
5
+ pyattackforge-0.1.8.dist-info/METADATA,sha256=SnEV1N8toYq2bgSc0FZ5HEHDRk4zSZ7z8TW7Smy4jPc,10754
6
+ pyattackforge-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ pyattackforge-0.1.8.dist-info/top_level.txt,sha256=1rDeMkWvFWuX3MS8V65no7KuybYyvtfIgbYSt5m_uPU,14
8
+ pyattackforge-0.1.8.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- pyattackforge/__init__.py,sha256=xZxubdjv_Fc1bRqfVRMR4j9SCTLXg9TfckWn9CQHH_E,839
2
- pyattackforge/client.py,sha256=gPd5CwP714NKe0IwhmKUrCMklQg1ARVOfcB4ibAne7o,38753
3
- pyattackforge/prev_client.py,sha256=Wu-5BVCJbLkWBsxjQmL3nZoyWqAqOACKM9ScXfl6C5o,14277
4
- pyattackforge-0.1.3.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
5
- pyattackforge-0.1.3.dist-info/METADATA,sha256=xzIKr5FGJ37Enc5QaCPyiVj0wFlgyUdR63Wq_u_M9sE,8770
6
- pyattackforge-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- pyattackforge-0.1.3.dist-info/top_level.txt,sha256=1rDeMkWvFWuX3MS8V65no7KuybYyvtfIgbYSt5m_uPU,14
8
- pyattackforge-0.1.3.dist-info/RECORD,,