scythe-ttp 0.12.4__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scythe-ttp might be problematic. Click here for more details.

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