fix(security): keep database passwords out of every argv

MariaDB dumps and restores read the password from MYSQL_PWD, and docker exec forwards env variables by name only, so no process listing shows a password. baudolo-restore gains --db-password-stdin; --db-password still works but warns.

🎯 Fix: 96 %
💥 Break: 3 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: ~100 %
🏆 Score: 93
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-05 18:37:04 +00:00
1 parent d672388f63
commit 328520169b
15 files changed
+320 -67

No files matched your search

+11
View File
@@ -13,7 +13,17 @@ def run(
capture: bool = True,
check: bool = True,
cwd: str | None = None,
stdin_text: str | None = None,
) -> subprocess.CompletedProcess:
"""
Args:
cmd: argv, the program first.
capture: keep stdout and stderr on the result.
check: raise on a non-zero exit.
cwd: working directory.
stdin_text: text fed to the command's stdin, e.g. a password for
``--db-password-stdin``.
"""
try:
return subprocess.run(
cmd,
@@ -21,6 +31,7 @@ def run(
cwd=cwd,
text=True,
capture_output=capture,
input=stdin_text,
)
except subprocess.CalledProcessError as e:
# Print captured output so failing E2E tests are "live" / debuggable in CI logs
+3 -3
View File
@@ -137,10 +137,10 @@ class TestE2EMariaDBFull(unittest.TestCase):
cls.db_name,
"--db-user",
cls.db_user,
"--db-password",
cls.db_password,
"--db-password-stdin",
"--empty",
]
],
stdin_text=cls.db_password,
)
@classmethod
+3 -25
View File
@@ -89,33 +89,12 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
/ f"{cls.pg_container}.cluster.backup.sql"
)
run(
[
"baudolo-restore",
"cluster",
cls.pg_volume,
cls.hash,
cls.version,
"--backups-dir",
cls.backups_dir,
"--repo-name",
cls.repo_name,
"--container",
cls.pg_container,
"--instance",
cls.pg_container,
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--empty",
]
)
run(cls._restore_argv(), stdin_text="pgpw")
for statement in SIBLING_SQL:
cls._psql("postgres", statement)
cls._psql("sibling", SIBLING_PAYLOAD)
cls.refused = run(cls._restore_argv(), check=False)
cls.refused = run(cls._restore_argv(), check=False, stdin_text="pgpw")
@classmethod
def _restore_argv(cls) -> list:
@@ -135,8 +114,7 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
cls.pg_container,
"--db-user",
"postgres",
"--db-password",
"pgpw",
"--db-password-stdin",
"--empty",
]
+4 -1
View File
@@ -94,7 +94,7 @@ class TestE2EPostgresFull(unittest.TestCase):
)
# Restore
run(
cls.restored = run(
[
"baudolo-restore",
"postgres",
@@ -129,6 +129,9 @@ class TestE2EPostgresFull(unittest.TestCase):
)
self.assertTrue(p.is_file(), f"Expected dump file at: {p}")
def test_the_deprecated_password_argument_still_restores_but_warns(self) -> None:
self.assertIn("visible in the process list", self.restored.stderr)
def test_data_restored(self) -> None:
p = run(
[
+2 -2
View File
@@ -73,12 +73,12 @@ class GateCase:
cls.db_name,
"--db-user",
cls.db_user,
"--db-password",
cls.db_password,
"--db-password-stdin",
"--empty",
*extra,
],
check=False,
stdin_text=cls.db_password,
)
@classmethod
+27 -16
View File
@@ -16,7 +16,7 @@ def _capture_dumps(*, db_type, rows, container, dump_tool="mariadb-dump"):
captured = []
def _capture(command, out_file, *, env=None):
captured.append((list(command), env))
captured.append((list(command), env, out_file))
with (
tempfile.TemporaryDirectory() as td,
@@ -46,14 +46,13 @@ class TestMariaDBDumpUsesTCP(unittest.TestCase):
)
self.assertEqual(len(captured), 1, f"expected one dump, got: {captured}")
argv, env = captured[0]
self.assertEqual(argv[:3], ["docker", "exec", "mariadb"])
argv, _env, _out = captured[0]
self.assertEqual(argv[:2], ["docker", "exec"])
self.assertEqual(argv[argv.index(dump_tool_of(argv)) - 1], "mariadb")
self.assertIn("--protocol=tcp", argv)
self.assertEqual(argv[argv.index("-h") + 1], "127.0.0.1")
self.assertEqual(argv[argv.index("-u") + 1], "appuser")
self.assertIn("-ps3cret", argv)
self.assertEqual(argv[-1], "appdb")
self.assertIsNone(env)
def test_the_probed_client_is_the_one_invoked(self):
captured = _capture_dumps(
@@ -62,7 +61,7 @@ class TestMariaDBDumpUsesTCP(unittest.TestCase):
container="mariadb",
dump_tool="mysqldump",
)
argv, _env = captured[0]
argv, _env, _out = captured[0]
self.assertIn("mysqldump", argv)
self.assertNotIn("mariadb-dump", argv)
@@ -72,20 +71,32 @@ class TestMariaDBDumpUsesTCP(unittest.TestCase):
rows=[("pg", "appdb", "appuser", "s3cret")],
container="pg",
)
argv, _env = captured[0]
argv, _env, _out = captured[0]
self.assertIn("pg_dump", argv)
self.assertNotIn("--protocol=tcp", argv)
def test_the_password_travels_in_the_environment_not_the_argv(self):
"""A process listing shows argv; PGPASSWORD must not be in it."""
captured = _capture_dumps(
db_type="postgres",
rows=[("pg", "appdb", "appuser", "s3cret")],
container="pg",
)
argv, env = captured[0]
self.assertEqual(env, {"PGPASSWORD": "s3cret"})
self.assertNotIn("s3cret", argv)
for db_type, container, variable in (
("postgres", "pg", "PGPASSWORD"),
("mariadb", "mariadb", "MYSQL_PWD"),
):
with self.subTest(engine=db_type):
argv, env, _out = _capture_dumps(
db_type=db_type,
rows=[(container, "appdb", "appuser", "s3cret")],
container=container,
)[0]
self.assertEqual(env, {variable: "s3cret"})
self.assertEqual(argv[argv.index("-e") + 1], variable)
self.assertLess(argv.index("-e"), argv.index(container))
self.assertFalse(
[arg for arg in argv if "s3cret" in arg],
f"password reached the argv: {argv}",
)
def dump_tool_of(argv):
return next(arg for arg in argv if arg in ("mariadb-dump", "mysqldump"))
class TestNoShellReachesTheDump(unittest.TestCase):
+1 -1
View File
@@ -54,7 +54,7 @@ class TestPostgresDumpCarriesThePassword(unittest.TestCase):
self.assertIn("-e", seen["command"])
self.assertEqual(seen["command"][seen["command"].index("-e") + 1], "PGPASSWORD")
self.assertEqual(seen["env"], {"PGPASSWORD": "secret"})
self.assertNotIn("secret", seen["command"])
self.assertFalse([arg for arg in seen["command"] if "secret" in arg])
if __name__ == "__main__":
@@ -0,0 +1,65 @@
import contextlib
import io
import unittest
from unittest.mock import patch
from baudolo.restore import __main__ as cli
BASE = [
"postgres",
"app_vol",
"hash",
"20260817000000",
"--repo-name",
"repo",
"--container",
"db",
"--db-name",
"app",
]
class TestRestorePasswordChannel(unittest.TestCase):
def call(self, extra, stdin=""):
stderr = io.StringIO()
with (
patch.object(cli, "restore_postgres_sql") as restore,
patch("sys.stdin", io.StringIO(stdin)),
contextlib.redirect_stderr(stderr),
):
code = cli.main([*BASE, *extra])
return code, restore, stderr.getvalue()
def test_stdin_carries_the_password_without_its_newline(self):
code, restore, stderr = self.call(["--db-password-stdin"], stdin="s3cret\n")
self.assertEqual(code, 0)
self.assertEqual(restore.call_args.kwargs["password"], "s3cret")
self.assertNotIn("WARNING", stderr)
def test_the_argument_still_works_but_warns(self):
code, restore, stderr = self.call(["--db-password", "s3cret"])
self.assertEqual(code, 0)
self.assertEqual(restore.call_args.kwargs["password"], "s3cret")
self.assertIn("visible in the process list", stderr)
def test_both_ways_is_a_usage_error(self):
with self.assertRaises(SystemExit) as caught:
self.call(["--db-password", "x", "--db-password-stdin"], stdin="y")
self.assertEqual(caught.exception.code, 2)
def test_neither_way_is_a_usage_error(self):
with self.assertRaises(SystemExit) as caught:
self.call([])
self.assertEqual(caught.exception.code, 2)
def test_files_needs_no_password(self):
with patch.object(cli, "restore_volume_files", return_value=0) as restore:
code = cli.main(
["files", "app_vol", "hash", "20260817000000", "--repo-name", "repo"]
)
self.assertEqual(code, 0)
restore.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,112 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from baudolo.restore import run as run_mod
from baudolo.restore.db import cluster as cluster_mod
from baudolo.restore.db import mariadb as mdb_mod
from baudolo.restore.db import postgres as pg_mod
from baudolo.restore.db import version as ver
from .test_version_gate import MARIADB_HEADER, POSTGRES_HEADER, cluster_header
SECRET = "s3cret-Pw"
class TestDockerExecForwardsByName(unittest.TestCase):
def test_the_value_stays_out_of_the_argv(self):
with patch.object(run_mod, "run") as run:
run_mod.docker_exec("db", ["psql"], docker_env={"PGPASSWORD": SECRET})
cmd = run.call_args.args[0]
self.assertEqual(
cmd, ["docker", "exec", "-i", "-e", "PGPASSWORD", "db", "psql"]
)
self.assertFalse([arg for arg in cmd if SECRET in arg])
self.assertEqual(run.call_args.kwargs["env"]["PGPASSWORD"], SECRET)
def test_the_rest_of_the_environment_is_kept(self):
with (
patch.dict(run_mod.os.environ, {"PATH": "/x"}),
patch.object(run_mod, "run") as run,
):
run_mod.docker_exec("db", ["psql"], docker_env={"PGPASSWORD": SECRET})
self.assertEqual(run.call_args.kwargs["env"]["PATH"], "/x")
def test_no_docker_env_inherits_unchanged(self):
with patch.object(run_mod, "run") as run:
run_mod.docker_exec("db", ["true"])
self.assertIsNone(run.call_args.kwargs["env"])
self.assertEqual(run.call_args.args[0], ["docker", "exec", "-i", "db", "true"])
class TestNoRestoreArgvCarriesThePassword(unittest.TestCase):
def setUp(self):
self.argvs = []
self.envs = []
self.dir = Path(tempfile.mkdtemp())
def fake_run(cmd, *, stdin=None, capture=False, env=None):
self.argvs.append(cmd)
self.envs.append(env or {})
stdout = b"17.11\n" if "SHOW server_version" in cmd else b"t1\n"
if "SELECT VERSION()" in cmd:
stdout = b"11.8.8-MariaDB\n"
return MagicMock(stdout=stdout)
patch.object(run_mod, "run", side_effect=fake_run).start()
self.addCleanup(patch.stopall)
def dump(self, name: str, text: str) -> str:
path = self.dir / name
path.write_text(text, encoding="utf-8")
return str(path)
def assert_clean(self, variable: str):
self.assertTrue(self.argvs)
for cmd in self.argvs:
self.assertFalse([arg for arg in cmd if SECRET in arg], cmd)
self.assertTrue(all(env.get(variable) == SECRET for env in self.envs))
def test_postgres(self):
pg_mod.restore_postgres_sql(
container="db",
db_name="app",
user="app",
password=SECRET,
sql_path=self.dump("app.backup.sql", POSTGRES_HEADER),
empty=True,
)
self.assert_clean("PGPASSWORD")
def test_cluster(self):
with patch.object(cluster_mod, "assert_instance_matches_dump"):
cluster_mod.restore_cluster_sql(
container="db",
user="postgres",
password=SECRET,
sql_path=self.dump("c.cluster.backup.sql", cluster_header(roles=1)),
empty=True,
)
self.assert_clean("PGPASSWORD")
def test_mariadb(self):
with patch.object(mdb_mod, "_pick_client", return_value="mariadb"):
mdb_mod.restore_mariadb_sql(
container="db",
db_name="app",
user="app",
password=SECRET,
sql_path=self.dump("app.backup.sql", MARIADB_HEADER),
empty=True,
)
self.assert_clean("MYSQL_PWD")
self.assertEqual(len(self.argvs), 4, "version, list, drop, replay")
def test_mariadb_version_probe(self):
ver.server_version("db", "mariadb", "app", SECRET, client="mariadb")
self.assert_clean("MYSQL_PWD")
if __name__ == "__main__":
unittest.main()