diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a14cbae222..2312db8b8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ Docs: https://docs.openclaw.ai - Discord/sandbox: include `image` in sandbox media param normalization so Discord event cover images cannot bypass sandbox path rewriting. (#64377) Thanks @mmaps. - Agents/exec: extend exec completion detection to cover local background exec formats so the owner-downgrade fires correctly for all exec paths. (#64376) Thanks @mmaps. +- Security/dependencies: pin axios to 1.15.0 and add a plugin install dependency denylist that blocks known malicious packages before install. (#63891) Thanks @mmaps. ## 2026.4.9 ### Changes diff --git a/package.json b/package.json index f05663227ca..f244ca01c83 100644 --- a/package.json +++ b/package.json @@ -1427,6 +1427,9 @@ "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "openshell": "0.1.0" }, + "overrides": { + "axios": "1.15.0" + }, "engines": { "node": ">=22.14.0" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 77ced24d87c..d418a0d6e39 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ minimumReleaseAge: 2880 minimumReleaseAgeExclude: - "acpx" + - "axios" - "basic-ftp" - "hono" - "openclaw" diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index f6c467096c3..23c1bd90e07 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -163,7 +163,10 @@ export async function installPackageDir(params: { hasDeps: boolean; depsLogMessage: string; afterCopy?: (installedDir: string) => void | Promise; -}): Promise<{ ok: true } | { ok: false; error: string }> { + afterInstall?: ( + installedDir: string, + ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; +}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { params.logger?.info?.(`Installing to ${params.targetDir}…`); const installBaseDir = path.dirname(params.targetDir); await fs.mkdir(installBaseDir, { recursive: true }); @@ -197,6 +200,10 @@ export async function installPackageDir(params: { } return { ok: false as const, error }; }; + const failWithCode = async (params: { error: string; code?: string }, cause?: unknown) => { + const failed = await fail(params.error, cause); + return params.code ? { ...failed, code: params.code } : failed; + }; const restoreBackup = async () => { if (!backupDir) { return; @@ -250,6 +257,17 @@ export async function installPackageDir(params: { } } + if (params.afterInstall) { + try { + const postInstallResult = await params.afterInstall(stageDir); + if (!postInstallResult.ok) { + return await failWithCode(postInstallResult); + } + } catch (err) { + return await fail(`post-install validation failed: ${String(err)}`, err); + } + } + if (params.mode === "update" && (await fileExists(canonicalTargetDir))) { const backupRoot = path.join(installBaseRealPath, ".openclaw-install-backups"); backupDir = path.join(backupRoot, `${path.basename(canonicalTargetDir)}-${Date.now()}`); diff --git a/src/plugins/dependency-denylist.test.ts b/src/plugins/dependency-denylist.test.ts new file mode 100644 index 00000000000..411f8d7b764 --- /dev/null +++ b/src/plugins/dependency-denylist.test.ts @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + blockedInstallDependencyPackageNames, + findBlockedPackageDirectoryInPath, + findBlockedPackageFileAliasInPath, + findBlockedManifestDependencies, + findBlockedNodeModulesDirectory, + findBlockedNodeModulesFileAlias, +} from "./dependency-denylist.js"; + +type RootPackageManifest = { + dependencies?: Record; + optionalDependencies?: Record; + overrides?: Record>; + peerDependencies?: Record; + pnpm?: { + overrides?: Record; + }; +}; + +function readRootManifest(): RootPackageManifest { + return JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), "package.json"), "utf8"), + ) as RootPackageManifest; +} + +function readRootLockfile(): string { + return fs.readFileSync(path.resolve(process.cwd(), "pnpm-lock.yaml"), "utf8"); +} + +describe("dependency denylist guardrails", () => { + it("finds blocked package names on vendored manifests", () => { + expect( + findBlockedManifestDependencies({ + name: "plain-crypto-js", + }), + ).toEqual([ + { + dependencyName: "plain-crypto-js", + field: "name", + }, + ]); + }); + + it("finds blocked packages declared through npm alias specs", () => { + expect( + findBlockedManifestDependencies({ + dependencies: { + "safe-name": "npm:plain-crypto-js@^4.2.1", + }, + peerDependencies: { + "@alias/safe": "npm:@scope/ok@^1.0.0", + }, + }), + ).toEqual([ + { + dependencyName: "plain-crypto-js", + declaredAs: "safe-name", + field: "dependencies", + }, + ]); + }); + + it("finds blocked packages declared through nested override alias specs", () => { + expect( + findBlockedManifestDependencies({ + overrides: { + axios: "1.15.0", + "@scope/parent": { + "safe-name": "npm:plain-crypto-js@^4.2.1", + }, + }, + }), + ).toEqual([ + { + dependencyName: "plain-crypto-js", + declaredAs: "@scope/parent > safe-name", + field: "overrides", + }, + ]); + }); + + it("pins the axios override to an exact version", () => { + const manifest = readRootManifest(); + expect(manifest.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/); + expect(manifest.pnpm?.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("finds blocked package directories under node_modules regardless of node_modules casing", () => { + expect( + findBlockedNodeModulesDirectory({ + directoryRelativePath: "vendor/Node_Modules/plain-crypto-js", + }), + ).toEqual({ + dependencyName: "plain-crypto-js", + directoryRelativePath: "vendor/Node_Modules/plain-crypto-js", + }); + }); + + it("finds blocked package directories regardless of blocked package segment casing", () => { + expect( + findBlockedNodeModulesDirectory({ + directoryRelativePath: "vendor/node_modules/Plain-Crypto-Js", + }), + ).toEqual({ + dependencyName: "Plain-Crypto-Js", + directoryRelativePath: "vendor/node_modules/Plain-Crypto-Js", + }); + }); + + it("finds blocked package file aliases under node_modules regardless of casing", () => { + expect( + findBlockedNodeModulesFileAlias({ + fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js.Js", + }), + ).toEqual({ + dependencyName: "Plain-Crypto-Js", + fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js.Js", + }); + }); + + it("finds blocked extensionless package file aliases under node_modules", () => { + expect( + findBlockedNodeModulesFileAlias({ + fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js", + }), + ).toEqual({ + dependencyName: "Plain-Crypto-Js", + fileRelativePath: "vendor/Node_Modules/Plain-Crypto-Js", + }); + }); + + it("finds blocked package directories anywhere in a resolved path", () => { + expect( + findBlockedPackageDirectoryInPath({ + pathRelativeToRoot: "vendor/Plain-Crypto-Js/dist/index.js", + }), + ).toEqual({ + dependencyName: "Plain-Crypto-Js", + directoryRelativePath: "vendor/Plain-Crypto-Js/dist/index.js", + }); + }); + + it("finds blocked package file aliases anywhere in a resolved path", () => { + expect( + findBlockedPackageFileAliasInPath({ + pathRelativeToRoot: "vendor/Plain-Crypto-Js.Js", + }), + ).toEqual({ + dependencyName: "Plain-Crypto-Js", + fileRelativePath: "vendor/Plain-Crypto-Js.Js", + }); + }); + + it("does not treat similarly named non-node_modules segments as package-resolution paths", () => { + expect( + findBlockedNodeModulesDirectory({ + directoryRelativePath: "vendor/node_modules_backup/plain-crypto-js", + }), + ).toBeUndefined(); + }); + + it("does not treat similarly named non-node_modules file aliases as package-resolution paths", () => { + expect( + findBlockedNodeModulesFileAlias({ + fileRelativePath: "vendor/plain-crypto-js.js", + }), + ).toBeUndefined(); + }); + + it("does not treat dotted non-loadable file aliases as blocked package paths", () => { + expect( + findBlockedNodeModulesFileAlias({ + fileRelativePath: "vendor/node_modules/plain-crypto-js.txt", + }), + ).toBeUndefined(); + }); + + it("does not treat similarly named non-package paths as blocked package directories", () => { + expect( + findBlockedPackageDirectoryInPath({ + pathRelativeToRoot: "vendor/safe-plain-crypto-js-notes/index.js", + }), + ).toBeUndefined(); + }); + + it("does not flag the unscoped name segment from an allowed scoped package path", () => { + expect( + findBlockedPackageDirectoryInPath({ + pathRelativeToRoot: "vendor/@scope/plain-crypto-js/dist/index.js", + }), + ).toBeUndefined(); + }); + + it("keeps blocked packages out of the root manifest", () => { + const manifest = readRootManifest(); + expect(findBlockedManifestDependencies(manifest)).toEqual([]); + }); + + it("keeps blocked packages out of the lockfile graph", () => { + const lockfile = readRootLockfile(); + for (const packageName of blockedInstallDependencyPackageNames) { + expect(lockfile).not.toContain(`\n ${packageName}@`); + expect(lockfile).not.toContain(`\n ${packageName}: `); + } + }); +}); diff --git a/src/plugins/dependency-denylist.ts b/src/plugins/dependency-denylist.ts new file mode 100644 index 00000000000..fac178df5b5 --- /dev/null +++ b/src/plugins/dependency-denylist.ts @@ -0,0 +1,326 @@ +const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAMES = ["plain-crypto-js"] as const; + +export const blockedInstallDependencyPackageNames = [ + ...BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAMES, +] as const; + +export type BlockedManifestDependencyFinding = { + dependencyName: string; + declaredAs?: string; + field: "dependencies" | "name" | "optionalDependencies" | "overrides" | "peerDependencies"; +}; + +export type BlockedPackageDirectoryFinding = { + dependencyName: string; + directoryRelativePath: string; +}; + +export type BlockedPackageFileFinding = { + dependencyName: string; + fileRelativePath: string; +}; + +type PackageDependencyMapFields = Partial< + Record< + Exclude, + Record + > +>; + +type PackageDependencyFields = { + name?: string; +} & PackageDependencyMapFields; + +interface PackageOverrideObject { + [key: string]: PackageOverrideValue; +} + +type PackageOverrideValue = string | PackageOverrideObject; + +type PackageOverrideFields = { + overrides?: unknown; +}; + +const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET = new Set( + blockedInstallDependencyPackageNames, +); + +const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_LOWER_SET = new Set( + blockedInstallDependencyPackageNames.map((packageName) => packageName.toLowerCase()), +); + +function isBlockedInstallDependencyPackageName(packageName: string): boolean { + return BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(packageName); +} + +function isBlockedInstallDependencyPackagePathName(packageName: string): boolean { + return BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_LOWER_SET.has(packageName.toLowerCase()); +} + +function normalizePathSegments(relativePath: string): string[] { + return relativePath + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function parseBlockedNodeModulesPackageId( + segments: string[], + packageNameSegmentTransform: (packageNameSegment: string) => string | undefined, +): string | undefined { + for (let index = 0; index < segments.length; index += 1) { + if (segments[index]?.toLowerCase() !== "node_modules") { + continue; + } + const packageScopeOrName = segments[index + 1]; + if (!packageScopeOrName) { + continue; + } + + if (packageScopeOrName.startsWith("@")) { + const packageNameSegment = segments[index + 2]; + if (!packageNameSegment) { + continue; + } + const packageName = packageNameSegmentTransform(packageNameSegment); + if (!packageName) { + continue; + } + const scopedPackageId = `${packageScopeOrName}/${packageName}`; + if (!isBlockedInstallDependencyPackagePathName(scopedPackageId)) { + continue; + } + return scopedPackageId; + } + + const packageName = packageNameSegmentTransform(packageScopeOrName); + if (!packageName || !isBlockedInstallDependencyPackagePathName(packageName)) { + continue; + } + return packageName; + } + + return undefined; +} + +function parseNpmAliasTargetPackageName(spec: string): string | undefined { + const normalized = spec.trim(); + if (!normalized.startsWith("npm:")) { + return undefined; + } + + const aliasTarget = normalized.slice("npm:".length).trim(); + if (!aliasTarget) { + return undefined; + } + + if (aliasTarget.startsWith("@")) { + const slashIndex = aliasTarget.indexOf("/"); + if (slashIndex < 0) { + return undefined; + } + const versionSeparatorIndex = aliasTarget.indexOf("@", slashIndex + 1); + return versionSeparatorIndex < 0 ? aliasTarget : aliasTarget.slice(0, versionSeparatorIndex); + } + + const versionSeparatorIndex = aliasTarget.indexOf("@"); + return versionSeparatorIndex < 0 ? aliasTarget : aliasTarget.slice(0, versionSeparatorIndex); +} + +function parsePackageNameFromOverrideSelector(selector: string): string | undefined { + const normalized = selector.trim(); + if (!normalized || normalized === ".") { + return undefined; + } + + if (normalized.startsWith("@")) { + const slashIndex = normalized.indexOf("/"); + if (slashIndex < 0) { + return undefined; + } + const versionSeparatorIndex = normalized.indexOf("@", slashIndex + 1); + return versionSeparatorIndex < 0 ? normalized : normalized.slice(0, versionSeparatorIndex); + } + + const versionSeparatorIndex = normalized.indexOf("@"); + return versionSeparatorIndex < 0 ? normalized : normalized.slice(0, versionSeparatorIndex); +} + +function collectBlockedOverrideFindings( + value: PackageOverrideValue, + path: string[] = [], +): BlockedManifestDependencyFinding[] { + if (typeof value === "string") { + const aliasTargetPackageName = parseNpmAliasTargetPackageName(value); + if (!aliasTargetPackageName) { + return []; + } + if (!BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(aliasTargetPackageName)) { + return []; + } + return [ + { + dependencyName: aliasTargetPackageName, + declaredAs: path.join(" > "), + field: "overrides", + }, + ]; + } + + const findings: BlockedManifestDependencyFinding[] = []; + for (const overrideKey of Object.keys(value).toSorted()) { + const overrideSelectorPackageName = parsePackageNameFromOverrideSelector(overrideKey); + if ( + overrideSelectorPackageName && + BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_SET.has(overrideSelectorPackageName) + ) { + findings.push({ + dependencyName: overrideSelectorPackageName, + declaredAs: [...path, overrideKey].join(" > "), + field: "overrides", + }); + } + findings.push(...collectBlockedOverrideFindings(value[overrideKey], [...path, overrideKey])); + } + return findings; +} + +function isPackageOverrideObject(value: unknown): value is PackageOverrideObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function findBlockedManifestDependencies( + manifest: PackageDependencyFields & PackageOverrideFields, +): BlockedManifestDependencyFinding[] { + const findings: BlockedManifestDependencyFinding[] = []; + if (manifest.name && isBlockedInstallDependencyPackageName(manifest.name)) { + findings.push({ dependencyName: manifest.name, field: "name" }); + } + if (isPackageOverrideObject(manifest.overrides)) { + findings.push(...collectBlockedOverrideFindings(manifest.overrides)); + } + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"] as const) { + const dependencyMap = manifest[field]; + if (!dependencyMap) { + continue; + } + for (const dependencyName of Object.keys(dependencyMap).toSorted()) { + if (isBlockedInstallDependencyPackageName(dependencyName)) { + findings.push({ dependencyName, field }); + continue; + } + + const aliasTargetPackageName = parseNpmAliasTargetPackageName(dependencyMap[dependencyName]); + if (!aliasTargetPackageName) { + continue; + } + if (!isBlockedInstallDependencyPackageName(aliasTargetPackageName)) { + continue; + } + findings.push({ + dependencyName: aliasTargetPackageName, + declaredAs: dependencyName, + field, + }); + } + } + return findings; +} + +export function findBlockedNodeModulesDirectory(params: { + directoryRelativePath: string; +}): BlockedPackageDirectoryFinding | undefined { + const dependencyName = parseBlockedNodeModulesPackageId( + normalizePathSegments(params.directoryRelativePath), + (packageNameSegment) => packageNameSegment, + ); + return dependencyName + ? { + dependencyName, + directoryRelativePath: params.directoryRelativePath, + } + : undefined; +} + +function parseBlockedPackageFileAliasName(fileName: string): string | undefined { + const extensionMatch = /^(.+)\.(js|json|node)$/i.exec(fileName); + if (extensionMatch) { + return extensionMatch[1]; + } + if (fileName.includes(".")) { + return undefined; + } + return fileName; +} + +export function findBlockedNodeModulesFileAlias(params: { + fileRelativePath: string; +}): BlockedPackageFileFinding | undefined { + const dependencyName = parseBlockedNodeModulesPackageId( + normalizePathSegments(params.fileRelativePath), + parseBlockedPackageFileAliasName, + ); + return dependencyName + ? { + dependencyName, + fileRelativePath: params.fileRelativePath, + } + : undefined; +} + +export function findBlockedPackageDirectoryInPath(params: { + pathRelativeToRoot: string; +}): BlockedPackageDirectoryFinding | undefined { + const segments = normalizePathSegments(params.pathRelativeToRoot); + + for (let index = 0; index < segments.length; index += 1) { + const packageScopeOrName = segments[index]; + if (!packageScopeOrName) { + continue; + } + + if (packageScopeOrName.startsWith("@")) { + const packageName = segments[index + 1]; + if (!packageName) { + continue; + } + const scopedPackageId = `${packageScopeOrName}/${packageName}`; + if (!isBlockedInstallDependencyPackagePathName(scopedPackageId)) { + index += 1; + continue; + } + return { + dependencyName: scopedPackageId, + directoryRelativePath: params.pathRelativeToRoot, + }; + } + + if (!isBlockedInstallDependencyPackagePathName(packageScopeOrName)) { + continue; + } + return { + dependencyName: packageScopeOrName, + directoryRelativePath: params.pathRelativeToRoot, + }; + } + + return undefined; +} + +export function findBlockedPackageFileAliasInPath(params: { + pathRelativeToRoot: string; +}): BlockedPackageFileFinding | undefined { + const segments = normalizePathSegments(params.pathRelativeToRoot); + const fileName = segments.at(-1); + if (!fileName) { + return undefined; + } + const dependencyName = parseBlockedPackageFileAliasName(fileName); + if (!dependencyName || !isBlockedInstallDependencyPackagePathName(dependencyName)) { + return undefined; + } + return { + dependencyName, + fileRelativePath: params.pathRelativeToRoot, + }; +} diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index bf6cbbb5e91..2bd2ee00bc6 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -1,6 +1,14 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; +import { + findBlockedPackageDirectoryInPath, + findBlockedPackageFileAliasInPath, + findBlockedManifestDependencies, + findBlockedNodeModulesDirectory, + findBlockedNodeModulesFileAlias, +} from "./dependency-denylist.js"; import { getGlobalHookRunner } from "./hook-runner-global.js"; import { createBeforeInstallHookPayload } from "./install-policy-context.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; @@ -27,6 +35,36 @@ type BuiltinInstallScan = { error?: string; }; +type PackageManifest = { + name?: string; + dependencies?: Record; + optionalDependencies?: Record; + overrides?: unknown; + peerDependencies?: Record; +}; + +type PackageManifestTraversalLimits = { + maxDepth: number; + maxDirectories: number; + maxManifests: number; +}; + +type BlockedPackageDirectoryFinding = { + dependencyName: string; + directoryRelativePath: string; +}; + +type BlockedPackageFileFinding = { + dependencyName: string; + fileRelativePath: string; +}; + +type PackageManifestTraversalResult = { + blockedDirectoryFinding?: BlockedPackageDirectoryFinding; + blockedFileFinding?: BlockedPackageFileFinding; + packageManifestPaths: string[]; +}; + type PluginInstallRequestKind = | "skill-install" | "plugin-dir" @@ -61,6 +99,102 @@ function buildScanFailureBlockReason(params: { error: string; targetLabel: strin return `${params.targetLabel} blocked: code safety scan failed (${params.error}). Run "openclaw security audit --deep" for details.`; } +function buildBlockedDependencyManifestLabel(params: { + manifestPackageName?: string; + manifestRelativePath: string; +}) { + const manifestLabel = + typeof params.manifestPackageName === "string" && params.manifestPackageName.trim() + ? `${params.manifestPackageName.trim()} (${params.manifestRelativePath})` + : params.manifestRelativePath; + return manifestLabel; +} + +function buildBlockedDependencyReason(params: { + findings: Array<{ + dependencyName: string; + declaredAs?: string; + field: "dependencies" | "name" | "optionalDependencies" | "overrides" | "peerDependencies"; + }>; + manifestPackageName?: string; + manifestRelativePath: string; + targetLabel: string; +}) { + const manifestLabel = buildBlockedDependencyManifestLabel({ + manifestPackageName: params.manifestPackageName, + manifestRelativePath: params.manifestRelativePath, + }); + const findingSummary = params.findings + .map((finding) => + finding.field === "name" + ? `"${finding.dependencyName}" as package name` + : finding.declaredAs + ? `"${finding.dependencyName}" via alias "${finding.declaredAs}" in ${finding.field}` + : `"${finding.dependencyName}" in ${finding.field}`, + ) + .join(", "); + return `${params.targetLabel} blocked: blocked dependencies ${findingSummary} declared in ${manifestLabel}.`; +} + +function buildBlockedDependencyDirectoryReason(params: { + dependencyName: string; + directoryRelativePath: string; + targetLabel: string; +}) { + return `${params.targetLabel} blocked: blocked dependency directory "${params.dependencyName}" declared at ${params.directoryRelativePath}.`; +} + +function buildBlockedDependencyFileReason(params: { + dependencyName: string; + fileRelativePath: string; + targetLabel: string; +}) { + return `${params.targetLabel} blocked: blocked dependency file alias "${params.dependencyName}" declared at ${params.fileRelativePath}.`; +} + +function pathContainsNodeModulesSegment(relativePath: string): boolean { + return relativePath + .split(/[\\/]+/) + .map((segment) => segment.trim().toLowerCase()) + .includes("node_modules"); +} + +async function inspectNodeModulesSymlinkTarget(params: { + rootRealPath: string; + symlinkPath: string; + symlinkRelativePath: string; +}): Promise< + Pick +> { + let resolvedTargetPath: string; + try { + resolvedTargetPath = await fs.realpath(params.symlinkPath); + } catch (error) { + throw new Error( + `manifest dependency scan could not resolve symlink target ${params.symlinkRelativePath}: ${String(error)}`, + { + cause: error, + }, + ); + } + + if (!isPathInside(params.rootRealPath, resolvedTargetPath)) { + throw new Error( + `manifest dependency scan found node_modules symlink target outside install root at ${params.symlinkRelativePath}`, + ); + } + + const resolvedTargetRelativePath = path.relative(params.rootRealPath, resolvedTargetPath); + return { + blockedDirectoryFinding: findBlockedPackageDirectoryInPath({ + pathRelativeToRoot: resolvedTargetRelativePath, + }), + blockedFileFinding: findBlockedPackageFileAliasInPath({ + pathRelativeToRoot: resolvedTargetRelativePath, + }), + }; +} + function buildBuiltinScanFromError(error: unknown): BuiltinInstallScan { return { status: "error", @@ -90,6 +224,240 @@ function buildBuiltinScanFromSummary(summary: { }; } +const DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS: PackageManifestTraversalLimits = { + maxDepth: 64, + maxDirectories: 10_000, + maxManifests: 10_000, +}; + +function readPositiveIntegerEnv(name: string, fallback: number): number { + const rawValue = process.env[name]; + if (!rawValue) { + return fallback; + } + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsedValue) || parsedValue < 1) { + return fallback; + } + return parsedValue; +} + +function resolvePackageManifestTraversalLimits(): PackageManifestTraversalLimits { + return { + maxDepth: readPositiveIntegerEnv( + "OPENCLAW_INSTALL_SCAN_MAX_DEPTH", + DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxDepth, + ), + maxDirectories: readPositiveIntegerEnv( + "OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES", + DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxDirectories, + ), + maxManifests: readPositiveIntegerEnv( + "OPENCLAW_INSTALL_SCAN_MAX_MANIFESTS", + DEFAULT_PACKAGE_MANIFEST_TRAVERSAL_LIMITS.maxManifests, + ), + }; +} + +async function collectPackageManifestPaths( + rootDir: string, +): Promise { + const limits = resolvePackageManifestTraversalLimits(); + const rootRealPath = await fs.realpath(rootDir).catch(() => rootDir); + const queue: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: rootDir }]; + const packageManifestPaths: string[] = []; + const visitedDirectories = new Set(); + let queueIndex = 0; + + while (queueIndex < queue.length) { + const current = queue[queueIndex]; + queueIndex += 1; + if (!current) { + continue; + } + + if (current.depth > limits.maxDepth) { + throw new Error( + `manifest dependency scan exceeded max depth (${limits.maxDepth}) at ${current.dir}`, + ); + } + + const currentDir = current.dir; + const currentRealPath = await fs.realpath(currentDir).catch(() => currentDir); + if (visitedDirectories.has(currentRealPath)) { + continue; + } + visitedDirectories.add(currentRealPath); + if (visitedDirectories.size > limits.maxDirectories) { + throw new Error( + `manifest dependency scan exceeded max directories (${limits.maxDirectories}) under ${rootDir}`, + ); + } + + let entries: Array<{ + name: string; + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; + }>; + try { + entries = await fs.readdir(currentDir, { encoding: "utf8", withFileTypes: true }); + } catch (error) { + throw new Error(`manifest dependency scan could not read ${currentDir}: ${String(error)}`, { + cause: error, + }); + } + + // Intentionally walk vendored/node_modules trees so bundled transitive + // manifests cannot hide blocked packages from install-time policy checks. + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const nextPath = path.join(currentDir, entry.name); + const relativeNextPath = path.relative(rootDir, nextPath) || entry.name; + if (entry.isSymbolicLink()) { + const blockedDirectoryFinding = findBlockedNodeModulesDirectory({ + directoryRelativePath: relativeNextPath, + }); + if (blockedDirectoryFinding) { + return { + blockedDirectoryFinding, + packageManifestPaths, + }; + } + const blockedFileFinding = findBlockedNodeModulesFileAlias({ + fileRelativePath: relativeNextPath, + }); + if (blockedFileFinding) { + return { + blockedFileFinding, + packageManifestPaths, + }; + } + if (pathContainsNodeModulesSegment(relativeNextPath)) { + const symlinkTargetInspection = await inspectNodeModulesSymlinkTarget({ + rootRealPath, + symlinkPath: nextPath, + symlinkRelativePath: relativeNextPath, + }); + if (symlinkTargetInspection.blockedDirectoryFinding) { + return { + blockedDirectoryFinding: symlinkTargetInspection.blockedDirectoryFinding, + packageManifestPaths, + }; + } + if (symlinkTargetInspection.blockedFileFinding) { + return { + blockedFileFinding: symlinkTargetInspection.blockedFileFinding, + packageManifestPaths, + }; + } + } + continue; + } + if (entry.isDirectory()) { + const blockedDirectoryFinding = findBlockedNodeModulesDirectory({ + directoryRelativePath: relativeNextPath, + }); + if (blockedDirectoryFinding) { + return { + blockedDirectoryFinding, + packageManifestPaths, + }; + } + queue.push({ depth: current.depth + 1, dir: nextPath }); + continue; + } + if (entry.isFile()) { + const blockedFileFinding = findBlockedNodeModulesFileAlias({ + fileRelativePath: relativeNextPath, + }); + if (blockedFileFinding) { + return { + blockedFileFinding, + packageManifestPaths, + }; + } + } + if (entry.isFile() && entry.name === "package.json") { + packageManifestPaths.push(nextPath); + if (packageManifestPaths.length > limits.maxManifests) { + throw new Error( + `manifest dependency scan exceeded max manifests (${limits.maxManifests}) under ${rootDir}`, + ); + } + } + } + } + + return { packageManifestPaths }; +} + +async function scanManifestDependencyDenylist(params: { + logger: InstallScanLogger; + packageDir: string; + targetLabel: string; +}): Promise { + const traversalResult = await collectPackageManifestPaths(params.packageDir); + if (traversalResult.blockedDirectoryFinding) { + const reason = buildBlockedDependencyDirectoryReason({ + dependencyName: traversalResult.blockedDirectoryFinding.dependencyName, + directoryRelativePath: traversalResult.blockedDirectoryFinding.directoryRelativePath, + targetLabel: params.targetLabel, + }); + params.logger.warn?.(`WARNING: ${reason}`); + return { + blocked: { + code: "security_scan_blocked", + reason, + }, + }; + } + if (traversalResult.blockedFileFinding) { + const reason = buildBlockedDependencyFileReason({ + dependencyName: traversalResult.blockedFileFinding.dependencyName, + fileRelativePath: traversalResult.blockedFileFinding.fileRelativePath, + targetLabel: params.targetLabel, + }); + params.logger.warn?.(`WARNING: ${reason}`); + return { + blocked: { + code: "security_scan_blocked", + reason, + }, + }; + } + + const packageManifestPaths = traversalResult.packageManifestPaths; + for (const manifestPath of packageManifestPaths) { + let manifest: PackageManifest; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as PackageManifest; + } catch { + continue; + } + + const blockedDependencies = findBlockedManifestDependencies(manifest); + if (blockedDependencies.length === 0) { + continue; + } + + const manifestRelativePath = path.relative(params.packageDir, manifestPath) || "package.json"; + const reason = buildBlockedDependencyReason({ + findings: blockedDependencies, + manifestPackageName: manifest.name, + manifestRelativePath, + targetLabel: params.targetLabel, + }); + params.logger.warn?.(`WARNING: ${reason}`); + return { + blocked: { + code: "security_scan_blocked", + reason, + }, + }; + } + return undefined; +} + async function scanDirectoryTarget(params: { includeFiles?: string[]; logger: InstallScanLogger; @@ -296,6 +664,15 @@ export async function scanBundleInstallSourceRuntime( version?: string; }, ): Promise { + const dependencyBlocked = await scanManifestDependencyDenylist({ + logger: params.logger, + packageDir: params.sourceDir, + targetLabel: `Bundle "${params.pluginId}" installation`, + }); + if (dependencyBlocked) { + return dependencyBlocked; + } + const builtinScan = await scanDirectoryTarget({ logger: params.logger, path: params.sourceDir, @@ -346,6 +723,15 @@ export async function scanPackageInstallSourceRuntime( version?: string; }, ): Promise { + const dependencyBlocked = await scanManifestDependencyDenylist({ + logger: params.logger, + packageDir: params.packageDir, + targetLabel: `Plugin "${params.pluginId}" installation`, + }); + if (dependencyBlocked) { + return dependencyBlocked; + } + const forcedScanEntries: string[] = []; for (const entry of params.extensions) { const resolvedEntry = path.resolve(params.packageDir, entry); @@ -402,6 +788,18 @@ export async function scanPackageInstallSourceRuntime( return hookResult?.blocked ? hookResult : builtinBlocked; } +export async function scanInstalledPackageDependencyTreeRuntime(params: { + logger: InstallScanLogger; + packageDir: string; + pluginId: string; +}): Promise { + return await scanManifestDependencyDenylist({ + logger: params.logger, + packageDir: params.packageDir, + targetLabel: `Plugin "${params.pluginId}" installation`, + }); +} + export async function scanFileInstallSourceRuntime( params: InstallSafetyOverrides & { filePath: string; diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 1843a295cbe..f058a679ea8 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -72,6 +72,15 @@ export async function scanPackageInstallSource( return await scanPackageInstallSourceRuntime(params); } +export async function scanInstalledPackageDependencyTree(params: { + logger: InstallScanLogger; + packageDir: string; + pluginId: string; +}): Promise { + const { scanInstalledPackageDependencyTreeRuntime } = await loadInstallSecurityScanRuntime(); + return await scanInstalledPackageDependencyTreeRuntime(params); +} + export async function scanFileInstallSource( params: InstallSafetyOverrides & { filePath: string; diff --git a/src/plugins/install.runtime.ts b/src/plugins/install.runtime.ts index b63a250388a..7d300ea1f23 100644 --- a/src/plugins/install.runtime.ts +++ b/src/plugins/install.runtime.ts @@ -23,6 +23,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { resolveCompatibilityHostVersion, resolveRuntimeServiceVersion } from "../version.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; import { + scanInstalledPackageDependencyTree, scanBundleInstallSource, scanFileInstallSource, scanPackageInstallSource, @@ -59,6 +60,7 @@ export { resolveCompatibilityHostVersion, resolveRuntimeServiceVersion, resolveTimedInstallModeOptions, + scanInstalledPackageDependencyTree, scanBundleInstallSource, scanFileInstallSource, scanPackageInstallSource, diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 04232c202ef..042b6e10b9f 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -563,6 +563,9 @@ beforeAll(async () => { beforeEach(() => { resetGlobalHookRunner(); vi.clearAllMocks(); + const run = vi.mocked(runCommandWithTimeout); + run.mockReset(); + mockSuccessfulCommandRun(run); vi.unstubAllEnvs(); resolveCompatibilityHostVersionMock.mockReturnValue("2026.3.28-beta.1"); }); @@ -764,6 +767,634 @@ describe("installPluginFromArchive", () => { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("blocks package installs when a package manifest declares a blocked dependency", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-dependency-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + dependencies: { + "plain-crypto-js": "^4.2.1", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('Plugin "blocked-dependency-plugin" installation blocked'); + expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); + expect(result.error).toContain("declared in blocked-dependency-plugin (package.json)"); + } + expect(warnings).toContain( + 'WARNING: Plugin "blocked-dependency-plugin" installation blocked: blocked dependencies "plain-crypto-js" in dependencies declared in blocked-dependency-plugin (package.json).', + ); + }); + + it("blocks package installs when a dependency aliases to a blocked package", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "aliased-blocked-dependency-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + dependencies: { + "safe-name": "npm:plain-crypto-js@^4.2.1", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('"plain-crypto-js" via alias "safe-name" in dependencies'); + expect(result.error).toContain( + "declared in aliased-blocked-dependency-plugin (package.json)", + ); + } + }); + + it("blocks package installs when overrides alias to a blocked package", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "override-aliased-blocked-dependency-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + overrides: { + "@scope/parent": { + "safe-name": "npm:plain-crypto-js@^4.2.1", + }, + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + '"plain-crypto-js" via alias "@scope/parent > safe-name" in overrides', + ); + expect(result.error).toContain( + "declared in override-aliased-blocked-dependency-plugin (package.json)", + ); + } + }); + + it("blocks package installs when a nested vendored package manifest declares a blocked dependency", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "vendored-blocked-dependency-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "axios", "package.json"), + JSON.stringify({ + name: "axios", + version: "1.14.1", + dependencies: { + "plain-crypto-js": "^4.2.1", + }, + }), + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); + expect(result.error).toContain("declared in axios (vendor/axios/package.json)"); + } + }); + + it("blocks package installs when node_modules contains a blocked package directory without package.json", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-dir-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js"); + fs.mkdirSync(blockedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); + } + }); + + it("blocks package installs when node_modules contains a blocked package file alias", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-file-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); + expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js"); + } + }); + + it("blocks package installs when node_modules contains a blocked extensionless package file alias", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-extensionless-file-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); + expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js"); + } + }); + + it.runIf(process.platform !== "win32")( + "blocks package installs when node_modules contains a blocked package symlink", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-symlink-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const actualDir = path.join(pluginDir, "vendor", "actual-package"); + fs.mkdirSync(actualDir, { recursive: true }); + fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks package installs when node_modules safe-name symlink targets a blocked package directory", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-symlink-target-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks package installs when node_modules safe-name symlink targets a blocked package file alias", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-file-symlink-target-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "plain-crypto-js.js"), + "module.exports = {};\n", + ); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js.js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks package installs when node_modules safe-name symlink targets a file under a blocked package directory", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "blocked-package-nested-file-symlink-target-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist"); + fs.mkdirSync(blockedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync( + "../plain-crypto-js/dist/index.js", + path.join(nodeModulesDir, "safe-name"), + "file", + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "does not block package installs when node_modules symlink targets an allowed scoped package path", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "allowed-scoped-symlink-target-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const scopedTargetDir = path.join(pluginDir, "vendor", "@scope", "plain-crypto-js"); + fs.mkdirSync(scopedTargetDir, { recursive: true }); + fs.writeFileSync(path.join(scopedTargetDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../@scope/plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }, + ); + + it.runIf(process.platform !== "win32")( + "fails package installs when node_modules symlink target escapes the install root", + async () => { + const { pluginDir, extensionsDir, tmpDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "outside-root-symlink-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const externalDir = path.join(tmpDir, "external-package"); + fs.mkdirSync(externalDir, { recursive: true }); + fs.writeFileSync(path.join(externalDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync(externalDir, path.join(nodeModulesDir, "safe-name"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("symlink target outside install root"); + } + }, + ); + + it("does not block package installs for blocked-looking names outside node_modules", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "non-node-modules-path-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const innocuousDir = path.join(pluginDir, "assets", "plain-crypto-js"); + fs.mkdirSync(innocuousDir, { recursive: true }); + fs.writeFileSync(path.join(innocuousDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }); + + it("does not block package installs for blocked package file aliases outside node_modules", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "non-node-modules-file-alias-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "assets", "plain-crypto-js.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(true); + }); + + it("blocks package installs when a broad vendored tree contains a deeply nested blocked manifest", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "wide-vendored-tree-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const vendorRoot = path.join(pluginDir, "vendor"); + for (let index = 0; index < 128; index += 1) { + fs.mkdirSync(path.join(vendorRoot, `pkg-${String(index).padStart(3, "0")}`), { + recursive: true, + }); + } + + const blockedManifestDir = path.join( + vendorRoot, + "pkg-127", + "node_modules", + "nested-safe", + "node_modules", + "plain-crypto-js", + ); + fs.mkdirSync(blockedManifestDir, { recursive: true }); + fs.writeFileSync( + path.join(blockedManifestDir, "package.json"), + JSON.stringify({ + name: "plain-crypto-js", + version: "4.2.1", + }), + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('"plain-crypto-js" as package name'); + expect(result.error).toContain( + "declared in plain-crypto-js (vendor/pkg-127/node_modules/nested-safe/node_modules/plain-crypto-js/package.json)", + ); + } + }); + + it("fails package installs when manifest traversal exceeds the directory cap", async () => { + vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DIRECTORIES", "4"); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "directory-cap-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const vendorRoot = path.join(pluginDir, "vendor"); + for (let index = 0; index < 8; index += 1) { + fs.mkdirSync(path.join(vendorRoot, `pkg-${index}`), { recursive: true }); + } + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("manifest dependency scan exceeded max directories (4)"); + } + }); + + it("fails package installs when manifest traversal exceeds the depth cap", async () => { + vi.stubEnv("OPENCLAW_INSTALL_SCAN_MAX_DEPTH", "2"); + + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "depth-cap-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const nestedDir = path.join(pluginDir, "vendor", "a", "b", "c"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + path.join(nestedDir, "package.json"), + JSON.stringify({ + name: "plain-crypto-js", + version: "4.2.1", + }), + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("manifest dependency scan exceeded max depth (2)"); + } + }); + + it.runIf(process.platform !== "win32")( + "fails package installs when manifest traversal cannot read a directory", + async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "unreadable-dir-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const blockedDir = path.join(pluginDir, "vendor", "sealed"); + fs.mkdirSync(blockedDir, { recursive: true }); + fs.writeFileSync( + path.join(blockedDir, "package.json"), + JSON.stringify({ name: "plain-crypto-js" }), + ); + fs.chmodSync(blockedDir, 0o000); + + try { + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED); + expect(result.error).toContain("manifest dependency scan could not read"); + expect(result.error).toContain("vendor/sealed"); + } + } finally { + fs.chmodSync(blockedDir, 0o755); + } + }, + ); + + it("reports all blocked dependencies from the same manifest", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "multiple-blocked-dependencies-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + dependencies: { + "plain-crypto-js": "^4.2.1", + }, + peerDependencies: { + "plain-crypto-js": "^4.2.1", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('"plain-crypto-js" in dependencies'); + expect(result.error).toContain('"plain-crypto-js" in peerDependencies'); + expect(result.error).toContain("multiple-blocked-dependencies-plugin (package.json)"); + } + }); + it("allows package installs with dangerous code patterns when forced unsafe install is set", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); @@ -796,6 +1427,47 @@ describe("installPluginFromArchive", () => { ).toBe(true); }); + it("keeps blocked dependency package checks active when forced unsafe install is set", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "forced-blocked-dependency-plugin", + version: "1.0.0", + openclaw: { extensions: ["index.js"] }, + dependencies: { + "plain-crypto-js": "^4.2.1", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n"); + + const { result, warnings } = await installFromDirWithWarnings({ + pluginDir, + extensionsDir, + dangerouslyForceUnsafeInstall: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); + } + expect( + warnings.some((warning) => + warning.includes('blocked dependencies "plain-crypto-js" in dependencies'), + ), + ).toBe(true); + expect( + warnings.some((warning) => + warning.includes( + "forced despite dangerous code patterns via --dangerously-force-unsafe-install", + ), + ), + ).toBe(false); + }); + it("blocks bundle installs when bundle contains dangerous code patterns", async () => { const { pluginDir, extensionsDir } = setupBundleInstallFixture({ bundleFormat: "codex", @@ -813,6 +1485,252 @@ describe("installPluginFromArchive", () => { expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true); }); + it("blocks bundle installs when a vendored manifest declares a blocked dependency", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Dependency Bundle", + }); + fs.mkdirSync(path.join(pluginDir, "vendor", "axios"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "axios", "package.json"), + JSON.stringify({ + name: "axios", + version: "1.14.1", + dependencies: { + "plain-crypto-js": "^4.2.1", + }, + }), + ); + + const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('Bundle "blocked-dependency-bundle" installation blocked'); + expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies'); + expect(result.error).toContain("declared in axios (vendor/axios/package.json)"); + } + expect( + warnings.some((warning) => + warning.includes('blocked dependencies "plain-crypto-js" in dependencies'), + ), + ).toBe(true); + }); + + it("blocks bundle installs when a vendored manifest uses a blocked package name", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Vendored Package Name Bundle", + }); + fs.mkdirSync(path.join(pluginDir, "vendor", "plain-crypto-js"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "plain-crypto-js", "package.json"), + JSON.stringify({ + name: "plain-crypto-js", + version: "4.2.1", + }), + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-vendored-package-name-bundle" installation blocked', + ); + expect(result.error).toContain('"plain-crypto-js" as package name'); + expect(result.error).toContain( + "declared in plain-crypto-js (vendor/plain-crypto-js/package.json)", + ); + } + }); + + it("blocks bundle installs when node_modules contains a blocked package directory without package.json", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package Dir Bundle", + }); + const blockedPackageDir = path.join(pluginDir, "vendor", "node_modules", "plain-crypto-js"); + fs.mkdirSync(blockedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('Bundle "blocked-package-dir-bundle" installation blocked'); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); + } + }); + + it("blocks bundle installs when node_modules contains a blocked package file alias", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package File Bundle", + }); + const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js.Js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('Bundle "blocked-package-file-bundle" installation blocked'); + expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); + expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js"); + } + }); + + it("blocks bundle installs when node_modules contains a blocked extensionless package file alias", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package Extensionless File Bundle", + }); + const nodeModulesDir = path.join(pluginDir, "vendor", "Node_Modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "Plain-Crypto-Js"), "module.exports = {};\n"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-package-extensionless-file-bundle" installation blocked', + ); + expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"'); + expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js"); + } + }); + + it.runIf(process.platform !== "win32")( + "blocks bundle installs when node_modules contains a blocked package symlink", + async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package Symlink Bundle", + }); + const actualDir = path.join(pluginDir, "vendor", "actual-package"); + fs.mkdirSync(actualDir, { recursive: true }); + fs.writeFileSync(path.join(actualDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../actual-package", path.join(nodeModulesDir, "plain-crypto-js"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-package-symlink-bundle" installation blocked', + ); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/node_modules/plain-crypto-js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks bundle installs when node_modules safe-name symlink targets a blocked package directory", + async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package Symlink Target Bundle", + }); + const targetDir = path.join(pluginDir, "vendor", "plain-crypto-js"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../plain-crypto-js", path.join(nodeModulesDir, "safe-name"), "dir"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-package-symlink-target-bundle" installation blocked', + ); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks bundle installs when node_modules safe-name symlink targets a blocked package file alias", + async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package File Symlink Target Bundle", + }); + fs.mkdirSync(path.join(pluginDir, "vendor"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "vendor", "plain-crypto-js.js"), + "module.exports = {};\n", + ); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync("../plain-crypto-js.js", path.join(nodeModulesDir, "safe-name"), "file"); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-package-file-symlink-target-bundle" installation blocked', + ); + expect(result.error).toContain('blocked dependency file alias "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js.js"); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "blocks bundle installs when node_modules safe-name symlink targets a file under a blocked package directory", + async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Blocked Package Nested File Symlink Target Bundle", + }); + const blockedPackageDir = path.join(pluginDir, "vendor", "plain-crypto-js", "dist"); + fs.mkdirSync(blockedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(blockedPackageDir, "index.js"), "module.exports = {};\n"); + + const nodeModulesDir = path.join(pluginDir, "vendor", "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.symlinkSync( + "../plain-crypto-js/dist/index.js", + path.join(nodeModulesDir, "safe-name"), + "file", + ); + + const { result } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain( + 'Bundle "blocked-package-nested-file-symlink-target-bundle" installation blocked', + ); + expect(result.error).toContain('blocked dependency directory "plain-crypto-js"'); + expect(result.error).toContain("vendor/plain-crypto-js/dist/index.js"); + } + }, + ); + it("surfaces plugin scanner findings from before_install", async () => { const handler = vi.fn().mockReturnValue({ findings: [ @@ -1163,6 +2081,50 @@ describe("installPluginFromDir", () => { expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); }); + it("blocks install when resolved dependencies introduce a denied package", async () => { + const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture(); + + const run = vi.mocked(runCommandWithTimeout); + run.mockImplementation(async (_command, opts) => { + const cwd = typeof opts === "number" ? undefined : opts?.cwd; + if (!cwd) { + throw new Error("expected cwd for npm install"); + } + const blockedPkgDir = path.join(cwd, "node_modules", "plain-crypto-js"); + fs.mkdirSync(blockedPkgDir, { recursive: true }); + fs.writeFileSync( + path.join(blockedPkgDir, "package.json"), + JSON.stringify({ + name: "plain-crypto-js", + version: "4.2.1", + }), + "utf-8", + ); + return { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit" as const, + }; + }); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); + expect(result.error).toContain('"plain-crypto-js" as package name'); + expect(result.error).toContain( + "declared in plain-crypto-js (node_modules/plain-crypto-js/package.json)", + ); + } + }); + it.each([ { name: "rejects plugins whose minHostVersion is newer than the current host", diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 7627f0aaeba..34f9e550d87 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -359,6 +359,9 @@ async function installPluginDirectoryIntoExtensions(params: { hasDeps: boolean; depsLogMessage: string; afterCopy?: (installedDir: string) => Promise; + afterInstall?: ( + installedDir: string, + ) => Promise | null>; nameEncoder?: (pluginId: string) => string; }): Promise { const runtime = await loadPluginInstallRuntime(); @@ -404,9 +407,24 @@ async function installPluginDirectoryIntoExtensions(params: { hasDeps: params.hasDeps, depsLogMessage: params.depsLogMessage, afterCopy: params.afterCopy, + afterInstall: async (installedDir) => { + const postInstallResult = await params.afterInstall?.(installedDir); + if (!postInstallResult) { + return { ok: true as const }; + } + return { + ok: false as const, + error: postInstallResult.error, + ...(postInstallResult.code ? { code: postInstallResult.code } : {}), + }; + }, }); if (!installRes.ok) { - return installRes; + return { + ok: false, + error: installRes.error, + ...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}), + }; } return buildDirectoryInstallResult({ @@ -752,6 +770,16 @@ async function installPluginFromPackageDir( } } }, + afterInstall: async (installedDir) => + await runInstallSourceScan({ + subject: `Plugin "${pluginId}"`, + scan: async () => + await runtime.scanInstalledPackageDependencyTree({ + logger, + packageDir: installedDir, + pluginId, + }), + }), }); }