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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,679 @@
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
+ """Production traffic ingestor plugin.
7
+
8
+ Parses anonymized production traffic captures from HAR files and
9
+ mitmproxy flow dumps (JSON or native format) and produces
10
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
11
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects for
12
+ seeding NAT's autonomous test loop with real traffic patterns.
13
+
14
+ Supported formats
15
+ -----------------
16
+ * HAR files (``"log"``/``"entries"`` structure) — the same format as
17
+ :class:`~mannf.product.ingestors.har_ingestor.HARIngestor` but with
18
+ production-traffic-specific sampling and anonymization support.
19
+ * mitmproxy JSON dumps (``mitmproxy --save-stream-file`` / ``mitmdump -w``
20
+ output converted to JSON via ``mitmproxy``) — a list of flow dicts with
21
+ ``"type": "http"``, ``"request"``, and ``"response"`` keys.
22
+ * ``.mitmproxy`` / ``.pcap`` path extensions are accepted as format hints
23
+ (PCAP is treated as mitmproxy-compatible after conversion).
24
+
25
+ Config keys
26
+ -----------
27
+ base_url_filter : str, optional
28
+ If set, only ingest traffic entries whose URL starts with this prefix.
29
+ Useful for isolating a single service in mixed prod captures.
30
+ exclude_static : bool, default True
31
+ Skip static asset requests (.js, .css, images, fonts, etc.).
32
+ include_response_data : bool, default False
33
+ Capture response bodies for regression testing.
34
+ sample_rate : float, default 1.0
35
+ Fraction of entries to include (0.0–1.0). Useful for very large
36
+ captures where full ingestion would be too slow.
37
+ min_status : int, optional
38
+ Only include entries with HTTP status ≥ this value.
39
+ max_status : int, optional
40
+ Only include entries with HTTP status ≤ this value.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import logging
47
+ import re
48
+ from pathlib import Path
49
+ from typing import Any
50
+ from urllib.parse import urlparse
51
+
52
+ from mannf.product.ingestors.base import IngestorPlugin
53
+ from mannf.product.ingestors.models import (
54
+ IngestedEndpoint,
55
+ IngestedTestCase,
56
+ IngestorSource,
57
+ IngestResult,
58
+ )
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Constants
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _STATIC_EXTENSIONS: frozenset[str] = frozenset({
67
+ ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
68
+ ".woff", ".woff2", ".ttf", ".eot", ".map", ".webp", ".bmp", ".tiff",
69
+ ".pdf", ".zip", ".gz", ".tar", ".mp4", ".webm", ".mp3", ".wav",
70
+ })
71
+
72
+ _AUTH_HEADERS: frozenset[str] = frozenset({
73
+ "authorization",
74
+ "cookie",
75
+ "x-api-key",
76
+ "x-auth-token",
77
+ "x-access-token",
78
+ "x-csrf-token",
79
+ })
80
+
81
+ _SENSITIVE_HEADERS: frozenset[str] = frozenset({
82
+ "authorization",
83
+ "cookie",
84
+ "x-api-key",
85
+ "x-auth-token",
86
+ "x-access-token",
87
+ "x-csrf-token",
88
+ "proxy-authorization",
89
+ "set-cookie",
90
+ })
91
+
92
+ _RE_UUID = re.compile(
93
+ r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
94
+ re.IGNORECASE,
95
+ )
96
+ _RE_NUMERIC_ID = re.compile(r"^\d+$")
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Helper functions
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def _normalise_path(path: str) -> str:
105
+ """Replace numeric IDs and UUIDs in *path* with ``{id}`` / ``{uuid}``."""
106
+ path = _RE_UUID.sub("{uuid}", path)
107
+ parts = path.split("/")
108
+ normalised = []
109
+ for part in parts:
110
+ if _RE_NUMERIC_ID.match(part):
111
+ normalised.append("{id}")
112
+ else:
113
+ normalised.append(part)
114
+ return "/".join(normalised)
115
+
116
+
117
+ def _is_static_resource(path: str) -> bool:
118
+ """Return ``True`` if *path* looks like a static asset."""
119
+ suffix = Path(path).suffix.lower()
120
+ return suffix in _STATIC_EXTENSIONS
121
+
122
+
123
+ def _endpoint_key(method: str, path: str) -> str:
124
+ return f"{method.upper()}:{path}"
125
+
126
+
127
+ def _extract_auth_requirements(headers: list[dict]) -> list[str]:
128
+ found: list[str] = []
129
+ for h in headers:
130
+ name = str(h.get("name", "")).lower()
131
+ if name in _AUTH_HEADERS and name not in found:
132
+ found.append(name)
133
+ return found
134
+
135
+
136
+ def _sanitise_headers(headers: list[dict]) -> list[dict]:
137
+ result: list[dict] = []
138
+ for h in headers:
139
+ name = str(h.get("name", ""))
140
+ if name.lower() in _SENSITIVE_HEADERS:
141
+ result.append({"name": name, "value": "[REDACTED]"})
142
+ else:
143
+ result.append(h)
144
+ return result
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Parsers for different traffic dump formats
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _parse_har_entries(data: dict) -> list[dict[str, Any]]:
153
+ """Extract a flat list of normalised entry dicts from a HAR document."""
154
+ log = data.get("log")
155
+ if not isinstance(log, dict):
156
+ return []
157
+ entries = log.get("entries", []) or []
158
+ if not isinstance(entries, list):
159
+ return []
160
+ return entries
161
+
162
+
163
+ def _parse_mitmproxy_entries(data: list) -> list[dict[str, Any]]:
164
+ """Convert a mitmproxy JSON flow list to HAR-compatible entry dicts.
165
+
166
+ mitmproxy JSON flows look like::
167
+
168
+ [
169
+ {
170
+ "type": "http",
171
+ "request": {
172
+ "method": "GET",
173
+ "url": "https://example.com/api",
174
+ "headers": [["Content-Type", "application/json"]],
175
+ "content": ""
176
+ },
177
+ "response": {
178
+ "status_code": 200,
179
+ "headers": [["Content-Type", "application/json"]],
180
+ "content": "{}"
181
+ }
182
+ },
183
+ ...
184
+ ]
185
+
186
+ Headers may be a list-of-pairs or a dict.
187
+ """
188
+ entries: list[dict[str, Any]] = []
189
+ for flow in data:
190
+ if not isinstance(flow, dict):
191
+ continue
192
+ if flow.get("type") not in ("http", None) and "request" not in flow:
193
+ continue
194
+ req = flow.get("request") or {}
195
+ resp = flow.get("response") or {}
196
+ if not isinstance(req, dict):
197
+ continue
198
+
199
+ url = req.get("url") or req.get("pretty_url") or ""
200
+ method = req.get("method", "GET") or "GET"
201
+
202
+ # Normalise headers: accept list-of-pairs or list-of-dicts
203
+ raw_req_headers = req.get("headers") or []
204
+ norm_req_headers: list[dict] = []
205
+ if isinstance(raw_req_headers, dict):
206
+ norm_req_headers = [{"name": k, "value": v} for k, v in raw_req_headers.items()]
207
+ elif isinstance(raw_req_headers, list):
208
+ for item in raw_req_headers:
209
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
210
+ norm_req_headers.append({"name": item[0], "value": item[1]})
211
+ elif isinstance(item, dict):
212
+ norm_req_headers.append(item)
213
+
214
+ raw_resp_headers = resp.get("headers") or []
215
+ norm_resp_headers: list[dict] = []
216
+ if isinstance(raw_resp_headers, dict):
217
+ norm_resp_headers = [{"name": k, "value": v} for k, v in raw_resp_headers.items()]
218
+ elif isinstance(raw_resp_headers, list):
219
+ for item in raw_resp_headers:
220
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
221
+ norm_resp_headers.append({"name": item[0], "value": item[1]})
222
+ elif isinstance(item, dict):
223
+ norm_resp_headers.append(item)
224
+
225
+ status_code = resp.get("status_code") or resp.get("status") or 0
226
+ try:
227
+ status_code = int(status_code)
228
+ except (TypeError, ValueError):
229
+ status_code = 0
230
+
231
+ resp_content_text = resp.get("content") or resp.get("text") or ""
232
+ if isinstance(resp_content_text, bytes):
233
+ resp_content_text = resp_content_text.decode("utf-8", errors="replace")
234
+
235
+ req_content = req.get("content") or req.get("text") or req.get("body") or ""
236
+ if isinstance(req_content, bytes):
237
+ req_content = req_content.decode("utf-8", errors="replace")
238
+
239
+ post_data: dict | None = None
240
+ if req_content:
241
+ content_type = ""
242
+ for h in norm_req_headers:
243
+ if h.get("name", "").lower() == "content-type":
244
+ content_type = h.get("value", "")
245
+ break
246
+ post_data = {"mimeType": content_type, "text": req_content}
247
+
248
+ entry: dict[str, Any] = {
249
+ "startedDateTime": flow.get("timestamp_start") or "",
250
+ "time": flow.get("timestamp_end", 0),
251
+ "request": {
252
+ "method": method.upper(),
253
+ "url": url,
254
+ "headers": norm_req_headers,
255
+ "queryString": [],
256
+ "postData": post_data,
257
+ },
258
+ "response": {
259
+ "status": status_code,
260
+ "headers": norm_resp_headers,
261
+ "content": {"text": resp_content_text, "mimeType": ""},
262
+ },
263
+ }
264
+ entries.append(entry)
265
+
266
+ return entries
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # TrafficIngestor
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ class TrafficIngestor(IngestorPlugin):
275
+ """Ingestor plugin for anonymized production traffic captures.
276
+
277
+ Parses HAR files and mitmproxy JSON dumps containing real production
278
+ traffic and converts them into scenario seeds for NAT's autonomous
279
+ test loop. This enables zero-cold-start testing: import production
280
+ traffic and immediately seed the autonomous loop with realistic flows.
281
+
282
+ Format detection
283
+ ----------------
284
+ * ``.mitmproxy`` extension → treated as mitmproxy JSON
285
+ * ``.pcap`` extension → treated as mitmproxy JSON (post-conversion)
286
+ * ``format="traffic"`` → auto-detect between HAR and mitmproxy based
287
+ on content structure
288
+ * HAR content (``{"log": ...}`` structure) → parsed as HAR
289
+ * JSON list of flow dicts → parsed as mitmproxy
290
+
291
+ Config keys
292
+ -----------
293
+ base_url_filter : str, optional
294
+ Only include entries whose URL starts with this prefix.
295
+ exclude_static : bool, default True
296
+ Skip static asset requests.
297
+ include_response_data : bool, default False
298
+ Capture response bodies.
299
+ sample_rate : float, default 1.0
300
+ Fraction of entries to include (0.0–1.0).
301
+ min_status : int, optional
302
+ Only include entries with HTTP status ≥ this value.
303
+ max_status : int, optional
304
+ Only include entries with HTTP status ≤ this value.
305
+ """
306
+
307
+ name = "traffic"
308
+ display_name = "Production Traffic"
309
+ description = (
310
+ "Ingest anonymized production traffic from HAR files or mitmproxy captures."
311
+ )
312
+ supported_extensions = [".mitmproxy", ".pcap"]
313
+ supported_formats = ["traffic", "mitmproxy", "pcap"]
314
+
315
+ # ------------------------------------------------------------------
316
+ # can_handle
317
+ # ------------------------------------------------------------------
318
+
319
+ def can_handle(self, source: IngestorSource) -> bool:
320
+ """Return ``True`` for traffic/mitmproxy sources."""
321
+ if source.format in self.supported_formats:
322
+ return True
323
+ if source.metadata.get("format_hint") in self.supported_formats:
324
+ return True
325
+ if source.path is not None:
326
+ ext = Path(source.path).suffix.lower()
327
+ if ext in self.supported_extensions:
328
+ return True
329
+ return False
330
+
331
+ # ------------------------------------------------------------------
332
+ # validate_config
333
+ # ------------------------------------------------------------------
334
+
335
+ def validate_config(self, config: dict) -> list[str]:
336
+ """Validate traffic ingestor configuration."""
337
+ errors: list[str] = []
338
+
339
+ if "min_status" in config:
340
+ try:
341
+ val = int(config["min_status"])
342
+ if not (100 <= val <= 599):
343
+ errors.append("min_status must be between 100 and 599.")
344
+ except (TypeError, ValueError):
345
+ errors.append("min_status must be an integer.")
346
+
347
+ if "max_status" in config:
348
+ try:
349
+ val = int(config["max_status"])
350
+ if not (100 <= val <= 599):
351
+ errors.append("max_status must be between 100 and 599.")
352
+ except (TypeError, ValueError):
353
+ errors.append("max_status must be an integer.")
354
+
355
+ if "min_status" in config and "max_status" in config:
356
+ try:
357
+ mn = int(config["min_status"])
358
+ mx = int(config["max_status"])
359
+ if mn > mx:
360
+ errors.append("min_status must not be greater than max_status.")
361
+ except (TypeError, ValueError):
362
+ pass
363
+
364
+ if "base_url_filter" in config:
365
+ val = config["base_url_filter"]
366
+ if not isinstance(val, str) or not val.strip():
367
+ errors.append("base_url_filter must be a non-empty string.")
368
+
369
+ if "sample_rate" in config:
370
+ try:
371
+ val = float(config["sample_rate"])
372
+ if not (0.0 < val <= 1.0):
373
+ errors.append("sample_rate must be between 0.0 (exclusive) and 1.0.")
374
+ except (TypeError, ValueError):
375
+ errors.append("sample_rate must be a number between 0.0 and 1.0.")
376
+
377
+ return errors
378
+
379
+ # ------------------------------------------------------------------
380
+ # ingest
381
+ # ------------------------------------------------------------------
382
+
383
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
384
+ """Parse a production traffic capture and return a normalised :class:`IngestResult`.
385
+
386
+ Parameters
387
+ ----------
388
+ source:
389
+ The source to ingest. Must provide either ``raw_content`` or a
390
+ valid ``path`` to a HAR or mitmproxy JSON file.
391
+ config:
392
+ Plugin-specific configuration. See class docstring for keys.
393
+
394
+ Returns
395
+ -------
396
+ IngestResult
397
+ Populated result on success; ``success=False`` with ``errors`` on
398
+ failure.
399
+ """
400
+ # --- Read content ---
401
+ try:
402
+ content = self._read_source(source)
403
+ except (FileNotFoundError, ValueError) as exc:
404
+ return self._make_error_result(source, str(exc))
405
+
406
+ if not content or not content.strip():
407
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
408
+
409
+ # --- Parse JSON ---
410
+ try:
411
+ data: Any = json.loads(content)
412
+ except (json.JSONDecodeError, ValueError) as exc:
413
+ return self._make_error_result(source, f"Failed to parse traffic JSON: {exc}")
414
+
415
+ # --- Determine format and extract entries ---
416
+ entries: list[dict[str, Any]] = []
417
+ detected_format = "unknown"
418
+
419
+ if isinstance(data, dict) and "log" in data:
420
+ # HAR format
421
+ entries = _parse_har_entries(data)
422
+ detected_format = "har"
423
+ elif isinstance(data, list):
424
+ # mitmproxy JSON list of flows
425
+ entries = _parse_mitmproxy_entries(data)
426
+ detected_format = "mitmproxy"
427
+ else:
428
+ return self._make_error_result(
429
+ source,
430
+ "Unrecognized traffic format. Expected HAR (object with 'log' key) "
431
+ "or mitmproxy JSON (array of flow objects).",
432
+ )
433
+
434
+ if not entries:
435
+ return self._make_error_result(
436
+ source,
437
+ f"No traffic entries found in {detected_format} capture.",
438
+ )
439
+
440
+ # --- Config options ---
441
+ base_url_filter: str = (config.get("base_url_filter") or "").strip()
442
+ exclude_static: bool = bool(config.get("exclude_static", True))
443
+ include_response_data: bool = bool(config.get("include_response_data", False))
444
+ min_status: int | None = None
445
+ max_status: int | None = None
446
+ if "min_status" in config:
447
+ try:
448
+ min_status = int(config["min_status"])
449
+ except (TypeError, ValueError):
450
+ pass
451
+ if "max_status" in config:
452
+ try:
453
+ max_status = int(config["max_status"])
454
+ except (TypeError, ValueError):
455
+ pass
456
+ sample_rate: float = 1.0
457
+ if "sample_rate" in config:
458
+ try:
459
+ sample_rate = float(config["sample_rate"])
460
+ except (TypeError, ValueError):
461
+ pass
462
+
463
+ # --- Apply sampling ---
464
+ if 0.0 < sample_rate < 1.0:
465
+ import math
466
+ keep_count = max(1, math.ceil(len(entries) * sample_rate))
467
+ # Systematic sampling to preserve temporal distribution
468
+ step = len(entries) / keep_count
469
+ entries = [entries[int(i * step)] for i in range(keep_count)]
470
+
471
+ warnings: list[str] = []
472
+ endpoint_map: dict[str, IngestedEndpoint] = {}
473
+ response_map: dict[str, list[tuple[int, str | None]]] = {}
474
+
475
+ skipped_static = 0
476
+ skipped_filter = 0
477
+ skipped_status = 0
478
+ total_entries = len(entries)
479
+
480
+ for entry in entries:
481
+ if not isinstance(entry, dict):
482
+ continue
483
+
484
+ request: dict[str, Any] = entry.get("request") or {}
485
+ response: dict[str, Any] = entry.get("response") or {}
486
+
487
+ if not isinstance(request, dict):
488
+ continue
489
+
490
+ raw_url: str = request.get("url", "") or ""
491
+ method: str = (request.get("method", "GET") or "GET").upper()
492
+
493
+ if not raw_url:
494
+ continue
495
+
496
+ try:
497
+ parsed = urlparse(raw_url)
498
+ except Exception: # noqa: BLE001
499
+ warnings.append(f"Skipped entry with unparseable URL: {raw_url!r}")
500
+ continue
501
+
502
+ base_url_part = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else ""
503
+ raw_path = parsed.path or "/"
504
+
505
+ if base_url_filter and not raw_url.startswith(base_url_filter):
506
+ skipped_filter += 1
507
+ continue
508
+
509
+ if exclude_static and _is_static_resource(raw_path):
510
+ skipped_static += 1
511
+ continue
512
+
513
+ response_status: int = 0
514
+ if isinstance(response, dict):
515
+ try:
516
+ response_status = int(
517
+ response.get("status") or response.get("status_code") or 0
518
+ )
519
+ except (TypeError, ValueError):
520
+ response_status = 0
521
+
522
+ if min_status is not None and response_status < min_status:
523
+ skipped_status += 1
524
+ continue
525
+ if max_status is not None and response_status > max_status:
526
+ skipped_status += 1
527
+ continue
528
+
529
+ normalised_path = _normalise_path(raw_path)
530
+ key = _endpoint_key(method, normalised_path)
531
+
532
+ raw_headers: list[dict] = request.get("headers", []) or []
533
+ auth_requirements = _extract_auth_requirements(raw_headers)
534
+
535
+ response_content_type = ""
536
+ response_body_text: str | None = None
537
+ if isinstance(response, dict):
538
+ resp_headers = response.get("headers", []) or []
539
+ for rh in resp_headers:
540
+ if rh.get("name", "").lower() == "content-type":
541
+ response_content_type = rh.get("value", "")
542
+ break
543
+ if include_response_data:
544
+ content_block: dict = response.get("content") or {}
545
+ response_body_text = (
546
+ content_block.get("text")
547
+ or response.get("text")
548
+ or None
549
+ )
550
+
551
+ if key not in endpoint_map:
552
+ metadata: dict[str, Any] = {
553
+ "base_url": base_url_part,
554
+ "original_urls": [raw_url],
555
+ "content_types_observed": [],
556
+ "traffic_source": detected_format,
557
+ }
558
+ if response_content_type:
559
+ metadata["content_types_observed"].append(response_content_type)
560
+
561
+ endpoint_map[key] = IngestedEndpoint(
562
+ method=method,
563
+ path=normalised_path,
564
+ summary=f"{method} {normalised_path}",
565
+ description=f"Production traffic: {method} {normalised_path}",
566
+ request_body=request.get("postData"),
567
+ responses={str(response_status): {"description": "Observed response"}}
568
+ if response_status else {},
569
+ auth_requirements=auth_requirements,
570
+ tags=["traffic", detected_format, method.lower()],
571
+ metadata=metadata,
572
+ )
573
+ response_map[key] = [(response_status, response_body_text)]
574
+ else:
575
+ existing = endpoint_map[key]
576
+ if raw_url not in existing.metadata.get("original_urls", []):
577
+ existing.metadata.setdefault("original_urls", []).append(raw_url)
578
+ for scheme in auth_requirements:
579
+ if scheme not in existing.auth_requirements:
580
+ existing.auth_requirements.append(scheme)
581
+ if response_content_type:
582
+ ct_list = existing.metadata.setdefault("content_types_observed", [])
583
+ if response_content_type not in ct_list:
584
+ ct_list.append(response_content_type)
585
+ if response_status and str(response_status) not in existing.responses:
586
+ existing.responses[str(response_status)] = {
587
+ "description": "Observed response"
588
+ }
589
+ response_map[key].append((response_status, response_body_text))
590
+
591
+ if not endpoint_map and total_entries > 0:
592
+ filtered_count = skipped_static + skipped_filter + skipped_status
593
+ if filtered_count == total_entries:
594
+ warnings.append(
595
+ "All entries were filtered out. Check base_url_filter, "
596
+ "exclude_static, and min_status/max_status settings."
597
+ )
598
+ else:
599
+ warnings.append(
600
+ "No valid endpoints could be extracted from the traffic capture."
601
+ )
602
+
603
+ if skipped_static:
604
+ warnings.append(
605
+ f"Skipped {skipped_static} static resource entries (exclude_static=True)."
606
+ )
607
+ if skipped_filter:
608
+ warnings.append(
609
+ f"Skipped {skipped_filter} entries that did not match "
610
+ f"base_url_filter={base_url_filter!r}."
611
+ )
612
+ if skipped_status:
613
+ warnings.append(
614
+ f"Skipped {skipped_status} entries outside the status code range."
615
+ )
616
+
617
+ endpoints = list(endpoint_map.values())
618
+ test_cases: list[IngestedTestCase] = []
619
+
620
+ for ep in endpoints:
621
+ ep_key = _endpoint_key(ep.method, ep.path)
622
+ recorded_responses = response_map.get(ep_key, [])
623
+ if recorded_responses:
624
+ statuses = [s for s, _ in recorded_responses if s]
625
+ recorded_status = max(set(statuses), key=statuses.count) if statuses else 200
626
+ resp_body = next(
627
+ (b for _, b in recorded_responses if b is not None), None
628
+ )
629
+ else:
630
+ recorded_status = 200
631
+ resp_body = None
632
+
633
+ source_ref = f"{ep.method} {ep.path}"
634
+ steps: list[dict] = [
635
+ {"action": "send_request", "method": ep.method, "path": ep.path},
636
+ {"action": "assert_status", "expected": recorded_status},
637
+ ]
638
+ if include_response_data and resp_body:
639
+ steps.append({"action": "assert_response_body", "snapshot": True})
640
+
641
+ test_cases.append(IngestedTestCase(
642
+ name=f"[{ep.method}] {ep.path} — production replay",
643
+ description=(
644
+ f"Replay of observed production {ep.method} request to {ep.path}. "
645
+ f"Expected status: {recorded_status}."
646
+ ),
647
+ steps=steps,
648
+ expected_result=f"HTTP {recorded_status} response",
649
+ priority="medium",
650
+ tags=["traffic", detected_format, "replay", ep.method.lower()],
651
+ source_ref=source_ref,
652
+ metadata={
653
+ "test_type": "traffic_replay",
654
+ "traffic_source": detected_format,
655
+ "recorded_status": recorded_status,
656
+ },
657
+ ))
658
+
659
+ stats: dict[str, Any] = {
660
+ "total_entries": total_entries,
661
+ "endpoints_count": len(endpoints),
662
+ "test_cases_count": len(test_cases),
663
+ "skipped_static": skipped_static,
664
+ "skipped_filter": skipped_filter,
665
+ "skipped_status": skipped_status,
666
+ "include_response_data": include_response_data,
667
+ "detected_format": detected_format,
668
+ "sample_rate": sample_rate,
669
+ }
670
+
671
+ return IngestResult(
672
+ success=True,
673
+ source=source,
674
+ endpoints=endpoints,
675
+ test_cases=test_cases,
676
+ errors=[],
677
+ warnings=warnings,
678
+ stats=stats,
679
+ )