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.
- {scythe_ttp-0.9.2/scythe_ttp.egg-info → scythe_ttp-0.11.0}/PKG-INFO +70 -1
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/README.md +69 -0
- scythe_ttp-0.11.0/VERSION +1 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/executor.py +38 -5
- scythe_ttp-0.11.0/scythe/core/headers.py +194 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/base.py +21 -2
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/executor.py +22 -1
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0/scythe_ttp.egg-info}/PKG-INFO +70 -1
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/SOURCES.txt +2 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/setup.py +1 -1
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_feature_completeness.py +4 -1
- scythe_ttp-0.11.0/tests/test_header_extraction.py +400 -0
- scythe_ttp-0.9.2/VERSION +0 -1
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/LICENSE +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/MANIFEST.in +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/requirements.txt +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/base.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/basic.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/auth/bearer.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/base.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/default.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/human.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/machine.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/behaviors/stealth.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/core/ttp.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/journeys/actions.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/base.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/batch.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/distributed.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/orchestrators/scale.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/payloads/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/payloads/generators.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/__init__.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/login_bruteforce.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/sql_injection.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe/ttps/web/uuid_guessing.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/dependency_links.txt +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/requires.txt +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/scythe_ttp.egg-info/top_level.txt +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/setup.cfg +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_authentication.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_behaviors.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_expected_results.py +0 -0
- {scythe_ttp-0.9.2 → scythe_ttp-0.11.0}/tests/test_journeys.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|