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,411 @@
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
+
5
+ """Product notification dispatcher for scan lifecycle events.
6
+
7
+ Delivers formatted notifications to Slack (Block Kit) and Microsoft Teams
8
+ (Adaptive Cards) via incoming webhooks. Both channels use a plain HTTP POST
9
+ with a JSON payload — no SDK required.
10
+
11
+ Webhook URLs are resolved in this order:
12
+ 1. Per-tenant ``notification_slack_webhook_url`` / ``notification_teams_webhook_url``
13
+ stored in the DB (populated via ``PUT /api/v1/notifications/config``).
14
+ 2. Global env var fallback: ``NAT_SLACK_WEBHOOK_URL`` / ``NAT_TEAMS_WEBHOOK_URL``.
15
+
16
+ Supported event types (scan lifecycle):
17
+ - ``scan.started``
18
+ - ``scan.completed``
19
+ - ``scan.failed``
20
+ - ``security_scan.completed``
21
+ - ``security_scan.failed``
22
+ - ``autonomous_run.regression``
23
+ - ``autonomous_run.recovery``
24
+ - ``autonomous_run.flake_spike``
25
+ - ``plan.generated``
26
+ - ``plan.approved``
27
+ - ``finding.commented``
28
+ - ``schedule.fired``
29
+ - ``e2e.regression``
30
+
31
+ All delivery errors are caught and logged — notifications must never block or
32
+ break the scan flow.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import hashlib
39
+ import hmac
40
+ import json
41
+ import logging
42
+ import os
43
+ from typing import Any, Optional
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Event metadata helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _EVENT_EMOJI: dict[str, str] = {
52
+ "scan.started": "🚀",
53
+ "scan.completed": "✅",
54
+ "scan.failed": "❌",
55
+ "security_scan.completed": "🔒",
56
+ "security_scan.failed": "🚨",
57
+ "autonomous_run.regression": "🔴",
58
+ "autonomous_run.recovery": "🟢",
59
+ "autonomous_run.flake_spike": "⚡",
60
+ "plan.generated": "📋",
61
+ "plan.approved": "🏷️",
62
+ "finding.commented": "💬",
63
+ "schedule.fired": "🗓️",
64
+ "e2e.regression": "🔴",
65
+ }
66
+
67
+ _EVENT_TITLE: dict[str, str] = {
68
+ "scan.started": "Scan Started",
69
+ "scan.completed": "Scan Completed",
70
+ "scan.failed": "Scan Failed",
71
+ "security_scan.completed": "Security Scan Completed",
72
+ "security_scan.failed": "Security Scan Failed",
73
+ "autonomous_run.regression": "Autonomous Run — New Regression",
74
+ "autonomous_run.recovery": "Autonomous Run — Recovery Detected",
75
+ "autonomous_run.flake_spike": "Autonomous Run — Flakiness Spike",
76
+ "plan.generated": "Test Plan Generated",
77
+ "plan.approved": "Test Plan Approved",
78
+ "finding.commented": "Finding Commented",
79
+ "schedule.fired": "Schedule Fired",
80
+ "e2e.regression": "E2E Regression Detected",
81
+ }
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Slack notifier
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class SlackNotifier:
90
+ """Deliver a formatted Slack message via an incoming webhook URL.
91
+
92
+ Uses Slack Block Kit for rich message formatting.
93
+ """
94
+
95
+ def __init__(self, webhook_url: str) -> None:
96
+ self.webhook_url = webhook_url
97
+
98
+ def _build_payload(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
99
+ """Build a Slack Block Kit message payload."""
100
+ emoji = _EVENT_EMOJI.get(event_type, "🔔")
101
+ title = f"{emoji} NAT — {_EVENT_TITLE.get(event_type, event_type)}"
102
+
103
+ fields = [
104
+ {
105
+ "type": "mrkdwn",
106
+ "text": f"*Event:*\n`{event_type}`",
107
+ },
108
+ ]
109
+ if payload.get("scan_id"):
110
+ fields.append(
111
+ {"type": "mrkdwn", "text": f"*Scan ID:*\n`{payload['scan_id']}`"}
112
+ )
113
+ if payload.get("tenant_id"):
114
+ fields.append(
115
+ {"type": "mrkdwn", "text": f"*Tenant:*\n`{payload['tenant_id']}`"}
116
+ )
117
+
118
+ blocks: list[dict[str, Any]] = [
119
+ {
120
+ "type": "header",
121
+ "text": {"type": "plain_text", "text": title, "emoji": True},
122
+ },
123
+ {"type": "section", "fields": fields},
124
+ ]
125
+
126
+ details = payload.get("details") or {}
127
+ if details:
128
+ detail_lines = [f"*{k}:* {v}" for k, v in details.items()]
129
+ blocks.append(
130
+ {
131
+ "type": "section",
132
+ "text": {"type": "mrkdwn", "text": "\n".join(detail_lines)},
133
+ }
134
+ )
135
+
136
+ return {"blocks": blocks}
137
+
138
+ async def send(self, event_type: str, payload: dict[str, Any]) -> None:
139
+ """POST the formatted payload to the Slack webhook URL."""
140
+ slack_payload = self._build_payload(event_type, payload)
141
+ await _http_post(self.webhook_url, slack_payload)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Microsoft Teams notifier
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class TeamsNotifier:
150
+ """Deliver a formatted Teams message via an incoming webhook connector.
151
+
152
+ Uses Adaptive Cards for rich card formatting.
153
+ """
154
+
155
+ def __init__(self, webhook_url: str) -> None:
156
+ self.webhook_url = webhook_url
157
+
158
+ def _build_payload(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
159
+ """Build a Teams Adaptive Card message payload."""
160
+ emoji = _EVENT_EMOJI.get(event_type, "🔔")
161
+ title = f"{emoji} NAT — {_EVENT_TITLE.get(event_type, event_type)}"
162
+
163
+ body: list[dict[str, Any]] = [
164
+ {
165
+ "type": "TextBlock",
166
+ "text": title,
167
+ "weight": "Bolder",
168
+ "size": "Medium",
169
+ },
170
+ ]
171
+
172
+ facts: list[dict[str, str]] = [{"title": "Event", "value": event_type}]
173
+ if payload.get("scan_id"):
174
+ facts.append({"title": "Scan ID", "value": str(payload["scan_id"])})
175
+ if payload.get("tenant_id"):
176
+ facts.append({"title": "Tenant", "value": str(payload["tenant_id"])})
177
+
178
+ details = payload.get("details") or {}
179
+ for k, v in details.items():
180
+ facts.append({"title": str(k).replace("_", " ").title(), "value": str(v)})
181
+
182
+ body.append({"type": "FactSet", "facts": facts})
183
+
184
+ adaptive_card = {
185
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
186
+ "type": "AdaptiveCard",
187
+ "version": "1.4",
188
+ "body": body,
189
+ }
190
+
191
+ return {
192
+ "type": "message",
193
+ "attachments": [
194
+ {
195
+ "contentType": "application/vnd.microsoft.card.adaptive",
196
+ "contentUrl": None,
197
+ "content": adaptive_card,
198
+ }
199
+ ],
200
+ }
201
+
202
+ async def send(self, event_type: str, payload: dict[str, Any]) -> None:
203
+ """POST the formatted payload to the Teams webhook URL."""
204
+ teams_payload = self._build_payload(event_type, payload)
205
+ await _http_post(self.webhook_url, teams_payload)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Public dispatcher
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ async def dispatch_scan_notification(
214
+ event_type: str,
215
+ *,
216
+ tenant_id: Optional[str] = None,
217
+ scan_id: Optional[str] = None,
218
+ details: Optional[dict[str, Any]] = None,
219
+ ) -> None:
220
+ """Dispatch a scan lifecycle notification to all configured channels.
221
+
222
+ Resolves Slack and Teams webhook URLs from:
223
+ 1. Per-tenant DB fields (``notification_slack_webhook_url`` /
224
+ ``notification_teams_webhook_url`` on the ``tenants`` table).
225
+ 2. Global env var fallback (``NAT_SLACK_WEBHOOK_URL`` /
226
+ ``NAT_TEAMS_WEBHOOK_URL``).
227
+
228
+ All failures are swallowed and logged — notifications must never break
229
+ the scan flow.
230
+
231
+ Args:
232
+ event_type: Scan event identifier, e.g. ``"scan.completed"``.
233
+ tenant_id: UUID string of the tenant that owns the scan.
234
+ scan_id: UUID string of the affected scan.
235
+ details: Additional event context (scan summary counts, error info).
236
+ """
237
+ payload: dict[str, Any] = {
238
+ "event_type": event_type,
239
+ "tenant_id": tenant_id,
240
+ "scan_id": scan_id,
241
+ "details": details or {},
242
+ }
243
+
244
+ slack_url = await _resolve_webhook_url(tenant_id, "slack")
245
+ teams_url = await _resolve_webhook_url(tenant_id, "teams")
246
+
247
+ tasks: list[Any] = []
248
+ if slack_url:
249
+ tasks.append(SlackNotifier(slack_url).send(event_type, payload))
250
+ if teams_url:
251
+ tasks.append(TeamsNotifier(teams_url).send(event_type, payload))
252
+
253
+ if not tasks:
254
+ logger.debug(
255
+ "No notification channels configured for scan event: %s (tenant=%s)",
256
+ event_type,
257
+ tenant_id,
258
+ )
259
+ return
260
+
261
+ results = await asyncio.gather(*tasks, return_exceptions=True)
262
+ for i, result in enumerate(results):
263
+ if isinstance(result, Exception):
264
+ logger.warning(
265
+ "Scan notification delivery failed (task %d) for event %s: %s",
266
+ i,
267
+ event_type,
268
+ result,
269
+ )
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # URL resolution
274
+ # ---------------------------------------------------------------------------
275
+
276
+
277
+ async def _resolve_webhook_url(
278
+ tenant_id: Optional[str], channel: str
279
+ ) -> str:
280
+ """Resolve the webhook URL for a given channel.
281
+
282
+ Tries the per-tenant DB field first, then falls back to the global env var.
283
+
284
+ Args:
285
+ tenant_id: Tenant UUID string (or None for anonymous scans).
286
+ channel: ``"slack"`` or ``"teams"``.
287
+
288
+ Returns:
289
+ Webhook URL string, or empty string if not configured.
290
+ """
291
+ env_var = "NAT_SLACK_WEBHOOK_URL" if channel == "slack" else "NAT_TEAMS_WEBHOOK_URL"
292
+ db_field = (
293
+ "notification_slack_webhook_url"
294
+ if channel == "slack"
295
+ else "notification_teams_webhook_url"
296
+ )
297
+
298
+ if tenant_id:
299
+ try:
300
+ db_url = await _get_tenant_webhook_url(tenant_id, db_field)
301
+ if db_url:
302
+ return db_url
303
+ except Exception as exc: # noqa: BLE001
304
+ logger.debug(
305
+ "_resolve_webhook_url: DB lookup failed for tenant %s (%s): %s",
306
+ tenant_id,
307
+ channel,
308
+ exc,
309
+ )
310
+
311
+ return os.environ.get(env_var, "")
312
+
313
+
314
+ async def _get_tenant_webhook_url(tenant_id: str, field: str) -> str:
315
+ """Fetch a webhook URL field from the tenant record in the DB.
316
+
317
+ Returns empty string if DB is unavailable or field is null.
318
+ """
319
+ try:
320
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
321
+
322
+ if _build_engine() is None or _async_session_factory is None:
323
+ return ""
324
+
325
+ import uuid # noqa: PLC0415
326
+
327
+ from mannf.product.models import Tenant # noqa: PLC0415
328
+
329
+ tid = uuid.UUID(tenant_id)
330
+ async with _async_session_factory() as session:
331
+ tenant = await session.get(Tenant, tid)
332
+ if tenant is None:
333
+ return ""
334
+ return getattr(tenant, field, None) or ""
335
+ except Exception: # noqa: BLE001
336
+ return ""
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # HTTP delivery backend
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ async def _http_post(
345
+ url: str,
346
+ payload: dict[str, Any],
347
+ *,
348
+ timeout: float = 10.0,
349
+ signing_secret: Optional[str] = None,
350
+ ) -> None:
351
+ """POST a JSON payload to the given URL.
352
+
353
+ Optionally adds an HMAC signature header (``X-NAT-Signature``) when
354
+ ``NAT_NOTIFICATION_WEBHOOK_SECRET`` is set or ``signing_secret`` is
355
+ provided.
356
+
357
+ Args:
358
+ url: Target webhook endpoint.
359
+ payload: JSON-serialisable message payload.
360
+ timeout: Request timeout in seconds.
361
+ signing_secret: Override for the signing secret (defaults to
362
+ ``NAT_NOTIFICATION_WEBHOOK_SECRET`` env var).
363
+
364
+ Raises:
365
+ RuntimeError: on network error or unexpected exception.
366
+ """
367
+ try:
368
+ import aiohttp # noqa: PLC0415
369
+ except ImportError:
370
+ logger.warning(
371
+ "_http_post: aiohttp not installed — notification delivery skipped"
372
+ )
373
+ return
374
+
375
+ try:
376
+ body = json.dumps(payload, default=str)
377
+ headers: dict[str, str] = {
378
+ "Content-Type": "application/json",
379
+ "User-Agent": "NAT-Notifications/1.0",
380
+ }
381
+
382
+ secret = signing_secret or os.environ.get("NAT_NOTIFICATION_WEBHOOK_SECRET", "")
383
+ if secret:
384
+ sig = hmac.new(
385
+ secret.encode(), body.encode(), hashlib.sha256
386
+ ).hexdigest()
387
+ headers["X-NAT-Signature"] = f"sha256={sig}"
388
+
389
+ async with aiohttp.ClientSession() as session:
390
+ async with session.post(
391
+ url,
392
+ data=body,
393
+ headers=headers,
394
+ timeout=aiohttp.ClientTimeout(total=timeout),
395
+ ) as resp:
396
+ if resp.status >= 400:
397
+ logger.warning(
398
+ "_http_post: server returned %d for event posted to %s",
399
+ resp.status,
400
+ url,
401
+ )
402
+ else:
403
+ logger.debug(
404
+ "_http_post: delivered notification to %s (status=%d)",
405
+ url,
406
+ resp.status,
407
+ )
408
+ except Exception as exc: # noqa: BLE001
409
+ raise RuntimeError(
410
+ f"Notification HTTP POST to {url!r} failed: {exc}"
411
+ ) from exc
@@ -0,0 +1,190 @@
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
+ """Onboarding progress tracker for new NAT tenants.
7
+
8
+ Tracks each tenant through their first-time setup journey:
9
+
10
+ ACCOUNT_CREATED → API_KEY_PROVISIONED → FIRST_SCAN_STARTED
11
+ → FIRST_SCAN_COMPLETED → RESULTS_VIEWED → BILLING_CONFIGURED
12
+
13
+ Progress is stored in-memory (per-process) and keyed by ``tenant_id``.
14
+ When a database is available, progress is also persisted as billing audit
15
+ events so it survives restarts.
16
+
17
+ Usage::
18
+
19
+ from mannf.product.onboarding import OnboardingStepName, mark_step_complete, get_onboarding_status
20
+
21
+ await mark_step_complete(tenant_id, OnboardingStepName.FIRST_SCAN_STARTED)
22
+ status = get_onboarding_status(tenant_id)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from datetime import datetime, timezone
29
+ from enum import Enum
30
+ from typing import Dict, List, Optional
31
+
32
+ from pydantic import BaseModel, Field
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Step definitions
38
+ # ---------------------------------------------------------------------------
39
+
40
+ # Ordered list of all steps — the order here determines progress_percent.
41
+ _ORDERED_STEPS = [
42
+ "ACCOUNT_CREATED",
43
+ "API_KEY_PROVISIONED",
44
+ "FIRST_SCAN_STARTED",
45
+ "FIRST_SCAN_COMPLETED",
46
+ "RESULTS_VIEWED",
47
+ "BILLING_CONFIGURED",
48
+ ]
49
+
50
+
51
+ class OnboardingStepName(str, Enum):
52
+ """Canonical names for each onboarding step."""
53
+
54
+ ACCOUNT_CREATED = "ACCOUNT_CREATED"
55
+ API_KEY_PROVISIONED = "API_KEY_PROVISIONED"
56
+ FIRST_SCAN_STARTED = "FIRST_SCAN_STARTED"
57
+ FIRST_SCAN_COMPLETED = "FIRST_SCAN_COMPLETED"
58
+ RESULTS_VIEWED = "RESULTS_VIEWED"
59
+ BILLING_CONFIGURED = "BILLING_CONFIGURED"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Pydantic response models
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class OnboardingStepDetail(BaseModel):
68
+ """Completion state for a single onboarding step."""
69
+
70
+ step: str
71
+ completed: bool = False
72
+ completed_at: Optional[str] = None
73
+
74
+
75
+ class OnboardingStatusResponse(BaseModel):
76
+ """Full onboarding progress for a tenant."""
77
+
78
+ tenant_id: str
79
+ completed_steps: List[str] = Field(default_factory=list)
80
+ next_step: Optional[str] = None
81
+ progress_percent: float = 0.0
82
+ all_steps: List[OnboardingStepDetail] = Field(default_factory=list)
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # In-memory progress store
87
+ # ---------------------------------------------------------------------------
88
+
89
+ # Maps tenant_id (str) → {step_name: completed_at ISO string}
90
+ _progress: Dict[str, Dict[str, str]] = {}
91
+
92
+
93
+ def _tenant_progress(tenant_id: str) -> Dict[str, str]:
94
+ """Return (and lazily initialise) the progress dict for *tenant_id*."""
95
+ return _progress.setdefault(tenant_id, {})
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Public API
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ async def mark_step_complete(tenant_id: str, step: OnboardingStepName) -> None:
104
+ """Record *step* as completed for *tenant_id* (idempotent).
105
+
106
+ If the step is already marked complete, this is a no-op. Progress is
107
+ stored in-memory and, when a database is available, also logged as a
108
+ billing audit event for durability.
109
+ """
110
+ progress = _tenant_progress(tenant_id)
111
+ if step.value in progress:
112
+ return # already done — idempotent
113
+
114
+ completed_at = datetime.now(timezone.utc).isoformat()
115
+ progress[step.value] = completed_at
116
+ logger.debug("Onboarding step %s completed for tenant %s", step.value, tenant_id)
117
+
118
+ # Fire-and-forget: persist to audit log when DB is available
119
+ try:
120
+ import asyncio # noqa: PLC0415
121
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
122
+
123
+ asyncio.create_task(
124
+ log_billing_event(
125
+ "onboarding.step_completed",
126
+ tenant_id=tenant_id,
127
+ source="system",
128
+ details={"step": step.value, "completed_at": completed_at},
129
+ )
130
+ )
131
+ except Exception: # noqa: BLE001
132
+ pass # DB not configured — in-memory only is fine
133
+
134
+
135
+ def is_step_complete(tenant_id: str, step: OnboardingStepName) -> bool:
136
+ """Return ``True`` if *step* has been completed for *tenant_id*."""
137
+ return step.value in _tenant_progress(tenant_id)
138
+
139
+
140
+ def get_onboarding_status(tenant_id: str) -> OnboardingStatusResponse:
141
+ """Build and return the full onboarding status for *tenant_id*."""
142
+ progress = _tenant_progress(tenant_id)
143
+
144
+ all_steps: List[OnboardingStepDetail] = []
145
+ completed_steps: List[str] = []
146
+ next_step: Optional[str] = None
147
+
148
+ for name in _ORDERED_STEPS:
149
+ completed_at = progress.get(name)
150
+ completed = completed_at is not None
151
+ all_steps.append(
152
+ OnboardingStepDetail(
153
+ step=name,
154
+ completed=completed,
155
+ completed_at=completed_at,
156
+ )
157
+ )
158
+ if completed:
159
+ completed_steps.append(name)
160
+ elif next_step is None:
161
+ next_step = name
162
+
163
+ total = len(_ORDERED_STEPS)
164
+ done = len(completed_steps)
165
+ progress_percent = round((done / total) * 100, 1) if total > 0 else 0.0
166
+
167
+ return OnboardingStatusResponse(
168
+ tenant_id=tenant_id,
169
+ completed_steps=completed_steps,
170
+ next_step=next_step,
171
+ progress_percent=progress_percent,
172
+ all_steps=all_steps,
173
+ )
174
+
175
+
176
+ def reset_progress(tenant_id: str) -> None:
177
+ """Clear all onboarding progress for *tenant_id* (used in tests)."""
178
+ _progress.pop(tenant_id, None)
179
+
180
+
181
+ def _mark_step_complete_sync(tenant_id: str, step: OnboardingStepName) -> None:
182
+ """Synchronously mark *step* as completed (in-memory only, no async).
183
+
184
+ This is intended for use in synchronous test code where calling
185
+ ``asyncio.run()`` would interfere with the event loop. It updates the
186
+ in-memory progress dict without attempting to persist to the audit log.
187
+ """
188
+ progress = _tenant_progress(tenant_id)
189
+ if step.value not in progress:
190
+ progress[step.value] = datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,39 @@
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
+ """Orchestration package — closed-loop Ingest → Prioritize → Scan → Export → Learn pipeline.
7
+
8
+ Public API::
9
+
10
+ from mannf.product.orchestration import IngestScanOrchestrator, Pipeline
11
+ from mannf.product.orchestrator_models import OrchestrationConfig, OrchestrationResult
12
+
13
+ # Fluent builder
14
+ result = await (
15
+ Pipeline()
16
+ .with_source("api.yaml", format="openapi")
17
+ .with_base_url("http://localhost:8080")
18
+ .with_exporter("github-issues", repo="org/repo")
19
+ .run()
20
+ )
21
+
22
+ # Direct orchestrator
23
+ config = OrchestrationConfig(
24
+ source_path="api.yaml",
25
+ scan_config={"base_url": "http://localhost:8080"},
26
+ )
27
+ orch = IngestScanOrchestrator(config)
28
+ result = await orch.orchestrate()
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from mannf.product.orchestration.ingest_scan_orchestrator import IngestScanOrchestrator
34
+ from mannf.product.orchestration.pipeline import Pipeline
35
+
36
+ __all__ = [
37
+ "IngestScanOrchestrator",
38
+ "Pipeline",
39
+ ]