pyattackforge 0.1.8__tar.gz → 0.2.0__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.2.0/PKG-INFO +178 -0
- pyattackforge-0.2.0/README.md +164 -0
- pyattackforge-0.2.0/pyattackforge/__init__.py +5 -0
- pyattackforge-0.2.0/pyattackforge/cache.py +33 -0
- pyattackforge-0.2.0/pyattackforge/client.py +155 -0
- pyattackforge-0.2.0/pyattackforge/config.py +61 -0
- pyattackforge-0.2.0/pyattackforge/exceptions.py +21 -0
- pyattackforge-0.2.0/pyattackforge/resources/__init__.py +23 -0
- pyattackforge-0.2.0/pyattackforge/resources/assets.py +39 -0
- pyattackforge-0.2.0/pyattackforge/resources/base.py +33 -0
- pyattackforge-0.2.0/pyattackforge/resources/findings.py +655 -0
- pyattackforge-0.2.0/pyattackforge/resources/notes.py +139 -0
- pyattackforge-0.2.0/pyattackforge/resources/projects.py +154 -0
- pyattackforge-0.2.0/pyattackforge/resources/reports.py +20 -0
- pyattackforge-0.2.0/pyattackforge/resources/users.py +59 -0
- pyattackforge-0.2.0/pyattackforge/resources/writeups.py +79 -0
- pyattackforge-0.2.0/pyattackforge/transport.py +134 -0
- pyattackforge-0.2.0/pyattackforge.egg-info/PKG-INFO +178 -0
- pyattackforge-0.2.0/pyattackforge.egg-info/SOURCES.txt +23 -0
- pyattackforge-0.2.0/pyattackforge.egg-info/requires.txt +6 -0
- pyattackforge-0.2.0/pyproject.toml +31 -0
- pyattackforge-0.1.8/PKG-INFO +0 -305
- pyattackforge-0.1.8/README.md +0 -277
- pyattackforge-0.1.8/pyattackforge/__init__.py +0 -22
- pyattackforge-0.1.8/pyattackforge/client.py +0 -1201
- pyattackforge-0.1.8/pyattackforge/prev_client.py +0 -384
- pyattackforge-0.1.8/pyattackforge.egg-info/PKG-INFO +0 -305
- pyattackforge-0.1.8/pyattackforge.egg-info/SOURCES.txt +0 -12
- pyattackforge-0.1.8/pyattackforge.egg-info/requires.txt +0 -1
- pyattackforge-0.1.8/setup.py +0 -28
- pyattackforge-0.1.8/tests/test_client.py +0 -553
- {pyattackforge-0.1.8 → pyattackforge-0.2.0}/LICENSE +0 -0
- {pyattackforge-0.1.8 → pyattackforge-0.2.0}/pyattackforge.egg-info/dependency_links.txt +0 -0
- {pyattackforge-0.1.8 → pyattackforge-0.2.0}/pyattackforge.egg-info/top_level.txt +0 -0
- {pyattackforge-0.1.8 → pyattackforge-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyattackforge
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the AttackForge SSAPI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: h2>=4.1.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
12
|
+
Requires-Dist: python-dotenv>=1.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# PyAttackForge
|
|
16
|
+
|
|
17
|
+
Next-generation Python SDK for the AttackForge SSAPI (Python 3.10+).
|
|
18
|
+
|
|
19
|
+
## AI Documentation
|
|
20
|
+
|
|
21
|
+
`DOCUMENTATION.md` is written for AI coding assistants (Codex/Claude) to implement or extend the library.
|
|
22
|
+
|
|
23
|
+
## Install (local)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install (PyPI)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pyattackforge
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Build
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m build --sdist --wheel
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Environment variables:
|
|
44
|
+
- `ATTACKFORGE_BASE_URL`
|
|
45
|
+
- `ATTACKFORGE_API_KEY`
|
|
46
|
+
- `ATTACKFORGE_TEST_PROJECT_ID` (only needed for live integration tests)
|
|
47
|
+
- `ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT` (optional, set to `true` to make new findings visible; default is pending/hidden)
|
|
48
|
+
- `ATTACKFORGE_FINDINGS_SUBSTATUS_KEY` (optional, default `substatus`)
|
|
49
|
+
- `ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE` (optional, default `Observed`)
|
|
50
|
+
- `ATTACKFORGE_UI_BASE_URL` (optional, UI base URL for project testcase uploads/verification)
|
|
51
|
+
- `ATTACKFORGE_UI_TOKEN` (optional, UI authorization token for project testcase uploads/verification)
|
|
52
|
+
|
|
53
|
+
Example `.env`:
|
|
54
|
+
|
|
55
|
+
```env
|
|
56
|
+
ATTACKFORGE_BASE_URL=https://demo.attackforge.com
|
|
57
|
+
ATTACKFORGE_API_KEY=replace-me
|
|
58
|
+
ATTACKFORGE_TEST_PROJECT_ID=replace-me
|
|
59
|
+
ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT=false
|
|
60
|
+
ATTACKFORGE_FINDINGS_SUBSTATUS_KEY=substatus
|
|
61
|
+
ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE=Observed
|
|
62
|
+
ATTACKFORGE_UI_BASE_URL=https://demo.attackforge.com
|
|
63
|
+
ATTACKFORGE_UI_TOKEN=replace-me
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage (sync)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pyattackforge import AttackForgeClient
|
|
70
|
+
|
|
71
|
+
with AttackForgeClient() as client:
|
|
72
|
+
projects = client.projects.get_projects()
|
|
73
|
+
print(projects)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Touch a testcase (updates `last_tested` and `testcase_type` in `project_testcase_custom_fields`):
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from pyattackforge import AttackForgeClient
|
|
80
|
+
|
|
81
|
+
with AttackForgeClient() as client:
|
|
82
|
+
client.testcases.touch_testcase("project_id", "testcase_id")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
By default, the SDK merges `last_tested` and `testcase_type` into existing project testcase custom fields. Use `overwrite=True` to replace the list.
|
|
86
|
+
Default format for `last_tested` is `YYYY-MM-DD` (date picker friendly). Override by supplying `timestamp=`.
|
|
87
|
+
Default `testcase_type` is `Security Test Case`. Override by supplying `testcase_type=`.
|
|
88
|
+
|
|
89
|
+
Create a testcase note with optional deduplication (case-insensitive, trimmed):
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pyattackforge import AttackForgeClient
|
|
93
|
+
|
|
94
|
+
with AttackForgeClient() as client:
|
|
95
|
+
payload = {"note": "Observed weak password policy", "note_type": "PLAINTEXT"}
|
|
96
|
+
client.testcases.create_testcase_note("project_id", "testcase_id", payload, dedupe=True)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When `dedupe=True`, the SDK compares against notes returned by the project testcase listing. If notes are not included in that response, the check is best-effort and the note will be created.
|
|
100
|
+
If a UI token is configured (`ATTACKFORGE_UI_TOKEN`), the SDK will also check the UI notes endpoint to avoid duplicates.
|
|
101
|
+
|
|
102
|
+
Upload vulnerability evidence with FIFO retention and dedupe:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pyattackforge import AttackForgeClient
|
|
106
|
+
|
|
107
|
+
with AttackForgeClient() as client:
|
|
108
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png")
|
|
109
|
+
# keep_last defaults to 2 (FIFO, keep most recent). Set keep_last=None to disable.
|
|
110
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png", dedupe=True)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For more reliable FIFO/dedupe, pass `project_id` so the SDK can use the project vulnerability listing (which contains evidence metadata):
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png", project_id="project_id")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Upload project testcase evidence with FIFO retention and dedupe:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from pyattackforge import AttackForgeClient
|
|
123
|
+
|
|
124
|
+
with AttackForgeClient() as client:
|
|
125
|
+
client.testcases.upload_testcase_file("project_id", "testcase_id", "evidence.png")
|
|
126
|
+
# keep_last defaults to 2 (FIFO, keep most recent). Set keep_last=None to disable.
|
|
127
|
+
client.testcases.upload_testcase_file("project_id", "testcase_id", "evidence.png", dedupe=True)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
When a UI token is configured, FIFO cleanup for testcase evidence uses the UI delete endpoint:
|
|
131
|
+
`/api/projects/:projectId/meta/:fileId/delete` (UI expects GET/POST semantics).
|
|
132
|
+
|
|
133
|
+
Helper methods (examples):
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
with AttackForgeClient() as client:
|
|
137
|
+
project = client.projects.find_project_by_name("Tantalum Labs Fake Pentest")
|
|
138
|
+
suite = client.testsuites.find_testsuite_by_name("OWASP Web App Penetration Test")
|
|
139
|
+
if suite:
|
|
140
|
+
testcases = client.testsuites.get_testsuite_testcases(suite["id"])
|
|
141
|
+
client.link_vulnerability_to_testcases("project_id", "vuln_id", ["testcase_id"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Testing
|
|
145
|
+
|
|
146
|
+
Unit tests:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
python3 -m pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Integration tests (require env vars):
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
python3 -m pytest -m integration
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Integration tests create and modify data in the target tenant and will attempt cleanup. They read `ATTACKFORGE_BASE_URL`, `ATTACKFORGE_API_KEY`, and `ATTACKFORGE_TEST_PROJECT_ID`.
|
|
159
|
+
|
|
160
|
+
## Status
|
|
161
|
+
|
|
162
|
+
This is a from-scratch rewrite. See `COVERAGE.md` for endpoint coverage and assumptions.
|
|
163
|
+
|
|
164
|
+
## Scripts
|
|
165
|
+
|
|
166
|
+
Import writeups from a JSON export:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python3 scripts/import_writeups.py --input writeups.json
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
By default the script will ensure custom libraries exist. If the SSAPI returns an error for a custom library key, it will create a small bootstrap writeup in that library. Use `--no-ensure-library` to skip.
|
|
173
|
+
|
|
174
|
+
Add missing writeup custom fields (Settings -> Writeups) based on the import file:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
python3 scripts/import_writeups.py --input writeups.json --ensure-writeup-fields
|
|
178
|
+
```
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# PyAttackForge
|
|
2
|
+
|
|
3
|
+
Next-generation Python SDK for the AttackForge SSAPI (Python 3.10+).
|
|
4
|
+
|
|
5
|
+
## AI Documentation
|
|
6
|
+
|
|
7
|
+
`DOCUMENTATION.md` is written for AI coding assistants (Codex/Claude) to implement or extend the library.
|
|
8
|
+
|
|
9
|
+
## Install (local)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install (PyPI)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install pyattackforge
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Build
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python -m build --sdist --wheel
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
Environment variables:
|
|
30
|
+
- `ATTACKFORGE_BASE_URL`
|
|
31
|
+
- `ATTACKFORGE_API_KEY`
|
|
32
|
+
- `ATTACKFORGE_TEST_PROJECT_ID` (only needed for live integration tests)
|
|
33
|
+
- `ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT` (optional, set to `true` to make new findings visible; default is pending/hidden)
|
|
34
|
+
- `ATTACKFORGE_FINDINGS_SUBSTATUS_KEY` (optional, default `substatus`)
|
|
35
|
+
- `ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE` (optional, default `Observed`)
|
|
36
|
+
- `ATTACKFORGE_UI_BASE_URL` (optional, UI base URL for project testcase uploads/verification)
|
|
37
|
+
- `ATTACKFORGE_UI_TOKEN` (optional, UI authorization token for project testcase uploads/verification)
|
|
38
|
+
|
|
39
|
+
Example `.env`:
|
|
40
|
+
|
|
41
|
+
```env
|
|
42
|
+
ATTACKFORGE_BASE_URL=https://demo.attackforge.com
|
|
43
|
+
ATTACKFORGE_API_KEY=replace-me
|
|
44
|
+
ATTACKFORGE_TEST_PROJECT_ID=replace-me
|
|
45
|
+
ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT=false
|
|
46
|
+
ATTACKFORGE_FINDINGS_SUBSTATUS_KEY=substatus
|
|
47
|
+
ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE=Observed
|
|
48
|
+
ATTACKFORGE_UI_BASE_URL=https://demo.attackforge.com
|
|
49
|
+
ATTACKFORGE_UI_TOKEN=replace-me
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage (sync)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from pyattackforge import AttackForgeClient
|
|
56
|
+
|
|
57
|
+
with AttackForgeClient() as client:
|
|
58
|
+
projects = client.projects.get_projects()
|
|
59
|
+
print(projects)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Touch a testcase (updates `last_tested` and `testcase_type` in `project_testcase_custom_fields`):
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pyattackforge import AttackForgeClient
|
|
66
|
+
|
|
67
|
+
with AttackForgeClient() as client:
|
|
68
|
+
client.testcases.touch_testcase("project_id", "testcase_id")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
By default, the SDK merges `last_tested` and `testcase_type` into existing project testcase custom fields. Use `overwrite=True` to replace the list.
|
|
72
|
+
Default format for `last_tested` is `YYYY-MM-DD` (date picker friendly). Override by supplying `timestamp=`.
|
|
73
|
+
Default `testcase_type` is `Security Test Case`. Override by supplying `testcase_type=`.
|
|
74
|
+
|
|
75
|
+
Create a testcase note with optional deduplication (case-insensitive, trimmed):
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from pyattackforge import AttackForgeClient
|
|
79
|
+
|
|
80
|
+
with AttackForgeClient() as client:
|
|
81
|
+
payload = {"note": "Observed weak password policy", "note_type": "PLAINTEXT"}
|
|
82
|
+
client.testcases.create_testcase_note("project_id", "testcase_id", payload, dedupe=True)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When `dedupe=True`, the SDK compares against notes returned by the project testcase listing. If notes are not included in that response, the check is best-effort and the note will be created.
|
|
86
|
+
If a UI token is configured (`ATTACKFORGE_UI_TOKEN`), the SDK will also check the UI notes endpoint to avoid duplicates.
|
|
87
|
+
|
|
88
|
+
Upload vulnerability evidence with FIFO retention and dedupe:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pyattackforge import AttackForgeClient
|
|
92
|
+
|
|
93
|
+
with AttackForgeClient() as client:
|
|
94
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png")
|
|
95
|
+
# keep_last defaults to 2 (FIFO, keep most recent). Set keep_last=None to disable.
|
|
96
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png", dedupe=True)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
For more reliable FIFO/dedupe, pass `project_id` so the SDK can use the project vulnerability listing (which contains evidence metadata):
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
client.findings.upload_vulnerability_evidence("vuln_id", "evidence.png", project_id="project_id")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Upload project testcase evidence with FIFO retention and dedupe:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from pyattackforge import AttackForgeClient
|
|
109
|
+
|
|
110
|
+
with AttackForgeClient() as client:
|
|
111
|
+
client.testcases.upload_testcase_file("project_id", "testcase_id", "evidence.png")
|
|
112
|
+
# keep_last defaults to 2 (FIFO, keep most recent). Set keep_last=None to disable.
|
|
113
|
+
client.testcases.upload_testcase_file("project_id", "testcase_id", "evidence.png", dedupe=True)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
When a UI token is configured, FIFO cleanup for testcase evidence uses the UI delete endpoint:
|
|
117
|
+
`/api/projects/:projectId/meta/:fileId/delete` (UI expects GET/POST semantics).
|
|
118
|
+
|
|
119
|
+
Helper methods (examples):
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
with AttackForgeClient() as client:
|
|
123
|
+
project = client.projects.find_project_by_name("Tantalum Labs Fake Pentest")
|
|
124
|
+
suite = client.testsuites.find_testsuite_by_name("OWASP Web App Penetration Test")
|
|
125
|
+
if suite:
|
|
126
|
+
testcases = client.testsuites.get_testsuite_testcases(suite["id"])
|
|
127
|
+
client.link_vulnerability_to_testcases("project_id", "vuln_id", ["testcase_id"])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Testing
|
|
131
|
+
|
|
132
|
+
Unit tests:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python3 -m pytest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Integration tests (require env vars):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python3 -m pytest -m integration
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Integration tests create and modify data in the target tenant and will attempt cleanup. They read `ATTACKFORGE_BASE_URL`, `ATTACKFORGE_API_KEY`, and `ATTACKFORGE_TEST_PROJECT_ID`.
|
|
145
|
+
|
|
146
|
+
## Status
|
|
147
|
+
|
|
148
|
+
This is a from-scratch rewrite. See `COVERAGE.md` for endpoint coverage and assumptions.
|
|
149
|
+
|
|
150
|
+
## Scripts
|
|
151
|
+
|
|
152
|
+
Import writeups from a JSON export:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
python3 scripts/import_writeups.py --input writeups.json
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
By default the script will ensure custom libraries exist. If the SSAPI returns an error for a custom library key, it will create a small bootstrap writeup in that library. Use `--no-ensure-library` to skip.
|
|
159
|
+
|
|
160
|
+
Add missing writeup custom fields (Settings -> Writeups) based on the import file:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
python3 scripts/import_writeups.py --input writeups.json --ensure-writeup-fields
|
|
164
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Small TTL cache used by the SDK to reduce API calls."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CacheEntry:
|
|
10
|
+
value: Any
|
|
11
|
+
expires_at: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TTLCache:
|
|
15
|
+
def __init__(self, default_ttl: float = 300.0) -> None:
|
|
16
|
+
self._default_ttl = default_ttl
|
|
17
|
+
self._data: Dict[str, CacheEntry] = {}
|
|
18
|
+
|
|
19
|
+
def get(self, key: str) -> Optional[Any]:
|
|
20
|
+
entry = self._data.get(key)
|
|
21
|
+
if not entry:
|
|
22
|
+
return None
|
|
23
|
+
if entry.expires_at < time.monotonic():
|
|
24
|
+
self._data.pop(key, None)
|
|
25
|
+
return None
|
|
26
|
+
return entry.value
|
|
27
|
+
|
|
28
|
+
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
29
|
+
ttl_value = self._default_ttl if ttl is None else ttl
|
|
30
|
+
self._data[key] = CacheEntry(value=value, expires_at=time.monotonic() + ttl_value)
|
|
31
|
+
|
|
32
|
+
def clear(self) -> None:
|
|
33
|
+
self._data.clear()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""High-level AttackForge client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Sequence, Dict, Any
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .config import ClientConfig, config_from_env
|
|
9
|
+
from .exceptions import APIError
|
|
10
|
+
from .transport import AttackForgeTransport
|
|
11
|
+
from .resources import (
|
|
12
|
+
AssetsResource,
|
|
13
|
+
ProjectsResource,
|
|
14
|
+
FindingsResource,
|
|
15
|
+
WriteupsResource,
|
|
16
|
+
TestcasesResource,
|
|
17
|
+
TestsuitesResource,
|
|
18
|
+
NotesResource,
|
|
19
|
+
UsersResource,
|
|
20
|
+
ReportsResource,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AttackForgeClient:
|
|
25
|
+
"""Facade client exposing resource groups for the AttackForge SSAPI."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
config: Optional[ClientConfig] = None,
|
|
33
|
+
timeout: Optional[float] = None,
|
|
34
|
+
max_retries: Optional[int] = None,
|
|
35
|
+
backoff_factor: Optional[float] = None,
|
|
36
|
+
http2: Optional[bool] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if config is None:
|
|
39
|
+
if base_url and api_key:
|
|
40
|
+
config = ClientConfig(
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
api_key=api_key,
|
|
43
|
+
timeout=30.0 if timeout is None else timeout,
|
|
44
|
+
max_retries=3 if max_retries is None else max_retries,
|
|
45
|
+
backoff_factor=0.5 if backoff_factor is None else backoff_factor,
|
|
46
|
+
http2=True if http2 is None else http2,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
config = config_from_env()
|
|
50
|
+
self._transport = AttackForgeTransport(config)
|
|
51
|
+
|
|
52
|
+
self.assets = AssetsResource(self._transport)
|
|
53
|
+
self.projects = ProjectsResource(self._transport)
|
|
54
|
+
self.findings = FindingsResource(self._transport)
|
|
55
|
+
self.writeups = WriteupsResource(self._transport)
|
|
56
|
+
self.testcases = TestcasesResource(self._transport)
|
|
57
|
+
self.testsuites = TestsuitesResource(self._transport)
|
|
58
|
+
self.notes = NotesResource(self._transport)
|
|
59
|
+
self.users = UsersResource(self._transport)
|
|
60
|
+
self.reports = ReportsResource(self._transport)
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
self._transport.close()
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> "AttackForgeClient":
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
69
|
+
self.close()
|
|
70
|
+
|
|
71
|
+
def link_vulnerability_to_testcases(
|
|
72
|
+
self,
|
|
73
|
+
project_id: str,
|
|
74
|
+
vulnerability_id: str,
|
|
75
|
+
testcase_ids: Sequence[str],
|
|
76
|
+
*,
|
|
77
|
+
verify: bool = True,
|
|
78
|
+
attempts: int = 3,
|
|
79
|
+
delay: float = 1.0,
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""
|
|
82
|
+
Link a vulnerability to project testcases, updating both sides (vulnerability + testcase).
|
|
83
|
+
|
|
84
|
+
Best-effort: errors on either side do not raise by default. Returns linkage metadata.
|
|
85
|
+
"""
|
|
86
|
+
candidates = [value for value in testcase_ids if value]
|
|
87
|
+
if not candidates:
|
|
88
|
+
return {"action": "noop", "linked_testcases": []}
|
|
89
|
+
|
|
90
|
+
linked_testcases = set()
|
|
91
|
+
try:
|
|
92
|
+
vuln = self.findings.get_vulnerability(vulnerability_id)
|
|
93
|
+
linked_testcases = self.findings.extract_linked_testcase_ids(vuln)
|
|
94
|
+
except APIError:
|
|
95
|
+
linked_testcases = set()
|
|
96
|
+
|
|
97
|
+
updated = sorted(linked_testcases.union(candidates))
|
|
98
|
+
if set(updated) != linked_testcases:
|
|
99
|
+
payload = {"linked_testcases": updated, "projectId": project_id}
|
|
100
|
+
try:
|
|
101
|
+
self.findings.update_vulnerability(vulnerability_id, payload)
|
|
102
|
+
except APIError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
for testcase_id in candidates:
|
|
106
|
+
try:
|
|
107
|
+
testcases_data = self.testcases.get_project_testcases(project_id)
|
|
108
|
+
except APIError:
|
|
109
|
+
continue
|
|
110
|
+
testcase = self.testcases.find_project_testcase_entry(testcases_data, testcase_id)
|
|
111
|
+
if not testcase:
|
|
112
|
+
continue
|
|
113
|
+
existing = self.testcases.extract_linked_vulnerability_ids(testcase)
|
|
114
|
+
if vulnerability_id in existing:
|
|
115
|
+
continue
|
|
116
|
+
payload = {"linked_vulnerabilities": sorted(existing.union({vulnerability_id}))}
|
|
117
|
+
try:
|
|
118
|
+
self.testcases.update_testcase(project_id, testcase_id, payload)
|
|
119
|
+
except APIError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
if not verify:
|
|
123
|
+
return {"linked_testcases": updated, "verified": False}
|
|
124
|
+
|
|
125
|
+
verified = False
|
|
126
|
+
for _ in range(max(attempts, 1)):
|
|
127
|
+
try:
|
|
128
|
+
vuln = self.findings.get_vulnerability(vulnerability_id)
|
|
129
|
+
linked_testcases = self.findings.extract_linked_testcase_ids(vuln)
|
|
130
|
+
except APIError:
|
|
131
|
+
linked_testcases = set()
|
|
132
|
+
missing = [tc_id for tc_id in candidates if tc_id not in linked_testcases]
|
|
133
|
+
if missing:
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
testcases_data = self.testcases.get_project_testcases(project_id)
|
|
138
|
+
except APIError:
|
|
139
|
+
time.sleep(delay)
|
|
140
|
+
continue
|
|
141
|
+
ok = True
|
|
142
|
+
for tc_id in candidates:
|
|
143
|
+
testcase = self.testcases.find_project_testcase_entry(testcases_data, tc_id)
|
|
144
|
+
if not testcase:
|
|
145
|
+
ok = False
|
|
146
|
+
break
|
|
147
|
+
linked_vulns = self.testcases.extract_linked_vulnerability_ids(testcase)
|
|
148
|
+
if vulnerability_id not in linked_vulns:
|
|
149
|
+
ok = False
|
|
150
|
+
break
|
|
151
|
+
if ok:
|
|
152
|
+
verified = True
|
|
153
|
+
break
|
|
154
|
+
time.sleep(delay)
|
|
155
|
+
return {"linked_testcases": updated, "verified": verified}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .exceptions import ConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ClientConfig:
|
|
12
|
+
base_url: str
|
|
13
|
+
api_key: str
|
|
14
|
+
ui_base_url: Optional[str] = None
|
|
15
|
+
ui_token: Optional[str] = None
|
|
16
|
+
timeout: float = 30.0
|
|
17
|
+
max_retries: int = 3
|
|
18
|
+
backoff_factor: float = 0.5
|
|
19
|
+
user_agent: str = "pyattackforge/0.0.1"
|
|
20
|
+
http2: bool = True
|
|
21
|
+
# Default visibility for newly created findings. False = pending/hidden.
|
|
22
|
+
default_findings_visible: bool = False
|
|
23
|
+
# Default substatus custom field for newly created findings.
|
|
24
|
+
default_findings_substatus_key: Optional[str] = "substatus"
|
|
25
|
+
default_findings_substatus_value: Optional[str] = "Observed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_from_env() -> ClientConfig:
|
|
29
|
+
base_url = os.getenv("ATTACKFORGE_BASE_URL")
|
|
30
|
+
api_key = os.getenv("ATTACKFORGE_API_KEY")
|
|
31
|
+
if not base_url or not api_key:
|
|
32
|
+
raise ConfigError("ATTACKFORGE_BASE_URL and ATTACKFORGE_API_KEY are required")
|
|
33
|
+
ui_base_url = os.getenv("ATTACKFORGE_UI_BASE_URL")
|
|
34
|
+
ui_token = os.getenv("ATTACKFORGE_UI_TOKEN")
|
|
35
|
+
visible_env = os.getenv("ATTACKFORGE_FINDINGS_VISIBLE_DEFAULT")
|
|
36
|
+
substatus_key_env = os.getenv("ATTACKFORGE_FINDINGS_SUBSTATUS_KEY")
|
|
37
|
+
substatus_value_env = os.getenv("ATTACKFORGE_FINDINGS_SUBSTATUS_VALUE")
|
|
38
|
+
default_visible = False
|
|
39
|
+
if visible_env is not None:
|
|
40
|
+
default_visible = visible_env.strip().lower() in {"1", "true", "yes", "y", "visible"}
|
|
41
|
+
return ClientConfig(
|
|
42
|
+
base_url=base_url,
|
|
43
|
+
api_key=api_key,
|
|
44
|
+
ui_base_url=ui_base_url,
|
|
45
|
+
ui_token=ui_token,
|
|
46
|
+
default_findings_visible=default_visible,
|
|
47
|
+
default_findings_substatus_key=_normalize_default_substatus(substatus_key_env, "substatus"),
|
|
48
|
+
default_findings_substatus_value=_normalize_default_substatus(substatus_value_env, "Observed"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_default_substatus(value: Optional[str], default: str) -> Optional[str]:
|
|
53
|
+
if value is None:
|
|
54
|
+
return default
|
|
55
|
+
cleaned = value.strip()
|
|
56
|
+
if not cleaned:
|
|
57
|
+
return None
|
|
58
|
+
lowered = cleaned.lower()
|
|
59
|
+
if lowered in {"none", "null", "false", "0", "off", "disable", "disabled"}:
|
|
60
|
+
return None
|
|
61
|
+
return cleaned
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""SDK exception types."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AttackForgeError(Exception):
|
|
7
|
+
"""Base exception for SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(AttackForgeError):
|
|
11
|
+
"""Raised when required configuration is missing or invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(AttackForgeError):
|
|
15
|
+
"""Raised for non-successful HTTP responses."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status_code: int, message: str, payload: Optional[dict] = None) -> None:
|
|
18
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.payload = payload or {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Resource modules."""
|
|
2
|
+
|
|
3
|
+
from .assets import AssetsResource
|
|
4
|
+
from .projects import ProjectsResource
|
|
5
|
+
from .findings import FindingsResource
|
|
6
|
+
from .writeups import WriteupsResource
|
|
7
|
+
from .testcases import TestcasesResource
|
|
8
|
+
from .testsuites import TestsuitesResource
|
|
9
|
+
from .notes import NotesResource
|
|
10
|
+
from .users import UsersResource
|
|
11
|
+
from .reports import ReportsResource
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AssetsResource",
|
|
15
|
+
"ProjectsResource",
|
|
16
|
+
"FindingsResource",
|
|
17
|
+
"WriteupsResource",
|
|
18
|
+
"TestcasesResource",
|
|
19
|
+
"TestsuitesResource",
|
|
20
|
+
"NotesResource",
|
|
21
|
+
"UsersResource",
|
|
22
|
+
"ReportsResource",
|
|
23
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Resource: assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from ..cache import TTLCache
|
|
8
|
+
from .base import BaseResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AssetsResource(BaseResource):
|
|
12
|
+
"""Assets API resource wrapper."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, transport) -> None: # type: ignore[override]
|
|
15
|
+
super().__init__(transport)
|
|
16
|
+
self._cache = TTLCache(default_ttl=300.0)
|
|
17
|
+
|
|
18
|
+
def create_asset_in_library(self, payload: Dict[str, Any]) -> Any:
|
|
19
|
+
return self._post("/api/ss/library/asset", json=payload)
|
|
20
|
+
|
|
21
|
+
def update_asset_in_library(self, asset_id: str, payload: Dict[str, Any]) -> Any:
|
|
22
|
+
return self._put(f"/api/ss/library/asset/{asset_id}", json=payload)
|
|
23
|
+
|
|
24
|
+
def get_assets(self, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
|
|
25
|
+
cache_key = "assets:list"
|
|
26
|
+
if params is None and not force_refresh:
|
|
27
|
+
cached = self._cache.get(cache_key)
|
|
28
|
+
if cached is not None:
|
|
29
|
+
return cached
|
|
30
|
+
data = self._get("/api/ss/assets", params=params)
|
|
31
|
+
if params is None:
|
|
32
|
+
self._cache.set(cache_key, data)
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
def get_asset_in_library(self, asset_id: str) -> Any:
|
|
36
|
+
return self._get("/api/ss/library/asset", params={"id": asset_id})
|
|
37
|
+
|
|
38
|
+
def get_asset_library_assets(self, payload: Dict[str, Any]) -> Any:
|
|
39
|
+
return self._post("/api/ss/library/assets", json=payload)
|