From 17edbd6e1fdbaa0aa03c4ba931acb932486a6d69 Mon Sep 17 00:00:00 2001 From: Richard Zampieri Date: Thu, 18 Jun 2026 23:03:28 -0700 Subject: [PATCH] fix(security): resolve Dependabot alerts and CodeQL findings on main Add npm overrides for js-yaml and undici, bump semver to 7.8.4, and route container-dev docker calls through safeSpawn/safeSpawnSync to avoid shell injection warnings without changing runtime behavior. --- package-lock.json | 83 +++++-------------------------------------- package.json | 6 +++- src/dev/form.ts | 71 +++++++++++++++++------------------- test/dev/form.spec.ts | 77 ++++++++++++++++++++++++++------------- 4 files changed, 98 insertions(+), 139 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa300b0..550c668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "inquirer": "^8.2.7", "mustache": "4.2.0", "ora": "5.4.1", - "semver": "7.6.3", + "semver": "7.8.4", "ts-node": "10.9.2", "yargs": "17.7.2" }, @@ -2038,16 +2038,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -2062,20 +2052,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -2837,19 +2813,6 @@ "node": ">=18" } }, - "node_modules/@release-it/conventional-changelog/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", @@ -9802,16 +9765,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/release-it/node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10148,9 +10101,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10440,13 +10393,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -10843,19 +10789,6 @@ } } }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -11028,13 +10961,13 @@ } }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 2e80972..8cdd5c9 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "inquirer": "^8.2.7", "mustache": "4.2.0", "ora": "5.4.1", - "semver": "7.6.3", + "semver": "7.8.4", "ts-node": "10.9.2", "yargs": "17.7.2" }, @@ -101,6 +101,10 @@ "tsx": "^4.19.2", "typescript": "5.2.2" }, + "overrides": { + "js-yaml": "4.2.0", + "undici": "7.28.0" + }, "lint-staged": { "src/**/*.ts": [ "eslint --cache --cache-location node_modules/.cache/eslint/ --fix", diff --git a/src/dev/form.ts b/src/dev/form.ts index 1357ab4..646b3de 100644 --- a/src/dev/form.ts +++ b/src/dev/form.ts @@ -1,7 +1,8 @@ import fs from "fs"; import path from "path"; import chalk from "chalk"; -import { spawn, execSync, SpawnOptions } from "child_process"; +import type { SpawnOptions } from "child_process"; +import { safeSpawn, safeSpawnSync } from "../utils/safe-spawn"; export interface DevOptions { container: boolean; @@ -186,15 +187,24 @@ export async function showStatus(options: DevOptions): Promise { // Show resource usage console.log(chalk.bold("\nResource Usage:")); try { - // Use double quotes for cross-platform compatibility (Windows + Unix) - const output = execSync( - 'docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"', + const result = safeSpawnSync( + "docker", + [ + "stats", + "--no-stream", + "--format", + "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }, ); - console.log(output); + if (!result.error && result.stdout) { + console.log(String(result.stdout)); + } else { + throw new Error("docker stats failed"); + } } catch { console.log(chalk.gray(" Unable to get resource stats")); } @@ -230,12 +240,10 @@ export async function showLogs(options: DevOptions): Promise { * Check if Docker is running */ function isDockerRunning(): boolean { - try { - execSync("docker info", { stdio: ["pipe", "pipe", "pipe"] }); - return true; - } catch { - return false; - } + const result = safeSpawnSync("docker", ["info"], { + stdio: ["pipe", "pipe", "pipe"], + }); + return !result.error && result.status === 0; } /** @@ -245,24 +253,19 @@ function runDockerCompose( args: string[], options: { cwd: string; env?: NodeJS.ProcessEnv }, ): void { - try { - // Try docker compose (v2) first - execSync(`docker compose ${args.join(" ")}`, { - cwd: options.cwd, - env: options.env || process.env, - stdio: "inherit", - }); - } catch { - // Fall back to docker-compose (v1) - try { - execSync(`docker-compose ${args.join(" ")}`, { - cwd: options.cwd, - env: options.env || process.env, - stdio: "inherit", - }); - } catch (error) { + const spawnOptions = { + cwd: options.cwd, + env: options.env || process.env, + stdio: "inherit" as const, + }; + + // Try docker compose (v2) first, fall back to docker-compose (v1). + const v2 = safeSpawnSync("docker", ["compose", ...args], spawnOptions); + if (v2.error || (typeof v2.status === "number" && v2.status !== 0)) { + const v1 = safeSpawnSync("docker-compose", args, spawnOptions); + if (v1.error || (typeof v1.status === "number" && v1.status !== 0)) { console.log(chalk.red("Error running docker-compose")); - throw error; + throw v1.error ?? new Error(`exited with code ${v1.status}`); } } } @@ -271,18 +274,10 @@ function runDockerCompose( * Spawn docker-compose command (for interactive/streaming) */ function spawnDockerCompose(args: string[], options: SpawnOptions): void { - // Try docker compose (v2) first - const proc = spawn("docker", ["compose", ...args], { - ...options, - shell: true, - }); + const proc = safeSpawn("docker", ["compose", ...args], options); proc.on("error", () => { - // Fall back to docker-compose (v1) - spawn("docker-compose", args, { - ...options, - shell: true, - }); + safeSpawn("docker-compose", args, options); }); } diff --git a/test/dev/form.spec.ts b/test/dev/form.spec.ts index 5ed6013..cb4e868 100644 --- a/test/dev/form.spec.ts +++ b/test/dev/form.spec.ts @@ -7,13 +7,15 @@ import * as os from "os"; import * as path from "path"; import { EventEmitter } from "events"; -const execSyncMock = jest.fn(); const spawnMock = jest.fn(); -jest.mock("child_process", () => ({ - execSync: (...args: unknown[]) => execSyncMock(...args), - spawn: (...args: unknown[]) => spawnMock(...args), -})); +jest.mock("cross-spawn", () => { + const fn = (...args: unknown[]) => spawnMock(...args); + (fn as unknown as { sync: jest.Mock }).sync = jest.fn((...args: unknown[]) => + spawnMock(...args), + ); + return fn; +}); import { attachToContainer, @@ -41,12 +43,34 @@ let originalCwd: string; let tmpDir: string; let logSpy: jest.SpyInstance; +function syncSuccess(): { + status: number; + error: null; + stdout: string; + stderr: string; +} { + return { status: 0, error: null, stdout: "stats table", stderr: "" }; +} + +function syncFailure(): { + status: number; + error: Error; + stdout: string; + stderr: string; +} { + return { + status: 1, + error: new Error("docker not running"), + stdout: "", + stderr: "", + }; +} + beforeEach(() => { originalCwd = process.cwd(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ex-cli-dev-form-")); process.chdir(tmpDir); logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); - execSyncMock.mockReset(); spawnMock.mockReset(); }); @@ -60,7 +84,7 @@ describe("dev/form", () => { it("startDevContainer exits early when compose file is missing", async () => { await startDevContainer(baseOptions); - expect(execSyncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); expect(logSpy.mock.calls.some((c) => String(c[0]).includes("not found"))).toBe( true, ); @@ -71,9 +95,7 @@ describe("dev/form", () => { path.join(tmpDir, baseOptions.composeFile), "services: {}\n", ); - execSyncMock.mockImplementation(() => { - throw new Error("docker not running"); - }); + spawnMock.mockReturnValue(syncFailure()); await startDevContainer(baseOptions); @@ -87,30 +109,30 @@ describe("dev/form", () => { path.join(tmpDir, baseOptions.composeFile), "services: {}\n", ); - execSyncMock.mockImplementation(() => "ok"); + spawnMock.mockReturnValue(syncSuccess()); await startDevContainer({ ...baseOptions, build: true }); - expect(execSyncMock).toHaveBeenCalled(); - const calls = execSyncMock.mock.calls.map((c) => String(c[0])); - expect(calls.some((c) => c.includes("docker compose") && c.includes("build"))).toBe( - true, - ); - expect(calls.some((c) => c.includes("docker compose") && c.includes("up"))).toBe( - true, + expect(spawnMock).toHaveBeenCalled(); + const composeCalls = spawnMock.mock.calls.filter( + ([cmd, args]) => cmd === "docker" && args?.[0] === "compose", ); - expect(calls.some((c) => c.includes("-d"))).toBe(true); + expect(composeCalls.some(([, args]) => args.includes("build"))).toBe(true); + expect(composeCalls.some(([, args]) => args.includes("up"))).toBe(true); + expect(composeCalls.some(([, args]) => args.includes("-d"))).toBe(true); }); it("stopDevContainer uses default compose when dev file is missing", async () => { fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), "services: {}\n"); - execSyncMock.mockImplementation(() => "ok"); + spawnMock.mockReturnValue(syncSuccess()); await stopDevContainer(baseOptions); - const call = String(execSyncMock.mock.calls[0][0]); - expect(call).toContain("docker-compose.yml"); - expect(call).toContain("down"); + expect(spawnMock).toHaveBeenCalledWith( + "docker", + expect.arrayContaining(["compose", "-f", expect.stringContaining("docker-compose.yml"), "down"]), + expect.any(Object), + ); }); it("attachToContainer reports missing compose file", async () => { @@ -148,11 +170,16 @@ describe("dev/form", () => { path.join(tmpDir, baseOptions.composeFile), "services: {}\n", ); - execSyncMock.mockImplementation(() => "stats table"); + spawnMock.mockReturnValue(syncSuccess()); await showStatus(baseOptions); - expect(execSyncMock.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(spawnMock.mock.calls.length).toBeGreaterThanOrEqual(2); + expect( + spawnMock.mock.calls.some( + ([cmd, args]) => cmd === "docker" && args?.[0] === "stats", + ), + ).toBe(true); }); it("showLogs spawns compose logs with tail and follow", async () => {