nat-engine 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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