fix(staging): rotate SSH secret handling and env ownership
Staging Backend Quality Gate / validate (pull_request) Successful in 16m32s
Staging Backend Quality Gate / publish (pull_request) Has been skipped

This commit is contained in:
Jesse
2026-08-06 15:34:31 +08:00
parent eccd831ae9
commit cfc7af6b46
16 changed files with 153 additions and 22 deletions
@@ -192,6 +192,37 @@ test("database env validator rejects symlinks and unsafe modes", () => {
}
});
test("database env validator enforces an explicit staging owner uid without printing values", () => {
const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-owner-"));
const envFile = join(root, ".env.staging.database");
try {
writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o600 });
chmodSync(envFile, 0o600);
const currentUid = process.getuid?.();
assert.equal(typeof currentUid, "number");
const accepted = spawnSync("bash", [validator, envFile], {
encoding: "utf8",
env: { ...process.env, EXPECTED_STAGING_ENV_OWNER_UID: String(currentUid) },
});
assert.equal(accepted.status, 0, accepted.stderr);
const rejected = spawnSync("bash", [validator, envFile], {
encoding: "utf8",
env: { ...process.env, EXPECTED_STAGING_ENV_OWNER_UID: String((currentUid ?? 0) + 1) },
});
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /invalid owner/);
assert.doesNotMatch(
`${rejected.stdout}${rejected.stderr}`,
/postgres-test-password|schema-owner-test-password|staging-backup-test-password/,
);
} 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");
@@ -46,6 +46,10 @@ const rolloutWorkflow = new URL(
"../../.github/workflows/configure-staging-rectification-rollout.yml",
import.meta.url,
);
const resetStagingAccountWorkflow = new URL(
"../../.github/workflows/reset-staging-account.yml",
import.meta.url,
);
const deployScript = new URL(
"../../deploy/run-staging-deploy.sh",
import.meta.url,
@@ -259,6 +263,40 @@ test("Gitea staging mutation workflows use the available runner and pinned Node
}
});
test("staging SSH secrets are single-line base64 and never injected as multiline private keys", () => {
for (const workflow of [
read(giteaDeployWorkflow),
read(giteaMigrationWorkflow),
read(deployWorkflow),
read(migrationWorkflow),
read(rolloutWorkflow),
read(resetStagingAccountWorkflow),
]) {
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.STAGING_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /printf '%s' "\$SSH_PRIVATE_KEY_BASE64" \| base64 --decode/);
assert.match(workflow, /chmod 600 [^\n]*(?:\$key_path|jyotisha-staging)/);
assert.match(workflow, /ssh-keygen -y -f [^\n]+>\/dev\/null/);
assert.doesNotMatch(workflow, /\n\s+SSH_PRIVATE_KEY: \$\{\{ secrets\.STAGING_SSH_PRIVATE_KEY \}\}/);
assert.doesNotMatch(workflow, /printf '%s\\n' "\$SSH_PRIVATE_KEY"/);
}
});
test("staging scripts validate deploy-owned env files and rollout preserves their owner", () => {
const deployRunner = read(deployScript);
const migrationRunner = read(migrationScript);
const rolloutRunner = read(rolloutScript);
for (const runner of [deployRunner, migrationRunner]) {
assert.match(runner, /EXPECTED_STAGING_ENV_OWNER_UID=.*stat[^\n]+"\$DEPLOY_PATH"/);
assert.match(runner, /export EXPECTED_STAGING_ENV_OWNER_UID/);
assert.ok(runner.indexOf("EXPECTED_STAGING_ENV_OWNER_UID=") < runner.indexOf("validate-staging-database-env.sh"));
}
assert.match(rolloutRunner, /EXPECTED_STAGING_ENV_OWNER_UID=.*stat[^\n]+"\$DEPLOY_PATH"/);
assert.match(rolloutRunner, /EXPECTED_STAGING_ENV_OWNER_GID=.*stat[^\n]+"\$DEPLOY_PATH"/);
assert.match(rolloutRunner, /chown "\$EXPECTED_STAGING_ENV_OWNER_UID:\$EXPECTED_STAGING_ENV_OWNER_GID" "\$temporary"/);
assert.ok(rolloutRunner.indexOf("chown \"$EXPECTED_STAGING_ENV_OWNER_UID") < rolloutRunner.indexOf("mv -f -- \"$temporary\" \"$env_file\""));
});
test("quality gate builds the Python package with its declared backend dependencies", () => {
const workflow = read(qualityWorkflow);