pyattackforge 0.1.1__py3-none-any.whl → 0.1.7__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 +1107 -383
- pyattackforge/prev_client.py +384 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.7.dist-info}/METADATA +120 -30
- pyattackforge-0.1.7.dist-info/RECORD +8 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.7.dist-info}/WHEEL +0 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.7.dist-info}/licenses/LICENSE +661 -661
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.7.dist-info}/top_level.txt +0 -0
- pyattackforge-0.1.1.dist-info/RECORD +0 -7
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyAttackForge is free software: you can redistribute it and/or modify
|
|
3
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
4
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
5
|
+
(at your option) any later version.
|
|
6
|
+
|
|
7
|
+
PyAttackForge is distributed in the hope that it will be useful,
|
|
8
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
GNU Affero General Public License for more details.
|
|
11
|
+
|
|
12
|
+
You should have received a copy of the GNU Affero General Public License
|
|
13
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime, timezone, timedelta
|
|
19
|
+
from typing import Any, Dict, Optional, Set, Tuple, List
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("pyattackforge")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PyAttackForgeClient:
|
|
26
|
+
"""
|
|
27
|
+
Python client for interacting with the AttackForge API.
|
|
28
|
+
|
|
29
|
+
Provides methods to manage assets, projects, and vulnerabilities.
|
|
30
|
+
Supports dry-run mode for testing without making real API calls.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key: str, base_url: str = "https://demo.attackforge.com", dry_run: bool = False):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the PyAttackForgeClient.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
api_key (str): Your AttackForge API key.
|
|
39
|
+
base_url (str, optional): The base URL for the AttackForge instance. Defaults to "https://demo.attackforge.com".
|
|
40
|
+
dry_run (bool, optional): If True, no real API calls are made. Defaults to False.
|
|
41
|
+
"""
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
self.headers = {
|
|
44
|
+
"X-SSAPI-KEY": api_key,
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Connection": "close"
|
|
47
|
+
}
|
|
48
|
+
self.dry_run = dry_run
|
|
49
|
+
self._asset_cache = None
|
|
50
|
+
self._project_scope_cache = {} # {project_id: set(asset_names)}
|
|
51
|
+
|
|
52
|
+
def _request(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
endpoint: str,
|
|
56
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
57
|
+
params: Optional[Dict[str, Any]] = None
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""
|
|
60
|
+
Internal method to send an HTTP request to the AttackForge API.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
method (str): HTTP method (get, post, put, etc.).
|
|
64
|
+
endpoint (str): API endpoint path.
|
|
65
|
+
json_data (dict, optional): JSON payload for the request.
|
|
66
|
+
params (dict, optional): Query parameters.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Response: The HTTP response object.
|
|
70
|
+
"""
|
|
71
|
+
url = f"{self.base_url}{endpoint}"
|
|
72
|
+
if self.dry_run:
|
|
73
|
+
logger.info("[DRY RUN] %s %s", method.upper(), url)
|
|
74
|
+
if json_data:
|
|
75
|
+
logger.info("Payload: %s", json_data)
|
|
76
|
+
if params:
|
|
77
|
+
logger.info("Params: %s", params)
|
|
78
|
+
return DummyResponse()
|
|
79
|
+
return requests.request(method, url, headers=self.headers, json=json_data, params=params)
|
|
80
|
+
|
|
81
|
+
def get_assets(self) -> Dict[str, Dict[str, Any]]:
|
|
82
|
+
"""
|
|
83
|
+
Retrieve all assets from AttackForge.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
dict: Mapping of asset names to asset details.
|
|
87
|
+
"""
|
|
88
|
+
if self._asset_cache is None:
|
|
89
|
+
self._asset_cache = {}
|
|
90
|
+
skip, limit = 0, 500
|
|
91
|
+
while True:
|
|
92
|
+
resp = self._request("get", "/api/ss/assets", params={"skip": skip, "limit": limit})
|
|
93
|
+
data = resp.json()
|
|
94
|
+
for asset in data.get("assets", []):
|
|
95
|
+
name = asset.get("asset")
|
|
96
|
+
if name:
|
|
97
|
+
self._asset_cache[name] = asset
|
|
98
|
+
if skip + limit >= data.get("count", 0):
|
|
99
|
+
break
|
|
100
|
+
skip += limit
|
|
101
|
+
return self._asset_cache
|
|
102
|
+
|
|
103
|
+
def get_asset_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
|
104
|
+
"""
|
|
105
|
+
Retrieve an asset by its name.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
name (str): The asset name.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
dict or None: Asset details if found, else None.
|
|
112
|
+
"""
|
|
113
|
+
return self.get_assets().get(name)
|
|
114
|
+
|
|
115
|
+
def create_asset(self, asset_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Create a new asset in AttackForge.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
asset_data (dict): Asset details.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
dict: Created asset details.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
RuntimeError: If asset creation fails.
|
|
127
|
+
"""
|
|
128
|
+
resp = self._request("post", "/api/ss/library/asset", json_data=asset_data)
|
|
129
|
+
if resp.status_code in (200, 201):
|
|
130
|
+
asset = resp.json()
|
|
131
|
+
self._asset_cache = None # Invalidate cache
|
|
132
|
+
return asset
|
|
133
|
+
if "Asset Already Exists" in resp.text:
|
|
134
|
+
return self.get_asset_by_name(asset_data["name"])
|
|
135
|
+
raise RuntimeError(f"Asset creation failed: {resp.text}")
|
|
136
|
+
|
|
137
|
+
def get_project_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
|
138
|
+
"""
|
|
139
|
+
Retrieve a project by its name.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
name (str): The project name.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
dict or None: Project details if found, else None.
|
|
146
|
+
"""
|
|
147
|
+
params = {
|
|
148
|
+
"startDate": "2000-01-01T00:00:00.000Z",
|
|
149
|
+
"endDate": "2100-01-01T00:00:00.000Z",
|
|
150
|
+
"status": "All"
|
|
151
|
+
}
|
|
152
|
+
resp = self._request("get", "/api/ss/projects", params=params)
|
|
153
|
+
for proj in resp.json().get("projects", []):
|
|
154
|
+
if proj.get("project_name") == name:
|
|
155
|
+
return proj
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def get_project_scope(self, project_id: str) -> Set[str]:
|
|
159
|
+
"""
|
|
160
|
+
Retrieve the scope (assets) of a project.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
project_id (str): The project ID.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
set: Set of asset names in the project scope.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
RuntimeError: If project retrieval fails.
|
|
170
|
+
"""
|
|
171
|
+
if project_id in self._project_scope_cache:
|
|
172
|
+
return self._project_scope_cache[project_id]
|
|
173
|
+
|
|
174
|
+
resp = self._request("get", f"/api/ss/project/{project_id}")
|
|
175
|
+
if resp.status_code != 200:
|
|
176
|
+
raise RuntimeError(f"Failed to retrieve project: {resp.text}")
|
|
177
|
+
|
|
178
|
+
scope = set(resp.json().get("scope", []))
|
|
179
|
+
self._project_scope_cache[project_id] = scope
|
|
180
|
+
return scope
|
|
181
|
+
|
|
182
|
+
def update_project_scope(self, project_id: str, new_assets: List[str]) -> Dict[str, Any]:
|
|
183
|
+
"""
|
|
184
|
+
Update the scope (assets) of a project.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
project_id (str): The project ID.
|
|
188
|
+
new_assets (iterable): Asset names to add to the scope.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
dict: Updated project details.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
RuntimeError: If update fails.
|
|
195
|
+
"""
|
|
196
|
+
current_scope = self.get_project_scope(project_id)
|
|
197
|
+
updated_scope = list(current_scope.union(new_assets))
|
|
198
|
+
resp = self._request("put", f"/api/ss/project/{project_id}", json_data={"scope": updated_scope})
|
|
199
|
+
if resp.status_code not in (200, 201):
|
|
200
|
+
raise RuntimeError(f"Failed to update project scope: {resp.text}")
|
|
201
|
+
self._project_scope_cache[project_id] = set(updated_scope)
|
|
202
|
+
return resp.json()
|
|
203
|
+
|
|
204
|
+
def create_project(self, name: str, **kwargs) -> Dict[str, Any]:
|
|
205
|
+
"""
|
|
206
|
+
Create a new project in AttackForge.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name (str): Project name.
|
|
210
|
+
**kwargs: Additional project fields.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
dict: Created project details.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
RuntimeError: If project creation fails.
|
|
217
|
+
"""
|
|
218
|
+
start, end = get_default_dates()
|
|
219
|
+
payload = {
|
|
220
|
+
"name": name,
|
|
221
|
+
"code": kwargs.get("code", "DEFAULT"),
|
|
222
|
+
"groups": kwargs.get("groups", []),
|
|
223
|
+
"startDate": kwargs.get("startDate", start),
|
|
224
|
+
"endDate": kwargs.get("endDate", end),
|
|
225
|
+
"scope": kwargs.get("scope", []),
|
|
226
|
+
"testsuites": kwargs.get("testsuites", []),
|
|
227
|
+
"organization_code": kwargs.get("organization_code", "ORG_DEFAULT"),
|
|
228
|
+
"vulnerability_code": kwargs.get("vulnerability_code", "VULN_"),
|
|
229
|
+
"scoringSystem": kwargs.get("scoringSystem", "CVSSv3.1"),
|
|
230
|
+
"team_notifications": kwargs.get("team_notifications", []),
|
|
231
|
+
"admin_notifications": kwargs.get("admin_notifications", []),
|
|
232
|
+
"custom_fields": kwargs.get("custom_fields", []),
|
|
233
|
+
"asset_library_ids": kwargs.get("asset_library_ids", []),
|
|
234
|
+
"sla_activation": kwargs.get("sla_activation", "automatic")
|
|
235
|
+
}
|
|
236
|
+
resp = self._request("post", "/api/ss/project", json_data=payload)
|
|
237
|
+
if resp.status_code in (200, 201):
|
|
238
|
+
return resp.json()
|
|
239
|
+
raise RuntimeError(f"Project creation failed: {resp.text}")
|
|
240
|
+
|
|
241
|
+
def update_project(self, project_id: str, update_fields: Dict[str, Any]) -> Dict[str, Any]:
|
|
242
|
+
"""
|
|
243
|
+
Update an existing project.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
project_id (str): The project ID.
|
|
247
|
+
update_fields (dict): Fields to update.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
dict: Updated project details.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
RuntimeError: If update fails.
|
|
254
|
+
"""
|
|
255
|
+
resp = self._request("put", f"/api/ss/project/{project_id}", json_data=update_fields)
|
|
256
|
+
if resp.status_code in (200, 201):
|
|
257
|
+
return resp.json()
|
|
258
|
+
raise RuntimeError(f"Project update failed: {resp.text}")
|
|
259
|
+
|
|
260
|
+
def create_vulnerability(
|
|
261
|
+
self,
|
|
262
|
+
project_id: str,
|
|
263
|
+
title: str,
|
|
264
|
+
affected_asset_name: str,
|
|
265
|
+
priority: str,
|
|
266
|
+
likelihood_of_exploitation: int,
|
|
267
|
+
description: str,
|
|
268
|
+
attack_scenario: str,
|
|
269
|
+
remediation_recommendation: str,
|
|
270
|
+
steps_to_reproduce: str,
|
|
271
|
+
tags: Optional[list] = None,
|
|
272
|
+
notes: Optional[list] = None,
|
|
273
|
+
is_zeroday: bool = False,
|
|
274
|
+
is_visible: bool = True,
|
|
275
|
+
import_to_library: Optional[str] = None,
|
|
276
|
+
import_source: Optional[str] = None,
|
|
277
|
+
import_source_id: Optional[str] = None,
|
|
278
|
+
custom_fields: Optional[list] = None,
|
|
279
|
+
linked_testcases: Optional[list] = None,
|
|
280
|
+
custom_tags: Optional[list] = None,
|
|
281
|
+
) -> Dict[str, Any]:
|
|
282
|
+
"""
|
|
283
|
+
Create a new security finding (vulnerability) in AttackForge.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
project_id (str): The project ID.
|
|
287
|
+
title (str): The title of the finding.
|
|
288
|
+
affected_asset_name (str): The name of the affected asset.
|
|
289
|
+
priority (str): The priority (e.g., "Critical").
|
|
290
|
+
likelihood_of_exploitation (int): Likelihood of exploitation (e.g., 10).
|
|
291
|
+
description (str): Description of the finding.
|
|
292
|
+
attack_scenario (str): Attack scenario details.
|
|
293
|
+
remediation_recommendation (str): Remediation recommendation.
|
|
294
|
+
steps_to_reproduce (str): Steps to reproduce the finding.
|
|
295
|
+
tags (list, optional): List of tags.
|
|
296
|
+
notes (list, optional): List of notes.
|
|
297
|
+
is_zeroday (bool, optional): Whether this is a zero-day finding.
|
|
298
|
+
is_visible (bool, optional): Whether the finding is visible.
|
|
299
|
+
import_to_library (str, optional): Library to import to.
|
|
300
|
+
import_source (str, optional): Source of import.
|
|
301
|
+
import_source_id (str, optional): Source ID for import.
|
|
302
|
+
custom_fields (list, optional): List of custom fields.
|
|
303
|
+
linked_testcases (list, optional): List of linked testcases.
|
|
304
|
+
custom_tags (list, optional): List of custom tags.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
dict: Created vulnerability details.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If any required field is missing.
|
|
311
|
+
RuntimeError: If vulnerability creation fails.
|
|
312
|
+
"""
|
|
313
|
+
# Validate required fields
|
|
314
|
+
required_fields = [
|
|
315
|
+
("project_id", project_id),
|
|
316
|
+
("title", title),
|
|
317
|
+
("affected_asset_name", affected_asset_name),
|
|
318
|
+
("priority", priority),
|
|
319
|
+
("likelihood_of_exploitation", likelihood_of_exploitation),
|
|
320
|
+
("description", description),
|
|
321
|
+
("attack_scenario", attack_scenario),
|
|
322
|
+
("remediation_recommendation", remediation_recommendation),
|
|
323
|
+
("steps_to_reproduce", steps_to_reproduce),
|
|
324
|
+
]
|
|
325
|
+
for field_name, value in required_fields:
|
|
326
|
+
if value is None:
|
|
327
|
+
raise ValueError(f"Missing required field: {field_name}")
|
|
328
|
+
|
|
329
|
+
payload = {
|
|
330
|
+
"projectId": project_id,
|
|
331
|
+
"title": title,
|
|
332
|
+
"affected_asset_name": affected_asset_name,
|
|
333
|
+
"priority": priority,
|
|
334
|
+
"likelihood_of_exploitation": likelihood_of_exploitation,
|
|
335
|
+
"description": description,
|
|
336
|
+
"attack_scenario": attack_scenario,
|
|
337
|
+
"remediation_recommendation": remediation_recommendation,
|
|
338
|
+
"steps_to_reproduce": steps_to_reproduce,
|
|
339
|
+
"tags": tags or [],
|
|
340
|
+
"is_zeroday": is_zeroday,
|
|
341
|
+
"is_visible": is_visible,
|
|
342
|
+
"import_to_library": import_to_library,
|
|
343
|
+
"import_source": import_source,
|
|
344
|
+
"import_source_id": import_source_id,
|
|
345
|
+
"custom_fields": custom_fields or [],
|
|
346
|
+
"linked_testcases": linked_testcases or [],
|
|
347
|
+
"custom_tags": custom_tags or [],
|
|
348
|
+
}
|
|
349
|
+
# Only include notes if it is a non-empty list
|
|
350
|
+
if notes:
|
|
351
|
+
payload["notes"] = notes
|
|
352
|
+
|
|
353
|
+
# Remove None values (for optional fields)
|
|
354
|
+
payload = {k: v for k, v in payload.items() if v is not None}
|
|
355
|
+
|
|
356
|
+
resp = self._request("post", "/api/ss/vulnerability", json_data=payload)
|
|
357
|
+
if resp.status_code in (200, 201):
|
|
358
|
+
return resp.json()
|
|
359
|
+
raise RuntimeError(f"Vulnerability creation failed: {resp.text}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class DummyResponse:
|
|
363
|
+
"""
|
|
364
|
+
Dummy response object for dry-run mode.
|
|
365
|
+
"""
|
|
366
|
+
def __init__(self) -> None:
|
|
367
|
+
self.status_code = 200
|
|
368
|
+
self.text = "[DRY RUN] No real API call performed."
|
|
369
|
+
|
|
370
|
+
def json(self) -> Dict[str, Any]:
|
|
371
|
+
return {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_default_dates() -> Tuple[str, str]:
|
|
375
|
+
"""
|
|
376
|
+
Get default start and end dates for a project (now and 30 days from now, in ISO format).
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
tuple: (start_date, end_date) as ISO 8601 strings.
|
|
380
|
+
"""
|
|
381
|
+
now = datetime.now(timezone.utc)
|
|
382
|
+
start = now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
383
|
+
end = (now + timedelta(days=30)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
384
|
+
return start, end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyattackforge
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -37,6 +37,10 @@ A lightweight Python library for interacting with the AttackForge API.
|
|
|
37
37
|
- Create and fetch projects
|
|
38
38
|
- Manage assets
|
|
39
39
|
- Submit vulnerabilities
|
|
40
|
+
- Create findings from existing writeups by passing a `writeup_id`
|
|
41
|
+
- Upload evidence to findings or testcases
|
|
42
|
+
- Update/assign testcases to link findings or add notes
|
|
43
|
+
- Link vulnerabilities to testcases via the client
|
|
40
44
|
- Dry-run mode for testing
|
|
41
45
|
|
|
42
46
|
---
|
|
@@ -66,13 +70,14 @@ A lightweight Python library for interacting with the AttackForge API.
|
|
|
66
70
|
client.create_vulnerability(
|
|
67
71
|
project_id="abc123",
|
|
68
72
|
title="Open SSH Port",
|
|
69
|
-
|
|
73
|
+
affected_assets=[{"name": "ssh-prod-1"}],
|
|
70
74
|
priority="High",
|
|
71
75
|
likelihood_of_exploitation=10,
|
|
72
76
|
description="SSH port 22 is open to the internet.",
|
|
73
77
|
attack_scenario="An attacker can brute-force SSH credentials.",
|
|
74
78
|
remediation_recommendation="Restrict SSH access to trusted IPs.",
|
|
75
79
|
steps_to_reproduce="1. Scan the host\n2. Observe port 22 is open",
|
|
80
|
+
writeup_id="68e92c7a821c05c8405a8003", # optional: use an existing writeup
|
|
76
81
|
tags=["ssh", "exposure"],
|
|
77
82
|
notes=["Observed on 2025-09-09"],
|
|
78
83
|
is_zeroday=False,
|
|
@@ -91,13 +96,14 @@ To create a security finding (vulnerability) in AttackForge, use the `create_vul
|
|
|
91
96
|
client.create_vulnerability(
|
|
92
97
|
project_id="abc123",
|
|
93
98
|
title="Open SSH Port",
|
|
94
|
-
|
|
99
|
+
affected_assets=[{"name": "ssh-prod-1"}],
|
|
95
100
|
priority="High",
|
|
96
101
|
likelihood_of_exploitation=10,
|
|
97
102
|
description="SSH port 22 is open to the internet.",
|
|
98
103
|
attack_scenario="An attacker can brute-force SSH credentials.",
|
|
99
104
|
remediation_recommendation="Restrict SSH access to trusted IPs.",
|
|
100
105
|
steps_to_reproduce="1. Scan the host\n2. Observe port 22 is open",
|
|
106
|
+
writeup_id="68e92c7a821c05c8405a8003", # optional: reuse an existing writeup
|
|
101
107
|
tags=["ssh", "exposure"],
|
|
102
108
|
notes=["Observed on 2025-09-09"],
|
|
103
109
|
is_zeroday=False,
|
|
@@ -105,16 +111,111 @@ client.create_vulnerability(
|
|
|
105
111
|
)
|
|
106
112
|
```
|
|
107
113
|
|
|
114
|
+
### Creating a finding from an existing writeup
|
|
115
|
+
|
|
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`):
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
client.create_finding_from_writeup(
|
|
120
|
+
project_id="abc123",
|
|
121
|
+
writeup_id="68e92c7a821c05c8405a8003", # writeup id
|
|
122
|
+
library="approved_writeups", # optional: library key/name
|
|
123
|
+
priority="High",
|
|
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"]
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Evidence and testcase helpers
|
|
134
|
+
|
|
135
|
+
Upload evidence to an existing finding:
|
|
136
|
+
```python
|
|
137
|
+
client.upload_finding_evidence(
|
|
138
|
+
vulnerability_id="6768d29db1782d7362a2df5f",
|
|
139
|
+
file_path="evidence.png"
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Upload evidence to a testcase:
|
|
144
|
+
```python
|
|
145
|
+
client.upload_testcase_evidence(
|
|
146
|
+
project_id="abc123",
|
|
147
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
148
|
+
file_path="testcase-evidence.png"
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Add a note to a finding (deduplicates by note text):
|
|
153
|
+
```python
|
|
154
|
+
client.add_note_to_finding(
|
|
155
|
+
vulnerability_id="6768d29db1782d7362a2df5f",
|
|
156
|
+
note="Observed during retest on 2025-09-19."
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Add a note/update to a testcase (PUT to the testcase endpoint):
|
|
161
|
+
```python
|
|
162
|
+
client.add_note_to_testcase(
|
|
163
|
+
project_id="abc123",
|
|
164
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
165
|
+
note="Observed during retest on 2025-09-19.",
|
|
166
|
+
status="Tested" # optional
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Associate findings to a testcase:
|
|
171
|
+
```python
|
|
172
|
+
client.assign_findings_to_testcase(
|
|
173
|
+
project_id="abc123",
|
|
174
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
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
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
|
|
108
208
|
**Parameters:**
|
|
109
209
|
- `project_id` (str): The project ID.
|
|
110
210
|
- `title` (str): The title of the finding.
|
|
111
|
-
- `
|
|
211
|
+
- `affected_assets` (list): List of affected assets (e.g., `[{"name": "host1"}]`).
|
|
112
212
|
- `priority` (str): The priority (e.g., "Critical", "High", "Medium", "Low").
|
|
113
213
|
- `likelihood_of_exploitation` (int): Likelihood of exploitation (e.g., 10).
|
|
114
214
|
- `description` (str): Description of the finding.
|
|
115
215
|
- `attack_scenario` (str): Attack scenario details.
|
|
116
216
|
- `remediation_recommendation` (str): Remediation recommendation.
|
|
117
217
|
- `steps_to_reproduce` (str): Steps to reproduce the finding.
|
|
218
|
+
- `writeup_id` (str, optional): Existing writeup/library reference ID to use directly.
|
|
118
219
|
- `tags` (list, optional): List of tags.
|
|
119
220
|
- `notes` (list, optional): List of notes.
|
|
120
221
|
- `is_zeroday` (bool, optional): Whether this is a zero-day finding.
|
|
@@ -146,32 +247,7 @@ See the source code for full details and docstrings.
|
|
|
146
247
|
- `create_vulnerability(
|
|
147
248
|
project_id: str,
|
|
148
249
|
title: str,
|
|
149
|
-
|
|
150
|
-
priority: str,
|
|
151
|
-
likelihood_of_exploitation: int,
|
|
152
|
-
description: str,
|
|
153
|
-
attack_scenario: str,
|
|
154
|
-
remediation_recommendation: str,
|
|
155
|
-
steps_to_reproduce: str,
|
|
156
|
-
tags: Optional[list] = None,
|
|
157
|
-
notes: Optional[list] = None,
|
|
158
|
-
is_zeroday: bool = False,
|
|
159
|
-
is_visible: bool = True,
|
|
160
|
-
import_to_library: Optional[str] = None,
|
|
161
|
-
import_source: Optional[str] = None,
|
|
162
|
-
import_source_id: Optional[str] = None,
|
|
163
|
-
custom_fields: Optional[list] = None,
|
|
164
|
-
linked_testcases: Optional[list] = None,
|
|
165
|
-
custom_tags: Optional[list] = None,
|
|
166
|
-
) -> dict`
|
|
167
|
-
|
|
168
|
-
See the source code for full details and docstrings.
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
- `create_vulnerability(
|
|
172
|
-
project_id: str,
|
|
173
|
-
title: str,
|
|
174
|
-
affected_asset_name: str,
|
|
250
|
+
affected_assets: list,
|
|
175
251
|
priority: str,
|
|
176
252
|
likelihood_of_exploitation: int,
|
|
177
253
|
description: str,
|
|
@@ -188,7 +264,21 @@ See the source code for full details and docstrings.
|
|
|
188
264
|
custom_fields: Optional[list] = None,
|
|
189
265
|
linked_testcases: Optional[list] = None,
|
|
190
266
|
custom_tags: Optional[list] = None,
|
|
267
|
+
writeup_custom_fields: Optional[list] = None,
|
|
191
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`
|
|
192
282
|
|
|
193
283
|
See the source code for full details and docstrings.
|
|
194
284
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pyattackforge/__init__.py,sha256=r4FT68O03ZuyQJ70RLwlLDqp7AqNTTcMm-FLU4zHwIU,807
|
|
2
|
+
pyattackforge/client.py,sha256=Z-rGVPYpmG8x4N79Izv1Q4sC3-aX40Lf7XdT8kidor8,45464
|
|
3
|
+
pyattackforge/prev_client.py,sha256=Wu-5BVCJbLkWBsxjQmL3nZoyWqAqOACKM9ScXfl6C5o,14277
|
|
4
|
+
pyattackforge-0.1.7.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
5
|
+
pyattackforge-0.1.7.dist-info/METADATA,sha256=fU2BIgYD4OqcLMIqeLWdtvBfHBf09M_OnAtUkkhnbHc,10754
|
|
6
|
+
pyattackforge-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
pyattackforge-0.1.7.dist-info/top_level.txt,sha256=1rDeMkWvFWuX3MS8V65no7KuybYyvtfIgbYSt5m_uPU,14
|
|
8
|
+
pyattackforge-0.1.7.dist-info/RECORD,,
|
|
File without changes
|