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.
- scythe/auth/cookie_jwt.py +43 -14
- scythe/cli/main.py +195 -14
- scythe/core/executor.py +143 -2
- scythe/core/ttp.py +61 -3
- scythe/journeys/actions.py +180 -76
- 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.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/METADATA +2 -1
- {scythe_ttp-0.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/RECORD +17 -16
- {scythe_ttp-0.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/WHEEL +0 -0
- {scythe_ttp-0.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/entry_points.txt +0 -0
- {scythe_ttp-0.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/licenses/LICENSE +0 -0
- {scythe_ttp-0.15.2.dist-info → scythe_ttp-0.18.1.dist-info}/top_level.txt +0 -0
scythe/journeys/actions.py
CHANGED
|
@@ -439,81 +439,163 @@ class TTPAction(Action):
|
|
|
439
439
|
True if TTP execution matches expected result, False otherwise
|
|
440
440
|
"""
|
|
441
441
|
try:
|
|
442
|
-
#
|
|
443
|
-
if self.
|
|
444
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
481
|
+
try:
|
|
482
|
+
# Execute step
|
|
483
|
+
self.ttp.execute_step(driver, payload)
|
|
468
484
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
result
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
#
|
|
509
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
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 = (
|
|
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")
|
scythe/journeys/executor.py
CHANGED
|
@@ -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:
|
scythe/orchestrators/base.py
CHANGED
|
@@ -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:
|
scythe/ttps/web/__init__.py
CHANGED
|
@@ -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
|