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,987 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Orchestration: Ingest → Transform → Prioritize → Scan → Learn.
|
|
7
|
+
|
|
8
|
+
The :class:`IngestOrchestrator` is the glue layer that connects NAT's ingestor
|
|
9
|
+
plugins to the belief-guided prioritizer and security scan engine, closing the
|
|
10
|
+
end-to-end loop:
|
|
11
|
+
|
|
12
|
+
ingest specs/test plans
|
|
13
|
+
→ transform to scan targets
|
|
14
|
+
→ prioritize with belief-guided scorer
|
|
15
|
+
→ scan endpoints
|
|
16
|
+
→ learn from results (smarter next scan)
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from mannf.product.orchestrator import IngestOrchestrator
|
|
21
|
+
from mannf.product.orchestrator_models import OrchestrationConfig
|
|
22
|
+
|
|
23
|
+
config = OrchestrationConfig(
|
|
24
|
+
source_path="openapi.yaml",
|
|
25
|
+
source_format="openapi",
|
|
26
|
+
scan_config={"base_url": "http://localhost:8080"},
|
|
27
|
+
)
|
|
28
|
+
orch = IngestOrchestrator(config)
|
|
29
|
+
|
|
30
|
+
# Dry run — inspect the plan before scanning
|
|
31
|
+
plan = await orch.plan()
|
|
32
|
+
|
|
33
|
+
# Full run
|
|
34
|
+
result = await orch.orchestrate()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import uuid
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from mannf.product.ingestors import (
|
|
48
|
+
BUILTIN_INGESTORS,
|
|
49
|
+
get_ingestor_by_name,
|
|
50
|
+
get_ingestor_for_source,
|
|
51
|
+
)
|
|
52
|
+
from mannf.product.ingestors.models import (
|
|
53
|
+
IngestedEndpoint,
|
|
54
|
+
IngestedTestCase,
|
|
55
|
+
IngestorSource,
|
|
56
|
+
IngestResult,
|
|
57
|
+
)
|
|
58
|
+
from mannf.product.orchestrator_models import (
|
|
59
|
+
OrchestrationConfig,
|
|
60
|
+
OrchestrationResult,
|
|
61
|
+
ScanFeedback,
|
|
62
|
+
ScanPlan,
|
|
63
|
+
ScanTarget,
|
|
64
|
+
)
|
|
65
|
+
from mannf.product.security.belief_guided import prioritize_for_security
|
|
66
|
+
from mannf.product.security.checks.base import EndpointInfo
|
|
67
|
+
from mannf.product.security.models import SecurityReport
|
|
68
|
+
from mannf.product.security.scanner import SecurityScanner
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
# Priority string → initial belief hint used as a risk_report score seed.
|
|
73
|
+
_PRIORITY_TO_SCORE: dict[str, float] = {
|
|
74
|
+
"critical": 0.9,
|
|
75
|
+
"high": 0.7,
|
|
76
|
+
"medium": 0.5,
|
|
77
|
+
"low": 0.3,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IngestOrchestrator:
|
|
82
|
+
"""End-to-end orchestrator that bridges ingestor plugins to the scan engine.
|
|
83
|
+
|
|
84
|
+
The orchestrator does **not** re-implement ingestor or scan logic — it
|
|
85
|
+
connects them. All heavy lifting (parsing, HTTP probing, belief scoring)
|
|
86
|
+
is delegated to the existing subsystems.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
config:
|
|
91
|
+
Orchestration configuration. The most important fields are
|
|
92
|
+
``source_path`` / ``source_url``, ``source_format`` (or
|
|
93
|
+
``ingestor_name``), and ``scan_config["base_url"]``.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, config: OrchestrationConfig) -> None:
|
|
97
|
+
self.config = config
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Public API
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async def orchestrate(self) -> OrchestrationResult:
|
|
104
|
+
"""Run the full ingest → prioritize → scan → learn pipeline.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
OrchestrationResult
|
|
109
|
+
Complete pipeline result including scan findings and feedback.
|
|
110
|
+
"""
|
|
111
|
+
result = OrchestrationResult(config=self.config)
|
|
112
|
+
|
|
113
|
+
# Phase 1: Ingest
|
|
114
|
+
ingest_result = await self._ingest(result)
|
|
115
|
+
if ingest_result is None:
|
|
116
|
+
return result # fatal ingest error — errors already recorded
|
|
117
|
+
|
|
118
|
+
result.ingest_stats = ingest_result.stats
|
|
119
|
+
result.warnings.extend(ingest_result.warnings)
|
|
120
|
+
|
|
121
|
+
# Phase 2 & 3: Transform + Prioritize
|
|
122
|
+
scan_plan = self._build_scan_plan(ingest_result, result)
|
|
123
|
+
|
|
124
|
+
# Phase 3b: LLM edge-case augmentation (optional)
|
|
125
|
+
scan_plan = await self._augment_scan_plan_with_llm(scan_plan, result)
|
|
126
|
+
|
|
127
|
+
result.scan_plan = scan_plan
|
|
128
|
+
|
|
129
|
+
if not scan_plan.targets:
|
|
130
|
+
result.warnings.append("No scan targets produced after transform phase — skipping scan.")
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
base_url = self.config.scan_config.get("base_url", "")
|
|
134
|
+
if not base_url:
|
|
135
|
+
result.errors.append(
|
|
136
|
+
"scan_config['base_url'] is required to run a scan. "
|
|
137
|
+
"Use plan() for a dry-run without base_url."
|
|
138
|
+
)
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
# Phase 4: Scan
|
|
142
|
+
scan_report = await self._scan(scan_plan, base_url, result)
|
|
143
|
+
result.scan_results = scan_report
|
|
144
|
+
|
|
145
|
+
# Phase 4b: Root cause analysis for high-severity findings (Phase 10.3)
|
|
146
|
+
if scan_report is not None and scan_report.findings:
|
|
147
|
+
await self._run_root_cause_analysis(scan_report, result)
|
|
148
|
+
|
|
149
|
+
# Phase 5: Learn
|
|
150
|
+
if scan_report is not None:
|
|
151
|
+
feedback = self._compute_feedback(scan_plan, scan_report)
|
|
152
|
+
result.feedback = feedback
|
|
153
|
+
await self._persist_feedback(feedback, result)
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
async def plan(self) -> OrchestrationResult:
|
|
158
|
+
"""Dry-run mode: ingest + transform + prioritize, but do not scan.
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
OrchestrationResult
|
|
163
|
+
Pipeline result with ``scan_plan`` populated but
|
|
164
|
+
``scan_results`` and ``feedback`` left as ``None``.
|
|
165
|
+
"""
|
|
166
|
+
result = OrchestrationResult(config=self.config)
|
|
167
|
+
|
|
168
|
+
ingest_result = await self._ingest(result)
|
|
169
|
+
if ingest_result is None:
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
result.ingest_stats = ingest_result.stats
|
|
173
|
+
result.warnings.extend(ingest_result.warnings)
|
|
174
|
+
|
|
175
|
+
scan_plan = self._build_scan_plan(ingest_result, result)
|
|
176
|
+
# Phase 3b: LLM edge-case augmentation (optional)
|
|
177
|
+
scan_plan = await self._augment_scan_plan_with_llm(scan_plan, result)
|
|
178
|
+
result.scan_plan = scan_plan
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_cli_args(cls, args: Any) -> "IngestOrchestrator":
|
|
183
|
+
"""Construct an :class:`IngestOrchestrator` from parsed CLI arguments.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
args:
|
|
188
|
+
Namespace object as returned by ``argparse.ArgumentParser.parse_args()``.
|
|
189
|
+
Expected attributes (all optional unless noted):
|
|
190
|
+
|
|
191
|
+
- ``ingest`` — source file path or URL *(required)*
|
|
192
|
+
- ``ingest_format`` — format hint (e.g. ``"openapi"``)
|
|
193
|
+
- ``ingestor`` — ingestor name override
|
|
194
|
+
- ``base_url`` — API base URL
|
|
195
|
+
- ``auth_token`` — Bearer token
|
|
196
|
+
- ``api_key`` — API key value
|
|
197
|
+
- ``api_key_header`` — API key header name (default ``X-API-Key``)
|
|
198
|
+
- ``severity_threshold`` — minimum severity to report
|
|
199
|
+
- ``belief_guided`` — bool, enable belief-guided prioritization
|
|
200
|
+
- ``export`` — exporter plugin name
|
|
201
|
+
- ``export_config`` — list of ``key=value`` strings
|
|
202
|
+
- ``feedback_save_path`` — file path to persist feedback JSON
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
IngestOrchestrator
|
|
207
|
+
"""
|
|
208
|
+
source = getattr(args, "ingest", None) or ""
|
|
209
|
+
source_format = getattr(args, "ingest_format", None)
|
|
210
|
+
ingestor_name = getattr(args, "ingestor", None)
|
|
211
|
+
|
|
212
|
+
scan_config: dict = {}
|
|
213
|
+
base_url = getattr(args, "base_url", None)
|
|
214
|
+
if base_url:
|
|
215
|
+
scan_config["base_url"] = base_url
|
|
216
|
+
|
|
217
|
+
auth_token = getattr(args, "auth_token", None)
|
|
218
|
+
if auth_token:
|
|
219
|
+
scan_config["auth_token"] = auth_token
|
|
220
|
+
|
|
221
|
+
api_key = getattr(args, "api_key", None)
|
|
222
|
+
if api_key:
|
|
223
|
+
scan_config["api_key"] = api_key
|
|
224
|
+
scan_config["api_key_header"] = getattr(args, "api_key_header", "X-API-Key")
|
|
225
|
+
|
|
226
|
+
severity = getattr(args, "severity_threshold", "low")
|
|
227
|
+
if severity:
|
|
228
|
+
scan_config["severity_threshold"] = severity
|
|
229
|
+
|
|
230
|
+
belief_guided = getattr(args, "belief_guided", True)
|
|
231
|
+
scan_config["belief_guided"] = bool(belief_guided)
|
|
232
|
+
|
|
233
|
+
# Parse exporter config key=value pairs
|
|
234
|
+
exporter_config: dict = {}
|
|
235
|
+
export_name = getattr(args, "export", None)
|
|
236
|
+
if export_name:
|
|
237
|
+
exporter_config["exporter_name"] = export_name
|
|
238
|
+
for pair in (getattr(args, "export_config", None) or []):
|
|
239
|
+
if "=" in pair:
|
|
240
|
+
k, _, v = pair.partition("=")
|
|
241
|
+
exporter_config[k.strip()] = v.strip()
|
|
242
|
+
|
|
243
|
+
feedback_config: dict = {}
|
|
244
|
+
feedback_path = getattr(args, "feedback_save_path", None)
|
|
245
|
+
if feedback_path:
|
|
246
|
+
feedback_config["save_path"] = feedback_path
|
|
247
|
+
|
|
248
|
+
# Determine if source is a path or a URL
|
|
249
|
+
source_path: str | None = None
|
|
250
|
+
source_url: str | None = None
|
|
251
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
252
|
+
source_url = source
|
|
253
|
+
elif source:
|
|
254
|
+
source_path = source
|
|
255
|
+
|
|
256
|
+
config = OrchestrationConfig(
|
|
257
|
+
ingestor_name=ingestor_name,
|
|
258
|
+
source_path=source_path,
|
|
259
|
+
source_url=source_url,
|
|
260
|
+
source_format=source_format,
|
|
261
|
+
scan_config=scan_config,
|
|
262
|
+
exporter_config=exporter_config,
|
|
263
|
+
feedback_config=feedback_config,
|
|
264
|
+
)
|
|
265
|
+
return cls(config)
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# Phase 1: Ingest
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async def _ingest(self, result: OrchestrationResult) -> IngestResult | None:
|
|
272
|
+
"""Run the ingest phase and return the :class:`IngestResult`.
|
|
273
|
+
|
|
274
|
+
Returns ``None`` and records an error in *result* on failure.
|
|
275
|
+
"""
|
|
276
|
+
source = self._build_source()
|
|
277
|
+
|
|
278
|
+
# Find the right ingestor
|
|
279
|
+
ingestor = None
|
|
280
|
+
if self.config.ingestor_name:
|
|
281
|
+
ingestor = get_ingestor_by_name(self.config.ingestor_name, BUILTIN_INGESTORS)
|
|
282
|
+
if ingestor is None:
|
|
283
|
+
result.errors.append(
|
|
284
|
+
f"No ingestor found with name '{self.config.ingestor_name}'. "
|
|
285
|
+
f"Available: {[i.name for i in BUILTIN_INGESTORS]}"
|
|
286
|
+
)
|
|
287
|
+
return None
|
|
288
|
+
else:
|
|
289
|
+
ingestor = get_ingestor_for_source(source, BUILTIN_INGESTORS)
|
|
290
|
+
if ingestor is None:
|
|
291
|
+
result.errors.append(
|
|
292
|
+
f"Could not auto-detect an ingestor for source format="
|
|
293
|
+
f"{source.format!r}, path={source.path!r}, url={source.url!r}. "
|
|
294
|
+
"Set ingestor_name in config or provide a source_format hint."
|
|
295
|
+
)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
logger.info("Using ingestor '%s' (%s)", ingestor.name, ingestor.display_name)
|
|
299
|
+
|
|
300
|
+
# Validate config
|
|
301
|
+
config_errors = ingestor.validate_config(self.config.ingestor_config)
|
|
302
|
+
if config_errors:
|
|
303
|
+
for err in config_errors:
|
|
304
|
+
logger.warning("Ingestor config warning: %s", err)
|
|
305
|
+
result.warnings.extend(config_errors)
|
|
306
|
+
|
|
307
|
+
# Run ingestion
|
|
308
|
+
try:
|
|
309
|
+
ingest_result: IngestResult = await ingestor.ingest(source, self.config.ingestor_config)
|
|
310
|
+
except Exception as exc: # noqa: BLE001
|
|
311
|
+
result.errors.append(f"Ingestor '{ingestor.name}' raised an exception: {exc}")
|
|
312
|
+
logger.exception("Ingestor '%s' raised an exception", ingestor.name)
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
if not ingest_result.success:
|
|
316
|
+
for err in ingest_result.errors:
|
|
317
|
+
result.errors.append(f"Ingest error: {err}")
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
logger.info(
|
|
321
|
+
"Ingested %d endpoint(s) and %d test case(s) from '%s'.",
|
|
322
|
+
len(ingest_result.endpoints),
|
|
323
|
+
len(ingest_result.test_cases),
|
|
324
|
+
ingestor.name,
|
|
325
|
+
)
|
|
326
|
+
if ingest_result.errors:
|
|
327
|
+
# Partial errors — not fatal
|
|
328
|
+
result.warnings.extend(
|
|
329
|
+
f"Ingest partial error: {e}" for e in ingest_result.errors
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return ingest_result
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
# Phase 2: Transform
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _transform_endpoint(self, ep: IngestedEndpoint, base_url: str) -> EndpointInfo:
|
|
339
|
+
"""Convert an :class:`IngestedEndpoint` to an :class:`EndpointInfo`.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
ep:
|
|
344
|
+
Ingested endpoint.
|
|
345
|
+
base_url:
|
|
346
|
+
API base URL to attach (from ``scan_config`` or ``ingestor_config``).
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
EndpointInfo
|
|
351
|
+
"""
|
|
352
|
+
return EndpointInfo(
|
|
353
|
+
method=ep.method.upper(),
|
|
354
|
+
path=ep.path,
|
|
355
|
+
base_url=base_url,
|
|
356
|
+
parameters=list(ep.parameters),
|
|
357
|
+
request_body=ep.request_body,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _build_risk_report(
|
|
361
|
+
self,
|
|
362
|
+
endpoints: list[IngestedEndpoint],
|
|
363
|
+
test_cases: list[IngestedTestCase],
|
|
364
|
+
) -> list[dict]:
|
|
365
|
+
"""Build a legacy-style risk report that seeds belief scores from ingestor priorities.
|
|
366
|
+
|
|
367
|
+
``IngestedTestCase.priority`` values (``"critical"``, ``"high"``,
|
|
368
|
+
``"medium"``, ``"low"``) are translated to float scores and attached to
|
|
369
|
+
the endpoint paths they appear to relate to. Auth-required endpoints get
|
|
370
|
+
a +0.1 boost.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
endpoints:
|
|
375
|
+
All ingested endpoints.
|
|
376
|
+
test_cases:
|
|
377
|
+
All ingested test cases.
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
list[dict]
|
|
382
|
+
Dicts with ``"path"`` and ``"risk_score"`` keys, as consumed by
|
|
383
|
+
:func:`~mannf.product.security.belief_guided.prioritize_for_security`.
|
|
384
|
+
"""
|
|
385
|
+
# Aggregate priority hints per path: take the max score across test cases
|
|
386
|
+
path_scores: dict[str, float] = {}
|
|
387
|
+
|
|
388
|
+
for tc in test_cases:
|
|
389
|
+
score = _PRIORITY_TO_SCORE.get(tc.priority, 0.5)
|
|
390
|
+
# Match test cases to endpoints by checking tags or source_ref
|
|
391
|
+
for ep in endpoints:
|
|
392
|
+
ep_key = ep.path.lower()
|
|
393
|
+
tc_lower = (tc.name + " " + tc.description + " " + tc.source_ref).lower()
|
|
394
|
+
tags_match = bool(set(tc.tags) & set(ep.tags))
|
|
395
|
+
path_in_tc = ep_key in tc_lower or ep.path in tc.source_ref
|
|
396
|
+
if tags_match or path_in_tc:
|
|
397
|
+
existing = path_scores.get(ep.path, 0.0)
|
|
398
|
+
path_scores[ep.path] = max(existing, score)
|
|
399
|
+
|
|
400
|
+
# For endpoints without any matched test case, use the highest priority
|
|
401
|
+
# across all test cases as a fallback baseline (or 0.5 if no test cases).
|
|
402
|
+
fallback = max(
|
|
403
|
+
(_PRIORITY_TO_SCORE.get(tc.priority, 0.5) for tc in test_cases),
|
|
404
|
+
default=0.5,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
report: list[dict] = []
|
|
408
|
+
for ep in endpoints:
|
|
409
|
+
base_score = path_scores.get(ep.path, fallback)
|
|
410
|
+
# Auth-required endpoints are higher risk
|
|
411
|
+
if ep.auth_requirements:
|
|
412
|
+
base_score = min(1.0, base_score + 0.1)
|
|
413
|
+
# source_hint=ai-suggestion → exploratory tag → slight boost
|
|
414
|
+
if ep.metadata.get("source_hint") == "ai-suggestion":
|
|
415
|
+
base_score = min(1.0, base_score + 0.05)
|
|
416
|
+
report.append({"path": ep.path, "risk_score": base_score})
|
|
417
|
+
|
|
418
|
+
return report
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------------
|
|
421
|
+
# Phase 2 + 3: Build prioritized ScanPlan
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def _build_scan_plan(
|
|
425
|
+
self,
|
|
426
|
+
ingest_result: IngestResult,
|
|
427
|
+
result: OrchestrationResult,
|
|
428
|
+
) -> ScanPlan:
|
|
429
|
+
"""Transform ingested data into a prioritized :class:`ScanPlan`.
|
|
430
|
+
|
|
431
|
+
1. Resolves the base URL from config.
|
|
432
|
+
2. Converts each :class:`IngestedEndpoint` to an :class:`EndpointInfo`.
|
|
433
|
+
3. Builds a seed risk report from ingestor priority hints.
|
|
434
|
+
4. Calls :func:`~mannf.product.security.belief_guided.prioritize_for_security`
|
|
435
|
+
to order endpoints.
|
|
436
|
+
5. Associates :class:`IngestedTestCase` objects with their endpoints.
|
|
437
|
+
|
|
438
|
+
Parameters
|
|
439
|
+
----------
|
|
440
|
+
ingest_result:
|
|
441
|
+
Result of the ingest phase.
|
|
442
|
+
result:
|
|
443
|
+
Mutable :class:`OrchestrationResult` for recording warnings.
|
|
444
|
+
|
|
445
|
+
Returns
|
|
446
|
+
-------
|
|
447
|
+
ScanPlan
|
|
448
|
+
"""
|
|
449
|
+
base_url = (
|
|
450
|
+
self.config.scan_config.get("base_url")
|
|
451
|
+
or self.config.ingestor_config.get("base_url")
|
|
452
|
+
or ""
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
endpoints = ingest_result.endpoints
|
|
456
|
+
test_cases = ingest_result.test_cases
|
|
457
|
+
|
|
458
|
+
if not endpoints:
|
|
459
|
+
result.warnings.append("Ingestor produced no endpoints — scan plan will be empty.")
|
|
460
|
+
return ScanPlan(
|
|
461
|
+
ingest_stats=ingest_result.stats,
|
|
462
|
+
metadata={"ingestor_name": self.config.ingestor_name or "auto"},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Build EndpointInfo objects for the prioritizer
|
|
466
|
+
endpoint_infos = [self._transform_endpoint(ep, base_url) for ep in endpoints]
|
|
467
|
+
|
|
468
|
+
# Seed the prioritizer with priority hints from the ingestor
|
|
469
|
+
risk_report = self._build_risk_report(endpoints, test_cases)
|
|
470
|
+
|
|
471
|
+
# Allow the orchestration config to override/extend the seed report
|
|
472
|
+
config_risk_report = self.config.prioritizer_config.get("risk_report")
|
|
473
|
+
if config_risk_report:
|
|
474
|
+
risk_report = config_risk_report
|
|
475
|
+
|
|
476
|
+
# Belief score overrides from config
|
|
477
|
+
seed_scores: dict[str, float] = self.config.prioritizer_config.get("seed_belief_scores", {})
|
|
478
|
+
|
|
479
|
+
# Run prioritization
|
|
480
|
+
prioritized = prioritize_for_security(
|
|
481
|
+
endpoint_infos,
|
|
482
|
+
risk_report=risk_report,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# Apply any explicit seed_belief_scores overrides on top
|
|
486
|
+
if seed_scores:
|
|
487
|
+
reordered = []
|
|
488
|
+
for ep_info, score, check_ids, belief_score, risk_score_val in prioritized:
|
|
489
|
+
override = seed_scores.get(ep_info.path) or seed_scores.get(
|
|
490
|
+
f"{ep_info.method.upper()} {ep_info.path}"
|
|
491
|
+
)
|
|
492
|
+
if override is not None:
|
|
493
|
+
score = min(1.0, max(0.0, float(override)))
|
|
494
|
+
reordered.append((ep_info, score, check_ids, belief_score, risk_score_val))
|
|
495
|
+
reordered.sort(key=lambda t: t[1], reverse=True)
|
|
496
|
+
prioritized = reordered
|
|
497
|
+
|
|
498
|
+
# Build a path → IngestedEndpoint lookup for test case association
|
|
499
|
+
path_to_ingest: dict[str, IngestedEndpoint] = {ep.path: ep for ep in endpoints}
|
|
500
|
+
|
|
501
|
+
# Associate test cases with their best-match endpoint
|
|
502
|
+
path_to_tcs: dict[str, list[IngestedTestCase]] = {ep.path: [] for ep in endpoints}
|
|
503
|
+
for tc in test_cases:
|
|
504
|
+
matched = False
|
|
505
|
+
for ep in endpoints:
|
|
506
|
+
tc_lower = (tc.name + " " + tc.description + " " + tc.source_ref).lower()
|
|
507
|
+
tags_match = bool(set(tc.tags) & set(ep.tags))
|
|
508
|
+
path_in_tc = ep.path.lower() in tc_lower or ep.path in tc.source_ref
|
|
509
|
+
if tags_match or path_in_tc:
|
|
510
|
+
path_to_tcs[ep.path].append(tc)
|
|
511
|
+
matched = True
|
|
512
|
+
break
|
|
513
|
+
if not matched:
|
|
514
|
+
# Attach unmatched test cases to the first (highest-priority) endpoint
|
|
515
|
+
if endpoints:
|
|
516
|
+
first_path = prioritized[0][0].path if prioritized else endpoints[0].path
|
|
517
|
+
path_to_tcs.setdefault(first_path, []).append(tc)
|
|
518
|
+
|
|
519
|
+
# Build ScanTarget list
|
|
520
|
+
targets: list[ScanTarget] = []
|
|
521
|
+
for ep_info, score, check_ids, _belief_score, _risk_score in prioritized:
|
|
522
|
+
ingest_ep = path_to_ingest.get(ep_info.path)
|
|
523
|
+
if ingest_ep is None:
|
|
524
|
+
continue
|
|
525
|
+
associated_tcs = path_to_tcs.get(ep_info.path, [])
|
|
526
|
+
target = ScanTarget(
|
|
527
|
+
endpoint=ingest_ep,
|
|
528
|
+
test_cases=associated_tcs,
|
|
529
|
+
priority_score=score,
|
|
530
|
+
check_ids=check_ids,
|
|
531
|
+
metadata={
|
|
532
|
+
"ingestor_name": self.config.ingestor_name or "auto",
|
|
533
|
+
"auth_required": bool(ingest_ep.auth_requirements),
|
|
534
|
+
"tags": ingest_ep.tags,
|
|
535
|
+
},
|
|
536
|
+
)
|
|
537
|
+
targets.append(target)
|
|
538
|
+
|
|
539
|
+
return ScanPlan(
|
|
540
|
+
targets=targets,
|
|
541
|
+
total_endpoints=len(targets),
|
|
542
|
+
total_test_cases=sum(len(t.test_cases) for t in targets),
|
|
543
|
+
ingest_stats=ingest_result.stats,
|
|
544
|
+
metadata={
|
|
545
|
+
"ingestor_name": self.config.ingestor_name or "auto",
|
|
546
|
+
"source_path": self.config.source_path,
|
|
547
|
+
"source_url": self.config.source_url,
|
|
548
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
549
|
+
},
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# ------------------------------------------------------------------
|
|
553
|
+
# Phase 3b: LLM edge-case augmentation (optional)
|
|
554
|
+
# ------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
async def _augment_scan_plan_with_llm(
|
|
557
|
+
self,
|
|
558
|
+
scan_plan: ScanPlan,
|
|
559
|
+
result: OrchestrationResult,
|
|
560
|
+
) -> ScanPlan:
|
|
561
|
+
"""Optionally augment high-priority scan targets with LLM-generated edge cases.
|
|
562
|
+
|
|
563
|
+
When ``llm_config`` is set and ``enabled`` is not ``False``, this method
|
|
564
|
+
invokes the configured LLM provider to generate additional test cases for
|
|
565
|
+
every target whose ``priority_score`` meets or exceeds the configured
|
|
566
|
+
``priority_threshold`` (default ``0.7``).
|
|
567
|
+
|
|
568
|
+
The LLM-generated test cases are appended to the target's ``test_cases``
|
|
569
|
+
list and tagged with ``source_ref="llm-generated"``.
|
|
570
|
+
|
|
571
|
+
Errors from the LLM (missing API key, network failures, etc.) are recorded
|
|
572
|
+
as non-fatal warnings so they never abort a scan.
|
|
573
|
+
|
|
574
|
+
Parameters
|
|
575
|
+
----------
|
|
576
|
+
scan_plan:
|
|
577
|
+
The prioritized scan plan to augment.
|
|
578
|
+
result:
|
|
579
|
+
Mutable :class:`OrchestrationResult` for recording warnings.
|
|
580
|
+
|
|
581
|
+
Returns
|
|
582
|
+
-------
|
|
583
|
+
ScanPlan
|
|
584
|
+
The (potentially augmented) scan plan. Returns *scan_plan* unchanged
|
|
585
|
+
when LLM generation is disabled or encounters an unrecoverable error.
|
|
586
|
+
"""
|
|
587
|
+
llm_cfg = self.config.llm_config
|
|
588
|
+
if not llm_cfg:
|
|
589
|
+
return scan_plan
|
|
590
|
+
|
|
591
|
+
if not llm_cfg.get("enabled", True):
|
|
592
|
+
return scan_plan
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
from mannf.product.llm.config import LLMConfig
|
|
596
|
+
from mannf.product.llm.factory import get_provider
|
|
597
|
+
except ImportError:
|
|
598
|
+
result.warnings.append(
|
|
599
|
+
"LLM augmentation requested but the 'llm' module is not available."
|
|
600
|
+
)
|
|
601
|
+
return scan_plan
|
|
602
|
+
|
|
603
|
+
# Build LLMConfig from the llm_config dict, ignoring orchestration-specific keys
|
|
604
|
+
skip_keys = {"enabled", "priority_threshold", "n_cases"}
|
|
605
|
+
llm_init_kwargs = {k: v for k, v in llm_cfg.items() if k not in skip_keys}
|
|
606
|
+
try:
|
|
607
|
+
llm_config_obj = LLMConfig(**llm_init_kwargs)
|
|
608
|
+
provider = get_provider(llm_config_obj)
|
|
609
|
+
except Exception as exc: # noqa: BLE001
|
|
610
|
+
result.warnings.append(f"LLM augmentation: failed to initialise provider: {exc}")
|
|
611
|
+
return scan_plan
|
|
612
|
+
|
|
613
|
+
if not provider.is_available():
|
|
614
|
+
result.warnings.append(
|
|
615
|
+
f"LLM provider '{llm_config_obj.provider}' is not available "
|
|
616
|
+
"(API key missing?). Skipping edge-case generation."
|
|
617
|
+
)
|
|
618
|
+
return scan_plan
|
|
619
|
+
|
|
620
|
+
threshold: float = float(llm_cfg.get("priority_threshold", 0.7))
|
|
621
|
+
n_cases: int = int(llm_cfg.get("n_cases", 3))
|
|
622
|
+
|
|
623
|
+
added_total = 0
|
|
624
|
+
for target in scan_plan.targets:
|
|
625
|
+
if target.priority_score < threshold:
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
ep = target.endpoint
|
|
629
|
+
endpoint_info = {
|
|
630
|
+
"method": ep.method,
|
|
631
|
+
"path": ep.path,
|
|
632
|
+
"description": ep.description or ep.summary,
|
|
633
|
+
"parameters": ep.parameters,
|
|
634
|
+
"schema": ep.request_body or {},
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
try:
|
|
638
|
+
generated = await provider.generate_test_cases(endpoint_info, {}, n=n_cases)
|
|
639
|
+
except Exception as exc: # noqa: BLE001
|
|
640
|
+
logger.warning(
|
|
641
|
+
"LLM edge-case generation failed for %s %s: %s",
|
|
642
|
+
ep.method,
|
|
643
|
+
ep.path,
|
|
644
|
+
exc,
|
|
645
|
+
)
|
|
646
|
+
result.warnings.append(
|
|
647
|
+
f"LLM edge-case generation failed for {ep.method} {ep.path}: {exc}"
|
|
648
|
+
)
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
for tc_dict in generated:
|
|
652
|
+
if not isinstance(tc_dict, dict):
|
|
653
|
+
continue
|
|
654
|
+
tc = IngestedTestCase(
|
|
655
|
+
name=tc_dict.get(
|
|
656
|
+
"expected_behavior",
|
|
657
|
+
f"LLM-generated test case for {ep.path}",
|
|
658
|
+
),
|
|
659
|
+
description=str(tc_dict.get("inputs", "")),
|
|
660
|
+
expected_result=tc_dict.get("expected_behavior", ""),
|
|
661
|
+
priority="high",
|
|
662
|
+
tags=["llm-generated", tc_dict.get("test_type", "edge_case")],
|
|
663
|
+
source_ref="llm-generated",
|
|
664
|
+
metadata={
|
|
665
|
+
"generated_by": "llm",
|
|
666
|
+
"test_type": tc_dict.get("test_type", "edge_case"),
|
|
667
|
+
"inputs": tc_dict.get("inputs", {}),
|
|
668
|
+
},
|
|
669
|
+
)
|
|
670
|
+
target.test_cases.append(tc)
|
|
671
|
+
added_total += 1
|
|
672
|
+
|
|
673
|
+
if added_total > 0:
|
|
674
|
+
scan_plan.total_test_cases = sum(len(t.test_cases) for t in scan_plan.targets)
|
|
675
|
+
logger.info(
|
|
676
|
+
"LLM augmentation added %d edge-case test case(s) across %d high-priority endpoint(s).",
|
|
677
|
+
added_total,
|
|
678
|
+
sum(1 for t in scan_plan.targets if t.priority_score >= threshold),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
return scan_plan
|
|
682
|
+
|
|
683
|
+
# ------------------------------------------------------------------
|
|
684
|
+
# Phase 4: Scan
|
|
685
|
+
# ------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
async def _scan(
|
|
688
|
+
self,
|
|
689
|
+
scan_plan: ScanPlan,
|
|
690
|
+
base_url: str,
|
|
691
|
+
result: OrchestrationResult,
|
|
692
|
+
) -> SecurityReport | None:
|
|
693
|
+
"""Execute the security scan against the prioritized targets.
|
|
694
|
+
|
|
695
|
+
Parameters
|
|
696
|
+
----------
|
|
697
|
+
scan_plan:
|
|
698
|
+
The prioritized scan plan.
|
|
699
|
+
base_url:
|
|
700
|
+
API base URL.
|
|
701
|
+
result:
|
|
702
|
+
Mutable :class:`OrchestrationResult` for recording errors.
|
|
703
|
+
|
|
704
|
+
Returns
|
|
705
|
+
-------
|
|
706
|
+
SecurityReport | None
|
|
707
|
+
"""
|
|
708
|
+
scan_cfg = self.config.scan_config
|
|
709
|
+
enabled_checks = scan_cfg.get("enabled_checks")
|
|
710
|
+
severity_threshold = scan_cfg.get("severity_threshold", "low")
|
|
711
|
+
timeout = float(scan_cfg.get("timeout", 10.0))
|
|
712
|
+
belief_guided = bool(scan_cfg.get("belief_guided", True))
|
|
713
|
+
|
|
714
|
+
scanner = SecurityScanner(
|
|
715
|
+
base_url=base_url,
|
|
716
|
+
enabled_checks=enabled_checks,
|
|
717
|
+
severity_threshold=severity_threshold,
|
|
718
|
+
timeout=timeout,
|
|
719
|
+
belief_guided=belief_guided,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Build EndpointInfo list preserving the plan's ordering
|
|
723
|
+
endpoint_infos = [
|
|
724
|
+
self._transform_endpoint(t.endpoint, base_url) for t in scan_plan.targets
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
# Re-use the plan's risk scores as a risk_report seed so the scanner's
|
|
728
|
+
# own prioritization remains consistent with our plan ordering.
|
|
729
|
+
risk_report = [
|
|
730
|
+
{"path": t.endpoint.path, "risk_score": t.priority_score}
|
|
731
|
+
for t in scan_plan.targets
|
|
732
|
+
]
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
report: SecurityReport = await scanner.scan(
|
|
736
|
+
endpoints=endpoint_infos,
|
|
737
|
+
risk_report=risk_report,
|
|
738
|
+
)
|
|
739
|
+
except Exception as exc: # noqa: BLE001
|
|
740
|
+
result.errors.append(f"Scanner raised an exception: {exc}")
|
|
741
|
+
logger.exception("SecurityScanner raised an exception")
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
logger.info(
|
|
745
|
+
"Scan complete: %d finding(s) across %d endpoint(s).",
|
|
746
|
+
len(report.findings),
|
|
747
|
+
report.total_endpoints_scanned,
|
|
748
|
+
)
|
|
749
|
+
return report
|
|
750
|
+
|
|
751
|
+
# ------------------------------------------------------------------
|
|
752
|
+
# Phase 4b: Root cause analysis (Phase 10.3)
|
|
753
|
+
# ------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
async def _run_root_cause_analysis(
|
|
756
|
+
self,
|
|
757
|
+
scan_report: SecurityReport,
|
|
758
|
+
result: OrchestrationResult,
|
|
759
|
+
) -> None:
|
|
760
|
+
"""Best-effort root cause analysis for high/critical security findings.
|
|
761
|
+
|
|
762
|
+
Reads LLM config from ``self.config.llm_config``. Silently skips
|
|
763
|
+
when no LLM is configured or when there are no high/critical findings.
|
|
764
|
+
"""
|
|
765
|
+
llm_cfg = self.config.llm_config
|
|
766
|
+
if not llm_cfg:
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
# Only analyze high/critical findings
|
|
770
|
+
high_severity = {"critical", "high"}
|
|
771
|
+
findings_to_analyze = [
|
|
772
|
+
f for f in scan_report.findings if f.severity in high_severity
|
|
773
|
+
]
|
|
774
|
+
if not findings_to_analyze:
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
try:
|
|
778
|
+
from mannf.product.llm.config import LLMConfig # noqa: PLC0415
|
|
779
|
+
from mannf.product.llm.factory import get_provider # noqa: PLC0415
|
|
780
|
+
from mannf.product.llm.root_cause_service import ScanRootCauseAnalyzer # noqa: PLC0415
|
|
781
|
+
|
|
782
|
+
config = LLMConfig(
|
|
783
|
+
provider=llm_cfg.get("provider", "openai"),
|
|
784
|
+
model=llm_cfg.get("model", ""),
|
|
785
|
+
api_key=llm_cfg.get("api_key"),
|
|
786
|
+
)
|
|
787
|
+
llm = get_provider(config)
|
|
788
|
+
quota = int(llm_cfg.get("root_cause_quota", -1))
|
|
789
|
+
analyzer = ScanRootCauseAnalyzer(llm_provider=llm, quota=quota)
|
|
790
|
+
|
|
791
|
+
failures = [
|
|
792
|
+
{
|
|
793
|
+
"id": f.check_id,
|
|
794
|
+
"type": "security",
|
|
795
|
+
"url": f.endpoint,
|
|
796
|
+
"error": f.description,
|
|
797
|
+
"severity": f.severity,
|
|
798
|
+
"check_id": f.check_id,
|
|
799
|
+
"title": f.title,
|
|
800
|
+
"cwe_id": f.cwe_id,
|
|
801
|
+
"evidence": f.evidence,
|
|
802
|
+
"remediation": f.remediation,
|
|
803
|
+
}
|
|
804
|
+
for f in findings_to_analyze
|
|
805
|
+
]
|
|
806
|
+
|
|
807
|
+
suggestions = await analyzer.analyze_failures(
|
|
808
|
+
failures,
|
|
809
|
+
{"scan_id": scan_report.scan_id, "scan_type": "security"},
|
|
810
|
+
)
|
|
811
|
+
result.root_cause_suggestions = [s.model_dump() for s in suggestions]
|
|
812
|
+
logger.info(
|
|
813
|
+
"IngestOrchestrator: root cause analysis produced %d suggestion(s)",
|
|
814
|
+
len(suggestions),
|
|
815
|
+
)
|
|
816
|
+
except Exception as exc: # noqa: BLE001
|
|
817
|
+
logger.debug("IngestOrchestrator: root cause analysis skipped — %s", exc)
|
|
818
|
+
|
|
819
|
+
# ------------------------------------------------------------------
|
|
820
|
+
# Phase 5: Learn (feedback)
|
|
821
|
+
# ------------------------------------------------------------------
|
|
822
|
+
|
|
823
|
+
def _compute_feedback(
|
|
824
|
+
self,
|
|
825
|
+
scan_plan: ScanPlan,
|
|
826
|
+
scan_report: SecurityReport,
|
|
827
|
+
) -> ScanFeedback:
|
|
828
|
+
"""Compute learning feedback from scan results.
|
|
829
|
+
|
|
830
|
+
Calculates priority accuracy — did the high-priority endpoints
|
|
831
|
+
yield more findings than the low-priority ones?
|
|
832
|
+
|
|
833
|
+
Parameters
|
|
834
|
+
----------
|
|
835
|
+
scan_plan:
|
|
836
|
+
The scan plan used.
|
|
837
|
+
scan_report:
|
|
838
|
+
The security report from the scan.
|
|
839
|
+
|
|
840
|
+
Returns
|
|
841
|
+
-------
|
|
842
|
+
ScanFeedback
|
|
843
|
+
"""
|
|
844
|
+
# Build a map from endpoint key (METHOD /path) → finding count
|
|
845
|
+
findings_by_endpoint: dict[str, int] = {}
|
|
846
|
+
for finding in scan_report.findings:
|
|
847
|
+
findings_by_endpoint[finding.endpoint] = (
|
|
848
|
+
findings_by_endpoint.get(finding.endpoint, 0) + 1
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
per_target: list[dict] = []
|
|
852
|
+
suggested_adjustments: dict[str, str] = {}
|
|
853
|
+
|
|
854
|
+
for target in scan_plan.targets:
|
|
855
|
+
ep_key = target.endpoint_key()
|
|
856
|
+
finding_count = findings_by_endpoint.get(ep_key, 0)
|
|
857
|
+
|
|
858
|
+
# Check if any test case expected an outcome that was found/missed
|
|
859
|
+
outcome_matched = False
|
|
860
|
+
if target.test_cases:
|
|
861
|
+
for tc in target.test_cases:
|
|
862
|
+
expected = tc.expected_result.lower()
|
|
863
|
+
if expected and finding_count > 0 and any(
|
|
864
|
+
kw in expected for kw in ("fail", "error", "vulnerability", "issue", "finding")
|
|
865
|
+
):
|
|
866
|
+
outcome_matched = True
|
|
867
|
+
break
|
|
868
|
+
elif not expected and finding_count == 0:
|
|
869
|
+
outcome_matched = True
|
|
870
|
+
|
|
871
|
+
per_target.append({
|
|
872
|
+
"endpoint": ep_key,
|
|
873
|
+
"priority_score": target.priority_score,
|
|
874
|
+
"findings_count": finding_count,
|
|
875
|
+
"matched_test_cases": [tc.name for tc in target.test_cases],
|
|
876
|
+
"outcome_matched": outcome_matched,
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
# Suggest a boost for endpoints that had unexpected findings
|
|
880
|
+
# (high findings but low priority → should be ranked higher next time)
|
|
881
|
+
if finding_count > 0 and target.priority_score < 0.5:
|
|
882
|
+
suggested_adjustments[ep_key] = "increase_priority"
|
|
883
|
+
# Suggest a decrease for high-priority endpoints with no findings
|
|
884
|
+
elif finding_count == 0 and target.priority_score > 0.7:
|
|
885
|
+
suggested_adjustments[ep_key] = "decrease_priority"
|
|
886
|
+
|
|
887
|
+
# Priority accuracy: fraction of total findings from the top-50% priority targets
|
|
888
|
+
total_findings = len(scan_report.findings)
|
|
889
|
+
if total_findings > 0 and scan_plan.targets:
|
|
890
|
+
n_top_half = max(1, len(scan_plan.targets) // 2)
|
|
891
|
+
top_half_keys = {
|
|
892
|
+
t.endpoint_key() for t in scan_plan.targets[:n_top_half]
|
|
893
|
+
}
|
|
894
|
+
top_half_findings = sum(
|
|
895
|
+
count
|
|
896
|
+
for ep_key, count in findings_by_endpoint.items()
|
|
897
|
+
if ep_key in top_half_keys
|
|
898
|
+
)
|
|
899
|
+
priority_accuracy = top_half_findings / total_findings
|
|
900
|
+
else:
|
|
901
|
+
priority_accuracy = 1.0 if total_findings == 0 else 0.0
|
|
902
|
+
|
|
903
|
+
return ScanFeedback(
|
|
904
|
+
scan_id=scan_report.scan_id,
|
|
905
|
+
total_targets=len(scan_plan.targets),
|
|
906
|
+
findings_count=total_findings,
|
|
907
|
+
per_target=per_target,
|
|
908
|
+
priority_accuracy=priority_accuracy,
|
|
909
|
+
suggested_adjustments=suggested_adjustments,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
async def _persist_feedback(
|
|
913
|
+
self,
|
|
914
|
+
feedback: ScanFeedback,
|
|
915
|
+
result: OrchestrationResult,
|
|
916
|
+
) -> None:
|
|
917
|
+
"""Optionally save feedback JSON to disk.
|
|
918
|
+
|
|
919
|
+
Parameters
|
|
920
|
+
----------
|
|
921
|
+
feedback:
|
|
922
|
+
The feedback to persist.
|
|
923
|
+
result:
|
|
924
|
+
Mutable :class:`OrchestrationResult` for recording warnings.
|
|
925
|
+
"""
|
|
926
|
+
save_path = self.config.feedback_config.get("save_path")
|
|
927
|
+
enabled = self.config.feedback_config.get("enabled", True)
|
|
928
|
+
if not save_path or not enabled:
|
|
929
|
+
return
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
path = Path(save_path)
|
|
933
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
934
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
935
|
+
json.dump(feedback.to_dict(), fh, indent=2)
|
|
936
|
+
logger.info("Feedback saved to %s", save_path)
|
|
937
|
+
except Exception as exc: # noqa: BLE001
|
|
938
|
+
result.warnings.append(f"Could not save feedback to '{save_path}': {exc}")
|
|
939
|
+
logger.warning("Could not save feedback: %s", exc)
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# Helpers
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
def _build_source(self) -> IngestorSource:
|
|
946
|
+
"""Build an :class:`IngestorSource` from config."""
|
|
947
|
+
fmt = (
|
|
948
|
+
self.config.source_format
|
|
949
|
+
or self.config.ingestor_name
|
|
950
|
+
or _guess_format(self.config.source_path or self.config.source_url or "")
|
|
951
|
+
)
|
|
952
|
+
return IngestorSource(
|
|
953
|
+
format=fmt or "",
|
|
954
|
+
path=self.config.source_path,
|
|
955
|
+
url=self.config.source_url,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _guess_format(source: str) -> str | None:
|
|
960
|
+
"""Heuristically guess the ingestor format from a file path or URL.
|
|
961
|
+
|
|
962
|
+
Parameters
|
|
963
|
+
----------
|
|
964
|
+
source:
|
|
965
|
+
A file path or URL string.
|
|
966
|
+
|
|
967
|
+
Returns
|
|
968
|
+
-------
|
|
969
|
+
str | None
|
|
970
|
+
A format string (e.g. ``"openapi"``, ``"har"``) or ``None``.
|
|
971
|
+
"""
|
|
972
|
+
lower = source.lower()
|
|
973
|
+
if lower.endswith(".har"):
|
|
974
|
+
return "har"
|
|
975
|
+
if lower.endswith(".feature"):
|
|
976
|
+
return "gherkin"
|
|
977
|
+
if lower.endswith(".bgstm"):
|
|
978
|
+
return "bgstm"
|
|
979
|
+
if "postman" in lower or lower.endswith("collection.json"):
|
|
980
|
+
return "postman"
|
|
981
|
+
if lower.endswith(".graphql") or lower.endswith(".gql") or "graphql" in lower:
|
|
982
|
+
return "graphql"
|
|
983
|
+
if lower.endswith(".mitmproxy") or lower.endswith(".pcap"):
|
|
984
|
+
return "traffic"
|
|
985
|
+
if lower.endswith(".yaml") or lower.endswith(".yml") or lower.endswith(".json"):
|
|
986
|
+
return "openapi"
|
|
987
|
+
return None
|