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,200 @@
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
+ """Bugzilla exporter plugin — creates bugs from NAT security findings via the Bugzilla REST API."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ import httpx
13
+
14
+ from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
15
+ from mannf.product.security.models import SecurityFinding, SecurityReport
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # NAT severity → Bugzilla (severity, priority) pairs
20
+ _SEVERITY_MAP: dict[str, tuple[str, str]] = {
21
+ "critical": ("critical", "P1"),
22
+ "high": ("major", "P2"),
23
+ "medium": ("normal", "P3"),
24
+ "low": ("minor", "P4"),
25
+ "info": ("trivial", "P5"),
26
+ }
27
+
28
+
29
+ class BugzillaExporter(ExporterPlugin):
30
+ """Exports NAT security findings as Bugzilla bugs via the Bugzilla REST API.
31
+
32
+ Required config keys:
33
+ bugzilla_url: Bugzilla instance URL, e.g. ``https://bugzilla.example.com``.
34
+ api_key: Bugzilla API key.
35
+ product: Bugzilla product name to file bugs against.
36
+ component: Bugzilla component name.
37
+
38
+ Optional config keys:
39
+ version: Product version string (default ``"unspecified"``).
40
+ field_mapping: ``dict[str, Any]`` of extra field overrides merged into the
41
+ bug payload before posting.
42
+ """
43
+
44
+ name = "bugzilla"
45
+ display_name = "Bugzilla"
46
+ description = "Creates Bugzilla bugs for NAT security findings via the Bugzilla REST API."
47
+
48
+ def validate_config(self, config: dict) -> list[str]:
49
+ required = ("bugzilla_url", "api_key", "product", "component")
50
+ return [f"'{k}' is required" for k in required if not config.get(k)]
51
+
52
+ async def test_connection(self, config: dict) -> TestConnectionResult:
53
+ """Test connection via GET /rest/version and GET /rest/product/{product}."""
54
+ errors = self.validate_config(config)
55
+ if errors:
56
+ return TestConnectionResult(
57
+ success=False,
58
+ message=f"Config validation failed: {'; '.join(errors)}",
59
+ details={"errors": errors},
60
+ )
61
+
62
+ base_url = config["bugzilla_url"].rstrip("/")
63
+ api_key = config["api_key"]
64
+ product = config["product"]
65
+ headers = {"Content-Type": "application/json"}
66
+ params_base = {"api_key": api_key}
67
+
68
+ try:
69
+ async with httpx.AsyncClient(timeout=10) as client:
70
+ # Step 1: confirm instance is reachable and API key works
71
+ version_resp = await client.get(
72
+ f"{base_url}/rest/version",
73
+ headers=headers,
74
+ params=params_base,
75
+ )
76
+ version_resp.raise_for_status()
77
+ version_data = version_resp.json()
78
+
79
+ # Step 2: confirm the product exists and the API key has access
80
+ product_resp = await client.get(
81
+ f"{base_url}/rest/product/{product}",
82
+ headers=headers,
83
+ params=params_base,
84
+ )
85
+ product_resp.raise_for_status()
86
+ except httpx.HTTPStatusError as exc:
87
+ status = exc.response.status_code
88
+ if status == 401:
89
+ msg = "Bugzilla authentication failed — check your api_key"
90
+ elif status == 403:
91
+ msg = "Bugzilla access forbidden — check api_key permissions"
92
+ elif status == 404:
93
+ url_used = str(exc.request.url)
94
+ if "product" in url_used:
95
+ msg = f"Bugzilla product '{product}' not found — check the product name"
96
+ else:
97
+ msg = f"Bugzilla instance not found at '{base_url}'"
98
+ else:
99
+ msg = f"Bugzilla API error {status}: {exc.response.text}"
100
+ return TestConnectionResult(success=False, message=msg, details={})
101
+ except httpx.RequestError as exc:
102
+ return TestConnectionResult(
103
+ success=False,
104
+ message=f"Bugzilla network error: {exc}",
105
+ details={},
106
+ )
107
+
108
+ bz_version = version_data.get("version", "unknown")
109
+ return TestConnectionResult(
110
+ success=True,
111
+ message=f"Connected to Bugzilla {bz_version} — product '{product}' is accessible",
112
+ details={"bugzilla_version": bz_version, "product": product},
113
+ )
114
+
115
+ def _severity_to_priority(self, severity: str) -> str:
116
+ """Map NAT severity to a Bugzilla priority string."""
117
+ _, priority = _SEVERITY_MAP.get(severity.lower(), ("normal", "P3"))
118
+ return priority
119
+
120
+ def _severity_to_bugzilla_severity(self, severity: str) -> str:
121
+ """Map NAT severity to a Bugzilla severity string."""
122
+ bz_severity, _ = _SEVERITY_MAP.get(severity.lower(), ("normal", "P3"))
123
+ return bz_severity
124
+
125
+ def _build_payload(
126
+ self,
127
+ finding: SecurityFinding,
128
+ report: SecurityReport,
129
+ config: dict,
130
+ ) -> dict:
131
+ """Construct the Bugzilla bug creation payload."""
132
+ product = config["product"]
133
+ component = config["component"]
134
+ version = config.get("version", "unspecified")
135
+ body_text = self._format_finding_body(finding, report)
136
+ bz_severity = self._severity_to_bugzilla_severity(finding.severity)
137
+ priority = self._severity_to_priority(finding.severity)
138
+
139
+ payload: dict = {
140
+ "product": product,
141
+ "component": component,
142
+ "version": version,
143
+ "summary": f"[NAT] {finding.title}",
144
+ "description": body_text,
145
+ "severity": bz_severity,
146
+ "priority": priority,
147
+ "type": "defect",
148
+ "keywords": ["nat-security", finding.check_id],
149
+ }
150
+
151
+ # Merge any caller-supplied field overrides last so they take precedence.
152
+ extra: dict = config.get("field_mapping") or {}
153
+ payload.update(extra)
154
+
155
+ return payload
156
+
157
+ async def export_finding(
158
+ self,
159
+ finding: SecurityFinding,
160
+ report: SecurityReport,
161
+ config: dict,
162
+ ) -> ExportResult:
163
+ base_url = config["bugzilla_url"].rstrip("/")
164
+ api_key = config["api_key"]
165
+ url = f"{base_url}/rest/bug"
166
+ headers = {"Content-Type": "application/json"}
167
+ payload = self._build_payload(finding, report, config)
168
+ # Bugzilla REST API accepts api_key as a top-level body field
169
+ payload["api_key"] = api_key
170
+
171
+ try:
172
+ async with httpx.AsyncClient(timeout=30) as client:
173
+ resp = await client.post(url, json=payload, headers=headers)
174
+ resp.raise_for_status()
175
+ data = resp.json()
176
+ except httpx.HTTPStatusError as exc:
177
+ status = exc.response.status_code
178
+ if status == 401:
179
+ error_msg = "Bugzilla authentication failed — check your api_key"
180
+ elif status == 403:
181
+ error_msg = "Bugzilla access forbidden — check api_key permissions"
182
+ else:
183
+ error_msg = f"Bugzilla API error {status}: {exc.response.text}"
184
+ logger.warning("BugzillaExporter: %s", error_msg)
185
+ return ExportResult(finding=finding, success=False, error=error_msg)
186
+ except httpx.RequestError as exc:
187
+ error_msg = f"Bugzilla network error: {exc}"
188
+ logger.warning("BugzillaExporter: %s", error_msg)
189
+ return ExportResult(finding=finding, success=False, error=error_msg)
190
+
191
+ bug_id: str = str(data.get("id", ""))
192
+ bug_url: str | None = (
193
+ f"{base_url}/show_bug.cgi?id={bug_id}" if bug_id else None
194
+ )
195
+ return ExportResult(
196
+ finding=finding,
197
+ success=True,
198
+ external_id=bug_id or None,
199
+ external_url=bug_url,
200
+ )
@@ -0,0 +1,275 @@
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
+ """Deduplication helpers for the auto-bug-filing pipeline.
7
+
8
+ Provides:
9
+
10
+ * :func:`make_failure_fingerprint` — deterministic ``sha256`` fingerprint for
11
+ a failure dict. The same failure across runs always produces the same hash,
12
+ enabling reliable deduplication across Jira, GitHub, and webhook targets.
13
+ * :func:`find_existing_jira_issue` — query Jira via JQL for an open issue that
14
+ carries the fingerprint in its labels.
15
+ * :func:`find_existing_github_issue` — search GitHub Issues for an open issue
16
+ whose title / body embeds the fingerprint.
17
+ * :func:`add_jira_comment` / :func:`add_github_comment` — append a new-occurrence
18
+ comment to an existing ticket rather than creating a duplicate.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import hashlib
25
+ import logging
26
+ from typing import Any, Dict, Optional
27
+
28
+ import httpx
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Label / tag prefix embedded in every auto-filed ticket so it can be found
33
+ # by the dedup search queries.
34
+ FINGERPRINT_LABEL_PREFIX = "nat-fp-"
35
+ AUTO_FILED_LABEL = "nat-auto-filed"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Fingerprint generation
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def make_failure_fingerprint(failure: Dict[str, Any]) -> str:
44
+ """Return a stable ``sha256`` hex-digest fingerprint for *failure*.
45
+
46
+ The fingerprint is derived from three stable, identifying attributes:
47
+
48
+ * ``page`` — normalised page path (e.g. ``/checkout``).
49
+ * ``error`` — the first 200 characters of the error message, lower-cased
50
+ and stripped of surrounding whitespace. Trimming prevents minor
51
+ formatting differences from producing different fingerprints.
52
+ * ``selector`` — the CSS/XPath selector that failed (empty string if absent).
53
+
54
+ Parameters
55
+ ----------
56
+ failure:
57
+ A failure dict from ``AutonomousRunReport.unique_failures``.
58
+
59
+ Returns
60
+ -------
61
+ str
62
+ A 64-character lowercase hex string (``sha256``).
63
+ """
64
+ page = (failure.get("page") or failure.get("url") or "").strip()
65
+ error = (failure.get("error") or "").strip().lower()[:200]
66
+ selector = (failure.get("selector") or "").strip()
67
+
68
+ raw = f"{page}||{error}||{selector}"
69
+ return hashlib.sha256(raw.encode()).hexdigest()
70
+
71
+
72
+ def fingerprint_label(fingerprint: str) -> str:
73
+ """Return the label/tag string used to mark a ticket with this fingerprint.
74
+
75
+ The label is truncated to 50 characters so it fits within common tracker
76
+ label length limits.
77
+ """
78
+ return f"{FINGERPRINT_LABEL_PREFIX}{fingerprint[:40]}"
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Jira deduplication
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ async def find_existing_jira_issue(
87
+ fingerprint: str,
88
+ *,
89
+ base_url: str,
90
+ project_key: str,
91
+ email: str,
92
+ api_token: str,
93
+ ) -> Optional[str]:
94
+ """Search Jira for an *open* issue with the given fingerprint label.
95
+
96
+ Uses the Jira Cloud REST API v3 ``/search`` endpoint with a JQL query::
97
+
98
+ project = "<project_key>" AND labels = "nat-fp-<fingerprint>"
99
+ AND statusCategory != Done
100
+
101
+ Parameters
102
+ ----------
103
+ fingerprint:
104
+ The hex fingerprint produced by :func:`make_failure_fingerprint`.
105
+ base_url:
106
+ Jira Cloud base URL, e.g. ``https://myco.atlassian.net``.
107
+ project_key:
108
+ Jira project key, e.g. ``"NAT"``.
109
+ email:
110
+ Atlassian account email for Basic auth.
111
+ api_token:
112
+ Atlassian API token for Basic auth.
113
+
114
+ Returns
115
+ -------
116
+ str | None
117
+ The issue key (e.g. ``"NAT-42"``) if a matching open issue exists,
118
+ otherwise ``None``.
119
+ """
120
+ label = fingerprint_label(fingerprint)
121
+ jql = (
122
+ f'project = "{project_key}" AND labels = "{label}" '
123
+ f'AND statusCategory != Done'
124
+ )
125
+ credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
126
+ headers = {
127
+ "Authorization": f"Basic {credentials}",
128
+ "Accept": "application/json",
129
+ }
130
+ url = f"{base_url.rstrip('/')}/rest/api/3/search"
131
+
132
+ try:
133
+ async with httpx.AsyncClient(headers=headers, timeout=15) as client:
134
+ resp = await client.get(url, params={"jql": jql, "maxResults": 1, "fields": "key"})
135
+ resp.raise_for_status()
136
+ data = resp.json()
137
+ issues = data.get("issues", [])
138
+ if issues:
139
+ return issues[0]["key"]
140
+ except Exception as exc: # noqa: BLE001
141
+ logger.warning("Jira dedup search failed (non-fatal): %s", exc)
142
+ return None
143
+
144
+
145
+ async def add_jira_comment(
146
+ issue_key: str,
147
+ comment_text: str,
148
+ *,
149
+ base_url: str,
150
+ email: str,
151
+ api_token: str,
152
+ ) -> bool:
153
+ """Append *comment_text* to an existing Jira issue.
154
+
155
+ Returns
156
+ -------
157
+ bool
158
+ ``True`` on success, ``False`` on error.
159
+ """
160
+ credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
161
+ headers = {
162
+ "Authorization": f"Basic {credentials}",
163
+ "Accept": "application/json",
164
+ "Content-Type": "application/json",
165
+ }
166
+ url = f"{base_url.rstrip('/')}/rest/api/3/issue/{issue_key}/comment"
167
+ # ADF plain text paragraph
168
+ body = {
169
+ "body": {
170
+ "type": "doc",
171
+ "version": 1,
172
+ "content": [
173
+ {
174
+ "type": "paragraph",
175
+ "content": [{"type": "text", "text": comment_text}],
176
+ }
177
+ ],
178
+ }
179
+ }
180
+ try:
181
+ async with httpx.AsyncClient(headers=headers, timeout=15) as client:
182
+ resp = await client.post(url, json=body)
183
+ resp.raise_for_status()
184
+ return True
185
+ except Exception as exc: # noqa: BLE001
186
+ logger.warning("Jira add_comment failed for %s (non-fatal): %s", issue_key, exc)
187
+ return False
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # GitHub deduplication
192
+ # ---------------------------------------------------------------------------
193
+
194
+ _GITHUB_API_BASE = "https://api.github.com"
195
+
196
+
197
+ async def find_existing_github_issue(
198
+ fingerprint: str,
199
+ *,
200
+ token: str,
201
+ repo: str,
202
+ ) -> Optional[int]:
203
+ """Search GitHub Issues for an open issue carrying the fingerprint label.
204
+
205
+ Uses the GitHub Search API::
206
+
207
+ repo:<owner>/<repo> is:issue is:open label:nat-fp-<fingerprint>
208
+
209
+ Parameters
210
+ ----------
211
+ fingerprint:
212
+ The hex fingerprint produced by :func:`make_failure_fingerprint`.
213
+ token:
214
+ GitHub personal access token.
215
+ repo:
216
+ Repository in ``owner/repo`` format.
217
+
218
+ Returns
219
+ -------
220
+ int | None
221
+ The issue number if a matching open issue exists, otherwise ``None``.
222
+ """
223
+ label = fingerprint_label(fingerprint)
224
+ query = f"repo:{repo} is:issue is:open label:{label}"
225
+ headers = {
226
+ "Authorization": f"Bearer {token}",
227
+ "Accept": "application/vnd.github+json",
228
+ "X-GitHub-Api-Version": "2022-11-28",
229
+ }
230
+ try:
231
+ async with httpx.AsyncClient(headers=headers, timeout=15) as client:
232
+ resp = await client.get(
233
+ f"{_GITHUB_API_BASE}/search/issues",
234
+ params={"q": query, "per_page": 1},
235
+ )
236
+ resp.raise_for_status()
237
+ items = resp.json().get("items", [])
238
+ if items:
239
+ return int(items[0]["number"])
240
+ except Exception as exc: # noqa: BLE001
241
+ logger.warning("GitHub dedup search failed (non-fatal): %s", exc)
242
+ return None
243
+
244
+
245
+ async def add_github_comment(
246
+ issue_number: int,
247
+ comment_body: str,
248
+ *,
249
+ token: str,
250
+ repo: str,
251
+ ) -> bool:
252
+ """Append *comment_body* to an existing GitHub issue.
253
+
254
+ Returns
255
+ -------
256
+ bool
257
+ ``True`` on success, ``False`` on error.
258
+ """
259
+ owner, repo_name = repo.split("/", 1)
260
+ headers = {
261
+ "Authorization": f"Bearer {token}",
262
+ "Accept": "application/vnd.github+json",
263
+ "X-GitHub-Api-Version": "2022-11-28",
264
+ }
265
+ url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}/issues/{issue_number}/comments"
266
+ try:
267
+ async with httpx.AsyncClient(headers=headers, timeout=15) as client:
268
+ resp = await client.post(url, json={"body": comment_body})
269
+ resp.raise_for_status()
270
+ return True
271
+ except Exception as exc: # noqa: BLE001
272
+ logger.warning(
273
+ "GitHub add_comment failed for issue #%d (non-fatal): %s", issue_number, exc
274
+ )
275
+ return False
@@ -0,0 +1,216 @@
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
+ """Adapter that converts autonomous-loop failure dicts into SecurityFinding objects.
7
+
8
+ The autonomous loop records failures as plain dicts with keys:
9
+ ``url``, ``page``, ``error``, ``task_id`` (and optionally ``selector``,
10
+ ``screenshot_url``, ``dom_snapshot``, ``steps``).
11
+
12
+ This module bridges that format into the ``SecurityFinding`` schema so that all
13
+ existing exporter plugins (Jira, GitHub Issues, webhook, etc.) can handle
14
+ functional test failures without modification.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from mannf.product.security.models import SecurityFinding, SecurityReport
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Mapping from simple error keywords to a NAT severity level.
27
+ _ERROR_SEVERITY_MAP: list[tuple[str, str]] = [
28
+ # Critical — security-adjacent or data-loss errors
29
+ ("security", "critical"),
30
+ ("exploit", "critical"),
31
+ ("injection", "critical"),
32
+ ("xss", "critical"),
33
+ ("sql", "critical"),
34
+ ("auth", "high"),
35
+ ("unauthori", "high"),
36
+ ("forbidden", "high"),
37
+ ("500", "high"),
38
+ ("crash", "high"),
39
+ ("exception", "high"),
40
+ ("timeout", "medium"),
41
+ ("404", "medium"),
42
+ ("not found", "medium"),
43
+ ("assertion", "medium"),
44
+ ("failed", "low"),
45
+ ("error", "low"),
46
+ ]
47
+
48
+ _DEFAULT_SEVERITY = "low"
49
+
50
+
51
+ def _infer_severity(error: str) -> str:
52
+ """Infer a severity level from a free-text error message."""
53
+ lower = error.lower()
54
+ for keyword, severity in _ERROR_SEVERITY_MAP:
55
+ if keyword in lower:
56
+ return severity
57
+ return _DEFAULT_SEVERITY
58
+
59
+
60
+ def _build_check_id(task_id: str) -> str:
61
+ """Generate a stable check ID for a functional failure."""
62
+ prefix = task_id[:8] if task_id else "unknown"
63
+ return f"FUNC-{prefix.upper()}"
64
+
65
+
66
+ class FunctionalFindingAdapter:
67
+ """Converts an autonomous-loop *unique_failure* dict to a :class:`SecurityFinding`.
68
+
69
+ Each failure dict produced by the autonomous loop has the following schema::
70
+
71
+ {
72
+ "url": str, # full URL where the failure occurred
73
+ "page": str, # page path key (e.g. "/checkout")
74
+ "error": str, # error message / exception text
75
+ "task_id": str, # stable task identifier
76
+ # optional
77
+ "selector": str | None, # CSS / XPath selector that failed
78
+ "screenshot_url": str | None, # screenshot artifact URL
79
+ "dom_snapshot": str | None, # DOM snapshot text / URL
80
+ "steps": list | None, # ordered scenario step dicts
81
+ }
82
+ """
83
+
84
+ @staticmethod
85
+ def from_unique_failure(
86
+ failure: Dict[str, Any],
87
+ *,
88
+ run_id: str = "",
89
+ target_url: str = "",
90
+ severity_override: Optional[str] = None,
91
+ ) -> SecurityFinding:
92
+ """Convert a single failure dict to a :class:`SecurityFinding`.
93
+
94
+ Parameters
95
+ ----------
96
+ failure:
97
+ A failure dict from ``AutonomousRunReport.unique_failures``.
98
+ run_id:
99
+ Optional autonomous run ID for additional context in the body.
100
+ target_url:
101
+ Root URL of the application under test.
102
+ severity_override:
103
+ If provided, this severity is used instead of the inferred one.
104
+
105
+ Returns
106
+ -------
107
+ SecurityFinding
108
+ A finding object compatible with all exporter plugins.
109
+ """
110
+ url = failure.get("url", target_url or "unknown")
111
+ page = failure.get("page", url)
112
+ error = failure.get("error", "Unknown error")
113
+ task_id = failure.get("task_id", "")
114
+ selector = failure.get("selector", "")
115
+ screenshot_url = failure.get("screenshot_url", "")
116
+ dom_snapshot = failure.get("dom_snapshot", "")
117
+ steps: List[Dict[str, Any]] = failure.get("steps") or []
118
+
119
+ severity = severity_override or _infer_severity(error)
120
+ check_id = _build_check_id(task_id)
121
+
122
+ # Build a human-readable description
123
+ description_parts = [
124
+ f"Functional test failure detected on page **{page}**.",
125
+ "",
126
+ f"**Error:** {error}",
127
+ ]
128
+ if selector:
129
+ description_parts.append(f"**Selector:** `{selector}`")
130
+ if url:
131
+ description_parts.append(f"**URL:** {url}")
132
+ description = "\n".join(description_parts)
133
+
134
+ # Build evidence block
135
+ evidence_parts = [f"Task ID: `{task_id}`" if task_id else ""]
136
+ if run_id:
137
+ evidence_parts.append(f"Run ID: `{run_id}`")
138
+ if screenshot_url:
139
+ evidence_parts.append(f"Screenshot: {screenshot_url}")
140
+ if dom_snapshot:
141
+ snapshot_preview = dom_snapshot[:500] + "…" if len(dom_snapshot) > 500 else dom_snapshot
142
+ evidence_parts.append(f"DOM Snapshot:\n```\n{snapshot_preview}\n```")
143
+ if steps:
144
+ steps_text = "\n".join(
145
+ f"{i + 1}. {s.get('action', s.get('type', '?'))}: {s.get('selector', s.get('value', ''))}"
146
+ for i, s in enumerate(steps[:10])
147
+ )
148
+ evidence_parts.append(f"Reproduction steps:\n{steps_text}")
149
+ evidence = "\n".join(p for p in evidence_parts if p)
150
+
151
+ return SecurityFinding(
152
+ check_id=check_id,
153
+ check_name="Autonomous Functional Test Failure",
154
+ severity=severity,
155
+ endpoint=page,
156
+ title=f"Functional failure on {page}: {error[:80]}",
157
+ description=description,
158
+ evidence=evidence or "No additional evidence available.",
159
+ remediation=(
160
+ "Review the reproduction steps and error message. "
161
+ "Investigate the affected page for broken interactions or regressions."
162
+ ),
163
+ cwe_id="",
164
+ confidence=0.85,
165
+ )
166
+
167
+ @classmethod
168
+ def from_unique_failures(
169
+ cls,
170
+ failures: List[Dict[str, Any]],
171
+ *,
172
+ run_id: str = "",
173
+ target_url: str = "",
174
+ severity_override: Optional[str] = None,
175
+ ) -> List[SecurityFinding]:
176
+ """Convert a list of failure dicts to a list of :class:`SecurityFinding` objects."""
177
+ findings: List[SecurityFinding] = []
178
+ for failure in failures:
179
+ try:
180
+ findings.append(
181
+ cls.from_unique_failure(
182
+ failure,
183
+ run_id=run_id,
184
+ target_url=target_url,
185
+ severity_override=severity_override,
186
+ )
187
+ )
188
+ except Exception as exc: # noqa: BLE001
189
+ logger.warning("FunctionalFindingAdapter: failed to convert failure dict: %s", exc)
190
+ return findings
191
+
192
+ @staticmethod
193
+ def make_report(
194
+ run_id: str,
195
+ target_url: str,
196
+ total_failures: int,
197
+ ) -> SecurityReport:
198
+ """Create a minimal :class:`SecurityReport` to satisfy exporter signatures.
199
+
200
+ Parameters
201
+ ----------
202
+ run_id:
203
+ The autonomous run ID.
204
+ target_url:
205
+ Root URL of the application under test.
206
+ total_failures:
207
+ Total number of failures in the run (used as ``total_endpoints_scanned``).
208
+ """
209
+ from datetime import datetime, timezone # noqa: PLC0415
210
+
211
+ return SecurityReport(
212
+ scan_id=run_id,
213
+ timestamp=datetime.now(timezone.utc).isoformat(),
214
+ target_url=target_url,
215
+ total_endpoints_scanned=total_failures,
216
+ )