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,257 @@
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
+ """Azure DevOps Work Items exporter plugin — creates Azure Boards work items from NAT findings.
7
+
8
+ Usage
9
+ -----
10
+ Export findings to Azure Boards by supplying the following config keys via
11
+ ``--export-config`` on the CLI::
12
+
13
+ nat security-scan --spec api.yaml --base-url http://localhost \\
14
+ --export azure-devops \\
15
+ --export-config organization=myorg \\
16
+ --export-config project=MyProject \\
17
+ --export-config pat=$AZURE_DEVOPS_PAT
18
+
19
+ Obtaining an Azure DevOps Personal Access Token
20
+ ------------------------------------------------
21
+ 1. Sign in to ``https://dev.azure.com/{organization}``.
22
+ 2. Go to *User Settings → Personal Access Tokens*.
23
+ 3. Create a token with the **Work Items (Read & Write)** scope.
24
+ 4. Copy the token value — it is shown only once.
25
+
26
+ Required config keys
27
+ --------------------
28
+ organization: Azure DevOps organization name (the ``{organization}`` segment
29
+ in ``https://dev.azure.com/{organization}``).
30
+ project: Azure DevOps project name.
31
+ pat: Personal Access Token with Work Items (Read & Write) scope.
32
+
33
+ Optional config keys
34
+ --------------------
35
+ work_item_type: Work item type to create (default ``"Bug"``).
36
+ area_path: Area path for the work item.
37
+ iteration_path: Iteration path for the work item.
38
+ assignee: E-mail or display name of the assignee.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import base64
44
+ import logging
45
+
46
+ import httpx
47
+
48
+ from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
49
+ from mannf.product.security.models import SecurityFinding, SecurityReport
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ _TITLE_MAX_LENGTH = 255
54
+
55
+ # ADO severity label format
56
+ _ADO_SEVERITY_MAP: dict[str, str] = {
57
+ "critical": "1 - Critical",
58
+ "high": "2 - High",
59
+ "medium": "3 - Medium",
60
+ "low": "4 - Low",
61
+ "info": "4 - Low",
62
+ }
63
+
64
+ # ADO priority integers (as strings to satisfy base class signature)
65
+ _ADO_PRIORITY_MAP: dict[str, str] = {
66
+ "critical": "1",
67
+ "high": "1",
68
+ "medium": "2",
69
+ "low": "3",
70
+ "info": "4",
71
+ }
72
+
73
+
74
+ class AzureDevOpsExporter(ExporterPlugin):
75
+ """Exports NAT security findings as Azure DevOps work items via the Azure Boards REST API.
76
+
77
+ Required config keys:
78
+ organization: Azure DevOps organization name.
79
+ project: Azure DevOps project name.
80
+ pat: Personal Access Token with Work Items (Read & Write) scope.
81
+
82
+ Optional config keys:
83
+ work_item_type: Work item type (default ``"Bug"``).
84
+ area_path: Area path for the work item.
85
+ iteration_path: Iteration path for the work item.
86
+ assignee: E-mail or display name of the assignee.
87
+
88
+ Example CLI usage::
89
+
90
+ nat security-scan --spec api.yaml --base-url http://localhost \\
91
+ --export azure-devops \\
92
+ --export-config organization=myorg \\
93
+ --export-config project=MyProject \\
94
+ --export-config pat=$AZURE_DEVOPS_PAT
95
+ """
96
+
97
+ name = "azure-devops"
98
+ display_name = "Azure DevOps Work Items"
99
+ description = "Export NAT findings as Azure DevOps work items via the Azure Boards REST API."
100
+
101
+ def validate_config(self, config: dict) -> list[str]:
102
+ errors: list[str] = []
103
+ if not config.get("organization"):
104
+ errors.append("'organization' is required")
105
+ if not config.get("project"):
106
+ errors.append("'project' is required")
107
+ if not config.get("pat"):
108
+ errors.append("'pat' is required")
109
+ return errors
110
+
111
+ async def test_connection(self, config: dict) -> TestConnectionResult:
112
+ """Test connection via work item types list — confirms PAT + org + project."""
113
+ errors = self.validate_config(config)
114
+ if errors:
115
+ return TestConnectionResult(
116
+ success=False,
117
+ message=f"Config validation failed: {'; '.join(errors)}",
118
+ details={"errors": errors},
119
+ )
120
+
121
+ organization = config["organization"]
122
+ project = config["project"]
123
+ pat = config["pat"]
124
+ url = (
125
+ f"https://dev.azure.com/{organization}/{project}"
126
+ "/_apis/wit/workitemtypes?api-version=7.1"
127
+ )
128
+ headers = {
129
+ "Authorization": self._build_auth_header(pat),
130
+ "Accept": "application/json",
131
+ }
132
+
133
+ try:
134
+ async with httpx.AsyncClient(timeout=10) as client:
135
+ resp = await client.get(url, headers=headers)
136
+ resp.raise_for_status()
137
+ data = resp.json()
138
+ except httpx.HTTPStatusError as exc:
139
+ status = exc.response.status_code
140
+ if status == 401:
141
+ msg = "Azure DevOps authentication failed — check your PAT"
142
+ elif status == 404:
143
+ msg = f"Azure DevOps project '{project}' not found in org '{organization}'"
144
+ else:
145
+ msg = f"Azure DevOps API error {status}: {exc.response.text}"
146
+ return TestConnectionResult(success=False, message=msg, details={})
147
+ except httpx.RequestError as exc:
148
+ return TestConnectionResult(
149
+ success=False,
150
+ message=f"Azure DevOps network error: {exc}",
151
+ details={},
152
+ )
153
+
154
+ count = data.get("count", 0)
155
+ return TestConnectionResult(
156
+ success=True,
157
+ message=f"Connected to Azure DevOps — project '{project}' found ({count} work item types)",
158
+ details={"organization": organization, "project": project, "workItemTypeCount": count},
159
+ )
160
+
161
+ def _severity_to_priority(self, severity: str) -> str:
162
+ """Return ADO priority integer (as a string) for the given NAT severity."""
163
+ return _ADO_PRIORITY_MAP.get(severity.lower(), "3")
164
+
165
+ def _build_auth_header(self, pat: str) -> str:
166
+ """Build a Basic auth header value from an Azure DevOps PAT.
167
+
168
+ Azure DevOps Basic auth uses an empty username and the PAT as the
169
+ password, base64-encoded as ``:<PAT>``.
170
+ """
171
+ encoded = base64.b64encode(f":{pat}".encode()).decode()
172
+ return f"Basic {encoded}"
173
+
174
+ def _build_patch_document(
175
+ self,
176
+ finding: SecurityFinding,
177
+ report: SecurityReport,
178
+ config: dict,
179
+ ) -> list[dict]:
180
+ """Build the JSON Patch operations array for the Azure Boards API."""
181
+ severity = finding.severity.lower()
182
+ ado_severity = _ADO_SEVERITY_MAP.get(severity, "4 - Low")
183
+ ado_priority = int(self._severity_to_priority(severity))
184
+
185
+ cwe_part = finding.cwe_id if finding.cwe_id else "unknown"
186
+ owasp_part = finding.check_id
187
+ tags = f"nat-security; owasp:{owasp_part}; severity:{severity}; cwe:{cwe_part}"
188
+
189
+ raw_title = f"[NAT] {finding.title}"
190
+ title = raw_title[:_TITLE_MAX_LENGTH]
191
+
192
+ body_md = self._format_finding_body(finding, report)
193
+ description_html = f"<div>{body_md}</div>"
194
+
195
+ ops = [
196
+ {"op": "add", "path": "/fields/System.Title", "value": title},
197
+ {"op": "add", "path": "/fields/System.Description", "value": description_html},
198
+ {"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": ado_priority},
199
+ {"op": "add", "path": "/fields/Microsoft.VSTS.Common.Severity", "value": ado_severity},
200
+ {"op": "add", "path": "/fields/System.Tags", "value": tags},
201
+ ]
202
+
203
+ if config.get("area_path"):
204
+ ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": config["area_path"]})
205
+ if config.get("iteration_path"):
206
+ ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": config["iteration_path"]})
207
+ if config.get("assignee"):
208
+ ops.append({"op": "add", "path": "/fields/System.AssignedTo", "value": config["assignee"]})
209
+
210
+ return ops
211
+
212
+ async def export_finding(
213
+ self,
214
+ finding: SecurityFinding,
215
+ report: SecurityReport,
216
+ config: dict,
217
+ ) -> ExportResult:
218
+ organization = config["organization"]
219
+ project = config["project"]
220
+ pat = config["pat"]
221
+ work_item_type = config.get("work_item_type", "Bug")
222
+
223
+ url = (
224
+ f"https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/"
225
+ f"${work_item_type}?api-version=7.1"
226
+ )
227
+
228
+ headers = {
229
+ "Content-Type": "application/json-patch+json",
230
+ "Authorization": self._build_auth_header(pat),
231
+ }
232
+
233
+ patch_document = self._build_patch_document(finding, report, config)
234
+
235
+ try:
236
+ async with httpx.AsyncClient(timeout=30) as client:
237
+ resp = await client.post(url, json=patch_document, headers=headers)
238
+ resp.raise_for_status()
239
+ data = resp.json()
240
+ except httpx.HTTPStatusError as exc:
241
+ error_msg = f"Azure DevOps API error {exc.response.status_code}: {exc.response.text}"
242
+ logger.warning("AzureDevOpsExporter: %s", error_msg)
243
+ return ExportResult(finding=finding, success=False, error=error_msg)
244
+ except httpx.RequestError as exc:
245
+ error_msg = f"Azure DevOps network error: {exc}"
246
+ logger.warning("AzureDevOpsExporter: %s", error_msg)
247
+ return ExportResult(finding=finding, success=False, error=error_msg)
248
+
249
+ external_id = str(data.get("id", ""))
250
+ external_url = data.get("_links", {}).get("html", {}).get("href")
251
+
252
+ return ExportResult(
253
+ finding=finding,
254
+ success=True,
255
+ external_id=external_id,
256
+ external_url=external_url,
257
+ )
@@ -0,0 +1,307 @@
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
+ """Abstract base for exporter plugins that push NAT findings to external trackers."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass, field
13
+
14
+ from mannf.product.security.models import SecurityFinding, SecurityReport
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class TestConnectionResult:
21
+ """Result of a lightweight connection test against an external service."""
22
+
23
+ success: bool
24
+ message: str # e.g., "Connected to Jira — project SEC found"
25
+ details: dict = field(default_factory=dict)
26
+
27
+ _SEVERITY_ORDER: dict[str, int] = {
28
+ "critical": 0,
29
+ "high": 1,
30
+ "medium": 2,
31
+ "low": 3,
32
+ "info": 4,
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class ExportResult:
38
+ """Result of exporting a single finding."""
39
+
40
+ finding: SecurityFinding
41
+ success: bool
42
+ external_id: str | None = None # e.g., Jira issue key "NAT-123"
43
+ external_url: str | None = None # link to the created issue
44
+ error: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class ExportSummary:
49
+ """Summary of a batch export operation."""
50
+
51
+ total: int
52
+ succeeded: int
53
+ failed: int
54
+ skipped: int
55
+ results: list[ExportResult] = field(default_factory=list)
56
+
57
+
58
+ class ExporterPlugin(ABC):
59
+ """Abstract base class for defect tracker exporter plugins.
60
+
61
+ Subclasses must define the class-level attributes ``name``,
62
+ ``display_name``, and ``description``, and implement the abstract
63
+ methods ``export_finding`` and ``validate_config``.
64
+
65
+ Example::
66
+
67
+ class MyExporter(ExporterPlugin):
68
+ name = "my-tracker"
69
+ display_name = "My Tracker"
70
+ description = "Export findings to My Tracker."
71
+
72
+ async def export_finding(self, finding, report, config):
73
+ ...
74
+
75
+ def validate_config(self, config):
76
+ errors = []
77
+ if not config.get("api_key"):
78
+ errors.append("api_key is required")
79
+ return errors
80
+ """
81
+
82
+ name: str # e.g., "jira", "linear", "github-issues"
83
+ display_name: str # e.g., "Jira Cloud", "Linear", "GitHub Issues"
84
+ description: str # one-line description
85
+
86
+ @abstractmethod
87
+ async def export_finding(
88
+ self,
89
+ finding: SecurityFinding,
90
+ report: SecurityReport,
91
+ config: dict,
92
+ ) -> ExportResult:
93
+ """Export a single finding to the external tracker.
94
+
95
+ Parameters
96
+ ----------
97
+ finding:
98
+ The security finding to export.
99
+ report:
100
+ The parent scan report that provides scan-level context.
101
+ config:
102
+ Exporter-specific configuration dict (keys validated by
103
+ :meth:`validate_config`).
104
+
105
+ Returns
106
+ -------
107
+ ExportResult
108
+ The result of the export attempt.
109
+ """
110
+ ...
111
+
112
+ async def export_findings(
113
+ self,
114
+ findings: list[SecurityFinding],
115
+ report: SecurityReport,
116
+ config: dict,
117
+ min_severity: str = "low",
118
+ ) -> ExportSummary:
119
+ """Export multiple findings, filtering by minimum severity.
120
+
121
+ The default implementation calls :meth:`export_finding` in a loop.
122
+ Export failures are caught and recorded in the summary rather than
123
+ propagated, so a single failing export never aborts the batch.
124
+
125
+ Parameters
126
+ ----------
127
+ findings:
128
+ All findings from the scan.
129
+ report:
130
+ The parent scan report.
131
+ config:
132
+ Exporter-specific configuration dict.
133
+ min_severity:
134
+ Minimum severity level to export (``"critical"``, ``"high"``,
135
+ ``"medium"``, ``"low"``, or ``"info"``). Findings below this
136
+ threshold are counted as *skipped*.
137
+
138
+ Returns
139
+ -------
140
+ ExportSummary
141
+ Aggregated results for every finding processed.
142
+ """
143
+ min_order = _SEVERITY_ORDER.get(min_severity.lower(), _SEVERITY_ORDER["low"])
144
+
145
+ results: list[ExportResult] = []
146
+ succeeded = 0
147
+ failed = 0
148
+ skipped = 0
149
+
150
+ for finding in findings:
151
+ finding_order = _SEVERITY_ORDER.get(finding.severity.lower(), _SEVERITY_ORDER["info"])
152
+ if finding_order > min_order:
153
+ skipped += 1
154
+ continue
155
+
156
+ try:
157
+ result = await self.export_finding(finding, report, config)
158
+ except Exception as exc: # noqa: BLE001
159
+ logger.warning(
160
+ "Exporter '%s' raised an unexpected error for finding '%s': %s",
161
+ self.name,
162
+ finding.check_id,
163
+ exc,
164
+ )
165
+ result = ExportResult(finding=finding, success=False, error=str(exc))
166
+
167
+ results.append(result)
168
+ if result.success:
169
+ succeeded += 1
170
+ else:
171
+ failed += 1
172
+
173
+ return ExportSummary(
174
+ total=len(findings),
175
+ succeeded=succeeded,
176
+ failed=failed,
177
+ skipped=skipped,
178
+ results=results,
179
+ )
180
+
181
+ @abstractmethod
182
+ def validate_config(self, config: dict) -> list[str]:
183
+ """Validate exporter-specific config.
184
+
185
+ Parameters
186
+ ----------
187
+ config:
188
+ Configuration dict to validate.
189
+
190
+ Returns
191
+ -------
192
+ list[str]
193
+ A list of human-readable error strings. An empty list means the
194
+ config is valid.
195
+ """
196
+ ...
197
+
198
+ async def test_connection(self, config: dict) -> TestConnectionResult:
199
+ """Test connectivity to the external service using the given config.
200
+
201
+ The default implementation validates the config and returns success if
202
+ there are no validation errors. Concrete subclasses should override
203
+ this with a lightweight, read-only API call that verifies credentials
204
+ and target availability live.
205
+
206
+ Parameters
207
+ ----------
208
+ config:
209
+ Exporter-specific configuration dict.
210
+
211
+ Returns
212
+ -------
213
+ TestConnectionResult
214
+ Result indicating whether the connection was successful.
215
+ """
216
+ errors = self.validate_config(config)
217
+ if errors:
218
+ return TestConnectionResult(
219
+ success=False,
220
+ message=f"Config validation failed: {'; '.join(errors)}",
221
+ details={"errors": errors},
222
+ )
223
+ return TestConnectionResult(
224
+ success=True,
225
+ message=f"Config for '{self.name}' is valid (no live connection test performed).",
226
+ details={},
227
+ )
228
+
229
+ def _severity_to_priority(self, severity: str) -> str:
230
+ """Map a NAT severity to a generic priority label.
231
+
232
+ Concrete exporters may override this to return tracker-specific
233
+ priority values (e.g., ``"P1"`` for Jira, ``"urgent"`` for Linear).
234
+
235
+ Parameters
236
+ ----------
237
+ severity:
238
+ NAT severity string (``"critical"``, ``"high"``, ``"medium"``,
239
+ ``"low"``, or ``"info"``).
240
+
241
+ Returns
242
+ -------
243
+ str
244
+ A generic priority label.
245
+ """
246
+ mapping = {
247
+ "critical": "critical",
248
+ "high": "high",
249
+ "medium": "medium",
250
+ "low": "low",
251
+ "info": "info",
252
+ }
253
+ return mapping.get(severity.lower(), "low")
254
+
255
+ def _format_finding_body(
256
+ self,
257
+ finding: SecurityFinding,
258
+ report: SecurityReport,
259
+ ) -> str:
260
+ """Generate a Markdown-formatted description body for a finding.
261
+
262
+ Parameters
263
+ ----------
264
+ finding:
265
+ The security finding to format.
266
+ report:
267
+ The parent scan report for additional context.
268
+
269
+ Returns
270
+ -------
271
+ str
272
+ A Markdown string suitable for use as an issue body.
273
+ """
274
+ lines = [
275
+ f"## {finding.title}",
276
+ "",
277
+ finding.description,
278
+ "",
279
+ "### Details",
280
+ "",
281
+ f"- **Endpoint:** `{finding.endpoint}`",
282
+ f"- **Severity:** {finding.severity}",
283
+ f"- **Confidence:** {finding.confidence:.0%}",
284
+ ]
285
+ if finding.cwe_id:
286
+ lines.append(f"- **CWE:** [{finding.cwe_id}](https://cwe.mitre.org/data/definitions/{finding.cwe_id.replace('CWE-', '')}.html)")
287
+ lines += [
288
+ "",
289
+ "### Evidence",
290
+ "",
291
+ finding.evidence,
292
+ "",
293
+ "### Remediation",
294
+ "",
295
+ finding.remediation,
296
+ "",
297
+ "### Scan Context",
298
+ "",
299
+ f"- **Scan ID:** `{report.scan_id}`",
300
+ f"- **Target URL:** {report.target_url}",
301
+ f"- **Timestamp:** {report.timestamp}",
302
+ f"- **Total Endpoints Scanned:** {report.total_endpoints_scanned}",
303
+ "",
304
+ "---",
305
+ "*Generated by [NAT (Neural Agent Testing Framework)](https://nat-testing.io)*",
306
+ ]
307
+ return "\n".join(lines)