mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
719 lines
26 KiB
TypeScript
719 lines
26 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
assertCanonicalNpmPackageName,
|
|
assertCompleteScannerSummary,
|
|
buildPluginNpmSecurityScanReport,
|
|
constrainPluginNpmSecurityScanReport,
|
|
loadPluginNpmSecurityArtifacts,
|
|
listPluginNpmSecurityArtifacts,
|
|
listPublishablePluginPackages,
|
|
normalizePackedFindingPath,
|
|
resolveCandidatePluginPackageDir,
|
|
resolveReviewedSourceLayout,
|
|
scanPublishablePluginPackages,
|
|
stageScannerRelevantPluginTarballFiles,
|
|
type PublishablePluginPackage,
|
|
type ScanPackageResult,
|
|
} from "../../scripts/lib/plugin-npm-security-scan.mts";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const CANDIDATE_SHA = "1".repeat(40);
|
|
const TOOLING_SHA = "2".repeat(40);
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
function initGitRepo(root: string): void {
|
|
execFileSync("git", ["init", "--quiet", root]);
|
|
execFileSync("git", ["-C", root, "config", "user.email", "test@example.invalid"]);
|
|
execFileSync("git", ["-C", root, "config", "user.name", "OpenClaw Test"]);
|
|
}
|
|
|
|
function writePublishableManifest(
|
|
root: string,
|
|
extensionId: string,
|
|
packageName: string,
|
|
extra: Record<string, unknown> = {},
|
|
): void {
|
|
const packageDir = join(root, "extensions", extensionId);
|
|
mkdirSync(packageDir, { recursive: true });
|
|
writeFileSync(
|
|
join(packageDir, "package.json"),
|
|
`${JSON.stringify({
|
|
name: packageName,
|
|
openclaw: { release: { publishToNpm: true } },
|
|
version: "1.0.0",
|
|
...extra,
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
execFileSync("git", ["-C", root, "add", `extensions/${extensionId}/package.json`]);
|
|
}
|
|
|
|
function writePluginArtifact(params: {
|
|
artifactRoot?: string;
|
|
extensionId: string;
|
|
files: Record<string, string | Buffer>;
|
|
packageName: string;
|
|
version?: string;
|
|
}) {
|
|
const root = params.artifactRoot
|
|
? join(params.artifactRoot, "..")
|
|
: tempDirs.make("openclaw-plugin-npm-security-artifact-");
|
|
const artifactRoot = params.artifactRoot ?? join(root, "artifacts");
|
|
const packageRoot = join(root, `source-${params.extensionId}`);
|
|
const artifactDir = join(
|
|
artifactRoot,
|
|
`plugin-npm-security-package-${CANDIDATE_SHA}-${params.extensionId}`,
|
|
);
|
|
const packageVersion = params.version ?? "1.0.0";
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
mkdirSync(artifactDir, { recursive: true });
|
|
writeFileSync(
|
|
join(packageRoot, "package.json"),
|
|
`${JSON.stringify({ name: params.packageName, version: packageVersion })}\n`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(packageRoot, "openclaw.plugin.json"),
|
|
`${JSON.stringify({ id: params.extensionId })}\n`,
|
|
"utf8",
|
|
);
|
|
for (const [relativePath, content] of Object.entries(params.files)) {
|
|
const filePath = join(packageRoot, relativePath);
|
|
mkdirSync(join(filePath, ".."), { recursive: true });
|
|
writeFileSync(filePath, content);
|
|
}
|
|
const packOutput = execFileSync(
|
|
"npm",
|
|
["pack", "--json", "--ignore-scripts", "--pack-destination", artifactDir],
|
|
{ cwd: packageRoot, encoding: "utf8" },
|
|
);
|
|
const packEntries = JSON.parse(packOutput) as Array<{ filename?: unknown }>;
|
|
const tarballName = packEntries[0]?.filename;
|
|
if (typeof tarballName !== "string") {
|
|
throw new Error("npm pack fixture did not return a tarball filename");
|
|
}
|
|
const tarballPath = join(artifactDir, tarballName);
|
|
const tarballSha256 = createHash("sha256").update(readFileSync(tarballPath)).digest("hex");
|
|
writeFileSync(
|
|
join(artifactDir, "plugin-npm-security-artifact.json"),
|
|
`${JSON.stringify({
|
|
artifactKind: "supplemental-inert-package-input",
|
|
candidateSha: CANDIDATE_SHA,
|
|
extensionId: params.extensionId,
|
|
packageDir: `extensions/${params.extensionId}`,
|
|
packageName: params.packageName,
|
|
packageVersion,
|
|
schemaVersion: 1,
|
|
tarballName,
|
|
tarballSha256,
|
|
toolingSha: TOOLING_SHA,
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
return {
|
|
artifact: {
|
|
artifactKind: "supplemental-inert-package-input" as const,
|
|
artifactDir,
|
|
candidateSha: CANDIDATE_SHA,
|
|
compressedBytes: readFileSync(tarballPath).byteLength,
|
|
expandedBytes: Object.values(params.files).reduce(
|
|
(total, value) => total + Buffer.byteLength(value),
|
|
0,
|
|
),
|
|
extensionId: params.extensionId,
|
|
packageDir: `extensions/${params.extensionId}`,
|
|
packageName: params.packageName,
|
|
packageVersion,
|
|
tarballPath,
|
|
tarballSha256,
|
|
toolingSha: TOOLING_SHA,
|
|
},
|
|
artifactRoot,
|
|
expectedPackage: {
|
|
extensionId: params.extensionId,
|
|
packageDir: `extensions/${params.extensionId}`,
|
|
packageName: params.packageName,
|
|
packageVersion,
|
|
} satisfies PublishablePluginPackage,
|
|
packageRoot,
|
|
tarballPath,
|
|
};
|
|
}
|
|
|
|
function currentLayoutFindings(): string[] {
|
|
return [
|
|
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts",
|
|
"@openclaw/codex:dangerous-exec:src/app-server/transport-process-containment.ts",
|
|
...Array.from(
|
|
{ length: 13 },
|
|
() => "@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts",
|
|
),
|
|
];
|
|
}
|
|
|
|
function syntheticResult(
|
|
packageName: string,
|
|
overrides: Partial<ScanPackageResult> = {},
|
|
): ScanPackageResult {
|
|
return {
|
|
expectedReviewedCriticalFindings: [],
|
|
packageName,
|
|
packageVersion: "1.0.0",
|
|
packedFileCount: 1,
|
|
reviewedCriticalFindings: [],
|
|
scanFindingCount: 0,
|
|
tarballSha256: "a".repeat(64),
|
|
unexpectedCriticalFindings: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("scripts/lib/plugin-npm-security-scan.mts", () => {
|
|
it("accepts only the complete current and frozen-legacy source layouts", () => {
|
|
const current = currentLayoutFindings();
|
|
const frozenLegacy = [
|
|
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/http.ts",
|
|
"@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/processes.ts",
|
|
"@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts",
|
|
"@openclaw/opencode-provider:dangerous-exec:session-catalog.ts",
|
|
"@openclaw/opencode-provider:dangerous-exec:session-catalog.test.ts",
|
|
];
|
|
|
|
expect(resolveReviewedSourceLayout(current)?.id).toBe("current");
|
|
expect(resolveReviewedSourceLayout(frozenLegacy)?.id).toBe("frozen-legacy");
|
|
expect(resolveReviewedSourceLayout(frozenLegacy.slice(0, -1))).toBeUndefined();
|
|
expect(resolveReviewedSourceLayout([...current, frozenLegacy[0]!])).toBeUndefined();
|
|
expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined();
|
|
});
|
|
|
|
it("scans checked-in malicious code without running candidate hooks or helpers", async () => {
|
|
const candidateRoot = tempDirs.make("openclaw-plugin-security-inert-pack-");
|
|
const artifactRoot = tempDirs.make("openclaw-plugin-security-inert-artifacts-");
|
|
const packageDir = join(candidateRoot, "extensions", "inert");
|
|
const executionMarkers = ["asset", "prepare", "prepack", "postpack", "replacement"].map(
|
|
(name) => join(candidateRoot, `${name}-ran`),
|
|
);
|
|
initGitRepo(candidateRoot);
|
|
mkdirSync(packageDir, { recursive: true });
|
|
const markerCommand = (marker: string) =>
|
|
`node -e "require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran')"`;
|
|
const maliciousCode = 'const { execSync } = require("node:child_process");\nexecSync("id");\n';
|
|
writeFileSync(
|
|
join(packageDir, "package.json"),
|
|
`${JSON.stringify({
|
|
files: [
|
|
"build-assets.mjs",
|
|
"index.js",
|
|
"openclaw.plugin.json",
|
|
"package.json",
|
|
"plugin-npm-security-scan.mjs",
|
|
],
|
|
name: "@openclaw/test-inert-pack",
|
|
openclaw: {
|
|
assetScripts: { build: markerCommand(executionMarkers[0]!) },
|
|
release: { publishToNpm: true },
|
|
},
|
|
scripts: {
|
|
postpack: markerCommand(executionMarkers[3]!),
|
|
prepack: markerCommand(executionMarkers[2]!),
|
|
prepare: markerCommand(executionMarkers[1]!),
|
|
},
|
|
version: "2026.8.1-beta.1",
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(packageDir, "openclaw.plugin.json"),
|
|
`${JSON.stringify({ id: "inert" })}\n`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(join(packageDir, "index.js"), maliciousCode, "utf8");
|
|
writeFileSync(
|
|
join(packageDir, "build-assets.mjs"),
|
|
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[0])}, "ran");\n`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(packageDir, "plugin-npm-security-scan.mjs"),
|
|
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[4])}, "ran");\n`,
|
|
"utf8",
|
|
);
|
|
execFileSync("git", ["-C", candidateRoot, "add", "."]);
|
|
execFileSync("git", ["-C", candidateRoot, "commit", "--quiet", "-m", "fixture"]);
|
|
const candidateSha = execFileSync("git", ["-C", candidateRoot, "rev-parse", "HEAD"], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const outputDir = join(artifactRoot, `plugin-npm-security-package-${candidateSha}-inert`);
|
|
const toolingSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
|
|
mkdirSync(outputDir, { recursive: true });
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"tsx",
|
|
"scripts/plugin-npm-security-prepare.mts",
|
|
"prepare",
|
|
"--candidate-root",
|
|
candidateRoot,
|
|
"--candidate-sha",
|
|
candidateSha,
|
|
"--extension-id",
|
|
"inert",
|
|
"--output-dir",
|
|
outputDir,
|
|
"--package-dir",
|
|
"extensions/inert",
|
|
"--package-name",
|
|
"@openclaw/test-inert-pack",
|
|
"--tooling-sha",
|
|
toolingSha,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: { ...process.env, NODE_ENV: "test" },
|
|
},
|
|
);
|
|
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
for (const marker of executionMarkers) {
|
|
expect(existsSync(marker)).toBe(false);
|
|
}
|
|
const metadata = JSON.parse(
|
|
readFileSync(join(outputDir, "plugin-npm-security-artifact.json"), "utf8"),
|
|
) as { artifactKind?: unknown; tarballName?: unknown };
|
|
expect(metadata.artifactKind).toBe("supplemental-inert-package-input");
|
|
expect(typeof metadata.tarballName).toBe("string");
|
|
const expectedPackage = {
|
|
extensionId: "inert",
|
|
packageDir: "extensions/inert",
|
|
packageName: "@openclaw/test-inert-pack",
|
|
packageVersion: "2026.8.1-beta.1",
|
|
};
|
|
const loaded = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha,
|
|
expectedPackages: [expectedPackage],
|
|
toolingSha,
|
|
});
|
|
expect(loaded.ingestionErrors).toEqual([]);
|
|
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
|
|
expect(scanned.scanErrors).toEqual([]);
|
|
expect(scanned.packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
|
|
line: 2,
|
|
path: "index.js",
|
|
ruleId: "dangerous-exec",
|
|
});
|
|
});
|
|
|
|
it("bounds manifests and rejects noncanonical or duplicate package identities", async () => {
|
|
expect(() => assertCanonicalNpmPackageName("OpenClaw/Bad", "fixture")).toThrow(
|
|
"invalid npm package name",
|
|
);
|
|
|
|
const duplicateRoot = tempDirs.make("openclaw-plugin-npm-security-duplicates-");
|
|
initGitRepo(duplicateRoot);
|
|
writePublishableManifest(duplicateRoot, "one", "@openclaw/duplicate");
|
|
writePublishableManifest(duplicateRoot, "two", "@openclaw/duplicate");
|
|
await expect(listPublishablePluginPackages(duplicateRoot)).rejects.toThrow(
|
|
"duplicate publishable package",
|
|
);
|
|
await expect(
|
|
listPublishablePluginPackages(duplicateRoot, { maxPackageManifests: 1 }),
|
|
).rejects.toThrow("package-count limit");
|
|
|
|
const manifestRoot = tempDirs.make("openclaw-plugin-npm-security-manifest-");
|
|
initGitRepo(manifestRoot);
|
|
writePublishableManifest(manifestRoot, "large", "@openclaw/large", {
|
|
description: "x".repeat(1024),
|
|
});
|
|
await expect(
|
|
listPublishablePluginPackages(manifestRoot, { maxManifestBytes: 256 }),
|
|
).rejects.toThrow("manifest exceeds the byte limit");
|
|
});
|
|
|
|
it("fails closed on truncated scans, candidate package escapes, and tarball symlinks", () => {
|
|
expect(() => assertCompleteScannerSummary("@openclaw/test", { truncated: true })).toThrow(
|
|
"security scan reached its file limit",
|
|
);
|
|
|
|
const candidateRoot = tempDirs.make("openclaw-plugin-npm-security-candidate-");
|
|
const outsideDir = tempDirs.make("openclaw-plugin-npm-security-outside-");
|
|
mkdirSync(join(candidateRoot, "extensions"), { recursive: true });
|
|
writeFileSync(
|
|
join(outsideDir, "package.json"),
|
|
`${JSON.stringify({ name: "@openclaw/escape", version: "1.0.0" })}\n`,
|
|
"utf8",
|
|
);
|
|
symlinkSync(outsideDir, join(candidateRoot, "extensions", "escape"));
|
|
expect(() => resolveCandidatePluginPackageDir(candidateRoot, "escape")).toThrow(
|
|
"package directory is not a real directory",
|
|
);
|
|
|
|
const outsideFile = join(outsideDir, "outside.ts");
|
|
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
|
|
|
|
const artifact = writePluginArtifact({
|
|
extensionId: "symlink",
|
|
files: { "index.js": "export const value = 1;\n" },
|
|
packageName: "@openclaw/test-symlink",
|
|
});
|
|
symlinkSync(outsideFile, join(artifact.packageRoot, "escape.ts"));
|
|
execFileSync(
|
|
"tar",
|
|
[
|
|
"-czf",
|
|
artifact.tarballPath,
|
|
"-C",
|
|
join(artifact.packageRoot, ".."),
|
|
basename(artifact.packageRoot),
|
|
],
|
|
{ env: { ...process.env, COPYFILE_DISABLE: "1" } },
|
|
);
|
|
expect(() => stageScannerRelevantPluginTarballFiles(artifact.tarballPath)).toThrow();
|
|
});
|
|
|
|
it("accounts for all packed bytes and scans only exact bundler hash filenames", () => {
|
|
const artifact = writePluginArtifact({
|
|
extensionId: "packed",
|
|
files: {
|
|
"asset.bin": Buffer.alloc(128),
|
|
"dist/service-BaCqPs_5.js": "export const value = 1;\n",
|
|
"dist/service-malware.js": "export const value = 2;\n",
|
|
},
|
|
packageName: "@openclaw/test-packed",
|
|
});
|
|
const staged = stageScannerRelevantPluginTarballFiles(artifact.tarballPath);
|
|
try {
|
|
expect(staged.inspection.inventory.map((entry) => entry.path)).toContain("package/asset.bin");
|
|
expect(staged.packedFiles).toContain("asset.bin");
|
|
} finally {
|
|
rmSync(staged.stageDir, { force: true, recursive: true });
|
|
}
|
|
expect(normalizePackedFindingPath("dist/service-BaCqPs_5.js")).toBe("dist/service-<hash>.js");
|
|
expect(normalizePackedFindingPath("dist/service-malware.js")).toBe("dist/service-malware.js");
|
|
});
|
|
|
|
it("finds malicious packed input code, ignores candidate scanner replacements, and fails slow", async () => {
|
|
const marker = join(tempDirs.make("openclaw-plugin-npm-security-marker-"), "ran");
|
|
const malicious = writePluginArtifact({
|
|
extensionId: "malicious",
|
|
files: {
|
|
"index.js": `const { execSync } = require("node:child_process");\nexecSync("id");\n`,
|
|
"scripts/plugin-npm-security-scan.mts": `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");\n`,
|
|
},
|
|
packageName: "@openclaw/test-malicious",
|
|
});
|
|
const oversized = writePluginArtifact({
|
|
extensionId: "oversized",
|
|
files: { "oversized.js": Buffer.alloc(1024 * 1024 + 1) },
|
|
packageName: "@openclaw/test-oversized",
|
|
});
|
|
|
|
const { packageResults, scanErrors } = await scanPublishablePluginPackages([
|
|
oversized.artifact,
|
|
malicious.artifact,
|
|
]);
|
|
|
|
expect(existsSync(marker)).toBe(false);
|
|
expect(packageResults).toHaveLength(1);
|
|
expect(packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
|
|
line: 2,
|
|
path: "index.js",
|
|
ruleId: "dangerous-exec",
|
|
});
|
|
expect(scanErrors).toHaveLength(1);
|
|
expect(scanErrors[0]).toContain("@openclaw/test-oversized");
|
|
expect(scanErrors[0]).not.toContain(oversized.artifact.tarballPath);
|
|
|
|
const report = buildPluginNpmSecurityScanReport({
|
|
candidateSha: CANDIDATE_SHA,
|
|
packageResults,
|
|
scanErrors,
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(report.status).toBe("fail");
|
|
expect(report.errors).toContainEqual(expect.stringContaining("unexpected critical findings"));
|
|
expect(report.errors).toContainEqual(expect.stringContaining("package scan failed"));
|
|
expect(JSON.stringify(report)).not.toContain("execSync");
|
|
});
|
|
|
|
it("validates immutable artifact identity and exact package plans", () => {
|
|
const artifact = writePluginArtifact({
|
|
extensionId: "identity",
|
|
files: { "index.js": "export const value = 1;\n" },
|
|
packageName: "@openclaw/test-identity",
|
|
});
|
|
expect(
|
|
listPluginNpmSecurityArtifacts({
|
|
artifactRoot: artifact.artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages: [artifact.expectedPackage],
|
|
toolingSha: TOOLING_SHA,
|
|
}).map((entry) => entry.packageName),
|
|
).toEqual(["@openclaw/test-identity"]);
|
|
expect(() =>
|
|
listPluginNpmSecurityArtifacts({
|
|
artifactRoot: artifact.artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages: [],
|
|
toolingSha: TOOLING_SHA,
|
|
}),
|
|
).toThrow("unexpected entries");
|
|
});
|
|
|
|
it("retains valid package scans when a sibling artifact is malformed", async () => {
|
|
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-mixed-");
|
|
const valid = writePluginArtifact({
|
|
artifactRoot,
|
|
extensionId: "valid",
|
|
files: { "index.js": "export const value = 1;\n" },
|
|
packageName: "@openclaw/test-valid",
|
|
});
|
|
const malformed = writePluginArtifact({
|
|
artifactRoot,
|
|
extensionId: "malformed",
|
|
files: { "index.js": "export const value = 2;\n" },
|
|
packageName: "@openclaw/test-malformed",
|
|
});
|
|
writeFileSync(
|
|
join(malformed.artifact.artifactDir, "plugin-npm-security-artifact.json"),
|
|
"{not-json}\n",
|
|
"utf8",
|
|
);
|
|
const expectedPackages = [malformed.expectedPackage, valid.expectedPackage].toSorted(
|
|
(left, right) => (left.packageName < right.packageName ? -1 : 1),
|
|
);
|
|
|
|
const loaded = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages,
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(loaded.artifacts.map((artifact) => artifact.packageName)).toEqual([
|
|
"@openclaw/test-valid",
|
|
]);
|
|
expect(loaded.ingestionErrors).toEqual([
|
|
"@openclaw/test-malformed: Plugin security artifact metadata is not valid JSON.",
|
|
]);
|
|
|
|
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
|
|
expect(scanned.scanErrors).toEqual([]);
|
|
expect(scanned.packageResults.map((result) => result.packageName)).toEqual([
|
|
"@openclaw/test-valid",
|
|
]);
|
|
});
|
|
|
|
it("retains valid package reports when a sibling is rejected before download", async () => {
|
|
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-pre-download-");
|
|
const valid = writePluginArtifact({
|
|
artifactRoot,
|
|
extensionId: "valid",
|
|
files: { "index.js": "export const value = 1;\n" },
|
|
packageName: "@openclaw/test-valid",
|
|
});
|
|
const rejectedPackage = {
|
|
extensionId: "oversized",
|
|
packageDir: "extensions/oversized",
|
|
packageName: "@openclaw/test-oversized",
|
|
packageVersion: "1.0.0",
|
|
} satisfies PublishablePluginPackage;
|
|
const preDownloadError =
|
|
"@openclaw/test-oversized: plugin security artifact exceeds the pre-download byte limit.";
|
|
const loaded = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages: [rejectedPackage, valid.expectedPackage].toSorted((left, right) =>
|
|
left.packageName.localeCompare(right.packageName),
|
|
),
|
|
rejectedPackageNames: [rejectedPackage.packageName],
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
|
|
expect(loaded.ingestionErrors).toEqual([]);
|
|
expect(loaded.artifacts.map((artifact) => artifact.packageName)).toEqual([
|
|
valid.expectedPackage.packageName,
|
|
]);
|
|
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
|
|
const report = buildPluginNpmSecurityScanReport({
|
|
candidateSha: CANDIDATE_SHA,
|
|
packageResults: scanned.packageResults,
|
|
scanErrors: [preDownloadError, ...scanned.scanErrors],
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(report.packages.map((plugin) => plugin.packageName)).toEqual([
|
|
valid.expectedPackage.packageName,
|
|
]);
|
|
expect(report.errors).toContain(preDownloadError);
|
|
expect(report.errors.join("\n")).not.toContain("plugin security artifact is missing");
|
|
});
|
|
|
|
it("bounds aggregate compressed and expanded artifact bytes deterministically", () => {
|
|
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-aggregate-");
|
|
const alpha = writePluginArtifact({
|
|
artifactRoot,
|
|
extensionId: "alpha",
|
|
files: { "alpha.js": Buffer.alloc(256, 1) },
|
|
packageName: "@openclaw/test-alpha",
|
|
});
|
|
const beta = writePluginArtifact({
|
|
artifactRoot,
|
|
extensionId: "beta",
|
|
files: { "beta.js": Buffer.alloc(256, 2) },
|
|
packageName: "@openclaw/test-beta",
|
|
});
|
|
const expectedPackages = [alpha.expectedPackage, beta.expectedPackage];
|
|
const baseline = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages,
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(baseline.ingestionErrors).toEqual([]);
|
|
|
|
const compressed = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages,
|
|
limits: { maxCompressedBytes: baseline.artifacts[0]!.compressedBytes },
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(compressed.artifacts.map((artifact) => artifact.packageName)).toEqual([
|
|
"@openclaw/test-alpha",
|
|
]);
|
|
expect(compressed.ingestionErrors).toEqual([
|
|
"@openclaw/test-beta: aggregate compressed-byte limit exceeded.",
|
|
]);
|
|
|
|
const expanded = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot,
|
|
candidateSha: CANDIDATE_SHA,
|
|
expectedPackages,
|
|
limits: { maxExpandedBytes: baseline.artifacts[0]!.expandedBytes },
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(expanded.artifacts.map((artifact) => artifact.packageName)).toEqual([
|
|
"@openclaw/test-alpha",
|
|
]);
|
|
expect(expanded.ingestionErrors).toEqual([
|
|
"@openclaw/test-beta: aggregate expanded-byte limit exceeded.",
|
|
]);
|
|
});
|
|
|
|
it("caps total findings and emits byte-identical bounded reports", () => {
|
|
const packageResults = [
|
|
syntheticResult("@openclaw/codex", {
|
|
reviewedCriticalFindings: currentLayoutFindings(),
|
|
scanFindingCount: 51,
|
|
}),
|
|
];
|
|
const report = buildPluginNpmSecurityScanReport({
|
|
candidateSha: CANDIDATE_SHA,
|
|
maxTotalFindings: 50,
|
|
packageResults,
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(report.errors).toContain(
|
|
"Plugin npm security scan exceeded the total finding-count limit.",
|
|
);
|
|
expect(report.scanScope).toBe("supplemental-inert-package-input");
|
|
expect(JSON.stringify(report)).toBe(
|
|
JSON.stringify(
|
|
buildPluginNpmSecurityScanReport({
|
|
candidateSha: CANDIDATE_SHA,
|
|
maxTotalFindings: 50,
|
|
packageResults: structuredClone(packageResults).toReversed(),
|
|
toolingSha: TOOLING_SHA,
|
|
}),
|
|
),
|
|
);
|
|
expect(constrainPluginNpmSecurityScanReport(report, 64).errors).toEqual([
|
|
"Plugin npm security scan report exceeded the byte limit.",
|
|
]);
|
|
});
|
|
|
|
it("writes sanitized exact-identity reports for timeout, heap, and RSS failures", () => {
|
|
const root = tempDirs.make("openclaw-plugin-npm-security-runner-");
|
|
const timeoutChild = join(root, "timeout.mjs");
|
|
const oomChild = join(root, "oom.mjs");
|
|
const rssChild = join(root, "rss.mjs");
|
|
writeFileSync(timeoutChild, "await new Promise(() => {});\n", "utf8");
|
|
writeFileSync(
|
|
oomChild,
|
|
"const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n",
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
rssChild,
|
|
"globalThis.value = Buffer.alloc(64 * 1024 * 1024, 1);\nsetInterval(() => {}, 1_000);\n",
|
|
"utf8",
|
|
);
|
|
for (const [label, child, timeoutMs, heapMb, rssMb, expectedError] of [
|
|
["timeout", timeoutChild, "25", "128", "1024", "timed out"],
|
|
["oom", oomChild, "10000", "16", "1024", "exceeded its process limit"],
|
|
["rss", rssChild, "10000", "128", "16", "exceeded its RSS limit"],
|
|
] as const) {
|
|
const reportPath = join(root, `${label}.json`);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"scripts/plugin-npm-security-scan-runner.mjs",
|
|
"--artifact-root",
|
|
join(root, "artifacts"),
|
|
"--candidate-sha",
|
|
CANDIDATE_SHA,
|
|
"--expected-packages-json",
|
|
"[]",
|
|
"--tooling-sha",
|
|
TOOLING_SHA,
|
|
"--report",
|
|
reportPath,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD: child,
|
|
OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB: heapMb,
|
|
OPENCLAW_PLUGIN_SECURITY_RUNNER_RSS_MB: rssMb,
|
|
OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS: timeoutMs,
|
|
},
|
|
timeout: 15_000,
|
|
},
|
|
);
|
|
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
|
|
candidateSha: string;
|
|
errors: string[];
|
|
toolingSha: string;
|
|
};
|
|
expect(result.status).toBe(1);
|
|
expect(report).toMatchObject({
|
|
candidateSha: CANDIDATE_SHA,
|
|
toolingSha: TOOLING_SHA,
|
|
});
|
|
expect(report.errors).toContainEqual(expect.stringContaining(expectedError));
|
|
expect(`${result.stdout}${result.stderr}${JSON.stringify(report)}`).not.toContain(root);
|
|
}
|
|
}, 30_000);
|
|
|
|
it("retains the complete current-root publishable plugin inventory contract", async () => {
|
|
const packages = await listPublishablePluginPackages(process.cwd());
|
|
expect(packages.length).toBeGreaterThan(0);
|
|
expect(packages.map((plugin) => plugin.packageName)).toContain("@openclaw/acpx");
|
|
expect(new Set(packages.map((plugin) => plugin.packageName)).size).toBe(packages.length);
|
|
expect(packages).toEqual(
|
|
packages.toSorted((left, right) =>
|
|
left.packageName < right.packageName ? -1 : left.packageName > right.packageName ? 1 : 0,
|
|
),
|
|
);
|
|
});
|
|
});
|