scythe-ttp 0.9.2__tar.gz → 0.11.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 (52) hide show
  1. {scythe_ttp-0.9.2/scythe_ttp.egg-info → scythe_ttp-0.11.0}/PKG-INFO +70 -1
  2. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/README.md +69 -0
  3. scythe_ttp-0.11.0/VERSION +1 -0
  4. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/executor.py +38 -5
  5. scythe_ttp-0.11.0/scythe/core/headers.py +194 -0
  6. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/base.py +21 -2
  7. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/executor.py +22 -1
  8. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0/scythe_ttp.egg-info}/PKG-INFO +70 -1
  9. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/SOURCES.txt +2 -0
  10. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/setup.py +1 -1
  11. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_feature_completeness.py +4 -1
  12. scythe_ttp-0.11.0/tests/test_header_extraction.py +400 -0
  13. scythe_ttp-0.9.2/VERSION +0 -1
  14. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/LICENSE +0 -0
  15. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/MANIFEST.in +0 -0
  16. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/requirements.txt +0 -0
  17. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/__init__.py +0 -0
  18. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/__init__.py +0 -0
  19. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/base.py +0 -0
  20. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/basic.py +0 -0
  21. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/bearer.py +0 -0
  22. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/__init__.py +0 -0
  23. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/base.py +0 -0
  24. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/default.py +0 -0
  25. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/human.py +0 -0
  26. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/machine.py +0 -0
  27. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/stealth.py +0 -0
  28. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/__init__.py +0 -0
  29. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/ttp.py +0 -0
  30. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/__init__.py +0 -0
  31. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/actions.py +0 -0
  32. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/__init__.py +0 -0
  33. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/base.py +0 -0
  34. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/batch.py +0 -0
  35. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/distributed.py +0 -0
  36. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/scale.py +0 -0
  37. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/payloads/__init__.py +0 -0
  38. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/payloads/generators.py +0 -0
  39. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/__init__.py +0 -0
  40. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/__init__.py +0 -0
  41. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/login_bruteforce.py +0 -0
  42. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/sql_injection.py +0 -0
  43. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/uuid_guessing.py +0 -0
  44. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/dependency_links.txt +0 -0
  45. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/requires.txt +0 -0
  46. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/top_level.txt +0 -0
  47. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/setup.cfg +0 -0
  48. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_authentication.py +0 -0
  49. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_behaviors.py +0 -0
  50. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_expected_results.py +0 -0
  51. {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_journeys.py +0 -0
  52. {scythe_ttp-0.9.2 → scythe_ttp-0.11.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.9.2
3
+ Version: 0.11.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
@@ -96,6 +96,7 @@ Scythe operates on the principle that robust systems must be tested under advers
96
96
  ### 📊 **Professional Reporting**
97
97
  * **Clear Result Indicators**: ✓ Expected outcomes, ✗ Unexpected results
98
98
  * **Comprehensive Logging**: Detailed execution tracking and analysis
99
+ * **Version Detection**: Automatic extraction of X-SCYTHE-TARGET-VERSION headers
99
100
  * **Performance Metrics**: Timing, success rates, and resource utilization
100
101
  * **Execution Statistics**: Detailed reporting across all test types
101
102
 
@@ -443,6 +444,74 @@ executor = TTPExecutor(
443
444
  )
444
445
  ```
445
446
 
447
+ ### Version Detection
448
+
449
+ Scythe automatically captures the `X-SCYTHE-TARGET-VERSION` header from HTTP responses to track which version of your web application is being tested:
450
+
451
+ ```python
452
+ from scythe.core.ttp import TTP
453
+ from scythe.core.executor import TTPExecutor
454
+
455
+ # Your web application should set this header:
456
+ # X-SCYTHE-TARGET-VERSION: 1.3.2
457
+
458
+ class MyTTP(TTP):
459
+ def get_payloads(self):
460
+ yield "test_payload"
461
+
462
+ def execute_step(self, driver, payload):
463
+ driver.get("http://your-app.com/login")
464
+ # ... test logic ...
465
+
466
+ def verify_result(self, driver):
467
+ return "welcome" in driver.page_source
468
+
469
+ # Run the test
470
+ ttp = MyTTP("Version Test", "Test with version detection")
471
+ executor = TTPExecutor(ttp=ttp, target_url="http://your-app.com")
472
+ executor.run()
473
+ ```
474
+
475
+ **Output includes version information:**
476
+ ```
477
+ ✓ EXPECTED SUCCESS: 'test_payload' | Version: 1.3.2
478
+ Target Version Summary:
479
+ Results with version info: 1/1
480
+ Version 1.3.2: 1 result(s)
481
+ ```
482
+
483
+ **Server-side implementation examples:**
484
+ ```python
485
+ # Python/Flask
486
+ @app.after_request
487
+ def add_version_header(response):
488
+ response.headers['X-SCYTHE-TARGET-VERSION'] = '1.3.2'
489
+ return response
490
+
491
+ # Node.js/Express
492
+ app.use((req, res, next) => {
493
+ res.set('X-SCYTHE-TARGET-VERSION', '1.3.2');
494
+ next();
495
+ });
496
+
497
+ # Java/Spring Boot
498
+ @Component
499
+ public class VersionHeaderFilter implements Filter {
500
+ @Override
501
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
502
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
503
+ httpResponse.setHeader("X-SCYTHE-TARGET-VERSION", "1.3.2");
504
+ chain.doFilter(request, response);
505
+ }
506
+ }
507
+ ```
508
+
509
+ This feature helps you:
510
+ - **Track test results** by application version
511
+ - **Verify deployment status** during testing
512
+ - **Correlate issues** with specific software versions
513
+ - **Ensure consistency** across test environments
514
+
446
515
  ### Custom Test Creation
447
516
 
448
517
  Extend Scythe for specific testing needs:
@@ -49,6 +49,7 @@ Scythe operates on the principle that robust systems must be tested under advers
49
49
  ### 📊 **Professional Reporting**
50
50
  * **Clear Result Indicators**: ✓ Expected outcomes, ✗ Unexpected results
51
51
  * **Comprehensive Logging**: Detailed execution tracking and analysis
52
+ * **Version Detection**: Automatic extraction of X-SCYTHE-TARGET-VERSION headers
52
53
  * **Performance Metrics**: Timing, success rates, and resource utilization
53
54
  * **Execution Statistics**: Detailed reporting across all test types
54
55
 
@@ -396,6 +397,74 @@ executor = TTPExecutor(
396
397
  )
397
398
  ```
398
399
 
400
+ ### Version Detection
401
+
402
+ Scythe automatically captures the `X-SCYTHE-TARGET-VERSION` header from HTTP responses to track which version of your web application is being tested:
403
+
404
+ ```python
405
+ from scythe.core.ttp import TTP
406
+ from scythe.core.executor import TTPExecutor
407
+
408
+ # Your web application should set this header:
409
+ # X-SCYTHE-TARGET-VERSION: 1.3.2
410
+
411
+ class MyTTP(TTP):
412
+ def get_payloads(self):
413
+ yield "test_payload"
414
+
415
+ def execute_step(self, driver, payload):
416
+ driver.get("http://your-app.com/login")
417
+ # ... test logic ...
418
+
419
+ def verify_result(self, driver):
420
+ return "welcome" in driver.page_source
421
+
422
+ # Run the test
423
+ ttp = MyTTP("Version Test", "Test with version detection")
424
+ executor = TTPExecutor(ttp=ttp, target_url="http://your-app.com")
425
+ executor.run()
426
+ ```
427
+
428
+ **Output includes version information:**
429
+ ```
430
+ ✓ EXPECTED SUCCESS: 'test_payload' | Version: 1.3.2
431
+ Target Version Summary:
432
+ Results with version info: 1/1
433
+ Version 1.3.2: 1 result(s)
434
+ ```
435
+
436
+ **Server-side implementation examples:**
437
+ ```python
438
+ # Python/Flask
439
+ @app.after_request
440
+ def add_version_header(response):
441
+ response.headers['X-SCYTHE-TARGET-VERSION'] = '1.3.2'
442
+ return response
443
+
444
+ # Node.js/Express
445
+ app.use((req, res, next) => {
446
+ res.set('X-SCYTHE-TARGET-VERSION', '1.3.2');
447
+ next();
448
+ });
449
+
450
+ # Java/Spring Boot
451
+ @Component
452
+ public class VersionHeaderFilter implements Filter {
453
+ @Override
454
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
455
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
456
+ httpResponse.setHeader("X-SCYTHE-TARGET-VERSION", "1.3.2");
457
+ chain.doFilter(request, response);
458
+ }
459
+ }
460
+ ```
461
+
462
+ This feature helps you:
463
+ - **Track test results** by application version
464
+ - **Verify deployment status** during testing
465
+ - **Correlate issues** with specific software versions
466
+ - **Ensure consistency** across test environments
467
+
399
468
  ### Custom Test Creation
400
469
 
401
470
  Extend Scythe for specific testing needs:
@@ -0,0 +1 @@
1
+ 0.12.1
@@ -5,6 +5,7 @@ from selenium.webdriver.chrome.options import Options
5
5
  from .ttp import TTP
6
6
  from typing import Optional
7
7
  from ..behaviors.base import Behavior
8
+ from .headers import HeaderExtractor
8
9
 
9
10
  # Configure logging
10
11
  logging.basicConfig(
@@ -33,8 +34,12 @@ class TTPExecutor:
33
34
  self.chrome_options.add_argument("--no-sandbox")
34
35
  self.chrome_options.add_argument("--disable-dev-shm-usage")
35
36
 
37
+ # Enable header extraction capabilities
38
+ HeaderExtractor.enable_logging_for_driver(self.chrome_options)
39
+
36
40
  self.driver = None
37
41
  self.results = []
42
+ self.header_extractor = HeaderExtractor()
38
43
 
39
44
  def _setup_driver(self):
40
45
  """Initializes the WebDriver."""
@@ -108,13 +113,27 @@ class TTPExecutor:
108
113
  if success:
109
114
  consecutive_failures = 0
110
115
  current_url = self.driver.current_url if self.driver else "unknown"
111
- result_entry = {'payload': payload, 'url': current_url, 'expected': self.ttp.expected_result, 'actual': True}
116
+
117
+ # Extract target version header
118
+ target_version = None
119
+ if self.driver:
120
+ target_version = self.header_extractor.extract_target_version(self.driver, self.target_url)
121
+
122
+ result_entry = {
123
+ 'payload': payload,
124
+ 'url': current_url,
125
+ 'expected': self.ttp.expected_result,
126
+ 'actual': True,
127
+ 'target_version': target_version
128
+ }
112
129
  self.results.append(result_entry)
113
130
 
114
131
  if self.ttp.expected_result:
115
- self.logger.info(f"EXPECTED SUCCESS: '{payload}'")
132
+ version_info = f" | Version: {target_version}" if target_version else ""
133
+ self.logger.info(f"EXPECTED SUCCESS: '{payload}'{version_info}")
116
134
  else:
117
- self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail)")
135
+ version_info = f" | Version: {target_version}" if target_version else ""
136
+ self.logger.warning(f"UNEXPECTED SUCCESS: '{payload}' (expected to fail){version_info}")
118
137
  else:
119
138
  consecutive_failures += 1
120
139
  if self.ttp.expected_result:
@@ -168,12 +187,26 @@ class TTPExecutor:
168
187
  if expected_successes:
169
188
  self.logger.info(f"Expected successes: {len(expected_successes)}")
170
189
  for result in expected_successes:
171
- self.logger.info(f" Payload: {result['payload']} | URL: {result['url']}")
190
+ version_info = f" | Version: {result['target_version']}" if result.get('target_version') else ""
191
+ self.logger.info(f" ✓ Payload: {result['payload']} | URL: {result['url']}{version_info}")
172
192
 
173
193
  if unexpected_successes:
174
194
  self.logger.warning(f"Unexpected successes: {len(unexpected_successes)}")
175
195
  for result in unexpected_successes:
176
- self.logger.warning(f" Payload: {result['payload']} | URL: {result['url']}")
196
+ version_info = f" | Version: {result['target_version']}" if result.get('target_version') else ""
197
+ self.logger.warning(f" ✗ Payload: {result['payload']} | URL: {result['url']}{version_info}")
198
+
199
+ # Display version summary
200
+ version_summary = self.header_extractor.get_version_summary(self.results)
201
+ if version_summary['results_with_version'] > 0:
202
+ self.logger.info("\nTarget Version Summary:")
203
+ self.logger.info(f" Results with version info: {version_summary['results_with_version']}/{version_summary['total_results']}")
204
+ if version_summary['unique_versions']:
205
+ for version in version_summary['unique_versions']:
206
+ count = version_summary['version_counts'][version]
207
+ self.logger.info(f" Version {version}: {count} result(s)")
208
+ else:
209
+ self.logger.info("\nNo X-SCYTHE-TARGET-VERSION headers detected in responses.")
177
210
  else:
178
211
  if self.ttp.expected_result:
179
212
  self.logger.info("No successes detected (expected to find vulnerabilities).")
@@ -0,0 +1,194 @@
1
+ import json
2
+ import logging
3
+ from typing import Optional, Dict, Any
4
+ from selenium.webdriver.remote.webdriver import WebDriver
5
+ from selenium.webdriver.chrome.options import Options
6
+
7
+
8
+ class HeaderExtractor:
9
+ """
10
+ Utility class for extracting HTTP response headers from WebDriver sessions.
11
+
12
+ Specifically designed to capture the X-SCYTHE-TARGET-VERSION header
13
+ that indicates the version of the web application being tested.
14
+ """
15
+
16
+ SCYTHE_VERSION_HEADER = "X-SCYTHE-TARGET-VERSION"
17
+
18
+ def __init__(self):
19
+ self.logger = logging.getLogger("HeaderExtractor")
20
+
21
+ @staticmethod
22
+ def enable_logging_for_driver(chrome_options: Options) -> None:
23
+ """
24
+ Enable performance logging capabilities for Chrome WebDriver.
25
+
26
+ This must be called during WebDriver setup to capture network logs.
27
+
28
+ Args:
29
+ chrome_options: Chrome options object to modify
30
+ """
31
+ # Enable performance logging to capture network events
32
+ chrome_options.add_argument("--enable-logging")
33
+ chrome_options.add_argument("--log-level=0")
34
+ chrome_options.set_capability("goog:loggingPrefs", {"performance": "ALL"})
35
+
36
+ def extract_target_version(self, driver: WebDriver, target_url: Optional[str] = None) -> Optional[str]:
37
+ """
38
+ Extract the X-SCYTHE-TARGET-VERSION header from the most recent HTTP response.
39
+
40
+ Args:
41
+ driver: WebDriver instance with performance logging enabled
42
+ target_url: Optional URL to filter responses for (if None, uses any response)
43
+
44
+ Returns:
45
+ Version string if header found, None otherwise
46
+ """
47
+ try:
48
+ # Get performance logs - using getattr to handle type checking
49
+ if not hasattr(driver, 'get_log'):
50
+ self.logger.warning("WebDriver does not support get_log method")
51
+ return None
52
+
53
+ logs = getattr(driver, 'get_log')('performance')
54
+
55
+ # Look for Network.responseReceived events
56
+ for log_entry in reversed(logs): # Start with most recent
57
+ try:
58
+ message = log_entry.get('message', {})
59
+ if isinstance(message, str):
60
+ message = json.loads(message)
61
+
62
+ method = message.get('message', {}).get('method', '')
63
+ params = message.get('message', {}).get('params', {})
64
+
65
+ if method == 'Network.responseReceived':
66
+ response = params.get('response', {})
67
+ headers = response.get('headers', {})
68
+ response_url = response.get('url', '')
69
+
70
+ # Filter by target URL if specified
71
+ if target_url and target_url not in response_url:
72
+ continue
73
+
74
+ # Look for the version header (case-insensitive)
75
+ version = self._find_version_header(headers)
76
+ if version:
77
+ self.logger.debug(f"Found target version '{version}' in response from {response_url}")
78
+ return version
79
+
80
+ except (json.JSONDecodeError, KeyError, AttributeError) as e:
81
+ self.logger.debug(f"Error parsing log entry: {e}")
82
+ continue
83
+
84
+ self.logger.debug("No X-SCYTHE-TARGET-VERSION header found in network logs")
85
+ return None
86
+
87
+ except Exception as e:
88
+ self.logger.warning(f"Failed to extract target version from logs: {e}")
89
+ return None
90
+
91
+ def _find_version_header(self, headers: Dict[str, Any]) -> Optional[str]:
92
+ """
93
+ Find the version header in a case-insensitive manner.
94
+
95
+ Args:
96
+ headers: Dictionary of HTTP headers
97
+
98
+ Returns:
99
+ Version string if found, None otherwise
100
+ """
101
+ # Check for exact case match first
102
+ if self.SCYTHE_VERSION_HEADER in headers:
103
+ return str(headers[self.SCYTHE_VERSION_HEADER]).strip()
104
+
105
+ # Check case-insensitive
106
+ header_lower = self.SCYTHE_VERSION_HEADER.lower()
107
+ for header_name, header_value in headers.items():
108
+ if header_name.lower() == header_lower:
109
+ return str(header_value).strip()
110
+
111
+ return None
112
+
113
+ def extract_all_headers(self, driver: WebDriver, target_url: Optional[str] = None) -> Dict[str, str]:
114
+ """
115
+ Extract all headers from the most recent HTTP response.
116
+
117
+ Useful for debugging or when additional headers might be needed.
118
+
119
+ Args:
120
+ driver: WebDriver instance with performance logging enabled
121
+ target_url: Optional URL to filter responses for
122
+
123
+ Returns:
124
+ Dictionary of headers from the most recent response
125
+ """
126
+ try:
127
+ # Get performance logs - using getattr to handle type checking
128
+ if not hasattr(driver, 'get_log'):
129
+ self.logger.warning("WebDriver does not support get_log method")
130
+ return {}
131
+
132
+ logs = getattr(driver, 'get_log')('performance')
133
+
134
+ for log_entry in reversed(logs):
135
+ try:
136
+ message = log_entry.get('message', {})
137
+ if isinstance(message, str):
138
+ message = json.loads(message)
139
+
140
+ method = message.get('message', {}).get('method', '')
141
+ params = message.get('message', {}).get('params', {})
142
+
143
+ if method == 'Network.responseReceived':
144
+ response = params.get('response', {})
145
+ headers = response.get('headers', {})
146
+ response_url = response.get('url', '')
147
+
148
+ # Filter by target URL if specified
149
+ if target_url and target_url not in response_url:
150
+ continue
151
+
152
+ # Convert all header values to strings
153
+ return {k: str(v) for k, v in headers.items()}
154
+
155
+ except (json.JSONDecodeError, KeyError, AttributeError):
156
+ continue
157
+
158
+ return {}
159
+
160
+ except Exception as e:
161
+ self.logger.warning(f"Failed to extract headers from logs: {e}")
162
+ return {}
163
+
164
+ def get_version_summary(self, results: list) -> Dict[str, Any]:
165
+ """
166
+ Analyze version information across multiple test results.
167
+
168
+ Args:
169
+ results: List of result dictionaries containing version information
170
+
171
+ Returns:
172
+ Dictionary with version analysis summary
173
+ """
174
+ versions = []
175
+ results_with_version = 0
176
+
177
+ for result in results:
178
+ version = result.get('target_version')
179
+ if version:
180
+ versions.append(version)
181
+ results_with_version += 1
182
+
183
+ summary = {
184
+ 'total_results': len(results),
185
+ 'results_with_version': results_with_version,
186
+ 'unique_versions': list(set(versions)) if versions else [],
187
+ 'version_counts': {}
188
+ }
189
+
190
+ # Count occurrences of each version
191
+ for version in versions:
192
+ summary['version_counts'][version] = summary['version_counts'].get(version, 0) + 1
193
+
194
+ return summary
@@ -276,6 +276,10 @@ class Journey:
276
276
  logger.info(f"Description: {self.description}")
277
277
  logger.info(f"Steps: {len(self.steps)}")
278
278
 
279
+ # Import here to avoid circular imports
280
+ from ..core.headers import HeaderExtractor
281
+ header_extractor = HeaderExtractor()
282
+
279
283
  start_time = time.time()
280
284
 
281
285
  # Set initial context
@@ -297,7 +301,9 @@ class Journey:
297
301
  'overall_success': False,
298
302
  'execution_time': 0,
299
303
  'step_results': [],
300
- 'errors': []
304
+ 'errors': [],
305
+ 'target_versions': [],
306
+ 'version_summary': {}
301
307
  }
302
308
 
303
309
  try:
@@ -335,6 +341,12 @@ class Journey:
335
341
  else:
336
342
  results['actions_failed'] += 1
337
343
 
344
+ # Extract target version header after step execution
345
+ target_version = header_extractor.extract_target_version(driver, target_url)
346
+ if target_version:
347
+ results['target_versions'].append(target_version)
348
+ logger.info(f"Target version detected: {target_version}")
349
+
338
350
  # Store step results
339
351
  step_result = {
340
352
  'step_name': step.name,
@@ -342,7 +354,8 @@ class Journey:
342
354
  'expected': step.expected_result,
343
355
  'actual': step_success,
344
356
  'actions': step.execution_results.copy(),
345
- 'step_data': step.step_data.copy()
357
+ 'step_data': step.step_data.copy(),
358
+ 'target_version': target_version
346
359
  }
347
360
  results['step_results'].append(step_result)
348
361
 
@@ -368,6 +381,12 @@ class Journey:
368
381
  results['end_time'] = end_time
369
382
  results['execution_time'] = end_time - start_time
370
383
 
384
+ # Generate version summary
385
+ if results['target_versions']:
386
+ version_summary = header_extractor.get_version_summary([{'target_version': v} for v in results['target_versions']])
387
+ results['version_summary'] = version_summary
388
+ logger.info(f"Target versions detected: {list(set(results['target_versions']))}")
389
+
371
390
  logger.info(f"Journey completed in {results['execution_time']:.2f} seconds")
372
391
  logger.info(f"Overall success: {results['overall_success']}")
373
392
  logger.info(f"Steps: {results['steps_succeeded']}/{results['steps_executed']} succeeded")
@@ -5,6 +5,7 @@ from selenium.webdriver.chrome.options import Options
5
5
  from typing import Optional, Dict, Any, List
6
6
  from ..behaviors.base import Behavior
7
7
  from .base import Journey
8
+ from ..core.headers import HeaderExtractor
8
9
 
9
10
  # Configure logging
10
11
  logging.basicConfig(
@@ -64,8 +65,12 @@ class JourneyExecutor:
64
65
  else:
65
66
  self.chrome_options.add_argument(f"--{key}={value}")
66
67
 
68
+ # Enable header extraction capabilities
69
+ HeaderExtractor.enable_logging_for_driver(self.chrome_options)
70
+
67
71
  self.driver = None
68
72
  self.execution_results = None
73
+ self.header_extractor = HeaderExtractor()
69
74
 
70
75
  def _setup_driver(self):
71
76
  """Initialize the WebDriver."""
@@ -297,14 +302,30 @@ class JourneyExecutor:
297
302
  step_success = step_result.get('actual', False)
298
303
  step_expected = step_result.get('expected', True)
299
304
  actions = step_result.get('actions', [])
305
+ target_version = step_result.get('target_version')
300
306
 
301
307
  status = "✓" if step_success == step_expected else "✗"
302
308
  result_text = "SUCCESS" if step_success else "FAILURE"
303
309
  expected_text = "expected" if step_success == step_expected else "unexpected"
310
+ version_info = f" | Version: {target_version}" if target_version else ""
304
311
 
305
- self.logger.info(f" {status} Step {i}: {step_name} - {result_text} ({expected_text})")
312
+ self.logger.info(f" {status} Step {i}: {step_name} - {result_text} ({expected_text}){version_info}")
306
313
  self.logger.info(f" Actions: {len([a for a in actions if a.get('actual', False)])}/{len(actions)} succeeded")
307
314
 
315
+ # Version summary
316
+ target_versions = self.execution_results.get('target_versions', [])
317
+
318
+ if target_versions:
319
+ self.logger.info("\nTarget Version Summary:")
320
+ self.logger.info(f" Steps with version info: {len(target_versions)}/{len(step_results) if step_results else 0}")
321
+ unique_versions = list(set(target_versions))
322
+ if unique_versions:
323
+ for version in unique_versions:
324
+ count = target_versions.count(version)
325
+ self.logger.info(f" Version {version}: {count} step(s)")
326
+ else:
327
+ self.logger.info("\nNo X-SCYTHE-TARGET-VERSION headers detected in responses.")
328
+
308
329
  self.logger.info("="*60)
309
330
 
310
331
  def get_results(self) -> Optional[Dict[str, Any]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scythe-ttp
3
- Version: 0.9.2
3
+ Version: 0.11.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
@@ -96,6 +96,7 @@ Scythe operates on the principle that robust systems must be tested under advers
96
96
  ### 📊 **Professional Reporting**
97
97
  * **Clear Result Indicators**: ✓ Expected outcomes, ✗ Unexpected results
98
98
  * **Comprehensive Logging**: Detailed execution tracking and analysis
99
+ * **Version Detection**: Automatic extraction of X-SCYTHE-TARGET-VERSION headers
99
100
  * **Performance Metrics**: Timing, success rates, and resource utilization
100
101
  * **Execution Statistics**: Detailed reporting across all test types
101
102
 
@@ -443,6 +444,74 @@ executor = TTPExecutor(
443
444
  )
444
445
  ```
445
446
 
447
+ ### Version Detection
448
+
449
+ Scythe automatically captures the `X-SCYTHE-TARGET-VERSION` header from HTTP responses to track which version of your web application is being tested:
450
+
451
+ ```python
452
+ from scythe.core.ttp import TTP
453
+ from scythe.core.executor import TTPExecutor
454
+
455
+ # Your web application should set this header:
456
+ # X-SCYTHE-TARGET-VERSION: 1.3.2
457
+
458
+ class MyTTP(TTP):
459
+ def get_payloads(self):
460
+ yield "test_payload"
461
+
462
+ def execute_step(self, driver, payload):
463
+ driver.get("http://your-app.com/login")
464
+ # ... test logic ...
465
+
466
+ def verify_result(self, driver):
467
+ return "welcome" in driver.page_source
468
+
469
+ # Run the test
470
+ ttp = MyTTP("Version Test", "Test with version detection")
471
+ executor = TTPExecutor(ttp=ttp, target_url="http://your-app.com")
472
+ executor.run()
473
+ ```
474
+
475
+ **Output includes version information:**
476
+ ```
477
+ ✓ EXPECTED SUCCESS: 'test_payload' | Version: 1.3.2
478
+ Target Version Summary:
479
+ Results with version info: 1/1
480
+ Version 1.3.2: 1 result(s)
481
+ ```
482
+
483
+ **Server-side implementation examples:**
484
+ ```python
485
+ # Python/Flask
486
+ @app.after_request
487
+ def add_version_header(response):
488
+ response.headers['X-SCYTHE-TARGET-VERSION'] = '1.3.2'
489
+ return response
490
+
491
+ # Node.js/Express
492
+ app.use((req, res, next) => {
493
+ res.set('X-SCYTHE-TARGET-VERSION', '1.3.2');
494
+ next();
495
+ });
496
+
497
+ # Java/Spring Boot
498
+ @Component
499
+ public class VersionHeaderFilter implements Filter {
500
+ @Override
501
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
502
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
503
+ httpResponse.setHeader("X-SCYTHE-TARGET-VERSION", "1.3.2");
504
+ chain.doFilter(request, response);
505
+ }
506
+ }
507
+ ```
508
+
509
+ This feature helps you:
510
+ - **Track test results** by application version
511
+ - **Verify deployment status** during testing
512
+ - **Correlate issues** with specific software versions
513
+ - **Ensure consistency** across test environments
514
+
446
515
  ### Custom Test Creation
447
516
 
448
517
  Extend Scythe for specific testing needs:
@@ -17,6 +17,7 @@ scythe/behaviors/machine.py
17
17
  scythe/behaviors/stealth.py
18
18
  scythe/core/__init__.py
19
19
  scythe/core/executor.py
20
+ scythe/core/headers.py
20
21
  scythe/core/ttp.py
21
22
  scythe/journeys/__init__.py
22
23
  scythe/journeys/actions.py
@@ -43,5 +44,6 @@ tests/test_authentication.py
43
44
  tests/test_behaviors.py
44
45
  tests/test_expected_results.py
45
46
  tests/test_feature_completeness.py
47
+ tests/test_header_extraction.py
46
48
  tests/test_journeys.py
47
49
  tests/test_orchestrators.py
@@ -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.9.2",
11
+ version="0.11.0",
12
12
  author="EpykLab",
13
13
  author_email="cyber@epyklab.com",
14
14
  description="An extensible framework for emulating attacker TTPs with Selenium.",
@@ -282,7 +282,10 @@ class TestFeatureIntegration(unittest.TestCase):
282
282
 
283
283
  self.assertEqual(len(journey.steps), 1)
284
284
  self.assertEqual(len(journey.steps[0].actions), 1)
285
- self.assertTrue(journey.steps[0].actions[0].ttp.requires_authentication())
285
+ # Type assertion to help type checker understand this is a TTPAction
286
+ ttp_action_instance = journey.steps[0].actions[0]
287
+ assert isinstance(ttp_action_instance, TTPAction)
288
+ self.assertTrue(ttp_action_instance.ttp.requires_authentication())
286
289
 
287
290
  def test_orchestrator_with_journey_and_auth(self):
288
291
  """Test orchestrator running journey with authentication."""
@@ -0,0 +1,400 @@
1
+ import unittest
2
+ from unittest.mock import Mock, patch
3
+ import json
4
+ from scythe.core.headers import HeaderExtractor
5
+
6
+
7
+ class TestHeaderExtractor(unittest.TestCase):
8
+ """Unit tests for HeaderExtractor class."""
9
+
10
+ def setUp(self):
11
+ """Set up test fixtures."""
12
+ self.extractor = HeaderExtractor()
13
+ self.mock_driver = Mock()
14
+
15
+ def test_extract_target_version_success(self):
16
+ """Test successful extraction of X-SCYTHE-TARGET-VERSION header."""
17
+ # Mock performance logs with version header
18
+ mock_logs = [
19
+ {
20
+ 'message': json.dumps({
21
+ 'message': {
22
+ 'method': 'Network.responseReceived',
23
+ 'params': {
24
+ 'response': {
25
+ 'url': 'http://example.com/',
26
+ 'headers': {
27
+ 'X-SCYTHE-TARGET-VERSION': '1.3.2',
28
+ 'Content-Type': 'text/html'
29
+ }
30
+ }
31
+ }
32
+ }
33
+ })
34
+ }
35
+ ]
36
+
37
+ self.mock_driver.get_log.return_value = mock_logs
38
+
39
+ version = self.extractor.extract_target_version(self.mock_driver)
40
+
41
+ self.assertEqual(version, '1.3.2')
42
+ self.mock_driver.get_log.assert_called_once_with('performance')
43
+
44
+ def test_extract_target_version_case_insensitive(self):
45
+ """Test case-insensitive header extraction."""
46
+ mock_logs = [
47
+ {
48
+ 'message': json.dumps({
49
+ 'message': {
50
+ 'method': 'Network.responseReceived',
51
+ 'params': {
52
+ 'response': {
53
+ 'url': 'http://example.com/',
54
+ 'headers': {
55
+ 'x-scythe-target-version': '2.0.1', # lowercase
56
+ 'Content-Type': 'text/html'
57
+ }
58
+ }
59
+ }
60
+ }
61
+ })
62
+ }
63
+ ]
64
+
65
+ self.mock_driver.get_log.return_value = mock_logs
66
+
67
+ version = self.extractor.extract_target_version(self.mock_driver)
68
+
69
+ self.assertEqual(version, '2.0.1')
70
+
71
+ def test_extract_target_version_no_header(self):
72
+ """Test extraction when version header is not present."""
73
+ mock_logs = [
74
+ {
75
+ 'message': json.dumps({
76
+ 'message': {
77
+ 'method': 'Network.responseReceived',
78
+ 'params': {
79
+ 'response': {
80
+ 'url': 'http://example.com/',
81
+ 'headers': {
82
+ 'Content-Type': 'text/html',
83
+ 'Server': 'nginx'
84
+ }
85
+ }
86
+ }
87
+ }
88
+ })
89
+ }
90
+ ]
91
+
92
+ self.mock_driver.get_log.return_value = mock_logs
93
+
94
+ version = self.extractor.extract_target_version(self.mock_driver)
95
+
96
+ self.assertIsNone(version)
97
+
98
+ def test_extract_target_version_empty_logs(self):
99
+ """Test extraction when no performance logs are available."""
100
+ self.mock_driver.get_log.return_value = []
101
+
102
+ version = self.extractor.extract_target_version(self.mock_driver)
103
+
104
+ self.assertIsNone(version)
105
+
106
+ def test_extract_target_version_with_url_filter(self):
107
+ """Test extraction with URL filtering."""
108
+ mock_logs = [
109
+ {
110
+ 'message': json.dumps({
111
+ 'message': {
112
+ 'method': 'Network.responseReceived',
113
+ 'params': {
114
+ 'response': {
115
+ 'url': 'http://other.com/',
116
+ 'headers': {
117
+ 'X-SCYTHE-TARGET-VERSION': '1.0.0',
118
+ }
119
+ }
120
+ }
121
+ }
122
+ })
123
+ },
124
+ {
125
+ 'message': json.dumps({
126
+ 'message': {
127
+ 'method': 'Network.responseReceived',
128
+ 'params': {
129
+ 'response': {
130
+ 'url': 'http://target.com/api',
131
+ 'headers': {
132
+ 'X-SCYTHE-TARGET-VERSION': '2.5.1',
133
+ }
134
+ }
135
+ }
136
+ }
137
+ })
138
+ }
139
+ ]
140
+
141
+ self.mock_driver.get_log.return_value = mock_logs
142
+
143
+ # Should find version from target.com, not other.com
144
+ version = self.extractor.extract_target_version(self.mock_driver, 'http://target.com')
145
+
146
+ self.assertEqual(version, '2.5.1')
147
+
148
+ def test_extract_target_version_malformed_json(self):
149
+ """Test extraction handles malformed JSON gracefully."""
150
+ mock_logs = [
151
+ {
152
+ 'message': 'invalid json content'
153
+ },
154
+ {
155
+ 'message': json.dumps({
156
+ 'message': {
157
+ 'method': 'Network.responseReceived',
158
+ 'params': {
159
+ 'response': {
160
+ 'url': 'http://example.com/',
161
+ 'headers': {
162
+ 'X-SCYTHE-TARGET-VERSION': '1.4.0',
163
+ }
164
+ }
165
+ }
166
+ }
167
+ })
168
+ }
169
+ ]
170
+
171
+ self.mock_driver.get_log.return_value = mock_logs
172
+
173
+ version = self.extractor.extract_target_version(self.mock_driver)
174
+
175
+ # Should skip malformed entry and find valid one
176
+ self.assertEqual(version, '1.4.0')
177
+
178
+ def test_extract_target_version_driver_exception(self):
179
+ """Test extraction handles driver exceptions gracefully."""
180
+ self.mock_driver.get_log.side_effect = Exception("Driver error")
181
+
182
+ version = self.extractor.extract_target_version(self.mock_driver)
183
+
184
+ self.assertIsNone(version)
185
+
186
+ def test_find_version_header_exact_match(self):
187
+ """Test _find_version_header with exact case match."""
188
+ headers = {
189
+ 'X-SCYTHE-TARGET-VERSION': '3.1.4',
190
+ 'Content-Type': 'application/json'
191
+ }
192
+
193
+ version = self.extractor._find_version_header(headers)
194
+
195
+ self.assertEqual(version, '3.1.4')
196
+
197
+ def test_find_version_header_case_insensitive_match(self):
198
+ """Test _find_version_header with case-insensitive match."""
199
+ headers = {
200
+ 'x-scythe-target-version': '2.7.8',
201
+ 'content-type': 'application/json'
202
+ }
203
+
204
+ version = self.extractor._find_version_header(headers)
205
+
206
+ self.assertEqual(version, '2.7.8')
207
+
208
+ def test_find_version_header_mixed_case(self):
209
+ """Test _find_version_header with mixed case."""
210
+ headers = {
211
+ 'X-Scythe-Target-Version': '1.2.3-beta',
212
+ 'Content-Type': 'text/html'
213
+ }
214
+
215
+ version = self.extractor._find_version_header(headers)
216
+
217
+ self.assertEqual(version, '1.2.3-beta')
218
+
219
+ def test_find_version_header_not_found(self):
220
+ """Test _find_version_header when header is not present."""
221
+ headers = {
222
+ 'Content-Type': 'text/html',
223
+ 'Server': 'Apache'
224
+ }
225
+
226
+ version = self.extractor._find_version_header(headers)
227
+
228
+ self.assertIsNone(version)
229
+
230
+ def test_find_version_header_strips_whitespace(self):
231
+ """Test _find_version_header strips whitespace from values."""
232
+ headers = {
233
+ 'X-SCYTHE-TARGET-VERSION': ' 2.0.0 '
234
+ }
235
+
236
+ version = self.extractor._find_version_header(headers)
237
+
238
+ self.assertEqual(version, '2.0.0')
239
+
240
+ def test_extract_all_headers_success(self):
241
+ """Test successful extraction of all headers."""
242
+ mock_logs = [
243
+ {
244
+ 'message': json.dumps({
245
+ 'message': {
246
+ 'method': 'Network.responseReceived',
247
+ 'params': {
248
+ 'response': {
249
+ 'url': 'http://example.com/',
250
+ 'headers': {
251
+ 'X-SCYTHE-TARGET-VERSION': '1.0.0',
252
+ 'Content-Type': 'text/html',
253
+ 'Server': 'nginx/1.18'
254
+ }
255
+ }
256
+ }
257
+ }
258
+ })
259
+ }
260
+ ]
261
+
262
+ self.mock_driver.get_log.return_value = mock_logs
263
+
264
+ headers = self.extractor.extract_all_headers(self.mock_driver)
265
+
266
+ expected_headers = {
267
+ 'X-SCYTHE-TARGET-VERSION': '1.0.0',
268
+ 'Content-Type': 'text/html',
269
+ 'Server': 'nginx/1.18'
270
+ }
271
+
272
+ self.assertEqual(headers, expected_headers)
273
+
274
+ def test_extract_all_headers_empty(self):
275
+ """Test extract_all_headers with no logs."""
276
+ self.mock_driver.get_log.return_value = []
277
+
278
+ headers = self.extractor.extract_all_headers(self.mock_driver)
279
+
280
+ self.assertEqual(headers, {})
281
+
282
+ def test_get_version_summary_with_versions(self):
283
+ """Test get_version_summary with version data."""
284
+ results = [
285
+ {'target_version': '1.0.0'},
286
+ {'target_version': '1.0.0'},
287
+ {'target_version': '1.1.0'},
288
+ {'target_version': None}, # No version
289
+ {'target_version': '1.0.0'}
290
+ ]
291
+
292
+ summary = self.extractor.get_version_summary(results)
293
+
294
+ expected_summary = {
295
+ 'total_results': 5,
296
+ 'results_with_version': 4,
297
+ 'unique_versions': ['1.0.0', '1.1.0'],
298
+ 'version_counts': {
299
+ '1.0.0': 3,
300
+ '1.1.0': 1
301
+ }
302
+ }
303
+
304
+ self.assertEqual(summary['total_results'], expected_summary['total_results'])
305
+ self.assertEqual(summary['results_with_version'], expected_summary['results_with_version'])
306
+ self.assertEqual(set(summary['unique_versions']), set(expected_summary['unique_versions']))
307
+ self.assertEqual(summary['version_counts'], expected_summary['version_counts'])
308
+
309
+ def test_get_version_summary_no_versions(self):
310
+ """Test get_version_summary with no version data."""
311
+ results = [
312
+ {'target_version': None},
313
+ {'other_field': 'value'},
314
+ {}
315
+ ]
316
+
317
+ summary = self.extractor.get_version_summary(results)
318
+
319
+ expected_summary = {
320
+ 'total_results': 3,
321
+ 'results_with_version': 0,
322
+ 'unique_versions': [],
323
+ 'version_counts': {}
324
+ }
325
+
326
+ self.assertEqual(summary, expected_summary)
327
+
328
+ def test_get_version_summary_empty_results(self):
329
+ """Test get_version_summary with empty results list."""
330
+ summary = self.extractor.get_version_summary([])
331
+
332
+ expected_summary = {
333
+ 'total_results': 0,
334
+ 'results_with_version': 0,
335
+ 'unique_versions': [],
336
+ 'version_counts': {}
337
+ }
338
+
339
+ self.assertEqual(summary, expected_summary)
340
+
341
+ @patch('scythe.core.headers.Options')
342
+ def test_enable_logging_for_driver(self, mock_options):
343
+ """Test that enable_logging_for_driver sets correct options."""
344
+ mock_chrome_options = Mock()
345
+
346
+ HeaderExtractor.enable_logging_for_driver(mock_chrome_options)
347
+
348
+ # Verify the correct arguments and capabilities are set
349
+ mock_chrome_options.add_argument.assert_any_call("--enable-logging")
350
+ mock_chrome_options.add_argument.assert_any_call("--log-level=0")
351
+ mock_chrome_options.set_capability.assert_called_once_with(
352
+ "goog:loggingPrefs",
353
+ {"performance": "ALL"}
354
+ )
355
+
356
+ def test_extract_target_version_with_multiple_responses(self):
357
+ """Test that most recent response is used when multiple responses exist."""
358
+ mock_logs = [
359
+ {
360
+ 'message': json.dumps({
361
+ 'message': {
362
+ 'method': 'Network.responseReceived',
363
+ 'params': {
364
+ 'response': {
365
+ 'url': 'http://example.com/old',
366
+ 'headers': {
367
+ 'X-SCYTHE-TARGET-VERSION': '1.0.0',
368
+ }
369
+ }
370
+ }
371
+ }
372
+ })
373
+ },
374
+ {
375
+ 'message': json.dumps({
376
+ 'message': {
377
+ 'method': 'Network.responseReceived',
378
+ 'params': {
379
+ 'response': {
380
+ 'url': 'http://example.com/new',
381
+ 'headers': {
382
+ 'X-SCYTHE-TARGET-VERSION': '2.0.0',
383
+ }
384
+ }
385
+ }
386
+ }
387
+ })
388
+ }
389
+ ]
390
+
391
+ self.mock_driver.get_log.return_value = mock_logs
392
+
393
+ # Should get most recent version (logs are processed in reverse order)
394
+ version = self.extractor.extract_target_version(self.mock_driver)
395
+
396
+ self.assertEqual(version, '2.0.0')
397
+
398
+
399
+ if __name__ == '__main__':
400
+ unittest.main()
scythe_ttp-0.9.2/VERSION DELETED
@@ -1 +0,0 @@
1
- 0.10.0
File without changes
File without changes
File without changes
File without changes