From 7ceea425440073000a62ba12940215c1dc1e97c5 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 1 Oct 2026 17:55:36 -0700 Subject: [PATCH] fix: keep pulled secrets private on disk --- src/commands/secrets.ts | 13 ++++++++--- test/secrets-permissions.test.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 test/secrets-permissions.test.ts diff --git a/src/commands/secrets.ts b/src/commands/secrets.ts index 3812712..17434f6 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -1,5 +1,5 @@ -import { writeFile } from 'node:fs/promises' -import { appendFileSync, existsSync, readFileSync } from 'node:fs' +import { open } from 'node:fs/promises' +import { appendFileSync, constants, existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { ApiClient, requireProject } from '../api.js' import { info, printJson, serializeEnv, handleApproval, die } from '../util.js' @@ -107,7 +107,14 @@ export async function secrets( warnCollisions(b.collisions, branchHint(branch, d.linkedBranch)) if (opts.print) { process.stdout.write(serializeEnv(bundle)); return } const out = opts.output ?? '.env' - await writeFile(out, serializeEnv(bundle)) + const file = await open(out, constants.O_WRONLY | constants.O_CREAT, 0o600) + try { + await file.chmod((await file.stat()).mode & 0o600) + await file.truncate(0) + await file.writeFile(serializeEnv(bundle)) + } finally { + await file.close() + } const scope = opts.service ? `${opts.service}, branch ${branch}` : `branch ${branch}` info(`wrote ${Object.keys(bundle).length} secrets to ${out} (${scope})`) if (ensureIgnored(process.cwd(), out)) info(` .gitignore += ${out} (credentials must never be committed)`) diff --git a/test/secrets-permissions.test.ts b/test/secrets-permissions.test.ts new file mode 100644 index 0000000..39cc3be --- /dev/null +++ b/test/secrets-permissions.test.ts @@ -0,0 +1,37 @@ +import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { secrets } from '../src/commands/secrets.js' + +it.each([ + { name: 'new file', mode: undefined, linked: false }, + { name: 'existing readable file', mode: 0o644, linked: false }, + { name: 'existing write-only file', mode: 0o200, linked: false }, + { name: 'symlink target', mode: 0o644, linked: true }, +])('writes private secret output: $name', async ({ mode, linked }) => { + if (linked && process.platform === 'win32') return + const dir = mkdtempSync(join(tmpdir(), 'insta-secrets-permissions-')) + const cwd = process.cwd() + const target = join(dir, 'secrets.env') + const output = linked ? join(dir, '.env') : target + try { + process.chdir(dir) + if (mode !== undefined) { + writeFileSync(target, 'OLD_SECRET=' + 'old-private-value'.repeat(20) + '\n') + chmodSync(target, mode) + } + if (linked) symlinkSync('secrets.env', output) + await secrets({ output }, { + projectId: 'p1', linkedBranch: 'main', + api: { rawRequest: async () => ({ status: 200, body: { secrets: { PASSWORD: 'private-value' } } }) }, + }) + if (process.platform !== 'win32') expect(statSync(target).mode & 0o777).toBe(mode === 0o200 ? 0o200 : 0o600) + if (linked) expect(lstatSync(output).isSymbolicLink()).toBe(true) + chmodSync(target, 0o600) + expect(readFileSync(target, 'utf8')).toBe('PASSWORD="private-value"\n') + } finally { + process.chdir(cwd) + rmSync(dir, { recursive: true, force: true }) + } +})