diff --git a/packages/cli/src/commands/fix/cmd-fix.mts b/packages/cli/src/commands/fix/cmd-fix.mts index 0cfbdf7d2..bd1ebb033 100644 --- a/packages/cli/src/commands/fix/cmd-fix.mts +++ b/packages/cli/src/commands/fix/cmd-fix.mts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import path from 'node:path' import terminalLink from 'terminal-link' @@ -408,6 +409,54 @@ async function run( return } + // Detect the common mistake of passing a vulnerability ID (GHSA / CVE / + // PURL) as a positional argument when the user meant to use `--id`. + // Without this guard we treat the ID as a directory path, resolve to cwd, + // and eventually fail with a confusing upload error. Run this before + // `getDefaultOrgSlug()` so users still get the helpful message when no + // API token is configured. + const rawInput = cli.input[0] + if (rawInput) { + const upperInput = rawInput.toUpperCase() + const isGhsa = upperInput.startsWith('GHSA-') + const isCve = upperInput.startsWith('CVE-') + const isPurl = rawInput.startsWith('pkg:') + if (isGhsa || isCve || isPurl) { + // `handle-fix.mts` validates IDs with case-sensitive format regexes: + // * GHSA — prefix must be uppercase, body segments lowercase [a-z0-9] + // * CVE — prefix must be uppercase, body is all digits (case-free) + // PURLs are intentionally lowercase and validated separately. + let suggestion: string + if (isGhsa) { + suggestion = 'GHSA-' + rawInput.slice(5).toLowerCase() + } else if (isCve) { + suggestion = 'CVE-' + rawInput.slice(4) + } else { + suggestion = rawInput + } + logger.fail( + `"${rawInput}" looks like a vulnerability identifier, not a directory path.\nDid you mean: socket fix ${FLAG_ID} ${suggestion}`, + ) + process.exitCode = 1 + return + } + } + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + // Validate the target directory exists so we fail fast with a clear + // message instead of the API's "Need at least one file to be uploaded". + // Also runs before the org-slug resolution so the user sees a clearer + // error when pointing at a typo'd path without an API token set. + if (!existsSync(cwd)) { + logger.fail(`Target directory does not exist: ${cwd}`) + process.exitCode = 1 + return + } + const orgSlugCResult = await getDefaultOrgSlug() if (!orgSlugCResult.ok) { process.exitCode = orgSlugCResult.code ?? 1 @@ -419,11 +468,6 @@ async function run( const orgSlug = orgSlugCResult.data - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - const spinner = undefined const includePatterns = cmdFlagValueToArray(include) diff --git a/packages/cli/test/unit/commands/fix/cmd-fix.test.mts b/packages/cli/test/unit/commands/fix/cmd-fix.test.mts index db55763e5..e87346794 100644 --- a/packages/cli/test/unit/commands/fix/cmd-fix.test.mts +++ b/packages/cli/test/unit/commands/fix/cmd-fix.test.mts @@ -331,14 +331,80 @@ describe('cmd-fix', () => { ) }) - it('should support custom cwd argument', async () => { - await cmdFix.run(['./custom/path'], importMeta, context) + describe('misplaced vulnerability identifier detection', () => { + // The case matrix handle-fix.mts actually validates downstream: + // GHSA: /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ + // CVE: /^CVE-\d{4}-\d{4,}$/ + // Suggestion must be exactly the form that passes those regexes, + // otherwise the user follows our advice and still gets an error. + it.each([ + // [label, input, expectedSuggestion] + // GHSA: prefix to upper, body to lower, regardless of input casing. + ['canonical GHSA', 'GHSA-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'], + ['lowercase GHSA', 'ghsa-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'], + ['mixed-case GHSA', 'GhSa-AbCd-EfGh-IjKl', 'GHSA-abcd-efgh-ijkl'], + // CVE: prefix to upper, body is digits so case is a no-op. + ['canonical CVE', 'CVE-2021-23337', 'CVE-2021-23337'], + ['lowercase CVE', 'cve-2021-23337', 'CVE-2021-23337'], + // PURL: always lowercase by spec, echo verbatim. + ['npm PURL', 'pkg:npm/left-pad@1.3.0', 'pkg:npm/left-pad@1.3.0'], + ])( + 'detects %s and suggests the downstream-valid form', + async (_label, input, expectedSuggestion) => { + await cmdFix.run([input], importMeta, context) + + expect(process.exitCode).toBe(1) + expect(mockHandleFix).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining( + 'looks like a vulnerability identifier, not a directory path', + ), + ) + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining(`--id ${expectedSuggestion}`), + ) + }, + ) + + it('validates IDs before resolving the org slug (no API token path)', async () => { + await cmdFix.run(['GHSA-xxxx-xxxx-xxxx'], importMeta, context) + + // The check must run *before* `getDefaultOrgSlug`, so users without + // a configured API token still see the helpful message instead of + // the generic "Unable to resolve org". + expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled() + }) + }) - expect(mockHandleFix).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining('custom/path'), - }), - ) + describe('target directory validation', () => { + it('should fail fast when target directory does not exist', async () => { + await cmdFix.run(['./this/path/does/not/exist'], importMeta, context) + + expect(process.exitCode).toBe(1) + expect(mockHandleFix).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining('Target directory does not exist'), + ) + }) + + it('validates the directory before resolving the org slug', async () => { + await cmdFix.run(['./this/path/does/not/exist'], importMeta, context) + + expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled() + }) + + it('lets a real directory flow through to handleFix', async () => { + const realDir = process.cwd() + await cmdFix.run([realDir], importMeta, context) + + expect(mockHandleFix).toHaveBeenCalledWith( + expect.objectContaining({ cwd: realDir }), + ) + // Sanity: no bail on the happy path. + expect(mockLogger.fail).not.toHaveBeenCalledWith( + expect.stringContaining('Target directory does not exist'), + ) + }) }) it('should support --json output mode', async () => {