-
Notifications
You must be signed in to change notification settings - Fork 155
fix(project): guard --password-file reads on create/update (#79) #321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
naufalfx805-source
wants to merge
1
commit into
TestSprite:main
from
naufalfx805-source:fix/issue-79-password-file-guard
+324
−2
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import { ApiError } from './errors.js'; | ||
| import { readSecretFileGuarded } from './secret-file.js'; | ||
|
|
||
| let tmpRoot: string; | ||
| const originalCwd = process.cwd(); | ||
|
|
||
| beforeEach(() => { | ||
| tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-secret-file-')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // mkdtempSync directory is small and short-lived; OS cleans it up. | ||
| process.chdir(originalCwd); | ||
| }); | ||
|
|
||
| describe('readSecretFileGuarded', () => { | ||
| it('returns the file contents', () => { | ||
| const path = join(tmpRoot, 'pw.txt'); | ||
| writeFileSync(path, 'hunter2'); | ||
| expect(readSecretFileGuarded('password-file', path)).toBe('hunter2'); | ||
| }); | ||
|
|
||
| it('trims surrounding whitespace and the trailing newline', () => { | ||
| const path = join(tmpRoot, 'pw-newline.txt'); | ||
| writeFileSync(path, ' hunter2 \n'); | ||
| expect(readSecretFileGuarded('password-file', path)).toBe('hunter2'); | ||
| }); | ||
|
|
||
| it('drops a leading UTF-8 BOM so PowerShell-written files still work', () => { | ||
| const path = join(tmpRoot, 'pw-bom.txt'); | ||
| writeFileSync(path, 'hunter2\n'); | ||
| expect(readSecretFileGuarded('password-file', path)).toBe('hunter2'); | ||
| }); | ||
|
|
||
| it('preserves interior whitespace', () => { | ||
| const path = join(tmpRoot, 'pw-spaces.txt'); | ||
| writeFileSync(path, 'two words\n'); | ||
| expect(readSecretFileGuarded('password-file', path)).toBe('two words'); | ||
| }); | ||
|
|
||
| it('resolves a relative path against the working directory', () => { | ||
| writeFileSync(join(tmpRoot, 'relative.txt'), 'from-cwd'); | ||
| process.chdir(tmpRoot); | ||
| expect(readSecretFileGuarded('password-file', 'relative.txt')).toBe('from-cwd'); | ||
| }); | ||
|
|
||
| it('returns an empty string for an empty file rather than throwing', () => { | ||
| const path = join(tmpRoot, 'empty.txt'); | ||
| writeFileSync(path, ''); | ||
| expect(readSecretFileGuarded('password-file', path)).toBe(''); | ||
| }); | ||
|
|
||
| describe('missing file', () => { | ||
| it('throws VALIDATION_ERROR with exit code 5', () => { | ||
| const path = join(tmpRoot, 'nope.txt'); | ||
| expect(() => readSecretFileGuarded('password-file', path)).toThrow(ApiError); | ||
| try { | ||
| readSecretFileGuarded('password-file', path); | ||
| expect.unreachable('should have thrown'); | ||
| } catch (err) { | ||
| expect(err).toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); | ||
| } | ||
| }); | ||
|
|
||
| it('names the offending flag and path in nextAction', () => { | ||
| const path = join(tmpRoot, 'nope.txt'); | ||
| try { | ||
| readSecretFileGuarded('password-file', path); | ||
| expect.unreachable('should have thrown'); | ||
| } catch (err) { | ||
| const { nextAction } = err as ApiError; | ||
| expect(nextAction).toContain('--password-file'); | ||
| expect(nextAction).toContain('file does not exist'); | ||
| expect(nextAction).toContain(path); | ||
| } | ||
| }); | ||
|
|
||
| it('attributes the error to whichever flag the caller names', () => { | ||
| const path = join(tmpRoot, 'nope.txt'); | ||
| try { | ||
| readSecretFileGuarded('client-secret-file', path); | ||
| expect.unreachable('should have thrown'); | ||
| } catch (err) { | ||
| expect((err as ApiError).nextAction).toContain('--client-secret-file'); | ||
| } | ||
| }); | ||
|
|
||
| it('reports the path as typed, not the resolved absolute path', () => { | ||
| process.chdir(tmpRoot); | ||
| try { | ||
| readSecretFileGuarded('password-file', 'missing.txt'); | ||
| expect.unreachable('should have thrown'); | ||
| } catch (err) { | ||
| const { nextAction } = err as ApiError; | ||
| expect(nextAction).toContain('missing.txt'); | ||
| expect(nextAction).not.toContain(tmpRoot); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe('directory instead of a file', () => { | ||
| it('throws VALIDATION_ERROR instead of crashing with EISDIR', () => { | ||
| const path = join(tmpRoot, 'a-directory'); | ||
| mkdirSync(path); | ||
| try { | ||
| readSecretFileGuarded('password-file', path); | ||
| expect.unreachable('should have thrown'); | ||
| } catch (err) { | ||
| expect(err).toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); | ||
| expect((err as ApiError).nextAction).toContain('not a regular file'); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| /** | ||
| * Guarded reader for the `--*-file` secret flags. | ||
| * | ||
| * Every one of these flags exists so a secret stays out of shell history, and | ||
| * every one of them is a path the user types by hand — so a typo is the | ||
| * expected failure, not an exceptional one. A bare | ||
| * `readFileSync(path, 'utf8').trim()` turns that typo into an unhandled Node | ||
| * exception: exit `1` instead of `5`, an `--output json` payload whose `error` | ||
| * is a bare string rather than the `{ code, message, nextAction }` envelope the | ||
| * rest of the CLI emits, and the absolute path plus errno leaked to stderr. | ||
| * | ||
| * This maps those failures onto the same typed `VALIDATION_ERROR` envelope the | ||
| * already-guarded file flags produce, mirroring `readCodeFileGuarded` in | ||
| * `src/commands/test.ts`. The payload cap is deliberately not carried over: | ||
| * secrets are small, and a size ceiling would be a behaviour change on a | ||
| * shipped flag rather than part of fixing the crash. | ||
| * | ||
| * Callers pass the flag name so the envelope names the flag the user actually | ||
| * typed — one helper serves `--password-file` today and the remaining | ||
| * credential/auto-auth file flags once they are migrated. | ||
| */ | ||
| import { readFileSync, statSync } from 'node:fs'; | ||
| import { isAbsolute, resolve } from 'node:path'; | ||
| import { localValidationError } from './errors.js'; | ||
|
|
||
| /** | ||
| * Read a secret from `path`, surfacing every filesystem failure as a typed | ||
| * `VALIDATION_ERROR` (exit 5) attributed to `flag`. | ||
| * | ||
| * The returned value is trimmed, matching what the unguarded call sites did. | ||
| * Trimming also drops a leading UTF-8 BOM: `U+FEFF` is ECMAScript whitespace, | ||
| * so a file written by PowerShell 5.1's default `Set-Content -Encoding utf8` | ||
| * no longer smuggles an invisible character into the secret. | ||
| * | ||
| * @param flag - Flag name without the leading dashes, e.g. `'password-file'`. | ||
| * @param path - Path as supplied by the user; may be relative. | ||
| * @throws {ApiError} `VALIDATION_ERROR` when the path is missing, unreadable, | ||
| * or not a regular file. | ||
| */ | ||
| export function readSecretFileGuarded(flag: string, path: string): string { | ||
| const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path); | ||
|
|
||
| let stat; | ||
| try { | ||
| stat = statSync(absolute); | ||
| } catch (err) { | ||
| throw secretFileError(flag, path, err, 'stat'); | ||
| } | ||
|
|
||
| // A directory would otherwise reach readFileSync and throw EISDIR on Linux | ||
| // while resolving to an empty read on some platforms — reject it up front so | ||
| // the contract is the same everywhere. | ||
| if (!stat.isFile()) { | ||
| throw localValidationError(flag, `not a regular file: ${path}`); | ||
| } | ||
|
|
||
| try { | ||
| return readFileSync(absolute, 'utf8').trim(); | ||
| } catch (err) { | ||
| throw secretFileError(flag, path, err, 'read'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Translate a Node filesystem error into the CLI's validation envelope, | ||
| * reporting the path the user typed rather than the resolved absolute path so | ||
| * no directory layout leaks into output. | ||
| */ | ||
| function secretFileError( | ||
| flag: string, | ||
| path: string, | ||
| err: unknown, | ||
| verb: 'stat' | 'read', | ||
| ): ReturnType<typeof localValidationError> { | ||
| const code = (err as NodeJS.ErrnoException).code; | ||
| if (code === 'ENOENT') { | ||
| return localValidationError(flag, `file does not exist: ${path}`); | ||
| } | ||
| if (code === 'EACCES' || code === 'EPERM') { | ||
| return localValidationError(flag, `permission denied reading ${path}`); | ||
| } | ||
| if (code === 'EISDIR') { | ||
| return localValidationError(flag, `not a regular file: ${path}`); | ||
| } | ||
| const reason = err instanceof Error ? err.message : 'unknown error'; | ||
| return localValidationError(flag, `cannot ${verb} ${path}: ${reason}`); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: TestSprite/testsprite-cli
Length of output: 8882
Information Disclosure (CWE-209): Generation of Error Message Containing Sensitive Information
Reachability: External · Exploitability: Trivial
Reachability path
Do not include raw filesystem messages in
nextAction.For unhandled filesystem errors such as
ENOTDIR, return a stable reason that includes only the user-supplied path.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions