diff --git a/CHANGELOG.md b/CHANGELOG.md index f00afa5113..a06ca5d982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed +- Reachability scans in pnpm and yarn projects no longer print a 403 from the Socket API when run with a repository-scoped token. The safety check that runs before Coana launches now uses Socket's public token and only looks up the Coana package itself. + ## [1.8.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.8.1) - 2026-10-12 ### Changed diff --git a/src/shadow/common.mts b/src/shadow/common.mts index 854e11c94a..2de423cfd6 100644 --- a/src/shadow/common.mts +++ b/src/shadow/common.mts @@ -14,17 +14,11 @@ import { logAlertsMap } from '../utils/socket-package-alert.mts' import type { AlertsByPurl } from '../utils/socket-package-alert.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' -/** - * Extract package PURLs from command arguments for add/dlx commands where - * packages are specified as arguments. - * Used by: pnpm, yarn. - */ function extractPackagePurlsFromCommandArgs( rawArgs: string[] | readonly string[], ): string[] { const packagePurls: string[] = [] - // For 'add package1 package2@version' or 'dlx package', get packages from args. const packageArgs = rawArgs .slice(1) .filter(a => !a.startsWith('-') && a !== '--') @@ -39,6 +33,67 @@ function extractPackagePurlsFromCommandArgs( return packagePurls } +export type DlxFlags = { + // Options whose value names a package to download. + package: ReadonlySet + // Other options that take the next argument as their value. + withValue: ReadonlySet +} + +/** + * Package option values, or else the first positional, name packages. Later + * positionals are arguments for the launched binary. + */ +function extractPackagePurlsFromDlxArgs( + rawArgs: string[] | readonly string[], + dlxFlags: DlxFlags, +): string[] { + const pkgSpecs: string[] = [] + const args = rawArgs.slice(1) + for (let i = 0, { length } = args; i < length; i += 1) { + const arg = args[i]! + if (arg === '--') { + break + } + if (arg.startsWith('-')) { + const eqIndex = arg.indexOf('=') + const name = eqIndex === -1 ? arg : arg.slice(0, eqIndex) + const isPackageFlag = dlxFlags.package.has(name) + if (eqIndex !== -1) { + if (isPackageFlag) { + pkgSpecs.push(arg.slice(eqIndex + 1)) + } + continue + } + const value = args[i + 1] + if ( + (isPackageFlag || dlxFlags.withValue.has(name)) && + value !== undefined && + !value.startsWith('-') + ) { + if (isPackageFlag) { + pkgSpecs.push(value) + } + i += 1 + } + continue + } + if (!pkgSpecs.length) { + pkgSpecs.push(arg) + } + break + } + + const packagePurls: string[] = [] + for (const pkgSpec of pkgSpecs) { + const purl = safeNpmSpecToPurl(pkgSpec) + if (purl) { + packagePurls.push(purl) + } + } + return packagePurls +} + /** * Extract package PURLs from package.json for install/update commands. * Used by: pnpm, yarn. @@ -81,9 +136,11 @@ async function extractPackagePurlsFromPackageJson( export type PackageScanOptions = { acceptRisks: boolean + apiToken?: string | undefined command: string | undefined cwd?: string | URL dlxCommands?: Set + dlxFlags?: DlxFlags | undefined installCommands: Set managerName: string nothrow?: boolean @@ -105,8 +162,10 @@ export async function scanPackagesAndLogAlerts( ): Promise { const { acceptRisks, + apiToken, command, dlxCommands, + dlxFlags = { package: new Set(), withValue: new Set() }, installCommands, managerName, nothrow = true, @@ -132,7 +191,9 @@ export async function scanPackagesAndLogAlerts( // Extract package names from command arguments before any downloads. let packagePurls: string[] = [] - if (isDlxCommand || isAddCommand(command)) { + if (isDlxCommand) { + packagePurls = extractPackagePurlsFromDlxArgs(rawArgs, dlxFlags) + } else if (isAddCommand(command)) { packagePurls = extractPackagePurlsFromCommandArgs(rawArgs) } else if (isInstallCommand) { // For install/update, scan dependencies from package.json. @@ -149,6 +210,7 @@ export async function scanPackagesAndLogAlerts( try { const alertsMap = await getAlertsMapFromPurls(packagePurls, { + apiToken, filter: acceptRisks ? { actions: ['error'], blocked: true } : { actions: ['error', 'monitor', 'warn'] }, diff --git a/src/shadow/pnpm/bin.mts b/src/shadow/pnpm/bin.mts index 7904c128fa..ffae846f5f 100644 --- a/src/shadow/pnpm/bin.mts +++ b/src/shadow/pnpm/bin.mts @@ -25,6 +25,7 @@ import { scanPackagesAndLogAlerts } from '../common.mts' import { ensureIpcInStdio } from '../stdio-ipc.mts' import type { IpcObject } from '../../constants.mts' +import type { DlxFlags } from '../common.mts' import type { SpawnExtra, SpawnOptions, @@ -41,6 +42,30 @@ export type ShadowPnpmResult = { const DLX_COMMANDS = new Set(['dlx']) +// pnpm 10's dlx and global options that take a value. pnpm stops parsing +// options at the first positional after `dlx`. +const DLX_FLAGS: DlxFlags = { + package: new Set(['--package']), + withValue: new Set([ + '--allow-build', + '--changed-files-ignore-pattern', + '--cpu', + '--dir', + '--filter', + '--filter-prod', + '--libc', + '--loglevel', + '--os', + '--prefix', + '--reporter', + '--test-pattern', + '--use-node-version', + '--workspace-packages', + '-C', + '-F', + ]), +} + const INSTALL_COMMANDS = new Set([ 'add', 'i', @@ -79,16 +104,21 @@ export default async function shadowPnpmBin( spinner?.start() if (needsScanning && !rawPnpmArgs.includes(FLAG_DRY_RUN)) { - const acceptRisks = !!constants.ENV.SOCKET_CLI_ACCEPT_RISKS + const acceptRisks = + !!ipc?.[constants.SOCKET_CLI_SHADOW_ACCEPT_RISKS] || + !!constants.ENV.SOCKET_CLI_ACCEPT_RISKS + const apiToken = ipc?.[constants.SOCKET_CLI_SHADOW_API_TOKEN] const viewAllRisks = !!constants.ENV.SOCKET_CLI_VIEW_ALL_RISKS // Handle add and dlx commands with shared utility. if (isDlxCommand || isAddCommand(command)) { const scanResult = await scanPackagesAndLogAlerts({ acceptRisks, + apiToken, command, cwd, dlxCommands: DLX_COMMANDS, + dlxFlags: DLX_FLAGS, installCommands: INSTALL_COMMANDS, managerName: PNPM, rawArgs: rawPnpmArgs, @@ -118,6 +148,7 @@ export default async function shadowPnpmBin( ) const alertsMap = await getAlertsMapFromPnpmLockfile(lockfile, { + apiToken, nothrow: true, filter: acceptRisks ? { actions: ['error'], blocked: true } diff --git a/src/shadow/pnpm/bin.test.mts b/src/shadow/pnpm/bin.test.mts index 280e59618b..6bc70dc087 100644 --- a/src/shadow/pnpm/bin.test.mts +++ b/src/shadow/pnpm/bin.test.mts @@ -230,4 +230,109 @@ describe('shadowPnpm', () => { }), ) }) + + it('should scan only the package spec for dlx, not the binary arguments', async () => { + await shadowPnpm([ + 'dlx', + '@coana-tech/cli@15.10.55', + 'run', + '.', + '--output-dir', + '/tmp/out', + '--analysis-timeout', + '3h', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/@coana-tech/cli@15.10.55'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should scan --package values for dlx', async () => { + await shadowPnpm([ + 'dlx', + '--package', + 'cowsay@1.6.0', + '--package=lodash@4.17.21', + 'cowsay', + 'hello', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0', 'pkg:npm/lodash@4.17.21'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should skip dlx option values when picking the package', async () => { + await shadowPnpm([ + 'dlx', + '--allow-build', + 'esbuild', + '--reporter', + 'append-only', + 'vite@6.0.0', + '--help', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/vite@6.0.0'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should scan every --package across other dlx options', async () => { + await shadowPnpm([ + 'dlx', + '--package', + 'cowsay@1.6.0', + '--allow-build', + 'esbuild', + '--package', + 'lodash@4.17.21', + 'cowsay', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0', 'pkg:npm/lodash@4.17.21'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should treat -p as a boolean option for dlx', async () => { + await shadowPnpm(['dlx', '-p', 'cowsay@1.6.0', 'hello']) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should use the IPC API token and accept-risks for dlx scans', async () => { + await shadowPnpm(['dlx', 'cowsay@1.6.0'], { + ipc: { + SOCKET_CLI_SHADOW_ACCEPT_RISKS: true, + SOCKET_CLI_SHADOW_API_TOKEN: 'public-token', + }, + }) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0'], + expect.objectContaining({ + apiToken: 'public-token', + filter: { actions: ['error'], blocked: true }, + }), + ) + }) }) diff --git a/src/shadow/yarn/bin.mts b/src/shadow/yarn/bin.mts index 3ab0054347..b0b9b9e2e7 100644 --- a/src/shadow/yarn/bin.mts +++ b/src/shadow/yarn/bin.mts @@ -11,6 +11,7 @@ import { scanPackagesAndLogAlerts } from '../common.mts' import { ensureIpcInStdio } from '../stdio-ipc.mts' import type { IpcObject } from '../../constants.mts' +import type { DlxFlags } from '../common.mts' import type { SpawnExtra, SpawnOptions, @@ -27,6 +28,11 @@ export type ShadowYarnResult = { const DLX_COMMANDS = new Set(['dlx']) +const DLX_FLAGS: DlxFlags = { + package: new Set(['--package', '-p']), + withValue: new Set(), +} + const INSTALL_COMMANDS = new Set([ 'add', 'install', @@ -59,10 +65,14 @@ export default async function shadowYarnBin( // Check for package scanning. const command = rawYarnArgs[0] const scanResult = await scanPackagesAndLogAlerts({ - acceptRisks: !!constants.ENV.SOCKET_CLI_ACCEPT_RISKS, + acceptRisks: + !!ipc?.[constants.SOCKET_CLI_SHADOW_ACCEPT_RISKS] || + !!constants.ENV.SOCKET_CLI_ACCEPT_RISKS, + apiToken: ipc?.[constants.SOCKET_CLI_SHADOW_API_TOKEN], command, cwd, dlxCommands: DLX_COMMANDS, + dlxFlags: DLX_FLAGS, installCommands: INSTALL_COMMANDS, managerName: YARN, rawArgs: rawYarnArgs, diff --git a/src/shadow/yarn/bin.test.mts b/src/shadow/yarn/bin.test.mts index 5efdb29de2..2473585529 100644 --- a/src/shadow/yarn/bin.test.mts +++ b/src/shadow/yarn/bin.test.mts @@ -272,4 +272,62 @@ describe('shadowYarn', () => { expect(mockGetAlertsMapFromPurls).not.toHaveBeenCalled() }) + + it('should scan only the package spec for dlx, not the binary arguments', async () => { + await shadowYarn([ + 'dlx', + '@coana-tech/cli@15.10.55', + 'run', + '.', + '--output-dir', + '/tmp/out', + '--analysis-timeout', + '3h', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/@coana-tech/cli@15.10.55'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should scan --package and -p values for dlx', async () => { + await shadowYarn([ + 'dlx', + '-p', + 'cowsay@1.6.0', + '--quiet', + '--package=lodash@4.17.21', + '--package', + 'axios@1.0.0', + 'cowsay', + 'hello', + ]) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0', 'pkg:npm/lodash@4.17.21', 'pkg:npm/axios@1.0.0'], + expect.objectContaining({ + nothrow: true, + }), + ) + }) + + it('should use the IPC API token and accept-risks for dlx scans', async () => { + await shadowYarn(['dlx', 'cowsay@1.6.0'], { + ipc: { + SOCKET_CLI_SHADOW_ACCEPT_RISKS: true, + SOCKET_CLI_SHADOW_API_TOKEN: 'public-token', + }, + }) + + expect(mockGetAlertsMapFromPurls).toHaveBeenCalledWith( + ['pkg:npm/cowsay@1.6.0'], + expect.objectContaining({ + apiToken: 'public-token', + filter: { actions: ['error'], blocked: true }, + }), + ) + }) })