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 +22 -24
- pyattackforge/client.py +313 -36
- {pyattackforge-0.1.3.dist-info → pyattackforge-0.1.8.dist-info}/METADATA +60 -36
- pyattackforge-0.1.8.dist-info/RECORD +8 -0
- pyattackforge-0.1.3.dist-info/RECORD +0 -8
- {pyattackforge-0.1.3.dist-info → pyattackforge-0.1.8.dist-info}/WHEEL +0 -0
- {pyattackforge-0.1.3.dist-info → pyattackforge-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {pyattackforge-0.1.3.dist-info → pyattackforge-0.1.8.dist-info}/top_level.txt +0 -0
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
GNU Affero General Public License
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
91
|
-
#if not asset_obj:
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
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
|
-
|
|
100
|
+
logger.debug(
|
|
101
|
+
"Found %s findings for project %s",
|
|
102
|
+
len(findings),
|
|
103
|
+
project_id
|
|
104
|
+
)
|
|
101
105
|
for f in findings:
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
#if "Asset Already Exists" in resp.text:
|
|
559
|
-
#
|
|
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
|
-
|
|
753
|
-
#if not asset_obj:
|
|
754
|
-
#
|
|
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
|
+
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.
|
|
162
|
+
client.add_note_to_testcase(
|
|
156
163
|
project_id="abc123",
|
|
157
164
|
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|