nat-engine 1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|