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,1019 @@
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
+ """cURL/Batch Import ingestor plugin.
7
+
8
+ Parses cURL commands (individual or batch files containing multiple cURL
9
+ commands) and extracts endpoints, request details, and test case intents.
10
+
11
+ Common sources:
12
+ * Browser dev tools "Copy as cURL"
13
+ * Postman "Copy as cURL" exports
14
+ * API documentation cURL examples
15
+ * Shell history / saved cURL scripts
16
+
17
+ Supported cURL flags
18
+ --------------------
19
+ ``-X`` / ``--request``
20
+ HTTP method (default ``GET``, or ``POST`` when ``-d`` / ``--data`` is present).
21
+ ``-H`` / ``--header``
22
+ Request header (repeatable).
23
+ ``-d`` / ``--data`` / ``--data-raw`` / ``--data-binary`` / ``--data-urlencode``
24
+ Request body.
25
+ ``-F`` / ``--form``
26
+ Multipart form field.
27
+ ``-u`` / ``--user``
28
+ Basic auth (``user:password``).
29
+ ``-k`` / ``--insecure``
30
+ Skip TLS verification (stored as metadata).
31
+ ``-L`` / ``--location``
32
+ Follow redirects (stored as metadata).
33
+ ``--compressed``
34
+ Accept compressed response (stored as metadata).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import logging
41
+ import re
42
+ import shlex
43
+ from pathlib import Path
44
+ from typing import Any
45
+ from urllib.parse import urlparse, parse_qs, urlencode, urljoin
46
+
47
+ from mannf.product.ingestors.base import IngestorPlugin
48
+ from mannf.product.ingestors.models import (
49
+ IngestedEndpoint,
50
+ IngestedTestCase,
51
+ IngestorSource,
52
+ IngestResult,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Constants
59
+ # ---------------------------------------------------------------------------
60
+
61
+ # Headers that indicate authentication requirements
62
+ _AUTH_HEADERS: frozenset[str] = frozenset({
63
+ "authorization",
64
+ "cookie",
65
+ "x-api-key",
66
+ "x-auth-token",
67
+ "x-access-token",
68
+ "x-csrf-token",
69
+ })
70
+
71
+ # Mutation HTTP methods
72
+ _MUTATION_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
73
+
74
+ # Auth keywords in path
75
+ _AUTH_PATH_KEYWORDS: tuple[str, ...] = (
76
+ "/auth", "/login", "/token", "/logout", "/password",
77
+ "/oauth", "/signin", "/signup", "/register",
78
+ )
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # cURL argument parsing
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _split_curl_commands(text: str) -> list[str]:
86
+ """Split *text* into individual cURL command strings.
87
+
88
+ Handles:
89
+ * Line continuation via ``\\`` at end of line (including Windows ``\\r\\n``).
90
+ * Comment lines starting with ``#``.
91
+ * Empty lines between commands.
92
+ * Mixed single-line and multi-line cURL commands.
93
+
94
+ Parameters
95
+ ----------
96
+ text:
97
+ Raw content that may contain one or more cURL commands.
98
+
99
+ Returns
100
+ -------
101
+ list[str]
102
+ List of raw cURL command strings, each on a single (joined) line.
103
+ """
104
+ # Normalise Windows line endings
105
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
106
+
107
+ # First, join lines that end with backslash (line continuation)
108
+ joined_lines: list[str] = []
109
+ current = ""
110
+ for line in text.split("\n"):
111
+ stripped = line.rstrip()
112
+ if stripped.endswith("\\"):
113
+ # Remove the trailing backslash and accumulate
114
+ current += stripped[:-1] + " "
115
+ else:
116
+ current += stripped
117
+ joined_lines.append(current)
118
+ current = ""
119
+ if current.strip():
120
+ joined_lines.append(current)
121
+
122
+ # Now split into individual curl command blocks
123
+ commands: list[str] = []
124
+ current_cmd = ""
125
+ for line in joined_lines:
126
+ stripped = line.strip()
127
+ # Skip comment lines and empty lines
128
+ if not stripped or stripped.startswith("#"):
129
+ if current_cmd.strip():
130
+ commands.append(current_cmd.strip())
131
+ current_cmd = ""
132
+ continue
133
+ # If this line starts a new curl command and we already have one
134
+ if re.match(r"^curl\s", stripped, re.IGNORECASE):
135
+ if current_cmd.strip():
136
+ commands.append(current_cmd.strip())
137
+ current_cmd = stripped
138
+ else:
139
+ # Continuation of previous command (shouldn't happen after join, but be safe)
140
+ current_cmd = (current_cmd + " " + stripped).strip()
141
+
142
+ if current_cmd.strip():
143
+ commands.append(current_cmd.strip())
144
+
145
+ return [cmd for cmd in commands if cmd.strip()]
146
+
147
+
148
+ def _tokenise(command: str) -> list[str]:
149
+ """Split a cURL command string into tokens, handling quoted strings.
150
+
151
+ Uses :mod:`shlex` with POSIX mode for robust handling of quoted strings,
152
+ escaped characters, and spaces within arguments.
153
+
154
+ Parameters
155
+ ----------
156
+ command:
157
+ Single-line cURL command string.
158
+
159
+ Returns
160
+ -------
161
+ list[str]
162
+ List of tokens.
163
+
164
+ Raises
165
+ ------
166
+ ValueError
167
+ If the command string cannot be tokenised (e.g., unmatched quotes).
168
+ """
169
+ try:
170
+ lex = shlex.shlex(command, posix=True)
171
+ lex.whitespace_split = True
172
+ lex.whitespace = " \t"
173
+ return list(lex)
174
+ except ValueError:
175
+ # Fallback: basic split on whitespace, stripping surrounding quotes
176
+ tokens: list[str] = []
177
+ for part in re.split(r"\s+", command):
178
+ if (part.startswith('"') and part.endswith('"')) or \
179
+ (part.startswith("'") and part.endswith("'")):
180
+ part = part[1:-1]
181
+ tokens.append(part)
182
+ return [t for t in tokens if t]
183
+
184
+
185
+ def _parse_curl_command(command: str) -> dict[str, Any]:
186
+ """Parse a single cURL command string into a structured dict.
187
+
188
+ Parameters
189
+ ----------
190
+ command:
191
+ Raw cURL command string (single line, after joining continuations).
192
+
193
+ Returns
194
+ -------
195
+ dict
196
+ Parsed fields:
197
+
198
+ * ``url`` (str | None)
199
+ * ``method`` (str)
200
+ * ``headers`` (list[tuple[str, str]])
201
+ * ``body`` (str | None)
202
+ * ``form_data`` (list[str])
203
+ * ``basic_auth`` (str | None) — raw ``user:password`` string
204
+ * ``insecure`` (bool)
205
+ * ``follow_redirects`` (bool)
206
+ * ``compressed`` (bool)
207
+ * ``errors`` (list[str])
208
+ """
209
+ result: dict[str, Any] = {
210
+ "url": None,
211
+ "method": None, # None means "not specified yet"
212
+ "headers": [],
213
+ "body": None,
214
+ "form_data": [],
215
+ "basic_auth": None,
216
+ "insecure": False,
217
+ "follow_redirects": False,
218
+ "compressed": False,
219
+ "errors": [],
220
+ }
221
+
222
+ try:
223
+ tokens = _tokenise(command)
224
+ except ValueError as exc:
225
+ result["errors"].append(f"Failed to tokenise command: {exc}")
226
+ return result
227
+
228
+ if not tokens:
229
+ result["errors"].append("Empty command.")
230
+ return result
231
+
232
+ # Strip leading 'curl' token (case-insensitive)
233
+ idx = 0
234
+ if tokens[idx].lower() == "curl":
235
+ idx += 1
236
+
237
+ def _next_arg(i: int) -> tuple[str | None, int]:
238
+ """Return (tokens[i+1], i+2) or (None, i+1) if out of bounds."""
239
+ if i + 1 < len(tokens):
240
+ return tokens[i + 1], i + 2
241
+ return None, i + 1
242
+
243
+ while idx < len(tokens):
244
+ tok = tokens[idx]
245
+
246
+ # --- Short flags that take a value ---
247
+ if tok in ("-X", "--request"):
248
+ val, idx = _next_arg(idx)
249
+ if val:
250
+ result["method"] = val.upper()
251
+ else:
252
+ result["errors"].append(f"Flag {tok} requires a value.")
253
+
254
+ elif tok in ("-H", "--header"):
255
+ val, idx = _next_arg(idx)
256
+ if val:
257
+ # Split "Name: Value" into (name, value)
258
+ colon_pos = val.find(":")
259
+ if colon_pos >= 0:
260
+ h_name = val[:colon_pos].strip()
261
+ h_val = val[colon_pos + 1:].strip()
262
+ else:
263
+ h_name = val
264
+ h_val = ""
265
+ result["headers"].append((h_name, h_val))
266
+ else:
267
+ result["errors"].append(f"Flag {tok} requires a value.")
268
+
269
+ elif tok in ("-d", "--data", "--data-raw", "--data-binary", "--data-urlencode"):
270
+ val, idx = _next_arg(idx)
271
+ if val is not None:
272
+ if result["body"] is None:
273
+ result["body"] = val
274
+ else:
275
+ # Multiple -d flags: concatenate with &
276
+ result["body"] = result["body"] + "&" + val
277
+ else:
278
+ result["errors"].append(f"Flag {tok} requires a value.")
279
+
280
+ elif tok in ("-F", "--form"):
281
+ val, idx = _next_arg(idx)
282
+ if val is not None:
283
+ result["form_data"].append(val)
284
+ else:
285
+ result["errors"].append(f"Flag {tok} requires a value.")
286
+
287
+ elif tok in ("-u", "--user"):
288
+ val, idx = _next_arg(idx)
289
+ if val:
290
+ result["basic_auth"] = val
291
+ else:
292
+ result["errors"].append(f"Flag {tok} requires a value.")
293
+
294
+ # --- Boolean flags ---
295
+ elif tok in ("-k", "--insecure"):
296
+ result["insecure"] = True
297
+ idx += 1
298
+
299
+ elif tok in ("-L", "--location"):
300
+ result["follow_redirects"] = True
301
+ idx += 1
302
+
303
+ elif tok == "--compressed":
304
+ result["compressed"] = True
305
+ idx += 1
306
+
307
+ # --- Flags to ignore (common curl flags that don't affect the request model) ---
308
+ elif tok in ("-s", "--silent", "-S", "--show-error", "-v", "--verbose",
309
+ "-i", "--include", "-I", "--head", "-o", "--output",
310
+ "-w", "--write-out", "--connect-timeout", "--max-time",
311
+ "-m", "--retry", "--retry-delay", "--retry-max-time",
312
+ "--http1.0", "--http1.1", "--http2", "--http3",
313
+ "--tlsv1", "--tlsv1.0", "--tlsv1.1", "--tlsv1.2", "--tlsv1.3",
314
+ "--cacert", "--capath", "--cert", "--key", "--pass",
315
+ "--ssl", "--ssl-reqd"):
316
+ # Some of these take a value, some don't — try to skip intelligently
317
+ flags_with_value = {
318
+ "-o", "--output", "-w", "--write-out",
319
+ "--connect-timeout", "--max-time", "-m",
320
+ "--retry", "--retry-delay", "--retry-max-time",
321
+ "--cacert", "--capath", "--cert", "--key", "--pass",
322
+ }
323
+ if tok in flags_with_value:
324
+ _, idx = _next_arg(idx)
325
+ else:
326
+ idx += 1
327
+
328
+ # --- Long flags with = syntax: e.g. --header="Content-Type: application/json" ---
329
+ elif tok.startswith("--") and "=" in tok:
330
+ flag, _, value = tok.partition("=")
331
+ # Re-inject as separate token pair for re-processing
332
+ tokens = tokens[:idx] + [flag, value] + tokens[idx + 1:]
333
+ # Don't advance idx — re-process from the same position
334
+
335
+ # --- Short flag with no space: e.g. -XPOST ---
336
+ elif re.match(r"^-[A-Za-z].+", tok) and not tok.startswith("--"):
337
+ short_flag = tok[:2]
338
+ remainder = tok[2:]
339
+ tokens = tokens[:idx] + [short_flag, remainder] + tokens[idx + 1:]
340
+ # Don't advance — re-process
341
+
342
+ # --- URL (not a flag) ---
343
+ elif not tok.startswith("-"):
344
+ # Could be the URL; strip surrounding quotes if any
345
+ url_candidate = tok
346
+ if (url_candidate.startswith('"') and url_candidate.endswith('"')) or \
347
+ (url_candidate.startswith("'") and url_candidate.endswith("'")):
348
+ url_candidate = url_candidate[1:-1]
349
+ if result["url"] is None:
350
+ result["url"] = url_candidate
351
+ # If we already have a URL, this might be a positional arg to ignore
352
+ idx += 1
353
+
354
+ else:
355
+ # Unknown flag — skip it (and its potential value if it looks like a value flag)
356
+ logger.debug("CurlIngestor: unknown flag %r — skipping.", tok)
357
+ idx += 1
358
+
359
+ # --- Infer method from context ---
360
+ if result["method"] is None:
361
+ if result["body"] is not None or result["form_data"]:
362
+ result["method"] = "POST"
363
+ else:
364
+ result["method"] = "GET"
365
+
366
+ return result
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # URL / header helpers
371
+ # ---------------------------------------------------------------------------
372
+
373
+
374
+ def _parse_url(raw_url: str, base_url_override: str | None = None) -> dict[str, Any]:
375
+ """Parse *raw_url* into components.
376
+
377
+ Parameters
378
+ ----------
379
+ raw_url:
380
+ The URL string from the cURL command (may be relative or absolute).
381
+ base_url_override:
382
+ If set, replace the scheme+host of the URL with this value.
383
+
384
+ Returns
385
+ -------
386
+ dict
387
+ Keys: ``base_url``, ``path``, ``query_params`` (list[dict]), ``full_url``.
388
+ """
389
+ url = raw_url.strip()
390
+ # Strip surrounding quotes
391
+ if (url.startswith('"') and url.endswith('"')) or \
392
+ (url.startswith("'") and url.endswith("'")):
393
+ url = url[1:-1]
394
+
395
+ if base_url_override:
396
+ # Replace the scheme+host with the override base URL
397
+ try:
398
+ parsed_orig = urlparse(url)
399
+ base_parsed = urlparse(base_url_override.rstrip("/"))
400
+ url = f"{base_parsed.scheme}://{base_parsed.netloc}{parsed_orig.path}"
401
+ if parsed_orig.query:
402
+ url += f"?{parsed_orig.query}"
403
+ if parsed_orig.fragment:
404
+ url += f"#{parsed_orig.fragment}"
405
+ except Exception: # noqa: BLE001
406
+ pass # use original URL
407
+
408
+ try:
409
+ parsed = urlparse(url)
410
+ except Exception: # noqa: BLE001
411
+ return {
412
+ "base_url": "",
413
+ "path": url,
414
+ "query_params": [],
415
+ "full_url": url,
416
+ }
417
+
418
+ base_url = ""
419
+ if parsed.scheme and parsed.netloc:
420
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
421
+
422
+ path = parsed.path or "/"
423
+
424
+ query_params: list[dict[str, Any]] = []
425
+ if parsed.query:
426
+ for name, values in parse_qs(parsed.query, keep_blank_values=True).items():
427
+ for value in values:
428
+ query_params.append({
429
+ "name": name,
430
+ "in": "query",
431
+ "required": False,
432
+ "value": value,
433
+ })
434
+
435
+ return {
436
+ "base_url": base_url,
437
+ "path": path,
438
+ "query_params": query_params,
439
+ "full_url": url,
440
+ }
441
+
442
+
443
+ def _detect_auth_requirements(
444
+ headers: list[tuple[str, str]],
445
+ basic_auth: str | None,
446
+ ) -> list[str]:
447
+ """Return auth scheme names detected from headers and basic auth.
448
+
449
+ Parameters
450
+ ----------
451
+ headers:
452
+ List of (name, value) header tuples.
453
+ basic_auth:
454
+ Raw ``user:password`` string from ``-u``, or ``None``.
455
+
456
+ Returns
457
+ -------
458
+ list[str]
459
+ E.g. ``["bearer"]``, ``["basic"]``, ``["apikey"]``.
460
+ """
461
+ schemes: list[str] = []
462
+ for name, value in headers:
463
+ name_lower = name.lower().strip()
464
+ if name_lower == "authorization":
465
+ parts = value.split(None, 1)
466
+ scheme = parts[0].lower() if parts else "bearer"
467
+ if scheme not in schemes:
468
+ schemes.append(scheme)
469
+ elif name_lower == "cookie":
470
+ if "cookie" not in schemes:
471
+ schemes.append("cookie")
472
+ elif name_lower in _AUTH_HEADERS:
473
+ if name_lower not in schemes:
474
+ schemes.append(name_lower)
475
+ if basic_auth:
476
+ if "basic" not in schemes:
477
+ schemes.append("basic")
478
+ return schemes
479
+
480
+
481
+ def _detect_content_type(
482
+ headers: list[tuple[str, str]],
483
+ body: str | None,
484
+ form_data: list[str],
485
+ ) -> str:
486
+ """Infer the content type from headers or body.
487
+
488
+ Parameters
489
+ ----------
490
+ headers:
491
+ Request headers as (name, value) pairs.
492
+ body:
493
+ Raw body string, if any.
494
+ form_data:
495
+ Form data fields, if any.
496
+
497
+ Returns
498
+ -------
499
+ str
500
+ A MIME type string, e.g. ``"application/json"``.
501
+ """
502
+ for name, value in headers:
503
+ if name.lower().strip() == "content-type":
504
+ return value.split(";")[0].strip()
505
+
506
+ if form_data:
507
+ return "multipart/form-data"
508
+
509
+ if body:
510
+ stripped = body.strip()
511
+ if stripped.startswith("{") or stripped.startswith("["):
512
+ return "application/json"
513
+ if "=" in stripped and "&" in stripped:
514
+ return "application/x-www-form-urlencoded"
515
+
516
+ return ""
517
+
518
+
519
+ def _build_request_body(
520
+ body: str | None,
521
+ form_data: list[str],
522
+ content_type: str,
523
+ ) -> dict[str, Any] | None:
524
+ """Build a structured request body representation.
525
+
526
+ Parameters
527
+ ----------
528
+ body:
529
+ Raw body string.
530
+ form_data:
531
+ Form fields from ``-F``.
532
+ content_type:
533
+ Detected content type.
534
+
535
+ Returns
536
+ -------
537
+ dict | None
538
+ Structured body dict, or ``None`` if no body.
539
+ """
540
+ if form_data:
541
+ fields: list[dict[str, str]] = []
542
+ for field in form_data:
543
+ eq_pos = field.find("=")
544
+ if eq_pos >= 0:
545
+ fname = field[:eq_pos].strip()
546
+ fval = field[eq_pos + 1:].strip()
547
+ # Strip surrounding quotes from value
548
+ if (fval.startswith('"') and fval.endswith('"')) or \
549
+ (fval.startswith("'") and fval.endswith("'")):
550
+ fval = fval[1:-1]
551
+ else:
552
+ fname = field
553
+ fval = ""
554
+ fields.append({"name": fname, "value": fval})
555
+ return {"mimeType": "multipart/form-data", "params": fields}
556
+
557
+ if body is None:
558
+ return None
559
+
560
+ result: dict[str, Any] = {"mimeType": content_type or "application/octet-stream"}
561
+
562
+ if "application/json" in content_type:
563
+ try:
564
+ result["json"] = json.loads(body)
565
+ except (json.JSONDecodeError, ValueError):
566
+ result["text"] = body
567
+ else:
568
+ result["text"] = body
569
+
570
+ return result
571
+
572
+
573
+ def _priority_for_endpoint(
574
+ method: str,
575
+ path: str,
576
+ auth_requirements: list[str],
577
+ ) -> str:
578
+ """Compute a test priority for an endpoint.
579
+
580
+ Rules (highest wins):
581
+
582
+ * Path contains auth-related keywords → ``"critical"``
583
+ * Endpoint requires authentication → ``"high"``
584
+ * Mutation method (POST/PUT/PATCH/DELETE) → ``"medium"``
585
+ * Everything else → ``"low"``
586
+
587
+ Parameters
588
+ ----------
589
+ method:
590
+ HTTP method.
591
+ path:
592
+ URL path.
593
+ auth_requirements:
594
+ Auth scheme list.
595
+
596
+ Returns
597
+ -------
598
+ str
599
+ One of ``"critical"``, ``"high"``, ``"medium"``, ``"low"``.
600
+ """
601
+ path_lower = path.lower()
602
+ if any(kw in path_lower for kw in _AUTH_PATH_KEYWORDS):
603
+ return "critical"
604
+ if auth_requirements:
605
+ return "high"
606
+ if method.upper() in _MUTATION_METHODS:
607
+ return "medium"
608
+ return "low"
609
+
610
+
611
+ def _endpoint_key(method: str, path: str) -> str:
612
+ """Return a deduplication key for a *method* + *path* pair."""
613
+ return f"{method.upper()}:{path}"
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # Test case generation
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ def _generate_test_cases(endpoint: IngestedEndpoint) -> list[IngestedTestCase]:
622
+ """Generate test cases for a single *endpoint*.
623
+
624
+ Always generates:
625
+
626
+ * A **positive / replay** test case.
627
+ * A **missing auth** security test case when auth is required.
628
+ * A **parameter tampering** test case when query or path parameters exist.
629
+
630
+ Parameters
631
+ ----------
632
+ endpoint:
633
+ The endpoint to generate tests for.
634
+
635
+ Returns
636
+ -------
637
+ list[IngestedTestCase]
638
+ Generated test cases.
639
+ """
640
+ test_cases: list[IngestedTestCase] = []
641
+ method = endpoint.method
642
+ path = endpoint.path
643
+ priority = endpoint.metadata.get("priority", "medium")
644
+ source_ref = f"{method} {path}"
645
+
646
+ # --- Positive / replay ---
647
+ test_cases.append(IngestedTestCase(
648
+ name=f"[{method}] {path} — positive replay",
649
+ description=(
650
+ f"Replay the original {method} request to {path} and expect a "
651
+ f"successful response."
652
+ ),
653
+ steps=[
654
+ {"action": "send_request", "method": method, "path": path},
655
+ {"action": "assert_status_in", "expected": [200, 201, 202, 204]},
656
+ ],
657
+ expected_result="HTTP 2xx — successful response",
658
+ priority=priority,
659
+ tags=["curl", "replay", method.lower()],
660
+ source_ref=source_ref,
661
+ metadata={"test_type": "positive"},
662
+ ))
663
+
664
+ # --- Missing auth ---
665
+ if endpoint.auth_requirements:
666
+ test_cases.append(IngestedTestCase(
667
+ name=f"[{method}] {path} — missing auth",
668
+ description=(
669
+ f"Send {method} {path} without the required authentication "
670
+ f"headers and expect 401 or 403."
671
+ ),
672
+ steps=[
673
+ {
674
+ "action": "send_request",
675
+ "method": method,
676
+ "path": path,
677
+ "omit_headers": list(endpoint.auth_requirements),
678
+ },
679
+ {"action": "assert_status_in", "expected": [401, 403]},
680
+ ],
681
+ expected_result="HTTP 401 or 403 — Unauthorized / Forbidden",
682
+ priority=priority,
683
+ tags=["curl", "security", "auth", method.lower()],
684
+ source_ref=source_ref,
685
+ metadata={"test_type": "security_missing_auth"},
686
+ ))
687
+
688
+ # --- Parameter tampering ---
689
+ has_params = any(
690
+ p.get("in") in ("query", "path")
691
+ for p in endpoint.parameters
692
+ )
693
+ if has_params:
694
+ test_cases.append(IngestedTestCase(
695
+ name=f"[{method}] {path} — parameter manipulation",
696
+ description=(
697
+ f"Send {method} {path} with manipulated/unexpected parameter "
698
+ f"values and expect 400, 404, or 422."
699
+ ),
700
+ steps=[
701
+ {
702
+ "action": "send_request",
703
+ "method": method,
704
+ "path": path,
705
+ "tamper_params": True,
706
+ },
707
+ {"action": "assert_status_in", "expected": [400, 404, 422]},
708
+ ],
709
+ expected_result="HTTP 400, 404, or 422 — invalid parameter rejected",
710
+ priority=priority,
711
+ tags=["curl", "security", "parameter_manipulation", method.lower()],
712
+ source_ref=source_ref,
713
+ metadata={"test_type": "security_parameter_manipulation"},
714
+ ))
715
+
716
+ return test_cases
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # CurlIngestor
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ class CurlIngestor(IngestorPlugin):
725
+ """Ingestor plugin for cURL commands (individual or batch files).
726
+
727
+ Parses cURL commands copied from browser dev tools, API documentation,
728
+ Postman exports, or shell history and produces normalised endpoint and
729
+ test-case objects.
730
+
731
+ Config keys
732
+ -----------
733
+ base_url_override : str, optional
734
+ Replace the base URL (scheme + host) for all commands.
735
+ default_headers : dict[str, str], optional
736
+ Headers to add to every parsed request.
737
+ ignore_insecure : bool, default False
738
+ If ``True``, suppress warnings for ``-k`` / ``--insecure`` flags.
739
+ """
740
+
741
+ name = "curl"
742
+ display_name = "cURL / Batch cURL"
743
+ description = (
744
+ "Ingest cURL commands (individual or batch files) to generate "
745
+ "structured scan targets and test cases."
746
+ )
747
+ supported_extensions = [".sh", ".curl", ".txt"]
748
+ supported_formats = ["curl"]
749
+
750
+ # ------------------------------------------------------------------
751
+ # can_handle
752
+ # ------------------------------------------------------------------
753
+
754
+ def can_handle(self, source: IngestorSource) -> bool:
755
+ """Return ``True`` for cURL sources.
756
+
757
+ Detection rules (in order):
758
+
759
+ 1. ``source.format`` is ``"curl"`` or ``source.metadata.get("format_hint")``
760
+ is ``"curl"``.
761
+ 2. ``source.raw_content`` starts with ``curl `` (case-insensitive) or
762
+ contains a line starting with ``curl ``.
763
+
764
+ Parameters
765
+ ----------
766
+ source:
767
+ The source to evaluate.
768
+
769
+ Returns
770
+ -------
771
+ bool
772
+ ``True`` when the source appears to contain cURL commands.
773
+ """
774
+ if source.format == "curl":
775
+ return True
776
+ if source.metadata.get("format_hint") == "curl":
777
+ return True
778
+
779
+ if source.raw_content:
780
+ content = source.raw_content.strip()
781
+ # Fast path: content starts with "curl "
782
+ if re.match(r"^curl\s", content, re.IGNORECASE):
783
+ return True
784
+ # Scan lines for any that start with "curl "
785
+ for line in content.splitlines():
786
+ if re.match(r"^\s*curl\s", line, re.IGNORECASE):
787
+ return True
788
+
789
+ return False
790
+
791
+ # ------------------------------------------------------------------
792
+ # validate_config
793
+ # ------------------------------------------------------------------
794
+
795
+ def validate_config(self, config: dict) -> list[str]:
796
+ """Validate cURL ingestor configuration.
797
+
798
+ Parameters
799
+ ----------
800
+ config:
801
+ Configuration dict.
802
+
803
+ Returns
804
+ -------
805
+ list[str]
806
+ Human-readable error strings; empty list means valid.
807
+ """
808
+ errors: list[str] = []
809
+
810
+ if "base_url_override" in config:
811
+ val = config["base_url_override"]
812
+ if not isinstance(val, str) or not val.strip():
813
+ errors.append("base_url_override must be a non-empty string.")
814
+ else:
815
+ try:
816
+ parsed = urlparse(val)
817
+ if not parsed.scheme or not parsed.netloc:
818
+ errors.append(
819
+ "base_url_override must be a full URL with scheme and host "
820
+ "(e.g., 'https://api.example.com')."
821
+ )
822
+ except Exception: # noqa: BLE001
823
+ errors.append("base_url_override is not a valid URL.")
824
+
825
+ if "default_headers" in config:
826
+ val = config["default_headers"]
827
+ if not isinstance(val, dict):
828
+ errors.append("default_headers must be a dict mapping header names to values.")
829
+ else:
830
+ for k, v in val.items():
831
+ if not isinstance(k, str) or not isinstance(v, str):
832
+ errors.append(
833
+ "default_headers keys and values must all be strings."
834
+ )
835
+ break
836
+
837
+ if "ignore_insecure" in config:
838
+ val = config["ignore_insecure"]
839
+ if not isinstance(val, bool):
840
+ errors.append("ignore_insecure must be a boolean.")
841
+
842
+ return errors
843
+
844
+ # ------------------------------------------------------------------
845
+ # ingest
846
+ # ------------------------------------------------------------------
847
+
848
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
849
+ """Parse cURL commands and return a normalised :class:`IngestResult`.
850
+
851
+ Parameters
852
+ ----------
853
+ source:
854
+ The source to ingest. Must provide either ``raw_content`` or a
855
+ valid ``path`` to a file containing one or more cURL commands.
856
+ config:
857
+ Plugin-specific configuration. See class docstring for keys.
858
+
859
+ Returns
860
+ -------
861
+ IngestResult
862
+ Populated result on success; ``success=False`` with ``errors`` on
863
+ failure.
864
+ """
865
+ # --- Read content ---
866
+ try:
867
+ content = self._read_source(source)
868
+ except (FileNotFoundError, ValueError) as exc:
869
+ return self._make_error_result(source, str(exc))
870
+
871
+ if not content or not content.strip():
872
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
873
+
874
+ # --- Config ---
875
+ base_url_override: str | None = config.get("base_url_override") or None
876
+ default_headers: dict[str, str] = config.get("default_headers") or {}
877
+ ignore_insecure: bool = bool(config.get("ignore_insecure", False))
878
+
879
+ warnings: list[str] = []
880
+
881
+ # --- Split into individual commands ---
882
+ raw_commands = _split_curl_commands(content)
883
+ if not raw_commands:
884
+ return self._make_error_result(
885
+ source, "No cURL commands found in content."
886
+ )
887
+
888
+ # --- Parse each command ---
889
+ endpoint_map: dict[str, IngestedEndpoint] = {}
890
+ test_cases: list[IngestedTestCase] = []
891
+ skipped = 0
892
+
893
+ for cmd_index, raw_cmd in enumerate(raw_commands):
894
+ parsed = _parse_curl_command(raw_cmd)
895
+
896
+ # Propagate parse errors as warnings
897
+ for err in parsed.get("errors", []):
898
+ warnings.append(f"Command #{cmd_index + 1}: {err}")
899
+
900
+ url_str = parsed.get("url")
901
+ if not url_str:
902
+ warnings.append(
903
+ f"Command #{cmd_index + 1}: No URL found — skipping."
904
+ )
905
+ skipped += 1
906
+ continue
907
+
908
+ # --- Merge default headers ---
909
+ headers: list[tuple[str, str]] = list(parsed["headers"])
910
+ for dh_name, dh_val in default_headers.items():
911
+ # Only add if not already present (case-insensitive)
912
+ existing_names = {h[0].lower() for h in headers}
913
+ if dh_name.lower() not in existing_names:
914
+ headers.append((dh_name, dh_val))
915
+
916
+ # --- Parse URL ---
917
+ url_info = _parse_url(url_str, base_url_override)
918
+ path = url_info["path"]
919
+ base_url = url_info["base_url"]
920
+ query_params = url_info["query_params"]
921
+
922
+ # --- Method ---
923
+ method = (parsed.get("method") or "GET").upper()
924
+
925
+ # --- Auth ---
926
+ auth_requirements = _detect_auth_requirements(
927
+ headers, parsed.get("basic_auth")
928
+ )
929
+
930
+ # --- Content type & body ---
931
+ body = parsed.get("body")
932
+ form_data = parsed.get("form_data") or []
933
+ content_type = _detect_content_type(headers, body, form_data)
934
+ request_body = _build_request_body(body, form_data, content_type)
935
+
936
+ # --- Warnings for insecure / follow redirects ---
937
+ if parsed.get("insecure") and not ignore_insecure:
938
+ warnings.append(
939
+ f"Command #{cmd_index + 1}: -k/--insecure flag detected — "
940
+ f"TLS verification disabled ({method} {url_str})."
941
+ )
942
+
943
+ # --- Priority ---
944
+ priority = _priority_for_endpoint(method, path, auth_requirements)
945
+
946
+ # --- Build parameters list ---
947
+ # Path parameters are extracted from curly-brace templates if present;
948
+ # for curl we don't typically normalise IDs, but respect explicit templates.
949
+ path_params: list[dict[str, Any]] = []
950
+ for match in re.finditer(r"\{(\w+)\}", path):
951
+ path_params.append({
952
+ "name": match.group(1),
953
+ "in": "path",
954
+ "required": True,
955
+ })
956
+ all_params = path_params + query_params
957
+
958
+ # --- Deduplicate ---
959
+ key = _endpoint_key(method, path)
960
+
961
+ if key not in endpoint_map:
962
+ # Build auth_requirements as header references
963
+ metadata: dict[str, Any] = {
964
+ "base_url": base_url,
965
+ "priority": priority,
966
+ "original_urls": [url_info["full_url"]],
967
+ "insecure": parsed.get("insecure", False),
968
+ "follow_redirects": parsed.get("follow_redirects", False),
969
+ "compressed": parsed.get("compressed", False),
970
+ }
971
+ if parsed.get("basic_auth"):
972
+ metadata["has_basic_auth"] = True
973
+
974
+ endpoint_map[key] = IngestedEndpoint(
975
+ method=method,
976
+ path=path,
977
+ summary=f"{method} {path}",
978
+ description=f"Extracted from cURL command: {method} {url_str}",
979
+ parameters=all_params,
980
+ request_body=request_body,
981
+ responses={},
982
+ auth_requirements=auth_requirements,
983
+ tags=["curl", method.lower()],
984
+ metadata=metadata,
985
+ )
986
+ else:
987
+ # Merge into existing
988
+ existing = endpoint_map[key]
989
+ if url_info["full_url"] not in existing.metadata.get("original_urls", []):
990
+ existing.metadata.setdefault("original_urls", []).append(
991
+ url_info["full_url"]
992
+ )
993
+ for scheme in auth_requirements:
994
+ if scheme not in existing.auth_requirements:
995
+ existing.auth_requirements.append(scheme)
996
+
997
+ # --- Generate test cases ---
998
+ endpoints = list(endpoint_map.values())
999
+ for ep in endpoints:
1000
+ test_cases.extend(_generate_test_cases(ep))
1001
+
1002
+ # --- Stats ---
1003
+ stats: dict[str, Any] = {
1004
+ "commands_found": len(raw_commands),
1005
+ "endpoints_extracted": len(endpoints),
1006
+ "test_cases_generated": len(test_cases),
1007
+ "commands_skipped": skipped,
1008
+ "warnings_count": len(warnings),
1009
+ }
1010
+
1011
+ return IngestResult(
1012
+ success=True,
1013
+ source=source,
1014
+ endpoints=endpoints,
1015
+ test_cases=test_cases,
1016
+ errors=[],
1017
+ warnings=warnings,
1018
+ stats=stats,
1019
+ )