Skip to content

Commit 2976ce0

Browse files
authored
fix(fix): validate target directory and detect misplaced IDs (#1227)
* fix(fix): validate target directory and detect misplaced IDs Backport of v1.x #1199 to main. Two quality-of-life fixes for \`socket fix\` that surface clear error messages instead of the confusing API error the user hits today: 1. **Misplaced vulnerability ID**: if the first positional arg looks like a GHSA, CVE, or PURL, we treat it as a directory path and eventually fail with "Need at least one file to be uploaded" from the API. Now we detect the pattern early, fail fast with a "Did you mean: socket fix --id <that value>" hint. 2. **Nonexistent target directory**: previously the directory wasn't validated upfront, so we'd happily resolve it, glob zero files, upload nothing, and the API would reject with the same confusing "Need at least one file to be uploaded" message. Now we \`existsSync()\` the resolved path and fail with "Target directory does not exist: <path>". Tests: * Reworked the existing "should support custom cwd argument" test to pass \`process.cwd()\` (guaranteed to exist) now that we validate the dir. * Added "should fail fast when target directory does not exist". * Added "should fail fast when a GHSA is passed as a positional arg". * fix(fix): move input validation before org-slug resolution Addresses both Cursor bugbot findings on #1227: 1. **[Medium] Misplaced-ID check was behind getDefaultOrgSlug()**. If a user had no API token configured, `getDefaultOrgSlug()` would fail first and the helpful "looks like a vulnerability identifier" / "Target directory does not exist" messages never reached them. Moved both validation blocks ahead of the org-slug resolution — they only depend on `cli.input[0]`, no token required. 2. **[Low] Case-insensitive detection with verbatim echo**. The regex matched `ghsa-abcd-…` but `handle-fix.mts` matches the GHSA/CVE prefix case-sensitively, so the suggested `socket fix --id ghsa-…` would fail downstream with "Unsupported ID format". Now we normalize GHSA/CVE to uppercase in the suggestion while keeping PURLs unchanged (they're intentionally lowercase). Added tests: - uppercase normalization of a lowercase GHSA in the suggestion. - validation short-circuits before `getDefaultOrgSlug` for both the misplaced-ID path and the missing-target-dir path. * fix(fix): preserve GHSA body case in uppercased suggestion Cursor bugbot caught a second-order bug in my earlier case fix on #1227. The previous commit uppercased the *entire* rawInput, but handle-fix.mts's format validator is GHSA_FORMAT_REGEXP = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/ which requires the body segments to be lowercase. A user typing `ghsa-abcd-efgh-ijkl` would have been told to run `socket fix --id GHSA-ABCD-EFGH-IJKL`, and that command would fail downstream with "Invalid GHSA format". Fix: only uppercase the prefix. * GHSA: `'GHSA-' + body.toLowerCase()` * CVE: `'CVE-' + rest` (body is digits + dashes, case-free) * PURL: leave verbatim. Added tests covering all three branches. * test(fix): tighten coverage of input-validation branches Tightens the cmd-fix tests introduced with the input-validation work: * Consolidates four separate ID-detection tests into an `it.each` table that exercises the real case matrix against handle-fix.mts's downstream format regexes: - canonical / lowercase / mixed-case GHSA - canonical / lowercase CVE - PURL (unchanged) Each row asserts both the "looks like a vulnerability identifier" message and the exact downstream-valid `--id <suggestion>` form. Previously a mixed-case GHSA and the canonical CVE/GHSA cases weren't covered, so a future regression to the case-normalization logic could slip by. * Splits the directory-validation tests into a dedicated `describe('target directory validation', ...)` block and adds a happy-path assertion ("lets a real directory flow through to handleFix") so we can tell the difference between "validation passed" and "validation silently skipped". * Folded the redundant "should support custom cwd argument" test into the happy-path assertion — same setup, one test instead of two with overlapping mocks. * Order-of-operations assertions (that validation runs before `getDefaultOrgSlug`) now live next to the related bail tests instead of floating at the end of the outer describe. Net: 6 cases in the ID-detection table (up from 3 hardcoded), plus 3 directory-validation tests including the happy path. 47 tests total.
1 parent 9fae51f commit 2976ce0

File tree

2 files changed

+122
-12
lines changed

2 files changed

+122
-12
lines changed

packages/cli/src/commands/fix/cmd-fix.mts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { existsSync } from 'node:fs'
12
import path from 'node:path'
23

34
import terminalLink from 'terminal-link'
@@ -408,6 +409,54 @@ async function run(
408409
return
409410
}
410411

412+
// Detect the common mistake of passing a vulnerability ID (GHSA / CVE /
413+
// PURL) as a positional argument when the user meant to use `--id`.
414+
// Without this guard we treat the ID as a directory path, resolve to cwd,
415+
// and eventually fail with a confusing upload error. Run this before
416+
// `getDefaultOrgSlug()` so users still get the helpful message when no
417+
// API token is configured.
418+
const rawInput = cli.input[0]
419+
if (rawInput) {
420+
const upperInput = rawInput.toUpperCase()
421+
const isGhsa = upperInput.startsWith('GHSA-')
422+
const isCve = upperInput.startsWith('CVE-')
423+
const isPurl = rawInput.startsWith('pkg:')
424+
if (isGhsa || isCve || isPurl) {
425+
// `handle-fix.mts` validates IDs with case-sensitive format regexes:
426+
// * GHSA — prefix must be uppercase, body segments lowercase [a-z0-9]
427+
// * CVE — prefix must be uppercase, body is all digits (case-free)
428+
// PURLs are intentionally lowercase and validated separately.
429+
let suggestion: string
430+
if (isGhsa) {
431+
suggestion = 'GHSA-' + rawInput.slice(5).toLowerCase()
432+
} else if (isCve) {
433+
suggestion = 'CVE-' + rawInput.slice(4)
434+
} else {
435+
suggestion = rawInput
436+
}
437+
logger.fail(
438+
`"${rawInput}" looks like a vulnerability identifier, not a directory path.\nDid you mean: socket fix ${FLAG_ID} ${suggestion}`,
439+
)
440+
process.exitCode = 1
441+
return
442+
}
443+
}
444+
445+
let [cwd = '.'] = cli.input
446+
// Note: path.resolve vs .join:
447+
// If given path is absolute then cwd should not affect it.
448+
cwd = path.resolve(process.cwd(), cwd)
449+
450+
// Validate the target directory exists so we fail fast with a clear
451+
// message instead of the API's "Need at least one file to be uploaded".
452+
// Also runs before the org-slug resolution so the user sees a clearer
453+
// error when pointing at a typo'd path without an API token set.
454+
if (!existsSync(cwd)) {
455+
logger.fail(`Target directory does not exist: ${cwd}`)
456+
process.exitCode = 1
457+
return
458+
}
459+
411460
const orgSlugCResult = await getDefaultOrgSlug()
412461
if (!orgSlugCResult.ok) {
413462
process.exitCode = orgSlugCResult.code ?? 1
@@ -419,11 +468,6 @@ async function run(
419468

420469
const orgSlug = orgSlugCResult.data
421470

422-
let [cwd = '.'] = cli.input
423-
// Note: path.resolve vs .join:
424-
// If given path is absolute then cwd should not affect it.
425-
cwd = path.resolve(process.cwd(), cwd)
426-
427471
const spinner = undefined
428472

429473
const includePatterns = cmdFlagValueToArray(include)

packages/cli/test/unit/commands/fix/cmd-fix.test.mts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,80 @@ describe('cmd-fix', () => {
331331
)
332332
})
333333

334-
it('should support custom cwd argument', async () => {
335-
await cmdFix.run(['./custom/path'], importMeta, context)
334+
describe('misplaced vulnerability identifier detection', () => {
335+
// The case matrix handle-fix.mts actually validates downstream:
336+
// GHSA: /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/
337+
// CVE: /^CVE-\d{4}-\d{4,}$/
338+
// Suggestion must be exactly the form that passes those regexes,
339+
// otherwise the user follows our advice and still gets an error.
340+
it.each([
341+
// [label, input, expectedSuggestion]
342+
// GHSA: prefix to upper, body to lower, regardless of input casing.
343+
['canonical GHSA', 'GHSA-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'],
344+
['lowercase GHSA', 'ghsa-abcd-efgh-ijkl', 'GHSA-abcd-efgh-ijkl'],
345+
['mixed-case GHSA', 'GhSa-AbCd-EfGh-IjKl', 'GHSA-abcd-efgh-ijkl'],
346+
// CVE: prefix to upper, body is digits so case is a no-op.
347+
['canonical CVE', 'CVE-2021-23337', 'CVE-2021-23337'],
348+
['lowercase CVE', 'cve-2021-23337', 'CVE-2021-23337'],
349+
// PURL: always lowercase by spec, echo verbatim.
350+
['npm PURL', 'pkg:npm/left-pad@1.3.0', 'pkg:npm/left-pad@1.3.0'],
351+
])(
352+
'detects %s and suggests the downstream-valid form',
353+
async (_label, input, expectedSuggestion) => {
354+
await cmdFix.run([input], importMeta, context)
355+
356+
expect(process.exitCode).toBe(1)
357+
expect(mockHandleFix).not.toHaveBeenCalled()
358+
expect(mockLogger.fail).toHaveBeenCalledWith(
359+
expect.stringContaining(
360+
'looks like a vulnerability identifier, not a directory path',
361+
),
362+
)
363+
expect(mockLogger.fail).toHaveBeenCalledWith(
364+
expect.stringContaining(`--id ${expectedSuggestion}`),
365+
)
366+
},
367+
)
368+
369+
it('validates IDs before resolving the org slug (no API token path)', async () => {
370+
await cmdFix.run(['GHSA-xxxx-xxxx-xxxx'], importMeta, context)
371+
372+
// The check must run *before* `getDefaultOrgSlug`, so users without
373+
// a configured API token still see the helpful message instead of
374+
// the generic "Unable to resolve org".
375+
expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled()
376+
})
377+
})
336378

337-
expect(mockHandleFix).toHaveBeenCalledWith(
338-
expect.objectContaining({
339-
cwd: expect.stringContaining('custom/path'),
340-
}),
341-
)
379+
describe('target directory validation', () => {
380+
it('should fail fast when target directory does not exist', async () => {
381+
await cmdFix.run(['./this/path/does/not/exist'], importMeta, context)
382+
383+
expect(process.exitCode).toBe(1)
384+
expect(mockHandleFix).not.toHaveBeenCalled()
385+
expect(mockLogger.fail).toHaveBeenCalledWith(
386+
expect.stringContaining('Target directory does not exist'),
387+
)
388+
})
389+
390+
it('validates the directory before resolving the org slug', async () => {
391+
await cmdFix.run(['./this/path/does/not/exist'], importMeta, context)
392+
393+
expect(mockGetDefaultOrgSlug).not.toHaveBeenCalled()
394+
})
395+
396+
it('lets a real directory flow through to handleFix', async () => {
397+
const realDir = process.cwd()
398+
await cmdFix.run([realDir], importMeta, context)
399+
400+
expect(mockHandleFix).toHaveBeenCalledWith(
401+
expect.objectContaining({ cwd: realDir }),
402+
)
403+
// Sanity: no bail on the happy path.
404+
expect(mockLogger.fail).not.toHaveBeenCalledWith(
405+
expect.stringContaining('Target directory does not exist'),
406+
)
407+
})
342408
})
343409

344410
it('should support --json output mode', async () => {

0 commit comments

Comments
 (0)