From 14141096ee8a2762edf2e075ce797fe94b8631a6 Mon Sep 17 00:00:00 2001 From: soridalac Date: Thu, 13 Aug 2026 12:00:02 -0700 Subject: [PATCH] fix: resolve binaries from PATH to prevent CWD-based RCE --- src/commands/dev/generate/command.ts | 4 +++- src/commands/dev/generate/flag.ts | 7 ++++--- src/commands/dev/generate/plugin.ts | 4 +++- src/generator.ts | 13 +++++++++++-- test/generator.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 test/generator.test.ts diff --git a/src/commands/dev/generate/command.ts b/src/commands/dev/generate/command.ts index e3079a56..2f16f050 100644 --- a/src/commands/dev/generate/command.ts +++ b/src/commands/dev/generate/command.ts @@ -107,7 +107,9 @@ export default class GenerateCommand extends SfCommand { if (Object.keys(generator.pjson.devDependencies).includes('@salesforce/plugin-command-reference')) { // Get a list of all commands in `sf`. We will use this to determine if a topic is internal or external. - const sfCommandsStdout = shelljs.exec('sf commands --json', { silent: true }).stdout; + const sfBin = shelljs.which('sf'); + if (!sfBin) throw messages.createError('errors.InvalidDir'); + const sfCommandsStdout = shelljs.exec(`${sfBin} commands --json`, { silent: true }).stdout; const commandsJson = JSON.parse(sfCommandsStdout) as Array<{ id: string }>; const commands = commandsJson.map((command) => command.id.replace(/:/g, '.').replace(/ /g, '.')); diff --git a/src/commands/dev/generate/flag.ts b/src/commands/dev/generate/flag.ts index 7c7c4d98..d0e57107 100644 --- a/src/commands/dev/generate/flag.ts +++ b/src/commands/dev/generate/flag.ts @@ -9,7 +9,6 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import select from '@inquirer/select'; -import shelljs from 'shelljs'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { toStandardizedId } from '@oclif/core'; @@ -18,6 +17,7 @@ import { askQuestions } from '../../../prompts/series/flagPrompts.js'; import { fileExists, build, apply, resolveCommandFilePath } from '../../../util.js'; import { FlagAnswers } from '../../../types.js'; import { stringToChoice } from '../../../prompts/functions.js'; +import { Generator } from '../../../generator.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); export const messages = Messages.loadMessages('@salesforce/plugin-dev', 'dev.generate.flag'); @@ -66,9 +66,10 @@ export default class DevGenerateFlag extends SfCommand { await updateMarkdownFile(answers, existing, standardizedCommandId); - shelljs.exec(`yarn prettier --write ${commandFilePath}`); + const generator = new Generator({ dryRun: flags['dry-run'] }); + generator.execute(`yarn prettier --write ${commandFilePath}`); - shelljs.exec('yarn compile'); + generator.execute('yarn compile'); this.log(`Added ${answers.name} flag to ${commandFilePath}`); } diff --git a/src/commands/dev/generate/plugin.ts b/src/commands/dev/generate/plugin.ts index 02ac6ea7..44fa9aeb 100644 --- a/src/commands/dev/generate/plugin.ts +++ b/src/commands/dev/generate/plugin.ts @@ -49,7 +49,9 @@ async function fetchGithubUserFromAPI(): Promise<{ login: string; name: string } function fetchGithubUserFromGit(): string | undefined { try { - const result = shelljs.exec('git config --get user.name', { silent: true }); + const gitBin = shelljs.which('git'); + if (!gitBin) return undefined; + const result = shelljs.exec(`${gitBin} config --get user.name`, { silent: true }); return result.stdout.trim(); } catch { // ignore diff --git a/src/generator.ts b/src/generator.ts index bc24b4fd..93e51a78 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -88,8 +88,17 @@ export class Generator { return; } - this.logger.debug(`Executing command: ${cmd}`); - shelljs.exec(cmd, { cwd: this.cwd }); + const args = cmd.split(' '); + const bin = args[0]; + const isBinPath = bin.includes('/') || bin.includes('\\'); + const resolved = isBinPath ? bin : shelljs.which(bin); + if (!resolved) { + throw new Error(`Could not find "${bin}" on PATH`); + } + const resolvedCmd = [resolved.toString(), ...args.slice(1)].join(' '); + + this.logger.debug(`Executing command: ${resolvedCmd}`); + shelljs.exec(resolvedCmd, { cwd: this.cwd }); } public async loadPjson(): Promise { diff --git a/test/generator.test.ts b/test/generator.test.ts new file mode 100644 index 00000000..706cc938 --- /dev/null +++ b/test/generator.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from 'chai'; +import { Generator } from '../src/generator.js'; + +describe('Generator.execute', () => { + it('should throw when binary is not found on PATH', () => { + const generator = new Generator(); + + expect(() => generator.execute('nonexistent-binary-xyz --help')).to.throw( + 'Could not find "nonexistent-binary-xyz" on PATH' + ); + }); + + it('should not execute commands in dry-run mode', () => { + const generator = new Generator({ dryRun: true }); + + generator.execute('nonexistent-binary-xyz --help'); + }); +});