pyattackforge 0.1.7__tar.gz → 0.1.8__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.
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/PKG-INFO +1 -1
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge/client.py +108 -14
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge.egg-info/PKG-INFO +1 -1
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/setup.py +1 -1
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/tests/test_client.py +72 -9
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/LICENSE +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/README.md +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge/__init__.py +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge/prev_client.py +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge.egg-info/SOURCES.txt +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge.egg-info/dependency_links.txt +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge.egg-info/requires.txt +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/pyattackforge.egg-info/top_level.txt +0 -0
- {pyattackforge-0.1.7 → pyattackforge-0.1.8}/setup.cfg +0 -0
|
@@ -198,6 +198,68 @@ class PyAttackForgeClient:
|
|
|
198
198
|
"result": result,
|
|
199
199
|
}
|
|
200
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
|
+
|
|
201
263
|
def get_findings_for_project(self, project_id: str, priority: Optional[str] = None) -> list:
|
|
202
264
|
"""
|
|
203
265
|
Fetch all findings/vulnerabilities for a given project.
|
|
@@ -209,20 +271,8 @@ class PyAttackForgeClient:
|
|
|
209
271
|
Returns:
|
|
210
272
|
list: List of finding/vulnerability dicts.
|
|
211
273
|
"""
|
|
212
|
-
params = {}
|
|
213
|
-
|
|
214
|
-
params["priority"] = priority
|
|
215
|
-
resp = self._request("get", f"/api/ss/project/{project_id}/vulnerabilities", params=params)
|
|
216
|
-
if resp.status_code != 200:
|
|
217
|
-
raise RuntimeError(f"Failed to fetch findings: {resp.text}")
|
|
218
|
-
# The response may have a "vulnerabilities" key or be a list directly
|
|
219
|
-
data = resp.json()
|
|
220
|
-
if isinstance(data, dict) and "vulnerabilities" in data:
|
|
221
|
-
return data["vulnerabilities"]
|
|
222
|
-
elif isinstance(data, list):
|
|
223
|
-
return data
|
|
224
|
-
else:
|
|
225
|
-
return []
|
|
274
|
+
params = {"priority": priority} if priority else None
|
|
275
|
+
return self._list_project_findings(project_id, params=params)
|
|
226
276
|
|
|
227
277
|
def get_vulnerability(self, vulnerability_id: str) -> Dict[str, Any]:
|
|
228
278
|
"""
|
|
@@ -244,6 +294,50 @@ class PyAttackForgeClient:
|
|
|
244
294
|
return data["vulnerability"]
|
|
245
295
|
return data
|
|
246
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
|
+
|
|
247
341
|
def add_note_to_finding(
|
|
248
342
|
self,
|
|
249
343
|
vulnerability_id: str,
|
|
@@ -154,15 +154,42 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
154
154
|
)
|
|
155
155
|
self.assertIsInstance(finding, dict)
|
|
156
156
|
|
|
157
|
-
def test_get_findings_for_project_dry_run(self):
|
|
158
|
-
findings = self.client.get_findings_for_project("dummy_project")
|
|
159
|
-
self.assertIsInstance(findings, list)
|
|
160
|
-
|
|
161
|
-
def
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
157
|
+
def test_get_findings_for_project_dry_run(self):
|
|
158
|
+
findings = self.client.get_findings_for_project("dummy_project")
|
|
159
|
+
self.assertIsInstance(findings, list)
|
|
160
|
+
|
|
161
|
+
def test_get_findings_with_pagination(self):
|
|
162
|
+
captured = {}
|
|
163
|
+
|
|
164
|
+
class Resp:
|
|
165
|
+
status_code = 200
|
|
166
|
+
text = "OK"
|
|
167
|
+
|
|
168
|
+
def json(self):
|
|
169
|
+
return {"vulnerabilities": [{"id": i} for i in range(10)]}
|
|
170
|
+
|
|
171
|
+
def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
|
|
172
|
+
captured["params"] = params
|
|
173
|
+
captured["endpoint"] = endpoint
|
|
174
|
+
return Resp()
|
|
175
|
+
|
|
176
|
+
self.client._request = fake_request
|
|
177
|
+
findings = self.client.get_findings("proj1", page=2, limit=3, priority="High")
|
|
178
|
+
self.assertEqual(len(findings), 3)
|
|
179
|
+
self.assertEqual(findings[0]["id"], 3)
|
|
180
|
+
self.assertEqual(captured["params"]["priority"], "High")
|
|
181
|
+
self.assertEqual(captured["params"]["skip"], 3)
|
|
182
|
+
self.assertEqual(captured["params"]["limit"], 3)
|
|
183
|
+
self.assertEqual(captured["params"]["page"], 2)
|
|
184
|
+
self.assertEqual(captured["endpoint"], "/api/ss/project/proj1/vulnerabilities")
|
|
185
|
+
with self.assertRaises(ValueError):
|
|
186
|
+
self.client.get_findings("proj1", page=0)
|
|
187
|
+
|
|
188
|
+
def test_upsert_finding_for_project_create(self):
|
|
189
|
+
# Simulate no existing findings (should create new)
|
|
190
|
+
self.client.get_findings_for_project = lambda project_id: []
|
|
191
|
+
# Patch get_all_writeups to return a matching writeup for this test
|
|
192
|
+
self.client.get_all_writeups = (
|
|
166
193
|
lambda force_refresh=False: [
|
|
167
194
|
{
|
|
168
195
|
"title": "UnitTest Finding",
|
|
@@ -380,6 +407,42 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
380
407
|
notes = captured["json_data"].get("notes", [])
|
|
381
408
|
self.assertEqual(len(notes), 1)
|
|
382
409
|
|
|
410
|
+
def test_update_finding(self):
|
|
411
|
+
captured = {}
|
|
412
|
+
|
|
413
|
+
class Resp:
|
|
414
|
+
status_code = 200
|
|
415
|
+
text = "OK"
|
|
416
|
+
|
|
417
|
+
def json(self):
|
|
418
|
+
return {"updated": True}
|
|
419
|
+
|
|
420
|
+
def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
|
|
421
|
+
captured["endpoint"] = endpoint
|
|
422
|
+
captured["json_data"] = json_data
|
|
423
|
+
return Resp()
|
|
424
|
+
|
|
425
|
+
self.client._request = fake_request
|
|
426
|
+
resp = self.client.update_finding(
|
|
427
|
+
vulnerability_id="v-1",
|
|
428
|
+
project_id="p-1",
|
|
429
|
+
affected_assets=[{"name": "asset-a"}, {"assetName": "asset-b"}, "asset-c"],
|
|
430
|
+
notes=[{"note": "n1", "type": "PLAINTEXT"}],
|
|
431
|
+
custom="field",
|
|
432
|
+
)
|
|
433
|
+
self.assertIsInstance(resp, dict)
|
|
434
|
+
payload = captured["json_data"]
|
|
435
|
+
self.assertEqual(payload["project_id"], "p-1")
|
|
436
|
+
self.assertEqual(
|
|
437
|
+
payload["affected_assets"],
|
|
438
|
+
[{"assetName": "asset-a"}, {"assetName": "asset-b"}, {"assetName": "asset-c"}],
|
|
439
|
+
)
|
|
440
|
+
self.assertEqual(payload["notes"], [{"note": "n1", "type": "PLAINTEXT"}])
|
|
441
|
+
self.assertEqual(payload["custom"], "field")
|
|
442
|
+
self.assertEqual(captured["endpoint"], "/api/ss/vulnerability/v-1")
|
|
443
|
+
with self.assertRaises(ValueError):
|
|
444
|
+
self.client.update_finding("", project_id="p-1")
|
|
445
|
+
|
|
383
446
|
def test_get_testcases(self):
|
|
384
447
|
# DummyResponse returns {}, so should yield an empty list without raising
|
|
385
448
|
cases = self.client.get_testcases("proj1")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|