scythe-ttp 0.17.0__tar.gz → 0.17.2__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.

Files changed (62) hide show
  1. {scythe_ttp-0.17.0/scythe_ttp.egg-info → scythe_ttp-0.17.2}/PKG-INFO +1 -1
  2. scythe_ttp-0.17.2/VERSION +1 -0
  3. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/cli/main.py +13 -0
  4. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/core/executor.py +18 -0
  5. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/core/ttp.py +61 -3
  6. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/journeys/actions.py +151 -69
  7. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/journeys/executor.py +6 -0
  8. scythe_ttp-0.17.2/scythe/ttps/web/login_bruteforce.py +195 -0
  9. scythe_ttp-0.17.2/scythe/ttps/web/sql_injection.py +297 -0
  10. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2/scythe_ttp.egg-info}/PKG-INFO +1 -1
  11. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe_ttp.egg-info/SOURCES.txt +2 -1
  12. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_expected_results.py +57 -0
  13. scythe_ttp-0.17.2/tests/test_ttp_api_mode.py +591 -0
  14. scythe_ttp-0.17.0/VERSION +0 -1
  15. scythe_ttp-0.17.0/scythe/ttps/web/login_bruteforce.py +0 -64
  16. scythe_ttp-0.17.0/scythe/ttps/web/sql_injection.py +0 -80
  17. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/LICENSE +0 -0
  18. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/MANIFEST.in +0 -0
  19. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/README.md +0 -0
  20. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/pyproject.toml +0 -0
  21. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/requirements.txt +0 -0
  22. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/__init__.py +0 -0
  23. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/auth/__init__.py +0 -0
  24. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/auth/base.py +0 -0
  25. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/auth/basic.py +0 -0
  26. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/auth/bearer.py +0 -0
  27. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/auth/cookie_jwt.py +0 -0
  28. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/__init__.py +0 -0
  29. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/base.py +0 -0
  30. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/default.py +0 -0
  31. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/human.py +0 -0
  32. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/machine.py +0 -0
  33. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/behaviors/stealth.py +0 -0
  34. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/cli/__init__.py +0 -0
  35. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/core/__init__.py +0 -0
  36. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/core/headers.py +0 -0
  37. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/journeys/__init__.py +0 -0
  38. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/journeys/base.py +0 -0
  39. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/orchestrators/__init__.py +0 -0
  40. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/orchestrators/base.py +0 -0
  41. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/orchestrators/batch.py +0 -0
  42. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/orchestrators/distributed.py +0 -0
  43. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/orchestrators/scale.py +0 -0
  44. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/payloads/__init__.py +0 -0
  45. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/payloads/generators.py +0 -0
  46. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/ttps/__init__.py +0 -0
  47. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/ttps/web/__init__.py +0 -0
  48. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe/ttps/web/uuid_guessing.py +0 -0
  49. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe_ttp.egg-info/dependency_links.txt +0 -0
  50. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe_ttp.egg-info/entry_points.txt +0 -0
  51. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe_ttp.egg-info/requires.txt +0 -0
  52. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/scythe_ttp.egg-info/top_level.txt +0 -0
  53. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/setup.cfg +0 -0
  54. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_api_models.py +0 -0
  55. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_authentication.py +0 -0
  56. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_behaviors.py +0 -0
  57. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_cli.py +0 -0
  58. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_cookie_jwt_auth.py +0 -0
  59. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_feature_completeness.py +0 -0
  60. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_header_extraction.py +0 -0
  61. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_journeys.py +0 -0
  62. {scythe_ttp-0.17.0 → scythe_ttp-0.17.2}/tests/test_orchestrators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scythe-ttp
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: An extensible framework for emulating attacker TTPs with Selenium.
5
5
  Author-email: EpykLab <cyber@epyklab.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -0,0 +1 @@
1
+ 0.17.2
@@ -68,6 +68,19 @@ def check_version_in_response_header(args) -> bool:
68
68
  def scythe_test_definition(args) -> bool:
69
69
  # TODO: implement your test using Scythe primitives.
70
70
  # Example placeholder that simply passes.
71
+
72
+ # Example usage with TTPExecutor:
73
+ # from scythe.core.executor import TTPExecutor
74
+ # executor = TTPExecutor(ttp=my_ttp, target_url=args.url)
75
+ # executor.run()
76
+ # return executor.was_successful() # Returns True if all results matched expectations
77
+
78
+ # Example usage with JourneyExecutor:
79
+ # from scythe.journeys.executor import JourneyExecutor
80
+ # executor = JourneyExecutor(journey=my_journey, target_url=args.url)
81
+ # executor.run()
82
+ # return executor.was_successful() # Returns True if journey succeeded as expected
83
+
71
84
  return True
72
85
 
73
86
 
@@ -40,6 +40,7 @@ class TTPExecutor:
40
40
  self.driver = None
41
41
  self.results = []
42
42
  self.header_extractor = HeaderExtractor()
43
+ self.has_test_failures = False # Track if any test had unexpected results
43
44
 
44
45
  def _setup_driver(self):
45
46
  """Initializes the WebDriver."""
@@ -134,10 +135,12 @@ class TTPExecutor:
134
135
  else:
135
136
  version_info = f" | Version: {target_version}" if target_version else ""
136
137
  self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail){version_info}")
138
+ self.has_test_failures = True # Mark as failure when result differs from expected
137
139
  else:
138
140
  consecutive_failures += 1
139
141
  if self.ttp.expected_result:
140
142
  self.logger.info(f"EXPECTED FAILURE: '{payload}' (security control working)")
143
+ self.has_test_failures = True # Mark as failure when result differs from expected
141
144
  else:
142
145
  self.logger.info(f"EXPECTED FAILURE: '{payload}'")
143
146
 
@@ -212,3 +215,18 @@ class TTPExecutor:
212
215
  self.logger.info("No successes detected (expected to find vulnerabilities).")
213
216
  else:
214
217
  self.logger.info("No successes detected (security controls working as expected).")
218
+
219
+ # Log overall test status
220
+ if self.has_test_failures:
221
+ self.logger.error("\n✗ TEST FAILED: One or more test results differed from expected")
222
+ else:
223
+ self.logger.info("\n✓ TEST PASSED: All test results matched expectations")
224
+
225
+ def was_successful(self) -> bool:
226
+ """
227
+ Check if all test results matched expectations.
228
+
229
+ Returns:
230
+ True if all test results matched expectations, False otherwise
231
+ """
232
+ return not self.has_test_failures
@@ -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, authentication: Optional['Authentication'] = None):
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
@@ -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):
@@ -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]]:
@@ -0,0 +1,195 @@
1
+ from selenium.webdriver.common.by import By
2
+ from selenium.webdriver.remote.webdriver import WebDriver
3
+ from selenium.common.exceptions import NoSuchElementException
4
+ from typing import Dict, Any, Optional
5
+ import requests
6
+
7
+ from ...core.ttp import TTP
8
+ from ...payloads.generators import PayloadGenerator
9
+
10
+ class LoginBruteforceTTP(TTP):
11
+ """
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
17
+ """
18
+ def __init__(self,
19
+ payload_generator: PayloadGenerator,
20
+ username: str,
21
+ username_selector: str = None,
22
+ password_selector: str = None,
23
+ submit_selector: str = None,
24
+ expected_result: bool = True,
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
+ """
49
+ super().__init__(
50
+ name="Login Bruteforce",
51
+ description="Attempts to guess a user's password using a list of payloads.",
52
+ expected_result=expected_result,
53
+ authentication=authentication,
54
+ execution_mode=execution_mode
55
+ )
56
+ self.payload_generator = payload_generator
57
+ self.username = username
58
+
59
+ # UI mode fields
60
+ self.username_selector = username_selector
61
+ self.password_selector = password_selector
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
+ }
72
+
73
+ def get_payloads(self):
74
+ """Yields passwords from the configured generator."""
75
+ yield from self.payload_generator()
76
+
77
+ def execute_step(self, driver: WebDriver, payload: str):
78
+ """Fills the login form and submits it."""
79
+ try:
80
+ username_field = driver.find_element(By.CSS_SELECTOR, self.username_selector)
81
+ password_field = driver.find_element(By.CSS_SELECTOR, self.password_selector)
82
+
83
+ username_field.clear()
84
+ username_field.send_keys(self.username)
85
+
86
+ password_field.clear()
87
+ password_field.send_keys(payload) # Payload is the password
88
+
89
+ # Use submit button if available, otherwise press Enter on the password field
90
+ try:
91
+ submit_button = driver.find_element(By.CSS_SELECTOR, self.submit_selector)
92
+ submit_button.click()
93
+ except NoSuchElementException:
94
+ password_field.send_keys("\n")
95
+
96
+ except NoSuchElementException as e:
97
+ raise Exception(f"Could not find a login element on the page: {e}")
98
+
99
+ def verify_result(self, driver: WebDriver) -> bool:
100
+ """
101
+ Checks for indicators of a successful login in UI mode.
102
+ A simple check is if the URL no longer contains 'login'.
103
+ """
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