pyattackforge 0.1.3__tar.gz → 0.1.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyattackforge
3
- Version: 0.1.3
3
+ Version: 0.1.7
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
 
@@ -12,6 +12,7 @@ A lightweight Python library for interacting with the AttackForge API.
12
12
  - Create findings from existing writeups by passing a `writeup_id`
13
13
  - Upload evidence to findings or testcases
14
14
  - Update/assign testcases to link findings or add notes
15
+ - Link vulnerabilities to testcases via the client
15
16
  - Dry-run mode for testing
16
17
 
17
18
  ---
@@ -84,14 +85,20 @@ client.create_vulnerability(
84
85
 
85
86
  ### Creating a finding from an existing writeup
86
87
 
87
- 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:
88
+ 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`):
88
89
 
89
90
  ```python
90
91
  client.create_finding_from_writeup(
91
92
  project_id="abc123",
92
- writeup_id="68e92c7a821c05c8405a8003",
93
+ writeup_id="68e92c7a821c05c8405a8003", # writeup id
94
+ library="approved_writeups", # optional: library key/name
93
95
  priority="High",
94
- affected_assets=[{"name": "ssh-prod-1"}]
96
+ affected_assets=[{"name": "ssh-prod-1"}],
97
+ linked_testcases=["5e8017d2e1385f0c58e8f4f8"], # optional: link testcases at creation
98
+ likelihood_of_exploitation=5,
99
+ steps_to_reproduce="1. Do something\n2. Observe result",
100
+ notes=[{"note": "Created via API", "type": "PLAINTEXT"}],
101
+ tags=["automation"]
95
102
  )
96
103
  ```
97
104
 
@@ -124,21 +131,49 @@ client.add_note_to_finding(
124
131
 
125
132
  Add a note/update to a testcase (PUT to the testcase endpoint):
126
133
  ```python
127
- client.update_testcase(
134
+ client.add_note_to_testcase(
128
135
  project_id="abc123",
129
136
  testcase_id="5e8017d2e1385f0c58e8f4f8",
130
- update_fields={
131
- "details": "Observed during retest on 2025-09-19."
132
- }
137
+ note="Observed during retest on 2025-09-19.",
138
+ status="Tested" # optional
133
139
  )
134
140
  ```
135
141
 
136
- Associate findings to a testcase (merges with existing linked vulnerabilities if provided):
142
+ Associate findings to a testcase:
137
143
  ```python
138
144
  client.assign_findings_to_testcase(
139
145
  project_id="abc123",
140
146
  testcase_id="5e8017d2e1385f0c58e8f4f8",
141
- vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"]
147
+ vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"],
148
+ additional_fields={"status": "Tested"} # optional
149
+ )
150
+ ```
151
+ Or link from the vulnerability side using its update endpoint:
152
+ ```python
153
+ client.link_vulnerability_to_testcases(
154
+ vulnerability_id="69273ef0f4a7c85d03930667",
155
+ testcase_ids=["5e8017d2e1385f0c58e8f4f8"],
156
+ project_id="abc123", # optional
157
+ )
158
+ ```
159
+
160
+ Fetch project testcases:
161
+ ```python
162
+ testcases = client.get_testcases("abc123")
163
+ ```
164
+
165
+ Fetch a single testcase (if supported in your tenant):
166
+ ```python
167
+ testcase = client.get_testcase("abc123", "5e8017d2e1385f0c58e8f4f8")
168
+ ```
169
+
170
+ Merge and add findings to a testcase in one call:
171
+ ```python
172
+ client.add_findings_to_testcase(
173
+ project_id="abc123",
174
+ testcase_id="5e8017d2e1385f0c58e8f4f8",
175
+ vulnerability_ids=["69273ef0f4a7c85d03930667"],
176
+ additional_fields={"status": "Tested"} # optional
142
177
  )
143
178
  ```
144
179
 
@@ -184,32 +219,7 @@ See the source code for full details and docstrings.
184
219
  - `create_vulnerability(
185
220
  project_id: str,
186
221
  title: str,
187
- affected_asset_name: str,
188
- priority: str,
189
- likelihood_of_exploitation: int,
190
- description: str,
191
- attack_scenario: str,
192
- remediation_recommendation: str,
193
- steps_to_reproduce: str,
194
- tags: Optional[list] = None,
195
- notes: Optional[list] = None,
196
- is_zeroday: bool = False,
197
- is_visible: bool = True,
198
- import_to_library: Optional[str] = None,
199
- import_source: Optional[str] = None,
200
- import_source_id: Optional[str] = None,
201
- custom_fields: Optional[list] = None,
202
- linked_testcases: Optional[list] = None,
203
- custom_tags: Optional[list] = None,
204
- ) -> dict`
205
-
206
- See the source code for full details and docstrings.
207
-
208
- ---
209
- - `create_vulnerability(
210
- project_id: str,
211
- title: str,
212
- affected_asset_name: str,
222
+ affected_assets: list,
213
223
  priority: str,
214
224
  likelihood_of_exploitation: int,
215
225
  description: str,
@@ -226,7 +236,21 @@ See the source code for full details and docstrings.
226
236
  custom_fields: Optional[list] = None,
227
237
  linked_testcases: Optional[list] = None,
228
238
  custom_tags: Optional[list] = None,
239
+ writeup_custom_fields: Optional[list] = None,
229
240
  ) -> dict`
241
+ - `create_finding_from_writeup(project_id: str, writeup_id: str, priority: str, affected_assets: Optional[list] = None, linked_testcases: Optional[list] = None, **kwargs) -> dict`
242
+ - `get_findings_for_project(project_id: str, priority: Optional[str] = None) -> list`
243
+ - `upsert_finding_for_project(...)`
244
+ - `get_vulnerability(vulnerability_id: str) -> dict`
245
+ - `add_note_to_finding(vulnerability_id: str, note: Any, note_type: str = "PLAINTEXT") -> dict`
246
+ - `upload_finding_evidence(vulnerability_id: str, file_path: str) -> dict`
247
+ - `upload_testcase_evidence(project_id: str, testcase_id: str, file_path: str) -> dict`
248
+ - `get_testcases(project_id: str) -> list`
249
+ - `get_testcase(project_id: str, testcase_id: str) -> dict or None`
250
+ - `link_vulnerability_to_testcases(vulnerability_id: str, testcase_ids: List[str], project_id: Optional[str] = None) -> dict`
251
+ - `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`
252
+ - `add_findings_to_testcase(project_id: str, testcase_id: str, vulnerability_ids: List[str], additional_fields: Optional[Dict[str, Any]] = None) -> dict`
253
+ - `add_note_to_testcase(project_id: str, testcase_id: str, note: str, status: Optional[str] = None) -> dict`
230
254
 
231
255
  See the source code for full details and docstrings.
232
256
 
@@ -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"]
@@ -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:
@@ -293,6 +302,88 @@ class PyAttackForgeClient:
293
302
  raise RuntimeError(f"Failed to add note: {resp.text}")
294
303
  return resp.json()
295
304
 
305
+ def link_vulnerability_to_testcases(
306
+ self,
307
+ vulnerability_id: str,
308
+ testcase_ids: List[str],
309
+ project_id: Optional[str] = None,
310
+ ) -> Dict[str, Any]:
311
+ """
312
+ Link a vulnerability to one or more testcases.
313
+
314
+ Args:
315
+ vulnerability_id (str): The vulnerability ID.
316
+ testcase_ids (list): List of testcase IDs to link.
317
+ project_id (str, optional): Project ID if required by the API.
318
+
319
+ Returns:
320
+ dict: API response.
321
+ """
322
+ if not vulnerability_id:
323
+ raise ValueError("Missing required field: vulnerability_id")
324
+ if not testcase_ids:
325
+ raise ValueError("testcase_ids must contain at least one ID")
326
+ payload: Dict[str, Any] = {
327
+ "linked_testcases": testcase_ids,
328
+ }
329
+ if project_id:
330
+ payload["project_id"] = project_id
331
+ resp = self._request(
332
+ "put",
333
+ f"/api/ss/vulnerability/{vulnerability_id}",
334
+ json_data=payload,
335
+ )
336
+ if resp.status_code not in (200, 201):
337
+ raise RuntimeError(f"Failed to link vulnerability to testcases: {resp.text}")
338
+ return resp.json()
339
+
340
+ def get_testcases(self, project_id: str) -> List[Dict[str, Any]]:
341
+ """
342
+ Retrieve testcases for a project.
343
+
344
+ Args:
345
+ project_id (str): Project ID.
346
+
347
+ Returns:
348
+ list: List of testcase dicts.
349
+ """
350
+ if not project_id:
351
+ raise ValueError("Missing required field: project_id")
352
+ resp = self._request("get", f"/api/ss/project/{project_id}/testcases")
353
+ if resp.status_code not in (200, 201):
354
+ raise RuntimeError(f"Failed to fetch testcases: {resp.text}")
355
+ data = resp.json()
356
+ if isinstance(data, dict) and "testcases" in data:
357
+ return data.get("testcases", [])
358
+ if isinstance(data, list):
359
+ return data
360
+ return []
361
+
362
+ def get_testcase(self, project_id: str, testcase_id: str) -> Optional[Dict[str, Any]]:
363
+ """
364
+ Retrieve a single testcase by ID.
365
+
366
+ Args:
367
+ project_id (str): Project ID.
368
+ testcase_id (str): Testcase ID.
369
+
370
+ Returns:
371
+ dict or None: Testcase details if found, else None.
372
+ """
373
+ if not project_id:
374
+ raise ValueError("Missing required field: project_id")
375
+ if not testcase_id:
376
+ raise ValueError("Missing required field: testcase_id")
377
+ resp = self._request("get", f"/api/ss/project/{project_id}/testcase/{testcase_id}")
378
+ if resp.status_code == 404:
379
+ return None
380
+ if resp.status_code not in (200, 201):
381
+ raise RuntimeError(f"Failed to fetch testcase: {resp.text}")
382
+ data = resp.json()
383
+ if isinstance(data, dict) and "testcase" in data:
384
+ return data["testcase"]
385
+ return data if isinstance(data, dict) else None
386
+
296
387
  def upload_finding_evidence(self, vulnerability_id: str, file_path: str) -> Dict[str, Any]:
297
388
  """
298
389
  Upload evidence to a finding/vulnerability.
@@ -363,6 +454,47 @@ class PyAttackForgeClient:
363
454
  raise RuntimeError(f"Testcase evidence upload failed: {resp.text}")
364
455
  return resp.json()
365
456
 
457
+ def add_note_to_testcase(
458
+ self,
459
+ project_id: str,
460
+ testcase_id: str,
461
+ note: str,
462
+ status: Optional[str] = None
463
+ ) -> Dict[str, Any]:
464
+ """
465
+ Create a testcase note via the dedicated note endpoint, optionally updating status via update_testcase.
466
+
467
+ Args:
468
+ project_id (str): Project ID.
469
+ testcase_id (str): Testcase ID.
470
+ note (str): Note text to set in the details field.
471
+ status (str, optional): Status to set (e.g., "Tested").
472
+
473
+ Returns:
474
+ dict: API response.
475
+ """
476
+ if not project_id:
477
+ raise ValueError("Missing required field: project_id")
478
+ if not testcase_id:
479
+ raise ValueError("Missing required field: testcase_id")
480
+ if not note:
481
+ raise ValueError("Missing required field: note")
482
+ endpoint = f"/api/ss/project/{project_id}/testcase/{testcase_id}/note"
483
+ payload: Dict[str, Any] = {"note": note, "note_type": "PLAINTEXT"}
484
+ resp = self._request("post", endpoint, json_data=payload)
485
+ if resp.status_code not in (200, 201):
486
+ raise RuntimeError(f"Failed to add testcase note: {resp.text}")
487
+ result = resp.json()
488
+
489
+ # Optionally update status using update_testcase
490
+ if status:
491
+ try:
492
+ self.update_testcase(project_id, testcase_id, {"status": status})
493
+ except Exception:
494
+ # If status update fails, still return note creation response
495
+ pass
496
+ return result
497
+
366
498
  def assign_findings_to_testcase(
367
499
  self,
368
500
  project_id: str,
@@ -429,6 +561,53 @@ class PyAttackForgeClient:
429
561
  raise RuntimeError(f"Failed to update testcase: {resp.text}")
430
562
  return resp.json()
431
563
 
564
+ def add_findings_to_testcase(
565
+ self,
566
+ project_id: str,
567
+ testcase_id: str,
568
+ vulnerability_ids: List[str],
569
+ additional_fields: Optional[Dict[str, Any]] = None
570
+ ) -> Dict[str, Any]:
571
+ """
572
+ Fetch a testcase, merge existing linked vulnerabilities with the provided list, and update it.
573
+
574
+ Args:
575
+ project_id (str): The project ID.
576
+ testcase_id (str): The testcase ID.
577
+ vulnerability_ids (list): List of vulnerability IDs to add.
578
+ additional_fields (dict, optional): Extra fields to include (e.g., status).
579
+
580
+ Returns:
581
+ dict: API response from the update.
582
+ """
583
+ if not project_id:
584
+ raise ValueError("Missing required field: project_id")
585
+ if not testcase_id:
586
+ raise ValueError("Missing required field: testcase_id")
587
+ if not vulnerability_ids:
588
+ raise ValueError("vulnerability_ids must contain at least one ID")
589
+
590
+ testcases = self.get_testcases(project_id)
591
+ testcase = next((t for t in testcases if t.get("id") == testcase_id), None)
592
+ if not testcase:
593
+ raise RuntimeError(f"Testcase '{testcase_id}' not found in project '{project_id}'")
594
+
595
+ existing_raw = testcase.get("linked_vulnerabilities", []) or []
596
+ existing_ids: List[str] = []
597
+ for item in existing_raw:
598
+ if isinstance(item, dict) and item.get("id"):
599
+ existing_ids.append(item["id"])
600
+ elif isinstance(item, str):
601
+ existing_ids.append(item)
602
+
603
+ return self.assign_findings_to_testcase(
604
+ project_id=project_id,
605
+ testcase_id=testcase_id,
606
+ vulnerability_ids=vulnerability_ids,
607
+ existing_linked_vulnerabilities=existing_ids,
608
+ additional_fields=additional_fields,
609
+ )
610
+
432
611
  def __init__(self, api_key: str, base_url: str = "https://demo.attackforge.com", dry_run: bool = False):
433
612
  """
434
613
  Initialize the PyAttackForgeClient.
@@ -550,14 +729,14 @@ class PyAttackForgeClient:
550
729
 
551
730
  def create_asset(self, asset_data: Dict[str, Any]) -> Dict[str, Any]:
552
731
  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}")
732
+ # resp = self._request("post", "/api/ss/library/asset", json_data=asset_data)
733
+ # if resp.status_code in (200, 201):
734
+ # asset = resp.json()
735
+ # self._asset_cache = None # Invalidate cache
736
+ # return asset
737
+ # if "Asset Already Exists" in resp.text:
738
+ # return self.get_asset_by_name(asset_data["name"])
739
+ # raise RuntimeError(f"Asset creation failed: {resp.text}")
561
740
 
562
741
  def get_project_by_name(self, name: str) -> Optional[Dict[str, Any]]:
563
742
  params = {
@@ -653,6 +832,7 @@ class PyAttackForgeClient:
653
832
  writeup_id: str,
654
833
  priority: str,
655
834
  affected_assets: Optional[list] = None,
835
+ linked_testcases: Optional[list] = None,
656
836
  **kwargs
657
837
  ) -> Dict[str, Any]:
658
838
  """
@@ -663,6 +843,7 @@ class PyAttackForgeClient:
663
843
  writeup_id (str): The writeup/library ID.
664
844
  priority (str): The priority.
665
845
  affected_assets (list, optional): List of affected asset objects or names.
846
+ linked_testcases (list, optional): List of testcase IDs to link.
666
847
  **kwargs: Additional fields.
667
848
 
668
849
  Returns:
@@ -684,6 +865,8 @@ class PyAttackForgeClient:
684
865
  for asset in affected_assets
685
866
  ]
686
867
  payload["affected_assets"] = [{"assetName": n} for n in asset_names]
868
+ if linked_testcases:
869
+ payload["linked_testcases"] = linked_testcases
687
870
  payload.update(kwargs)
688
871
  resp = self._request("post", "/api/ss/vulnerability-with-library", json_data=payload)
689
872
  if resp.status_code in (200, 201):
@@ -749,9 +932,9 @@ class PyAttackForgeClient:
749
932
  name = asset["assetName"] if isinstance(asset, dict) and "assetName" in asset \
750
933
  else asset["name"] if isinstance(asset, dict) and "name" in asset \
751
934
  else asset
752
- asset_obj = self.get_asset_by_name(name)
753
- #if not asset_obj:
754
- # asset_obj = self.create_asset({"name": name})
935
+ self.get_asset_by_name(name)
936
+ # if not asset_obj:
937
+ # asset_obj = self.create_asset({"name": name})
755
938
  asset_names.append(name)
756
939
  # Ensure all assets are in project scope
757
940
  scope = self.get_project_scope(project_id)
@@ -808,7 +991,6 @@ class PyAttackForgeClient:
808
991
  )
809
992
  return result
810
993
 
811
-
812
994
  def create_vulnerability_old(
813
995
  self,
814
996
  project_id: str,
@@ -906,6 +1088,7 @@ class PyAttackForgeClient:
906
1088
  return resp.json()
907
1089
  raise RuntimeError(f"Vulnerability creation failed: {resp.text}")
908
1090
 
1091
+
909
1092
  class DummyResponse:
910
1093
  def __init__(self) -> None:
911
1094
  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.7
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
 
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="pyattackforge",
8
- version="0.1.3",
8
+ version="0.1.7",
9
9
  packages=find_packages(),
10
10
  install_requires=[
11
11
  "requests>=2.20.0"
@@ -90,7 +90,7 @@ class TestPyAttackForgeClient(unittest.TestCase):
90
90
  # Ensure the client uses the provided writeup_id and does not attempt to create/search
91
91
  self.client.get_all_writeups = lambda force_refresh=False: []
92
92
  self.client.find_writeup_in_cache = lambda title, library="Main Vulnerabilities": None
93
- captured = {}
93
+ captured = {"endpoints": []}
94
94
 
95
95
  def fake_create_from_writeup(**kwargs):
96
96
  captured.update(kwargs)
@@ -203,24 +203,26 @@ class TestPyAttackForgeClient(unittest.TestCase):
203
203
  }
204
204
  self.client.get_findings_for_project = lambda project_id: [existing_finding]
205
205
  # Patch get_all_writeups to return a matching writeup for this test
206
- self.client.get_all_writeups = (
207
- lambda force_refresh=False: [
208
- {
209
- "title": "UnitTest Finding",
210
- "belongs_to_library": "Main Vulnerabilities",
211
- "reference_id": "dummy_writeup_id",
212
- }
213
- ]
214
- )
215
- self.client.create_writeup = lambda **kwargs: {"reference_id": "dummy_writeup_id"}
216
- # Patch _request to simulate API update response
217
- class Resp:
218
- status_code = 200
219
- def json(self):
220
- return {"updated": True}
221
- text = "OK"
222
- self.client._request = (
223
- lambda method, endpoint, json_data=None, params=None: Resp()
206
+ self.client.get_all_writeups = (
207
+ lambda force_refresh=False: [
208
+ {
209
+ "title": "UnitTest Finding",
210
+ "belongs_to_library": "Main Vulnerabilities",
211
+ "reference_id": "dummy_writeup_id",
212
+ }
213
+ ]
214
+ )
215
+ self.client.create_writeup = lambda **kwargs: {"reference_id": "dummy_writeup_id"}
216
+ # Patch _request to simulate API update response
217
+
218
+ class Resp:
219
+ status_code = 200
220
+
221
+ def json(self):
222
+ return {"updated": True}
223
+ text = "OK"
224
+ self.client._request = (
225
+ lambda method, endpoint, json_data=None, params=None: Resp()
224
226
  )
225
227
  result = self.client.upsert_finding_for_project(
226
228
  project_id="dummy_project",
@@ -339,7 +341,7 @@ class TestPyAttackForgeClient(unittest.TestCase):
339
341
  os.remove(evidence_path)
340
342
 
341
343
  def test_assign_findings_to_testcase_merges(self):
342
- captured = {}
344
+ captured = {"endpoints": []}
343
345
 
344
346
  def fake_update(project_id, testcase_id, update_fields):
345
347
  captured["payload"] = update_fields
@@ -360,11 +362,12 @@ class TestPyAttackForgeClient(unittest.TestCase):
360
362
  self.client.get_vulnerability = lambda vid: {
361
363
  "vulnerability_notes": [{"note": "Existing note", "type": "PLAINTEXT"}]
362
364
  }
363
- captured = {}
365
+ captured = {"endpoints": []}
364
366
 
365
367
  class Resp:
366
368
  status_code = 200
367
369
  text = "OK"
370
+
368
371
  def json(self):
369
372
  return {"ok": True}
370
373
 
@@ -376,7 +379,112 @@ class TestPyAttackForgeClient(unittest.TestCase):
376
379
  self.assertIsInstance(result, dict)
377
380
  notes = captured["json_data"].get("notes", [])
378
381
  self.assertEqual(len(notes), 1)
379
-
382
+
383
+ def test_get_testcases(self):
384
+ # DummyResponse returns {}, so should yield an empty list without raising
385
+ cases = self.client.get_testcases("proj1")
386
+ self.assertIsInstance(cases, list)
387
+ self.assertEqual(cases, [])
388
+
389
+ def test_get_testcase(self):
390
+ class Resp:
391
+ def __init__(self, status_code, body):
392
+ self.status_code = status_code
393
+ self._body = body
394
+
395
+ def json(self):
396
+ return self._body
397
+ text = "resp"
398
+
399
+ calls = []
400
+
401
+ def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
402
+ calls.append(endpoint)
403
+ if "tc-ok" in endpoint:
404
+ return Resp(200, {"testcase": {"id": "tc-ok", "status": "Not Tested"}})
405
+ return Resp(404, {})
406
+
407
+ self.client._request = fake_request
408
+ tc_none = self.client.get_testcase("proj", "tc-missing")
409
+ self.assertIsNone(tc_none)
410
+ tc = self.client.get_testcase("proj", "tc-ok")
411
+ self.assertIsInstance(tc, dict)
412
+ self.assertEqual(tc.get("id"), "tc-ok")
413
+
414
+ def test_add_note_to_testcase(self):
415
+ captured = {"endpoints": []}
416
+
417
+ class Resp:
418
+ status_code = 200
419
+
420
+ def json(self):
421
+ return {"status": "Testcase Note Created"}
422
+
423
+ def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
424
+ captured["endpoints"].append((endpoint, json_data))
425
+ return Resp()
426
+
427
+ self.client._request = fake_request
428
+ resp = self.client.add_note_to_testcase("proj1", "tc1", "Note text", status="Tested")
429
+ self.assertIsInstance(resp, dict)
430
+ note_calls = [c for c in captured["endpoints"] if "/note" in c[0]]
431
+ self.assertTrue(note_calls)
432
+ self.assertEqual(note_calls[0][1]["note"], "Note text")
433
+ self.assertEqual(note_calls[0][1]["note_type"], "PLAINTEXT")
434
+
435
+ def test_link_vulnerability_to_testcases(self):
436
+ captured = {}
437
+
438
+ class Resp:
439
+ status_code = 200
440
+ text = "OK"
441
+
442
+ def json(self):
443
+ return {"linked": True}
444
+
445
+ def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
446
+ captured["method"] = method
447
+ captured["endpoint"] = endpoint
448
+ captured["json_data"] = json_data
449
+ return Resp()
450
+
451
+ self.client._request = fake_request
452
+ resp = self.client.link_vulnerability_to_testcases("v1", ["tc1", "tc2"], project_id="proj1")
453
+ self.assertIsInstance(resp, dict)
454
+ self.assertEqual(captured["endpoint"], "/api/ss/vulnerability/v1")
455
+ self.assertEqual(captured["json_data"]["linked_testcases"], ["tc1", "tc2"])
456
+ self.assertEqual(captured["json_data"]["project_id"], "proj1")
457
+
458
+ def test_add_findings_to_testcase(self):
459
+ captured = {}
460
+ # Simulate existing testcase with one linked vuln (as dict)
461
+ self.client.get_testcases = lambda project_id: [
462
+ {
463
+ "id": "tc1",
464
+ "linked_vulnerabilities": [{"id": "existing"}],
465
+ }
466
+ ]
467
+
468
+ def fake_assign(project_id, testcase_id, vulnerability_ids, existing_linked_vulnerabilities=None, additional_fields=None):
469
+ captured["project_id"] = project_id
470
+ captured["testcase_id"] = testcase_id
471
+ captured["vulnerability_ids"] = vulnerability_ids
472
+ captured["existing_linked_vulnerabilities"] = existing_linked_vulnerabilities
473
+ captured["additional_fields"] = additional_fields
474
+ return {"assigned": True}
475
+
476
+ self.client.assign_findings_to_testcase = fake_assign
477
+ resp = self.client.add_findings_to_testcase(
478
+ "proj1",
479
+ "tc1",
480
+ ["new1", "new2"],
481
+ additional_fields={"status": "Tested"},
482
+ )
483
+ self.assertIsInstance(resp, dict)
484
+ self.assertEqual(captured["existing_linked_vulnerabilities"], ["existing"])
485
+ self.assertEqual(captured["vulnerability_ids"], ["new1", "new2"])
486
+ self.assertEqual(captured["additional_fields"], {"status": "Tested"})
487
+
380
488
 
381
489
  if __name__ == "__main__":
382
490
  unittest.main()
File without changes
File without changes