From b11f8db4a19eddebdefa72f8be99c04f8ecef36d Mon Sep 17 00:00:00 2001 From: William Joy Date: Sat, 16 May 2026 01:19:29 -0700 Subject: [PATCH 1/3] Harden Railway deployment DB secrets --- docs/railway-db-runtime-secrets.md | 27 +++++ .../common/config/main-local.php | 22 ++--- .../july_2025/29_july_2025_deployment.sh | 18 ++-- .../july_2025/3_july_2025_deployment.sh | 20 ++-- .../july_2025/29_july_2025_deployment.sh | 18 ++-- .../july_2025/3_july_2025_deployment.sh | 20 ++-- tests/check-railway-db-runtime-secrets.py | 98 +++++++++++++++++++ 7 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 docs/railway-db-runtime-secrets.md create mode 100644 tests/check-railway-db-runtime-secrets.py diff --git a/docs/railway-db-runtime-secrets.md b/docs/railway-db-runtime-secrets.md new file mode 100644 index 00000000..b1d09b4f --- /dev/null +++ b/docs/railway-db-runtime-secrets.md @@ -0,0 +1,27 @@ +# Railway DB runtime secrets + +Railway database credentials must be supplied through runtime environment variables, not committed PHP config or deployment scripts. + +Dev-server Railway app config: + +- `DB_DSN` +- `DB_USERNAME` +- `DB_PASSWORD` +- `WALLET_DB_DSN` +- `WALLET_DB_USERNAME` +- `WALLET_DB_PASSWORD` +- `REDIS_HOSTNAME` +- `REDIS_USERNAME` +- `REDIS_PASSWORD` +- `REDIS_PORT` +- `REDIS_DATABASE` + +Railway deployment scripts: + +- `MYSQL_HOST`, defaults to `mysql.railway.internal` +- `MYSQL_PORT`, defaults to `3306` +- `MYSQL_USER`, defaults to `root` +- `MYSQL_DATABASE`, defaults to `railway` +- `MYSQL_PASSWORD`, required + +Set `MYSQL_PASSWORD` in Railway variables before running the deployment scripts. The scripts pass it to `mysql` through `MYSQL_PWD` for each command instead of embedding the password in the script or command line. diff --git a/environments/dev-server-railway/common/config/main-local.php b/environments/dev-server-railway/common/config/main-local.php index e9d8bc3c..d7744e93 100644 --- a/environments/dev-server-railway/common/config/main-local.php +++ b/environments/dev-server-railway/common/config/main-local.php @@ -3,16 +3,16 @@ 'components' => [ 'db' => [ 'class' => 'yii\db\Connection', - 'dsn' => 'mysql:host=mysql.railway.internal;dbname=railway', - 'username' => 'root', - 'password' => 'TpijAlObvfdvZxzPgrnMTHMxyekEqTtt', + 'dsn' => getenv('DB_DSN') ?: 'mysql:host=mysql.railway.internal;dbname=railway', + 'username' => getenv('DB_USERNAME') ?: 'root', + 'password' => getenv('DB_PASSWORD') ?: '', 'charset' => 'utf8mb4', ], 'walletDb' => [ 'class' => 'yii\db\Connection', - 'dsn' => 'mysql:host=mysql-5abl.railway.internal;dbname=railway', - 'username' => 'root', - 'password' => 'hOCpxbVoSIbPUnuuBmaQGILPshVyRRuj', + 'dsn' => getenv('WALLET_DB_DSN') ?: 'mysql:host=mysql-5abl.railway.internal;dbname=railway', + 'username' => getenv('WALLET_DB_USERNAME') ?: 'root', + 'password' => getenv('WALLET_DB_PASSWORD') ?: '', 'charset' => 'utf8', ], //todo: replace with wallet from sandbox @@ -39,11 +39,11 @@ ], 'redis' => [ 'class' => 'yii\redis\Connection', - 'hostname' => 'redis.railway.internal', - 'username' => 'default', - 'password' => 'nySjmLVspFXlYOzKrOFQcRwuUprjyDli', - 'port' => 6379, - 'database' => 0, + 'hostname' => getenv('REDIS_HOSTNAME') ?: 'redis.railway.internal', + 'username' => getenv('REDIS_USERNAME') ?: 'default', + 'password' => getenv('REDIS_PASSWORD') ?: null, + 'port' => (int)(getenv('REDIS_PORT') ?: 6379), + 'database' => (int)(getenv('REDIS_DATABASE') ?: 0), ], 'cache' => [ //'class' => 'yii\redis\Cache', diff --git a/environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh b/environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh index 95465283..00c66a09 100644 --- a/environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh +++ b/environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh @@ -1,20 +1,24 @@ #!/bin/sh -MYSQL_HOST="mysql.railway.internal" -MYSQL_PORT=3306 -MYSQL_USER="root" -MYSQL_PASSWORD="TpijAlObvfdvZxzPgrnMTHMxyekEqTtt" -MYSQL_DATABASE="railway" +MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_DATABASE="${MYSQL_DATABASE:-railway}" +: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}" + +run_mysql() { + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@" +} echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..." for attempt in $(seq 1 60); do - mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break + run_mysql -e "SELECT 1" >/dev/null 2>&1 && break echo "Attempt $attempt: retrying in 1s..." sleep 1 [ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1 done -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e " +run_mysql "$MYSQL_DATABASE" -e " ALTER TABLE permission_user DROP COLUMN IF EXISTS companies; " diff --git a/environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh b/environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh index 7cbd4e6f..42ab747f 100644 --- a/environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh +++ b/environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh @@ -1,27 +1,31 @@ #!/bin/sh -MYSQL_HOST="mysql.railway.internal" -MYSQL_PORT=3306 -MYSQL_USER="root" -MYSQL_PASSWORD="TpijAlObvfdvZxzPgrnMTHMxyekEqTtt" -MYSQL_DATABASE="railway" +MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_DATABASE="${MYSQL_DATABASE:-railway}" +: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}" + +run_mysql() { + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@" +} echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..." for attempt in $(seq 1 60); do - mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break + run_mysql -e "SELECT 1" >/dev/null 2>&1 && break echo "Attempt $attempt: retrying in 1s..." sleep 1 [ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1 done echo "Disabling ONLY_FULL_GROUP_BY..." -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e " +run_mysql -e " SET GLOBAL sql_mode = REPLACE(@@GLOBAL.sql_mode, 'ONLY_FULL_GROUP_BY', ''); SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', ''); " echo "Converting 'candidate' table to utf8mb4 for emoji support..." -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e " +run_mysql "$MYSQL_DATABASE" -e " ALTER TABLE candidate MODIFY candidate_intro TEXT CHARACTER SET utf8mb4 diff --git a/environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh b/environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh index 3a7ef243..00c66a09 100644 --- a/environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh +++ b/environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh @@ -1,20 +1,24 @@ #!/bin/sh -MYSQL_HOST="mysql.railway.internal" -MYSQL_PORT=3306 -MYSQL_USER="root" -MYSQL_PASSWORD="JImnisvcRDpKLdWpoMECoHHoCbutPhQC" -MYSQL_DATABASE="railway" +MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_DATABASE="${MYSQL_DATABASE:-railway}" +: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}" + +run_mysql() { + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@" +} echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..." for attempt in $(seq 1 60); do - mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break + run_mysql -e "SELECT 1" >/dev/null 2>&1 && break echo "Attempt $attempt: retrying in 1s..." sleep 1 [ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1 done -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e " +run_mysql "$MYSQL_DATABASE" -e " ALTER TABLE permission_user DROP COLUMN IF EXISTS companies; " diff --git a/environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh b/environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh index 1e288ec7..7a101623 100644 --- a/environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh +++ b/environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh @@ -1,28 +1,32 @@ #!/bin/sh -MYSQL_HOST="mysql.railway.internal" -MYSQL_PORT=3306 -MYSQL_USER="root" -MYSQL_PASSWORD="JImnisvcRDpKLdWpoMECoHHoCbutPhQC" -MYSQL_DATABASE="railway" +MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_DATABASE="${MYSQL_DATABASE:-railway}" +: "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}" + +run_mysql() { + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$@" +} echo "Waiting for MySQL at $MYSQL_HOST:$MYSQL_PORT..." for attempt in $(seq 1 60); do - mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" >/dev/null 2>&1 && break + run_mysql -e "SELECT 1" >/dev/null 2>&1 && break echo "Attempt $attempt: retrying in 1s..." sleep 1 [ "$attempt" -eq 60 ] && echo "MySQL not responding." && exit 1 done echo "Disabling ONLY_FULL_GROUP_BY..." -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e " +run_mysql -e " SET GLOBAL sql_mode = REPLACE(@@GLOBAL.sql_mode, 'ONLY_FULL_GROUP_BY', ''); SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'ONLY_FULL_GROUP_BY', ''); " echo "Converting 'candidate' table to utf8mb4 for emoji support..." -mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e " +run_mysql "$MYSQL_DATABASE" -e " ALTER TABLE candidate MODIFY candidate_intro TEXT CHARACTER SET utf8mb4 diff --git a/tests/check-railway-db-runtime-secrets.py b/tests/check-railway-db-runtime-secrets.py new file mode 100644 index 00000000..29b25c4b --- /dev/null +++ b/tests/check-railway-db-runtime-secrets.py @@ -0,0 +1,98 @@ +"""Validate that Railway database credentials stay runtime-configured.""" + + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +DEV_CONFIG = ROOT / "environments/dev-server-railway/common/config/main-local.php" +DEPLOYMENT_SCRIPTS = [ + ROOT / "environments/dev-server-railway/deployments/july_2025/3_july_2025_deployment.sh", + ROOT / "environments/dev-server-railway/deployments/july_2025/29_july_2025_deployment.sh", + ROOT / "environments/prod-railway/deployments/july_2025/3_july_2025_deployment.sh", + ROOT / "environments/prod-railway/deployments/july_2025/29_july_2025_deployment.sh", +] +DOC = ROOT / "docs/railway-db-runtime-secrets.md" + +DEV_CONFIG_ENV_VARS = [ + "DB_DSN", + "DB_USERNAME", + "DB_PASSWORD", + "WALLET_DB_DSN", + "WALLET_DB_USERNAME", + "WALLET_DB_PASSWORD", + "REDIS_HOSTNAME", + "REDIS_USERNAME", + "REDIS_PASSWORD", + "REDIS_PORT", + "REDIS_DATABASE", +] + +SCRIPT_TOKENS = [ + 'MYSQL_HOST="${MYSQL_HOST:-mysql.railway.internal}"', + 'MYSQL_PORT="${MYSQL_PORT:-3306}"', + 'MYSQL_USER="${MYSQL_USER:-root}"', + 'MYSQL_DATABASE="${MYSQL_DATABASE:-railway}"', + ': "${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set in Railway environment variables.}"', + 'MYSQL_PWD="$MYSQL_PASSWORD" mysql', +] + +COMPONENT_PASSWORD_ENV = { + "db": "DB_PASSWORD", + "walletDb": "WALLET_DB_PASSWORD", + "redis": "REDIS_PASSWORD", +} + + +def read(path): + """Read a repository file as UTF-8 text.""" + return path.read_text(encoding="utf-8") + + +def require(condition, message): + """Fail the check with a clear message when a condition is false.""" + if not condition: + raise SystemExit(message) + + +def component_block(config, name): + """Return the top-level component block from the Yii config.""" + marker = f" '{name}' => [" + start = config.find(marker) + require(start != -1, f"Dev Railway config must define the {name} component.") + end = config.find("\n ],", start) + require(end != -1, f"Dev Railway config must close the {name} component.") + return config[start:end] + + +def main(): + """Run the Railway runtime secret regression checks.""" + config = read(DEV_CONFIG) + doc = read(DOC) + script_contents = [read(path) for path in DEPLOYMENT_SCRIPTS] + + for name in DEV_CONFIG_ENV_VARS: + require(f"getenv('{name}')" in config, f"Dev Railway config must use getenv('{name}').") + require(f"`{name}`" in doc, f"Docs must mention `{name}`.") + + for component, env_var in COMPONENT_PASSWORD_ENV.items(): + block = component_block(config, component) + require( + f"'password' => getenv('{env_var}')" in block, + f"{component} password must come from getenv('{env_var}').", + ) + require("'password' => '" not in block, f"{component} password must not be an inline literal.") + require("'password' => \"" not in block, f"{component} password must not be an inline literal.") + + for path, content in zip(DEPLOYMENT_SCRIPTS, script_contents): + for token in SCRIPT_TOKENS: + require(token in content, f"{path} must contain {token!r}.") + require('-p"$MYSQL_PASSWORD"' not in content, f"{path} must not pass MYSQL_PASSWORD on the command line.") + require('MYSQL_PASSWORD="' not in content, f"{path} must not assign a committed MYSQL_PASSWORD.") + + print("Railway DB runtime secret check passed.") + + +if __name__ == "__main__": + main() From 8a9b1cc028f47f8adb60eae3570d89d997f9ff8c Mon Sep 17 00:00:00 2001 From: William Joy Date: Sun, 17 May 2026 04:03:42 -0700 Subject: [PATCH 2/3] Harden runtime secret fragment guard --- tests/check-railway-db-runtime-secrets.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/check-railway-db-runtime-secrets.py b/tests/check-railway-db-runtime-secrets.py index 29b25c4b..e69ccf4d 100644 --- a/tests/check-railway-db-runtime-secrets.py +++ b/tests/check-railway-db-runtime-secrets.py @@ -1,6 +1,7 @@ """Validate that Railway database credentials stay runtime-configured.""" +import os from pathlib import Path @@ -45,6 +46,18 @@ } +def load_forbidden_secret_fragments(): + """Load optional secret fragments without committing them to this test.""" + fragments = [] + fragments.extend(os.environ.get("FORBIDDEN_SECRET_FRAGMENTS", "").splitlines()) + + fragment_file = os.environ.get("FORBIDDEN_SECRET_FRAGMENTS_FILE") + if fragment_file: + fragments.extend(Path(fragment_file).read_text(encoding="utf-8").splitlines()) + + return [fragment.strip() for fragment in fragments if fragment.strip()] + + def read(path): """Read a repository file as UTF-8 text.""" return path.read_text(encoding="utf-8") @@ -71,6 +84,12 @@ def main(): config = read(DEV_CONFIG) doc = read(DOC) script_contents = [read(path) for path in DEPLOYMENT_SCRIPTS] + forbidden_fragments = load_forbidden_secret_fragments() + checked_files = [ + (DEV_CONFIG, config), + (DOC, doc), + *zip(DEPLOYMENT_SCRIPTS, script_contents), + ] for name in DEV_CONFIG_ENV_VARS: require(f"getenv('{name}')" in config, f"Dev Railway config must use getenv('{name}').") @@ -91,6 +110,13 @@ def main(): require('-p"$MYSQL_PASSWORD"' not in content, f"{path} must not pass MYSQL_PASSWORD on the command line.") require('MYSQL_PASSWORD="' not in content, f"{path} must not assign a committed MYSQL_PASSWORD.") + for path, content in checked_files: + match_count = sum(1 for fragment in forbidden_fragments if fragment in content) + require( + match_count == 0, + f"{path} must not contain {match_count} runtime-provided forbidden secret fragment(s).", + ) + print("Railway DB runtime secret check passed.") From 8e25c3845c4b9dd6ff66362e29d6fc13eceb4de3 Mon Sep 17 00:00:00 2001 From: William Joy Date: Sun, 17 May 2026 04:14:32 -0700 Subject: [PATCH 3/3] Make Railway secret guard format tolerant --- tests/check-railway-db-runtime-secrets.py | 34 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/check-railway-db-runtime-secrets.py b/tests/check-railway-db-runtime-secrets.py index e69ccf4d..75e4f2ff 100644 --- a/tests/check-railway-db-runtime-secrets.py +++ b/tests/check-railway-db-runtime-secrets.py @@ -2,6 +2,7 @@ import os +import re from pathlib import Path @@ -53,7 +54,15 @@ def load_forbidden_secret_fragments(): fragment_file = os.environ.get("FORBIDDEN_SECRET_FRAGMENTS_FILE") if fragment_file: - fragments.extend(Path(fragment_file).read_text(encoding="utf-8").splitlines()) + fragment_path = Path(fragment_file) + require( + fragment_path.is_file(), + f"Forbidden secret fragment file is not readable: {fragment_file}", + ) + try: + fragments.extend(fragment_path.read_text(encoding="utf-8").splitlines()) + except OSError as error: + raise SystemExit(f"Could not read forbidden secret fragment file {fragment_file}: {error}") from error return [fragment.strip() for fragment in fragments if fragment.strip()] @@ -79,6 +88,22 @@ def component_block(config, name): return config[start:end] +def uses_getenv(text, env_var): + """Return whether text reads an environment variable via getenv.""" + return re.search(rf"getenv\(\s*['\"]{re.escape(env_var)}['\"]\s*\)", text) is not None + + +def password_uses_getenv(text, env_var): + """Return whether a PHP password key reads the expected environment variable.""" + pattern = rf"['\"]password['\"]\s*=>\s*getenv\(\s*['\"]{re.escape(env_var)}['\"]\s*\)" + return re.search(pattern, text) is not None + + +def password_has_inline_literal(text): + """Return whether a PHP password key is assigned a quoted literal.""" + return re.search(r"['\"]password['\"]\s*=>\s*['\"]", text) is not None + + def main(): """Run the Railway runtime secret regression checks.""" config = read(DEV_CONFIG) @@ -92,17 +117,16 @@ def main(): ] for name in DEV_CONFIG_ENV_VARS: - require(f"getenv('{name}')" in config, f"Dev Railway config must use getenv('{name}').") + require(uses_getenv(config, name), f"Dev Railway config must use getenv('{name}').") require(f"`{name}`" in doc, f"Docs must mention `{name}`.") for component, env_var in COMPONENT_PASSWORD_ENV.items(): block = component_block(config, component) require( - f"'password' => getenv('{env_var}')" in block, + password_uses_getenv(block, env_var), f"{component} password must come from getenv('{env_var}').", ) - require("'password' => '" not in block, f"{component} password must not be an inline literal.") - require("'password' => \"" not in block, f"{component} password must not be an inline literal.") + require(not password_has_inline_literal(block), f"{component} password must not be an inline literal.") for path, content in zip(DEPLOYMENT_SCRIPTS, script_contents): for token in SCRIPT_TOKENS: