diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index bd027ce0c6f2..56fccf2041de 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -1,9 +1,9 @@ -import { execFile } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { execFile, spawnSync } from "node:child_process"; +import fs, { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { isScannable, scanDirectoryWithSummary } from "../security/skill-scanner.js"; type NpmPackFile = { @@ -130,11 +130,73 @@ function stageScannerRelevantPackedFiles( return stageDir; } -function collectPublishablePluginPackages(): PublishablePluginPackage[] { - return readdirSync("extensions", { withFileTypes: true }) +function listPublishablePluginPackageDirs(): string[] { + const externalDirs = listExternalPluginPackageDirs(); + if (externalDirs) { + return externalDirs; + } + return fs + .readdirSync("extensions", { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - .flatMap((entry) => { - const packageDir = join("extensions", entry.name); + .map((entry) => join("extensions", entry.name)) + .toSorted(); +} + +function listExternalPluginPackageDirs(): string[] | null { + const packageFiles = listGitExtensionPackageFiles() ?? listFindExtensionPackageFiles(); + if (!packageFiles) { + return null; + } + return packageFiles + .flatMap((file) => { + const match = /^extensions\/([^/]+)\/package\.json$/u.exec(file); + return match?.[1] ? [join("extensions", match[1])] : []; + }) + .toSorted(); +} + +function listGitExtensionPackageFiles(): string[] | null { + const result = spawnSync("git", ["ls-files", "--", "extensions/*/package.json"], { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .toSorted(); +} + +function listFindExtensionPackageFiles(): string[] | null { + const result = spawnSync( + "find", + [resolve("extensions"), "-maxdepth", "2", "-type", "f", "-name", "package.json"], + { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((file) => relative(process.cwd(), file).split(sep).join("/")) + .toSorted(); +} + +function collectPublishablePluginPackages(): PublishablePluginPackage[] { + return listPublishablePluginPackageDirs() + .flatMap((packageDir) => { const packageJsonPath = join(packageDir, "package.json"); let packageJson: { name?: unknown; @@ -231,6 +293,21 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P } describe("publishable plugin npm package install security scan", () => { + it("lists publishable plugin packages without scanning extension directories in-process", () => { + const readDir = vi.spyOn(fs, "readdirSync"); + try { + const packages = collectPublishablePluginPackages(); + + expect(packages.length).toBeGreaterThan(0); + expect( + packages.every((plugin) => plugin.packageDir.split(sep).join("/").startsWith("extensions/")), + ).toBe(true); + expect(readDir).not.toHaveBeenCalled(); + } finally { + readDir.mockRestore(); + } + }); + it("keeps npm-published plugin files clear of unexpected critical hits", async () => { const unexpectedCriticalFindings: string[] = []; const reviewedCriticalFindings = new Set();