scythe-ttp 0.16.1__py3-none-any.whl → 0.18.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.
- scythe/auth/cookie_jwt.py +43 -14
- scythe/cli/main.py +192 -9
- scythe/core/executor.py +143 -2
- scythe/core/ttp.py +61 -3
- scythe/journeys/actions.py +151 -69
- scythe/journeys/executor.py +15 -0
- scythe/orchestrators/base.py +18 -0
- scythe/ttps/web/__init__.py +12 -0
- scythe/ttps/web/login_bruteforce.py +138 -7
- scythe/ttps/web/request_flooding.py +503 -0
- scythe/ttps/web/sql_injection.py +232 -15
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/METADATA +2 -1
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/RECORD +17 -16
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/WHEEL +0 -0
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/entry_points.txt +0 -0
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/licenses/LICENSE +0 -0
- {scythe_ttp-0.16.1.dist-info → scythe_ttp-0.18.1.dist-info}/top_level.txt +0 -0
scythe/auth/cookie_jwt.py
CHANGED
|
@@ -49,10 +49,17 @@ class CookieJWTAuth(Authentication):
|
|
|
49
49
|
|
|
50
50
|
Behavior:
|
|
51
51
|
- In API mode: JourneyExecutor will call get_auth_cookies(); this class will
|
|
52
|
-
perform a POST to login_url (if token not cached),
|
|
53
|
-
|
|
52
|
+
perform a POST to login_url (if token not cached), extract the token, and
|
|
53
|
+
return {cookie_name: token}.
|
|
54
54
|
- In UI mode: authenticate() will ensure the browser has the cookie set for
|
|
55
55
|
the target domain.
|
|
56
|
+
|
|
57
|
+
Parameters:
|
|
58
|
+
- content_type: Either "json" (default) to send payload as JSON, or "form"
|
|
59
|
+
to send as application/x-www-form-urlencoded form data.
|
|
60
|
+
- jwt_source: Either "json" (default) to extract JWT from the JSON response body
|
|
61
|
+
using jwt_json_path, or "cookie" to extract it from the Set-Cookie response header
|
|
62
|
+
using cookie_name.
|
|
56
63
|
"""
|
|
57
64
|
|
|
58
65
|
def __init__(self,
|
|
@@ -64,6 +71,8 @@ class CookieJWTAuth(Authentication):
|
|
|
64
71
|
extra_fields: Optional[Dict[str, Any]] = None,
|
|
65
72
|
jwt_json_path: str = "token",
|
|
66
73
|
cookie_name: str = "stellarbridge",
|
|
74
|
+
content_type: str = "json",
|
|
75
|
+
jwt_source: str = "json",
|
|
67
76
|
session: Optional[requests.Session] = None,
|
|
68
77
|
description: str = "Authenticate via API and set JWT cookie"):
|
|
69
78
|
super().__init__(
|
|
@@ -78,29 +87,49 @@ class CookieJWTAuth(Authentication):
|
|
|
78
87
|
self.extra_fields = extra_fields or {}
|
|
79
88
|
self.jwt_json_path = jwt_json_path
|
|
80
89
|
self.cookie_name = cookie_name
|
|
90
|
+
self.content_type = content_type
|
|
91
|
+
self.jwt_source = jwt_source
|
|
81
92
|
# Avoid importing requests in test environments; allow injected session
|
|
82
93
|
self._session = session or (requests.Session() if requests is not None else None)
|
|
83
94
|
self.token: Optional[str] = None
|
|
84
95
|
|
|
85
96
|
def _login_and_get_token(self) -> str:
|
|
86
97
|
payload: Dict[str, Any] = dict(self.extra_fields)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if self.password is not None:
|
|
90
|
-
payload[self.password_field] = self.password
|
|
98
|
+
payload[self.username_field] = self.username
|
|
99
|
+
payload[self.password_field] = self.password
|
|
91
100
|
try:
|
|
92
|
-
|
|
101
|
+
if self.content_type == "form":
|
|
102
|
+
resp = self._session.post(self.login_url, data=payload, timeout=15)
|
|
103
|
+
else:
|
|
104
|
+
resp = self._session.post(self.login_url, json=payload, timeout=15)
|
|
93
105
|
# try json; raise on non-2xx to surface errors
|
|
94
106
|
resp.raise_for_status()
|
|
95
|
-
data = resp.json()
|
|
96
107
|
except Exception as e:
|
|
97
108
|
raise AuthenticationError(f"Login request failed: {e}", self.name)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
109
|
+
|
|
110
|
+
# Extract token from either response cookies or JSON body
|
|
111
|
+
token = None
|
|
112
|
+
if self.jwt_source == "cookie":
|
|
113
|
+
# Extract from response cookies
|
|
114
|
+
token = resp.cookies.get(self.cookie_name)
|
|
115
|
+
if not token or not isinstance(token, str):
|
|
116
|
+
raise AuthenticationError(
|
|
117
|
+
f"JWT cookie '{self.cookie_name}' not found in login response",
|
|
118
|
+
self.name,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
# Extract from JSON response body
|
|
122
|
+
try:
|
|
123
|
+
data = resp.json()
|
|
124
|
+
except Exception as e:
|
|
125
|
+
raise AuthenticationError(f"Failed to parse JSON response: {e}", self.name)
|
|
126
|
+
token = _extract_by_dot_path(data, self.jwt_json_path)
|
|
127
|
+
if not token or not isinstance(token, str):
|
|
128
|
+
raise AuthenticationError(
|
|
129
|
+
f"JWT not found at path '{self.jwt_json_path}' in login response",
|
|
130
|
+
self.name,
|
|
131
|
+
)
|
|
132
|
+
|
|
104
133
|
self.token = token
|
|
105
134
|
self.store_auth_data('jwt', token)
|
|
106
135
|
self.store_auth_data('login_time', time.time())
|
scythe/cli/main.py
CHANGED
|
@@ -65,10 +65,49 @@ def check_version_in_response_header(args) -> bool:
|
|
|
65
65
|
return False
|
|
66
66
|
return True
|
|
67
67
|
|
|
68
|
-
def scythe_test_definition(args) ->
|
|
68
|
+
def scythe_test_definition(args) -> int:
|
|
69
69
|
# TODO: implement your test using Scythe primitives.
|
|
70
70
|
# Example placeholder that simply passes.
|
|
71
|
-
|
|
71
|
+
|
|
72
|
+
# Example usage with TTPExecutor:
|
|
73
|
+
# from scythe.core.executor import TTPExecutor
|
|
74
|
+
# from scythe.ttps.web.login_bruteforce import LoginBruteforceTTP
|
|
75
|
+
#
|
|
76
|
+
# ttp = LoginBruteforceTTP(
|
|
77
|
+
# payloads=['admin', 'root', 'test'],
|
|
78
|
+
# expected_result=False # Expect security controls to block attempts
|
|
79
|
+
# )
|
|
80
|
+
# executor = TTPExecutor(ttp=ttp, target_url=args.url)
|
|
81
|
+
# executor.run()
|
|
82
|
+
# return executor.was_successful() # Returns True if all results matched expectations
|
|
83
|
+
|
|
84
|
+
# Example usage with JourneyExecutor:
|
|
85
|
+
# from scythe.journeys.executor import JourneyExecutor
|
|
86
|
+
# from scythe.journeys.base import Journey, Step
|
|
87
|
+
# from scythe.journeys.actions import NavigateAction, FillFormAction, ClickAction
|
|
88
|
+
#
|
|
89
|
+
# journey = Journey(
|
|
90
|
+
# name="Login Journey",
|
|
91
|
+
# description="Test user login flow",
|
|
92
|
+
# expected_result=True # Expect journey to succeed
|
|
93
|
+
# )
|
|
94
|
+
# journey.add_step(Step("Navigate").add_action(NavigateAction(url=args.url)))
|
|
95
|
+
# executor = JourneyExecutor(journey=journey, target_url=args.url)
|
|
96
|
+
# executor.run()
|
|
97
|
+
# return executor.was_successful() # Returns True if journey succeeded as expected
|
|
98
|
+
|
|
99
|
+
# Example usage with Orchestrators:
|
|
100
|
+
# from scythe.orchestrators.scale import ScaleOrchestrator
|
|
101
|
+
# from scythe.orchestrators.base import OrchestrationStrategy
|
|
102
|
+
#
|
|
103
|
+
# orchestrator = ScaleOrchestrator(
|
|
104
|
+
# strategy=OrchestrationStrategy.PARALLEL,
|
|
105
|
+
# max_workers=10
|
|
106
|
+
# )
|
|
107
|
+
# result = orchestrator.orchestrate_ttp(ttp=my_ttp, target_url=args.url, replications=100)
|
|
108
|
+
# return orchestrator.exit_code(result) == 0 # Returns True if all executions succeeded
|
|
109
|
+
|
|
110
|
+
return executor.exit_code() # assumes executor var
|
|
72
111
|
|
|
73
112
|
|
|
74
113
|
def main():
|
|
@@ -83,19 +122,151 @@ def main():
|
|
|
83
122
|
dest='gate_versions',
|
|
84
123
|
help='Gate versions to test against')
|
|
85
124
|
|
|
125
|
+
# Core Application Parameters
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
'--protocol',
|
|
128
|
+
default='https',
|
|
129
|
+
choices=['http', 'https'],
|
|
130
|
+
help='Protocol to use (http/https, default: https)')
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
'--port',
|
|
133
|
+
type=int,
|
|
134
|
+
help='Port number for the target application')
|
|
135
|
+
|
|
136
|
+
# Authentication Parameters
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
'--username',
|
|
139
|
+
help='Username for authentication')
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
'--password',
|
|
142
|
+
help='Password for authentication')
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
'--token',
|
|
145
|
+
help='Bearer token or API key')
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
'--auth-type',
|
|
148
|
+
choices=['basic', 'bearer', 'form'],
|
|
149
|
+
help='Authentication method (basic, bearer, form, etc.)')
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
'--credentials-file',
|
|
152
|
+
help='Path to file containing multiple user credentials')
|
|
153
|
+
|
|
154
|
+
# Test Data Parameters
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
'--users-file',
|
|
157
|
+
help='Path to CSV file containing user data')
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
'--emails-file',
|
|
160
|
+
help='Path to text file containing email addresses')
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
'--payload-file',
|
|
163
|
+
help='Path to file containing test payloads')
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
'--data-file',
|
|
166
|
+
help='Generic path to test data file')
|
|
167
|
+
|
|
168
|
+
# Execution Control Parameters
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
'--batch-size',
|
|
171
|
+
type=int,
|
|
172
|
+
default=10,
|
|
173
|
+
help='Number of operations per batch (default: 10)')
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
'--max-batches',
|
|
176
|
+
type=int,
|
|
177
|
+
help='Maximum number of batches to run')
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
'--workers',
|
|
180
|
+
type=int,
|
|
181
|
+
help='Number of concurrent workers/threads')
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
'--replications',
|
|
184
|
+
type=int,
|
|
185
|
+
help='Number of test replications for load testing')
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
'--timeout',
|
|
188
|
+
type=int,
|
|
189
|
+
help='Request timeout in seconds')
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
'--delay',
|
|
192
|
+
type=float,
|
|
193
|
+
help='Delay between requests in seconds')
|
|
194
|
+
|
|
195
|
+
# Browser/Execution Parameters
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
'--headless',
|
|
198
|
+
action='store_true',
|
|
199
|
+
help='Run browser in headless mode (flag)')
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
'--browser',
|
|
202
|
+
choices=['chrome', 'firefox', 'safari', 'edge'],
|
|
203
|
+
help='Browser type (chrome, firefox, etc.)')
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
'--user-agent',
|
|
206
|
+
help='Custom user agent string')
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
'--proxy',
|
|
209
|
+
help='Proxy server URL')
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
'--proxy-file',
|
|
212
|
+
help='Path to file containing proxy list')
|
|
213
|
+
|
|
214
|
+
# Output and Reporting Parameters
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
'--output-dir',
|
|
217
|
+
help='Directory for output files')
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
'--report-format',
|
|
220
|
+
choices=['json', 'csv', 'html'],
|
|
221
|
+
help='Report format (json, csv, html)')
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
'--log-level',
|
|
224
|
+
choices=['debug', 'info', 'warning', 'error'],
|
|
225
|
+
help='Logging level (debug, info, warning, error)')
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
'--verbose',
|
|
228
|
+
action='store_true',
|
|
229
|
+
help='Enable verbose output (flag)')
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
'--silent',
|
|
232
|
+
action='store_true',
|
|
233
|
+
help='Suppress output except errors (flag)')
|
|
234
|
+
|
|
235
|
+
# Test Control Parameters
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
'--fail-fast',
|
|
238
|
+
action='store_true',
|
|
239
|
+
help='Stop immediately on first failure (flag)')
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
'--dry-run',
|
|
242
|
+
action='store_true',
|
|
243
|
+
help='Validate configuration without executing tests (flag)')
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
'--test-type',
|
|
246
|
+
choices=['load', 'security', 'functional'],
|
|
247
|
+
help='Type of test to run (load, security, functional)')
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
'--iterations',
|
|
250
|
+
type=int,
|
|
251
|
+
help='Number of test iterations')
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
'--duration',
|
|
254
|
+
type=int,
|
|
255
|
+
help='Test duration in seconds')
|
|
256
|
+
|
|
86
257
|
args = parser.parse_args()
|
|
87
258
|
|
|
88
259
|
if check_url_available(args.url):
|
|
89
260
|
if args.gate_versions:
|
|
90
261
|
if check_version_in_response_header(args):
|
|
91
|
-
|
|
92
|
-
sys.exit(
|
|
262
|
+
exit_code = scythe_test_definition(args)
|
|
263
|
+
sys.exit(exit_code)
|
|
93
264
|
else:
|
|
94
265
|
print("No compatible version found in response header.")
|
|
95
266
|
sys.exit(1)
|
|
96
267
|
else:
|
|
97
|
-
|
|
98
|
-
sys.exit(
|
|
268
|
+
exit_code = scythe_test_definition(args)
|
|
269
|
+
sys.exit(exit_code)
|
|
99
270
|
else:
|
|
100
271
|
print("URL not available.")
|
|
101
272
|
sys.exit(1)
|
|
@@ -109,6 +280,13 @@ class ScytheCLIError(Exception):
|
|
|
109
280
|
pass
|
|
110
281
|
|
|
111
282
|
|
|
283
|
+
class ExitWithCode(Exception):
|
|
284
|
+
"""Exception to exit with a specific code from within Typer commands."""
|
|
285
|
+
def __init__(self, code: int):
|
|
286
|
+
self.code = code
|
|
287
|
+
super().__init__()
|
|
288
|
+
|
|
289
|
+
|
|
112
290
|
def _find_project_root(start: Optional[str] = None) -> Optional[str]:
|
|
113
291
|
"""Walk upwards from start (or cwd) to find a directory containing .scythe."""
|
|
114
292
|
cur = os.path.abspath(start or os.getcwd())
|
|
@@ -557,7 +735,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
557
735
|
code, output, version = _run_test(project_root, name, extra)
|
|
558
736
|
_record_run(project_root, name, code, output, version)
|
|
559
737
|
print(output)
|
|
560
|
-
|
|
738
|
+
# Raise exception to propagate exit code through Typer
|
|
739
|
+
if code != 0:
|
|
740
|
+
raise ExitWithCode(code)
|
|
741
|
+
return 0
|
|
561
742
|
|
|
562
743
|
db_app = typer.Typer(
|
|
563
744
|
no_args_is_help=True,
|
|
@@ -592,8 +773,10 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
592
773
|
app.add_typer(db_app, name="db")
|
|
593
774
|
|
|
594
775
|
try:
|
|
595
|
-
|
|
596
|
-
return
|
|
776
|
+
app()
|
|
777
|
+
return 0
|
|
778
|
+
except ExitWithCode as e:
|
|
779
|
+
return e.code
|
|
597
780
|
except ScytheCLIError as e:
|
|
598
781
|
print(f"Error: {e}", file=sys.stderr)
|
|
599
782
|
return 2
|
scythe/core/executor.py
CHANGED
|
@@ -3,9 +3,10 @@ import logging
|
|
|
3
3
|
from selenium import webdriver
|
|
4
4
|
from selenium.webdriver.chrome.options import Options
|
|
5
5
|
from .ttp import TTP
|
|
6
|
-
from typing import Optional
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
7
|
from ..behaviors.base import Behavior
|
|
8
8
|
from .headers import HeaderExtractor
|
|
9
|
+
import requests
|
|
9
10
|
|
|
10
11
|
# Configure logging
|
|
11
12
|
logging.basicConfig(
|
|
@@ -40,6 +41,7 @@ class TTPExecutor:
|
|
|
40
41
|
self.driver = None
|
|
41
42
|
self.results = []
|
|
42
43
|
self.header_extractor = HeaderExtractor()
|
|
44
|
+
self.has_test_failures = False # Track if any test had unexpected results
|
|
43
45
|
|
|
44
46
|
def _setup_driver(self):
|
|
45
47
|
"""Initializes the WebDriver."""
|
|
@@ -59,7 +61,18 @@ class TTPExecutor:
|
|
|
59
61
|
self.logger.info(f"Using behavior: {self.behavior.name}")
|
|
60
62
|
self.logger.info(f"Behavior description: {self.behavior.description}")
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
# Check execution mode
|
|
65
|
+
if self.ttp.execution_mode == 'api':
|
|
66
|
+
self.logger.info("Execution mode: API")
|
|
67
|
+
self._run_api_mode()
|
|
68
|
+
return
|
|
69
|
+
else:
|
|
70
|
+
self.logger.info("Execution mode: UI")
|
|
71
|
+
self._setup_driver()
|
|
72
|
+
self._run_ui_mode()
|
|
73
|
+
|
|
74
|
+
def _run_ui_mode(self):
|
|
75
|
+
"""Execute TTP in UI mode using Selenium."""
|
|
63
76
|
|
|
64
77
|
try:
|
|
65
78
|
# Handle authentication if required
|
|
@@ -134,10 +147,12 @@ class TTPExecutor:
|
|
|
134
147
|
else:
|
|
135
148
|
version_info = f" | Version: {target_version}" if target_version else ""
|
|
136
149
|
self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail){version_info}")
|
|
150
|
+
self.has_test_failures = True # Mark as failure when result differs from expected
|
|
137
151
|
else:
|
|
138
152
|
consecutive_failures += 1
|
|
139
153
|
if self.ttp.expected_result:
|
|
140
154
|
self.logger.info(f"EXPECTED FAILURE: '{payload}' (security control working)")
|
|
155
|
+
self.has_test_failures = True # Mark as failure when result differs from expected
|
|
141
156
|
else:
|
|
142
157
|
self.logger.info(f"EXPECTED FAILURE: '{payload}'")
|
|
143
158
|
|
|
@@ -169,6 +184,108 @@ class TTPExecutor:
|
|
|
169
184
|
finally:
|
|
170
185
|
self._cleanup()
|
|
171
186
|
|
|
187
|
+
def _run_api_mode(self):
|
|
188
|
+
"""Execute TTP in API mode using requests."""
|
|
189
|
+
session = requests.Session()
|
|
190
|
+
context: Dict[str, Any] = {
|
|
191
|
+
'target_url': self.target_url,
|
|
192
|
+
'auth_headers': {},
|
|
193
|
+
'rate_limit_resume_at': None
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
# Handle authentication if required (API mode)
|
|
198
|
+
if self.ttp.requires_authentication():
|
|
199
|
+
auth_name = self.ttp.authentication.name if self.ttp.authentication else "Unknown"
|
|
200
|
+
self.logger.info(f"Authentication required for TTP: {auth_name}")
|
|
201
|
+
|
|
202
|
+
# Try to get auth headers directly
|
|
203
|
+
try:
|
|
204
|
+
if hasattr(self.ttp.authentication, 'get_auth_headers'):
|
|
205
|
+
auth_headers = self.ttp.authentication.get_auth_headers() or {}
|
|
206
|
+
context['auth_headers'] = auth_headers
|
|
207
|
+
session.headers.update(auth_headers)
|
|
208
|
+
self.logger.info("Authentication headers applied")
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self.logger.warning(f"Failed to get auth headers: {e}")
|
|
211
|
+
|
|
212
|
+
consecutive_failures = 0
|
|
213
|
+
|
|
214
|
+
for i, payload in enumerate(self.ttp.get_payloads(), 1):
|
|
215
|
+
# Check if behavior wants to continue
|
|
216
|
+
if self.behavior and not self.behavior.should_continue(i, consecutive_failures):
|
|
217
|
+
self.logger.info("Behavior requested to stop execution")
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
self.logger.info(f"Attempt {i}: Executing with payload -> '{payload}'")
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
# Execute API request
|
|
224
|
+
response = self.ttp.execute_step_api(session, payload, context)
|
|
225
|
+
|
|
226
|
+
# Use behavior delay if available, otherwise use default
|
|
227
|
+
if self.behavior:
|
|
228
|
+
step_delay = self.behavior.get_step_delay(i)
|
|
229
|
+
else:
|
|
230
|
+
step_delay = self.delay
|
|
231
|
+
|
|
232
|
+
time.sleep(step_delay)
|
|
233
|
+
|
|
234
|
+
# Verify result
|
|
235
|
+
success = self.ttp.verify_result_api(response, context)
|
|
236
|
+
|
|
237
|
+
# Compare actual result with expected result
|
|
238
|
+
if success:
|
|
239
|
+
consecutive_failures = 0
|
|
240
|
+
|
|
241
|
+
# Extract target version from response headers
|
|
242
|
+
target_version = response.headers.get('X-SCYTHE-TARGET-VERSION') or response.headers.get('x-scythe-target-version')
|
|
243
|
+
|
|
244
|
+
result_entry = {
|
|
245
|
+
'payload': payload,
|
|
246
|
+
'url': response.url if hasattr(response, 'url') else self.target_url,
|
|
247
|
+
'expected': self.ttp.expected_result,
|
|
248
|
+
'actual': True,
|
|
249
|
+
'target_version': target_version
|
|
250
|
+
}
|
|
251
|
+
self.results.append(result_entry)
|
|
252
|
+
|
|
253
|
+
if self.ttp.expected_result:
|
|
254
|
+
version_info = f" | Version: {target_version}" if target_version else ""
|
|
255
|
+
self.logger.info(f"EXPECTED SUCCESS: '{payload}'{version_info}")
|
|
256
|
+
else:
|
|
257
|
+
version_info = f" | Version: {target_version}" if target_version else ""
|
|
258
|
+
self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail){version_info}")
|
|
259
|
+
self.has_test_failures = True
|
|
260
|
+
else:
|
|
261
|
+
consecutive_failures += 1
|
|
262
|
+
if self.ttp.expected_result:
|
|
263
|
+
self.logger.info(f"EXPECTED FAILURE: '{payload}' (security control working)")
|
|
264
|
+
self.has_test_failures = True
|
|
265
|
+
else:
|
|
266
|
+
self.logger.info(f"EXPECTED FAILURE: '{payload}'")
|
|
267
|
+
|
|
268
|
+
except Exception as step_error:
|
|
269
|
+
consecutive_failures += 1
|
|
270
|
+
self.logger.error(f"Error during step {i}: {step_error}")
|
|
271
|
+
|
|
272
|
+
# Let behavior handle the error
|
|
273
|
+
if self.behavior:
|
|
274
|
+
if not self.behavior.on_error(step_error, i):
|
|
275
|
+
self.logger.info("Behavior requested to stop due to error")
|
|
276
|
+
break
|
|
277
|
+
else:
|
|
278
|
+
# Default behavior: continue on most errors
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
self.logger.info("Test interrupted by user.")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
self.logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
|
285
|
+
finally:
|
|
286
|
+
session.close()
|
|
287
|
+
self._cleanup()
|
|
288
|
+
|
|
172
289
|
def _cleanup(self):
|
|
173
290
|
"""Closes the WebDriver and prints a summary."""
|
|
174
291
|
if self.driver:
|
|
@@ -212,3 +329,27 @@ class TTPExecutor:
|
|
|
212
329
|
self.logger.info("No successes detected (expected to find vulnerabilities).")
|
|
213
330
|
else:
|
|
214
331
|
self.logger.info("No successes detected (security controls working as expected).")
|
|
332
|
+
|
|
333
|
+
# Log overall test status
|
|
334
|
+
if self.has_test_failures:
|
|
335
|
+
self.logger.error("\n✗ TEST FAILED: One or more test results differed from expected")
|
|
336
|
+
else:
|
|
337
|
+
self.logger.info("\n✓ TEST PASSED: All test results matched expectations")
|
|
338
|
+
|
|
339
|
+
def was_successful(self) -> bool:
|
|
340
|
+
"""
|
|
341
|
+
Check if all test results matched expectations.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True if all test results matched expectations, False otherwise
|
|
345
|
+
"""
|
|
346
|
+
return not self.has_test_failures
|
|
347
|
+
|
|
348
|
+
def exit_code(self) -> int:
|
|
349
|
+
"""
|
|
350
|
+
Get the exit code for this test execution.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
0 if test was successful (results matched expectations), 1 otherwise
|
|
354
|
+
"""
|
|
355
|
+
return 0 if self.was_successful() else 1
|
scythe/core/ttp.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
|
-
from typing import Generator, Any, Optional, TYPE_CHECKING
|
|
2
|
+
from typing import Generator, Any, Optional, TYPE_CHECKING, Dict
|
|
3
3
|
from selenium.webdriver.remote.webdriver import WebDriver
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from ..auth.base import Authentication
|
|
7
|
+
import requests
|
|
7
8
|
|
|
8
9
|
class TTP(ABC):
|
|
9
10
|
"""
|
|
@@ -11,9 +12,14 @@ class TTP(ABC):
|
|
|
11
12
|
|
|
12
13
|
Each TTP implementation must define how to generate payloads, how to
|
|
13
14
|
execute a test step with a given payload, and how to verify the outcome.
|
|
15
|
+
|
|
16
|
+
TTPs can operate in two modes:
|
|
17
|
+
- 'ui': Uses Selenium WebDriver to interact with web UI (default)
|
|
18
|
+
- 'api': Uses requests library to interact directly with backend APIs
|
|
14
19
|
"""
|
|
15
20
|
|
|
16
|
-
def __init__(self, name: str, description: str, expected_result: bool = True,
|
|
21
|
+
def __init__(self, name: str, description: str, expected_result: bool = True,
|
|
22
|
+
authentication: Optional['Authentication'] = None, execution_mode: str = 'ui'):
|
|
17
23
|
"""
|
|
18
24
|
Initialize a TTP.
|
|
19
25
|
|
|
@@ -24,11 +30,13 @@ class TTP(ABC):
|
|
|
24
30
|
True means we expect to find vulnerabilities/success conditions.
|
|
25
31
|
False means we expect the security controls to prevent success.
|
|
26
32
|
authentication: Optional authentication mechanism to use before executing TTP
|
|
33
|
+
execution_mode: Execution mode - 'ui' for Selenium-based UI testing or 'api' for direct API testing
|
|
27
34
|
"""
|
|
28
35
|
self.name = name
|
|
29
36
|
self.description = description
|
|
30
37
|
self.expected_result = expected_result
|
|
31
38
|
self.authentication = authentication
|
|
39
|
+
self.execution_mode = execution_mode.lower()
|
|
32
40
|
|
|
33
41
|
@abstractmethod
|
|
34
42
|
def get_payloads(self) -> Generator[Any, None, None]:
|
|
@@ -80,9 +88,59 @@ class TTP(ABC):
|
|
|
80
88
|
@abstractmethod
|
|
81
89
|
def verify_result(self, driver: WebDriver) -> bool:
|
|
82
90
|
"""
|
|
83
|
-
Verifies the outcome of the executed step.
|
|
91
|
+
Verifies the outcome of the executed step in UI mode.
|
|
84
92
|
|
|
85
93
|
Returns:
|
|
86
94
|
True if the test indicates a potential success/vulnerability, False otherwise.
|
|
87
95
|
"""
|
|
88
96
|
pass
|
|
97
|
+
|
|
98
|
+
def execute_step_api(self, session: 'requests.Session', payload: Any, context: Dict[str, Any]) -> 'requests.Response':
|
|
99
|
+
"""
|
|
100
|
+
Executes a single test action using the provided payload via API request.
|
|
101
|
+
This method should be overridden by TTPs that support API mode.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session: requests.Session instance for making HTTP requests
|
|
105
|
+
payload: The payload to use for this test iteration
|
|
106
|
+
context: Shared context dictionary for storing state and auth headers
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
requests.Response object from the API call
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
NotImplementedError: If the TTP does not support API mode
|
|
113
|
+
"""
|
|
114
|
+
raise NotImplementedError(f"{self.name} does not support API execution mode")
|
|
115
|
+
|
|
116
|
+
def verify_result_api(self, response: 'requests.Response', context: Dict[str, Any]) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
Verifies the outcome of the executed step in API mode.
|
|
119
|
+
This method should be overridden by TTPs that support API mode.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
response: The requests.Response object from execute_step_api
|
|
123
|
+
context: Shared context dictionary for accessing state
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if the test indicates a potential success/vulnerability, False otherwise
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
NotImplementedError: If the TTP does not support API mode
|
|
130
|
+
"""
|
|
131
|
+
raise NotImplementedError(f"{self.name} does not support API result verification in API mode")
|
|
132
|
+
|
|
133
|
+
def supports_api_mode(self) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Check if this TTP implementation supports API execution mode.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if API mode is supported, False otherwise
|
|
139
|
+
"""
|
|
140
|
+
# Check if the TTP has overridden the API methods
|
|
141
|
+
try:
|
|
142
|
+
# Try to call the method on the class to see if it's been overridden
|
|
143
|
+
return (type(self).execute_step_api != TTP.execute_step_api or
|
|
144
|
+
type(self).verify_result_api != TTP.verify_result_api)
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|