diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py index be785e2fd78..c675dddd30f 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/utils.py +++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py @@ -306,8 +306,10 @@ class ProxyExtrasDBManager: "migrate", "diff", "--from-empty", - "--to-url", - database_url, + # Read the URL from DATABASE_URL (already in prisma_env) through + # the schema's datasource, so the password stays out of argv. + "--to-schema-datasource", + schema_path, "--script", ], stdout=open(migration_file, "w"), @@ -519,6 +521,11 @@ class ProxyExtrasDBManager: diff_dir = Path(tempfile.mkdtemp(prefix="litellm_migration_diff_")) diff_sql_path = diff_dir / "migration.sql" + # Hand the URL to the child as DATABASE_URL, which the schema's datasource + # reads. On the command line any local user could read the password. + diff_env = _get_prisma_env() + diff_env["DATABASE_URL"] = diff_url + # 1. Generate migration SQL for the diff between DB and schema try: logger.info("Generating migration diff between DB and schema.prisma...") @@ -528,15 +535,15 @@ class ProxyExtrasDBManager: _get_prisma_command(), "migrate", "diff", - "--from-url", - diff_url, + "--from-schema-datasource", + schema_path, "--to-schema-datamodel", schema_path, "--script", ], timeout=prisma_command_timeout(), stdout=f, - env=_get_prisma_env(), + env=diff_env, ) except subprocess.CalledProcessError as e: logger.warning(f"Failed to generate migration diff: {e.stderr}") diff --git a/litellm/proxy/db/check_migration.py b/litellm/proxy/db/check_migration.py index 6e2a06c96e1..20c92029c85 100644 --- a/litellm/proxy/db/check_migration.py +++ b/litellm/proxy/db/check_migration.py @@ -70,20 +70,24 @@ def check_prisma_schema_diff_helper(db_url: str) -> tuple[bool, list[str]]: verbose_logger.debug("Checking for Prisma schema diff...") timeout: Final = prisma_command_timeout() + # schema.prisma reads its datasource url from DATABASE_URL, so hand the URL to + # the child through its environment. On the command line the password would be + # readable by any local user through ps or /proc//cmdline. + env: Final = {**os.environ, "DATABASE_URL": db_url} try: result: Final = run_prisma( [ "prisma", "migrate", "diff", - "--from-url", - db_url, + "--from-schema-datasource", + "./schema.prisma", "--to-schema-datamodel", "./schema.prisma", "--script", ], timeout=timeout, - env=os.environ.copy(), + env=env, ) sql_commands: Final = extract_sql_commands(result.stdout) diff --git a/tests/unit/litellm_proxy_extras/test_litellm_proxy_extras_utils.py b/tests/unit/litellm_proxy_extras/test_litellm_proxy_extras_utils.py index 02bdf3429ea..5a2c87b6c31 100644 --- a/tests/unit/litellm_proxy_extras/test_litellm_proxy_extras_utils.py +++ b/tests/unit/litellm_proxy_extras/test_litellm_proxy_extras_utils.py @@ -724,6 +724,64 @@ class TestResolveAllMigrationsLedger: assert self._executed_sql(calls) == _PARTITIONED_DRIFT_SQL +class TestMigrateDiffKeepsDatabaseUrlOutOfArgv: + """argv is world-readable through ps and /proc//cmdline, so every + `prisma migrate diff` has to take its URL from DATABASE_URL, read by the + schema's datasource, instead of --from-url/--to-url.""" + + _SECRET = "s3cret-pw" + + def _capture(self, monkeypatch): + import litellm_proxy_extras.utils as utils_module + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((list(cmd), kwargs.get("env"))) + return _FakeCompleted() + + monkeypatch.setattr(utils_module.prisma_toolchain, "run_prisma", fake_run) + return calls + + def _diff_call(self, calls): + return next((cmd, env) for cmd, env in calls if "diff" in cmd) + + def test_drift_diff_reads_direct_url_from_env(self, monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"postgresql://u:{self._SECRET}@pooler:6543/db") + monkeypatch.setenv("DIRECT_URL", f"postgresql://u:{self._SECRET}@direct:5432/db") + calls = self._capture(monkeypatch) + + ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma") + + cmd, env = self._diff_call(calls) + assert not any(self._SECRET in arg for arg in cmd) + assert cmd[cmd.index("--from-schema-datasource") + 1] == "schema.prisma" + assert env["DATABASE_URL"] == f"postgresql://u:{self._SECRET}@direct:5432/db" + + def test_drift_diff_falls_back_to_database_url(self, monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"postgresql://u:{self._SECRET}@db:5432/db") + monkeypatch.delenv("DIRECT_URL", raising=False) + calls = self._capture(monkeypatch) + + ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma") + + cmd, env = self._diff_call(calls) + assert not any(self._SECRET in arg for arg in cmd) + assert env["DATABASE_URL"] == f"postgresql://u:{self._SECRET}@db:5432/db" + + def test_baseline_diff_reads_database_url_from_env(self, monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"postgresql://u:{self._SECRET}@db:5432/db") + monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", staticmethod(lambda: str(tmp_path))) + calls = self._capture(monkeypatch) + + assert ProxyExtrasDBManager._create_baseline_migration("schema.prisma") is True + + cmd, env = self._diff_call(calls) + assert not any(self._SECRET in arg for arg in cmd) + assert cmd[cmd.index("--to-schema-datasource") + 1] == "schema.prisma" + assert env["DATABASE_URL"] == f"postgresql://u:{self._SECRET}@db:5432/db" + + class TestPartitionedSpendLogsPushGuard: def _forbid_subprocess(self, monkeypatch): import litellm_proxy_extras.utils as utils_module diff --git a/tests/unit/proxy/db/test_check_migration.py b/tests/unit/proxy/db/test_check_migration.py index 74a6841cb23..ec787687c30 100644 --- a/tests/unit/proxy/db/test_check_migration.py +++ b/tests/unit/proxy/db/test_check_migration.py @@ -1,8 +1,9 @@ +import subprocess + import pytest - def test_check_migration_out_of_sync(mocker): """ Test that the check_prisma_schema_diff function @@ -51,7 +52,7 @@ def test_migrate_diff_stops_at_its_budget_and_takes_its_process_tree_with_it(fak assert check_prisma_schema_diff_helper("postgresql://u:p@localhost:9/x") == (False, []) assert fake_prisma_cli.calls == [ - ["migrate", "diff", "--from-url", "postgresql://u:p@localhost:9/x", + ["migrate", "diff", "--from-schema-datasource", "./schema.prisma", "--to-schema-datamodel", "./schema.prisma", "--script"] ] assert fake_prisma_cli.grandchild_is_gone(within_seconds=5) @@ -69,3 +70,29 @@ def test_migrate_diff_without_the_prisma_runner_skips_instead_of_crashing_boot(m monkeypatch.setitem(sys.modules, "litellm_proxy_extras.prisma_toolchain", None) assert check_prisma_schema_diff_helper("postgresql://u:p@localhost:9/x") == (False, []) + + +def test_migrate_diff_passes_the_database_url_through_env_not_argv(monkeypatch): + """ + argv is world-readable through ps and /proc//cmdline, so the URL (password + included) has to reach `prisma migrate diff` through DATABASE_URL instead. + """ + from litellm_proxy_extras import prisma_toolchain + + from litellm.proxy.db.check_migration import check_prisma_schema_diff_helper + + db_url = "postgresql://user:s3cret@db:5432/litellm" + monkeypatch.setenv("DATABASE_URL", "postgresql://other@elsewhere/db") + captured = {} + + def fake_run_prisma(argv, **kwargs): + captured["argv"] = list(argv) + captured["env"] = kwargs["env"] + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(prisma_toolchain, "run_prisma", fake_run_prisma) + + assert check_prisma_schema_diff_helper(db_url) == (False, []) + assert not any("s3cret" in arg for arg in captured["argv"]) + assert "--from-schema-datasource" in captured["argv"] + assert captured["env"]["DATABASE_URL"] == db_url