Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
runGet,
runList,
runUpdate,
MAX_SECRET_FILE_BYTES,
} from './project.js';

const PROJECT_FIXTURE: CliProject = {
Expand Down Expand Up @@ -1423,3 +1424,185 @@ 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 --credential-file over the size cap → PAYLOAD_TOO_LARGE (exit 5)', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-cred-big-'));
const bigFile = join(dir, 'big.txt');
writeFileSync(bigFile, 'a'.repeat(MAX_SECRET_FILE_BYTES + 1));
await expect(
runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: bigFile,
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE', 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: () => {} },
);
// blindfold: manual — the on-disk fixture written above is " tok-from-file\n"; the 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 });
});
});
77 changes: 70 additions & 7 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFileSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { Command } from 'commander';
import {
emitDryRunBanner,
Expand Down Expand Up @@ -210,7 +211,7 @@ export async function runCreate(
// Resolve password: flag > file > none
let password = opts.password;
if (password === undefined && opts.passwordFile !== undefined) {
password = readFileSync(opts.passwordFile, 'utf8').trim();
password = readSecretFileGuarded(opts.passwordFile, 'password-file');
}

const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`;
Expand Down Expand Up @@ -326,7 +327,7 @@ export async function runUpdate(
// filesystem, even when --password-file is present.
let password = opts.password;
if (password === undefined && opts.passwordFile !== undefined) {
password = readFileSync(opts.passwordFile, 'utf8').trim();
password = readSecretFileGuarded(opts.passwordFile, 'password-file');
}

const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`;
Expand Down Expand Up @@ -467,7 +468,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(opts.credentialFile, 'credential-file');
}
if (opts.authType !== 'public' && (credential === undefined || credential === '')) {
throw localValidationError(
Expand Down Expand Up @@ -576,16 +577,18 @@ export async function runAutoAuth(
// Resolve secrets from --*-file variants so they stay out of shell history.
const password =
opts.password ??
(opts.passwordFile !== undefined ? readFileSync(opts.passwordFile, 'utf8').trim() : undefined);
(opts.passwordFile !== undefined
? readSecretFileGuarded(opts.passwordFile, 'password-file')
: undefined);
const clientSecret =
opts.clientSecret ??
(opts.clientSecretFile !== undefined
? readFileSync(opts.clientSecretFile, 'utf8').trim()
? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file')
: undefined);
const refreshToken =
opts.refreshToken ??
(opts.refreshTokenFile !== undefined
? readFileSync(opts.refreshTokenFile, 'utf8').trim()
? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file')
: undefined);
Comment on lines 577 to 592

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Same dry-run bypass: secret-file resolution runs before the --dry-run check in runAutoAuth.

password/clientSecret/refreshToken are resolved (potentially reading files via readSecretFileGuarded) at Lines 578-592, but opts.dryRun isn't checked until Line 619. Any of --password-file, --client-secret-file, or --refresh-token-file will hit the real filesystem during --dry-run, contradicting the documented offline guarantee.

🔧 Proposed fix — move secret-file resolution after the dry-run early return
-  // Resolve secrets from --*-file variants so they stay out of shell history.
-  const password =
-    opts.password ??
-    (opts.passwordFile !== undefined
-      ? readSecretFileGuarded(opts.passwordFile, 'password-file')
-      : undefined);
-  const clientSecret =
-    opts.clientSecret ??
-    (opts.clientSecretFile !== undefined
-      ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file')
-      : undefined);
-  const refreshToken =
-    opts.refreshToken ??
-    (opts.refreshTokenFile !== undefined
-      ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file')
-      : undefined);
-
   const enabled = opts.disable !== true;
   const body: Record<string, unknown> = { enabled, method: opts.method, inject: opts.inject };

Then, immediately after if (opts.dryRun) { ... return sample; }:

+  // Resolve secrets from --*-file variants so they stay out of shell history.
+  // Deferred until after the dry-run return so preview mode never touches disk.
+  const password =
+    opts.password ??
+    (opts.passwordFile !== undefined
+      ? readSecretFileGuarded(opts.passwordFile, 'password-file')
+      : undefined);
+  const clientSecret =
+    opts.clientSecret ??
+    (opts.clientSecretFile !== undefined
+      ? readSecretFileGuarded(opts.clientSecretFile, 'client-secret-file')
+      : undefined);
+  const refreshToken =
+    opts.refreshToken ??
+    (opts.refreshTokenFile !== undefined
+      ? readSecretFileGuarded(opts.refreshTokenFile, 'refresh-token-file')
+      : undefined);
+  maybe('password', password);
+  maybe('clientSecret', clientSecret);
+  maybe('refreshToken', refreshToken);

(then remove the now-duplicate maybe('password', password) etc. calls further down, or restructure to build body in one place.)

As per path instructions, dry-run must skip local filesystem work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/project.ts` around lines 577 - 592, Move the password,
clientSecret, and refreshToken resolution using readSecretFileGuarded out of the
initial setup in runAutoAuth and place it after the opts.dryRun early return,
ensuring dry-run performs no filesystem access. Restructure the subsequent
request-body construction to use the resolved values without duplicate maybe
calls.

Source: Path instructions


const enabled = opts.disable !== true;
Expand Down Expand Up @@ -1082,3 +1085,63 @@ function localValidationError(message: string): ApiError {
},
});
}

/** Upper bound for any secret read from a `--*-file` flag. */
export const MAX_SECRET_FILE_BYTES = 64 * 1024;

/**
* Read a secret (password / credential / client-secret / refresh-token) from a
* `--*-file` flag, failing with a structured VALIDATION_ERROR (exit 5) instead
* of an unwrapped `readFileSync` error. A missing path, a non-regular file, or
* an oversized file all surface a typed envelope carrying the flag name — the
* same contract the `--code-file` guard in `test.ts` already provides. Without
* this, a missing file escaped as a raw `ENOENT` (exit 1) and broke the
* `--output json` envelope. `flagName` is the bare flag (e.g. `credential-file`).
*/
function readSecretFileGuarded(path: string, flagName: string): string {
const absolute = resolve(path);
let stat;
try {
stat = statSync(absolute);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw secretFileError(flagName, 'must point to a readable file', {
path: absolute,
error: message,
});
}

if (!stat.isFile()) {
throw secretFileError(flagName, 'must point to a regular file', { path: absolute });
}

if (stat.size > MAX_SECRET_FILE_BYTES) {
throw ApiError.fromEnvelope({
error: {
code: 'PAYLOAD_TOO_LARGE',
message: 'Secret file is too large.',
nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`,
requestId: 'local',
details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES },
},
});
}

return readFileSync(absolute, 'utf8').trim();
}
Comment on lines +1101 to +1131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Node.js fs.statSync succeed on a file the process lacks read permission for, while fs.readFileSync fails with EACCES?

💡 Result:

No, fs.statSync does not necessarily succeed if the process lacks read permission for the file. The success of both fs.statSync and fs.readFileSync depends on the operating system's permission model, and both may fail with EACCES if the process does not have the required access rights [1][2]. In POSIX-compliant systems, fs.statSync requires the process to have permission to traverse (execute/search) all directories in the path leading to the file [3]. If the process lacks the necessary permissions to traverse the path or access the file's metadata, fs.statSync will throw an EACCES error [1][3]. Similarly, fs.readFileSync requires read permission for the file itself, and will fail with EACCES if that permission is missing [1][2]. While some users might observe scenarios where metadata is accessible while file content is not, this is determined by the underlying file system permissions and the kernel's enforcement of them, not by a difference in how Node.js handles these specific operations [1]. If an EACCES error occurs, it indicates that the operating system has explicitly denied the request based on the current process's user/group identity and the file's security settings [4][1]. Node.js merely surfaces the error provided by the operating system [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate project.ts =="
fd -a 'project\.ts$' . | sed 's#^\./##'

echo "== inspect relevant helper and usages =="
file="$(fd 'project\.ts$' . | head -n 1)"
wc -l "$file"
sed -n '1060,1145p' "$file"

echo "== find readSecretFileGuarded usages =="
rg -n "readSecretFileGuarded|secretFileError|MAX_SECRET_FILE_BYTES|--dry-run|readFileSync|statSync" "$file" DOCUMENTATION.md 2>/dev/null || true

echo "== behavioral Node.fs stat/read permission probe =="
node - <<'JS'
const fs = require('fs');
const path = require('path');
const os = require('os');
const tmp = require('os').tmpdir();
let fn;
try {
  fn = path.join(tmp, `secret-mode-probe-${process.pid}-${process.hrtime.bigint().toString()}`);
  fs.writeFileSync(fn, 'secret');
  const before = fs.statSync(fn);
  fs.chmodSync(fn, 0);
  console.log('node', process.version);
  console.log('platform', process.platform);
  console.log('effectiveUid', process.geteuid());
  console.log('initial stat ok, isFile=' + before.isFile());
  try {
    const data = fs.readFileSync(fn, 'utf8').trim();
    console.log('readFileSync result:', data);
  } catch (err) {
    console.log('readFileSync error code=' + err.code + ' msg=' + err.message);
  }
  console.log('final stat after chmod 0');
  try {
    const post = fs.statSync(fn);
    console.log('stat ok, isFile=' + post.isFile());
  } catch (err) {
    console.log('stat error code=' + err.code + ' msg=' + err.message);
  }
} finally {
  try { fs.chmodSync(fn, 0o600); fs.unlinkSync(fn); } catch {}
}
JS

Repository: TestSprite/testsprite-cli

Length of output: 9913


Wrap the final secret-file read in this helper

statSync can succeed while readFileSync fails with EACCES when the file becomes unreadable after stat (for example, root running against a mode-0 file). Those failures currently surface as a raw filesystem error instead of the documented structured validation envelope.

🔧 Proposed fix — guard the final read too
-  return readFileSync(absolute, 'utf8').trim();
+  try {
+    return readFileSync(absolute, 'utf8').trim();
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    throw secretFileError(flagName, 'must point to a readable file', {
+      path: absolute,
+      error: message,
+    });
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function readSecretFileGuarded(path: string, flagName: string): string {
const absolute = resolve(path);
let stat;
try {
stat = statSync(absolute);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw secretFileError(flagName, 'must point to a readable file', {
path: absolute,
error: message,
});
}
if (!stat.isFile()) {
throw secretFileError(flagName, 'must point to a regular file', { path: absolute });
}
if (stat.size > MAX_SECRET_FILE_BYTES) {
throw ApiError.fromEnvelope({
error: {
code: 'PAYLOAD_TOO_LARGE',
message: 'Secret file is too large.',
nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`,
requestId: 'local',
details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES },
},
});
}
return readFileSync(absolute, 'utf8').trim();
}
function readSecretFileGuarded(path: string, flagName: string): string {
const absolute = resolve(path);
let stat;
try {
stat = statSync(absolute);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw secretFileError(flagName, 'must point to a readable file', {
path: absolute,
error: message,
});
}
if (!stat.isFile()) {
throw secretFileError(flagName, 'must point to a regular file', { path: absolute });
}
if (stat.size > MAX_SECRET_FILE_BYTES) {
throw ApiError.fromEnvelope({
error: {
code: 'PAYLOAD_TOO_LARGE',
message: 'Secret file is too large.',
nextAction: `Flag \`--${flagName}\` is invalid: file must be at most ${MAX_SECRET_FILE_BYTES} bytes.`,
requestId: 'local',
details: { field: flagName, sizeBytes: stat.size, maxBytes: MAX_SECRET_FILE_BYTES },
},
});
}
try {
return readFileSync(absolute, 'utf8').trim();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw secretFileError(flagName, 'must point to a readable file', {
path: absolute,
error: message,
});
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/project.ts` around lines 1101 - 1131, Update
readSecretFileGuarded so the final readFileSync operation is wrapped in the same
structured error handling as statSync. Convert read failures into
secretFileError(flagName, 'must point to a readable file', including the
absolute path and underlying error message), while preserving the existing
validation and trimmed successful-read behavior.

Source: Path instructions


function secretFileError(
flagName: string,
reason: string,
details: Record<string, unknown>,
): ApiError {
return ApiError.fromEnvelope({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request.',
nextAction: `Flag \`--${flagName}\` is invalid: ${reason}.`,
requestId: 'local',
details: { field: flagName, reason, ...details },
},
});
}
Loading