diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index ad482ae..2553695 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -2079,3 +2079,216 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); }); }); + +describe('#282 — secret --*-file flags are guarded (structured error, exit 5, no raw ENOENT)', () => { + const noNetwork = () => { + throw new Error('network should not be hit'); + }; + const deps = (credentialsPath: string) => ({ + credentialsPath, + fetchImpl: makeFetch(noNetwork), + stdout: () => {}, + stderr: () => {}, + }); + const missingPath = () => join(mkdtempSync(join(tmpdir(), 'cli-missing-')), 'no-such-secret.txt'); + + it('runCredential --credential-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCredential --credential-file pointing at a directory → VALIDATION_ERROR (exit 5)', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-dir-')); + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: dir, + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCredential reads a valid --credential-file (trimmed) and sends it', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-ok-')); + const credFile = join(dir, 'cred.txt'); + writeFileSync(credFile, ' tok-from-file\n'); + let sentBody: { credential?: string } | undefined; + const fetchImpl = makeFetch((_url, init) => { + sentBody = init.body ? JSON.parse(init.body as string) : undefined; + return { status: 200, body: { projectId: 'p1', authType: 'API key', rewroteCount: 1 } }; + }); + await runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: credFile, + }, + { credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + // The on-disk fixture is " tok-from-file\n"; the shared guard trims it. + expect(sentBody?.credential).toBe('tok-from-file'); + }); + + it('runAutoAuth --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'password', + inject: 'bearer', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runAutoAuth --client-secret-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'refresh_token', + inject: 'bearer', + clientSecretFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runAutoAuth --refresh-token-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + method: 'refresh_token', + inject: 'bearer', + refreshTokenFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runCreate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runCreate( + { + profile: 'default', + output: 'json', + debug: false, + type: 'frontend', + name: 'FE', + targetUrl: 'https://example.com', + username: 'u', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runUpdate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runUpdate( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + passwordFile: missingPath(), + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + }); + + it('runAutoAuth --dry-run with missing --password-file skips filesystem (returns sample)', async () => { + const { credentialsPath } = makeCreds(); + let fetched = false; + const fetchImpl = makeFetch(() => { + fetched = true; + return { body: {} }; + }); + const result = await runAutoAuth( + { + profile: 'default', + output: 'json', + debug: false, + dryRun: true, + projectId: 'p1', + method: 'password', + inject: 'bearer', + passwordFile: missingPath(), + }, + { credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} }, + ); + expect(fetched).toBe(false); + // blindfold: manual — dry-run skips file reads; returns sample with the projectId we passed in + expect(result.projectId).toBe('p1'); + }); + + it('runCredential --credential-file unreadable after stat → VALIDATION_ERROR (exit 5)', async () => { + if (process.getuid?.() === 0) return; // root bypasses permission checks + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-cred-mode-')); + const f = join(dir, 'secret.txt'); + writeFileSync(f, 'tok'); + chmodSync(f, 0o000); + try { + await expect( + runCredential( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'p1', + authType: 'API key', + credentialFile: f, + }, + deps(credentialsPath), + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + } finally { + chmodSync(f, 0o644); + } + }); +}); diff --git a/src/commands/project.ts b/src/commands/project.ts index 11f31f2..9b831b2 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; import { Command } from 'commander'; import { emitDryRunBanner, @@ -615,7 +614,7 @@ export async function runCredential( // except `public` (which clears it). let credential = opts.credential; if (credential === undefined && opts.credentialFile !== undefined) { - credential = readFileSync(opts.credentialFile, 'utf8').trim(); + credential = readSecretFileGuarded('credential-file', opts.credentialFile); } if (opts.authType !== 'public' && (credential === undefined || credential === '')) { throw localValidationError( @@ -721,22 +720,42 @@ export async function runAutoAuth( throw localValidationError(`--inject must be one of: ${AUTO_AUTH_INJECTS.join(', ')}`); } + const enabled = opts.disable !== true; + + const idempotencyKey = opts.idempotencyKey ?? `cli-proj-autoauth-${randomUUID()}`; + if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) { + stderr(`idempotency-key: ${idempotencyKey}`); + } + + if (opts.dryRun) { + const sample: CliProjectAutoAuthResponse = { + projectId: opts.projectId, + enabled, + method: opts.method, + inject: opts.inject, + }; + out.print(sample, data => renderAutoAuthText(data as CliProjectAutoAuthResponse)); + return sample; + } + // Resolve secrets from --*-file variants so they stay out of shell history. + // Placed after the dry-run early return so --dry-run never touches the filesystem. const password = opts.password ?? - (opts.passwordFile !== undefined ? readFileSync(opts.passwordFile, 'utf8').trim() : undefined); + (opts.passwordFile !== undefined + ? readSecretFileGuarded('password-file', opts.passwordFile) + : undefined); const clientSecret = opts.clientSecret ?? (opts.clientSecretFile !== undefined - ? readFileSync(opts.clientSecretFile, 'utf8').trim() + ? readSecretFileGuarded('client-secret-file', opts.clientSecretFile) : undefined); const refreshToken = opts.refreshToken ?? (opts.refreshTokenFile !== undefined - ? readFileSync(opts.refreshTokenFile, 'utf8').trim() + ? readSecretFileGuarded('refresh-token-file', opts.refreshTokenFile) : undefined); - const enabled = opts.disable !== true; const body: Record = { enabled, method: opts.method, inject: opts.inject }; const maybe = (k: string, v: string | undefined): void => { if (v !== undefined) body[k] = v; @@ -756,22 +775,6 @@ export async function runAutoAuth( maybe('scope', opts.scope); maybe('region', opts.region); - const idempotencyKey = opts.idempotencyKey ?? `cli-proj-autoauth-${randomUUID()}`; - if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) { - stderr(`idempotency-key: ${idempotencyKey}`); - } - - if (opts.dryRun) { - const sample: CliProjectAutoAuthResponse = { - projectId: opts.projectId, - enabled, - method: opts.method, - inject: opts.inject, - }; - out.print(sample, data => renderAutoAuthText(data as CliProjectAutoAuthResponse)); - return sample; - } - const client = makeClient(opts, deps); const res = await client.put( `/projects/${encodeURIComponent(opts.projectId)}/auto-auth`,