scythe-ttp 0.12.4__py3-none-any.whl → 0.14.0__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.
Potentially problematic release.
This version of scythe-ttp might be problematic. Click here for more details.
- scythe/auth/__init__.py +3 -1
- scythe/auth/base.py +9 -0
- scythe/auth/cookie_jwt.py +172 -0
- scythe/cli/__init__.py +3 -0
- scythe/cli/main.py +601 -0
- scythe/core/headers.py +69 -9
- scythe/journeys/__init__.py +2 -1
- scythe/journeys/actions.py +235 -1
- scythe/journeys/base.py +161 -12
- scythe/journeys/executor.py +102 -22
- scythe/ttps/web/uuid_guessing.py +3 -2
- {scythe_ttp-0.12.4.dist-info → scythe_ttp-0.14.0.dist-info}/METADATA +84 -16
- {scythe_ttp-0.12.4.dist-info → scythe_ttp-0.14.0.dist-info}/RECORD +17 -13
- scythe_ttp-0.14.0.dist-info/entry_points.txt +2 -0
- {scythe_ttp-0.12.4.dist-info → scythe_ttp-0.14.0.dist-info}/WHEEL +0 -0
- {scythe_ttp-0.12.4.dist-info → scythe_ttp-0.14.0.dist-info}/licenses/LICENSE +0 -0
- {scythe_ttp-0.12.4.dist-info → scythe_ttp-0.14.0.dist-info}/top_level.txt +0 -0
scythe/auth/__init__.py
CHANGED
|
@@ -8,9 +8,11 @@ authenticate before executing their main functionality.
|
|
|
8
8
|
from .base import Authentication
|
|
9
9
|
from .bearer import BearerTokenAuth
|
|
10
10
|
from .basic import BasicAuth
|
|
11
|
+
from .cookie_jwt import CookieJWTAuth
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
'Authentication',
|
|
14
15
|
'BearerTokenAuth',
|
|
15
|
-
'BasicAuth'
|
|
16
|
+
'BasicAuth',
|
|
17
|
+
'CookieJWTAuth',
|
|
16
18
|
]
|
scythe/auth/base.py
CHANGED
|
@@ -80,6 +80,15 @@ class Authentication(ABC):
|
|
|
80
80
|
"""
|
|
81
81
|
return {}
|
|
82
82
|
|
|
83
|
+
def get_auth_cookies(self) -> Dict[str, str]:
|
|
84
|
+
"""
|
|
85
|
+
Get authentication cookies that should be set for API requests.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary mapping cookie name to cookie value.
|
|
89
|
+
"""
|
|
90
|
+
return {}
|
|
91
|
+
|
|
83
92
|
def store_auth_data(self, key: str, value: Any) -> None:
|
|
84
93
|
"""
|
|
85
94
|
Store authentication-related data.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Optional, Any
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import requests # type: ignore
|
|
9
|
+
except Exception: # pragma: no cover - tests may run without requests installed
|
|
10
|
+
requests = None # type: ignore
|
|
11
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
12
|
+
|
|
13
|
+
from .base import Authentication, AuthenticationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _extract_by_dot_path(data: Any, path: str) -> Optional[Any]:
|
|
17
|
+
"""
|
|
18
|
+
Extract a value from a nested dict/list structure using a simple dot path.
|
|
19
|
+
Supports numeric indices for lists, e.g., "data.items.0.token".
|
|
20
|
+
"""
|
|
21
|
+
if not path:
|
|
22
|
+
return None
|
|
23
|
+
parts = path.split(".")
|
|
24
|
+
current: Any = data
|
|
25
|
+
for part in parts:
|
|
26
|
+
if isinstance(current, dict):
|
|
27
|
+
if part in current:
|
|
28
|
+
current = current[part]
|
|
29
|
+
else:
|
|
30
|
+
return None
|
|
31
|
+
elif isinstance(current, list):
|
|
32
|
+
try:
|
|
33
|
+
idx = int(part)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
if idx < 0 or idx >= len(current):
|
|
37
|
+
return None
|
|
38
|
+
current = current[idx]
|
|
39
|
+
else:
|
|
40
|
+
return None
|
|
41
|
+
return current
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CookieJWTAuth(Authentication):
|
|
45
|
+
"""
|
|
46
|
+
Hybrid authentication where a JWT is acquired from an API login response and
|
|
47
|
+
then used as a cookie for subsequent requests. Useful when the target server
|
|
48
|
+
expects a cookie (e.g., "stellarbridge") instead of Authorization headers.
|
|
49
|
+
|
|
50
|
+
Behavior:
|
|
51
|
+
- In API mode: JourneyExecutor will call get_auth_cookies(); this class will
|
|
52
|
+
perform a POST to login_url (if token not cached), parse JSON, extract the
|
|
53
|
+
token via jwt_json_path, and return {cookie_name: token}.
|
|
54
|
+
- In UI mode: authenticate() will ensure the browser has the cookie set for
|
|
55
|
+
the target domain.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self,
|
|
59
|
+
login_url: str,
|
|
60
|
+
username: Optional[str] = None,
|
|
61
|
+
password: Optional[str] = None,
|
|
62
|
+
username_field: str = "email",
|
|
63
|
+
password_field: str = "password",
|
|
64
|
+
extra_fields: Optional[Dict[str, Any]] = None,
|
|
65
|
+
jwt_json_path: str = "token",
|
|
66
|
+
cookie_name: str = "stellarbridge",
|
|
67
|
+
session: Optional[requests.Session] = None,
|
|
68
|
+
description: str = "Authenticate via API and set JWT cookie"):
|
|
69
|
+
super().__init__(
|
|
70
|
+
name="Cookie JWT Authentication",
|
|
71
|
+
description=description
|
|
72
|
+
)
|
|
73
|
+
self.login_url = login_url
|
|
74
|
+
self.username = username
|
|
75
|
+
self.password = password
|
|
76
|
+
self.username_field = username_field
|
|
77
|
+
self.password_field = password_field
|
|
78
|
+
self.extra_fields = extra_fields or {}
|
|
79
|
+
self.jwt_json_path = jwt_json_path
|
|
80
|
+
self.cookie_name = cookie_name
|
|
81
|
+
# Avoid importing requests in test environments; allow injected session
|
|
82
|
+
self._session = session or (requests.Session() if requests is not None else None)
|
|
83
|
+
self.token: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
def _login_and_get_token(self) -> str:
|
|
86
|
+
payload: Dict[str, Any] = dict(self.extra_fields)
|
|
87
|
+
if self.username is not None:
|
|
88
|
+
payload[self.username_field] = self.username
|
|
89
|
+
if self.password is not None:
|
|
90
|
+
payload[self.password_field] = self.password
|
|
91
|
+
try:
|
|
92
|
+
resp = self._session.post(self.login_url, json=payload, timeout=15)
|
|
93
|
+
# try json; raise on non-2xx to surface errors
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
data = resp.json()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise AuthenticationError(f"Login request failed: {e}", self.name)
|
|
98
|
+
token = _extract_by_dot_path(data, self.jwt_json_path)
|
|
99
|
+
if not token or not isinstance(token, str):
|
|
100
|
+
raise AuthenticationError(
|
|
101
|
+
f"JWT not found at path '{self.jwt_json_path}' in login response",
|
|
102
|
+
self.name,
|
|
103
|
+
)
|
|
104
|
+
self.token = token
|
|
105
|
+
self.store_auth_data('jwt', token)
|
|
106
|
+
self.store_auth_data('login_time', time.time())
|
|
107
|
+
return token
|
|
108
|
+
|
|
109
|
+
def get_auth_cookies(self) -> Dict[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Return cookie mapping for API mode. Will perform login if token absent.
|
|
112
|
+
"""
|
|
113
|
+
if not self.token:
|
|
114
|
+
self._login_and_get_token()
|
|
115
|
+
if not self.token:
|
|
116
|
+
return {}
|
|
117
|
+
return {self.cookie_name: self.token}
|
|
118
|
+
|
|
119
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
|
120
|
+
"""
|
|
121
|
+
For this hybrid approach, we typically do not use auth headers.
|
|
122
|
+
"""
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
def authenticate(self, driver: WebDriver, target_url: str) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
UI path: ensure the cookie exists on the browser for the target domain.
|
|
128
|
+
Will perform the API login if token not yet acquired.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
if not self.token:
|
|
132
|
+
self._login_and_get_token()
|
|
133
|
+
if not self.token:
|
|
134
|
+
return False
|
|
135
|
+
# Navigate to the target domain base so cookie domain matches
|
|
136
|
+
parsed = urlparse(target_url)
|
|
137
|
+
base = f"{parsed.scheme}://{parsed.netloc}"
|
|
138
|
+
try:
|
|
139
|
+
driver.get(base)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
cookie_dict = {
|
|
143
|
+
'name': self.cookie_name,
|
|
144
|
+
'value': self.token,
|
|
145
|
+
'path': '/',
|
|
146
|
+
}
|
|
147
|
+
# If domain available, set explicitly to be safe
|
|
148
|
+
if parsed.netloc:
|
|
149
|
+
cookie_dict['domain'] = parsed.hostname or parsed.netloc
|
|
150
|
+
driver.add_cookie(cookie_dict)
|
|
151
|
+
self.authenticated = True
|
|
152
|
+
return True
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise AuthenticationError(f"Cookie auth failed: {e}", self.name)
|
|
155
|
+
|
|
156
|
+
def is_authenticated(self, driver: WebDriver) -> bool:
|
|
157
|
+
return self.authenticated and self.token is not None
|
|
158
|
+
|
|
159
|
+
def logout(self, driver: WebDriver) -> bool:
|
|
160
|
+
try:
|
|
161
|
+
self.token = None
|
|
162
|
+
self.authenticated = False
|
|
163
|
+
self.clear_auth_data()
|
|
164
|
+
# Best-effort cookie removal
|
|
165
|
+
try:
|
|
166
|
+
driver.delete_cookie(self.cookie_name)
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
super().logout(driver)
|
|
170
|
+
return True
|
|
171
|
+
except Exception:
|
|
172
|
+
return False
|
scythe/cli/__init__.py
ADDED