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,70 @@
|
|
|
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
|
+
"""API4:2023 — Unrestricted Resource Consumption (rate limiting)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
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
|
+
_PROBE_COUNT = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResourceConsumptionCheck(SecurityCheck):
|
|
21
|
+
owasp_id = "API4:2023"
|
|
22
|
+
name = "Unrestricted Resource Consumption"
|
|
23
|
+
description = "Tests whether the endpoint enforces rate limiting."
|
|
24
|
+
severity_default = "medium"
|
|
25
|
+
|
|
26
|
+
async def run(
|
|
27
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
28
|
+
) -> list[SecurityFinding]:
|
|
29
|
+
url = endpoint.full_url
|
|
30
|
+
method = endpoint.method.upper()
|
|
31
|
+
|
|
32
|
+
async def _probe() -> int:
|
|
33
|
+
try:
|
|
34
|
+
resp = await client.request(method, url)
|
|
35
|
+
return resp.status_code
|
|
36
|
+
except httpx.RequestError:
|
|
37
|
+
return -1
|
|
38
|
+
|
|
39
|
+
results = await asyncio.gather(*[_probe() for _ in range(_PROBE_COUNT)])
|
|
40
|
+
|
|
41
|
+
rate_limited = any(s == 429 for s in results)
|
|
42
|
+
if rate_limited:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
successful = sum(1 for s in results if 200 <= s < 300)
|
|
46
|
+
if successful < _PROBE_COUNT:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
self._make_finding(
|
|
51
|
+
endpoint,
|
|
52
|
+
title="No rate limiting detected",
|
|
53
|
+
description=(
|
|
54
|
+
f"All {_PROBE_COUNT} rapid consecutive requests to this endpoint "
|
|
55
|
+
"succeeded without receiving a 429 Too Many Requests response. "
|
|
56
|
+
"This may indicate missing rate limiting controls."
|
|
57
|
+
),
|
|
58
|
+
evidence=(
|
|
59
|
+
f"{_PROBE_COUNT}/{_PROBE_COUNT} requests returned success "
|
|
60
|
+
f"(status codes: {list(results)})"
|
|
61
|
+
),
|
|
62
|
+
remediation=(
|
|
63
|
+
"Implement rate limiting (e.g., token bucket, sliding window) "
|
|
64
|
+
"at the API gateway or application layer. Return HTTP 429 with "
|
|
65
|
+
"Retry-After header when limits are exceeded."
|
|
66
|
+
),
|
|
67
|
+
cwe_id="CWE-770",
|
|
68
|
+
confidence=0.5,
|
|
69
|
+
)
|
|
70
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
"""API6:2023 — Unrestricted Access to Sensitive Business Flows."""
|
|
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
|
+
_SENSITIVE_KEYWORDS = (
|
|
16
|
+
"password-reset", "forgot-password", "forgot_password", "reset-password",
|
|
17
|
+
"reset_password", "payment", "checkout", "transfer", "withdraw",
|
|
18
|
+
"register", "signup", "sign-up", "login", "sign-in",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_RATE_LIMIT_INDICATORS = ("x-ratelimit", "x-rate-limit", "retry-after")
|
|
22
|
+
_CAPTCHA_INDICATORS = ("captcha", "recaptcha", "hcaptcha", "cf-turnstile")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SensitiveFlowsCheck(SecurityCheck):
|
|
26
|
+
owasp_id = "API6:2023"
|
|
27
|
+
name = "Unrestricted Access to Sensitive Business Flows"
|
|
28
|
+
description = "Detects sensitive business flows that may lack adequate protections."
|
|
29
|
+
severity_default = "low"
|
|
30
|
+
|
|
31
|
+
async def run(
|
|
32
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
33
|
+
) -> list[SecurityFinding]:
|
|
34
|
+
lower_path = endpoint.path.lower()
|
|
35
|
+
matched = [kw for kw in _SENSITIVE_KEYWORDS if kw in lower_path]
|
|
36
|
+
if not matched:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
url = endpoint.full_url
|
|
40
|
+
method = endpoint.method.upper()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
resp = await client.request(method, url)
|
|
44
|
+
except httpx.RequestError:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
response_headers_lower = {k.lower(): v for k, v in resp.headers.items()}
|
|
48
|
+
body_lower = resp.text.lower()
|
|
49
|
+
|
|
50
|
+
has_rate_limit = any(h in response_headers_lower for h in _RATE_LIMIT_INDICATORS)
|
|
51
|
+
has_captcha = any(ind in body_lower for ind in _CAPTCHA_INDICATORS)
|
|
52
|
+
|
|
53
|
+
if has_rate_limit or has_captcha:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
protection_hints = []
|
|
57
|
+
if not has_rate_limit:
|
|
58
|
+
protection_hints.append("no rate-limit headers detected")
|
|
59
|
+
if not has_captcha:
|
|
60
|
+
protection_hints.append("no CAPTCHA indicators detected")
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
self._make_finding(
|
|
64
|
+
endpoint,
|
|
65
|
+
title=f"Sensitive business flow may lack bot/abuse protections",
|
|
66
|
+
description=(
|
|
67
|
+
f"The endpoint path matches sensitive keyword(s): {matched}. "
|
|
68
|
+
"No rate-limiting headers or CAPTCHA indicators were found in "
|
|
69
|
+
"the response, suggesting it may be susceptible to automated abuse."
|
|
70
|
+
),
|
|
71
|
+
evidence=f"{method} {url} → {resp.status_code}; {'; '.join(protection_hints)}",
|
|
72
|
+
remediation=(
|
|
73
|
+
"Add rate limiting, CAPTCHA challenges, and/or account lockout "
|
|
74
|
+
"mechanisms to protect sensitive flows from automated abuse."
|
|
75
|
+
),
|
|
76
|
+
cwe_id="CWE-799",
|
|
77
|
+
severity="low",
|
|
78
|
+
confidence=0.5,
|
|
79
|
+
)
|
|
80
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
"""API7:2023 — Server Side Request Forgery (SSRF)."""
|
|
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
|
+
_URL_PARAM_NAMES = {
|
|
16
|
+
"url", "uri", "href", "link", "src", "source", "redirect",
|
|
17
|
+
"callback", "webhook", "endpoint", "target", "host", "domain",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_SSRF_PAYLOADS = [
|
|
21
|
+
"http://localhost:80",
|
|
22
|
+
"http://127.0.0.1",
|
|
23
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
24
|
+
"http://[::1]",
|
|
25
|
+
"http://0.0.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _url_params(endpoint: EndpointInfo) -> list[dict]:
|
|
30
|
+
return [
|
|
31
|
+
p for p in endpoint.parameters
|
|
32
|
+
if p.get("name", "").lower() in _URL_PARAM_NAMES
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SSRFCheck(SecurityCheck):
|
|
37
|
+
owasp_id = "API7:2023"
|
|
38
|
+
name = "Server Side Request Forgery"
|
|
39
|
+
description = "Tests for SSRF by injecting internal URLs into URL-like parameters."
|
|
40
|
+
severity_default = "high"
|
|
41
|
+
|
|
42
|
+
async def run(
|
|
43
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
44
|
+
) -> list[SecurityFinding]:
|
|
45
|
+
ssrf_params = _url_params(endpoint)
|
|
46
|
+
if not ssrf_params:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
method = endpoint.method.upper()
|
|
50
|
+
findings: list[SecurityFinding] = []
|
|
51
|
+
|
|
52
|
+
for param in ssrf_params:
|
|
53
|
+
param_name = param["name"]
|
|
54
|
+
param_in = param.get("in", "query")
|
|
55
|
+
|
|
56
|
+
for payload in _SSRF_PAYLOADS:
|
|
57
|
+
try:
|
|
58
|
+
if param_in == "query":
|
|
59
|
+
resp = await client.request(
|
|
60
|
+
method, endpoint.full_url, params={param_name: payload}
|
|
61
|
+
)
|
|
62
|
+
elif param_in == "path":
|
|
63
|
+
# replace placeholder in path
|
|
64
|
+
path = endpoint.path.replace(
|
|
65
|
+
"{" + param_name + "}", payload
|
|
66
|
+
)
|
|
67
|
+
url = f"{endpoint.base_url.rstrip('/')}{path}"
|
|
68
|
+
resp = await client.request(method, url)
|
|
69
|
+
else:
|
|
70
|
+
continue
|
|
71
|
+
except httpx.RequestError:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if resp.status_code == 200 and resp.text.strip():
|
|
75
|
+
findings.append(
|
|
76
|
+
self._make_finding(
|
|
77
|
+
endpoint,
|
|
78
|
+
title="Possible SSRF: internal URL payload returned data",
|
|
79
|
+
description=(
|
|
80
|
+
f"Injecting '{payload}' into parameter '{param_name}' "
|
|
81
|
+
f"returned HTTP 200 with a non-empty body, which may "
|
|
82
|
+
"indicate the server is fetching the provided URL."
|
|
83
|
+
),
|
|
84
|
+
evidence=(
|
|
85
|
+
f"param={param_name}, payload={payload}, "
|
|
86
|
+
f"status={resp.status_code}, "
|
|
87
|
+
f"body_snippet={resp.text[:200]}"
|
|
88
|
+
),
|
|
89
|
+
remediation=(
|
|
90
|
+
"Validate and sanitize all URL parameters against an "
|
|
91
|
+
"allowlist of permitted domains/schemes. Use a dedicated "
|
|
92
|
+
"HTTP client with no access to internal networks for "
|
|
93
|
+
"outbound requests."
|
|
94
|
+
),
|
|
95
|
+
cwe_id="CWE-918",
|
|
96
|
+
confidence=0.7,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
break # one finding per param is enough
|
|
100
|
+
|
|
101
|
+
return findings
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
"""API10:2023 — Unsafe Consumption of APIs (injection)."""
|
|
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
|
+
_INJECTION_PAYLOADS: list[tuple[str, str]] = [
|
|
16
|
+
("sql", "' OR '1'='1"),
|
|
17
|
+
("sql_drop", "'; DROP TABLE users; --"),
|
|
18
|
+
("nosql", '{"$gt": ""}'),
|
|
19
|
+
("xss", "<script>alert(1)</script>"),
|
|
20
|
+
("cmd", "; ls -la"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_STACK_TRACE_MARKERS = [
|
|
24
|
+
"Traceback", "at line", "Exception in thread",
|
|
25
|
+
"NullPointerException", "stack trace", "ORA-", "SQL syntax",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnsafeConsumptionCheck(SecurityCheck):
|
|
30
|
+
owasp_id = "API10:2023"
|
|
31
|
+
name = "Unsafe Consumption of APIs"
|
|
32
|
+
description = "Tests for injection vulnerabilities in string parameters."
|
|
33
|
+
severity_default = "high"
|
|
34
|
+
|
|
35
|
+
async def run(
|
|
36
|
+
self, endpoint: EndpointInfo, client: httpx.AsyncClient
|
|
37
|
+
) -> list[SecurityFinding]:
|
|
38
|
+
string_params = [
|
|
39
|
+
p for p in endpoint.parameters
|
|
40
|
+
if p.get("schema", {}).get("type", "string") == "string"
|
|
41
|
+
or "type" not in p.get("schema", {})
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
if not string_params:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
method = endpoint.method.upper()
|
|
48
|
+
findings: list[SecurityFinding] = []
|
|
49
|
+
seen_endpoints: set[str] = set()
|
|
50
|
+
|
|
51
|
+
for param in string_params:
|
|
52
|
+
param_name = param.get("name", "")
|
|
53
|
+
param_in = param.get("in", "query")
|
|
54
|
+
if not param_name:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
for payload_type, payload in _INJECTION_PAYLOADS:
|
|
58
|
+
try:
|
|
59
|
+
if param_in == "query":
|
|
60
|
+
resp = await client.request(
|
|
61
|
+
method,
|
|
62
|
+
endpoint.full_url,
|
|
63
|
+
params={param_name: payload},
|
|
64
|
+
)
|
|
65
|
+
elif param_in == "path":
|
|
66
|
+
path = endpoint.path.replace(
|
|
67
|
+
"{" + param_name + "}", payload
|
|
68
|
+
)
|
|
69
|
+
url = f"{endpoint.base_url.rstrip('/')}{path}"
|
|
70
|
+
resp = await client.request(method, url)
|
|
71
|
+
else:
|
|
72
|
+
continue
|
|
73
|
+
except httpx.RequestError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
body = resp.text
|
|
77
|
+
reflected = payload in body
|
|
78
|
+
has_trace = any(m in body for m in _STACK_TRACE_MARKERS)
|
|
79
|
+
is_500 = resp.status_code == 500
|
|
80
|
+
|
|
81
|
+
if not (reflected or has_trace or is_500):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
key = f"{param_name}:{payload_type}"
|
|
85
|
+
if key in seen_endpoints:
|
|
86
|
+
continue
|
|
87
|
+
seen_endpoints.add(key)
|
|
88
|
+
|
|
89
|
+
reasons = []
|
|
90
|
+
if reflected:
|
|
91
|
+
reasons.append("payload reflected in response")
|
|
92
|
+
if has_trace:
|
|
93
|
+
reasons.append("stack trace detected")
|
|
94
|
+
if is_500:
|
|
95
|
+
reasons.append("HTTP 500 returned")
|
|
96
|
+
|
|
97
|
+
findings.append(
|
|
98
|
+
self._make_finding(
|
|
99
|
+
endpoint,
|
|
100
|
+
title=f"Possible {payload_type.upper()} injection in parameter '{param_name}'",
|
|
101
|
+
description=(
|
|
102
|
+
f"Injecting a {payload_type} payload into parameter "
|
|
103
|
+
f"'{param_name}' triggered: {', '.join(reasons)}."
|
|
104
|
+
),
|
|
105
|
+
evidence=(
|
|
106
|
+
f"param={param_name}, payload={payload!r}, "
|
|
107
|
+
f"status={resp.status_code}, reasons={reasons}, "
|
|
108
|
+
f"snippet={body[:200]}"
|
|
109
|
+
),
|
|
110
|
+
remediation=(
|
|
111
|
+
"Use parameterized queries and prepared statements for database "
|
|
112
|
+
"access. Validate and sanitize all input. Encode output before "
|
|
113
|
+
"rendering. Never pass user input to shell commands."
|
|
114
|
+
),
|
|
115
|
+
cwe_id="CWE-74",
|
|
116
|
+
confidence=0.7 if (reflected or has_trace) else 0.5,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return findings
|
|
@@ -0,0 +1,92 @@
|
|
|
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 finding and report dataclasses."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
_severity_order: dict[str, int] = {
|
|
13
|
+
"critical": 0,
|
|
14
|
+
"high": 1,
|
|
15
|
+
"medium": 2,
|
|
16
|
+
"low": 3,
|
|
17
|
+
"info": 4,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SecurityFinding:
|
|
23
|
+
check_id: str
|
|
24
|
+
check_name: str
|
|
25
|
+
severity: str
|
|
26
|
+
endpoint: str
|
|
27
|
+
title: str
|
|
28
|
+
description: str
|
|
29
|
+
evidence: str
|
|
30
|
+
remediation: str
|
|
31
|
+
cwe_id: str
|
|
32
|
+
confidence: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class EndpointSecurityPriority:
|
|
37
|
+
"""Per-endpoint prioritization metadata produced by the belief-guided scorer."""
|
|
38
|
+
|
|
39
|
+
endpoint: str
|
|
40
|
+
priority_score: float
|
|
41
|
+
belief_score: float
|
|
42
|
+
risk_score: float
|
|
43
|
+
check_ids: list[str]
|
|
44
|
+
checks_run: list[str]
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"endpoint": self.endpoint,
|
|
49
|
+
"priority_score": self.priority_score,
|
|
50
|
+
"belief_score": self.belief_score,
|
|
51
|
+
"risk_score": self.risk_score,
|
|
52
|
+
"check_ids": self.check_ids,
|
|
53
|
+
"checks_run": self.checks_run,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SecurityReport:
|
|
59
|
+
scan_id: str
|
|
60
|
+
timestamp: str
|
|
61
|
+
target_url: str
|
|
62
|
+
total_endpoints_scanned: int
|
|
63
|
+
findings: list[SecurityFinding] = field(default_factory=list)
|
|
64
|
+
summary: dict = field(default_factory=dict)
|
|
65
|
+
belief_guided: bool = False
|
|
66
|
+
prioritization: list[EndpointSecurityPriority] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"scan_id": self.scan_id,
|
|
71
|
+
"timestamp": self.timestamp,
|
|
72
|
+
"target_url": self.target_url,
|
|
73
|
+
"total_endpoints_scanned": self.total_endpoints_scanned,
|
|
74
|
+
"findings": [
|
|
75
|
+
{
|
|
76
|
+
"check_id": f.check_id,
|
|
77
|
+
"check_name": f.check_name,
|
|
78
|
+
"severity": f.severity,
|
|
79
|
+
"endpoint": f.endpoint,
|
|
80
|
+
"title": f.title,
|
|
81
|
+
"description": f.description,
|
|
82
|
+
"evidence": f.evidence,
|
|
83
|
+
"remediation": f.remediation,
|
|
84
|
+
"cwe_id": f.cwe_id,
|
|
85
|
+
"confidence": f.confidence,
|
|
86
|
+
}
|
|
87
|
+
for f in self.findings
|
|
88
|
+
],
|
|
89
|
+
"summary": self.summary,
|
|
90
|
+
"belief_guided": self.belief_guided,
|
|
91
|
+
"prioritization": [p.to_dict() for p in self.prioritization],
|
|
92
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
"""Plugin loader for custom SecurityCheck implementations.
|
|
7
|
+
|
|
8
|
+
Provides two discovery mechanisms:
|
|
9
|
+
- Directory scanning: load .py files from a directory path.
|
|
10
|
+
- Entry-point discovery: use importlib.metadata to find registered plugins.
|
|
11
|
+
|
|
12
|
+
Both mechanisms validate that each discovered class has the required attributes
|
|
13
|
+
and the ``run`` method before instantiating it. Invalid plugins are skipped
|
|
14
|
+
with a warning — they will never cause the scanner to crash.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib
|
|
20
|
+
import importlib.util
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
from importlib.metadata import entry_points
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
from mannf.product.security.checks.base import SecurityCheck
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_REQUIRED_ATTRS = ("owasp_id", "name", "description", "severity_default")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_valid_plugin(cls: type) -> bool:
|
|
39
|
+
"""Return True if *cls* is a valid, concrete SecurityCheck subclass."""
|
|
40
|
+
if not (isinstance(cls, type) and issubclass(cls, SecurityCheck) and cls is not SecurityCheck):
|
|
41
|
+
return False
|
|
42
|
+
for attr in _REQUIRED_ATTRS:
|
|
43
|
+
if not hasattr(cls, attr):
|
|
44
|
+
logger.warning("Plugin %s is missing required attribute '%s' — skipping.", cls.__name__, attr)
|
|
45
|
+
return False
|
|
46
|
+
if not callable(getattr(cls, "run", None)):
|
|
47
|
+
logger.warning("Plugin %s has no callable 'run' method — skipping.", cls.__name__)
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _instantiate(cls: type) -> SecurityCheck | None:
|
|
53
|
+
"""Try to instantiate *cls*; log a warning and return None on failure."""
|
|
54
|
+
try:
|
|
55
|
+
return cls()
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
logger.warning("Plugin %s raised %s during __init__ — skipping.", cls.__name__, exc)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_plugins_from_directory(path: str) -> list[SecurityCheck]:
|
|
62
|
+
"""Discover and load SecurityCheck subclasses from all ``.py`` files in *path*.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
path:
|
|
67
|
+
Filesystem path to a directory containing plugin ``.py`` files.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
list[SecurityCheck]
|
|
72
|
+
Instantiated check objects for every valid plugin found.
|
|
73
|
+
"""
|
|
74
|
+
plugins: list[SecurityCheck] = []
|
|
75
|
+
directory = Path(path)
|
|
76
|
+
|
|
77
|
+
if not directory.exists():
|
|
78
|
+
logger.warning("Plugin directory '%s' does not exist — no plugins loaded.", path)
|
|
79
|
+
return plugins
|
|
80
|
+
|
|
81
|
+
if not directory.is_dir():
|
|
82
|
+
logger.warning("Plugin path '%s' is not a directory — no plugins loaded.", path)
|
|
83
|
+
return plugins
|
|
84
|
+
|
|
85
|
+
for py_file in sorted(directory.glob("*.py")):
|
|
86
|
+
module_name = f"_nat_plugin_{py_file.stem}"
|
|
87
|
+
try:
|
|
88
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
89
|
+
if spec is None or spec.loader is None:
|
|
90
|
+
logger.warning("Could not create module spec for '%s' — skipping.", py_file)
|
|
91
|
+
continue
|
|
92
|
+
module = importlib.util.module_from_spec(spec)
|
|
93
|
+
sys.modules[module_name] = module
|
|
94
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
95
|
+
except Exception as exc: # noqa: BLE001
|
|
96
|
+
logger.warning("Failed to import plugin file '%s': %s — skipping.", py_file, exc)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
|
100
|
+
if obj.__module__ != module_name:
|
|
101
|
+
# Avoid re-processing imported base classes
|
|
102
|
+
continue
|
|
103
|
+
if _is_valid_plugin(obj):
|
|
104
|
+
instance = _instantiate(obj)
|
|
105
|
+
if instance is not None:
|
|
106
|
+
plugins.append(instance)
|
|
107
|
+
|
|
108
|
+
return plugins
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_plugins_from_entry_points(group: str = "nat.security_checks") -> list[SecurityCheck]:
|
|
112
|
+
"""Discover and load SecurityCheck subclasses registered as package entry points.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
group:
|
|
117
|
+
The entry-point group name to scan (default ``"nat.security_checks"``).
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
list[SecurityCheck]
|
|
122
|
+
Instantiated check objects for every valid entry point found.
|
|
123
|
+
"""
|
|
124
|
+
plugins: list[SecurityCheck] = []
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
eps = entry_points(group=group)
|
|
128
|
+
except Exception as exc: # noqa: BLE001
|
|
129
|
+
logger.warning("Failed to query entry points for group '%s': %s", group, exc)
|
|
130
|
+
return plugins
|
|
131
|
+
|
|
132
|
+
for ep in eps:
|
|
133
|
+
try:
|
|
134
|
+
obj = ep.load()
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
logger.warning("Failed to load entry point '%s': %s — skipping.", ep.name, exc)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if _is_valid_plugin(obj):
|
|
140
|
+
instance = _instantiate(obj)
|
|
141
|
+
if instance is not None:
|
|
142
|
+
plugins.append(instance)
|
|
143
|
+
else:
|
|
144
|
+
logger.warning(
|
|
145
|
+
"Entry point '%s' does not point to a valid SecurityCheck subclass — skipping.",
|
|
146
|
+
ep.name,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return plugins
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_all_plugins(
|
|
153
|
+
plugins_dir: str | None = None,
|
|
154
|
+
entry_point_group: str = "nat.security_checks",
|
|
155
|
+
) -> list[SecurityCheck]:
|
|
156
|
+
"""Load plugins from both a directory and registered entry points, deduplicating by class name.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
plugins_dir:
|
|
161
|
+
Optional filesystem path to a directory of plugin ``.py`` files.
|
|
162
|
+
entry_point_group:
|
|
163
|
+
Entry-point group to scan (default ``"nat.security_checks"``).
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
list[SecurityCheck]
|
|
168
|
+
Merged, deduplicated list of instantiated check objects.
|
|
169
|
+
"""
|
|
170
|
+
seen_class_names: set[str] = set()
|
|
171
|
+
combined: list[SecurityCheck] = []
|
|
172
|
+
|
|
173
|
+
dir_plugins = load_plugins_from_directory(plugins_dir) if plugins_dir else []
|
|
174
|
+
ep_plugins = load_plugins_from_entry_points(entry_point_group)
|
|
175
|
+
|
|
176
|
+
for plugin in dir_plugins + ep_plugins:
|
|
177
|
+
class_name = type(plugin).__name__
|
|
178
|
+
if class_name not in seen_class_names:
|
|
179
|
+
seen_class_names.add(class_name)
|
|
180
|
+
combined.append(plugin)
|
|
181
|
+
|
|
182
|
+
return combined
|