From b51ef5770754d2f66b169b8ea53aa9c26ddb8a41 Mon Sep 17 00:00:00 2001 From: John Kennedy <65985482+jkennedyvz@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:36:53 +0000 Subject: [PATCH] fix: validate nested Git dependency credentials Co-authored-by: open-swe[bot] --- libs/cli/langgraph_cli/config.py | 66 ++++++++++++++++---- libs/cli/langgraph_cli/uv_lock.py | 10 +++ libs/cli/tests/unit_tests/test_config.py | 79 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index ecf3cc894..aabc824d7 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -6,6 +6,7 @@ import re import shlex import textwrap from collections import Counter +from collections.abc import Iterable from typing import Literal, NamedTuple import click @@ -36,7 +37,10 @@ DISALLOWED_BUILD_COMMAND_CHARS = [ # This blocks background execution (cmd &) while allowing command # chaining (cmd1 && cmd2) which is common in build commands. _SINGLE_AMPERSAND_RE = re.compile(r"(?[^/\s]+)", re.I) +_GIT_HTTP_AUTHORITY_RES = ( + re.compile(r"git\+https?://(?P[^/\s\"']+)", re.I), + re.compile(r"\bgit\s*=\s*[\"']https?://(?P[^/\s\"']+)", re.I), +) _API_VERSION_PATTERN = re.compile( r"^(?P\d+)" r"(?:\.(?P\d+))?" @@ -83,10 +87,53 @@ def _has_git_http_url_userinfo(dependency: str) -> bool: """Check whether a Git HTTP URL contains userinfo.""" return any( "@" in match.group("authority") - for match in _GIT_HTTP_AUTHORITY_RE.finditer(dependency) + for pattern in _GIT_HTTP_AUTHORITY_RES + for match in pattern.finditer(dependency) ) +def _validate_git_http_url_userinfo(values: Iterable[str]) -> None: + """Reject credential-bearing Git HTTP URLs without echoing their values.""" + if not any(_has_git_http_url_userinfo(value) for value in values): + return + raise click.UsageError( + "Git dependency URLs must not contain credentials or other URL " + "userinfo because generated Dockerfiles and image layers can retain " + "them. Use a credential-free Git URL and provide short-lived " + "credentials through your build environment's secret-backed Git " + "credential helper." + ) + + +def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None: + """Reject credential-bearing Git HTTP URLs in dependency files.""" + contents: list[str] = [] + for path in paths: + if not path.is_file(): + continue + try: + contents.append(path.read_text(encoding="utf-8", errors="replace")) + except OSError: + raise click.UsageError( + f"Could not inspect dependency file for embedded credentials: {path}" + ) from None + _validate_git_http_url_userinfo(contents) + + +def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None: + """Validate dependency files copied into a non-uv Python image.""" + paths: list[pathlib.Path] = [] + for dependency in config["dependencies"]: + if not isinstance(dependency, str) or not dependency.startswith("."): + continue + root = (config_path.parent / dependency).resolve() + paths.extend( + root / name + for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg") + ) + _validate_git_http_url_userinfo_files(paths) + + MIN_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11" @@ -424,17 +471,11 @@ def validate_config(config: Config) -> Config: ' "source": {"kind": "uv", "root": ".."}' ) - if any( - isinstance(dependency, str) and _has_git_http_url_userinfo(dependency) + _validate_git_http_url_userinfo( + dependency for dependency in config["dependencies"] - ): - raise click.UsageError( - "Git dependency URLs must not contain credentials or other URL " - "userinfo because generated Dockerfiles and image layers can retain " - "them. Use a credential-free Git URL and provide short-lived " - "credentials through your build environment's secret-backed Git " - "credential helper." - ) + if isinstance(dependency, str) + ) source = config.get("source") source_kind = _get_source_kind(config) @@ -1301,6 +1342,7 @@ def python_config_to_docker( api_version=api_version, build_tools_to_uninstall=build_tools_to_uninstall, ) + _validate_local_dependency_files(config_path, config) if pip_installer == "auto": if _image_supports_uv(base_image): pip_installer = "uv" diff --git a/libs/cli/langgraph_cli/uv_lock.py b/libs/cli/langgraph_cli/uv_lock.py index 1e33d16d7..7866a7233 100644 --- a/libs/cli/langgraph_cli/uv_lock.py +++ b/libs/cli/langgraph_cli/uv_lock.py @@ -880,6 +880,7 @@ def python_config_to_docker_uv_lock( _get_node_pm_install_cmd, _get_pip_cleanup_lines, _image_supports_uv, + _validate_git_http_url_userinfo_files, docker_tag, ) @@ -890,11 +891,20 @@ def python_config_to_docker_uv_lock( ) config_root = config_path.parent.resolve() + source_root = config["source"].get("root", ".") + project_root = (config_root / source_root).resolve() + _validate_git_http_url_userinfo_files( + [project_root / "pyproject.toml", project_root / "uv.lock"] + ) + install_cmd = "uv pip install --system" _, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands( config, install_cmd ) plan = _plan_uv_lock_workspace(config_path, config) + _validate_git_http_url_userinfo_files( + package.pyproject_path for package in plan.install_order + ) _update_uv_lock_graph_paths(config_path, config, plan) for section, key in [ diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index 3d34f415d..1e0be96d8 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -299,6 +299,85 @@ def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str): assert config["dependencies"] == [dependency] +def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements( + tmp_path: pathlib.Path, +): + config_path = tmp_path / "langgraph.json" + config_path.write_text("{}\n") + (tmp_path / "agent.py").write_text("graph = object()\n") + (tmp_path / "requirements.txt").write_text( + "private @ git+https://secret-token@github.com/org/private.git\n" + ) + config = validate_config( + { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + } + ) + + with pytest.raises(click.UsageError) as exc_info: + config_to_docker( + config_path, + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + message = str(exc_info.value) + assert "must not contain credentials or other URL userinfo" in message + assert "secret-token" not in message + + +@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"]) +def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files( + tmp_path: pathlib.Path, manifest: str +): + config_path = tmp_path / "langgraph.json" + config_path.write_text("{}\n") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "agent.py").write_text("graph = object()\n") + pyproject = textwrap.dedent( + """ + [project] + name = "agent" + version = "0.1.0" + dependencies = ["private"] + + [tool.uv.sources] + private = { git = "https://github.com/org/private.git" } + """ + ).strip() + uv_lock = "# uv lock file\n" + if manifest == "pyproject.toml": + pyproject = pyproject.replace( + "https://github.com", "https://secret-token@github.com" + ) + else: + uv_lock += ( + 'source = { git = "https://secret-token@github.com/org/private.git" }\n' + ) + (tmp_path / "pyproject.toml").write_text(pyproject + "\n") + (tmp_path / "uv.lock").write_text(uv_lock) + config = validate_config( + { + "python_version": "3.11", + "graphs": {"agent": "./src/agent.py:graph"}, + "source": {"kind": "uv"}, + } + ) + + with pytest.raises(click.UsageError) as exc_info: + config_to_docker( + config_path, + config, + base_image="langchain/langgraph-api:0.2.47", + ) + + message = str(exc_info.value) + assert "must not contain credentials or other URL userinfo" in message + assert "secret-token" not in message + + def test_validate_config_image_distro(): """Test validation of image_distro field.""" # Valid image_distro values should work