mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 02:10:21 +00:00
fix(security): pin axios to 1.15.0 and add dependency denylist for plugin installs [AI-assisted] (#63891)
* fix: address issue * fix: address review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * Plugins: fix install security CI regressions * Plugins: make manifest traversal linear * Plugins: bound manifest security traversal * Plugins: block denied node_modules package dirs * Plugins: match node_modules case-insensitively * Plugins: block denied package symlink paths * Tests: normalize blocked symlink assertion * Plugins: fail closed on unreadable denied paths * Plugins: block denied node_modules file aliases * Plugins: inspect node_modules symlink targets * Plugins: preserve symlink target package paths * fix: address PR review feedback * chore(changelog): add axios pin and dependency denylist entry --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
209
src/plugins/dependency-denylist.test.ts
Normal file
209
src/plugins/dependency-denylist.test.ts
Normal file
@@ -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<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
overrides?: Record<string, string | Record<string, string>>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
pnpm?: {
|
||||
overrides?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
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}: `);
|
||||
}
|
||||
});
|
||||
});
|
||||
326
src/plugins/dependency-denylist.ts
Normal file
326
src/plugins/dependency-denylist.ts
Normal file
@@ -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<BlockedManifestDependencyFinding["field"], "name" | "overrides">,
|
||||
Record<string, string>
|
||||
>
|
||||
>;
|
||||
|
||||
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<string>(
|
||||
blockedInstallDependencyPackageNames,
|
||||
);
|
||||
|
||||
const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAME_LOWER_SET = new Set<string>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
overrides?: unknown;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
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<PackageManifestTraversalResult, "blockedDirectoryFinding" | "blockedFileFinding">
|
||||
> {
|
||||
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<PackageManifestTraversalResult> {
|
||||
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<string>();
|
||||
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<InstallSecurityScanResult | undefined> {
|
||||
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<InstallSecurityScanResult | undefined> {
|
||||
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<InstallSecurityScanResult | undefined> {
|
||||
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<InstallSecurityScanResult | undefined> {
|
||||
return await scanManifestDependencyDenylist({
|
||||
logger: params.logger,
|
||||
packageDir: params.packageDir,
|
||||
targetLabel: `Plugin "${params.pluginId}" installation`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanFileInstallSourceRuntime(
|
||||
params: InstallSafetyOverrides & {
|
||||
filePath: string;
|
||||
|
||||
@@ -72,6 +72,15 @@ export async function scanPackageInstallSource(
|
||||
return await scanPackageInstallSourceRuntime(params);
|
||||
}
|
||||
|
||||
export async function scanInstalledPackageDependencyTree(params: {
|
||||
logger: InstallScanLogger;
|
||||
packageDir: string;
|
||||
pluginId: string;
|
||||
}): Promise<InstallSecurityScanResult | undefined> {
|
||||
const { scanInstalledPackageDependencyTreeRuntime } = await loadInstallSecurityScanRuntime();
|
||||
return await scanInstalledPackageDependencyTreeRuntime(params);
|
||||
}
|
||||
|
||||
export async function scanFileInstallSource(
|
||||
params: InstallSafetyOverrides & {
|
||||
filePath: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -359,6 +359,9 @@ async function installPluginDirectoryIntoExtensions(params: {
|
||||
hasDeps: boolean;
|
||||
depsLogMessage: string;
|
||||
afterCopy?: (installedDir: string) => Promise<void>;
|
||||
afterInstall?: (
|
||||
installedDir: string,
|
||||
) => Promise<Extract<InstallPluginResult, { ok: false }> | null>;
|
||||
nameEncoder?: (pluginId: string) => string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user