Skip to content
Merged
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
54 changes: 49 additions & 5 deletions packages/cli/src/commands/fix/cmd-fix.mts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs'
import path from 'node:path'

import terminalLink from 'terminal-link'
Expand Down Expand Up @@ -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
}
}
Comment thread
jdalton marked this conversation as resolved.

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
Expand All @@ -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)
Expand Down
80 changes: 73 additions & 7 deletions packages/cli/test/unit/commands/fix/cmd-fix.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down