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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyattackforge
3
- Version: 0.1.7
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
@@ -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
- if priority:
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyattackforge
3
- Version: 0.1.7
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
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="pyattackforge",
8
- version="0.1.7",
8
+ version="0.1.8",
9
9
  packages=find_packages(),
10
10
  install_requires=[
11
11
  "requests>=2.20.0"
@@ -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 test_upsert_finding_for_project_create(self):
162
- # Simulate no existing findings (should create new)
163
- self.client.get_findings_for_project = lambda project_id: []
164
- # Patch get_all_writeups to return a matching writeup for this test
165
- self.client.get_all_writeups = (
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