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,197 @@
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
+ """GitHub Issues exporter plugin — creates GitHub Issues from NAT security findings."""
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.exporters.dedup import AUTO_FILED_LABEL
16
+ from mannf.product.security.models import SecurityFinding, SecurityReport
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _GITHUB_API_BASE = "https://api.github.com"
21
+
22
+
23
+ class GitHubIssuesExporter(ExporterPlugin):
24
+ """Exports NAT security findings as GitHub Issues via the REST API.
25
+
26
+ Required config keys:
27
+ token: GitHub personal access token or ``$GITHUB_TOKEN``.
28
+ repo: Repository in ``owner/repo`` format, e.g. ``"myorg/myrepo"``.
29
+
30
+ Optional config keys:
31
+ labels: Extra label names to add to every created issue.
32
+ assignees: GitHub usernames to assign to every created issue.
33
+ deduplicate: If ``true`` (bool), search for an existing open issue
34
+ with the same ``[NAT]`` title before creating a new one.
35
+ Defaults to ``false``.
36
+ """
37
+
38
+ name = "github-issues"
39
+ display_name = "GitHub Issues"
40
+ description = "Creates GitHub Issues for NAT security findings via the REST API."
41
+
42
+ def validate_config(self, config: dict) -> list[str]:
43
+ errors: list[str] = []
44
+ if not config.get("token"):
45
+ errors.append("'token' is required")
46
+ repo = config.get("repo", "")
47
+ if not repo:
48
+ errors.append("'repo' is required")
49
+ elif "/" not in repo or len(repo.split("/")) != 2 or not all(repo.split("/")):
50
+ errors.append("'repo' must be in 'owner/repo' format")
51
+ return errors
52
+
53
+ async def test_connection(self, config: dict) -> TestConnectionResult:
54
+ """Test connection by fetching the repository metadata via GET /repos/{owner}/{repo}."""
55
+ errors = self.validate_config(config)
56
+ if errors:
57
+ return TestConnectionResult(
58
+ success=False,
59
+ message=f"Config validation failed: {'; '.join(errors)}",
60
+ details={"errors": errors},
61
+ )
62
+
63
+ token = config["token"]
64
+ owner, repo_name = config["repo"].split("/", 1)
65
+ url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}"
66
+
67
+ try:
68
+ async with httpx.AsyncClient(headers=self._build_headers(token), timeout=10) as client:
69
+ resp = await client.get(url)
70
+ resp.raise_for_status()
71
+ data = resp.json()
72
+ except httpx.HTTPStatusError as exc:
73
+ status = exc.response.status_code
74
+ if status == 401:
75
+ msg = "GitHub authentication failed — check your token"
76
+ elif status == 404:
77
+ msg = f"GitHub repo '{config['repo']}' not found or not accessible"
78
+ else:
79
+ msg = f"GitHub API error {status}: {exc.response.text}"
80
+ return TestConnectionResult(success=False, message=msg, details={})
81
+ except httpx.RequestError as exc:
82
+ return TestConnectionResult(
83
+ success=False,
84
+ message=f"GitHub network error: {exc}",
85
+ details={},
86
+ )
87
+
88
+ return TestConnectionResult(
89
+ success=True,
90
+ message=f"Connected to GitHub — repo '{data.get('full_name', config['repo'])}' found",
91
+ details={"repo": data.get("full_name"), "private": data.get("private")},
92
+ )
93
+
94
+ def _build_headers(self, token: str) -> dict:
95
+ return {
96
+ "Authorization": f"Bearer {token}",
97
+ "Accept": "application/vnd.github+json",
98
+ "X-GitHub-Api-Version": "2022-11-28",
99
+ }
100
+
101
+ async def _find_existing_issue(
102
+ self,
103
+ client: httpx.AsyncClient,
104
+ owner: str,
105
+ repo: str,
106
+ title: str,
107
+ ) -> str | None:
108
+ """Search for an open issue with *title*; return its HTML URL or None."""
109
+ search_url = f"{_GITHUB_API_BASE}/search/issues"
110
+ query = f'repo:{owner}/{repo} is:issue is:open in:title "{title}"'
111
+ try:
112
+ resp = await client.get(search_url, params={"q": query, "per_page": 1})
113
+ resp.raise_for_status()
114
+ items = resp.json().get("items", [])
115
+ if items:
116
+ return items[0].get("html_url")
117
+ except Exception as exc: # noqa: BLE001
118
+ logger.warning("GitHubIssuesExporter: deduplication search failed: %s", exc)
119
+ return None
120
+
121
+ async def export_finding(
122
+ self,
123
+ finding: SecurityFinding,
124
+ report: SecurityReport,
125
+ config: dict,
126
+ ) -> ExportResult:
127
+ token = config["token"]
128
+ owner, repo_name = config["repo"].split("/", 1)
129
+ extra_labels: list[str] = config.get("labels") or []
130
+ assignees: list[str] = config.get("assignees") or []
131
+ deduplicate: bool = bool(config.get("deduplicate", False))
132
+ milestone: int | None = config.get("milestone") # GitHub milestone number
133
+
134
+ title = f"[NAT] {finding.title}"
135
+ body = self._format_finding_body(finding, report)
136
+
137
+ # Functional findings (auto-filed from autonomous loop) use a distinct
138
+ # label set; security findings keep "nat-security".
139
+ is_functional = finding.check_id.startswith("FUNC-")
140
+ if is_functional:
141
+ base_labels = [AUTO_FILED_LABEL, finding.severity]
142
+ else:
143
+ base_labels = ["nat-security", finding.severity, finding.check_id]
144
+ labels = base_labels + extra_labels
145
+
146
+ headers = self._build_headers(token)
147
+
148
+ try:
149
+ async with httpx.AsyncClient(headers=headers, timeout=30) as client:
150
+ if deduplicate:
151
+ existing_url = await self._find_existing_issue(
152
+ client, owner, repo_name, title
153
+ )
154
+ if existing_url:
155
+ logger.info(
156
+ "GitHubIssuesExporter: skipping duplicate — existing issue: %s",
157
+ existing_url,
158
+ )
159
+ issue_number = existing_url.rstrip("/").split("/")[-1]
160
+ return ExportResult(
161
+ finding=finding,
162
+ success=True,
163
+ external_id=issue_number,
164
+ external_url=existing_url,
165
+ )
166
+
167
+ payload: dict = {
168
+ "title": title,
169
+ "body": body,
170
+ "labels": labels,
171
+ }
172
+ if assignees:
173
+ payload["assignees"] = assignees
174
+ if milestone is not None:
175
+ payload["milestone"] = milestone
176
+
177
+ resp = await client.post(
178
+ f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}/issues",
179
+ json=payload,
180
+ )
181
+ resp.raise_for_status()
182
+ data = resp.json()
183
+ except httpx.HTTPStatusError as exc:
184
+ error_msg = f"GitHub API error {exc.response.status_code}: {exc.response.text}"
185
+ logger.warning("GitHubIssuesExporter: %s", error_msg)
186
+ return ExportResult(finding=finding, success=False, error=error_msg)
187
+ except httpx.RequestError as exc:
188
+ error_msg = f"GitHub network error: {exc}"
189
+ logger.warning("GitHubIssuesExporter: %s", error_msg)
190
+ return ExportResult(finding=finding, success=False, error=error_msg)
191
+
192
+ return ExportResult(
193
+ finding=finding,
194
+ success=True,
195
+ external_id=str(data.get("number", "")),
196
+ external_url=data.get("html_url"),
197
+ )
@@ -0,0 +1,215 @@
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
+ """GitLab Issues exporter plugin — creates GitLab issues from NAT security findings.
7
+
8
+ Usage
9
+ -----
10
+ Export findings to a GitLab project 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 gitlab \\
15
+ --export-config gitlab_url=https://gitlab.com \\
16
+ --export-config project_id=12345 \\
17
+ --export-config private_token=$GITLAB_TOKEN
18
+
19
+ Obtaining a GitLab Personal Access Token
20
+ -----------------------------------------
21
+ 1. Sign in to your GitLab instance.
22
+ 2. Go to *User Settings → Access Tokens* (or *Profile → Preferences → Access Tokens*).
23
+ 3. Create a token with the ``api`` scope.
24
+ 4. Copy the token value — it is shown only once.
25
+
26
+ Required config keys
27
+ --------------------
28
+ gitlab_url: GitLab instance base URL, e.g. ``https://gitlab.com`` or a
29
+ self-hosted URL such as ``https://gitlab.mycompany.com``.
30
+ project_id: Numeric GitLab project ID or ``namespace/project`` path slug,
31
+ e.g. ``12345`` or ``mygroup/myproject``.
32
+ private_token: GitLab Personal Access Token with ``api`` scope.
33
+
34
+ Optional config keys
35
+ --------------------
36
+ labels: List of additional label strings to attach to every issue.
37
+ assignee_ids: List of GitLab user IDs (integers) to assign.
38
+ confidential: Whether issues should be confidential (default ``False``).
39
+ min_severity: Minimum severity to export (default ``"low"``).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ from urllib.parse import quote_plus
46
+
47
+ import httpx
48
+
49
+ from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
50
+ from mannf.product.security.models import SecurityFinding, SecurityReport
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ _TITLE_MAX_LENGTH = 255
55
+
56
+
57
+ def _owasp_label(check_id: str) -> str:
58
+ """Convert a check_id such as ``'OWASP-API1'`` to ``'owasp:API1'``."""
59
+ if check_id.upper().startswith("OWASP-"):
60
+ return "owasp:" + check_id[len("OWASP-"):]
61
+ return check_id
62
+
63
+
64
+ class GitLabExporter(ExporterPlugin):
65
+ """Exports NAT security findings as GitLab Issues via the GitLab REST API.
66
+
67
+ Required config keys:
68
+ gitlab_url: GitLab instance base URL (e.g. ``https://gitlab.com``).
69
+ project_id: Numeric project ID or ``namespace/project`` slug.
70
+ private_token: Personal Access Token with ``api`` scope.
71
+
72
+ Optional config keys:
73
+ labels: List of extra label strings for every created issue.
74
+ assignee_ids: List of GitLab user IDs to assign.
75
+ confidential: Create issues as confidential (default ``False``).
76
+ min_severity: Minimum severity threshold (default ``"low"``).
77
+
78
+ Example CLI usage::
79
+
80
+ nat security-scan --spec api.yaml --base-url http://localhost \\
81
+ --export gitlab \\
82
+ --export-config gitlab_url=https://gitlab.com \\
83
+ --export-config project_id=12345 \\
84
+ --export-config private_token=$GITLAB_TOKEN
85
+ """
86
+
87
+ name = "gitlab"
88
+ display_name = "GitLab Issues"
89
+ description = "Export NAT findings as GitLab issues via the GitLab REST API."
90
+
91
+ def validate_config(self, config: dict) -> list[str]:
92
+ errors: list[str] = []
93
+ if not config.get("gitlab_url"):
94
+ errors.append("'gitlab_url' is required")
95
+ if not config.get("project_id"):
96
+ errors.append("'project_id' is required")
97
+ if not config.get("private_token"):
98
+ errors.append("'private_token' is required")
99
+ return errors
100
+
101
+ async def test_connection(self, config: dict) -> TestConnectionResult:
102
+ """Test connection via GET /api/v4/projects/{project_id} — confirms token + project."""
103
+ errors = self.validate_config(config)
104
+ if errors:
105
+ return TestConnectionResult(
106
+ success=False,
107
+ message=f"Config validation failed: {'; '.join(errors)}",
108
+ details={"errors": errors},
109
+ )
110
+
111
+ gitlab_url = config["gitlab_url"].rstrip("/")
112
+ project_id = config["project_id"]
113
+ private_token = config["private_token"]
114
+ url = f"{gitlab_url}/api/v4/projects/{self._project_path(project_id)}"
115
+
116
+ try:
117
+ async with httpx.AsyncClient(timeout=10) as client:
118
+ resp = await client.get(url, headers={"PRIVATE-TOKEN": private_token})
119
+ resp.raise_for_status()
120
+ data = resp.json()
121
+ except httpx.HTTPStatusError as exc:
122
+ status = exc.response.status_code
123
+ if status == 401:
124
+ msg = "GitLab authentication failed — check your private_token"
125
+ elif status == 404:
126
+ msg = f"GitLab project '{project_id}' not found or not accessible"
127
+ else:
128
+ msg = f"GitLab API error {status}: {exc.response.text}"
129
+ return TestConnectionResult(success=False, message=msg, details={})
130
+ except httpx.RequestError as exc:
131
+ return TestConnectionResult(
132
+ success=False,
133
+ message=f"GitLab network error: {exc}",
134
+ details={},
135
+ )
136
+
137
+ return TestConnectionResult(
138
+ success=True,
139
+ message=f"Connected to GitLab — project '{data.get('path_with_namespace', project_id)}' found",
140
+ details={"project": data.get("path_with_namespace"), "id": data.get("id")},
141
+ )
142
+
143
+ def _severity_to_priority(self, severity: str) -> str:
144
+ mapping = {
145
+ "critical": "priority::critical",
146
+ "high": "priority::high",
147
+ "medium": "priority::medium",
148
+ "low": "priority::low",
149
+ "info": "priority::info",
150
+ }
151
+ return mapping.get(severity.lower(), "priority::low")
152
+
153
+ def _build_labels(self, finding: SecurityFinding, extra_labels: list[str]) -> str:
154
+ """Return a comma-separated label string for the GitLab issue."""
155
+ labels = [
156
+ "nat-security",
157
+ f"severity:{finding.severity}",
158
+ _owasp_label(finding.check_id),
159
+ ]
160
+ labels.extend(extra_labels)
161
+ return ",".join(labels)
162
+
163
+ def _project_path(self, project_id: str | int) -> str:
164
+ """URL-encode the project_id for use in a GitLab API path segment."""
165
+ return quote_plus(str(project_id))
166
+
167
+ async def export_finding(
168
+ self,
169
+ finding: SecurityFinding,
170
+ report: SecurityReport,
171
+ config: dict,
172
+ ) -> ExportResult:
173
+ gitlab_url = config["gitlab_url"].rstrip("/")
174
+ project_id = config["project_id"]
175
+ private_token = config["private_token"]
176
+ extra_labels: list[str] = config.get("labels") or []
177
+ assignee_ids: list[int] = config.get("assignee_ids") or []
178
+ confidential: bool = bool(config.get("confidential", False))
179
+
180
+ raw_title = f"[NAT] {finding.title}"
181
+ title = raw_title[:_TITLE_MAX_LENGTH]
182
+ description = self._format_finding_body(finding, report)
183
+ labels_str = self._build_labels(finding, extra_labels)
184
+
185
+ url = f"{gitlab_url}/api/v4/projects/{self._project_path(project_id)}/issues"
186
+ headers = {"PRIVATE-TOKEN": private_token}
187
+ payload: dict = {
188
+ "title": title,
189
+ "description": description,
190
+ "labels": labels_str,
191
+ "confidential": confidential,
192
+ }
193
+ if assignee_ids:
194
+ payload["assignee_ids"] = assignee_ids
195
+
196
+ try:
197
+ async with httpx.AsyncClient(headers=headers, timeout=30) as client:
198
+ resp = await client.post(url, json=payload)
199
+ resp.raise_for_status()
200
+ data = resp.json()
201
+ except httpx.HTTPStatusError as exc:
202
+ error_msg = f"GitLab API error {exc.response.status_code}: {exc.response.text}"
203
+ logger.warning("GitLabExporter: %s", error_msg)
204
+ return ExportResult(finding=finding, success=False, error=error_msg)
205
+ except httpx.RequestError as exc:
206
+ error_msg = f"GitLab network error: {exc}"
207
+ logger.warning("GitLabExporter: %s", error_msg)
208
+ return ExportResult(finding=finding, success=False, error=error_msg)
209
+
210
+ return ExportResult(
211
+ finding=finding,
212
+ success=True,
213
+ external_id=str(data.get("iid", "")),
214
+ external_url=data.get("web_url"),
215
+ )
@@ -0,0 +1,180 @@
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
+ """Jira Cloud exporter plugin — creates Jira issues from NAT security findings."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import logging
12
+
13
+ import httpx
14
+
15
+ from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
16
+ from mannf.product.security.models import SecurityFinding, SecurityReport
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _SEVERITY_TO_JIRA_PRIORITY: dict[str, str] = {
21
+ "critical": "Highest",
22
+ "high": "High",
23
+ "medium": "Medium",
24
+ "low": "Low",
25
+ "info": "Lowest",
26
+ }
27
+
28
+
29
+ class JiraExporter(ExporterPlugin):
30
+ """Exports NAT security findings as Jira Cloud issues via the REST API v3.
31
+
32
+ Required config keys:
33
+ base_url: Jira Cloud base URL, e.g. ``https://myco.atlassian.net``.
34
+ project_key: Jira project key, e.g. ``"NAT"``.
35
+ email: Atlassian account email used for Basic auth.
36
+ api_token: Atlassian API token used for Basic auth.
37
+
38
+ Optional config keys:
39
+ issue_type: Jira issue type name (default ``"Bug"``).
40
+ component: Jira component name to assign to new issues.
41
+ """
42
+
43
+ name = "jira"
44
+ display_name = "Jira Cloud"
45
+ description = "Creates Jira Cloud issues for NAT security findings via the REST API v3."
46
+
47
+ def validate_config(self, config: dict) -> list[str]:
48
+ required = ("base_url", "project_key", "email", "api_token")
49
+ return [f"'{k}' is required" for k in required if not config.get(k)]
50
+
51
+ async def test_connection(self, config: dict) -> TestConnectionResult:
52
+ """Test connection via GET /rest/api/3/myself — confirms email + api_token + base_url."""
53
+ errors = self.validate_config(config)
54
+ if errors:
55
+ return TestConnectionResult(
56
+ success=False,
57
+ message=f"Config validation failed: {'; '.join(errors)}",
58
+ details={"errors": errors},
59
+ )
60
+
61
+ base_url = config["base_url"].rstrip("/")
62
+ email = config["email"]
63
+ api_token = config["api_token"]
64
+ credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
65
+ headers = {
66
+ "Authorization": f"Basic {credentials}",
67
+ "Accept": "application/json",
68
+ }
69
+
70
+ try:
71
+ async with httpx.AsyncClient(timeout=10) as client:
72
+ resp = await client.get(
73
+ f"{base_url}/rest/api/3/myself",
74
+ headers=headers,
75
+ )
76
+ resp.raise_for_status()
77
+ data = resp.json()
78
+ except httpx.HTTPStatusError as exc:
79
+ status = exc.response.status_code
80
+ if status == 401:
81
+ msg = "Jira authentication failed — check your email and api_token"
82
+ elif status == 403:
83
+ msg = "Jira access forbidden — check account permissions"
84
+ elif status == 404:
85
+ msg = f"Jira instance not found at '{base_url}'"
86
+ else:
87
+ msg = f"Jira API error {status}: {exc.response.text}"
88
+ return TestConnectionResult(success=False, message=msg, details={})
89
+ except httpx.RequestError as exc:
90
+ return TestConnectionResult(
91
+ success=False,
92
+ message=f"Jira network error: {exc}",
93
+ details={},
94
+ )
95
+
96
+ display_name = data.get("displayName", email)
97
+ return TestConnectionResult(
98
+ success=True,
99
+ message=f"Connected to Jira — authenticated as '{display_name}'",
100
+ details={"displayName": display_name, "accountId": data.get("accountId")},
101
+ )
102
+
103
+ def _severity_to_priority(self, severity: str) -> str:
104
+ return _SEVERITY_TO_JIRA_PRIORITY.get(severity.lower(), "Low")
105
+
106
+ def _build_adf_body(self, markdown_text: str) -> dict:
107
+ """Wrap plain text in an Atlassian Document Format (ADF) paragraph node."""
108
+ return {
109
+ "type": "doc",
110
+ "version": 1,
111
+ "content": [
112
+ {
113
+ "type": "paragraph",
114
+ "content": [{"type": "text", "text": markdown_text}],
115
+ }
116
+ ],
117
+ }
118
+
119
+ async def export_finding(
120
+ self,
121
+ finding: SecurityFinding,
122
+ report: SecurityReport,
123
+ config: dict,
124
+ ) -> ExportResult:
125
+ base_url = config["base_url"].rstrip("/")
126
+ project_key = config["project_key"]
127
+ email = config["email"]
128
+ api_token = config["api_token"]
129
+ issue_type = config.get("issue_type", "Bug")
130
+ component = config.get("component")
131
+
132
+ priority_name = self._severity_to_priority(finding.severity)
133
+ body_text = self._format_finding_body(finding, report)
134
+
135
+ credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
136
+ headers = {
137
+ "Authorization": f"Basic {credentials}",
138
+ "Accept": "application/json",
139
+ "Content-Type": "application/json",
140
+ }
141
+
142
+ labels = ["nat-security", finding.check_id]
143
+ fields: dict = {
144
+ "project": {"key": project_key},
145
+ "summary": f"[NAT] {finding.title}",
146
+ "description": self._build_adf_body(body_text),
147
+ "issuetype": {"name": issue_type},
148
+ "priority": {"name": priority_name},
149
+ "labels": labels,
150
+ }
151
+ if component:
152
+ fields["components"] = [{"name": component}]
153
+
154
+ payload = {"fields": fields}
155
+
156
+ try:
157
+ async with httpx.AsyncClient(headers=headers, timeout=30) as client:
158
+ resp = await client.post(
159
+ f"{base_url}/rest/api/3/issue",
160
+ json=payload,
161
+ )
162
+ resp.raise_for_status()
163
+ data = resp.json()
164
+ except httpx.HTTPStatusError as exc:
165
+ error_msg = f"Jira API error {exc.response.status_code}: {exc.response.text}"
166
+ logger.warning("JiraExporter: %s", error_msg)
167
+ return ExportResult(finding=finding, success=False, error=error_msg)
168
+ except httpx.RequestError as exc:
169
+ error_msg = f"Jira network error: {exc}"
170
+ logger.warning("JiraExporter: %s", error_msg)
171
+ return ExportResult(finding=finding, success=False, error=error_msg)
172
+
173
+ issue_key = data.get("key", "")
174
+ issue_url = f"{base_url}/browse/{issue_key}" if issue_key else None
175
+ return ExportResult(
176
+ finding=finding,
177
+ success=True,
178
+ external_id=issue_key,
179
+ external_url=issue_url,
180
+ )