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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
}
|