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,473 @@
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
+ """Background scheduler engine for recurring scans.
6
+
7
+ The engine runs a configurable tick loop. On each tick it:
8
+
9
+ 1. Queries all *enabled* schedules where ``next_run_at <= now``.
10
+ 2. For each due schedule, spawns a background coroutine that executes
11
+ the appropriate scan type (functional or security) via the existing
12
+ NAT infrastructure.
13
+ 3. Updates ``last_run_at``, ``last_run_status``, ``last_run_scan_id``,
14
+ and ``run_count`` on the schedule.
15
+ 4. Recalculates and sets ``next_run_at`` via
16
+ :func:`~mannf.product.scheduling.cron_utils.next_run_after`.
17
+
18
+ The engine checks the tenant's monthly quota before dispatching each
19
+ scan. If the tenant has exceeded their quota the scan is skipped and
20
+ ``last_run_status`` is set to ``"skipped_quota"``.
21
+
22
+ Usage::
23
+
24
+ from mannf.product.scheduling.engine import SchedulerEngine
25
+ from mannf.product.scheduling.store import ScheduleStore
26
+
27
+ store = ScheduleStore()
28
+ engine = SchedulerEngine(store=store, tick_interval=60)
29
+ await engine.start() # returns immediately; engine runs in background
30
+ # ... later ...
31
+ await engine.stop()
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import logging
38
+ import uuid
39
+ from datetime import datetime, timezone
40
+ from typing import Any
41
+
42
+ from mannf.product.scheduling.cron_utils import next_run_after
43
+ from mannf.product.scheduling.models import ScanSchedule
44
+ from mannf.product.scheduling.store import ScheduleStore
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Module-level imports for external dependencies — imported here so that
49
+ # tests can patch them at this module's namespace.
50
+ try:
51
+ from mannf.product.notifications import dispatch_scan_notification
52
+ except Exception: # noqa: BLE001
53
+ dispatch_scan_notification = None # type: ignore[assignment]
54
+
55
+ try:
56
+ from mannf.product.database import _build_engine
57
+ except Exception: # noqa: BLE001
58
+ _build_engine = None # type: ignore[assignment]
59
+
60
+ try:
61
+ from mannf.product.billing.tenant_auth import get_monthly_usage
62
+ except Exception: # noqa: BLE001
63
+ get_monthly_usage = None # type: ignore[assignment]
64
+
65
+
66
+ class SchedulerEngine:
67
+ """Async background engine that fires scheduled scans on a tick loop.
68
+
69
+ Args:
70
+ store: The :class:`~mannf.product.scheduling.store.ScheduleStore`
71
+ instance shared with the API layer.
72
+ tick_interval: Seconds between scheduler ticks (default: 60).
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ store: ScheduleStore,
78
+ tick_interval: int = 60,
79
+ ) -> None:
80
+ self._store = store
81
+ self._tick_interval = tick_interval
82
+ self._task: asyncio.Task[None] | None = None
83
+ self._running = False
84
+
85
+ # ------------------------------------------------------------------
86
+ # Lifecycle
87
+ # ------------------------------------------------------------------
88
+
89
+ async def start(self) -> None:
90
+ """Start the background tick loop.
91
+
92
+ This method returns immediately; the loop runs as an
93
+ :class:`asyncio.Task`. Call :meth:`stop` to cancel it cleanly.
94
+ """
95
+ if self._running:
96
+ logger.warning("SchedulerEngine is already running; ignoring start()")
97
+ return
98
+ self._running = True
99
+ self._task = asyncio.create_task(self._tick_loop(), name="nat-scheduler")
100
+ logger.info(
101
+ "SchedulerEngine started (tick_interval=%ds)", self._tick_interval
102
+ )
103
+
104
+ async def stop(self) -> None:
105
+ """Cancel the background tick loop and wait for it to finish."""
106
+ self._running = False
107
+ if self._task is not None and not self._task.done():
108
+ self._task.cancel()
109
+ try:
110
+ await self._task
111
+ except asyncio.CancelledError:
112
+ pass
113
+ self._task = None
114
+ logger.info("SchedulerEngine stopped")
115
+
116
+ @property
117
+ def is_running(self) -> bool:
118
+ """``True`` when the engine's background task is active."""
119
+ return self._running and self._task is not None and not self._task.done()
120
+
121
+ # ------------------------------------------------------------------
122
+ # Internal tick loop
123
+ # ------------------------------------------------------------------
124
+
125
+ async def _tick_loop(self) -> None:
126
+ """Main loop: sleep, then process due schedules."""
127
+ while self._running:
128
+ try:
129
+ await self._process_due_schedules()
130
+ except Exception: # noqa: BLE001
131
+ logger.exception("Unexpected error in SchedulerEngine tick loop")
132
+ try:
133
+ await asyncio.sleep(self._tick_interval)
134
+ except asyncio.CancelledError:
135
+ break
136
+
137
+ async def _process_due_schedules(self) -> None:
138
+ """Identify and execute all schedules that are due to run."""
139
+ now = datetime.now(timezone.utc)
140
+ for schedule in self._store.iter_enabled():
141
+ if not schedule.next_run_at:
142
+ # First run: calculate and persist next_run_at, then check.
143
+ try:
144
+ nxt = next_run_after(
145
+ schedule.cron_expression, now, tz=schedule.timezone
146
+ )
147
+ schedule.next_run_at = nxt.isoformat()
148
+ self._store.update(schedule)
149
+ except Exception: # noqa: BLE001
150
+ logger.exception(
151
+ "Failed to calculate next_run_at for schedule %s",
152
+ schedule.schedule_id,
153
+ )
154
+ continue
155
+
156
+ try:
157
+ next_dt = datetime.fromisoformat(schedule.next_run_at)
158
+ except ValueError:
159
+ logger.warning(
160
+ "Schedule %s has invalid next_run_at %r; skipping",
161
+ schedule.schedule_id,
162
+ schedule.next_run_at,
163
+ )
164
+ continue
165
+
166
+ # Ensure next_dt is timezone-aware for comparison.
167
+ if next_dt.tzinfo is None:
168
+ next_dt = next_dt.replace(tzinfo=timezone.utc)
169
+
170
+ if next_dt <= now:
171
+ # When a distributed queue is configured, enqueue the schedule
172
+ # instead of spawning a local asyncio task.
173
+ from mannf.product.scheduling.queue import get_default_queue # noqa: PLC0415
174
+ default_queue = get_default_queue()
175
+ if default_queue is not None:
176
+ import json as _json # noqa: PLC0415
177
+ try:
178
+ await default_queue.enqueue(
179
+ "scheduled_scan",
180
+ {
181
+ "schedule_id": schedule.schedule_id,
182
+ "tenant_id": schedule.tenant_id,
183
+ "schedule_type": schedule.schedule_type,
184
+ "schedule_config": _json.dumps(schedule.scan_config or {}),
185
+ },
186
+ dedup_key=f"schedule:{schedule.schedule_id}",
187
+ )
188
+ logger.info(
189
+ "Enqueued scheduled scan %s to worker pool",
190
+ schedule.schedule_id,
191
+ )
192
+ except Exception: # noqa: BLE001
193
+ logger.exception(
194
+ "Failed to enqueue scheduled scan %s; falling back to local task",
195
+ schedule.schedule_id,
196
+ )
197
+ asyncio.create_task(
198
+ self._run_schedule(schedule),
199
+ name=f"nat-scheduled-scan-{schedule.schedule_id}",
200
+ )
201
+ else:
202
+ asyncio.create_task(
203
+ self._run_schedule(schedule),
204
+ name=f"nat-scheduled-scan-{schedule.schedule_id}",
205
+ )
206
+
207
+ # ------------------------------------------------------------------
208
+ # Scan execution
209
+ # ------------------------------------------------------------------
210
+
211
+ async def _check_quota(self, tenant_id: str, scan_type: str) -> bool:
212
+ """Return ``True`` if the tenant is within their monthly quota.
213
+
214
+ Gracefully returns ``True`` (allow) when the database is not
215
+ available (legacy / dev mode).
216
+ """
217
+ try:
218
+ if _build_engine is None or _build_engine() is None:
219
+ return True # No DB — no quota enforcement
220
+
221
+ # We need the tenant's quota limit. Load it from the DB.
222
+ from mannf.product.models import Tenant # noqa: PLC0415
223
+ from mannf.product.database import _async_session_factory # noqa: PLC0415
224
+
225
+ if _async_session_factory is None:
226
+ return True
227
+
228
+ import sqlalchemy as sa # noqa: PLC0415
229
+ async with _async_session_factory() as session:
230
+ result = await session.execute(
231
+ sa.select(Tenant).where(Tenant.id == uuid.UUID(tenant_id))
232
+ )
233
+ tenant_obj = result.scalar_one_or_none()
234
+ if tenant_obj is None:
235
+ return True # Unknown tenant — allow (don't block)
236
+
237
+ quota_attr = (
238
+ "monthly_scan_quota"
239
+ if scan_type == "functional"
240
+ else "monthly_security_scan_quota"
241
+ )
242
+ tenant_quota = getattr(tenant_obj, quota_attr, 999_999)
243
+
244
+ if get_monthly_usage is None:
245
+ return True
246
+ used = await get_monthly_usage(uuid.UUID(tenant_id), scan_type)
247
+ return used < tenant_quota
248
+ except Exception: # noqa: BLE001
249
+ logger.warning(
250
+ "Quota check failed for tenant %s (%s); allowing scan",
251
+ tenant_id,
252
+ scan_type,
253
+ )
254
+ return True # Fail open
255
+
256
+ async def _run_schedule(self, schedule: ScanSchedule) -> None:
257
+ """Execute a single scheduled scan and update the schedule record."""
258
+ logger.info(
259
+ "Running scheduled %s scan '%s' (id=%s, tenant=%s)",
260
+ schedule.schedule_type,
261
+ schedule.name,
262
+ schedule.schedule_id,
263
+ schedule.tenant_id,
264
+ )
265
+
266
+ # Quota guard
267
+ quota_ok = await self._check_quota(schedule.tenant_id, schedule.schedule_type)
268
+ if not quota_ok:
269
+ logger.warning(
270
+ "Skipping scheduled scan %s: tenant %s has exceeded quota",
271
+ schedule.schedule_id,
272
+ schedule.tenant_id,
273
+ )
274
+ schedule.last_run_status = "skipped_quota"
275
+ schedule.last_run_at = datetime.now(timezone.utc).isoformat()
276
+ self._advance_schedule(schedule)
277
+ self._store.update(schedule)
278
+ return
279
+
280
+ # Mark as running
281
+ schedule.last_run_at = datetime.now(timezone.utc).isoformat()
282
+ schedule.last_run_status = "running"
283
+ self._store.update(schedule)
284
+
285
+ scan_id: str | None = None
286
+ status = "failed"
287
+ try:
288
+ if schedule.schedule_type == "security":
289
+ scan_id, status = await self._execute_security_scan(schedule)
290
+ elif schedule.schedule_type == "autonomous":
291
+ scan_id, status = await self._execute_autonomous_sweep(schedule)
292
+ else:
293
+ scan_id, status = await self._execute_functional_scan(schedule)
294
+ except Exception: # noqa: BLE001
295
+ logger.exception(
296
+ "Scheduled scan %s ('%s') raised an unhandled exception",
297
+ schedule.schedule_id,
298
+ schedule.name,
299
+ )
300
+ status = "failed"
301
+
302
+ # Update result metadata
303
+ schedule.last_run_status = status
304
+ schedule.last_run_scan_id = scan_id
305
+ schedule.run_count += 1
306
+ self._advance_schedule(schedule)
307
+ self._store.update(schedule)
308
+
309
+ # Dispatch notification (fire-and-forget)
310
+ event = (
311
+ "scheduled_scan.completed"
312
+ if status == "completed"
313
+ else "scheduled_scan.failed"
314
+ )
315
+ try:
316
+ if dispatch_scan_notification is not None:
317
+ asyncio.create_task(
318
+ dispatch_scan_notification(
319
+ event,
320
+ tenant_id=schedule.tenant_id,
321
+ scan_id=scan_id,
322
+ details={
323
+ "schedule_id": schedule.schedule_id,
324
+ "schedule_name": schedule.name,
325
+ "schedule_type": schedule.schedule_type,
326
+ "run_count": schedule.run_count,
327
+ "status": status,
328
+ },
329
+ ),
330
+ name=f"nat-scheduler-notify-{schedule.schedule_id}",
331
+ )
332
+ except Exception: # noqa: BLE001
333
+ logger.warning(
334
+ "Failed to dispatch notification for scheduled scan %s",
335
+ schedule.schedule_id,
336
+ )
337
+
338
+ # ------------------------------------------------------------------
339
+ # Scan delegates
340
+ # ------------------------------------------------------------------
341
+
342
+ async def _execute_security_scan(
343
+ self, schedule: ScanSchedule
344
+ ) -> tuple[str, str]:
345
+ """Run a security scan for *schedule* and return ``(scan_id, status)``."""
346
+ from mannf.product.security.scanner import SecurityScanner # noqa: PLC0415
347
+ from mannf.product.security.checks.base import EndpointInfo # noqa: PLC0415
348
+
349
+ cfg = schedule.scan_config or {}
350
+ spec_path = schedule.spec_path
351
+ base_url = schedule.base_url
352
+ spec_format = schedule.spec_format
353
+
354
+ if spec_format == "postman":
355
+ from mannf.product.integrations.postman_parser import PostmanParser # noqa: PLC0415
356
+ parser: Any = PostmanParser(spec_path=spec_path, base_url=base_url)
357
+ else:
358
+ from mannf.product.integrations.openapi_parser import OpenApiParser # noqa: PLC0415
359
+ from mannf.product.integrations.http_sut import HttpApiSUT # noqa: PLC0415
360
+ sut = HttpApiSUT(base_url=base_url)
361
+ parser = OpenApiParser(
362
+ spec_path=spec_path, base_url=base_url, registry=sut.registry
363
+ )
364
+
365
+ test_cases = parser.parse()
366
+ endpoints: list[EndpointInfo] = []
367
+ seen: set[str] = set()
368
+ for tc in test_cases:
369
+ method = tc.inputs.get("method", "GET").upper()
370
+ path = tc.target
371
+ key = f"{method} {path}"
372
+ if key not in seen:
373
+ seen.add(key)
374
+ endpoints.append(
375
+ EndpointInfo(
376
+ method=method,
377
+ path=path,
378
+ base_url=base_url,
379
+ parameters=tc.inputs.get("parameters", []),
380
+ request_body=tc.inputs.get("body"),
381
+ )
382
+ )
383
+
384
+ scanner = SecurityScanner(
385
+ base_url=base_url,
386
+ enabled_checks=cfg.get("checks"),
387
+ severity_threshold=cfg.get("severity_threshold", "low"),
388
+ belief_guided=True,
389
+ adaptive_checks=True,
390
+ )
391
+ report = await scanner.scan(endpoints)
392
+ return report.scan_id, "completed"
393
+
394
+ async def _execute_functional_scan(
395
+ self, schedule: ScanSchedule
396
+ ) -> tuple[str, str]:
397
+ """Run a functional scan for *schedule* and return ``(scan_id, status)``."""
398
+ from mannf.product.integrations.openapi_parser import OpenApiParser # noqa: PLC0415
399
+ from mannf.product.integrations.http_sut import HttpApiSUT # noqa: PLC0415
400
+
401
+ cfg = schedule.scan_config or {}
402
+ spec_path = schedule.spec_path
403
+ base_url = schedule.base_url
404
+ spec_format = schedule.spec_format
405
+ max_tests: int | None = cfg.get("max_tests")
406
+ auth_token: str | None = cfg.get("auth_token")
407
+ api_key: str | None = cfg.get("api_key")
408
+ api_key_header: str = cfg.get("api_key_header", "X-API-Key")
409
+
410
+ if spec_format == "postman":
411
+ from mannf.product.integrations.postman_parser import PostmanParser # noqa: PLC0415
412
+ test_cases = PostmanParser(spec_path=spec_path, base_url=base_url).parse()
413
+ else:
414
+ sut_tmp = HttpApiSUT(base_url=base_url)
415
+ test_cases = OpenApiParser(
416
+ spec_path=spec_path, base_url=base_url, registry=sut_tmp.registry
417
+ ).parse()
418
+
419
+ if max_tests:
420
+ test_cases = test_cases[:max_tests]
421
+
422
+ sut = HttpApiSUT(
423
+ base_url=base_url,
424
+ auth_token=auth_token,
425
+ api_key=api_key,
426
+ api_key_header=api_key_header,
427
+ )
428
+
429
+ scan_id = str(uuid.uuid4())
430
+ for tc in test_cases:
431
+ await sut.execute(tc)
432
+
433
+ return scan_id, "completed"
434
+
435
+ # ------------------------------------------------------------------
436
+ # Next-run helper
437
+ # ------------------------------------------------------------------
438
+
439
+ def _advance_schedule(self, schedule: ScanSchedule) -> None:
440
+ """Recalculate and set ``next_run_at`` on *schedule*."""
441
+ try:
442
+ now = datetime.now(timezone.utc)
443
+ nxt = next_run_after(
444
+ schedule.cron_expression, now, tz=schedule.timezone
445
+ )
446
+ schedule.next_run_at = nxt.isoformat()
447
+ except Exception: # noqa: BLE001
448
+ logger.exception(
449
+ "Failed to advance next_run_at for schedule %s",
450
+ schedule.schedule_id,
451
+ )
452
+
453
+ async def _execute_autonomous_sweep(
454
+ self, schedule: ScanSchedule
455
+ ) -> tuple[str, str]:
456
+ """Run an autonomous test-loop sweep for *schedule* and return ``(run_id, status)``."""
457
+ from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
458
+
459
+ cfg = schedule.scan_config or {}
460
+ agent = AutonomousTestLoopAgent(
461
+ target_url=schedule.base_url,
462
+ max_iterations=cfg.get("max_iterations", 5),
463
+ max_duration_s=cfg.get("max_duration_s", 600.0),
464
+ stability_threshold=cfg.get("stability_threshold", 2),
465
+ strategy=cfg.get("strategy", "all"),
466
+ risk_threshold=cfg.get("risk_threshold", 0.6),
467
+ max_pages=cfg.get("max_pages", 50),
468
+ max_depth=cfg.get("max_depth", 5),
469
+ recrawl_every=cfg.get("recrawl_every", 3),
470
+ )
471
+ report = await agent.run_loop()
472
+ final_status = "completed" if report.status in ("complete", "stable") else "failed"
473
+ return agent.run_id, final_status
@@ -0,0 +1,86 @@
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
+ """Data model for scheduled scans."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass
13
+ class ScanSchedule:
14
+ """Persistent configuration for a recurring scan.
15
+
16
+ Attributes:
17
+ schedule_id: UUID identifying this schedule.
18
+ tenant_id: UUID of the owning tenant.
19
+ name: Human-readable label, e.g. ``"Nightly API regression"``.
20
+ schedule_type: ``"functional"`` or ``"security"``.
21
+ cron_expression: Standard 5-field cron string, e.g. ``"0 2 * * *"``.
22
+ timezone: IANA timezone name (default ``"UTC"``).
23
+ spec_path: Path or URL to the OpenAPI / Postman spec file.
24
+ spec_format: ``"openapi"`` or ``"postman"``.
25
+ base_url: Target API base URL.
26
+ enabled: When ``False`` the schedule is paused and will not fire.
27
+ scan_config: Extra per-schedule config forwarded to the scanner
28
+ (e.g. ``checks``, ``severity_threshold``, ``max_tests``, ``auth``).
29
+ max_schedules_for_tier: Maximum schedules allowed on the tenant's
30
+ plan tier (populated by the store on creation).
31
+ created_at: ISO 8601 timestamp of creation.
32
+ updated_at: ISO 8601 timestamp of last modification.
33
+ last_run_at: ISO 8601 timestamp of the most recent execution, or
34
+ ``None`` if the schedule has never fired.
35
+ next_run_at: ISO 8601 timestamp of the next scheduled execution, or
36
+ ``None`` if not yet calculated.
37
+ last_run_status: ``"completed"``, ``"failed"``, ``"running"``, or
38
+ ``None`` when the schedule has never fired.
39
+ last_run_scan_id: UUID of the scan result from the last execution, or
40
+ ``None``.
41
+ run_count: Total number of times this schedule has been executed.
42
+ """
43
+
44
+ schedule_id: str
45
+ tenant_id: str
46
+ name: str
47
+ schedule_type: str # "functional" or "security"
48
+ cron_expression: str
49
+ timezone: str
50
+ spec_path: str
51
+ spec_format: str # "openapi" or "postman"
52
+ base_url: str
53
+ enabled: bool
54
+ scan_config: dict = field(default_factory=dict)
55
+ max_schedules_for_tier: int = 0
56
+ created_at: str = ""
57
+ updated_at: str = ""
58
+ last_run_at: str | None = None
59
+ next_run_at: str | None = None
60
+ last_run_status: str | None = None
61
+ last_run_scan_id: str | None = None
62
+ run_count: int = 0
63
+
64
+ def to_dict(self) -> dict:
65
+ """Return a JSON-serialisable dictionary representation."""
66
+ return {
67
+ "schedule_id": self.schedule_id,
68
+ "tenant_id": self.tenant_id,
69
+ "name": self.name,
70
+ "schedule_type": self.schedule_type,
71
+ "cron_expression": self.cron_expression,
72
+ "timezone": self.timezone,
73
+ "spec_path": self.spec_path,
74
+ "spec_format": self.spec_format,
75
+ "base_url": self.base_url,
76
+ "enabled": self.enabled,
77
+ "scan_config": self.scan_config,
78
+ "max_schedules_for_tier": self.max_schedules_for_tier,
79
+ "created_at": self.created_at,
80
+ "updated_at": self.updated_at,
81
+ "last_run_at": self.last_run_at,
82
+ "next_run_at": self.next_run_at,
83
+ "last_run_status": self.last_run_status,
84
+ "last_run_scan_id": self.last_run_scan_id,
85
+ "run_count": self.run_count,
86
+ }