nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Enhanced Anomaly Detection with adaptive thresholds and classification.
|
|
7
|
+
|
|
8
|
+
Learns the *normal* response profile for each endpoint and scores incoming
|
|
9
|
+
responses on how anomalous they are. Anomalies are classified into:
|
|
10
|
+
|
|
11
|
+
* ``latency_spike`` — Response time significantly above learned baseline.
|
|
12
|
+
* ``status_code_shift`` — Unexpected status code pattern change.
|
|
13
|
+
* ``payload_anomaly`` — Response body structure deviates from learned norm.
|
|
14
|
+
* ``error_rate_surge`` — Sudden increase in error rate.
|
|
15
|
+
|
|
16
|
+
Thresholds adapt automatically based on the rolling baseline rather than being
|
|
17
|
+
hardcoded, which prevents false positives on naturally slow endpoints.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import statistics
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnomalyType(str, Enum):
|
|
33
|
+
"""Taxonomy of detectable anomaly types."""
|
|
34
|
+
|
|
35
|
+
LATENCY_SPIKE = "latency_spike"
|
|
36
|
+
STATUS_CODE_SHIFT = "status_code_shift"
|
|
37
|
+
PAYLOAD_ANOMALY = "payload_anomaly"
|
|
38
|
+
ERROR_RATE_SURGE = "error_rate_surge"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Severity(str, Enum):
|
|
42
|
+
"""Alert severity levels."""
|
|
43
|
+
|
|
44
|
+
INFO = "info"
|
|
45
|
+
WARNING = "warning"
|
|
46
|
+
CRITICAL = "critical"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class AnomalyAlert:
|
|
51
|
+
"""A structured anomaly alert produced by the detector.
|
|
52
|
+
|
|
53
|
+
Attributes
|
|
54
|
+
----------
|
|
55
|
+
endpoint:
|
|
56
|
+
The API endpoint where the anomaly was detected.
|
|
57
|
+
anomaly_type:
|
|
58
|
+
Classification of the detected anomaly.
|
|
59
|
+
severity:
|
|
60
|
+
How severe the anomaly is (info / warning / critical).
|
|
61
|
+
score:
|
|
62
|
+
Anomaly score in [0.0, 1.0]; 1.0 = maximally anomalous.
|
|
63
|
+
description:
|
|
64
|
+
Human-readable explanation of what was detected.
|
|
65
|
+
detected_at:
|
|
66
|
+
ISO 8601 timestamp of when the alert was generated.
|
|
67
|
+
scan_id:
|
|
68
|
+
Optional scan identifier linking the alert to a specific run.
|
|
69
|
+
details:
|
|
70
|
+
Additional structured information about the anomaly.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
endpoint: str
|
|
74
|
+
anomaly_type: AnomalyType
|
|
75
|
+
severity: Severity
|
|
76
|
+
score: float
|
|
77
|
+
description: str
|
|
78
|
+
detected_at: str = field(
|
|
79
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
80
|
+
)
|
|
81
|
+
scan_id: Optional[str] = None
|
|
82
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"endpoint": self.endpoint,
|
|
87
|
+
"anomaly_type": self.anomaly_type,
|
|
88
|
+
"severity": self.severity,
|
|
89
|
+
"score": round(self.score, 4),
|
|
90
|
+
"description": self.description,
|
|
91
|
+
"detected_at": self.detected_at,
|
|
92
|
+
"scan_id": self.scan_id,
|
|
93
|
+
"details": self.details,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Per-endpoint baseline statistics
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _EndpointBaseline:
|
|
103
|
+
"""Rolling baseline stats for a single endpoint."""
|
|
104
|
+
|
|
105
|
+
latencies: List[float] = field(default_factory=list)
|
|
106
|
+
status_codes: List[int] = field(default_factory=list)
|
|
107
|
+
payload_sizes: List[int] = field(default_factory=list)
|
|
108
|
+
error_flags: List[bool] = field(default_factory=list)
|
|
109
|
+
seen_status_codes: Set[int] = field(default_factory=set)
|
|
110
|
+
|
|
111
|
+
# Adaptive thresholds (recomputed periodically)
|
|
112
|
+
latency_mean: float = 0.0
|
|
113
|
+
latency_std: float = 0.0
|
|
114
|
+
error_rate: float = 0.0
|
|
115
|
+
payload_size_mean: float = 0.0
|
|
116
|
+
payload_size_std: float = 0.0
|
|
117
|
+
|
|
118
|
+
_window: int = 50 # rolling window size
|
|
119
|
+
|
|
120
|
+
def update(
|
|
121
|
+
self,
|
|
122
|
+
latency_ms: float,
|
|
123
|
+
status_code: int,
|
|
124
|
+
payload_size: int,
|
|
125
|
+
is_error: bool,
|
|
126
|
+
) -> None:
|
|
127
|
+
def _push(lst: list, val: Any) -> None:
|
|
128
|
+
lst.append(val)
|
|
129
|
+
if len(lst) > self._window:
|
|
130
|
+
lst.pop(0)
|
|
131
|
+
|
|
132
|
+
_push(self.latencies, latency_ms)
|
|
133
|
+
_push(self.status_codes, status_code)
|
|
134
|
+
_push(self.payload_sizes, payload_size)
|
|
135
|
+
_push(self.error_flags, is_error)
|
|
136
|
+
self.seen_status_codes.add(status_code)
|
|
137
|
+
|
|
138
|
+
if len(self.latencies) >= 3:
|
|
139
|
+
self.latency_mean = statistics.mean(self.latencies)
|
|
140
|
+
self.latency_std = statistics.stdev(self.latencies) if len(self.latencies) > 1 else 0.0
|
|
141
|
+
if self.payload_sizes:
|
|
142
|
+
self.payload_size_mean = statistics.mean(self.payload_sizes)
|
|
143
|
+
self.payload_size_std = (
|
|
144
|
+
statistics.stdev(self.payload_sizes) if len(self.payload_sizes) > 1 else 0.0
|
|
145
|
+
)
|
|
146
|
+
if self.error_flags:
|
|
147
|
+
self.error_rate = sum(self.error_flags) / len(self.error_flags)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_warmed_up(self) -> bool:
|
|
151
|
+
"""True once the baseline has enough data to compute thresholds."""
|
|
152
|
+
return len(self.latencies) >= 5
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Main detector
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
class EnhancedAnomalyDetector:
|
|
160
|
+
"""Adaptive anomaly detector for API test responses.
|
|
161
|
+
|
|
162
|
+
Learns per-endpoint baseline response profiles and scores each new
|
|
163
|
+
response on how anomalous it is. Anomaly thresholds adapt automatically
|
|
164
|
+
as the baseline evolves.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
latency_sigma_threshold:
|
|
169
|
+
Number of standard deviations above the mean latency to trigger a
|
|
170
|
+
``latency_spike`` warning. Default is 2σ (≈95th percentile).
|
|
171
|
+
latency_critical_sigma:
|
|
172
|
+
Standard deviations threshold for a *critical* latency spike.
|
|
173
|
+
error_rate_warning_threshold:
|
|
174
|
+
Error rate (0–1) at which an ``error_rate_surge`` warning fires.
|
|
175
|
+
error_rate_critical_threshold:
|
|
176
|
+
Error rate at which a *critical* surge fires.
|
|
177
|
+
payload_sigma_threshold:
|
|
178
|
+
Standard deviations from mean payload size to flag a payload anomaly.
|
|
179
|
+
alert_history_limit:
|
|
180
|
+
Maximum number of alerts retained in memory.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
latency_sigma_threshold: float = 2.0,
|
|
186
|
+
latency_critical_sigma: float = 4.0,
|
|
187
|
+
error_rate_warning_threshold: float = 0.3,
|
|
188
|
+
error_rate_critical_threshold: float = 0.6,
|
|
189
|
+
payload_sigma_threshold: float = 3.0,
|
|
190
|
+
alert_history_limit: int = 500,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._lat_warn_sigma = latency_sigma_threshold
|
|
193
|
+
self._lat_crit_sigma = latency_critical_sigma
|
|
194
|
+
self._err_warn = error_rate_warning_threshold
|
|
195
|
+
self._err_crit = error_rate_critical_threshold
|
|
196
|
+
self._payload_sigma = payload_sigma_threshold
|
|
197
|
+
self._alert_limit = alert_history_limit
|
|
198
|
+
|
|
199
|
+
self._baselines: Dict[str, _EndpointBaseline] = defaultdict(
|
|
200
|
+
_EndpointBaseline
|
|
201
|
+
)
|
|
202
|
+
self._alerts: List[AnomalyAlert] = []
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Core observe API
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def observe(
|
|
209
|
+
self,
|
|
210
|
+
endpoint: str,
|
|
211
|
+
latency_ms: float,
|
|
212
|
+
status_code: int,
|
|
213
|
+
payload: Any = None,
|
|
214
|
+
is_error: bool = False,
|
|
215
|
+
scan_id: Optional[str] = None,
|
|
216
|
+
) -> Tuple[float, List[AnomalyAlert]]:
|
|
217
|
+
"""Record a new response observation and return anomaly signals.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
endpoint:
|
|
222
|
+
Target endpoint identifier.
|
|
223
|
+
latency_ms:
|
|
224
|
+
Response latency in milliseconds.
|
|
225
|
+
status_code:
|
|
226
|
+
HTTP status code returned.
|
|
227
|
+
payload:
|
|
228
|
+
Response body (used for payload size estimation).
|
|
229
|
+
is_error:
|
|
230
|
+
Whether this response counts as an error.
|
|
231
|
+
scan_id:
|
|
232
|
+
Optional scan identifier for alert attribution.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
(anomaly_score, new_alerts)
|
|
237
|
+
*anomaly_score* is in [0.0, 1.0].
|
|
238
|
+
*new_alerts* contains any new :class:`AnomalyAlert` objects
|
|
239
|
+
generated by this observation.
|
|
240
|
+
"""
|
|
241
|
+
payload_size = self._estimate_payload_size(payload)
|
|
242
|
+
baseline = self._baselines[endpoint]
|
|
243
|
+
|
|
244
|
+
# Compute anomaly scores *before* updating baseline to avoid
|
|
245
|
+
# the new observation polluting its own threshold check
|
|
246
|
+
new_alerts: List[AnomalyAlert] = []
|
|
247
|
+
composite_score = 0.0
|
|
248
|
+
|
|
249
|
+
if baseline.is_warmed_up:
|
|
250
|
+
lat_score, lat_alert = self._check_latency(
|
|
251
|
+
endpoint, latency_ms, baseline, scan_id
|
|
252
|
+
)
|
|
253
|
+
sc_score, sc_alert = self._check_status_code(
|
|
254
|
+
endpoint, status_code, baseline, scan_id
|
|
255
|
+
)
|
|
256
|
+
pl_score, pl_alert = self._check_payload(
|
|
257
|
+
endpoint, payload_size, baseline, scan_id
|
|
258
|
+
)
|
|
259
|
+
err_score, err_alert = self._check_error_rate(
|
|
260
|
+
endpoint, is_error, baseline, scan_id
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
for alert in [lat_alert, sc_alert, pl_alert, err_alert]:
|
|
264
|
+
if alert is not None:
|
|
265
|
+
new_alerts.append(alert)
|
|
266
|
+
self._store_alert(alert)
|
|
267
|
+
|
|
268
|
+
composite_score = min(
|
|
269
|
+
1.0, (lat_score + sc_score + pl_score + err_score) / 4.0
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Now update baseline
|
|
273
|
+
baseline.update(latency_ms, status_code, payload_size, is_error)
|
|
274
|
+
|
|
275
|
+
return composite_score, new_alerts
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Anomaly score (no alert side-effect)
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def score(
|
|
282
|
+
self,
|
|
283
|
+
endpoint: str,
|
|
284
|
+
latency_ms: float,
|
|
285
|
+
status_code: int,
|
|
286
|
+
payload: Any = None,
|
|
287
|
+
is_error: bool = False,
|
|
288
|
+
) -> float:
|
|
289
|
+
"""Return anomaly score for a response without updating the baseline."""
|
|
290
|
+
baseline = self._baselines.get(endpoint)
|
|
291
|
+
if baseline is None or not baseline.is_warmed_up:
|
|
292
|
+
return 0.0
|
|
293
|
+
|
|
294
|
+
lat_score, _ = self._check_latency(endpoint, latency_ms, baseline, None)
|
|
295
|
+
sc_score, _ = self._check_status_code(endpoint, status_code, baseline, None)
|
|
296
|
+
pl_score, _ = self._check_payload(
|
|
297
|
+
endpoint, self._estimate_payload_size(payload), baseline, None
|
|
298
|
+
)
|
|
299
|
+
err_score, _ = self._check_error_rate(endpoint, is_error, baseline, None)
|
|
300
|
+
|
|
301
|
+
return min(1.0, (lat_score + sc_score + pl_score + err_score) / 4.0)
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# Latency check
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def _check_latency(
|
|
308
|
+
self,
|
|
309
|
+
endpoint: str,
|
|
310
|
+
latency_ms: float,
|
|
311
|
+
baseline: _EndpointBaseline,
|
|
312
|
+
scan_id: Optional[str],
|
|
313
|
+
) -> Tuple[float, Optional[AnomalyAlert]]:
|
|
314
|
+
mean = baseline.latency_mean
|
|
315
|
+
std = baseline.latency_std
|
|
316
|
+
|
|
317
|
+
# When std is zero (all observations identical) use a relative floor
|
|
318
|
+
# so minor fluctuations don't trigger false positives.
|
|
319
|
+
if std < 1e-6:
|
|
320
|
+
# When all observations are identical (std = 0), apply a relative
|
|
321
|
+
# floor of 10 % of the mean (min 1 ms) to prevent every minor
|
|
322
|
+
# fluctuation from triggering a false positive.
|
|
323
|
+
_RELATIVE_FLOOR = 0.10 # 10 % of mean
|
|
324
|
+
_ABS_MIN_MS = 1.0 # absolute minimum floor (milliseconds)
|
|
325
|
+
std = max(_ABS_MIN_MS, mean * _RELATIVE_FLOOR)
|
|
326
|
+
|
|
327
|
+
deviation = (latency_ms - mean) / std
|
|
328
|
+
|
|
329
|
+
if deviation < self._lat_warn_sigma:
|
|
330
|
+
return 0.0, None
|
|
331
|
+
|
|
332
|
+
# Normalize: at warning threshold → 0.3, at critical → 0.8, beyond → 1.0
|
|
333
|
+
score = min(
|
|
334
|
+
1.0,
|
|
335
|
+
0.3 + 0.5 * (deviation - self._lat_warn_sigma)
|
|
336
|
+
/ max(1.0, self._lat_crit_sigma - self._lat_warn_sigma),
|
|
337
|
+
)
|
|
338
|
+
severity = (
|
|
339
|
+
Severity.CRITICAL if deviation >= self._lat_crit_sigma else Severity.WARNING
|
|
340
|
+
)
|
|
341
|
+
alert = AnomalyAlert(
|
|
342
|
+
endpoint=endpoint,
|
|
343
|
+
anomaly_type=AnomalyType.LATENCY_SPIKE,
|
|
344
|
+
severity=severity,
|
|
345
|
+
score=round(score, 4),
|
|
346
|
+
description=(
|
|
347
|
+
f"Latency {latency_ms:.1f}ms is {deviation:.1f}σ above the "
|
|
348
|
+
f"baseline mean of {mean:.1f}ms (σ={std:.1f}ms)"
|
|
349
|
+
),
|
|
350
|
+
scan_id=scan_id,
|
|
351
|
+
details={
|
|
352
|
+
"latency_ms": latency_ms,
|
|
353
|
+
"baseline_mean_ms": round(mean, 2),
|
|
354
|
+
"baseline_std_ms": round(std, 2),
|
|
355
|
+
"sigma_deviation": round(deviation, 2),
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
return score, alert
|
|
359
|
+
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
# Status code check
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
def _check_status_code(
|
|
365
|
+
self,
|
|
366
|
+
endpoint: str,
|
|
367
|
+
status_code: int,
|
|
368
|
+
baseline: _EndpointBaseline,
|
|
369
|
+
scan_id: Optional[str],
|
|
370
|
+
) -> Tuple[float, Optional[AnomalyAlert]]:
|
|
371
|
+
if status_code in baseline.seen_status_codes:
|
|
372
|
+
return 0.0, None
|
|
373
|
+
|
|
374
|
+
# Novel status code — severity depends on the code family
|
|
375
|
+
if status_code >= 500:
|
|
376
|
+
score, severity = 0.9, Severity.CRITICAL
|
|
377
|
+
elif status_code >= 400:
|
|
378
|
+
score, severity = 0.6, Severity.WARNING
|
|
379
|
+
else:
|
|
380
|
+
score, severity = 0.3, Severity.INFO
|
|
381
|
+
|
|
382
|
+
alert = AnomalyAlert(
|
|
383
|
+
endpoint=endpoint,
|
|
384
|
+
anomaly_type=AnomalyType.STATUS_CODE_SHIFT,
|
|
385
|
+
severity=severity,
|
|
386
|
+
score=score,
|
|
387
|
+
description=(
|
|
388
|
+
f"Unexpected status code {status_code} — not seen in baseline "
|
|
389
|
+
f"(known codes: {sorted(baseline.seen_status_codes)})"
|
|
390
|
+
),
|
|
391
|
+
scan_id=scan_id,
|
|
392
|
+
details={
|
|
393
|
+
"status_code": status_code,
|
|
394
|
+
"known_status_codes": sorted(baseline.seen_status_codes),
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
return score, alert
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
# Payload size check
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def _check_payload(
|
|
404
|
+
self,
|
|
405
|
+
endpoint: str,
|
|
406
|
+
payload_size: int,
|
|
407
|
+
baseline: _EndpointBaseline,
|
|
408
|
+
scan_id: Optional[str],
|
|
409
|
+
) -> Tuple[float, Optional[AnomalyAlert]]:
|
|
410
|
+
mean = baseline.payload_size_mean
|
|
411
|
+
std = baseline.payload_size_std if baseline.payload_size_std > 0 else 1.0
|
|
412
|
+
deviation = abs(payload_size - mean) / std
|
|
413
|
+
|
|
414
|
+
if deviation < self._payload_sigma:
|
|
415
|
+
return 0.0, None
|
|
416
|
+
|
|
417
|
+
score = min(1.0, 0.4 + 0.3 * (deviation - self._payload_sigma) / self._payload_sigma)
|
|
418
|
+
severity = Severity.CRITICAL if deviation >= self._payload_sigma * 2 else Severity.WARNING
|
|
419
|
+
alert = AnomalyAlert(
|
|
420
|
+
endpoint=endpoint,
|
|
421
|
+
anomaly_type=AnomalyType.PAYLOAD_ANOMALY,
|
|
422
|
+
severity=severity,
|
|
423
|
+
score=round(score, 4),
|
|
424
|
+
description=(
|
|
425
|
+
f"Payload size {payload_size} bytes deviates {deviation:.1f}σ from "
|
|
426
|
+
f"baseline mean {mean:.0f}±{std:.0f} bytes"
|
|
427
|
+
),
|
|
428
|
+
scan_id=scan_id,
|
|
429
|
+
details={
|
|
430
|
+
"payload_size": payload_size,
|
|
431
|
+
"baseline_mean_bytes": round(mean, 0),
|
|
432
|
+
"baseline_std_bytes": round(std, 0),
|
|
433
|
+
"sigma_deviation": round(deviation, 2),
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
return score, alert
|
|
437
|
+
|
|
438
|
+
# ------------------------------------------------------------------
|
|
439
|
+
# Error rate check
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def _check_error_rate(
|
|
443
|
+
self,
|
|
444
|
+
endpoint: str,
|
|
445
|
+
is_error: bool,
|
|
446
|
+
baseline: _EndpointBaseline,
|
|
447
|
+
scan_id: Optional[str],
|
|
448
|
+
) -> Tuple[float, Optional[AnomalyAlert]]:
|
|
449
|
+
if not is_error:
|
|
450
|
+
return 0.0, None
|
|
451
|
+
|
|
452
|
+
# Compute a provisional error rate including this observation
|
|
453
|
+
n = len(baseline.error_flags) + 1
|
|
454
|
+
errors = sum(baseline.error_flags) + 1
|
|
455
|
+
current_rate = errors / n
|
|
456
|
+
|
|
457
|
+
if current_rate < self._err_warn:
|
|
458
|
+
return 0.0, None
|
|
459
|
+
|
|
460
|
+
if current_rate >= self._err_crit:
|
|
461
|
+
score, severity = 0.9, Severity.CRITICAL
|
|
462
|
+
else:
|
|
463
|
+
score = 0.3 + 0.6 * (current_rate - self._err_warn) / max(
|
|
464
|
+
0.01, self._err_crit - self._err_warn
|
|
465
|
+
)
|
|
466
|
+
severity = Severity.WARNING
|
|
467
|
+
|
|
468
|
+
alert = AnomalyAlert(
|
|
469
|
+
endpoint=endpoint,
|
|
470
|
+
anomaly_type=AnomalyType.ERROR_RATE_SURGE,
|
|
471
|
+
severity=severity,
|
|
472
|
+
score=round(score, 4),
|
|
473
|
+
description=(
|
|
474
|
+
f"Error rate surging: {current_rate:.1%} "
|
|
475
|
+
f"(baseline: {baseline.error_rate:.1%}, "
|
|
476
|
+
f"threshold: {self._err_warn:.0%})"
|
|
477
|
+
),
|
|
478
|
+
scan_id=scan_id,
|
|
479
|
+
details={
|
|
480
|
+
"current_error_rate": round(current_rate, 4),
|
|
481
|
+
"baseline_error_rate": round(baseline.error_rate, 4),
|
|
482
|
+
"warning_threshold": self._err_warn,
|
|
483
|
+
"critical_threshold": self._err_crit,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
return score, alert
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Alert management
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def _store_alert(self, alert: AnomalyAlert) -> None:
|
|
493
|
+
self._alerts.append(alert)
|
|
494
|
+
if len(self._alerts) > self._alert_limit:
|
|
495
|
+
self._alerts.pop(0)
|
|
496
|
+
|
|
497
|
+
def get_alerts(
|
|
498
|
+
self,
|
|
499
|
+
endpoint: Optional[str] = None,
|
|
500
|
+
scan_id: Optional[str] = None,
|
|
501
|
+
severity: Optional[Severity] = None,
|
|
502
|
+
) -> List[AnomalyAlert]:
|
|
503
|
+
"""Return stored anomaly alerts, optionally filtered."""
|
|
504
|
+
alerts = self._alerts
|
|
505
|
+
if endpoint is not None:
|
|
506
|
+
alerts = [a for a in alerts if a.endpoint == endpoint]
|
|
507
|
+
if scan_id is not None:
|
|
508
|
+
alerts = [a for a in alerts if a.scan_id == scan_id]
|
|
509
|
+
if severity is not None:
|
|
510
|
+
alerts = [a for a in alerts if a.severity == severity]
|
|
511
|
+
return list(alerts)
|
|
512
|
+
|
|
513
|
+
def get_alerts_dict(
|
|
514
|
+
self,
|
|
515
|
+
endpoint: Optional[str] = None,
|
|
516
|
+
scan_id: Optional[str] = None,
|
|
517
|
+
severity: Optional[Severity] = None,
|
|
518
|
+
) -> List[Dict[str, Any]]:
|
|
519
|
+
"""Return alerts as a list of JSON-serializable dicts."""
|
|
520
|
+
return [a.to_dict() for a in self.get_alerts(endpoint, scan_id, severity)]
|
|
521
|
+
|
|
522
|
+
def clear_alerts(self) -> None:
|
|
523
|
+
"""Remove all stored alerts."""
|
|
524
|
+
self._alerts.clear()
|
|
525
|
+
|
|
526
|
+
# ------------------------------------------------------------------
|
|
527
|
+
# Helpers
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
@staticmethod
|
|
531
|
+
def _estimate_payload_size(payload: Any) -> int:
|
|
532
|
+
if payload is None:
|
|
533
|
+
return 0
|
|
534
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
535
|
+
return len(payload)
|
|
536
|
+
if isinstance(payload, str):
|
|
537
|
+
return len(payload.encode("utf-8"))
|
|
538
|
+
try:
|
|
539
|
+
return len(json.dumps(payload).encode("utf-8"))
|
|
540
|
+
except (TypeError, ValueError):
|
|
541
|
+
return len(str(payload).encode("utf-8"))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Browser agent utilities for functional/UI testing."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mannf.core.browser.dom_snapshot import DOMElement, DOMSnapshot, build_selector, capture_dom_snapshot
|
|
11
|
+
from mannf.core.browser.visual_comparer import (
|
|
12
|
+
VisualComparisonResult,
|
|
13
|
+
compare_screenshots,
|
|
14
|
+
load_baseline,
|
|
15
|
+
save_baseline,
|
|
16
|
+
)
|
|
17
|
+
from mannf.core.browser.accessibility_scanner import (
|
|
18
|
+
AccessibilityScanResult,
|
|
19
|
+
AccessibilityViolation,
|
|
20
|
+
scan_accessibility,
|
|
21
|
+
)
|
|
22
|
+
from mannf.core.browser.performance_metrics import (
|
|
23
|
+
PerformanceMetric,
|
|
24
|
+
PerformanceResult,
|
|
25
|
+
evaluate_metrics,
|
|
26
|
+
get_threshold_violations,
|
|
27
|
+
DEFAULT_THRESHOLDS,
|
|
28
|
+
)
|
|
29
|
+
from mannf.core.browser.scenario_generator import ScenarioConfigGenerator
|
|
30
|
+
from mannf.core.browser.security_scenario_generator import SecurityScenarioGenerator
|
|
31
|
+
from mannf.core.browser.reflection_analyzer import ReflectionAnalyzer, analyze_security_results
|
|
32
|
+
from mannf.core.browser.ingestor_bridge import IngestorBridge
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DOMElement",
|
|
36
|
+
"DOMSnapshot",
|
|
37
|
+
"build_selector",
|
|
38
|
+
"capture_dom_snapshot",
|
|
39
|
+
# Visual comparison
|
|
40
|
+
"VisualComparisonResult",
|
|
41
|
+
"compare_screenshots",
|
|
42
|
+
"load_baseline",
|
|
43
|
+
"save_baseline",
|
|
44
|
+
# Accessibility scanning
|
|
45
|
+
"AccessibilityScanResult",
|
|
46
|
+
"AccessibilityViolation",
|
|
47
|
+
"scan_accessibility",
|
|
48
|
+
# Performance metrics
|
|
49
|
+
"PerformanceMetric",
|
|
50
|
+
"PerformanceResult",
|
|
51
|
+
"evaluate_metrics",
|
|
52
|
+
"get_threshold_violations",
|
|
53
|
+
"DEFAULT_THRESHOLDS",
|
|
54
|
+
# Scenario generation
|
|
55
|
+
"ScenarioConfigGenerator",
|
|
56
|
+
# Security scenario generation
|
|
57
|
+
"SecurityScenarioGenerator",
|
|
58
|
+
# Reflection analysis
|
|
59
|
+
"ReflectionAnalyzer",
|
|
60
|
+
"analyze_security_results",
|
|
61
|
+
# Ingestor bridge
|
|
62
|
+
"IngestorBridge",
|
|
63
|
+
]
|