From 3a6941a60c8086ce7a2d88fa9b61f0fe77bd19ef Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 13 Oct 2026 07:32:49 +0200 Subject: [PATCH 1/2] fix(shadow): scan only the dlx package with IPC token The pnpm and yarn shadow bins ran their pre-launch package scan with the user's default API token and ignored the IPC overrides that internal callers pass. When the CLI launched Coana with pnpm dlx or yarn dlx, a repository-scoped token got a 403 from the purl endpoint on every reachability run. The npm path already used the public token. The dlx scan also treated every positional argument as a package, so the launched tool's own arguments were looked up as npm packages, and an alert on one of them could abort the run. Both bins now take the API token and accept-risks flag from IPC, and dlx scans only the --package values or the first positional. --- CHANGELOG.md | 5 ++++ src/shadow/common.mts | 56 ++++++++++++++++++++++++++++++++++-- src/shadow/pnpm/bin.mts | 7 ++++- src/shadow/pnpm/bin.test.mts | 55 +++++++++++++++++++++++++++++++++++ src/shadow/yarn/bin.mts | 5 +++- src/shadow/yarn/bin.test.mts | 55 +++++++++++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 5 deletions(-) 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..0a4560c2c8 100644 --- a/src/shadow/common.mts +++ b/src/shadow/common.mts @@ -15,7 +15,7 @@ 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 + * Extract package PURLs from command arguments for add commands where * packages are specified as arguments. * Used by: pnpm, yarn. */ @@ -24,7 +24,7 @@ function extractPackagePurlsFromCommandArgs( ): string[] { const packagePurls: string[] = [] - // For 'add package1 package2@version' or 'dlx package', get packages from args. + // For 'add package1 package2@version', get packages from args. const packageArgs = rawArgs .slice(1) .filter(a => !a.startsWith('-') && a !== '--') @@ -39,6 +39,51 @@ function extractPackagePurlsFromCommandArgs( return packagePurls } +/** + * Only `--package`/`-p` values, or else the first positional, name packages. + * Later positionals are arguments for the launched binary. + */ +function extractPackagePurlsFromDlxArgs( + rawArgs: string[] | readonly string[], +): 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 === '--package' || arg === '-p') { + const value = args[i + 1] + if (value !== undefined) { + pkgSpecs.push(value) + } + i += 1 + continue + } + if (arg.startsWith('--package=')) { + pkgSpecs.push(arg.slice('--package='.length)) + continue + } + if (arg.startsWith('-')) { + 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,6 +126,7 @@ async function extractPackagePurlsFromPackageJson( export type PackageScanOptions = { acceptRisks: boolean + apiToken?: string | undefined command: string | undefined cwd?: string | URL dlxCommands?: Set @@ -105,6 +151,7 @@ export async function scanPackagesAndLogAlerts( ): Promise { const { acceptRisks, + apiToken, command, dlxCommands, installCommands, @@ -132,7 +179,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) + } else if (isAddCommand(command)) { packagePurls = extractPackagePurlsFromCommandArgs(rawArgs) } else if (isInstallCommand) { // For install/update, scan dependencies from package.json. @@ -149,6 +198,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..c13bdae609 100644 --- a/src/shadow/pnpm/bin.mts +++ b/src/shadow/pnpm/bin.mts @@ -79,13 +79,17 @@ 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, @@ -118,6 +122,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..9c1fc682b1 100644 --- a/src/shadow/pnpm/bin.test.mts +++ b/src/shadow/pnpm/bin.test.mts @@ -230,4 +230,59 @@ 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 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..6423c314dc 100644 --- a/src/shadow/yarn/bin.mts +++ b/src/shadow/yarn/bin.mts @@ -59,7 +59,10 @@ 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, diff --git a/src/shadow/yarn/bin.test.mts b/src/shadow/yarn/bin.test.mts index 5efdb29de2..62b291cfb6 100644 --- a/src/shadow/yarn/bin.test.mts +++ b/src/shadow/yarn/bin.test.mts @@ -272,4 +272,59 @@ 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 values for dlx', async () => { + await shadowYarn([ + '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 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 }, + }), + ) + }) }) From 77c1c660cd16d5f39ecf6c21ca0cb687e5a62bc0 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 13 Oct 2026 12:52:28 +0200 Subject: [PATCH 2/2] fix(shadow): skip dlx option values when picking package pnpm options like --allow-build and --reporter take the next argument as their value. The dlx scan treated that value as the package and stopped, so the package pnpm actually launched went unchecked. Each shadow bin now passes the options that take a value, so the scan skips their values before choosing the package. In pnpm, -p means --parseable, so only yarn treats it as --package. --- src/shadow/common.mts | 52 ++++++++++++++++++++++-------------- src/shadow/pnpm/bin.mts | 26 ++++++++++++++++++ src/shadow/pnpm/bin.test.mts | 50 ++++++++++++++++++++++++++++++++++ src/shadow/yarn/bin.mts | 7 +++++ src/shadow/yarn/bin.test.mts | 9 ++++--- 5 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/shadow/common.mts b/src/shadow/common.mts index 0a4560c2c8..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 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', get packages from args. const packageArgs = rawArgs .slice(1) .filter(a => !a.startsWith('-') && a !== '--') @@ -39,12 +33,20 @@ 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 +} + /** - * Only `--package`/`-p` values, or else the first positional, name packages. - * Later positionals are arguments for the launched binary. + * 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) @@ -53,19 +55,27 @@ function extractPackagePurlsFromDlxArgs( if (arg === '--') { break } - if (arg === '--package' || arg === '-p') { + 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 (value !== undefined) { - pkgSpecs.push(value) + if ( + (isPackageFlag || dlxFlags.withValue.has(name)) && + value !== undefined && + !value.startsWith('-') + ) { + if (isPackageFlag) { + pkgSpecs.push(value) + } + i += 1 } - i += 1 - continue - } - if (arg.startsWith('--package=')) { - pkgSpecs.push(arg.slice('--package='.length)) - continue - } - if (arg.startsWith('-')) { continue } if (!pkgSpecs.length) { @@ -130,6 +140,7 @@ export type PackageScanOptions = { command: string | undefined cwd?: string | URL dlxCommands?: Set + dlxFlags?: DlxFlags | undefined installCommands: Set managerName: string nothrow?: boolean @@ -154,6 +165,7 @@ export async function scanPackagesAndLogAlerts( apiToken, command, dlxCommands, + dlxFlags = { package: new Set(), withValue: new Set() }, installCommands, managerName, nothrow = true, @@ -180,7 +192,7 @@ export async function scanPackagesAndLogAlerts( let packagePurls: string[] = [] if (isDlxCommand) { - packagePurls = extractPackagePurlsFromDlxArgs(rawArgs) + packagePurls = extractPackagePurlsFromDlxArgs(rawArgs, dlxFlags) } else if (isAddCommand(command)) { packagePurls = extractPackagePurlsFromCommandArgs(rawArgs) } else if (isInstallCommand) { diff --git a/src/shadow/pnpm/bin.mts b/src/shadow/pnpm/bin.mts index c13bdae609..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', @@ -93,6 +118,7 @@ export default async function shadowPnpmBin( command, cwd, dlxCommands: DLX_COMMANDS, + dlxFlags: DLX_FLAGS, installCommands: INSTALL_COMMANDS, managerName: PNPM, rawArgs: rawPnpmArgs, diff --git a/src/shadow/pnpm/bin.test.mts b/src/shadow/pnpm/bin.test.mts index 9c1fc682b1..6bc70dc087 100644 --- a/src/shadow/pnpm/bin.test.mts +++ b/src/shadow/pnpm/bin.test.mts @@ -269,6 +269,56 @@ describe('shadowPnpm', () => { ) }) + 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: { diff --git a/src/shadow/yarn/bin.mts b/src/shadow/yarn/bin.mts index 6423c314dc..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', @@ -66,6 +72,7 @@ export default async function shadowYarnBin( 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 62b291cfb6..2473585529 100644 --- a/src/shadow/yarn/bin.test.mts +++ b/src/shadow/yarn/bin.test.mts @@ -293,18 +293,21 @@ describe('shadowYarn', () => { ) }) - it('should scan --package values for dlx', async () => { + it('should scan --package and -p values for dlx', async () => { await shadowYarn([ 'dlx', - '--package', + '-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/cowsay@1.6.0', 'pkg:npm/lodash@4.17.21', 'pkg:npm/axios@1.0.0'], expect.objectContaining({ nothrow: true, }),