pyattackforge 0.1.1__py3-none-any.whl → 0.1.3__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 +0 -0
- pyattackforge/client.py +924 -383
- pyattackforge/prev_client.py +384 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.3.dist-info}/METADATA +70 -4
- pyattackforge-0.1.3.dist-info/RECORD +8 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.3.dist-info}/WHEEL +0 -0
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.3.dist-info}/licenses/LICENSE +661 -661
- {pyattackforge-0.1.1.dist-info → pyattackforge-0.1.3.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.3
|
|
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,9 @@ 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
|
|
40
43
|
- Dry-run mode for testing
|
|
41
44
|
|
|
42
45
|
---
|
|
@@ -66,13 +69,14 @@ A lightweight Python library for interacting with the AttackForge API.
|
|
|
66
69
|
client.create_vulnerability(
|
|
67
70
|
project_id="abc123",
|
|
68
71
|
title="Open SSH Port",
|
|
69
|
-
|
|
72
|
+
affected_assets=[{"name": "ssh-prod-1"}],
|
|
70
73
|
priority="High",
|
|
71
74
|
likelihood_of_exploitation=10,
|
|
72
75
|
description="SSH port 22 is open to the internet.",
|
|
73
76
|
attack_scenario="An attacker can brute-force SSH credentials.",
|
|
74
77
|
remediation_recommendation="Restrict SSH access to trusted IPs.",
|
|
75
78
|
steps_to_reproduce="1. Scan the host\n2. Observe port 22 is open",
|
|
79
|
+
writeup_id="68e92c7a821c05c8405a8003", # optional: use an existing writeup
|
|
76
80
|
tags=["ssh", "exposure"],
|
|
77
81
|
notes=["Observed on 2025-09-09"],
|
|
78
82
|
is_zeroday=False,
|
|
@@ -91,13 +95,14 @@ To create a security finding (vulnerability) in AttackForge, use the `create_vul
|
|
|
91
95
|
client.create_vulnerability(
|
|
92
96
|
project_id="abc123",
|
|
93
97
|
title="Open SSH Port",
|
|
94
|
-
|
|
98
|
+
affected_assets=[{"name": "ssh-prod-1"}],
|
|
95
99
|
priority="High",
|
|
96
100
|
likelihood_of_exploitation=10,
|
|
97
101
|
description="SSH port 22 is open to the internet.",
|
|
98
102
|
attack_scenario="An attacker can brute-force SSH credentials.",
|
|
99
103
|
remediation_recommendation="Restrict SSH access to trusted IPs.",
|
|
100
104
|
steps_to_reproduce="1. Scan the host\n2. Observe port 22 is open",
|
|
105
|
+
writeup_id="68e92c7a821c05c8405a8003", # optional: reuse an existing writeup
|
|
101
106
|
tags=["ssh", "exposure"],
|
|
102
107
|
notes=["Observed on 2025-09-09"],
|
|
103
108
|
is_zeroday=False,
|
|
@@ -105,16 +110,77 @@ client.create_vulnerability(
|
|
|
105
110
|
)
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
### Creating a finding from an existing writeup
|
|
114
|
+
|
|
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
|
+
|
|
117
|
+
```python
|
|
118
|
+
client.create_finding_from_writeup(
|
|
119
|
+
project_id="abc123",
|
|
120
|
+
writeup_id="68e92c7a821c05c8405a8003",
|
|
121
|
+
priority="High",
|
|
122
|
+
affected_assets=[{"name": "ssh-prod-1"}]
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Evidence and testcase helpers
|
|
127
|
+
|
|
128
|
+
Upload evidence to an existing finding:
|
|
129
|
+
```python
|
|
130
|
+
client.upload_finding_evidence(
|
|
131
|
+
vulnerability_id="6768d29db1782d7362a2df5f",
|
|
132
|
+
file_path="evidence.png"
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Upload evidence to a testcase:
|
|
137
|
+
```python
|
|
138
|
+
client.upload_testcase_evidence(
|
|
139
|
+
project_id="abc123",
|
|
140
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
141
|
+
file_path="testcase-evidence.png"
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Add a note to a finding (deduplicates by note text):
|
|
146
|
+
```python
|
|
147
|
+
client.add_note_to_finding(
|
|
148
|
+
vulnerability_id="6768d29db1782d7362a2df5f",
|
|
149
|
+
note="Observed during retest on 2025-09-19."
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Add a note/update to a testcase (PUT to the testcase endpoint):
|
|
154
|
+
```python
|
|
155
|
+
client.update_testcase(
|
|
156
|
+
project_id="abc123",
|
|
157
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
158
|
+
update_fields={
|
|
159
|
+
"details": "Observed during retest on 2025-09-19."
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Associate findings to a testcase (merges with existing linked vulnerabilities if provided):
|
|
165
|
+
```python
|
|
166
|
+
client.assign_findings_to_testcase(
|
|
167
|
+
project_id="abc123",
|
|
168
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
169
|
+
vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"]
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
108
173
|
**Parameters:**
|
|
109
174
|
- `project_id` (str): The project ID.
|
|
110
175
|
- `title` (str): The title of the finding.
|
|
111
|
-
- `
|
|
176
|
+
- `affected_assets` (list): List of affected assets (e.g., `[{"name": "host1"}]`).
|
|
112
177
|
- `priority` (str): The priority (e.g., "Critical", "High", "Medium", "Low").
|
|
113
178
|
- `likelihood_of_exploitation` (int): Likelihood of exploitation (e.g., 10).
|
|
114
179
|
- `description` (str): Description of the finding.
|
|
115
180
|
- `attack_scenario` (str): Attack scenario details.
|
|
116
181
|
- `remediation_recommendation` (str): Remediation recommendation.
|
|
117
182
|
- `steps_to_reproduce` (str): Steps to reproduce the finding.
|
|
183
|
+
- `writeup_id` (str, optional): Existing writeup/library reference ID to use directly.
|
|
118
184
|
- `tags` (list, optional): List of tags.
|
|
119
185
|
- `notes` (list, optional): List of notes.
|
|
120
186
|
- `is_zeroday` (bool, optional): Whether this is a zero-day finding.
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|