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,383 @@
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
+ """Generic Webhook exporter plugin — POSTs NAT findings as JSON to any HTTP endpoint.
7
+
8
+ This exporter enables integration with Zapier, n8n, Make, or any custom HTTP
9
+ endpoint without needing a dedicated plugin.
10
+
11
+ Usage
12
+ -----
13
+ Export findings to a webhook endpoint by supplying the following config keys
14
+ via ``--export-config`` on the CLI::
15
+
16
+ nat security-scan --spec api.yaml --base-url http://localhost \\
17
+ --export webhook \\
18
+ --export-config webhook_url=https://hooks.example.com/nat \\
19
+ --export-config hmac_secret=$WEBHOOK_SECRET
20
+
21
+ Example with batch mode::
22
+
23
+ nat security-scan --spec api.yaml --base-url http://localhost \\
24
+ --export webhook \\
25
+ --export-config webhook_url=https://hooks.zapier.com/hooks/catch/... \\
26
+ --export-config batch_mode=true
27
+
28
+ Required config keys
29
+ --------------------
30
+ webhook_url: The HTTP(S) URL to POST findings to.
31
+
32
+ Optional config keys
33
+ --------------------
34
+ auth_header: Value for the ``Authorization`` header (e.g.,
35
+ ``"Bearer mytoken"`` or ``"Basic abc123"``).
36
+ custom_headers: Dict of additional HTTP headers to include in every
37
+ request.
38
+ hmac_secret: If provided, compute HMAC-SHA256 of the JSON payload
39
+ body and include it as ``X-NAT-Signature: sha256=<hex>``
40
+ header. Use this to verify the webhook origin on the
41
+ receiving end.
42
+ include_report_context: Whether to include scan-level context (scan_id,
43
+ timestamp, target_url) in each finding payload.
44
+ Defaults to ``true``.
45
+ batch_mode: If ``true``, send all findings in a single POST as a
46
+ JSON array. If ``false`` (default), send one POST per
47
+ finding.
48
+ timeout: HTTP request timeout in seconds (default ``30``).
49
+
50
+ Security considerations
51
+ -----------------------
52
+ * Always use HTTPS webhook URLs so payloads are encrypted in transit.
53
+ * Use ``hmac_secret`` to authenticate the source of the webhook. On the
54
+ receiving end, recompute HMAC-SHA256 of the raw body with the shared secret
55
+ and compare it to the ``X-NAT-Signature`` header value.
56
+ * Store the HMAC secret in an environment variable rather than hard-coding it.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import hashlib
62
+ import hmac
63
+ import json
64
+ import logging
65
+ from datetime import datetime, timezone
66
+
67
+ import httpx
68
+
69
+ from mannf.product.exporters.base import ExportResult, ExportSummary, ExporterPlugin, TestConnectionResult
70
+ from mannf.product.security.models import SecurityFinding, SecurityReport
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ class WebhookExporter(ExporterPlugin):
76
+ """Exports NAT security findings as JSON payloads to any HTTP webhook endpoint.
77
+
78
+ Required config keys:
79
+ webhook_url: The HTTP(S) URL to POST findings to.
80
+
81
+ Optional config keys:
82
+ auth_header: ``Authorization`` header value.
83
+ custom_headers: Dict of additional HTTP headers.
84
+ hmac_secret: Shared secret for HMAC-SHA256 request signing.
85
+ include_report_context: Include scan-level context in each payload
86
+ (default ``true``).
87
+ batch_mode: Send all findings in one POST as a JSON array
88
+ (default ``false``).
89
+ timeout: HTTP timeout in seconds (default ``30``).
90
+
91
+ Example CLI usage::
92
+
93
+ nat security-scan --spec api.yaml --base-url http://localhost \\
94
+ --export webhook \\
95
+ --export-config webhook_url=https://hooks.example.com/nat \\
96
+ --export-config hmac_secret=$WEBHOOK_SECRET
97
+ """
98
+
99
+ name = "webhook"
100
+ display_name = "Generic Webhook"
101
+ description = "Export NAT findings as JSON payloads to any HTTP webhook endpoint."
102
+
103
+ # ------------------------------------------------------------------
104
+ # Config validation
105
+ # ------------------------------------------------------------------
106
+
107
+ def validate_config(self, config: dict) -> list[str]:
108
+ errors: list[str] = []
109
+
110
+ webhook_url = config.get("webhook_url", "")
111
+ if not webhook_url:
112
+ errors.append("'webhook_url' is required")
113
+ elif not (webhook_url.startswith("http://") or webhook_url.startswith("https://")):
114
+ errors.append("'webhook_url' must start with 'http://' or 'https://'")
115
+
116
+ timeout = config.get("timeout")
117
+ if timeout is not None:
118
+ try:
119
+ t = float(timeout)
120
+ if t <= 0:
121
+ errors.append("'timeout' must be a positive number")
122
+ except (TypeError, ValueError):
123
+ errors.append("'timeout' must be a positive number")
124
+
125
+ custom_headers = config.get("custom_headers")
126
+ if custom_headers is not None and not isinstance(custom_headers, dict):
127
+ errors.append("'custom_headers' must be a dict")
128
+
129
+ return errors
130
+
131
+ # ------------------------------------------------------------------
132
+ # Connection test
133
+ # ------------------------------------------------------------------
134
+
135
+ async def test_connection(self, config: dict) -> TestConnectionResult:
136
+ """Test connectivity via HEAD (or OPTIONS) on the webhook_url — confirms URL is reachable."""
137
+ errors = self.validate_config(config)
138
+ if errors:
139
+ return TestConnectionResult(
140
+ success=False,
141
+ message=f"Config validation failed: {'; '.join(errors)}",
142
+ details={"errors": errors},
143
+ )
144
+
145
+ webhook_url = config["webhook_url"]
146
+ timeout = self._get_timeout(config)
147
+
148
+ # Build minimal headers (auth only — no HMAC needed for a probe)
149
+ probe_headers: dict[str, str] = {}
150
+ if config.get("auth_header"):
151
+ probe_headers["Authorization"] = config["auth_header"]
152
+ if isinstance(config.get("custom_headers"), dict):
153
+ probe_headers.update(config["custom_headers"])
154
+
155
+ try:
156
+ async with httpx.AsyncClient(timeout=timeout) as client:
157
+ # Try HEAD first; fall back to OPTIONS if HEAD is not allowed
158
+ resp = await client.head(webhook_url, headers=probe_headers)
159
+ if resp.status_code == 405:
160
+ resp = await client.options(webhook_url, headers=probe_headers)
161
+ # Treat any 2xx or 3xx as reachable; even 4xx means the URL exists
162
+ reachable = resp.status_code < 500
163
+ except httpx.RequestError as exc:
164
+ return TestConnectionResult(
165
+ success=False,
166
+ message=f"Webhook URL unreachable: {exc}",
167
+ details={"webhook_url": webhook_url},
168
+ )
169
+
170
+ if not reachable:
171
+ return TestConnectionResult(
172
+ success=False,
173
+ message=f"Webhook URL returned server error {resp.status_code}",
174
+ details={"webhook_url": webhook_url, "status_code": resp.status_code},
175
+ )
176
+
177
+ return TestConnectionResult(
178
+ success=True,
179
+ message=f"Webhook URL is reachable (HTTP {resp.status_code})",
180
+ details={"webhook_url": webhook_url, "status_code": resp.status_code},
181
+ )
182
+
183
+ # ------------------------------------------------------------------
184
+ # Internal helpers
185
+ # ------------------------------------------------------------------
186
+
187
+ def _build_headers(self, body_bytes: bytes, config: dict) -> dict[str, str]:
188
+ """Build the full HTTP headers dict for a webhook request."""
189
+ headers: dict[str, str] = {"Content-Type": "application/json"}
190
+
191
+ auth_header = config.get("auth_header")
192
+ if auth_header:
193
+ headers["Authorization"] = auth_header
194
+
195
+ custom_headers = config.get("custom_headers")
196
+ if isinstance(custom_headers, dict):
197
+ headers.update(custom_headers)
198
+
199
+ hmac_secret = config.get("hmac_secret")
200
+ if hmac_secret:
201
+ secret_bytes = (
202
+ hmac_secret.encode() if isinstance(hmac_secret, str) else hmac_secret
203
+ )
204
+ digest = hmac.new(secret_bytes, body_bytes, hashlib.sha256).hexdigest()
205
+ headers["X-NAT-Signature"] = f"sha256={digest}"
206
+
207
+ return headers
208
+
209
+ def _build_finding_payload(
210
+ self,
211
+ finding: SecurityFinding,
212
+ report: SecurityReport,
213
+ config: dict,
214
+ ) -> dict:
215
+ """Build the JSON payload dict for a single finding."""
216
+ include_context = str(config.get("include_report_context", "true")).lower() not in (
217
+ "false",
218
+ "0",
219
+ "no",
220
+ )
221
+
222
+ payload: dict = {
223
+ "check_id": finding.check_id,
224
+ "check_name": finding.check_name,
225
+ "severity": finding.severity,
226
+ "endpoint": finding.endpoint,
227
+ "title": finding.title,
228
+ "description": finding.description,
229
+ "evidence": finding.evidence,
230
+ "remediation": finding.remediation,
231
+ "cwe_id": finding.cwe_id,
232
+ "confidence": finding.confidence,
233
+ "exported_at": datetime.now(tz=timezone.utc).isoformat(),
234
+ }
235
+
236
+ if include_context:
237
+ payload["scan_id"] = report.scan_id
238
+ payload["timestamp"] = report.timestamp
239
+ payload["target_url"] = report.target_url
240
+
241
+ return payload
242
+
243
+ def _get_timeout(self, config: dict) -> float:
244
+ """Return the configured timeout, falling back to 30 seconds."""
245
+ timeout = config.get("timeout", 30)
246
+ try:
247
+ return float(timeout)
248
+ except (TypeError, ValueError):
249
+ return 30.0
250
+
251
+ # ------------------------------------------------------------------
252
+ # Single-finding export
253
+ # ------------------------------------------------------------------
254
+
255
+ async def export_finding(
256
+ self,
257
+ finding: SecurityFinding,
258
+ report: SecurityReport,
259
+ config: dict,
260
+ ) -> ExportResult:
261
+ webhook_url = config["webhook_url"]
262
+ timeout = self._get_timeout(config)
263
+
264
+ payload = self._build_finding_payload(finding, report, config)
265
+ body_bytes = json.dumps(payload).encode()
266
+ headers = self._build_headers(body_bytes, config)
267
+
268
+ try:
269
+ async with httpx.AsyncClient(timeout=timeout) as client:
270
+ resp = await client.post(
271
+ webhook_url,
272
+ content=body_bytes,
273
+ headers=headers,
274
+ )
275
+ resp.raise_for_status()
276
+ except httpx.HTTPStatusError as exc:
277
+ error_msg = f"Webhook error {exc.response.status_code}: {exc.response.text}"
278
+ logger.warning("WebhookExporter: %s", error_msg)
279
+ return ExportResult(finding=finding, success=False, error=error_msg)
280
+ except httpx.RequestError as exc:
281
+ error_msg = f"Webhook network error: {exc}"
282
+ logger.warning("WebhookExporter: %s", error_msg)
283
+ return ExportResult(finding=finding, success=False, error=error_msg)
284
+
285
+ return ExportResult(
286
+ finding=finding,
287
+ success=True,
288
+ external_id=str(resp.status_code),
289
+ external_url=webhook_url,
290
+ )
291
+
292
+ # ------------------------------------------------------------------
293
+ # Batch export override
294
+ # ------------------------------------------------------------------
295
+
296
+ async def export_findings(
297
+ self,
298
+ findings: list[SecurityFinding],
299
+ report: SecurityReport,
300
+ config: dict,
301
+ min_severity: str = "low",
302
+ ) -> ExportSummary:
303
+ """Override to support batch_mode: send all findings in one POST."""
304
+ batch_mode = str(config.get("batch_mode", "false")).lower() in ("true", "1", "yes")
305
+
306
+ if not batch_mode:
307
+ return await super().export_findings(findings, report, config, min_severity)
308
+
309
+ # --- batch mode: filter by severity, then POST once as a JSON array ---
310
+ from mannf.product.exporters.base import _SEVERITY_ORDER # noqa: PLC0415
311
+
312
+ min_order = _SEVERITY_ORDER.get(min_severity.lower(), _SEVERITY_ORDER["low"])
313
+
314
+ eligible: list[SecurityFinding] = []
315
+ skipped = 0
316
+ for f in findings:
317
+ f_order = _SEVERITY_ORDER.get(f.severity.lower(), _SEVERITY_ORDER["info"])
318
+ if f_order > min_order:
319
+ skipped += 1
320
+ else:
321
+ eligible.append(f)
322
+
323
+ if not eligible:
324
+ return ExportSummary(
325
+ total=len(findings),
326
+ succeeded=0,
327
+ failed=0,
328
+ skipped=skipped,
329
+ results=[],
330
+ )
331
+
332
+ webhook_url = config["webhook_url"]
333
+ timeout = self._get_timeout(config)
334
+
335
+ payloads = [self._build_finding_payload(f, report, config) for f in eligible]
336
+ body_bytes = json.dumps(payloads).encode()
337
+ headers = self._build_headers(body_bytes, config)
338
+
339
+ results: list[ExportResult] = []
340
+ succeeded = 0
341
+ failed = 0
342
+
343
+ try:
344
+ async with httpx.AsyncClient(timeout=timeout) as client:
345
+ resp = await client.post(
346
+ webhook_url,
347
+ content=body_bytes,
348
+ headers=headers,
349
+ )
350
+ resp.raise_for_status()
351
+
352
+ for f in eligible:
353
+ results.append(
354
+ ExportResult(
355
+ finding=f,
356
+ success=True,
357
+ external_id=str(resp.status_code),
358
+ external_url=webhook_url,
359
+ )
360
+ )
361
+ succeeded = len(eligible)
362
+
363
+ except httpx.HTTPStatusError as exc:
364
+ error_msg = f"Webhook batch error {exc.response.status_code}: {exc.response.text}"
365
+ logger.warning("WebhookExporter: %s", error_msg)
366
+ for f in eligible:
367
+ results.append(ExportResult(finding=f, success=False, error=error_msg))
368
+ failed = len(eligible)
369
+
370
+ except httpx.RequestError as exc:
371
+ error_msg = f"Webhook batch network error: {exc}"
372
+ logger.warning("WebhookExporter: %s", error_msg)
373
+ for f in eligible:
374
+ results.append(ExportResult(finding=f, success=False, error=error_msg))
375
+ failed = len(eligible)
376
+
377
+ return ExportSummary(
378
+ total=len(findings),
379
+ succeeded=succeeded,
380
+ failed=failed,
381
+ skipped=skipped,
382
+ results=results,
383
+ )
@@ -0,0 +1,18 @@
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
+ """NAT output formatters package.
7
+
8
+ Provides pluggable report formatters beyond the built-in text/json/html/junit
9
+ formats. Current formatters:
10
+
11
+ - :mod:`allure_formatter` — Allure JSON result files (one per test case)
12
+ - :mod:`ctrf_formatter` — Common Test Results Format (CTRF) JSON report
13
+ """
14
+
15
+ from mannf.product.formatters.allure_formatter import write_allure_results
16
+ from mannf.product.formatters.ctrf_formatter import build_ctrf_report
17
+
18
+ __all__ = ["write_allure_results", "build_ctrf_report"]
@@ -0,0 +1,161 @@
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
+ """Allure JSON result formatter for NAT scan output.
7
+
8
+ Converts a NAT :class:`~mannf.core.testing.models.TestSuite` and its
9
+ accompanying report dict into Allure's ``*-result.json`` file format (one
10
+ JSON file per test case), written to a caller-specified output directory.
11
+
12
+ Usage::
13
+
14
+ from mannf.product.formatters.allure_formatter import write_allure_results
15
+
16
+ write_allure_results(report, suite, output_dir="/tmp/allure-results")
17
+
18
+ The produced files are compatible with ``allure generate`` and the Allure
19
+ GitHub Actions step (``simple-elf/allure-report-action``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import uuid
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from mannf.core.testing.models import TestSuite
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Internal helpers
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _ALLURE_STATUS_MAP = {
38
+ True: "passed",
39
+ False: "failed",
40
+ }
41
+
42
+
43
+ def _allure_timestamp_ms() -> int:
44
+ """Return the current UTC time as milliseconds since epoch."""
45
+ return int(datetime.now(timezone.utc).timestamp() * 1000)
46
+
47
+
48
+ def _result_to_allure(
49
+ result: Any,
50
+ suite_name: str,
51
+ base_url: str,
52
+ start_ms: int,
53
+ ) -> dict[str, Any]:
54
+ """Convert a single :class:`~mannf.core.testing.models.TestResult` to an
55
+ Allure result dict.
56
+
57
+ Parameters
58
+ ----------
59
+ result:
60
+ The :class:`TestResult` object from a :class:`TestSuite`.
61
+ suite_name:
62
+ Human-readable name of the containing test suite.
63
+ base_url:
64
+ Base URL of the API under test — used to build a full URL label.
65
+ start_ms:
66
+ Suite start timestamp in milliseconds since epoch (used when the
67
+ result does not carry its own timestamp).
68
+ """
69
+ tc_id = getattr(result, "test_case_id", None) or "unknown"
70
+ passed = getattr(result, "passed", True)
71
+ error = getattr(result, "error", None)
72
+ exec_ms = getattr(result, "execution_time_ms", None) or 0.0
73
+
74
+ stop_ms = start_ms + int(exec_ms)
75
+
76
+ status: str
77
+ if getattr(result, "error", None) and not passed:
78
+ status = "broken"
79
+ elif not passed:
80
+ status = "failed"
81
+ else:
82
+ status = "passed"
83
+
84
+ doc: dict[str, Any] = {
85
+ "uuid": str(uuid.uuid4()),
86
+ "historyId": tc_id,
87
+ "testCaseId": tc_id,
88
+ "fullName": f"{suite_name}.{tc_id}",
89
+ "name": tc_id,
90
+ "status": status,
91
+ "start": start_ms,
92
+ "stop": stop_ms,
93
+ "labels": [
94
+ {"name": "suite", "value": suite_name},
95
+ {"name": "framework", "value": "NAT"},
96
+ {"name": "language", "value": "Python"},
97
+ ],
98
+ "links": [],
99
+ "parameters": [],
100
+ "attachments": [],
101
+ "statusDetails": {},
102
+ }
103
+
104
+ if base_url:
105
+ doc["labels"].append({"name": "host", "value": base_url})
106
+
107
+ if error:
108
+ doc["statusDetails"] = {
109
+ "message": str(error),
110
+ "trace": str(error),
111
+ "known": False,
112
+ "muted": False,
113
+ "flaky": False,
114
+ }
115
+
116
+ return doc
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Public API
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def write_allure_results(
125
+ report: dict[str, Any],
126
+ suite: "TestSuite",
127
+ output_dir: str,
128
+ ) -> list[str]:
129
+ """Write Allure ``*-result.json`` files for every test case in *suite*.
130
+
131
+ Parameters
132
+ ----------
133
+ report:
134
+ The NAT scan report dict (as produced by ``_run_scan``).
135
+ suite:
136
+ The :class:`~mannf.core.testing.models.TestSuite` that ran the tests.
137
+ output_dir:
138
+ Directory to write result files into. Will be created if it does not
139
+ exist.
140
+
141
+ Returns
142
+ -------
143
+ list[str]
144
+ Absolute paths to the written result files.
145
+ """
146
+ out = Path(output_dir)
147
+ out.mkdir(parents=True, exist_ok=True)
148
+
149
+ suite_name = getattr(suite, "name", report.get("spec", "NAT Scan"))
150
+ base_url = report.get("base_url", report.get("endpoint", ""))
151
+ start_ms = _allure_timestamp_ms()
152
+
153
+ written: list[str] = []
154
+ for result in getattr(suite, "results", []):
155
+ allure_doc = _result_to_allure(result, suite_name, base_url, start_ms)
156
+ filename = f"{allure_doc['uuid']}-result.json"
157
+ dest = out / filename
158
+ dest.write_text(json.dumps(allure_doc, indent=2, ensure_ascii=False), encoding="utf-8")
159
+ written.append(str(dest))
160
+
161
+ return written