{"title":"Command Injection via Untrusted Executable Path in subprocess.Popen()","language":"Python","severity":"Critical","cwe":"CWE-78","source_lines":[3],"flow_lines":[3,5,7],"sink_lines":[7],"vulnerable_code":"import subprocess\nimport os\n\ndef deploy_ml_model(model_name, deployment_env, custom_runtime):\n    deployment_path = f\"/opt/ml/deployments/{deployment_env}\"\n    os.makedirs(deployment_path, exist_ok=True)\n    runtime_binary = custom_runtime if custom_runtime else \"/usr/bin/python3\"\n    deploy_cmd = [runtime_binary, \"-m\", \"mlflow.models.serve\", \"--model-uri\", model_name, \"--port\", \"8080\"]\n    process = subprocess.Popen(deploy_cmd, cwd=deployment_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n    stdout, stderr = process.communicate(timeout=30)\n    if process.returncode == 0:\n        return {\"status\": \"deployed\", \"output\": stdout.decode()}\n    return {\"status\": \"failed\", \"error\": stderr.decode()}","explanation":"The custom_runtime parameter is untrusted user input that is directly used as the first element of the command array in subprocess.Popen() without validation. An attacker can provide a malicious executable path or inject command arguments by exploiting path traversal or symbolic links, leading to arbitrary command execution on the server.","remediation":"The fix replaces direct use of the user-supplied runtime path with an allowlist-based lookup, where users specify a runtime key (e.g., 'python3.9') that maps to a pre-approved absolute path. Additionally, the resolved path is validated to ensure it exists as a regular file and is executable, and symlink resolution via os.path.realpath() prevents path traversal attacks. Input validation is also added for model_name and deployment_env parameters.","secure_code":"import subprocess\nimport os\nimport re\n\nALLOWED_RUNTIMES = {\n    \"python3\": \"/usr/bin/python3\",\n    \"python3.8\": \"/usr/bin/python3.8\",\n    \"python3.9\": \"/usr/bin/python3.9\",\n    \"python3.10\": \"/usr/bin/python3.10\",\n    \"python3.11\": \"/usr/bin/python3.11\",\n    \"conda-python\": \"/opt/conda/bin/python\",\n}\n\nALLOWED_ENVIRONMENTS = re.compile(r'^[a-zA-Z0-9_\\-]+$')\nALLOWED_MODEL_NAMES = re.compile(r'^[a-zA-Z0-9_\\-/.]+$')\n\n\ndef deploy_ml_model(model_name, deployment_env, custom_runtime):\n    # Validate deployment_env to prevent path traversal\n    if not deployment_env or not ALLOWED_ENVIRONMENTS.match(deployment_env):\n        raise ValueError(f\"Invalid deployment environment: {deployment_env}\")\n\n    # Validate model_name to prevent injection\n    if not model_name or not ALLOWED_MODEL_NAMES.match(model_name):\n        raise ValueError(f\"Invalid model name: {model_name}\")\n\n    deployment_path = f\"/opt/ml/deployments/{deployment_env}\"\n    os.makedirs(deployment_path, exist_ok=True)\n\n    # Validate custom_runtime against an allowlist\n    if custom_runtime:\n        if custom_runtime not in ALLOWED_RUNTIMES:\n            raise ValueError(\n                f\"Invalid runtime '{custom_runtime}'. \"\n                f\"Allowed runtimes: {list(ALLOWED_RUNTIMES.keys())}\"\n            )\n        runtime_binary = ALLOWED_RUNTIMES[custom_runtime]\n    else:\n        runtime_binary = ALLOWED_RUNTIMES[\"python3\"]\n\n    # Verify the resolved runtime binary exists and is a regular file\n    resolved_path = os.path.realpath(runtime_binary)\n    if not os.path.isfile(resolved_path):\n        raise FileNotFoundError(f\"Runtime binary not found: {runtime_binary}\")\n    if not os.access(resolved_path, os.X_OK):\n        raise PermissionError(f\"Runtime binary is not executable: {runtime_binary}\")\n\n    deploy_cmd = [\n        resolved_path, \"-m\", \"mlflow.models.serve\",\n        \"--model-uri\", model_name,\n        \"--port\", \"8080\"\n    ]\n\n    process = subprocess.Popen(\n        deploy_cmd,\n        cwd=deployment_path,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE\n    )\n    stdout, stderr = process.communicate(timeout=30)\n\n    if process.returncode == 0:\n        return {\"status\": \"deployed\", \"output\": stdout.decode()}\n    return {\"status\": \"failed\", \"error\": stderr.decode()}"}