scythe-ttp 0.12.4__tar.gz → 0.13.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.
Potentially problematic release.
This version of scythe-ttp might be problematic. Click here for more details.
- {scythe_ttp-0.12.4/scythe_ttp.egg-info → scythe_ttp-0.13.0}/PKG-INFO +3 -1
- scythe_ttp-0.13.0/VERSION +1 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/requirements.txt +2 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/auth/__init__.py +3 -1
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/auth/base.py +9 -0
- scythe_ttp-0.13.0/scythe/auth/cookie_jwt.py +172 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/journeys/__init__.py +2 -1
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/journeys/actions.py +124 -1
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/journeys/base.py +40 -7
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/journeys/executor.py +63 -22
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/ttps/web/uuid_guessing.py +3 -2
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0/scythe_ttp.egg-info}/PKG-INFO +3 -1
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe_ttp.egg-info/SOURCES.txt +3 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe_ttp.egg-info/requires.txt +2 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/setup.py +1 -1
- scythe_ttp-0.13.0/tests/test_api_models.py +126 -0
- scythe_ttp-0.13.0/tests/test_cookie_jwt_auth.py +201 -0
- scythe_ttp-0.12.4/VERSION +0 -1
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/LICENSE +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/MANIFEST.in +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/README.md +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/auth/basic.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/auth/bearer.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/base.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/default.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/human.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/machine.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/behaviors/stealth.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/core/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/core/executor.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/core/headers.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/core/ttp.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/orchestrators/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/orchestrators/base.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/orchestrators/batch.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/orchestrators/distributed.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/orchestrators/scale.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/payloads/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/payloads/generators.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/ttps/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/ttps/web/__init__.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/ttps/web/login_bruteforce.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe/ttps/web/sql_injection.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe_ttp.egg-info/dependency_links.txt +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/scythe_ttp.egg-info/top_level.txt +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/setup.cfg +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_authentication.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_behaviors.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_expected_results.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_feature_completeness.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_header_extraction.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_journeys.py +0 -0
- {scythe_ttp-0.12.4 → scythe_ttp-0.13.0}/tests/test_orchestrators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scythe-ttp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: An extensible framework for emulating attacker TTPs with Selenium.
|
|
5
5
|
Home-page: https://github.com/EpykLab/scythe
|
|
6
6
|
Author: EpykLab
|
|
@@ -23,6 +23,8 @@ Requires-Dist: h11==0.16.0
|
|
|
23
23
|
Requires-Dist: idna==3.10
|
|
24
24
|
Requires-Dist: outcome==1.3.0.post0
|
|
25
25
|
Requires-Dist: PySocks==1.7.1
|
|
26
|
+
Requires-Dist: pydantic==2.7.1
|
|
27
|
+
Requires-Dist: pydantic-core==2.18.2
|
|
26
28
|
Requires-Dist: requests==2.32.4
|
|
27
29
|
Requires-Dist: selenium==4.34.0
|
|
28
30
|
Requires-Dist: setuptools==80.9.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.14.0
|
|
@@ -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
|
]
|
|
@@ -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
|
|
@@ -7,7 +7,7 @@ or other custom actions like navigation, form filling, etc.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from .base import Journey, Step, Action
|
|
10
|
-
from .actions import NavigateAction, ClickAction, FillFormAction, WaitAction, TTPAction
|
|
10
|
+
from .actions import NavigateAction, ClickAction, FillFormAction, WaitAction, TTPAction, ApiRequestAction
|
|
11
11
|
from .executor import JourneyExecutor
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
@@ -19,5 +19,6 @@ __all__ = [
|
|
|
19
19
|
'FillFormAction',
|
|
20
20
|
'WaitAction',
|
|
21
21
|
'TTPAction',
|
|
22
|
+
'ApiRequestAction',
|
|
22
23
|
'JourneyExecutor'
|
|
23
24
|
]
|
|
@@ -5,6 +5,7 @@ from selenium.webdriver.common.by import By
|
|
|
5
5
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
6
6
|
from selenium.webdriver.support import expected_conditions as EC
|
|
7
7
|
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
|
8
|
+
import requests
|
|
8
9
|
|
|
9
10
|
from .base import Action
|
|
10
11
|
from ..core.ttp import TTP
|
|
@@ -643,4 +644,126 @@ class AssertAction(Action):
|
|
|
643
644
|
by_method = selector_map.get(self.selector_type)
|
|
644
645
|
if by_method is None:
|
|
645
646
|
raise ValueError(f"Unsupported selector type: {self.selector_type}")
|
|
646
|
-
return by_method
|
|
647
|
+
return by_method
|
|
648
|
+
|
|
649
|
+
class ApiRequestAction(Action):
|
|
650
|
+
"""Action to perform a REST API request in Journey API mode.
|
|
651
|
+
|
|
652
|
+
This action ignores the WebDriver and uses a requests.Session provided
|
|
653
|
+
in the journey context under the key 'requests_session'. It merges any
|
|
654
|
+
'auth_headers' from the context into the request headers.
|
|
655
|
+
Optionally, it can validate and parse the JSON response using a Pydantic
|
|
656
|
+
model class when provided.
|
|
657
|
+
"""
|
|
658
|
+
def __init__(self,
|
|
659
|
+
method: str,
|
|
660
|
+
url: str,
|
|
661
|
+
params: Optional[Dict[str, Any]] = None,
|
|
662
|
+
body_json: Optional[Dict[str, Any]] = None,
|
|
663
|
+
data: Optional[Dict[str, Any]] = None,
|
|
664
|
+
headers: Optional[Dict[str, str]] = None,
|
|
665
|
+
expected_status: Optional[int] = 200,
|
|
666
|
+
timeout: float = 10.0,
|
|
667
|
+
name: Optional[str] = None,
|
|
668
|
+
description: Optional[str] = None,
|
|
669
|
+
expected_result: bool = True,
|
|
670
|
+
response_model: Optional[Any] = None,
|
|
671
|
+
response_model_context_key: Optional[str] = None,
|
|
672
|
+
fail_on_validation_error: bool = False):
|
|
673
|
+
self.method = method.upper()
|
|
674
|
+
self.url = url
|
|
675
|
+
self.params = params or {}
|
|
676
|
+
self.body_json = body_json
|
|
677
|
+
self.data = data
|
|
678
|
+
self.headers = headers or {}
|
|
679
|
+
self.expected_status = expected_status
|
|
680
|
+
self.timeout = timeout
|
|
681
|
+
self.response_model = response_model
|
|
682
|
+
self.response_model_context_key = response_model_context_key
|
|
683
|
+
self.fail_on_validation_error = fail_on_validation_error
|
|
684
|
+
name = name or f"API {self.method} {url}"
|
|
685
|
+
description = description or f"Perform {self.method} request to {url}"
|
|
686
|
+
super().__init__(name, description, expected_result)
|
|
687
|
+
|
|
688
|
+
def execute(self, driver: WebDriver, context: Dict[str, Any]) -> bool:
|
|
689
|
+
# Resolve session
|
|
690
|
+
session = context.get('requests_session')
|
|
691
|
+
if session is None:
|
|
692
|
+
session = requests.Session()
|
|
693
|
+
context['requests_session'] = session
|
|
694
|
+
|
|
695
|
+
# Build headers: auth headers from context + action headers (action overrides)
|
|
696
|
+
final_headers = {}
|
|
697
|
+
auth_headers = context.get('auth_headers', {}) or {}
|
|
698
|
+
if auth_headers:
|
|
699
|
+
final_headers.update(auth_headers)
|
|
700
|
+
if self.headers:
|
|
701
|
+
final_headers.update(self.headers)
|
|
702
|
+
|
|
703
|
+
# Resolve URL: absolute or join with target_url from context
|
|
704
|
+
from urllib.parse import urljoin
|
|
705
|
+
base_url = context.get('target_url') or ''
|
|
706
|
+
resolved_url = self.url if self.url.lower().startswith('http') else urljoin(base_url, self.url)
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
response = session.request(
|
|
710
|
+
self.method,
|
|
711
|
+
resolved_url,
|
|
712
|
+
params=self.params or None,
|
|
713
|
+
json=self.body_json,
|
|
714
|
+
data=self.data,
|
|
715
|
+
headers=final_headers or None,
|
|
716
|
+
timeout=self.timeout,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# Store details
|
|
720
|
+
self.store_result('url', resolved_url)
|
|
721
|
+
self.store_result('request_headers', final_headers)
|
|
722
|
+
self.store_result('status_code', getattr(response, 'status_code', None))
|
|
723
|
+
response_headers = dict(getattr(response, 'headers', {}) or {})
|
|
724
|
+
self.store_result('response_headers', response_headers)
|
|
725
|
+
# Publish to context for downstream version extraction
|
|
726
|
+
context['last_response_headers'] = response_headers
|
|
727
|
+
context['last_response_url'] = resolved_url
|
|
728
|
+
# Try JSON, fallback to text
|
|
729
|
+
body = None
|
|
730
|
+
parsed_model = None
|
|
731
|
+
validation_error = None
|
|
732
|
+
try:
|
|
733
|
+
body = response.json()
|
|
734
|
+
self.store_result('response_json', body)
|
|
735
|
+
# If a response_model is provided, attempt validation/parsing
|
|
736
|
+
if self.response_model is not None:
|
|
737
|
+
try:
|
|
738
|
+
# Pydantic v2 preferred: model_validate
|
|
739
|
+
if hasattr(self.response_model, 'model_validate'):
|
|
740
|
+
parsed_model = self.response_model.model_validate(body)
|
|
741
|
+
else:
|
|
742
|
+
# Pydantic v1 fallback
|
|
743
|
+
parsed_model = self.response_model.parse_obj(body)
|
|
744
|
+
self.store_result('response_model_instance', parsed_model)
|
|
745
|
+
# Save into context for downstream actions
|
|
746
|
+
key = self.response_model_context_key or 'last_response_model'
|
|
747
|
+
context[key] = parsed_model
|
|
748
|
+
except Exception as ve:
|
|
749
|
+
validation_error = str(ve)
|
|
750
|
+
self.store_result('response_validation_error', validation_error)
|
|
751
|
+
except Exception:
|
|
752
|
+
text = getattr(response, 'text', '')
|
|
753
|
+
# Limit stored text to keep logs light
|
|
754
|
+
self.store_result('response_text', text if text is None or len(text) <= 2000 else text[:2000])
|
|
755
|
+
|
|
756
|
+
# Determine success (status-based by default)
|
|
757
|
+
if self.expected_status is not None:
|
|
758
|
+
http_ok = (getattr(response, 'status_code', None) == self.expected_status)
|
|
759
|
+
else:
|
|
760
|
+
http_ok = bool(getattr(response, 'ok', False))
|
|
761
|
+
|
|
762
|
+
# Optionally fail on validation error
|
|
763
|
+
if self.response_model is not None and self.fail_on_validation_error and self.get_result('response_validation_error'):
|
|
764
|
+
return False if http_ok else False
|
|
765
|
+
|
|
766
|
+
return http_ok
|
|
767
|
+
except Exception as e:
|
|
768
|
+
self.store_result('error', str(e))
|
|
769
|
+
return False
|
|
@@ -311,12 +311,45 @@ class Journey:
|
|
|
311
311
|
if self.requires_authentication():
|
|
312
312
|
auth_name = self.authentication.name if self.authentication else "Unknown"
|
|
313
313
|
logger.info(f"Authentication required: {auth_name}")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
314
|
+
if driver is None:
|
|
315
|
+
# API mode: use header-based authentication if available
|
|
316
|
+
headers = {}
|
|
317
|
+
try:
|
|
318
|
+
if self.authentication and hasattr(self.authentication, 'get_auth_headers'):
|
|
319
|
+
headers = self.authentication.get_auth_headers() or {}
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(f"Failed to get authentication headers: {e}")
|
|
322
|
+
headers = {}
|
|
323
|
+
cookies = {}
|
|
324
|
+
if headers:
|
|
325
|
+
# Merge into existing context headers
|
|
326
|
+
existing = self.get_context('auth_headers', {})
|
|
327
|
+
merged = {**existing, **headers}
|
|
328
|
+
self.set_context('auth_headers', merged)
|
|
329
|
+
logger.info("Authentication headers prepared for API mode")
|
|
330
|
+
# Try to merge cookies as well (hybrid auth)
|
|
331
|
+
try:
|
|
332
|
+
if self.authentication and hasattr(self.authentication, 'get_auth_cookies'):
|
|
333
|
+
cookies = self.authentication.get_auth_cookies() or {}
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Failed to get authentication cookies: {e}")
|
|
336
|
+
cookies = {}
|
|
337
|
+
if cookies:
|
|
338
|
+
existing_cookies = self.get_context('auth_cookies', {})
|
|
339
|
+
merged_cookies = {**existing_cookies, **cookies}
|
|
340
|
+
self.set_context('auth_cookies', merged_cookies)
|
|
341
|
+
logger.info("Authentication cookies prepared for API mode")
|
|
342
|
+
if not headers and not cookies:
|
|
343
|
+
logger.error("Authentication required but no headers/cookies available in API mode")
|
|
344
|
+
results['errors'].append("Authentication failed (no API auth data)")
|
|
345
|
+
return results
|
|
346
|
+
else:
|
|
347
|
+
auth_success = self.authenticate(driver, target_url)
|
|
348
|
+
if not auth_success:
|
|
349
|
+
logger.error("Authentication failed - aborting journey")
|
|
350
|
+
results['errors'].append("Authentication failed")
|
|
351
|
+
return results
|
|
352
|
+
logger.info("Authentication successful")
|
|
320
353
|
|
|
321
354
|
# Execute each step
|
|
322
355
|
for i, step in enumerate(self.steps, 1):
|
|
@@ -342,7 +375,7 @@ class Journey:
|
|
|
342
375
|
results['actions_failed'] += 1
|
|
343
376
|
|
|
344
377
|
# Extract target version header after step execution
|
|
345
|
-
target_version = header_extractor.
|
|
378
|
+
target_version = header_extractor.extract_target_version_hybrid(driver, target_url)
|
|
346
379
|
if target_version:
|
|
347
380
|
results['target_versions'].append(target_version)
|
|
348
381
|
logger.info(f"Target version detected: {target_version}")
|
|
@@ -3,6 +3,7 @@ import logging
|
|
|
3
3
|
from selenium import webdriver
|
|
4
4
|
from selenium.webdriver.chrome.options import Options
|
|
5
5
|
from typing import Optional, Dict, Any, List
|
|
6
|
+
import requests
|
|
6
7
|
from ..behaviors.base import Behavior
|
|
7
8
|
from .base import Journey
|
|
8
9
|
from ..core.headers import HeaderExtractor
|
|
@@ -24,6 +25,10 @@ class JourneyExecutor:
|
|
|
24
25
|
|
|
25
26
|
Similar to TTPExecutor but designed for complex multi-step scenarios
|
|
26
27
|
involving journeys composed of steps and actions.
|
|
28
|
+
|
|
29
|
+
Supports two interaction modes:
|
|
30
|
+
- UI: browser-driven via Selenium (default, backward-compatible)
|
|
31
|
+
- API: REST-driven via requests without starting a browser
|
|
27
32
|
"""
|
|
28
33
|
|
|
29
34
|
def __init__(self,
|
|
@@ -31,7 +36,8 @@ class JourneyExecutor:
|
|
|
31
36
|
target_url: str,
|
|
32
37
|
headless: bool = True,
|
|
33
38
|
behavior: Optional[Behavior] = None,
|
|
34
|
-
driver_options: Optional[Dict[str, Any]] = None
|
|
39
|
+
driver_options: Optional[Dict[str, Any]] = None,
|
|
40
|
+
mode: str = "UI"):
|
|
35
41
|
"""
|
|
36
42
|
Initialize the Journey executor.
|
|
37
43
|
|
|
@@ -45,6 +51,7 @@ class JourneyExecutor:
|
|
|
45
51
|
self.journey = journey
|
|
46
52
|
self.target_url = target_url
|
|
47
53
|
self.behavior = behavior
|
|
54
|
+
self.mode = (mode or "UI").upper()
|
|
48
55
|
self.logger = logging.getLogger(f"Journey.{self.journey.name}")
|
|
49
56
|
|
|
50
57
|
# Setup Chrome options
|
|
@@ -108,29 +115,62 @@ class JourneyExecutor:
|
|
|
108
115
|
self.logger.info(f"Using behavior: {self.behavior.name}")
|
|
109
116
|
self.logger.info(f"Behavior description: {self.behavior.description}")
|
|
110
117
|
|
|
111
|
-
self._setup_driver()
|
|
112
|
-
|
|
113
118
|
try:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
if self.mode == 'API':
|
|
120
|
+
# API mode: no WebDriver, prepare requests session and context
|
|
121
|
+
session = requests.Session()
|
|
122
|
+
auth_headers = {}
|
|
123
|
+
auth_cookies = {}
|
|
124
|
+
if getattr(self.journey, 'authentication', None):
|
|
125
|
+
# Try to obtain headers/cookies directly (no browser flow)
|
|
126
|
+
try:
|
|
127
|
+
auth_headers = self.journey.authentication.get_auth_headers() or {}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self.logger.warning(f"Failed to get auth headers from authentication: {e}")
|
|
130
|
+
try:
|
|
131
|
+
if hasattr(self.journey.authentication, 'get_auth_cookies'):
|
|
132
|
+
auth_cookies = self.journey.authentication.get_auth_cookies() or {}
|
|
133
|
+
except Exception as e:
|
|
134
|
+
self.logger.warning(f"Failed to get auth cookies from authentication: {e}")
|
|
135
|
+
if auth_headers:
|
|
136
|
+
session.headers.update(auth_headers)
|
|
137
|
+
if auth_cookies:
|
|
138
|
+
for ck, cv in auth_cookies.items():
|
|
139
|
+
try:
|
|
140
|
+
session.cookies.set(ck, cv)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Seed journey context for API actions
|
|
145
|
+
self.journey.set_context('mode', 'API')
|
|
146
|
+
self.journey.set_context('requests_session', session)
|
|
147
|
+
self.journey.set_context('auth_headers', auth_headers)
|
|
148
|
+
self.journey.set_context('auth_cookies', auth_cookies)
|
|
149
|
+
|
|
150
|
+
# Execute journey with a None driver (API actions ignore driver)
|
|
151
|
+
self.execution_results = self.journey.execute(None, self.target_url)
|
|
121
152
|
else:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
# UI mode (default)
|
|
154
|
+
self._setup_driver()
|
|
155
|
+
|
|
156
|
+
# Pre-execution behavior setup
|
|
157
|
+
if self.behavior and self.driver:
|
|
158
|
+
self.behavior.pre_execution(self.driver, self.target_url)
|
|
159
|
+
|
|
160
|
+
# Execute the journey
|
|
161
|
+
if self.driver:
|
|
162
|
+
self.execution_results = self.journey.execute(self.driver, self.target_url)
|
|
163
|
+
else:
|
|
164
|
+
raise RuntimeError("WebDriver not initialized")
|
|
165
|
+
|
|
166
|
+
# Apply behavior timing between steps if configured
|
|
167
|
+
if self.behavior:
|
|
168
|
+
self._apply_behavior_to_journey()
|
|
169
|
+
|
|
170
|
+
# Post-execution behavior cleanup
|
|
171
|
+
if self.behavior and self.driver:
|
|
172
|
+
behavior_results = self._convert_results_for_behavior()
|
|
173
|
+
self.behavior.post_execution(self.driver, behavior_results)
|
|
134
174
|
|
|
135
175
|
except KeyboardInterrupt:
|
|
136
176
|
self.logger.info("Journey interrupted by user.")
|
|
@@ -143,6 +183,7 @@ class JourneyExecutor:
|
|
|
143
183
|
self.execution_results = self._create_error_results(str(e))
|
|
144
184
|
|
|
145
185
|
finally:
|
|
186
|
+
# Cleanup and print summary (driver quit only if initialized)
|
|
146
187
|
self._cleanup()
|
|
147
188
|
|
|
148
189
|
return self.execution_results
|
|
@@ -5,6 +5,7 @@ from ...payloads.generators import PayloadGenerator
|
|
|
5
5
|
import requests
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
|
+
|
|
8
9
|
class GuessUUIDInURL(TTP):
|
|
9
10
|
def __init__(self,
|
|
10
11
|
target_url: str,
|
|
@@ -18,10 +19,10 @@ class GuessUUIDInURL(TTP):
|
|
|
18
19
|
description="simulate bruteforcing UUID's in the URL path",
|
|
19
20
|
expected_result=expected_result,
|
|
20
21
|
authentication=authentication)
|
|
21
|
-
|
|
22
|
+
|
|
22
23
|
self.target_url = target_url
|
|
23
24
|
self.uri_root_path = uri_root_path
|
|
24
|
-
self.payload_generator = payload_generator
|
|
25
|
+
self.payload_generator = payload_generator
|
|
25
26
|
|
|
26
27
|
def get_payloads(self):
|
|
27
28
|
yield from self.payload_generator()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scythe-ttp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: An extensible framework for emulating attacker TTPs with Selenium.
|
|
5
5
|
Home-page: https://github.com/EpykLab/scythe
|
|
6
6
|
Author: EpykLab
|
|
@@ -23,6 +23,8 @@ Requires-Dist: h11==0.16.0
|
|
|
23
23
|
Requires-Dist: idna==3.10
|
|
24
24
|
Requires-Dist: outcome==1.3.0.post0
|
|
25
25
|
Requires-Dist: PySocks==1.7.1
|
|
26
|
+
Requires-Dist: pydantic==2.7.1
|
|
27
|
+
Requires-Dist: pydantic-core==2.18.2
|
|
26
28
|
Requires-Dist: requests==2.32.4
|
|
27
29
|
Requires-Dist: selenium==4.34.0
|
|
28
30
|
Requires-Dist: setuptools==80.9.0
|
|
@@ -9,6 +9,7 @@ scythe/auth/__init__.py
|
|
|
9
9
|
scythe/auth/base.py
|
|
10
10
|
scythe/auth/basic.py
|
|
11
11
|
scythe/auth/bearer.py
|
|
12
|
+
scythe/auth/cookie_jwt.py
|
|
12
13
|
scythe/behaviors/__init__.py
|
|
13
14
|
scythe/behaviors/base.py
|
|
14
15
|
scythe/behaviors/default.py
|
|
@@ -40,8 +41,10 @@ scythe_ttp.egg-info/SOURCES.txt
|
|
|
40
41
|
scythe_ttp.egg-info/dependency_links.txt
|
|
41
42
|
scythe_ttp.egg-info/requires.txt
|
|
42
43
|
scythe_ttp.egg-info/top_level.txt
|
|
44
|
+
tests/test_api_models.py
|
|
43
45
|
tests/test_authentication.py
|
|
44
46
|
tests/test_behaviors.py
|
|
47
|
+
tests/test_cookie_jwt_auth.py
|
|
45
48
|
tests/test_expected_results.py
|
|
46
49
|
tests/test_feature_completeness.py
|
|
47
50
|
tests/test_header_extraction.py
|
|
@@ -8,7 +8,7 @@ with open("./requirements.txt", "r", encoding="utf-8") as f:
|
|
|
8
8
|
|
|
9
9
|
setuptools.setup(
|
|
10
10
|
name="scythe-ttp",
|
|
11
|
-
version="0.
|
|
11
|
+
version="0.13.0",
|
|
12
12
|
author="EpykLab",
|
|
13
13
|
author_email="cyber@epyklab.com",
|
|
14
14
|
description="An extensible framework for emulating attacker TTPs with Selenium.",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from scythe.journeys.actions import ApiRequestAction
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _FakeResponse:
|
|
8
|
+
def __init__(self, status_code: int = 200, headers: Optional[Dict[str, str]] = None, json_body: Optional[Dict[str, Any]] = None, text: str = ""):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.headers = headers or {}
|
|
11
|
+
self._json = json_body
|
|
12
|
+
self.text = text
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def ok(self) -> bool:
|
|
16
|
+
return 200 <= self.status_code < 300
|
|
17
|
+
|
|
18
|
+
def json(self) -> Dict[str, Any]:
|
|
19
|
+
if self._json is None:
|
|
20
|
+
raise ValueError("No JSON body")
|
|
21
|
+
return self._json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeSession:
|
|
25
|
+
def __init__(self, response: _FakeResponse):
|
|
26
|
+
self._response = response
|
|
27
|
+
self.headers: Dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
def request(self, method, url, params=None, json=None, data=None, headers=None, timeout=None):
|
|
30
|
+
# emulate minimal requests.Session.request
|
|
31
|
+
return self._response
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Fake Pydantic-like models to avoid external imports
|
|
35
|
+
class FakeModelV2:
|
|
36
|
+
def __init__(self, status: str, version: Optional[str] = None):
|
|
37
|
+
self.status = status
|
|
38
|
+
self.version = version
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def model_validate(cls, data: Dict[str, Any]) -> "FakeModelV2":
|
|
42
|
+
# Minimal validation: require 'status'
|
|
43
|
+
if not isinstance(data, dict) or "status" not in data or not isinstance(data["status"], str):
|
|
44
|
+
raise ValueError("Invalid data for FakeModelV2: missing 'status' as str")
|
|
45
|
+
version = data.get("version")
|
|
46
|
+
if version is not None and not isinstance(version, str):
|
|
47
|
+
raise ValueError("Invalid 'version' type")
|
|
48
|
+
return cls(status=data["status"], version=version)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FakeModelV1:
|
|
52
|
+
def __init__(self, status: str):
|
|
53
|
+
self.status = status
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def parse_obj(cls, data: Dict[str, Any]) -> "FakeModelV1":
|
|
57
|
+
if not isinstance(data, dict) or "status" not in data or not isinstance(data["status"], str):
|
|
58
|
+
raise ValueError("Invalid data for FakeModelV1: missing 'status' as str")
|
|
59
|
+
return cls(status=data["status"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestApiRequestActionModels(unittest.TestCase):
|
|
63
|
+
def test_valid_json_parses_into_model_v2(self):
|
|
64
|
+
fake_resp = _FakeResponse(
|
|
65
|
+
status_code=200,
|
|
66
|
+
headers={"Content-Type": "application/json"},
|
|
67
|
+
json_body={"status": "ok", "version": "1.2.3"},
|
|
68
|
+
)
|
|
69
|
+
session = _FakeSession(fake_resp)
|
|
70
|
+
|
|
71
|
+
action = ApiRequestAction(
|
|
72
|
+
method="GET",
|
|
73
|
+
url="/api/health",
|
|
74
|
+
expected_status=200,
|
|
75
|
+
response_model=FakeModelV2,
|
|
76
|
+
response_model_context_key="health_model",
|
|
77
|
+
)
|
|
78
|
+
context: Dict[str, Any] = {
|
|
79
|
+
"target_url": "http://localhost:8080",
|
|
80
|
+
"requests_session": session,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
result = action.execute(driver=None, context=context)
|
|
84
|
+
|
|
85
|
+
self.assertTrue(result)
|
|
86
|
+
# Model instance stored
|
|
87
|
+
model_instance = action.get_result("response_model_instance")
|
|
88
|
+
self.assertIsNotNone(model_instance)
|
|
89
|
+
self.assertIsInstance(model_instance, FakeModelV2)
|
|
90
|
+
self.assertEqual(model_instance.status, "ok")
|
|
91
|
+
# Context updated with model
|
|
92
|
+
self.assertIn("health_model", context)
|
|
93
|
+
self.assertIsInstance(context["health_model"], FakeModelV2)
|
|
94
|
+
# No validation error recorded
|
|
95
|
+
self.assertIsNone(action.get_result("response_validation_error"))
|
|
96
|
+
|
|
97
|
+
def test_invalid_json_records_error_and_can_fail_v1(self):
|
|
98
|
+
fake_resp = _FakeResponse(
|
|
99
|
+
status_code=200,
|
|
100
|
+
headers={"Content-Type": "application/json"},
|
|
101
|
+
json_body={"wrong": "shape"},
|
|
102
|
+
)
|
|
103
|
+
session = _FakeSession(fake_resp)
|
|
104
|
+
|
|
105
|
+
action = ApiRequestAction(
|
|
106
|
+
method="GET",
|
|
107
|
+
url="/api/health",
|
|
108
|
+
expected_status=200,
|
|
109
|
+
response_model=FakeModelV1, # triggers parse_obj path
|
|
110
|
+
fail_on_validation_error=True,
|
|
111
|
+
)
|
|
112
|
+
context: Dict[str, Any] = {
|
|
113
|
+
"target_url": "http://localhost:8080",
|
|
114
|
+
"requests_session": session,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result = action.execute(driver=None, context=context)
|
|
118
|
+
|
|
119
|
+
# HTTP status would normally be success, but validation error should force failure
|
|
120
|
+
self.assertFalse(result)
|
|
121
|
+
self.assertIsNone(action.get_result("response_model_instance"))
|
|
122
|
+
self.assertIsNotNone(action.get_result("response_validation_error"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
|
|
7
|
+
# Provide minimal shims if dependencies are not installed
|
|
8
|
+
# requests shim
|
|
9
|
+
if 'requests' not in sys.modules:
|
|
10
|
+
requests_mod = types.ModuleType('requests')
|
|
11
|
+
class _Session:
|
|
12
|
+
pass
|
|
13
|
+
requests_mod.Session = _Session
|
|
14
|
+
sys.modules['requests'] = requests_mod
|
|
15
|
+
|
|
16
|
+
# Provide a minimal selenium shim if selenium is not installed
|
|
17
|
+
if 'selenium' not in sys.modules:
|
|
18
|
+
selenium_mod = types.ModuleType('selenium')
|
|
19
|
+
webdriver_mod = types.ModuleType('selenium.webdriver')
|
|
20
|
+
chrome_mod = types.ModuleType('selenium.webdriver.chrome')
|
|
21
|
+
options_mod = types.ModuleType('selenium.webdriver.chrome.options')
|
|
22
|
+
remote_mod = types.ModuleType('selenium.webdriver.remote')
|
|
23
|
+
remote_webdriver_mod = types.ModuleType('selenium.webdriver.remote.webdriver')
|
|
24
|
+
common_mod = types.ModuleType('selenium.webdriver.common')
|
|
25
|
+
common_by_mod = types.ModuleType('selenium.webdriver.common.by')
|
|
26
|
+
selenium_common_mod = types.ModuleType('selenium.common')
|
|
27
|
+
selenium_common_ex_mod = types.ModuleType('selenium.common.exceptions')
|
|
28
|
+
support_mod = types.ModuleType('selenium.webdriver.support')
|
|
29
|
+
support_ui_mod = types.ModuleType('selenium.webdriver.support.ui')
|
|
30
|
+
support_ec_mod = types.ModuleType('selenium.webdriver.support.expected_conditions')
|
|
31
|
+
|
|
32
|
+
class _Options: # placeholder Options
|
|
33
|
+
def __init__(self):
|
|
34
|
+
pass
|
|
35
|
+
options_mod.Options = _Options
|
|
36
|
+
|
|
37
|
+
class _WebDriver: # minimal placeholder to satisfy type import
|
|
38
|
+
pass
|
|
39
|
+
remote_webdriver_mod.WebDriver = _WebDriver
|
|
40
|
+
|
|
41
|
+
class _By:
|
|
42
|
+
ID = 'id'
|
|
43
|
+
XPATH = 'xpath'
|
|
44
|
+
CSS_SELECTOR = 'css selector'
|
|
45
|
+
NAME = 'name'
|
|
46
|
+
CLASS_NAME = 'class name'
|
|
47
|
+
TAG_NAME = 'tag name'
|
|
48
|
+
common_by_mod.By = _By
|
|
49
|
+
|
|
50
|
+
class _NoSuchElementException(Exception):
|
|
51
|
+
pass
|
|
52
|
+
class _TimeoutException(Exception):
|
|
53
|
+
pass
|
|
54
|
+
selenium_common_ex_mod.NoSuchElementException = _NoSuchElementException
|
|
55
|
+
selenium_common_ex_mod.TimeoutException = _TimeoutException
|
|
56
|
+
|
|
57
|
+
# Dummy expected conditions and WebDriverWait
|
|
58
|
+
class _EC:
|
|
59
|
+
@staticmethod
|
|
60
|
+
def element_to_be_clickable(locator):
|
|
61
|
+
return True
|
|
62
|
+
support_ec_mod = types.ModuleType('selenium.webdriver.support.expected_conditions')
|
|
63
|
+
support_ec_mod.element_to_be_clickable = _EC.element_to_be_clickable
|
|
64
|
+
|
|
65
|
+
class _WebDriverWait:
|
|
66
|
+
def __init__(self, driver, timeout):
|
|
67
|
+
pass
|
|
68
|
+
def until(self, method):
|
|
69
|
+
return Mock()
|
|
70
|
+
support_ui_mod = types.ModuleType('selenium.webdriver.support.ui')
|
|
71
|
+
support_ui_mod.WebDriverWait = _WebDriverWait
|
|
72
|
+
support_mod = types.ModuleType('selenium.webdriver.support')
|
|
73
|
+
|
|
74
|
+
sys.modules['selenium'] = selenium_mod
|
|
75
|
+
sys.modules['selenium.webdriver'] = webdriver_mod
|
|
76
|
+
sys.modules['selenium.webdriver.chrome'] = chrome_mod
|
|
77
|
+
sys.modules['selenium.webdriver.chrome.options'] = options_mod
|
|
78
|
+
sys.modules['selenium.webdriver.remote'] = remote_mod
|
|
79
|
+
sys.modules['selenium.webdriver.remote.webdriver'] = remote_webdriver_mod
|
|
80
|
+
sys.modules['selenium.webdriver.common'] = common_mod
|
|
81
|
+
sys.modules['selenium.webdriver.common.by'] = common_by_mod
|
|
82
|
+
sys.modules['selenium.webdriver.support'] = support_mod
|
|
83
|
+
sys.modules['selenium.webdriver.support.ui'] = support_ui_mod
|
|
84
|
+
sys.modules['selenium.webdriver.support.expected_conditions'] = support_ec_mod
|
|
85
|
+
sys.modules['selenium.common'] = selenium_common_mod
|
|
86
|
+
sys.modules['selenium.common.exceptions'] = selenium_common_ex_mod
|
|
87
|
+
|
|
88
|
+
from scythe.auth.cookie_jwt import CookieJWTAuth
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _FakeLoginResponse:
|
|
92
|
+
def __init__(self, data: Dict[str, Any], status_code: int = 200):
|
|
93
|
+
self._data = data
|
|
94
|
+
self.status_code = status_code
|
|
95
|
+
|
|
96
|
+
def raise_for_status(self):
|
|
97
|
+
if not (200 <= self.status_code < 300):
|
|
98
|
+
raise RuntimeError("HTTP Error")
|
|
99
|
+
|
|
100
|
+
def json(self):
|
|
101
|
+
return self._data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _FakeLoginSession:
|
|
105
|
+
def __init__(self, data: Dict[str, Any]):
|
|
106
|
+
self._data = data
|
|
107
|
+
|
|
108
|
+
def post(self, url, json=None, timeout=None):
|
|
109
|
+
return _FakeLoginResponse(self._data, 200)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _CookieJar:
|
|
113
|
+
def __init__(self):
|
|
114
|
+
self._cookies: Dict[str, str] = {}
|
|
115
|
+
|
|
116
|
+
def set(self, key: str, value: str):
|
|
117
|
+
self._cookies[key] = value
|
|
118
|
+
|
|
119
|
+
def get(self, key: str, default=None):
|
|
120
|
+
return self._cookies.get(key, default)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _FakeRequestsSession:
|
|
124
|
+
def __init__(self):
|
|
125
|
+
self.headers: Dict[str, str] = {}
|
|
126
|
+
self.cookies = _CookieJar()
|
|
127
|
+
self._last_request: Dict[str, Any] = {}
|
|
128
|
+
self._status: int = 200
|
|
129
|
+
self._text: str = "ok"
|
|
130
|
+
self._json: Optional[Dict[str, Any]] = None
|
|
131
|
+
|
|
132
|
+
class _Resp:
|
|
133
|
+
def __init__(self, status_code: int, headers: Dict[str, str], text: str, json_body: Optional[Dict[str, Any]]):
|
|
134
|
+
self.status_code = status_code
|
|
135
|
+
self.headers = headers
|
|
136
|
+
self.text = text
|
|
137
|
+
self._json = json_body
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def ok(self):
|
|
141
|
+
return 200 <= self.status_code < 300
|
|
142
|
+
|
|
143
|
+
def json(self):
|
|
144
|
+
if self._json is None:
|
|
145
|
+
raise ValueError("No JSON body")
|
|
146
|
+
return self._json
|
|
147
|
+
|
|
148
|
+
def request(self, method, url, params=None, json=None, data=None, headers=None, timeout=None):
|
|
149
|
+
# Record request for assertions
|
|
150
|
+
self._last_request = {
|
|
151
|
+
'method': method,
|
|
152
|
+
'url': url,
|
|
153
|
+
'headers': headers or {},
|
|
154
|
+
'params': params or {},
|
|
155
|
+
'json': json,
|
|
156
|
+
'data': data,
|
|
157
|
+
}
|
|
158
|
+
return self._Resp(self._status, {}, self._text, self._json)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestCookieJWTAuth(unittest.TestCase):
|
|
162
|
+
def test_get_auth_cookies_via_login(self):
|
|
163
|
+
fake_login = _FakeLoginSession({"auth": {"jwt": "ABC123"}})
|
|
164
|
+
auth = CookieJWTAuth(
|
|
165
|
+
login_url="http://api.example.com/login",
|
|
166
|
+
username="user@example.com",
|
|
167
|
+
password="secret",
|
|
168
|
+
username_field="email",
|
|
169
|
+
password_field="password",
|
|
170
|
+
jwt_json_path="auth.jwt",
|
|
171
|
+
cookie_name="stellarbridge",
|
|
172
|
+
session=fake_login,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
cookies = auth.get_auth_cookies()
|
|
176
|
+
self.assertEqual(cookies, {"stellarbridge": "ABC123"})
|
|
177
|
+
self.assertEqual(auth.token, "ABC123")
|
|
178
|
+
self.assertEqual(auth.get_auth_headers(), {})
|
|
179
|
+
|
|
180
|
+
def test_ui_auth_sets_browser_cookie(self):
|
|
181
|
+
fake_login = _FakeLoginSession({"token": "XYZ"})
|
|
182
|
+
auth = CookieJWTAuth(
|
|
183
|
+
login_url="http://api.example.com/login",
|
|
184
|
+
username="user@example.com",
|
|
185
|
+
password="secret",
|
|
186
|
+
jwt_json_path="token",
|
|
187
|
+
cookie_name="stellarbridge",
|
|
188
|
+
session=fake_login,
|
|
189
|
+
)
|
|
190
|
+
driver = Mock()
|
|
191
|
+
result = auth.authenticate(driver, target_url="http://app.example.com/protected")
|
|
192
|
+
self.assertTrue(result)
|
|
193
|
+
# add_cookie called with correct name and value
|
|
194
|
+
called_args = driver.add_cookie.call_args[0][0]
|
|
195
|
+
self.assertEqual(called_args["name"], "stellarbridge")
|
|
196
|
+
self.assertEqual(called_args["value"], "XYZ")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
unittest.main()
|
scythe_ttp-0.12.4/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.12.5
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|