pyattackforge 0.1.3__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.3 → pyattackforge-0.1.8}/PKG-INFO +60 -36
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/README.md +59 -35
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge/__init__.py +22 -24
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge/client.py +313 -36
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge.egg-info/PKG-INFO +60 -36
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/setup.py +1 -1
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/tests/test_client.py +202 -31
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/LICENSE +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge/prev_client.py +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge.egg-info/SOURCES.txt +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge.egg-info/dependency_links.txt +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge.egg-info/requires.txt +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/pyattackforge.egg-info/top_level.txt +0 -0
- {pyattackforge-0.1.3 → pyattackforge-0.1.8}/setup.cfg +0 -0
|
@@ -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
|
|
|
@@ -12,6 +12,7 @@ A lightweight Python library for interacting with the AttackForge API.
|
|
|
12
12
|
- Create findings from existing writeups by passing a `writeup_id`
|
|
13
13
|
- Upload evidence to findings or testcases
|
|
14
14
|
- Update/assign testcases to link findings or add notes
|
|
15
|
+
- Link vulnerabilities to testcases via the client
|
|
15
16
|
- Dry-run mode for testing
|
|
16
17
|
|
|
17
18
|
---
|
|
@@ -84,14 +85,20 @@ client.create_vulnerability(
|
|
|
84
85
|
|
|
85
86
|
### Creating a finding from an existing writeup
|
|
86
87
|
|
|
87
|
-
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:
|
|
88
|
+
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`):
|
|
88
89
|
|
|
89
90
|
```python
|
|
90
91
|
client.create_finding_from_writeup(
|
|
91
92
|
project_id="abc123",
|
|
92
|
-
writeup_id="68e92c7a821c05c8405a8003",
|
|
93
|
+
writeup_id="68e92c7a821c05c8405a8003", # writeup id
|
|
94
|
+
library="approved_writeups", # optional: library key/name
|
|
93
95
|
priority="High",
|
|
94
|
-
affected_assets=[{"name": "ssh-prod-1"}]
|
|
96
|
+
affected_assets=[{"name": "ssh-prod-1"}],
|
|
97
|
+
linked_testcases=["5e8017d2e1385f0c58e8f4f8"], # optional: link testcases at creation
|
|
98
|
+
likelihood_of_exploitation=5,
|
|
99
|
+
steps_to_reproduce="1. Do something\n2. Observe result",
|
|
100
|
+
notes=[{"note": "Created via API", "type": "PLAINTEXT"}],
|
|
101
|
+
tags=["automation"]
|
|
95
102
|
)
|
|
96
103
|
```
|
|
97
104
|
|
|
@@ -124,21 +131,49 @@ client.add_note_to_finding(
|
|
|
124
131
|
|
|
125
132
|
Add a note/update to a testcase (PUT to the testcase endpoint):
|
|
126
133
|
```python
|
|
127
|
-
client.
|
|
134
|
+
client.add_note_to_testcase(
|
|
128
135
|
project_id="abc123",
|
|
129
136
|
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
137
|
+
note="Observed during retest on 2025-09-19.",
|
|
138
|
+
status="Tested" # optional
|
|
133
139
|
)
|
|
134
140
|
```
|
|
135
141
|
|
|
136
|
-
Associate findings to a testcase
|
|
142
|
+
Associate findings to a testcase:
|
|
137
143
|
```python
|
|
138
144
|
client.assign_findings_to_testcase(
|
|
139
145
|
project_id="abc123",
|
|
140
146
|
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
141
|
-
vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"]
|
|
147
|
+
vulnerability_ids=["66849b77950ab45e68fc7b48", "6768d29db1782d7362a2df5f"],
|
|
148
|
+
additional_fields={"status": "Tested"} # optional
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
Or link from the vulnerability side using its update endpoint:
|
|
152
|
+
```python
|
|
153
|
+
client.link_vulnerability_to_testcases(
|
|
154
|
+
vulnerability_id="69273ef0f4a7c85d03930667",
|
|
155
|
+
testcase_ids=["5e8017d2e1385f0c58e8f4f8"],
|
|
156
|
+
project_id="abc123", # optional
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Fetch project testcases:
|
|
161
|
+
```python
|
|
162
|
+
testcases = client.get_testcases("abc123")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Fetch a single testcase (if supported in your tenant):
|
|
166
|
+
```python
|
|
167
|
+
testcase = client.get_testcase("abc123", "5e8017d2e1385f0c58e8f4f8")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Merge and add findings to a testcase in one call:
|
|
171
|
+
```python
|
|
172
|
+
client.add_findings_to_testcase(
|
|
173
|
+
project_id="abc123",
|
|
174
|
+
testcase_id="5e8017d2e1385f0c58e8f4f8",
|
|
175
|
+
vulnerability_ids=["69273ef0f4a7c85d03930667"],
|
|
176
|
+
additional_fields={"status": "Tested"} # optional
|
|
142
177
|
)
|
|
143
178
|
```
|
|
144
179
|
|
|
@@ -184,32 +219,7 @@ See the source code for full details and docstrings.
|
|
|
184
219
|
- `create_vulnerability(
|
|
185
220
|
project_id: str,
|
|
186
221
|
title: str,
|
|
187
|
-
|
|
188
|
-
priority: str,
|
|
189
|
-
likelihood_of_exploitation: int,
|
|
190
|
-
description: str,
|
|
191
|
-
attack_scenario: str,
|
|
192
|
-
remediation_recommendation: str,
|
|
193
|
-
steps_to_reproduce: str,
|
|
194
|
-
tags: Optional[list] = None,
|
|
195
|
-
notes: Optional[list] = None,
|
|
196
|
-
is_zeroday: bool = False,
|
|
197
|
-
is_visible: bool = True,
|
|
198
|
-
import_to_library: Optional[str] = None,
|
|
199
|
-
import_source: Optional[str] = None,
|
|
200
|
-
import_source_id: Optional[str] = None,
|
|
201
|
-
custom_fields: Optional[list] = None,
|
|
202
|
-
linked_testcases: Optional[list] = None,
|
|
203
|
-
custom_tags: Optional[list] = None,
|
|
204
|
-
) -> dict`
|
|
205
|
-
|
|
206
|
-
See the source code for full details and docstrings.
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
- `create_vulnerability(
|
|
210
|
-
project_id: str,
|
|
211
|
-
title: str,
|
|
212
|
-
affected_asset_name: str,
|
|
222
|
+
affected_assets: list,
|
|
213
223
|
priority: str,
|
|
214
224
|
likelihood_of_exploitation: int,
|
|
215
225
|
description: str,
|
|
@@ -226,7 +236,21 @@ See the source code for full details and docstrings.
|
|
|
226
236
|
custom_fields: Optional[list] = None,
|
|
227
237
|
linked_testcases: Optional[list] = None,
|
|
228
238
|
custom_tags: Optional[list] = None,
|
|
239
|
+
writeup_custom_fields: Optional[list] = None,
|
|
229
240
|
) -> dict`
|
|
241
|
+
- `create_finding_from_writeup(project_id: str, writeup_id: str, priority: str, affected_assets: Optional[list] = None, linked_testcases: Optional[list] = None, **kwargs) -> dict`
|
|
242
|
+
- `get_findings_for_project(project_id: str, priority: Optional[str] = None) -> list`
|
|
243
|
+
- `upsert_finding_for_project(...)`
|
|
244
|
+
- `get_vulnerability(vulnerability_id: str) -> dict`
|
|
245
|
+
- `add_note_to_finding(vulnerability_id: str, note: Any, note_type: str = "PLAINTEXT") -> dict`
|
|
246
|
+
- `upload_finding_evidence(vulnerability_id: str, file_path: str) -> dict`
|
|
247
|
+
- `upload_testcase_evidence(project_id: str, testcase_id: str, file_path: str) -> dict`
|
|
248
|
+
- `get_testcases(project_id: str) -> list`
|
|
249
|
+
- `get_testcase(project_id: str, testcase_id: str) -> dict or None`
|
|
250
|
+
- `link_vulnerability_to_testcases(vulnerability_id: str, testcase_ids: List[str], project_id: Optional[str] = None) -> dict`
|
|
251
|
+
- `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`
|
|
252
|
+
- `add_findings_to_testcase(project_id: str, testcase_id: str, vulnerability_ids: List[str], additional_fields: Optional[Dict[str, Any]] = None) -> dict`
|
|
253
|
+
- `add_note_to_testcase(project_id: str, testcase_id: str, note: str, status: Optional[str] = None) -> dict`
|
|
230
254
|
|
|
231
255
|
See the source code for full details and docstrings.
|
|
232
256
|
|
|
@@ -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"]
|
|
@@ -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
|
|
|
@@ -90,7 +90,7 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
90
90
|
# Ensure the client uses the provided writeup_id and does not attempt to create/search
|
|
91
91
|
self.client.get_all_writeups = lambda force_refresh=False: []
|
|
92
92
|
self.client.find_writeup_in_cache = lambda title, library="Main Vulnerabilities": None
|
|
93
|
-
captured = {}
|
|
93
|
+
captured = {"endpoints": []}
|
|
94
94
|
|
|
95
95
|
def fake_create_from_writeup(**kwargs):
|
|
96
96
|
captured.update(kwargs)
|
|
@@ -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",
|
|
@@ -203,24 +230,26 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
203
230
|
}
|
|
204
231
|
self.client.get_findings_for_project = lambda project_id: [existing_finding]
|
|
205
232
|
# Patch get_all_writeups to return a matching writeup for this test
|
|
206
|
-
self.client.get_all_writeups = (
|
|
207
|
-
lambda force_refresh=False: [
|
|
208
|
-
{
|
|
209
|
-
"title": "UnitTest Finding",
|
|
210
|
-
"belongs_to_library": "Main Vulnerabilities",
|
|
211
|
-
"reference_id": "dummy_writeup_id",
|
|
212
|
-
}
|
|
213
|
-
]
|
|
214
|
-
)
|
|
215
|
-
self.client.create_writeup = lambda **kwargs: {"reference_id": "dummy_writeup_id"}
|
|
216
|
-
# Patch _request to simulate API update response
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
self.client.get_all_writeups = (
|
|
234
|
+
lambda force_refresh=False: [
|
|
235
|
+
{
|
|
236
|
+
"title": "UnitTest Finding",
|
|
237
|
+
"belongs_to_library": "Main Vulnerabilities",
|
|
238
|
+
"reference_id": "dummy_writeup_id",
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
)
|
|
242
|
+
self.client.create_writeup = lambda **kwargs: {"reference_id": "dummy_writeup_id"}
|
|
243
|
+
# Patch _request to simulate API update response
|
|
244
|
+
|
|
245
|
+
class Resp:
|
|
246
|
+
status_code = 200
|
|
247
|
+
|
|
248
|
+
def json(self):
|
|
249
|
+
return {"updated": True}
|
|
250
|
+
text = "OK"
|
|
251
|
+
self.client._request = (
|
|
252
|
+
lambda method, endpoint, json_data=None, params=None: Resp()
|
|
224
253
|
)
|
|
225
254
|
result = self.client.upsert_finding_for_project(
|
|
226
255
|
project_id="dummy_project",
|
|
@@ -339,7 +368,7 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
339
368
|
os.remove(evidence_path)
|
|
340
369
|
|
|
341
370
|
def test_assign_findings_to_testcase_merges(self):
|
|
342
|
-
captured = {}
|
|
371
|
+
captured = {"endpoints": []}
|
|
343
372
|
|
|
344
373
|
def fake_update(project_id, testcase_id, update_fields):
|
|
345
374
|
captured["payload"] = update_fields
|
|
@@ -360,11 +389,12 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
360
389
|
self.client.get_vulnerability = lambda vid: {
|
|
361
390
|
"vulnerability_notes": [{"note": "Existing note", "type": "PLAINTEXT"}]
|
|
362
391
|
}
|
|
363
|
-
captured = {}
|
|
392
|
+
captured = {"endpoints": []}
|
|
364
393
|
|
|
365
394
|
class Resp:
|
|
366
395
|
status_code = 200
|
|
367
396
|
text = "OK"
|
|
397
|
+
|
|
368
398
|
def json(self):
|
|
369
399
|
return {"ok": True}
|
|
370
400
|
|
|
@@ -376,7 +406,148 @@ class TestPyAttackForgeClient(unittest.TestCase):
|
|
|
376
406
|
self.assertIsInstance(result, dict)
|
|
377
407
|
notes = captured["json_data"].get("notes", [])
|
|
378
408
|
self.assertEqual(len(notes), 1)
|
|
379
|
-
|
|
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
|
+
|
|
446
|
+
def test_get_testcases(self):
|
|
447
|
+
# DummyResponse returns {}, so should yield an empty list without raising
|
|
448
|
+
cases = self.client.get_testcases("proj1")
|
|
449
|
+
self.assertIsInstance(cases, list)
|
|
450
|
+
self.assertEqual(cases, [])
|
|
451
|
+
|
|
452
|
+
def test_get_testcase(self):
|
|
453
|
+
class Resp:
|
|
454
|
+
def __init__(self, status_code, body):
|
|
455
|
+
self.status_code = status_code
|
|
456
|
+
self._body = body
|
|
457
|
+
|
|
458
|
+
def json(self):
|
|
459
|
+
return self._body
|
|
460
|
+
text = "resp"
|
|
461
|
+
|
|
462
|
+
calls = []
|
|
463
|
+
|
|
464
|
+
def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
|
|
465
|
+
calls.append(endpoint)
|
|
466
|
+
if "tc-ok" in endpoint:
|
|
467
|
+
return Resp(200, {"testcase": {"id": "tc-ok", "status": "Not Tested"}})
|
|
468
|
+
return Resp(404, {})
|
|
469
|
+
|
|
470
|
+
self.client._request = fake_request
|
|
471
|
+
tc_none = self.client.get_testcase("proj", "tc-missing")
|
|
472
|
+
self.assertIsNone(tc_none)
|
|
473
|
+
tc = self.client.get_testcase("proj", "tc-ok")
|
|
474
|
+
self.assertIsInstance(tc, dict)
|
|
475
|
+
self.assertEqual(tc.get("id"), "tc-ok")
|
|
476
|
+
|
|
477
|
+
def test_add_note_to_testcase(self):
|
|
478
|
+
captured = {"endpoints": []}
|
|
479
|
+
|
|
480
|
+
class Resp:
|
|
481
|
+
status_code = 200
|
|
482
|
+
|
|
483
|
+
def json(self):
|
|
484
|
+
return {"status": "Testcase Note Created"}
|
|
485
|
+
|
|
486
|
+
def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
|
|
487
|
+
captured["endpoints"].append((endpoint, json_data))
|
|
488
|
+
return Resp()
|
|
489
|
+
|
|
490
|
+
self.client._request = fake_request
|
|
491
|
+
resp = self.client.add_note_to_testcase("proj1", "tc1", "Note text", status="Tested")
|
|
492
|
+
self.assertIsInstance(resp, dict)
|
|
493
|
+
note_calls = [c for c in captured["endpoints"] if "/note" in c[0]]
|
|
494
|
+
self.assertTrue(note_calls)
|
|
495
|
+
self.assertEqual(note_calls[0][1]["note"], "Note text")
|
|
496
|
+
self.assertEqual(note_calls[0][1]["note_type"], "PLAINTEXT")
|
|
497
|
+
|
|
498
|
+
def test_link_vulnerability_to_testcases(self):
|
|
499
|
+
captured = {}
|
|
500
|
+
|
|
501
|
+
class Resp:
|
|
502
|
+
status_code = 200
|
|
503
|
+
text = "OK"
|
|
504
|
+
|
|
505
|
+
def json(self):
|
|
506
|
+
return {"linked": True}
|
|
507
|
+
|
|
508
|
+
def fake_request(method, endpoint, json_data=None, params=None, files=None, data=None, headers_override=None):
|
|
509
|
+
captured["method"] = method
|
|
510
|
+
captured["endpoint"] = endpoint
|
|
511
|
+
captured["json_data"] = json_data
|
|
512
|
+
return Resp()
|
|
513
|
+
|
|
514
|
+
self.client._request = fake_request
|
|
515
|
+
resp = self.client.link_vulnerability_to_testcases("v1", ["tc1", "tc2"], project_id="proj1")
|
|
516
|
+
self.assertIsInstance(resp, dict)
|
|
517
|
+
self.assertEqual(captured["endpoint"], "/api/ss/vulnerability/v1")
|
|
518
|
+
self.assertEqual(captured["json_data"]["linked_testcases"], ["tc1", "tc2"])
|
|
519
|
+
self.assertEqual(captured["json_data"]["project_id"], "proj1")
|
|
520
|
+
|
|
521
|
+
def test_add_findings_to_testcase(self):
|
|
522
|
+
captured = {}
|
|
523
|
+
# Simulate existing testcase with one linked vuln (as dict)
|
|
524
|
+
self.client.get_testcases = lambda project_id: [
|
|
525
|
+
{
|
|
526
|
+
"id": "tc1",
|
|
527
|
+
"linked_vulnerabilities": [{"id": "existing"}],
|
|
528
|
+
}
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
def fake_assign(project_id, testcase_id, vulnerability_ids, existing_linked_vulnerabilities=None, additional_fields=None):
|
|
532
|
+
captured["project_id"] = project_id
|
|
533
|
+
captured["testcase_id"] = testcase_id
|
|
534
|
+
captured["vulnerability_ids"] = vulnerability_ids
|
|
535
|
+
captured["existing_linked_vulnerabilities"] = existing_linked_vulnerabilities
|
|
536
|
+
captured["additional_fields"] = additional_fields
|
|
537
|
+
return {"assigned": True}
|
|
538
|
+
|
|
539
|
+
self.client.assign_findings_to_testcase = fake_assign
|
|
540
|
+
resp = self.client.add_findings_to_testcase(
|
|
541
|
+
"proj1",
|
|
542
|
+
"tc1",
|
|
543
|
+
["new1", "new2"],
|
|
544
|
+
additional_fields={"status": "Tested"},
|
|
545
|
+
)
|
|
546
|
+
self.assertIsInstance(resp, dict)
|
|
547
|
+
self.assertEqual(captured["existing_linked_vulnerabilities"], ["existing"])
|
|
548
|
+
self.assertEqual(captured["vulnerability_ids"], ["new1", "new2"])
|
|
549
|
+
self.assertEqual(captured["additional_fields"], {"status": "Tested"})
|
|
550
|
+
|
|
380
551
|
|
|
381
552
|
if __name__ == "__main__":
|
|
382
553
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|