pyattackforge 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyattackforge/__init__.py +5 -0
- pyattackforge/cache.py +33 -0
- pyattackforge/client.py +155 -0
- pyattackforge/config.py +61 -0
- pyattackforge/exceptions.py +21 -0
- pyattackforge/resources/__init__.py +23 -0
- pyattackforge/resources/assets.py +39 -0
- pyattackforge/resources/base.py +33 -0
- pyattackforge/resources/findings.py +655 -0
- pyattackforge/resources/notes.py +139 -0
- pyattackforge/resources/projects.py +154 -0
- pyattackforge/resources/reports.py +20 -0
- pyattackforge/resources/users.py +59 -0
- pyattackforge/resources/writeups.py +79 -0
- pyattackforge/transport.py +134 -0
- pyattackforge-0.0.1.dist-info/METADATA +162 -0
- pyattackforge-0.0.1.dist-info/RECORD +20 -0
- pyattackforge-0.0.1.dist-info/WHEEL +5 -0
- pyattackforge-0.0.1.dist-info/licenses/LICENSE +661 -0
- pyattackforge-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Resource: notes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import os
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
from .base import BaseResource
|
|
10
|
+
from ..exceptions import APIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotesResource(BaseResource):
|
|
14
|
+
"""Notes API resource wrapper."""
|
|
15
|
+
|
|
16
|
+
def create_remediation_note(self, vulnerability_id: str, payload: Dict[str, Any]) -> Any:
|
|
17
|
+
return self._post(f"/api/ss/vulnerability/{vulnerability_id}/remediationNote", json=payload)
|
|
18
|
+
|
|
19
|
+
def update_remediation_note(self, vulnerability_id: str, remediation_note_id: str, payload: Dict[str, Any]) -> Any:
|
|
20
|
+
return self._put(
|
|
21
|
+
f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}",
|
|
22
|
+
json=payload,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def upload_remediation_note_file(self, vulnerability_id: str, remediation_note_id: str, file_path: str) -> Any:
|
|
26
|
+
if not os.path.isfile(file_path):
|
|
27
|
+
raise FileNotFoundError(file_path)
|
|
28
|
+
with open(file_path, "rb") as handle:
|
|
29
|
+
return self._post_files(
|
|
30
|
+
f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}/file",
|
|
31
|
+
files={"file": (os.path.basename(file_path), handle)},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def download_remediation_note_file(
|
|
35
|
+
self,
|
|
36
|
+
vulnerability_id: str,
|
|
37
|
+
remediation_note_id: str,
|
|
38
|
+
file_name: str,
|
|
39
|
+
*,
|
|
40
|
+
project_id: Optional[str] = None,
|
|
41
|
+
allow_report_fallback: bool = True,
|
|
42
|
+
) -> Any:
|
|
43
|
+
"""
|
|
44
|
+
Download a remediation note file.
|
|
45
|
+
|
|
46
|
+
If the SSAPI download endpoint fails, optionally fall back to report data
|
|
47
|
+
(base64) when available.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return self._get(
|
|
51
|
+
f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}/file/{file_name}"
|
|
52
|
+
)
|
|
53
|
+
except APIError as exc:
|
|
54
|
+
if not allow_report_fallback:
|
|
55
|
+
raise
|
|
56
|
+
resolved_project_id = project_id or self._find_project_id_for_vulnerability(vulnerability_id)
|
|
57
|
+
if not resolved_project_id:
|
|
58
|
+
raise
|
|
59
|
+
report = self._post(
|
|
60
|
+
f"/api/ss/project/{resolved_project_id}/report/raw",
|
|
61
|
+
json={"excludeBinaries": False, "vulnerabilityIds": [vulnerability_id]},
|
|
62
|
+
)
|
|
63
|
+
file_entry = self._find_remediation_note_file_from_report(
|
|
64
|
+
report, vulnerability_id, remediation_note_id, file_name
|
|
65
|
+
)
|
|
66
|
+
if file_entry is None:
|
|
67
|
+
report = self._post(
|
|
68
|
+
f"/api/ss/project/{resolved_project_id}/report/raw",
|
|
69
|
+
json={"excludeBinaries": False},
|
|
70
|
+
)
|
|
71
|
+
file_entry = self._find_remediation_note_file_from_report(
|
|
72
|
+
report, vulnerability_id, remediation_note_id, file_name
|
|
73
|
+
)
|
|
74
|
+
if isinstance(file_entry, dict):
|
|
75
|
+
payload = file_entry.get("fileBase64")
|
|
76
|
+
if isinstance(payload, str) and payload:
|
|
77
|
+
try:
|
|
78
|
+
padded = payload + ("=" * (-len(payload) % 4))
|
|
79
|
+
return base64.b64decode(padded)
|
|
80
|
+
except (ValueError, TypeError):
|
|
81
|
+
pass
|
|
82
|
+
raise exc
|
|
83
|
+
|
|
84
|
+
def _find_project_id_for_vulnerability(self, vulnerability_id: str) -> Optional[str]:
|
|
85
|
+
data = self._get("/api/ss/projects-and-vulnerabilities")
|
|
86
|
+
projects = data.get("projects") if isinstance(data, dict) else None
|
|
87
|
+
if not isinstance(projects, list):
|
|
88
|
+
return None
|
|
89
|
+
for project in projects:
|
|
90
|
+
if not isinstance(project, dict):
|
|
91
|
+
continue
|
|
92
|
+
vulns = project.get("project_vulnerabilities")
|
|
93
|
+
if not isinstance(vulns, list):
|
|
94
|
+
continue
|
|
95
|
+
for vuln in vulns:
|
|
96
|
+
if not isinstance(vuln, dict):
|
|
97
|
+
continue
|
|
98
|
+
vid = vuln.get("vulnerability_id") or vuln.get("id")
|
|
99
|
+
if vid == vulnerability_id:
|
|
100
|
+
return project.get("project_id") or project.get("id")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _find_remediation_note_file_from_report(
|
|
104
|
+
self,
|
|
105
|
+
report: Any,
|
|
106
|
+
vulnerability_id: str,
|
|
107
|
+
remediation_note_id: str,
|
|
108
|
+
file_name: Optional[str],
|
|
109
|
+
) -> Optional[Dict[str, Any]]:
|
|
110
|
+
if not isinstance(report, dict):
|
|
111
|
+
return None
|
|
112
|
+
vulnerabilities = report.get("vulnerabilities")
|
|
113
|
+
if not isinstance(vulnerabilities, list):
|
|
114
|
+
return None
|
|
115
|
+
for vuln in vulnerabilities:
|
|
116
|
+
if not isinstance(vuln, dict):
|
|
117
|
+
continue
|
|
118
|
+
vid = vuln.get("id") or vuln.get("vulnerability_id")
|
|
119
|
+
if vid != vulnerability_id:
|
|
120
|
+
continue
|
|
121
|
+
for asset in vuln.get("affected_assets") or []:
|
|
122
|
+
if not isinstance(asset, dict):
|
|
123
|
+
continue
|
|
124
|
+
for note in asset.get("remediation_notes") or []:
|
|
125
|
+
if not isinstance(note, dict):
|
|
126
|
+
continue
|
|
127
|
+
nid = note.get("id") or note.get("remediation_note_id") or note.get("note_id")
|
|
128
|
+
if nid != remediation_note_id:
|
|
129
|
+
continue
|
|
130
|
+
files = note.get("remediation_note_files") or []
|
|
131
|
+
if not isinstance(files, list):
|
|
132
|
+
continue
|
|
133
|
+
for entry in files:
|
|
134
|
+
if not isinstance(entry, dict):
|
|
135
|
+
continue
|
|
136
|
+
entry_name = entry.get("fileName") or entry.get("file_name") or entry.get("name")
|
|
137
|
+
if file_name is None or entry_name == file_name:
|
|
138
|
+
return entry
|
|
139
|
+
return None
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Resource: projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional, Iterable, List
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from ..cache import TTLCache
|
|
9
|
+
from .base import BaseResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectsResource(BaseResource):
|
|
13
|
+
"""Projects API resource wrapper."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, transport) -> None: # type: ignore[override]
|
|
16
|
+
super().__init__(transport)
|
|
17
|
+
self._cache = TTLCache(default_ttl=300.0)
|
|
18
|
+
|
|
19
|
+
def create_project(self, payload: Dict[str, Any]) -> Any:
|
|
20
|
+
return self._post("/api/ss/project", json=payload)
|
|
21
|
+
|
|
22
|
+
def get_project(self, project_id: str, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
|
|
23
|
+
cache_key = f"project:{project_id}"
|
|
24
|
+
if params is None and not force_refresh:
|
|
25
|
+
cached = self._cache.get(cache_key)
|
|
26
|
+
if cached is not None:
|
|
27
|
+
return cached
|
|
28
|
+
data = self._get(f"/api/ss/project/{project_id}", params=params)
|
|
29
|
+
if params is None:
|
|
30
|
+
self._cache.set(cache_key, data)
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
def get_projects(self, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
34
|
+
return self._get("/api/ss/projects", params=params)
|
|
35
|
+
|
|
36
|
+
def get_projects_and_vulnerabilities(self, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
37
|
+
return self._get("/api/ss/projects-and-vulnerabilities", params=params)
|
|
38
|
+
|
|
39
|
+
def update_project(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
40
|
+
data = self._put(f"/api/ss/project/{project_id}", json=payload)
|
|
41
|
+
self._cache.set(f"project:{project_id}", data)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
def archive_project(self, project_id: str) -> Any:
|
|
45
|
+
return self._put(f"/api/ss/project/{project_id}/archive")
|
|
46
|
+
|
|
47
|
+
def restore_project(self, project_id: str) -> Any:
|
|
48
|
+
return self._put(f"/api/ss/project/{project_id}/restore")
|
|
49
|
+
|
|
50
|
+
def destroy_projects(self, project_ids: Iterable[str], keep_logs: Optional[bool] = None) -> Any:
|
|
51
|
+
payload: Dict[str, Any] = {"project_ids": list(project_ids)}
|
|
52
|
+
if keep_logs is not None:
|
|
53
|
+
payload["keep_logs"] = keep_logs
|
|
54
|
+
return self._delete("/api/ss/project/destroy", json=payload)
|
|
55
|
+
|
|
56
|
+
def clone_project(self, project_id: str, payload: Optional[Dict[str, Any]] = None) -> Any:
|
|
57
|
+
return self._post(f"/api/ss/project/{project_id}/clone", json=payload or {})
|
|
58
|
+
|
|
59
|
+
def create_scope(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
60
|
+
return self._post(f"/api/ss/project/{project_id}/assets", json=payload)
|
|
61
|
+
|
|
62
|
+
def update_scope(self, project_id: str, asset_id: str, payload: Dict[str, Any]) -> Any:
|
|
63
|
+
return self._put(f"/api/ss/project/{project_id}/asset/{asset_id}", json=payload)
|
|
64
|
+
|
|
65
|
+
def get_project_workspace(self, project_id: str) -> Any:
|
|
66
|
+
return self._get(f"/api/ss/project/{project_id}/workspace")
|
|
67
|
+
|
|
68
|
+
def upload_workspace_file(self, project_id: str, file_path: str) -> Any:
|
|
69
|
+
if not os.path.isfile(file_path):
|
|
70
|
+
raise FileNotFoundError(file_path)
|
|
71
|
+
with open(file_path, "rb") as handle:
|
|
72
|
+
return self._post_files(
|
|
73
|
+
f"/api/ss/project/{project_id}/workspace/file",
|
|
74
|
+
files={"file": (os.path.basename(file_path), handle)},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def download_workspace_file(self, project_id: str, file_name: str) -> Any:
|
|
78
|
+
return self._get(f"/api/ss/project/{project_id}/workspace/{file_name}")
|
|
79
|
+
|
|
80
|
+
def get_project_notes(self, project_id: str) -> Any:
|
|
81
|
+
return self._get(f"/api/ss/project/{project_id}/notes")
|
|
82
|
+
|
|
83
|
+
def create_project_note(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
84
|
+
return self._post(f"/api/ss/project/{project_id}/note", json=payload)
|
|
85
|
+
|
|
86
|
+
def update_project_note(self, project_id: str, note_id: str, payload: Dict[str, Any]) -> Any:
|
|
87
|
+
return self._put(f"/api/ss/project/{project_id}/note/{note_id}", json=payload)
|
|
88
|
+
|
|
89
|
+
def create_project_workspace_note(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
90
|
+
return self._post(f"/api/ss/project/{project_id}/workspace/note", json=payload)
|
|
91
|
+
|
|
92
|
+
def update_project_workspace_note(self, project_id: str, note_id: str, payload: Dict[str, Any]) -> Any:
|
|
93
|
+
return self._put(f"/api/ss/project/{project_id}/workspace/note/{note_id}", json=payload)
|
|
94
|
+
|
|
95
|
+
def get_project_membership_administrators(self, project_id: str) -> Any:
|
|
96
|
+
return self._get(f"/api/ss/project/{project_id}/member-admins")
|
|
97
|
+
|
|
98
|
+
def _normalize_member_admin_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
99
|
+
if "member_admins" in payload:
|
|
100
|
+
return payload
|
|
101
|
+
return {"member_admins": [payload]}
|
|
102
|
+
|
|
103
|
+
def add_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
104
|
+
return self._post(
|
|
105
|
+
f"/api/ss/project/{project_id}/member-admins",
|
|
106
|
+
json=self._normalize_member_admin_payload(payload),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def update_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
110
|
+
return self._put(
|
|
111
|
+
f"/api/ss/project/{project_id}/member-admins",
|
|
112
|
+
json=self._normalize_member_admin_payload(payload),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def remove_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
116
|
+
return self._delete(
|
|
117
|
+
f"/api/ss/project/{project_id}/member-admins",
|
|
118
|
+
json=self._normalize_member_admin_payload(payload),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def invite_user_to_project(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
122
|
+
return self._post(f"/api/ss/project/{project_id}/invite", json=payload)
|
|
123
|
+
|
|
124
|
+
def invite_users_to_project_team(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
125
|
+
return self._post(f"/api/ss/project/{project_id}/team/invite", json=payload)
|
|
126
|
+
|
|
127
|
+
def remove_project_team_members(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
128
|
+
return self._put(f"/api/ss/project/{project_id}/team/remove", json=payload)
|
|
129
|
+
|
|
130
|
+
def update_user_access_on_project(self, project_id: str, user_id: str, payload: Dict[str, Any]) -> Any:
|
|
131
|
+
return self._put(f"/api/ss/project/{project_id}/access/{user_id}", json=payload)
|
|
132
|
+
|
|
133
|
+
def extract_projects_list(self, projects_data: Any) -> List[Dict[str, Any]]:
|
|
134
|
+
if not isinstance(projects_data, dict):
|
|
135
|
+
return []
|
|
136
|
+
projects = projects_data.get("projects") or (projects_data.get("data") or {}).get("projects") or []
|
|
137
|
+
return [proj for proj in projects if isinstance(proj, dict)] if isinstance(projects, list) else []
|
|
138
|
+
|
|
139
|
+
def find_project_by_name(
|
|
140
|
+
self, name: str, *, projects_data: Optional[Any] = None, case_insensitive: bool = True
|
|
141
|
+
) -> Optional[Dict[str, Any]]:
|
|
142
|
+
data = projects_data if projects_data is not None else self.get_projects()
|
|
143
|
+
desired = self._normalize_string(name) if case_insensitive else name
|
|
144
|
+
for project in self.extract_projects_list(data):
|
|
145
|
+
project_name = project.get("project_name") or project.get("name")
|
|
146
|
+
if not isinstance(project_name, str):
|
|
147
|
+
continue
|
|
148
|
+
candidate = self._normalize_string(project_name) if case_insensitive else project_name
|
|
149
|
+
if candidate == desired:
|
|
150
|
+
return project
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def _normalize_string(self, value: str) -> str:
|
|
154
|
+
return value.strip().lower()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Resource: reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .base import BaseResource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReportsResource(BaseResource):
|
|
11
|
+
"""Reports API resource wrapper."""
|
|
12
|
+
|
|
13
|
+
def get_project_report(self, project_id: str, report_type: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
14
|
+
return self._get(f"/api/ss/project/{project_id}/report/{report_type}", params=params)
|
|
15
|
+
|
|
16
|
+
def get_project_report_data(self, project_id: str, report_type: str, payload: Dict[str, Any]) -> Any:
|
|
17
|
+
return self._post(f"/api/ss/project/{project_id}/report/{report_type}", json=payload)
|
|
18
|
+
|
|
19
|
+
def update_exec_summary_notes(self, project_id: str, payload: Dict[str, Any]) -> Any:
|
|
20
|
+
return self._put(f"/api/ss/project/{project_id}/execSummaryNotes", json=payload)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Resource: users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
from .base import BaseResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UsersResource(BaseResource):
|
|
12
|
+
"""Users API resource wrapper."""
|
|
13
|
+
|
|
14
|
+
def create_user(self, payload: Dict[str, Any]) -> Any:
|
|
15
|
+
return self._post("/api/ss/user", json=payload)
|
|
16
|
+
|
|
17
|
+
def create_users(self, users: Any) -> Any:
|
|
18
|
+
return self._post("/api/ss/users", json=users)
|
|
19
|
+
|
|
20
|
+
def get_user(self, user_id: str) -> Any:
|
|
21
|
+
return self._get(f"/api/ss/users/{user_id}")
|
|
22
|
+
|
|
23
|
+
def get_users(self, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
24
|
+
return self._get("/api/ss/users", params=params)
|
|
25
|
+
|
|
26
|
+
def get_user_by_email(self, email: str) -> Any:
|
|
27
|
+
email_value = urllib.parse.quote(email, safe="")
|
|
28
|
+
return self._get(f"/api/ss/users/email/{email_value}")
|
|
29
|
+
|
|
30
|
+
def get_user_by_username(self, username: str) -> Any:
|
|
31
|
+
username_value = urllib.parse.quote(username, safe="")
|
|
32
|
+
return self._get(f"/api/ss/users/username/{username_value}")
|
|
33
|
+
|
|
34
|
+
def update_user(self, user_id: str, payload: Dict[str, Any]) -> Any:
|
|
35
|
+
return self._put(f"/api/ss/user/{user_id}", json=payload)
|
|
36
|
+
|
|
37
|
+
def activate_user(self, user_id: str) -> Any:
|
|
38
|
+
return self._put(f"/api/ss/user/{user_id}/activate")
|
|
39
|
+
|
|
40
|
+
def deactivate_user(self, user_id: str) -> Any:
|
|
41
|
+
return self._put(f"/api/ss/user/{user_id}/deactivate")
|
|
42
|
+
|
|
43
|
+
def add_user_to_group(self, payload: Dict[str, Any]) -> Any:
|
|
44
|
+
return self._post("/api/ss/group/user", json=payload)
|
|
45
|
+
|
|
46
|
+
def update_user_access_on_group(self, user_id: str, payload: Dict[str, Any]) -> Any:
|
|
47
|
+
return self._put(f"/api/ss/group/user/{user_id}", json=payload)
|
|
48
|
+
|
|
49
|
+
def get_user_groups(self, user_id: str) -> Any:
|
|
50
|
+
return self._get(f"/api/ss/user/{user_id}/groups")
|
|
51
|
+
|
|
52
|
+
def get_user_projects(self, user_id: str) -> Any:
|
|
53
|
+
return self._get(f"/api/ss/user/{user_id}/projects")
|
|
54
|
+
|
|
55
|
+
def get_user_audit_logs(self, user_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
56
|
+
return self._get(f"/api/ss/user/{user_id}/auditlogs", params=params)
|
|
57
|
+
|
|
58
|
+
def get_user_login_history(self, user_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
59
|
+
return self._get(f"/api/ss/user/{user_id}/logins", params=params)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Resource: writeups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from ..cache import TTLCache
|
|
9
|
+
from .base import BaseResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WriteupsResource(BaseResource):
|
|
13
|
+
"""Writeups (library vulnerabilities) API resource wrapper."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, transport) -> None: # type: ignore[override]
|
|
16
|
+
super().__init__(transport)
|
|
17
|
+
self._cache = TTLCache(default_ttl=300.0)
|
|
18
|
+
|
|
19
|
+
def get_writeups(self, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
|
|
20
|
+
cache_key = "writeups:list"
|
|
21
|
+
if params is None and not force_refresh:
|
|
22
|
+
cached = self._cache.get(cache_key)
|
|
23
|
+
if cached is not None:
|
|
24
|
+
return cached
|
|
25
|
+
data = self._get("/api/ss/library", params=params)
|
|
26
|
+
if params is None:
|
|
27
|
+
self._cache.set(cache_key, data)
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
def get_writeup_files(self, *, writeup_id: Optional[str] = None, name: Optional[str] = None) -> list[Dict[str, Any]]:
|
|
31
|
+
"""
|
|
32
|
+
Fetch file entries for a writeup (library vulnerability).
|
|
33
|
+
|
|
34
|
+
The SSAPI returns file storage names under `files[].storage_name` when
|
|
35
|
+
querying `/api/ss/library` with `id` or `name` filters.
|
|
36
|
+
"""
|
|
37
|
+
params: Dict[str, Any] = {}
|
|
38
|
+
if writeup_id:
|
|
39
|
+
params["id"] = writeup_id
|
|
40
|
+
if name:
|
|
41
|
+
params["name"] = name
|
|
42
|
+
data = self.get_writeups(params=params, force_refresh=True)
|
|
43
|
+
if isinstance(data, dict):
|
|
44
|
+
candidates = data.get("vulnerabilities") or data.get("library") or data.get("issues") or []
|
|
45
|
+
elif isinstance(data, list):
|
|
46
|
+
candidates = data
|
|
47
|
+
else:
|
|
48
|
+
candidates = []
|
|
49
|
+
if not isinstance(candidates, list):
|
|
50
|
+
return []
|
|
51
|
+
for entry in candidates:
|
|
52
|
+
if not isinstance(entry, dict):
|
|
53
|
+
continue
|
|
54
|
+
if writeup_id and entry.get("id") != writeup_id:
|
|
55
|
+
continue
|
|
56
|
+
if name and entry.get("title") != name:
|
|
57
|
+
continue
|
|
58
|
+
files = entry.get("files")
|
|
59
|
+
if isinstance(files, list):
|
|
60
|
+
return files
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
def create_writeup(self, payload: Dict[str, Any]) -> Any:
|
|
64
|
+
return self._post("/api/ss/library/vulnerability", json=payload)
|
|
65
|
+
|
|
66
|
+
def update_writeup(self, writeup_id: str, payload: Dict[str, Any]) -> Any:
|
|
67
|
+
return self._put(f"/api/ss/library/{writeup_id}", json=payload)
|
|
68
|
+
|
|
69
|
+
def upload_writeup_file(self, writeup_id: str, file_path: str) -> Any:
|
|
70
|
+
if not os.path.isfile(file_path):
|
|
71
|
+
raise FileNotFoundError(file_path)
|
|
72
|
+
with open(file_path, "rb") as handle:
|
|
73
|
+
return self._post_files(
|
|
74
|
+
f"/api/ss/library/{writeup_id}/file",
|
|
75
|
+
files={"file": (os.path.basename(file_path), handle)},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def download_writeup_file(self, writeup_id: str, file_name: str) -> Any:
|
|
79
|
+
return self._get(f"/api/ss/library/{writeup_id}/file/{file_name}")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""HTTP transport for AttackForge SSAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
import time
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .exceptions import APIError
|
|
11
|
+
from .config import ClientConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TransportResponse:
|
|
16
|
+
status_code: int
|
|
17
|
+
data: Any
|
|
18
|
+
headers: Dict[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AttackForgeTransport:
|
|
22
|
+
def __init__(self, config: ClientConfig, client: Optional[httpx.Client] = None) -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
self._client = client or httpx.Client(
|
|
25
|
+
base_url=config.base_url.rstrip("/"),
|
|
26
|
+
timeout=config.timeout,
|
|
27
|
+
headers={
|
|
28
|
+
"X-SSAPI-KEY": config.api_key,
|
|
29
|
+
"Connection": "close",
|
|
30
|
+
"User-Agent": config.user_agent,
|
|
31
|
+
},
|
|
32
|
+
http2=config.http2,
|
|
33
|
+
)
|
|
34
|
+
self._http2_client: Optional[httpx.Client] = None
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
self._client.close()
|
|
38
|
+
if self._http2_client is not None:
|
|
39
|
+
self._http2_client.close()
|
|
40
|
+
|
|
41
|
+
def _get_http2_client(self) -> httpx.Client:
|
|
42
|
+
if self._http2_client is None:
|
|
43
|
+
self._http2_client = httpx.Client(
|
|
44
|
+
base_url=self._config.base_url.rstrip("/"),
|
|
45
|
+
timeout=self._config.timeout,
|
|
46
|
+
headers={
|
|
47
|
+
"X-SSAPI-KEY": self._config.api_key,
|
|
48
|
+
"Connection": "close",
|
|
49
|
+
"User-Agent": self._config.user_agent,
|
|
50
|
+
},
|
|
51
|
+
http2=True,
|
|
52
|
+
)
|
|
53
|
+
return self._http2_client
|
|
54
|
+
|
|
55
|
+
def request(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
params: Optional[Dict[str, Any]] = None,
|
|
61
|
+
json: Optional[Dict[str, Any]] = None,
|
|
62
|
+
files: Optional[Dict[str, Any]] = None,
|
|
63
|
+
data: Optional[Dict[str, Any]] = None,
|
|
64
|
+
headers: Optional[Dict[str, str]] = None,
|
|
65
|
+
timeout: Optional[float] = None,
|
|
66
|
+
retries: Optional[int] = None,
|
|
67
|
+
) -> TransportResponse:
|
|
68
|
+
attempt = 0
|
|
69
|
+
max_retries = self._config.max_retries if retries is None else retries
|
|
70
|
+
backoff = self._config.backoff_factor
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
request_headers = None
|
|
74
|
+
if headers:
|
|
75
|
+
request_headers = headers
|
|
76
|
+
if files:
|
|
77
|
+
request_headers = dict(self._client.headers)
|
|
78
|
+
request_headers.pop("Content-Type", None)
|
|
79
|
+
if headers:
|
|
80
|
+
request_headers.update(headers)
|
|
81
|
+
response = self._client.request(
|
|
82
|
+
method=method,
|
|
83
|
+
url=path,
|
|
84
|
+
params=params,
|
|
85
|
+
json=json,
|
|
86
|
+
files=files,
|
|
87
|
+
data=data,
|
|
88
|
+
headers=request_headers,
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
)
|
|
91
|
+
except httpx.HTTPError as exc:
|
|
92
|
+
if attempt >= max_retries:
|
|
93
|
+
raise APIError(status_code=0, message=str(exc)) from exc
|
|
94
|
+
time.sleep(backoff * (2 ** attempt))
|
|
95
|
+
attempt += 1
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if response.status_code >= 500 and files:
|
|
99
|
+
try:
|
|
100
|
+
response = self._get_http2_client().request(
|
|
101
|
+
method=method,
|
|
102
|
+
url=path,
|
|
103
|
+
params=params,
|
|
104
|
+
json=json,
|
|
105
|
+
files=files,
|
|
106
|
+
data=data,
|
|
107
|
+
headers=request_headers,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
except httpx.HTTPError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
if response.status_code in (429, 500, 502, 503, 504) and attempt < max_retries:
|
|
114
|
+
time.sleep(backoff * (2 ** attempt))
|
|
115
|
+
attempt += 1
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
data_out: Any
|
|
119
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
120
|
+
try:
|
|
121
|
+
data_out = response.json()
|
|
122
|
+
except ValueError:
|
|
123
|
+
data_out = response.text
|
|
124
|
+
else:
|
|
125
|
+
data_out = response.content
|
|
126
|
+
|
|
127
|
+
if response.status_code >= 400:
|
|
128
|
+
raise APIError(response.status_code, response.text, payload={"data": data_out})
|
|
129
|
+
|
|
130
|
+
return TransportResponse(
|
|
131
|
+
status_code=response.status_code,
|
|
132
|
+
data=data_out,
|
|
133
|
+
headers=dict(response.headers),
|
|
134
|
+
)
|