From 9ff5e6266cb0461a4211189b85b4b84552c83036 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 21:39:12 +0800 Subject: [PATCH] fix: harden staging database env validation --- .gitignore | 1 + deploy/validate-staging-database-env.sh | 4 +- frontend/tests/database-env-validator.test.ts | 140 ++++++++++++++++++ frontend/tests/database-topology.test.ts | 15 +- 4 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/database-env-validator.test.ts diff --git a/.gitignore b/.gitignore index 536c62b3..a1818814 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ brain/ COVERAGE_AUDIT_REPORT.md *.tmp .env.local +.env.staging.database .jyotish.local.env .coverage coverage.xml diff --git a/deploy/validate-staging-database-env.sh b/deploy/validate-staging-database-env.sh index f317dd40..e14ece9c 100755 --- a/deploy/validate-staging-database-env.sh +++ b/deploy/validate-staging-database-env.sh @@ -43,12 +43,12 @@ fi definition_count() { local key="$1" - grep -Ec "^(export[[:space:]]+)?${key}=" "$ENV_FILE" || true + grep -Ec "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$ENV_FILE" || true } environment_value() { local key="$1" - sed -n -E "s/^(export[[:space:]]+)?${key}=(.*)$/\\2/p" "$ENV_FILE" + sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" "$ENV_FILE" } require_once_non_empty() { diff --git a/frontend/tests/database-env-validator.test.ts b/frontend/tests/database-env-validator.test.ts new file mode 100644 index 00000000..290ac8ba --- /dev/null +++ b/frontend/tests/database-env-validator.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { test } from "node:test"; + +const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); +const validator = join(repositoryRoot, "deploy/validate-staging-database-env.sh"); +const validEnvironment = [ + "POSTGRES_DB=jyotisha", + "POSTGRES_USER=postgres", + "POSTGRES_PASSWORD=postgres-test-password", + "SCHEMA_OWNER_PASSWORD=schema-owner-test-password", + "IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password", + "APP_RUNTIME_PASSWORD=app-runtime-test-password", + "ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password", + "MIGRATION_RUNNER_PASSWORD=migration-runner-test-password", + "BACKUP_READER_PASSWORD=backup-reader-test-password", + "STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password", + "SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha", +]; + +test("database env validator rejects Compose-compatible duplicate selectors without printing values", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + const composeFile = join(root, "compose.yml"); + + try { + writeFileSync( + composeFile, + [ + "services:", + " probe:", + " image: alpine", + " environment:", + " SELECTED: ${POSTGRES_DB}", + "", + ].join("\n"), + ); + writeFileSync( + envFile, + `${[...validEnvironment, " POSTGRES_DB = evil"].join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const rendered = spawnSync( + "docker", + [ + "compose", + "--env-file", + envFile, + "-f", + composeFile, + "config", + "--format", + "json", + ], + { encoding: "utf8" }, + ); + assert.equal(rendered.status, 0, rendered.stderr); + assert.equal( + JSON.parse(rendered.stdout).services.probe.environment.SELECTED, + "evil", + ); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator rejects symlinks and unsafe modes", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + const target = join(root, "database-target.env"); + + try { + writeFileSync(target, `${validEnvironment.join("\n")}\n`, { mode: 0o600 }); + chmodSync(target, 0o600); + symlinkSync(target, envFile); + assert.notEqual( + spawnSync("bash", [validator, envFile], { encoding: "utf8" }).status, + 0, + ); + + rmSync(envFile); + writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o644 }); + chmodSync(envFile, 0o644); + assert.notEqual( + spawnSync("bash", [validator, envFile], { encoding: "utf8" }).status, + 0, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator accepts a private valid file without printing values", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o600 }); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "staging database environment validated\n"); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("staging database environment file is ignored", () => { + const result = spawnSync("git", ["check-ignore", ".env.staging.database"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ".env.staging.database\n"); +}); diff --git a/frontend/tests/database-topology.test.ts b/frontend/tests/database-topology.test.ts index f6309675..5af2b456 100644 --- a/frontend/tests/database-topology.test.ts +++ b/frontend/tests/database-topology.test.ts @@ -17,19 +17,20 @@ test("database roles have no cluster privileges", () => { assert.equal( fixture.psql(` select rolname || ':' || rolsuper || ':' || rolcreatedb || ':' || - rolcreaterole || ':' || rolbypassrls + rolcreaterole || ':' || rolbypassrls || ':' || + rolreplication || ':' || rolinherit from pg_roles where rolname in ('schema_owner','identity_runtime','app_runtime', 'admin_runtime','migration_runner','backup_reader') order by rolname `), [ - "admin_runtime:f:f:f:f", - "app_runtime:f:f:f:f", - "backup_reader:f:f:f:f", - "identity_runtime:f:f:f:f", - "migration_runner:f:f:f:f", - "schema_owner:f:f:f:f", + "admin_runtime:f:f:f:f:f:f", + "app_runtime:f:f:f:f:f:f", + "backup_reader:f:f:f:f:f:f", + "identity_runtime:f:f:f:f:f:f", + "migration_runner:f:f:f:f:f:f", + "schema_owner:f:f:f:f:f:f", ].join("\n"), ); } finally {