From 09c76fba9c912df75d47584a248840ecc566bb82 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 02:03:44 +0300 Subject: [PATCH] fix: prevent DEV SSH child processes from swallowing deployment checks --- docs/operations/dev-ci-migration-role.md | 5 +++++ scripts/dev_ci_ssh.cjs | 5 ++++- scripts/tests/test_dev_ci_ssh.cjs | 26 +++++++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/operations/dev-ci-migration-role.md b/docs/operations/dev-ci-migration-role.md index 225c910a..4ce731ab 100644 --- a/docs/operations/dev-ci-migration-role.md +++ b/docs/operations/dev-ci-migration-role.md @@ -89,6 +89,11 @@ transport, не шифрование. В payload нет host/database overrides. и запускает только фиксированный `bash -se`. Весь deploy script передаётся через SSH stdin: secret-bearing script не попадает в argv удалённого shell и не записывается во временный файл. Старый SSH action заменён только в DEV workflow. +Скрипт передаётся единым Bash-блоком с stdin `/dev/null`: Bash разбирает его целиком +до выполнения, поэтому `docker exec` не может поглотить последующие checks из SSH stdin. +Явные heredoc payload/Python продолжают работать. Regression tests запускают настоящий +Bash через loopback SSH: stdin-consuming child не пропускает завершающие checks, +а ошибка последующего check завершает deployment с ошибкой. Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags, ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction. diff --git a/scripts/dev_ci_ssh.cjs b/scripts/dev_ci_ssh.cjs index 5cb48136..4aceef24 100644 --- a/scripts/dev_ci_ssh.cjs +++ b/scripts/dev_ci_ssh.cjs @@ -48,7 +48,10 @@ async function deploy(config, stdout = process.stdout, stderr = process.stderr) if (code === 0 && !signal) finish(); else finish(new Error('DEV remote deployment failed; inspect masked command output')); }); - stream.end(config.script.endsWith('\n') ? config.script : `${config.script}\n`); + // Bash разбирает весь блок до запуска: дочерние процессы получают EOF, + // поэтому docker exec не может прочитать оставшиеся команды из SSH stdin. + const script = config.script.endsWith('\n') ? config.script : `${config.script}\n`; + stream.end(`{\n${script}} { observed.input += chunk.toString(); }); + if (options.bash) { + const child = spawn(options.bash, ['-se'], { stdio: ['pipe', 'pipe', 'pipe'] }); + child.stdout.pipe(stream, { end: false }); + child.stderr.pipe(stream.stderr, { end: false }); + stream.pipe(child.stdin); + child.on('error', () => { stream.exit(127); stream.end(); }); + child.on('close', (code) => { stream.exit(code); stream.end(); }); + return; + } stream.on('end', () => { if (options.hang) return; stream.write('remote stdout\n'); @@ -61,7 +71,7 @@ test('exec argv is fixed; full secret-bearing script is delivered only on stdin' const f = await fixture(context); await deploy(f.config, f.stdout, f.stderr); assert.deepEqual(f.observed.commands, ['bash -se']); - assert.equal(f.observed.input, script); + assert.equal(f.observed.input, `{\n${script}} { const f = await fixture(context, { hang: true }); await assert.rejects(deploy({ ...f.config, timeoutMs: 1000 }, f.stdout, f.stderr), /timed out/); }); + +const bash = process.platform === 'win32' ? process.env.MIGRATION_TEST_BASH : '/bin/bash'; + +test('real shell: stdin-consuming child cannot swallow later checks or heredocs', { skip: !bash }, async (context) => { + const f = await fixture(context, { bash }); + await deploy({ ...f.config, script: "printf 'before\\n'\ncat >/dev/null\nprintf 'after\\n'\ncat <<'PAYLOAD'\nliteral $() `text`\nPAYLOAD\nprintf 'final-smoke-check\\n'\n" }, f.stdout, f.stderr); + assert.equal(f.output(), 'before\nafter\nliteral $() `text`\nfinal-smoke-check\n'); +}); + +test('real shell: failed check after stdin-consuming child fails deployment', { skip: !bash }, async (context) => { + const f = await fixture(context, { bash }); + await assert.rejects(deploy({ ...f.config, script: "cat >/dev/null\nfalse\nprintf 'must-not-run\\n'\n" }, f.stdout, f.stderr), /remote deployment failed/); + assert.ok(!f.output().includes('must-not-run')); +});