From 3f7a3cc5c537957d55fa9e6aeab9d860f7a60078 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 13 May 2024 12:12:13 -0700 Subject: [PATCH] feat: runVSCodeCommand as workaround for CVE-2024-27980 --- CHANGELOG.md | 4 ++ README.md | 12 ++---- lib/download.test.ts | 3 +- lib/download.ts | 65 +++++++++++++++++++++++++++++---- lib/index.ts | 12 ++++-- lib/runTest.ts | 56 +++------------------------- lib/util.ts | 55 ++++++++++++++++++++++++++-- sample/.github/workflows/ci.yml | 35 ++++++++++++++++++ sample/src/test/runTest.ts | 18 ++++----- 9 files changed, 176 insertions(+), 84 deletions(-) create mode 100644 sample/.github/workflows/ci.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ac3391..dbbebbdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### 2.3.10 | 2024-01-19 + +- Add `runVSCodeCommand` method and workaround for Node CVE-2024-27980 + ### 2.3.9 | 2024-01-19 - Fix archive extraction on Windows failing when run under Electron diff --git a/README.md b/README.md index b4965828..f9f4298e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Test Status Badge](https://github.com/microsoft/vscode-test/workflows/Tests/badge.svg) -This module helps you test VS Code extensions. +This module helps you test VS Code extensions. Note that new extensions may want to use the [VS Code Test CLI](https://github.com/microsoft/vscode-test-cli/blob/main/README.md), which leverages this module, for a richer editing and execution experience. Supported: @@ -13,10 +13,10 @@ Supported: ## Usage -See [./sample](./sample) for a runnable sample, with [Azure DevOps Pipelines](https://github.com/microsoft/vscode-test/blob/master/sample/azure-pipelines.yml) and [Travis CI](https://github.com/microsoft/vscode-test/blob/master/.travis.yml) configuration. +See [./sample](./sample) for a runnable sample, with [Azure DevOps Pipelines](https://github.com/microsoft/vscode-test/blob/main/sample/azure-pipelines.yml) and [Github ACtions](https://github.com/microsoft/vscode-test/blob/main/sample/.travis.yml) configuration. ```ts -import { runTests } from '@vscode/test-electron'; +import { runTests, runVSCodeCommand, downloadAndUnzipVSCode } from '@vscode/test-electron'; async function go() { try { @@ -82,11 +82,7 @@ async function go() { /** * Install Python extension */ - const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); - cp.spawnSync(cli, [...args, '--install-extension', 'ms-python.python'], { - encoding: 'utf-8', - stdio: 'inherit', - }); + await runVSCodeCommand(['--install-extension', 'ms-python.python'], { version: '1.35.0' }); /** * - Add additional launch flags for VS Code diff --git a/lib/download.test.ts b/lib/download.test.ts index f923050d..7857a2b9 100644 --- a/lib/download.test.ts +++ b/lib/download.test.ts @@ -49,7 +49,8 @@ describe('sane downloads', () => { } if (platform === systemDefaultPlatform) { - const version = spawnSync(exePath, ['--version']); + const shell = process.platform === 'win32'; + const version = spawnSync(shell ? `"${exePath}"` : exePath, ['--version'], { shell }); expect(version.status).to.equal(0); expect(version.stdout.toString().trim()).to.not.be.empty; } diff --git a/lib/download.ts b/lib/download.ts index 4482705a..5a238337 100644 --- a/lib/download.ts +++ b/lib/download.ts @@ -173,13 +173,64 @@ export type DownloadPlatform = StringLiteralUnion< >; export interface DownloadOptions { - readonly cachePath: string; - readonly version: DownloadVersion; - readonly platform: DownloadPlatform; - readonly extensionDevelopmentPath?: string | string[]; - readonly reporter?: ProgressReporter; - readonly extractSync?: boolean; - readonly timeout?: number; + /** + * The VS Code version to download. Valid versions are: + * - `'stable'` + * - `'insiders'` + * - `'1.32.0'`, `'1.31.1'`, etc + * + * Defaults to `stable`, which is latest stable version. + * + * *If a local copy exists at `.vscode-test/vscode-`, skip download.* + */ + version: DownloadVersion; + + /** + * The VS Code platform to download. If not specified, it defaults to the + * current platform. + * + * Possible values are: + * - `win32-x64-archive` + * - `win32-arm64-archive ` + * - `darwin` + * - `darwin-arm64` + * - `linux-x64` + * - `linux-arm64` + * - `linux-armhf` + */ + platform: DownloadPlatform; + + /** + * Path where the downloaded VS Code instance is stored. + * Defaults to `.vscode-test` within your working directory folder. + */ + cachePath: string; + + /** + * Absolute path to the extension root. Passed to `--extensionDevelopmentPath`. + * Must include a `package.json` Extension Manifest. + */ + extensionDevelopmentPath?: string | string[]; + + /** + * Progress reporter to use while VS Code is downloaded. Defaults to a + * console reporter. A {@link SilentReporter} is also available, and you + * may implement your own. + */ + reporter?: ProgressReporter; + + /** + * Whether the downloaded zip should be synchronously extracted. Should be + * omitted unless you're experiencing issues installing VS Code versions. + */ + extractSync?: boolean; + + /** + * Number of milliseconds after which to time out if no data is received from + * the remote when downloading VS Code. Note that this is an 'idle' timeout + * and does not enforce the total time VS Code may take to download. + */ + timeout?: number; } interface IDownload { diff --git a/lib/index.ts b/lib/index.ts index 04d9e473..db74cf79 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -3,7 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export { download, downloadAndUnzipVSCode } from './download'; -export { runTests } from './runTest'; -export { resolveCliPathFromVSCodeExecutablePath, resolveCliArgsFromVSCodeExecutablePath } from './util'; +export { download, downloadAndUnzipVSCode, DownloadOptions } from './download'; +export { runTests, TestOptions } from './runTest'; +export { + resolveCliPathFromVSCodeExecutablePath, + resolveCliArgsFromVSCodeExecutablePath, + runVSCodeCommand, + VSCodeCommandError, + RunVSCodeCommandOptions, +} from './util'; export * from './progress'; diff --git a/lib/runTest.ts b/lib/runTest.ts index b4727bb5..bf1841eb 100644 --- a/lib/runTest.ts +++ b/lib/runTest.ts @@ -5,11 +5,10 @@ import * as cp from 'child_process'; import * as path from 'path'; -import { downloadAndUnzipVSCode, DownloadVersion, DownloadPlatform, defaultCachePath } from './download'; -import { ProgressReporter } from './progress'; +import { DownloadOptions, defaultCachePath, downloadAndUnzipVSCode } from './download'; import { killTree } from './util'; -export interface TestOptions { +export interface TestOptions extends Partial { /** * The VS Code executable path used for testing. * @@ -18,33 +17,6 @@ export interface TestOptions { */ vscodeExecutablePath?: string; - /** - * The VS Code version to download. Valid versions are: - * - `'stable'` - * - `'insiders'` - * - `'1.32.0'`, `'1.31.1'`, etc - * - * Defaults to `stable`, which is latest stable version. - * - * *If a local copy exists at `.vscode-test/vscode-`, skip download.* - */ - version?: DownloadVersion; - - /** - * The VS Code platform to download. If not specified, it defaults to the - * current platform. - * - * Possible values are: - * - `win32-x64-archive` - * - `win32-arm64-archive ` - * - `darwin` - * - `darwin-arm64` - * - `linux-x64` - * - `linux-arm64` - * - `linux-armhf` - */ - platform?: DownloadPlatform; - /** * Whether VS Code should be launched using default settings and extensions * installed on this machine. If `false`, then separate directories will be @@ -95,26 +67,6 @@ export interface TestOptions { * See `code --help` for possible arguments. */ launchArgs?: string[]; - - /** - * Progress reporter to use while VS Code is downloaded. Defaults to a - * console reporter. A {@link SilentReporter} is also available, and you - * may implement your own. - */ - reporter?: ProgressReporter; - - /** - * Whether the downloaded zip should be synchronously extracted. Should be - * omitted unless you're experiencing issues installing VS Code versions. - */ - extractSync?: boolean; - - /** - * Number of milliseconds after which to time out if no data is received from - * the remote when downloading VS Code. Note that this is an 'idle' timeout - * and does not enforce the total time VS Code may take to download. - */ - timeout?: number; } /** @@ -185,7 +137,9 @@ async function innerRunTests( } ): Promise { const fullEnv = Object.assign({}, process.env, testRunnerEnv); - const cmd = cp.spawn(executable, args, { env: fullEnv }); + const shell = process.platform === 'win32'; + const cmd = cp.spawn(shell ? `"${executable}"` : executable, args, { env: fullEnv, shell }); + let exitRequested = false; const ctrlc1 = () => { process.removeListener(SIGINT, ctrlc1); diff --git a/lib/util.ts b/lib/util.ts index f06fd6c3..2df6615e 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -3,17 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ChildProcess, spawn } from 'child_process'; +import { ChildProcess, SpawnOptions, spawn } from 'child_process'; +import { createHash } from 'crypto'; import { readFileSync } from 'fs'; import * as createHttpProxyAgent from 'http-proxy-agent'; import * as https from 'https'; import * as createHttpsProxyAgent from 'https-proxy-agent'; import * as path from 'path'; import { URL } from 'url'; -import { DownloadPlatform } from './download'; +import { DownloadOptions, DownloadPlatform, downloadAndUnzipVSCode } from './download'; import * as request from './request'; import { TestOptions, getProfileArguments } from './runTest'; -import { createHash } from 'crypto'; export let systemDefaultPlatform: DownloadPlatform; @@ -176,6 +176,7 @@ export function resolveCliPathFromVSCodeExecutablePath( * cp.spawnSync(cli, [...args, '--install-extension', ''], { * encoding: 'utf-8', * stdio: 'inherit' + * shell: process.platform === 'win32', * }); * ``` * @@ -195,6 +196,54 @@ export function resolveCliArgsFromVSCodeExecutablePath( return args; } +export type RunVSCodeCommandOptions = Partial & { spawn?: SpawnOptions }; + +export class VSCodeCommandError extends Error { + constructor( + args: string[], + public readonly exitCode: number | null, + public readonly stderr: string, + public stdout: string + ) { + super(`'code ${args.join(' ')}' failed with exit code ${exitCode}:\n\n${stderr}\n\n${stdout}`); + } +} + +/** + * Runs a VS Code command, and returns its output + * @throws a {@link VSCodeCommandError} if the command fails + */ +export async function runVSCodeCommand(args: string[], options: RunVSCodeCommandOptions = {}) { + const vscodeExecutablePath = await downloadAndUnzipVSCode(options); + const [cli, ...baseArgs] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); + + const shell = process.platform === 'win32'; + + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const child = spawn(shell ? `"${cli}"` : cli, [...baseArgs, ...args], { + stdio: 'pipe', + shell, + windowsHide: true, + ...options.spawn, + }); + + child.stdout?.setEncoding('utf-8').on('data', (data) => (stdout += data)); + child.stderr?.setEncoding('utf-8').on('data', (data) => (stderr += data)); + + child.on('error', reject); + child.on('exit', (code) => { + if (code !== 0) { + reject(new VSCodeCommandError(args, code, stderr, stdout)); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + /** Predicates whether arg is undefined or null */ export function isDefined(arg: T | undefined | null): arg is T { return arg != null; diff --git a/sample/.github/workflows/ci.yml b/sample/.github/workflows/ci.yml new file mode 100644 index 00000000..f09ad69e --- /dev/null +++ b/sample/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Run VSCode Extension Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js environment + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: yarn install + + - name: Compile + run: yarn compile + + - name: Run tests + run: xvfb-run -a yarn test + if: runner.os == 'Linux' + + - name: Run tests + run: yarn test + if: runner.os != 'Linux' diff --git a/sample/src/test/runTest.ts b/sample/src/test/runTest.ts index 90cd2af3..c679af7d 100644 --- a/sample/src/test/runTest.ts +++ b/sample/src/test/runTest.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as cp from 'child_process'; -import { runTests, downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from '../../..'; +import { runTests, downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath, runVSCodeCommand } from '../../..'; async function go() { const extensionDevelopmentPath = path.resolve(__dirname, '../../../'); @@ -28,10 +28,10 @@ async function go() { }); /** - * Use 1.36.1 release for testing + * Use 1.61.0 release for testing */ await runTests({ - version: '1.36.1', + version: '1.61.0', extensionDevelopmentPath, extensionTestsPath, launchArgs: [testWorkspace], @@ -58,14 +58,14 @@ async function go() { }); /** - * Noop, since 1.36.1 already downloaded to .vscode-test/vscode-1.36.1 + * Noop, since 1.61.0 already downloaded to .vscode-test/vscode-1.61.0 */ - await downloadAndUnzipVSCode('1.36.1'); + await downloadAndUnzipVSCode('1.61.0'); /** * Manually download VS Code 1.35.0 release for testing. */ - const vscodeExecutablePath = await downloadAndUnzipVSCode('1.35.0'); + const vscodeExecutablePath = await downloadAndUnzipVSCode('1.60.0'); await runTests({ vscodeExecutablePath, extensionDevelopmentPath, @@ -76,11 +76,7 @@ async function go() { /** * Install Python extension */ - const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); - cp.spawnSync(cli, [...args, '--install-extension', 'ms-python.python'], { - encoding: 'utf-8', - stdio: 'inherit', - }); + await runVSCodeCommand(['--install-extension', 'ms-python.python'], { version: '1.60.0' }); /** * - Add additional launch flags for VS Code