mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add header analyzer orchestrator
This commit is contained in:
@@ -63,7 +63,7 @@ backend/app/engine/
|
||||
- `backend/app/engine/scanners/proofpoint.py` (tests 66–67)
|
||||
- `backend/app/engine/scanners/microsoft_general.py` (tests 31–34, 80, 83–85, 99–102)
|
||||
- `backend/app/engine/scanners/general.py` (remaining tests: 4–11, 17, 22–26, 36–37, 44–46, 68, 75–79, 82, 86–87, 90–96, 98, 103–106)
|
||||
- [ ] T014 Create `backend/app/engine/analyzer.py` — `HeaderAnalyzer` orchestrator: accepts `AnalysisRequest`, uses `HeaderParser` + `ScannerRegistry`, runs scanners with per-test timeout, collects results (marking failed tests with error status per FR-25), supports progress callback `Callable[[int, int, str], None]`. Verify `test_analyzer.py` passes (TDD Green)
|
||||
- [x] T014 Create `backend/app/engine/analyzer.py` — `HeaderAnalyzer` orchestrator: accepts `AnalysisRequest`, uses `HeaderParser` + `ScannerRegistry`, runs scanners with per-test timeout, collects results (marking failed tests with error status per FR-25), supports progress callback `Callable[[int, int, str], None]`. Verify `test_analyzer.py` passes (TDD Green)
|
||||
|
||||
## Completion
|
||||
|
||||
|
||||
140
backend/app/engine/analyzer.py
Normal file
140
backend/app/engine/analyzer.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
|
||||
from .models import AnalysisRequest, AnalysisResult, ReportMetadata, Severity, TestResult, TestStatus
|
||||
from .parser import HeaderParser, ParsedHeader
|
||||
from .scanner_base import BaseScanner
|
||||
from .scanner_registry import ScannerRegistry
|
||||
|
||||
|
||||
ProgressCallback = Callable[[int, int, str], None]
|
||||
|
||||
DEFAULT_PER_TEST_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
|
||||
class HeaderAnalyzer:
|
||||
def __init__(
|
||||
self,
|
||||
parser: HeaderParser | None = None,
|
||||
registry: ScannerRegistry | None = None,
|
||||
per_test_timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
self._parser = parser or HeaderParser()
|
||||
self._registry = registry or ScannerRegistry()
|
||||
if per_test_timeout_seconds is None:
|
||||
per_test_timeout_seconds = DEFAULT_PER_TEST_TIMEOUT_SECONDS
|
||||
self._per_test_timeout_seconds = max(0.0, float(per_test_timeout_seconds))
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
request: AnalysisRequest,
|
||||
progress_callback: ProgressCallback | None = None,
|
||||
) -> AnalysisResult:
|
||||
start = perf_counter()
|
||||
headers = self._parser.parse(request.headers)
|
||||
scanners = self._select_scanners(request)
|
||||
total_tests = len(scanners)
|
||||
|
||||
results: list[TestResult] = []
|
||||
passed = failed = skipped = 0
|
||||
incomplete: list[str] = []
|
||||
|
||||
for index, scanner in enumerate(scanners):
|
||||
if progress_callback is not None:
|
||||
try:
|
||||
progress_callback(index, total_tests, scanner.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = self._run_scanner(scanner, headers)
|
||||
except TimeoutError as exc:
|
||||
failed += 1
|
||||
incomplete.append(scanner.name)
|
||||
results.append(self._error_result(scanner, str(exc)))
|
||||
continue
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
incomplete.append(scanner.name)
|
||||
results.append(self._error_result(scanner, str(exc)))
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
skipped += 1
|
||||
results.append(self._skipped_result(scanner))
|
||||
continue
|
||||
|
||||
if result.status == TestStatus.error:
|
||||
failed += 1
|
||||
incomplete.append(scanner.name)
|
||||
elif result.status == TestStatus.skipped:
|
||||
skipped += 1
|
||||
else:
|
||||
passed += 1
|
||||
results.append(result)
|
||||
|
||||
elapsed_ms = (perf_counter() - start) * 1000
|
||||
metadata = ReportMetadata(
|
||||
total_tests=total_tests,
|
||||
passed_tests=passed,
|
||||
failed_tests=failed,
|
||||
skipped_tests=skipped,
|
||||
elapsed_ms=elapsed_ms,
|
||||
timed_out=False,
|
||||
incomplete_tests=incomplete,
|
||||
)
|
||||
return AnalysisResult(results=results, metadata=metadata)
|
||||
|
||||
def _select_scanners(self, request: AnalysisRequest) -> list[BaseScanner]:
|
||||
if request.config.test_ids:
|
||||
return self._registry.get_by_ids(request.config.test_ids)
|
||||
return self._registry.get_all()
|
||||
|
||||
def _run_scanner(
|
||||
self, scanner: BaseScanner, headers: list[ParsedHeader]
|
||||
) -> TestResult | None:
|
||||
if self._per_test_timeout_seconds <= 0:
|
||||
return scanner.run(headers)
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
future = executor.submit(scanner.run, headers)
|
||||
try:
|
||||
return future.result(timeout=self._per_test_timeout_seconds)
|
||||
except FutureTimeoutError as exc:
|
||||
future.cancel()
|
||||
raise TimeoutError(
|
||||
f"Test {scanner.id} timed out after {self._per_test_timeout_seconds:.2f}s"
|
||||
) from exc
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
@staticmethod
|
||||
def _error_result(scanner: BaseScanner, message: str) -> TestResult:
|
||||
return TestResult(
|
||||
test_id=scanner.id,
|
||||
test_name=scanner.name,
|
||||
header_name="-",
|
||||
header_value="-",
|
||||
analysis="",
|
||||
description="",
|
||||
severity=Severity.info,
|
||||
status=TestStatus.error,
|
||||
error=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _skipped_result(scanner: BaseScanner) -> TestResult:
|
||||
return TestResult(
|
||||
test_id=scanner.id,
|
||||
test_name=scanner.name,
|
||||
header_name="-",
|
||||
header_value="-",
|
||||
analysis="",
|
||||
description="",
|
||||
severity=Severity.info,
|
||||
status=TestStatus.skipped,
|
||||
error=None,
|
||||
)
|
||||
Reference in New Issue
Block a user