scythe-ttp 0.15.2__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.
@@ -439,81 +439,163 @@ class TTPAction(Action):
439
439
  True if TTP execution matches expected result, False otherwise
440
440
  """
441
441
  try:
442
- # Determine target URL
443
- if self.target_url:
444
- url = self.target_url
445
- elif 'current_url' in context:
446
- url = context['current_url']
442
+ # Check execution mode
443
+ if self.ttp.execution_mode == 'api':
444
+ return self._execute_api_mode(driver, context)
447
445
  else:
448
- url = driver.current_url
449
-
450
- # Navigate to URL if needed
451
- if url != driver.current_url:
452
- driver.get(url)
453
-
454
- # Execute TTP authentication if required
455
- if self.ttp.requires_authentication():
456
- auth_success = self.ttp.authenticate(driver, url)
457
- if not auth_success:
458
- self.store_result('error', 'TTP authentication failed')
459
- return False
460
-
461
- # Execute TTP payloads
462
- ttp_results = []
463
- success_count = 0
464
- total_count = 0
446
+ return self._execute_ui_mode(driver, context)
447
+
448
+ except Exception as e:
449
+ self.store_result('error', str(e))
450
+ return False
451
+
452
+ def _execute_ui_mode(self, driver: WebDriver, context: Dict[str, Any]) -> bool:
453
+ """Execute TTP in UI mode using Selenium."""
454
+ # Determine target URL
455
+ if self.target_url:
456
+ url = self.target_url
457
+ elif 'current_url' in context:
458
+ url = context['current_url']
459
+ else:
460
+ url = driver.current_url
461
+
462
+ # Navigate to URL if needed
463
+ if url != driver.current_url:
464
+ driver.get(url)
465
+
466
+ # Execute TTP authentication if required
467
+ if self.ttp.requires_authentication():
468
+ auth_success = self.ttp.authenticate(driver, url)
469
+ if not auth_success:
470
+ self.store_result('error', 'TTP authentication failed')
471
+ return False
472
+
473
+ # Execute TTP payloads
474
+ ttp_results = []
475
+ success_count = 0
476
+ total_count = 0
477
+
478
+ for payload in self.ttp.get_payloads():
479
+ total_count += 1
465
480
 
466
- for payload in self.ttp.get_payloads():
467
- total_count += 1
481
+ try:
482
+ # Execute step
483
+ self.ttp.execute_step(driver, payload)
468
484
 
469
- try:
470
- # Execute step
471
- self.ttp.execute_step(driver, payload)
472
-
473
- # Verify result
474
- result = self.ttp.verify_result(driver)
475
-
476
- ttp_results.append({
477
- 'payload': str(payload),
478
- 'success': result,
479
- 'url': driver.current_url
480
- })
485
+ # Verify result
486
+ result = self.ttp.verify_result(driver)
487
+
488
+ ttp_results.append({
489
+ 'payload': str(payload),
490
+ 'success': result,
491
+ 'url': driver.current_url
492
+ })
493
+
494
+ if result:
495
+ success_count += 1
481
496
 
482
- if result:
483
- success_count += 1
484
-
485
- except Exception as e:
486
- ttp_results.append({
487
- 'payload': str(payload),
488
- 'success': False,
489
- 'error': str(e),
490
- 'url': driver.current_url
491
- })
492
-
493
- # Store results
494
- self.store_result('ttp_name', self.ttp.name)
495
- self.store_result('total_payloads', total_count)
496
- self.store_result('successful_payloads', success_count)
497
- self.store_result('ttp_results', ttp_results)
498
- self.store_result('success_rate', success_count / total_count if total_count > 0 else 0)
499
-
500
- # Update context
501
- context[f'ttp_results_{self.ttp.name}'] = ttp_results
502
- context['last_ttp_success_count'] = success_count
503
-
504
- # Determine action success based on expected result
505
- has_successes = success_count > 0
497
+ except Exception as e:
498
+ ttp_results.append({
499
+ 'payload': str(payload),
500
+ 'success': False,
501
+ 'error': str(e),
502
+ 'url': driver.current_url
503
+ })
504
+
505
+ # Store results
506
+ self.store_result('ttp_name', self.ttp.name)
507
+ self.store_result('execution_mode', 'ui')
508
+ self.store_result('total_payloads', total_count)
509
+ self.store_result('successful_payloads', success_count)
510
+ self.store_result('ttp_results', ttp_results)
511
+ self.store_result('success_rate', success_count / total_count if total_count > 0 else 0)
512
+
513
+ # Update context
514
+ context[f'ttp_results_{self.ttp.name}'] = ttp_results
515
+ context['last_ttp_success_count'] = success_count
516
+
517
+ # Determine action success based on expected result
518
+ has_successes = success_count > 0
519
+
520
+ if self.expected_result:
521
+ # Expecting TTP to find vulnerabilities/succeed
522
+ return has_successes
523
+ else:
524
+ # Expecting TTP to fail (security controls working)
525
+ return not has_successes
526
+
527
+ def _execute_api_mode(self, driver: WebDriver, context: Dict[str, Any]) -> bool:
528
+ """Execute TTP in API mode using requests library."""
529
+ import requests
530
+
531
+ # Get or create requests session
532
+ session = context.get('requests_session')
533
+ if session is None:
534
+ session = requests.Session()
535
+ context['requests_session'] = session
536
+
537
+ # Set target URL in context if provided
538
+ if self.target_url:
539
+ context['target_url'] = self.target_url
540
+
541
+ # Verify TTP supports API mode
542
+ if not self.ttp.supports_api_mode():
543
+ self.store_result('error', f'TTP {self.ttp.name} does not support API execution mode')
544
+ return False
545
+
546
+ # Execute TTP payloads via API
547
+ ttp_results = []
548
+ success_count = 0
549
+ total_count = 0
550
+
551
+ for payload in self.ttp.get_payloads():
552
+ total_count += 1
506
553
 
507
- if self.expected_result:
508
- # Expecting TTP to find vulnerabilities/succeed
509
- return has_successes
510
- else:
511
- # Expecting TTP to fail (security controls working)
512
- return not has_successes
554
+ try:
555
+ # Execute step via API
556
+ response = self.ttp.execute_step_api(session, payload, context)
513
557
 
514
- except Exception as e:
515
- self.store_result('error', str(e))
516
- return False
558
+ # Verify result
559
+ result = self.ttp.verify_result_api(response, context)
560
+
561
+ ttp_results.append({
562
+ 'payload': str(payload),
563
+ 'success': result,
564
+ 'status_code': response.status_code,
565
+ 'url': response.url
566
+ })
567
+
568
+ if result:
569
+ success_count += 1
570
+
571
+ except Exception as e:
572
+ ttp_results.append({
573
+ 'payload': str(payload),
574
+ 'success': False,
575
+ 'error': str(e)
576
+ })
577
+
578
+ # Store results
579
+ self.store_result('ttp_name', self.ttp.name)
580
+ self.store_result('execution_mode', 'api')
581
+ self.store_result('total_payloads', total_count)
582
+ self.store_result('successful_payloads', success_count)
583
+ self.store_result('ttp_results', ttp_results)
584
+ self.store_result('success_rate', success_count / total_count if total_count > 0 else 0)
585
+
586
+ # Update context
587
+ context[f'ttp_results_{self.ttp.name}'] = ttp_results
588
+ context['last_ttp_success_count'] = success_count
589
+
590
+ # Determine action success based on expected result
591
+ has_successes = success_count > 0
592
+
593
+ if self.expected_result:
594
+ # Expecting TTP to find vulnerabilities/succeed
595
+ return has_successes
596
+ else:
597
+ # Expecting TTP to fail (security controls working)
598
+ return not has_successes
517
599
 
518
600
 
519
601
  class AssertAction(Action):
@@ -659,6 +741,7 @@ class ApiRequestAction(Action):
659
741
  def __init__(self,
660
742
  method: str,
661
743
  url: str,
744
+ flush: bool = False,
662
745
  params: Optional[Dict[str, Any]] = None,
663
746
  body_json: Optional[Dict[str, Any]] = None,
664
747
  data: Optional[Dict[str, Any]] = None,
@@ -673,6 +756,7 @@ class ApiRequestAction(Action):
673
756
  fail_on_validation_error: bool = False):
674
757
  self.method = method.upper()
675
758
  self.url = url
759
+ self.flush = flush
676
760
  self.params = params or {}
677
761
  self.body_json = body_json
678
762
  self.data = data
@@ -692,15 +776,15 @@ class ApiRequestAction(Action):
692
776
  if session is None:
693
777
  session = requests.Session()
694
778
  context['requests_session'] = session
695
-
696
- # Build headers: auth headers from context + action headers (action overrides)
779
+
780
+ # Build headers: auth headers from context and action headers (action overrides)
697
781
  final_headers = {}
698
782
  auth_headers = context.get('auth_headers', {}) or {}
699
783
  if auth_headers:
700
784
  final_headers.update(auth_headers)
701
785
  if self.headers:
702
786
  final_headers.update(self.headers)
703
-
787
+
704
788
  # Simple masking for sensitive headers
705
789
  def _mask_headers(headers: Dict[str, Any]) -> Dict[str, Any]:
706
790
  masked = {}
@@ -713,7 +797,7 @@ class ApiRequestAction(Action):
713
797
  else:
714
798
  masked[k] = v
715
799
  return masked
716
-
800
+
717
801
  # Resolve URL: absolute or join with target_url from context
718
802
  from urllib.parse import urljoin
719
803
  from ..core.headers import HeaderExtractor
@@ -725,7 +809,7 @@ class ApiRequestAction(Action):
725
809
  resolved_url = self.url
726
810
  else:
727
811
  resolved_url = urljoin(base_url, self.url)
728
-
812
+
729
813
  # Store request details early
730
814
  self.store_result('request_method', self.method)
731
815
  self.store_result('url', resolved_url)
@@ -736,7 +820,7 @@ class ApiRequestAction(Action):
736
820
  if self.data is not None:
737
821
  self.store_result('request_data', self.data)
738
822
  self.store_result('request_headers', _mask_headers(final_headers))
739
-
823
+
740
824
  logger = logging.getLogger("Journey.ApiRequestAction")
741
825
  # Honor any pending rate-limit resume time set by previous actions/steps
742
826
  try:
@@ -861,14 +945,25 @@ class ApiRequestAction(Action):
861
945
 
862
946
  # Determine success (status-based by default)
863
947
  if self.expected_status is not None:
864
- http_ok = (getattr(response, 'status_code', None) == self.expected_status)
948
+ http_ok = (status_code == self.expected_status)
949
+ if not http_ok:
950
+ self.store_result('status_mismatch', f"Expected status {self.expected_status}, got {status_code}")
951
+ logger.warning(f"API request status mismatch: expected {self.expected_status}, got {status_code}")
865
952
  else:
866
953
  http_ok = bool(getattr(response, 'ok', False))
867
954
 
955
+ # Store the final status check result
956
+ self.store_result('http_status_ok', http_ok)
957
+
868
958
  # Optionally fail on validation error
869
959
  if self.response_model is not None and self.fail_on_validation_error and self.get_result('response_validation_error'):
870
960
  return False
871
961
 
962
+ if self.flush:
963
+ clear_requests_session(context)
964
+
965
+ # Return False if status doesn't match expected, regardless of expected_result
966
+ # The framework will then compare this with expected_result to determine test outcome
872
967
  return http_ok
873
968
  except Exception as e:
874
969
  last_exception = e
@@ -878,3 +973,12 @@ class ApiRequestAction(Action):
878
973
 
879
974
  # If we got here and had an exception or no return, fail
880
975
  return False
976
+
977
+ def clear_requests_session(context: Dict[str, Any]):
978
+ """Clear the request session from the context."""
979
+ logger = logging.getLogger("Journey.ApiRequestAction")
980
+ session = context.get('requests_session')
981
+ if session is not None:
982
+ session.close()
983
+ context['requests_session'] = None
984
+ logger.info("Cleared requests session from context")
@@ -406,6 +406,12 @@ class JourneyExecutor:
406
406
  else:
407
407
  self.logger.info("\nNo X-SCYTHE-TARGET-VERSION headers detected in responses.")
408
408
 
409
+ # Log overall test status (similar to TTPExecutor)
410
+ if self.was_successful():
411
+ self.logger.info("\n✓ TEST PASSED: Journey results matched expectations")
412
+ else:
413
+ self.logger.error("\n✗ TEST FAILED: Journey results differed from expected")
414
+
409
415
  self.logger.info("="*60)
410
416
 
411
417
  def get_results(self) -> Optional[Dict[str, Any]]:
@@ -457,6 +463,15 @@ class JourneyExecutor:
457
463
  actual = self.execution_results.get('overall_success', False)
458
464
  expected = self.execution_results.get('expected_result', True)
459
465
  return actual == expected
466
+
467
+ def exit_code(self) -> int:
468
+ """
469
+ Get the exit code for this journey execution.
470
+
471
+ Returns:
472
+ 0 if journey was successful (results matched expectations), 1 otherwise
473
+ """
474
+ return 0 if self.was_successful() else 1
460
475
 
461
476
 
462
477
  class JourneyRunner:
@@ -281,6 +281,24 @@ class Orchestrator(ABC):
281
281
  self.logger.warning(f" ... and {len(result.errors) - 3} more errors")
282
282
 
283
283
  self.logger.info("="*60)
284
+
285
+ def exit_code(self, result: OrchestrationResult) -> int:
286
+ """
287
+ Get the exit code for an orchestration result.
288
+
289
+ An orchestration is considered successful if all executions completed
290
+ successfully (matching their expected results).
291
+
292
+ Args:
293
+ result: OrchestrationResult to evaluate
294
+
295
+ Returns:
296
+ 0 if all executions were successful, 1 otherwise
297
+ """
298
+ # Check if any executions failed or if there were errors
299
+ if result.failed_executions > 0 or len(result.errors) > 0:
300
+ return 1
301
+ return 0
284
302
 
285
303
 
286
304
  class ExecutionContext:
@@ -0,0 +1,12 @@
1
+ from .login_bruteforce import LoginBruteforceTTP
2
+ from .sql_injection import InputFieldInjector, URLManipulation
3
+ from .uuid_guessing import GuessUUIDInURL
4
+ from .request_flooding import RequestFloodingTTP
5
+
6
+ __all__ = [
7
+ 'LoginBruteforceTTP',
8
+ 'InputFieldInjector',
9
+ 'URLManipulation',
10
+ 'GuessUUIDInURL',
11
+ 'RequestFloodingTTP'
12
+ ]
@@ -1,6 +1,8 @@
1
1
  from selenium.webdriver.common.by import By
2
2
  from selenium.webdriver.remote.webdriver import WebDriver
3
3
  from selenium.common.exceptions import NoSuchElementException
4
+ from typing import Dict, Any, Optional
5
+ import requests
4
6
 
5
7
  from ...core.ttp import TTP
6
8
  from ...payloads.generators import PayloadGenerator
@@ -8,27 +10,65 @@ from ...payloads.generators import PayloadGenerator
8
10
  class LoginBruteforceTTP(TTP):
9
11
  """
10
12
  A TTP that emulates a login bruteforce attack.
13
+
14
+ Supports two execution modes:
15
+ - UI mode: Uses Selenium to fill login forms
16
+ - API mode: Makes direct HTTP POST requests to login endpoints
11
17
  """
12
18
  def __init__(self,
13
19
  payload_generator: PayloadGenerator,
14
20
  username: str,
15
- username_selector: str,
16
- password_selector: str,
17
- submit_selector: str,
21
+ username_selector: str = None,
22
+ password_selector: str = None,
23
+ submit_selector: str = None,
18
24
  expected_result: bool = True,
19
- authentication=None):
20
-
25
+ authentication=None,
26
+ execution_mode: str = 'ui',
27
+ api_endpoint: Optional[str] = None,
28
+ username_field: str = 'username',
29
+ password_field: str = 'password',
30
+ success_indicators: Optional[Dict[str, Any]] = None):
31
+ """
32
+ Initialize the Login Bruteforce TTP.
33
+
34
+ Args:
35
+ payload_generator: Generator that yields password payloads
36
+ username: Username to attempt login with
37
+ username_selector: CSS selector for username field (UI mode)
38
+ password_selector: CSS selector for password field (UI mode)
39
+ submit_selector: CSS selector for submit button (UI mode)
40
+ expected_result: Whether we expect to find a valid password
41
+ authentication: Optional authentication to perform before testing
42
+ execution_mode: 'ui' or 'api'
43
+ api_endpoint: API endpoint path for login (API mode, e.g., '/api/auth/login')
44
+ username_field: Field name for username in API request body (API mode)
45
+ password_field: Field name for password in API request body (API mode)
46
+ success_indicators: Dict with keys 'status_code' (int), 'response_contains' (str),
47
+ 'response_not_contains' (str) to determine successful login in API mode
48
+ """
21
49
  super().__init__(
22
50
  name="Login Bruteforce",
23
51
  description="Attempts to guess a user's password using a list of payloads.",
24
52
  expected_result=expected_result,
25
- authentication=authentication
53
+ authentication=authentication,
54
+ execution_mode=execution_mode
26
55
  )
27
56
  self.payload_generator = payload_generator
28
57
  self.username = username
58
+
59
+ # UI mode fields
29
60
  self.username_selector = username_selector
30
61
  self.password_selector = password_selector
31
62
  self.submit_selector = submit_selector
63
+
64
+ # API mode fields
65
+ self.api_endpoint = api_endpoint
66
+ self.username_field = username_field
67
+ self.password_field = password_field
68
+ self.success_indicators = success_indicators or {
69
+ 'status_code': 200,
70
+ 'response_not_contains': 'invalid'
71
+ }
32
72
 
33
73
  def get_payloads(self):
34
74
  """Yields passwords from the configured generator."""
@@ -58,7 +98,98 @@ class LoginBruteforceTTP(TTP):
58
98
 
59
99
  def verify_result(self, driver: WebDriver) -> bool:
60
100
  """
61
- Checks for indicators of a successful login.
101
+ Checks for indicators of a successful login in UI mode.
62
102
  A simple check is if the URL no longer contains 'login'.
63
103
  """
64
104
  return "login" not in driver.current_url.lower()
105
+
106
+ def execute_step_api(self, session: requests.Session, payload: str, context: Dict[str, Any]) -> requests.Response:
107
+ """
108
+ Executes a login attempt via API request.
109
+
110
+ Args:
111
+ session: requests.Session for making HTTP requests
112
+ payload: The password to attempt
113
+ context: Shared context dictionary
114
+
115
+ Returns:
116
+ requests.Response from the login attempt
117
+ """
118
+ from urllib.parse import urljoin
119
+
120
+ # Build the full URL
121
+ base_url = context.get('target_url', '')
122
+ if not base_url:
123
+ raise ValueError("target_url must be set in context for API mode")
124
+
125
+ url = urljoin(base_url, self.api_endpoint or '/login')
126
+
127
+ # Build request body
128
+ body = {
129
+ self.username_field: self.username,
130
+ self.password_field: payload
131
+ }
132
+
133
+ # Merge auth headers from context
134
+ headers = {}
135
+ auth_headers = context.get('auth_headers', {})
136
+ if auth_headers:
137
+ headers.update(auth_headers)
138
+
139
+ # Honor rate limiting
140
+ import time
141
+ resume_at = context.get('rate_limit_resume_at')
142
+ now = time.time()
143
+ if isinstance(resume_at, (int, float)) and resume_at > now:
144
+ wait_s = min(resume_at - now, 30)
145
+ if wait_s > 0:
146
+ time.sleep(wait_s)
147
+
148
+ # Make the request
149
+ response = session.post(url, json=body, headers=headers or None, timeout=10.0)
150
+
151
+ # Handle rate limiting
152
+ if response.status_code == 429:
153
+ retry_after = response.headers.get('Retry-After', '1')
154
+ try:
155
+ wait_s = int(retry_after)
156
+ except (ValueError, TypeError):
157
+ wait_s = 1
158
+ context['rate_limit_resume_at'] = time.time() + min(wait_s, 30)
159
+
160
+ return response
161
+
162
+ def verify_result_api(self, response: requests.Response, context: Dict[str, Any]) -> bool:
163
+ """
164
+ Verifies if the login attempt was successful based on the API response.
165
+
166
+ Args:
167
+ response: The response from execute_step_api
168
+ context: Shared context dictionary
169
+
170
+ Returns:
171
+ True if login appears successful, False otherwise
172
+ """
173
+ # Check status code
174
+ expected_status = self.success_indicators.get('status_code')
175
+ if expected_status is not None and response.status_code != expected_status:
176
+ return False
177
+
178
+ # Check response body contains/not contains strings
179
+ try:
180
+ response_text = response.text.lower()
181
+
182
+ # Check if response should contain certain text
183
+ contains = self.success_indicators.get('response_contains')
184
+ if contains and contains.lower() not in response_text:
185
+ return False
186
+
187
+ # Check if response should NOT contain certain text
188
+ not_contains = self.success_indicators.get('response_not_contains')
189
+ if not_contains and not_contains.lower() in response_text:
190
+ return False
191
+
192
+ return True
193
+ except Exception:
194
+ # If we can't read the response, consider it a failure
195
+ return False