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,235 @@
|
|
|
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
|
+
"""In-memory schedule store with per-tier limit enforcement.
|
|
6
|
+
|
|
7
|
+
The store is a simple dict-backed registry that mirrors the pattern used
|
|
8
|
+
by ``_scans`` and ``_security_scans`` in ``mannf.product.server``.
|
|
9
|
+
|
|
10
|
+
Tier schedule limits
|
|
11
|
+
--------------------
|
|
12
|
+
+------------+------------------+
|
|
13
|
+
| Plan | Max schedules |
|
|
14
|
+
+============+==================+
|
|
15
|
+
| free | 0 (blocked) |
|
|
16
|
+
+------------+------------------+
|
|
17
|
+
| pro | 3 |
|
|
18
|
+
+------------+------------------+
|
|
19
|
+
| team | 10 |
|
|
20
|
+
+------------+------------------+
|
|
21
|
+
| enterprise | 999 999 |
|
|
22
|
+
+------------+------------------+
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from mannf.product.scheduling.store import ScheduleStore, TierLimitExceeded
|
|
27
|
+
|
|
28
|
+
store = ScheduleStore()
|
|
29
|
+
schedule = store.create(schedule, plan_tier="pro")
|
|
30
|
+
schedules = store.list_for_tenant("tenant-uuid")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
from typing import Iterator
|
|
37
|
+
|
|
38
|
+
from mannf.product.scheduling.models import ScanSchedule
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Per-tier limit constants
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
TIER_SCHEDULE_LIMITS: dict[str, int] = {
|
|
47
|
+
"free": 0,
|
|
48
|
+
"pro": 3,
|
|
49
|
+
"team": 10,
|
|
50
|
+
"enterprise": 999_999,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_DEFAULT_TIER = "free"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_schedule_limit(plan_tier: str) -> int:
|
|
57
|
+
"""Return the maximum number of schedules allowed for *plan_tier*.
|
|
58
|
+
|
|
59
|
+
Unknown tiers default to the ``"free"`` limit (0 — blocked).
|
|
60
|
+
"""
|
|
61
|
+
return TIER_SCHEDULE_LIMITS.get(plan_tier, TIER_SCHEDULE_LIMITS[_DEFAULT_TIER])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Custom exceptions
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class TierLimitExceeded(Exception):
|
|
69
|
+
"""Raised when a tenant tries to exceed the schedule limit for their plan.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
plan_tier: The tenant's plan tier name.
|
|
73
|
+
limit: The maximum number of schedules allowed.
|
|
74
|
+
current: The current number of schedules the tenant already has.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, plan_tier: str, limit: int, current: int) -> None:
|
|
78
|
+
self.plan_tier = plan_tier
|
|
79
|
+
self.limit = limit
|
|
80
|
+
self.current = current
|
|
81
|
+
super().__init__(
|
|
82
|
+
f"Schedule limit exceeded for plan '{plan_tier}': "
|
|
83
|
+
f"limit is {limit}, current count is {current}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ScheduleNotFound(KeyError):
|
|
88
|
+
"""Raised when a schedule_id does not exist in the store."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Store implementation
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
class ScheduleStore:
|
|
96
|
+
"""Thread-safe, in-memory store for :class:`~mannf.product.scheduling.models.ScanSchedule` objects.
|
|
97
|
+
|
|
98
|
+
All mutating operations enforce per-tier schedule limits. Pass
|
|
99
|
+
``plan_tier`` on :meth:`create` to have the limit checked; use
|
|
100
|
+
``"enterprise"`` for privileged callers that bypass gating.
|
|
101
|
+
|
|
102
|
+
The store is intentionally simple — persistence and cross-process
|
|
103
|
+
sharing are out of scope for PR 1.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._schedules: dict[str, ScanSchedule] = {}
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# CRUD helpers
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def create(self, schedule: ScanSchedule, plan_tier: str = _DEFAULT_TIER) -> ScanSchedule:
|
|
114
|
+
"""Persist a new schedule, enforcing the per-tier limit.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
schedule: A fully-populated :class:`ScanSchedule` instance. The
|
|
118
|
+
``schedule_id`` must be unique within the store.
|
|
119
|
+
plan_tier: The owning tenant's plan tier name. Defaults to
|
|
120
|
+
``"free"`` (which blocks scheduling).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The stored :class:`ScanSchedule` (same object, unchanged).
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
TierLimitExceeded: When the tenant already has the maximum number
|
|
127
|
+
of schedules allowed for their plan.
|
|
128
|
+
ValueError: When a schedule with the same ``schedule_id`` already
|
|
129
|
+
exists.
|
|
130
|
+
"""
|
|
131
|
+
limit = get_schedule_limit(plan_tier)
|
|
132
|
+
current = sum(
|
|
133
|
+
1 for s in self._schedules.values()
|
|
134
|
+
if s.tenant_id == schedule.tenant_id
|
|
135
|
+
)
|
|
136
|
+
if current >= limit:
|
|
137
|
+
raise TierLimitExceeded(plan_tier, limit, current)
|
|
138
|
+
|
|
139
|
+
if schedule.schedule_id in self._schedules:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Schedule with id '{schedule.schedule_id}' already exists"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Stamp the tier limit so API callers can surface it.
|
|
145
|
+
schedule.max_schedules_for_tier = limit
|
|
146
|
+
|
|
147
|
+
self._schedules[schedule.schedule_id] = schedule
|
|
148
|
+
logger.debug(
|
|
149
|
+
"Created schedule %s for tenant %s (tier=%s, %d/%d used)",
|
|
150
|
+
schedule.schedule_id,
|
|
151
|
+
schedule.tenant_id,
|
|
152
|
+
plan_tier,
|
|
153
|
+
current + 1,
|
|
154
|
+
limit,
|
|
155
|
+
)
|
|
156
|
+
return schedule
|
|
157
|
+
|
|
158
|
+
def get(self, schedule_id: str) -> ScanSchedule:
|
|
159
|
+
"""Return the schedule with *schedule_id*.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ScheduleNotFound: When no schedule with that ID exists.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
return self._schedules[schedule_id]
|
|
166
|
+
except KeyError:
|
|
167
|
+
raise ScheduleNotFound(schedule_id)
|
|
168
|
+
|
|
169
|
+
def list_for_tenant(self, tenant_id: str) -> list[ScanSchedule]:
|
|
170
|
+
"""Return all schedules owned by *tenant_id*, sorted by name."""
|
|
171
|
+
return sorted(
|
|
172
|
+
(s for s in self._schedules.values() if s.tenant_id == tenant_id),
|
|
173
|
+
key=lambda s: s.name,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def update(self, schedule: ScanSchedule) -> ScanSchedule:
|
|
177
|
+
"""Replace the stored schedule entry for ``schedule.schedule_id``.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
schedule: The updated :class:`ScanSchedule`. The ``schedule_id``
|
|
181
|
+
must already exist in the store.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The updated :class:`ScanSchedule`.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
ScheduleNotFound: When the schedule does not exist.
|
|
188
|
+
"""
|
|
189
|
+
if schedule.schedule_id not in self._schedules:
|
|
190
|
+
raise ScheduleNotFound(schedule.schedule_id)
|
|
191
|
+
self._schedules[schedule.schedule_id] = schedule
|
|
192
|
+
return schedule
|
|
193
|
+
|
|
194
|
+
def delete(self, schedule_id: str) -> None:
|
|
195
|
+
"""Remove the schedule with *schedule_id* from the store.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ScheduleNotFound: When no schedule with that ID exists.
|
|
199
|
+
"""
|
|
200
|
+
if schedule_id not in self._schedules:
|
|
201
|
+
raise ScheduleNotFound(schedule_id)
|
|
202
|
+
del self._schedules[schedule_id]
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Iteration helpers (used by the engine)
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def iter_enabled(self) -> Iterator[ScanSchedule]:
|
|
209
|
+
"""Yield all enabled schedules across all tenants."""
|
|
210
|
+
for schedule in list(self._schedules.values()):
|
|
211
|
+
if schedule.enabled:
|
|
212
|
+
yield schedule
|
|
213
|
+
|
|
214
|
+
def __len__(self) -> int:
|
|
215
|
+
return len(self._schedules)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Module-level singleton (for CLI access)
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
_store_singleton: ScheduleStore | None = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_store() -> ScheduleStore:
|
|
226
|
+
"""Return the module-level :class:`ScheduleStore` singleton.
|
|
227
|
+
|
|
228
|
+
In the server context the store is managed by ``server.py``; this helper
|
|
229
|
+
is primarily useful for CLI commands that need to interact with the store
|
|
230
|
+
without importing the full server module.
|
|
231
|
+
"""
|
|
232
|
+
global _store_singleton
|
|
233
|
+
if _store_singleton is None:
|
|
234
|
+
_store_singleton = ScheduleStore()
|
|
235
|
+
return _store_singleton
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
"""Security package for OWASP API Security Top 10 testing."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mannf.product.security.belief_guided import prioritize_for_security
|
|
11
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
12
|
+
from mannf.product.security.reporter import SecurityReporter
|
|
13
|
+
from mannf.product.security.scanner import SecurityScanner
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SecurityScanner",
|
|
17
|
+
"SecurityFinding",
|
|
18
|
+
"SecurityReport",
|
|
19
|
+
"SecurityReporter",
|
|
20
|
+
"prioritize_for_security",
|
|
21
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
"""Belief-guided endpoint prioritization for security scanning."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from mannf.product.security.checks.base import EndpointInfo
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from mannf.core.agents.belief_state import BeliefState
|
|
16
|
+
from mannf.core.prioritization.risk_scorer import RiskScorer
|
|
17
|
+
|
|
18
|
+
_HIGH_VALUE_PATH_KEYWORDS = (
|
|
19
|
+
"admin", "payment", "user", "account", "auth", "token",
|
|
20
|
+
"secret", "key", "password", "config", "management", "internal",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_CHECK_HINTS: dict[str, list[str]] = {
|
|
24
|
+
"admin": ["API5:2023", "API2:2023"],
|
|
25
|
+
"payment": ["API6:2023", "API4:2023", "API10:2023"],
|
|
26
|
+
"user": ["API1:2023", "API2:2023", "API3:2023"],
|
|
27
|
+
"account": ["API1:2023", "API2:2023"],
|
|
28
|
+
"auth": ["API2:2023"],
|
|
29
|
+
"token": ["API2:2023"],
|
|
30
|
+
"password": ["API2:2023", "API6:2023"],
|
|
31
|
+
"config": ["API5:2023", "API8:2023"],
|
|
32
|
+
"management": ["API5:2023"],
|
|
33
|
+
"internal": ["API5:2023", "API8:2023"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_DEFAULT_CHECKS = [
|
|
37
|
+
"API1:2023", "API2:2023", "API3:2023", "API4:2023", "API5:2023",
|
|
38
|
+
"API6:2023", "API7:2023", "API8:2023", "API9:2023", "API10:2023",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prioritize_for_security(
|
|
43
|
+
endpoints: list[EndpointInfo],
|
|
44
|
+
risk_report: list[dict] | None = None,
|
|
45
|
+
belief_states: list[BeliefState] | None = None,
|
|
46
|
+
risk_scorer: RiskScorer | None = None,
|
|
47
|
+
fp_rates: dict[str, float] | None = None,
|
|
48
|
+
) -> list[tuple[EndpointInfo, float, list[str], float, float]]:
|
|
49
|
+
"""Return ``(endpoint, priority_score, check_ids, belief_score, risk_score)`` tuples sorted by descending priority.
|
|
50
|
+
|
|
51
|
+
Priority logic:
|
|
52
|
+
- Base score 0.5 for all endpoints
|
|
53
|
+
- If *belief_states* are provided, average ``fault_likelihood`` across agents is used as the primary score
|
|
54
|
+
- If *risk_scorer* is provided, its composite score is used as the primary score (overrides belief_states base)
|
|
55
|
+
- If *risk_report* is provided (legacy), use ``risk_score`` from the matching report entry
|
|
56
|
+
- Endpoints with high-value keywords in the path get +0.2
|
|
57
|
+
- POST/PUT/DELETE methods get +0.1 over GET
|
|
58
|
+
- If *fp_rates* is provided, the FP rate for the endpoint is used as a downward modifier:
|
|
59
|
+
``score *= (1 - fp_rate)``
|
|
60
|
+
- ``check_ids`` lists the OWASP IDs most relevant for this endpoint
|
|
61
|
+
- ``belief_score`` is the raw belief-derived score (0.5 if not available)
|
|
62
|
+
- ``risk_score`` is the raw risk-scorer composite score (0.5 if not available)
|
|
63
|
+
"""
|
|
64
|
+
risk_map: dict[str, float] = {}
|
|
65
|
+
if risk_report:
|
|
66
|
+
for entry in risk_report:
|
|
67
|
+
path = entry.get("path") or entry.get("endpoint", "")
|
|
68
|
+
score = entry.get("risk_score", 0.5)
|
|
69
|
+
if path:
|
|
70
|
+
risk_map[path] = float(score)
|
|
71
|
+
|
|
72
|
+
results: list[tuple[EndpointInfo, float, list[str], float, float]] = []
|
|
73
|
+
|
|
74
|
+
for ep in endpoints:
|
|
75
|
+
endpoint_key = ep.path
|
|
76
|
+
|
|
77
|
+
# Compute belief score: average fault_likelihood across all BeliefState agents
|
|
78
|
+
belief_score = 0.5
|
|
79
|
+
if belief_states:
|
|
80
|
+
likelihoods = []
|
|
81
|
+
for bs in belief_states:
|
|
82
|
+
fl = bs.fault_likelihood.get(endpoint_key)
|
|
83
|
+
if fl is None:
|
|
84
|
+
# Try matching by method+path key format
|
|
85
|
+
method_key = f"{ep.method.upper()} {endpoint_key}"
|
|
86
|
+
fl = bs.fault_likelihood.get(method_key)
|
|
87
|
+
if fl is not None:
|
|
88
|
+
likelihoods.append(fl)
|
|
89
|
+
if likelihoods:
|
|
90
|
+
belief_score = sum(likelihoods) / len(likelihoods)
|
|
91
|
+
|
|
92
|
+
# Compute risk scorer composite score
|
|
93
|
+
risk_score_val = 0.5
|
|
94
|
+
if risk_scorer is not None:
|
|
95
|
+
try:
|
|
96
|
+
risk_score_val = risk_scorer.score(endpoint_key, belief_states or [])
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
# Determine the primary base score:
|
|
101
|
+
# 1. risk_scorer (highest fidelity composite) if available
|
|
102
|
+
# 2. belief_states (direct BDI signal) if available
|
|
103
|
+
# 3. legacy risk_report
|
|
104
|
+
# 4. default 0.5
|
|
105
|
+
if risk_scorer is not None and risk_score_val != 0.5:
|
|
106
|
+
score = risk_score_val
|
|
107
|
+
elif belief_states:
|
|
108
|
+
score = belief_score
|
|
109
|
+
else:
|
|
110
|
+
score = risk_map.get(ep.path, 0.5)
|
|
111
|
+
|
|
112
|
+
lower_path = ep.path.lower()
|
|
113
|
+
matched_keywords = [kw for kw in _HIGH_VALUE_PATH_KEYWORDS if kw in lower_path]
|
|
114
|
+
if matched_keywords:
|
|
115
|
+
score = min(1.0, score + 0.2)
|
|
116
|
+
|
|
117
|
+
if ep.method.upper() in ("POST", "PUT", "DELETE"):
|
|
118
|
+
score = min(1.0, score + 0.1)
|
|
119
|
+
|
|
120
|
+
# Apply FP rate as a downward modifier: score *= (1 - fp_rate)
|
|
121
|
+
if fp_rates:
|
|
122
|
+
ep_fp_rate = fp_rates.get(endpoint_key)
|
|
123
|
+
if ep_fp_rate is None:
|
|
124
|
+
method_key = f"{ep.method.upper()} {endpoint_key}"
|
|
125
|
+
ep_fp_rate = fp_rates.get(method_key)
|
|
126
|
+
if ep_fp_rate is not None:
|
|
127
|
+
fp_rate_clamped = max(0.0, min(1.0, float(ep_fp_rate)))
|
|
128
|
+
score = max(0.0, score * (1.0 - fp_rate_clamped))
|
|
129
|
+
|
|
130
|
+
check_ids: list[str] = []
|
|
131
|
+
seen: set[str] = set()
|
|
132
|
+
for kw in matched_keywords:
|
|
133
|
+
for cid in _CHECK_HINTS.get(kw, []):
|
|
134
|
+
if cid not in seen:
|
|
135
|
+
check_ids.append(cid)
|
|
136
|
+
seen.add(cid)
|
|
137
|
+
if not check_ids:
|
|
138
|
+
check_ids = list(_DEFAULT_CHECKS)
|
|
139
|
+
|
|
140
|
+
results.append((ep, score, check_ids, belief_score, risk_score_val))
|
|
141
|
+
|
|
142
|
+
results.sort(key=lambda t: t[1], reverse=True)
|
|
143
|
+
return results
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
"""Security checks package — all 10 OWASP API Security Top 10 checks."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mannf.product.security.checks.base import EndpointInfo, SecurityCheck
|
|
11
|
+
from mannf.product.security.checks.bfla import BFLACheck
|
|
12
|
+
from mannf.product.security.checks.bola import BOLACheck
|
|
13
|
+
from mannf.product.security.checks.bopla import BOPLACheck
|
|
14
|
+
from mannf.product.security.checks.broken_auth import BrokenAuthCheck
|
|
15
|
+
from mannf.product.security.checks.graphql_security import (
|
|
16
|
+
GraphQLAliasDoSCheck,
|
|
17
|
+
GraphQLBatchQueryCheck,
|
|
18
|
+
GraphQLDepthAttackCheck,
|
|
19
|
+
GraphQLFieldSuggestionCheck,
|
|
20
|
+
GraphQLIntrospectionCheck,
|
|
21
|
+
)
|
|
22
|
+
from mannf.product.security.checks.inventory import InventoryCheck
|
|
23
|
+
from mannf.product.security.checks.misconfig import MisconfigCheck
|
|
24
|
+
from mannf.product.security.checks.resource_consumption import ResourceConsumptionCheck
|
|
25
|
+
from mannf.product.security.checks.sensitive_flows import SensitiveFlowsCheck
|
|
26
|
+
from mannf.product.security.checks.ssrf import SSRFCheck
|
|
27
|
+
from mannf.product.security.checks.unsafe_consumption import UnsafeConsumptionCheck
|
|
28
|
+
from mannf.product.security.plugin_loader import (
|
|
29
|
+
load_all_plugins,
|
|
30
|
+
load_plugins_from_directory,
|
|
31
|
+
load_plugins_from_entry_points,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"SecurityCheck",
|
|
36
|
+
"EndpointInfo",
|
|
37
|
+
"BOLACheck",
|
|
38
|
+
"BrokenAuthCheck",
|
|
39
|
+
"BOPLACheck",
|
|
40
|
+
"ResourceConsumptionCheck",
|
|
41
|
+
"BFLACheck",
|
|
42
|
+
"SensitiveFlowsCheck",
|
|
43
|
+
"SSRFCheck",
|
|
44
|
+
"MisconfigCheck",
|
|
45
|
+
"InventoryCheck",
|
|
46
|
+
"UnsafeConsumptionCheck",
|
|
47
|
+
"GraphQLIntrospectionCheck",
|
|
48
|
+
"GraphQLDepthAttackCheck",
|
|
49
|
+
"GraphQLBatchQueryCheck",
|
|
50
|
+
"GraphQLFieldSuggestionCheck",
|
|
51
|
+
"GraphQLAliasDoSCheck",
|
|
52
|
+
"load_plugins_from_directory",
|
|
53
|
+
"load_plugins_from_entry_points",
|
|
54
|
+
"load_all_plugins",
|
|
55
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
"""Abstract base for OWASP security checks."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from mannf.product.security.models import SecurityFinding
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class EndpointInfo:
|
|
20
|
+
"""Lightweight endpoint descriptor for security checks."""
|
|
21
|
+
|
|
22
|
+
method: str
|
|
23
|
+
path: str
|
|
24
|
+
base_url: str
|
|
25
|
+
parameters: list[dict] = field(default_factory=list)
|
|
26
|
+
request_body: dict | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def full_url(self) -> str:
|
|
30
|
+
base = self.base_url.rstrip("/")
|
|
31
|
+
path = self.path if self.path.startswith("/") else f"/{self.path}"
|
|
32
|
+
return f"{base}{path}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SecurityCheck(ABC):
|
|
36
|
+
owasp_id: str
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
severity_default: str
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def run(
|
|
43
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
44
|
+
) -> list[SecurityFinding]:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def _make_finding(
|
|
48
|
+
self,
|
|
49
|
+
endpoint: EndpointInfo,
|
|
50
|
+
title: str,
|
|
51
|
+
description: str,
|
|
52
|
+
evidence: str,
|
|
53
|
+
remediation: str,
|
|
54
|
+
cwe_id: str,
|
|
55
|
+
severity: str | None = None,
|
|
56
|
+
confidence: float = 0.8,
|
|
57
|
+
) -> SecurityFinding:
|
|
58
|
+
return SecurityFinding(
|
|
59
|
+
check_id=self.owasp_id,
|
|
60
|
+
check_name=self.name,
|
|
61
|
+
severity=severity if severity is not None else self.severity_default,
|
|
62
|
+
endpoint=f"{endpoint.method.upper()} {endpoint.path}",
|
|
63
|
+
title=title,
|
|
64
|
+
description=description,
|
|
65
|
+
evidence=evidence,
|
|
66
|
+
remediation=remediation,
|
|
67
|
+
cwe_id=cwe_id,
|
|
68
|
+
confidence=confidence,
|
|
69
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
"""API5:2023 — Broken Function Level Authorization (BFLA)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from mannf.product.security.checks.base import EndpointInfo, SecurityCheck
|
|
13
|
+
from mannf.product.security.models import SecurityFinding
|
|
14
|
+
|
|
15
|
+
_ADMIN_PATH_HINTS = (
|
|
16
|
+
"/admin", "/management", "/internal", "/config",
|
|
17
|
+
"/settings", "/users", "/roles",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_PRIVILEGED_METHODS = {"DELETE", "PUT"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_admin_like(path: str) -> bool:
|
|
24
|
+
lower = path.lower()
|
|
25
|
+
return any(hint in lower for hint in _ADMIN_PATH_HINTS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BFLACheck(SecurityCheck):
|
|
29
|
+
owasp_id = "API5:2023"
|
|
30
|
+
name = "Broken Function Level Authorization"
|
|
31
|
+
description = "Tests whether function-level access controls are enforced."
|
|
32
|
+
severity_default = "high"
|
|
33
|
+
|
|
34
|
+
async def run(
|
|
35
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
36
|
+
) -> list[SecurityFinding]:
|
|
37
|
+
method = endpoint.method.upper()
|
|
38
|
+
is_admin = _is_admin_like(endpoint.path)
|
|
39
|
+
is_privileged_method = method in _PRIVILEGED_METHODS
|
|
40
|
+
|
|
41
|
+
if not is_admin and not is_privileged_method:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
url = endpoint.full_url
|
|
45
|
+
headers = {} # no auth header intentionally
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
resp = await client.request(method, url, headers=headers)
|
|
49
|
+
except httpx.RequestError:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
if resp.status_code not in (200, 201):
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
label_parts = []
|
|
56
|
+
if is_admin:
|
|
57
|
+
label_parts.append("admin-like path")
|
|
58
|
+
if is_privileged_method:
|
|
59
|
+
label_parts.append(f"privileged method ({method})")
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
self._make_finding(
|
|
63
|
+
endpoint,
|
|
64
|
+
title="Admin/privileged endpoint accessible without authorization",
|
|
65
|
+
description=(
|
|
66
|
+
f"The endpoint ({', '.join(label_parts)}) returned "
|
|
67
|
+
f"HTTP {resp.status_code} without an Authorization header."
|
|
68
|
+
),
|
|
69
|
+
evidence=f"{method} {url} (no auth) → {resp.status_code}",
|
|
70
|
+
remediation=(
|
|
71
|
+
"Enforce role-based access control (RBAC) on all admin and "
|
|
72
|
+
"privileged endpoints. Reject unauthorized access with HTTP 401/403."
|
|
73
|
+
),
|
|
74
|
+
cwe_id="CWE-285",
|
|
75
|
+
confidence=0.7,
|
|
76
|
+
)
|
|
77
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
"""API1:2023 — Broken Object Level Authorization (BOLA)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from mannf.product.security.checks.base import EndpointInfo, SecurityCheck
|
|
15
|
+
from mannf.product.security.models import SecurityFinding
|
|
16
|
+
|
|
17
|
+
_UUID_RE = re.compile(
|
|
18
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE
|
|
19
|
+
)
|
|
20
|
+
_NUM_RE = re.compile(r"/(\d+)(?=/|$)")
|
|
21
|
+
_ALT_UUID = "00000000-0000-0000-0000-000000000099"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BOLACheck(SecurityCheck):
|
|
25
|
+
owasp_id = "API1:2023"
|
|
26
|
+
name = "Broken Object Level Authorization"
|
|
27
|
+
description = "Tests whether object-level access controls are enforced per request."
|
|
28
|
+
severity_default = "high"
|
|
29
|
+
|
|
30
|
+
async def run(
|
|
31
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
32
|
+
) -> list[SecurityFinding]:
|
|
33
|
+
findings: list[SecurityFinding] = []
|
|
34
|
+
path = endpoint.path
|
|
35
|
+
|
|
36
|
+
has_uuid = bool(_UUID_RE.search(path))
|
|
37
|
+
has_num = bool(_NUM_RE.search(path))
|
|
38
|
+
if not has_uuid and not has_num:
|
|
39
|
+
return findings
|
|
40
|
+
|
|
41
|
+
probes: list[str] = []
|
|
42
|
+
if has_uuid:
|
|
43
|
+
probes.append(_UUID_RE.sub(_ALT_UUID, path))
|
|
44
|
+
|
|
45
|
+
if has_num:
|
|
46
|
+
for replacement in ("2", "99999", "0", "-1"):
|
|
47
|
+
probes.append(_NUM_RE.sub(f"/{replacement}", path, count=1))
|
|
48
|
+
|
|
49
|
+
for probe_path in probes:
|
|
50
|
+
if probe_path == path:
|
|
51
|
+
continue
|
|
52
|
+
url = f"{endpoint.base_url.rstrip('/')}{probe_path}"
|
|
53
|
+
try:
|
|
54
|
+
resp = await client.request(endpoint.method.upper(), url)
|
|
55
|
+
except httpx.RequestError:
|
|
56
|
+
continue
|
|
57
|
+
if resp.status_code == 200:
|
|
58
|
+
findings.append(
|
|
59
|
+
self._make_finding(
|
|
60
|
+
endpoint,
|
|
61
|
+
title="Possible BOLA: Resource accessible with alternate ID",
|
|
62
|
+
description=(
|
|
63
|
+
"A request to a modified resource ID returned HTTP 200. "
|
|
64
|
+
"This may indicate missing object-level authorization checks."
|
|
65
|
+
),
|
|
66
|
+
evidence=f"GET {url} → {resp.status_code}",
|
|
67
|
+
remediation=(
|
|
68
|
+
"Validate that the authenticated user owns or has permission "
|
|
69
|
+
"to access the requested resource ID on every request."
|
|
70
|
+
),
|
|
71
|
+
cwe_id="CWE-639",
|
|
72
|
+
confidence=0.5,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
break # one finding per endpoint is enough
|
|
76
|
+
|
|
77
|
+
return findings
|