mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 17:01:15 +00:00
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
/** Release-lane coverage for npm plugin install security scanning. */
|
|
import { execFile, spawnSync } from "node:child_process";
|
|
import fs, { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { beforeAll, describe, expect, it, test } from "vitest";
|
|
import { isScannable, scanDirectoryWithSummary } from "../skills/security/scanner.js";
|
|
import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js";
|
|
import { listGitTrackedFiles, toRepoPath, toRepoRelativePath } from "../test-utils/repo-files.js";
|
|
|
|
type NpmPackFile = {
|
|
path?: unknown;
|
|
};
|
|
|
|
type NpmPackResult = {
|
|
files?: unknown;
|
|
};
|
|
|
|
type PublishablePluginPackage = {
|
|
packageDir: string;
|
|
packageName: string;
|
|
};
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([
|
|
"@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts",
|
|
"@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs",
|
|
"@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/app-server/transport-stdio.ts",
|
|
"@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts",
|
|
"@openclaw/discord:dangerous-exec:src/voice/audio.ts",
|
|
"@openclaw/google-meet:dangerous-exec:src/node-host.ts",
|
|
"@openclaw/google-meet:dangerous-exec:src/realtime.ts",
|
|
"@openclaw/matrix:dangerous-exec:src/matrix/deps.ts",
|
|
"@openclaw/raft:dangerous-exec:src/gateway.ts",
|
|
"@openclaw/signal:dangerous-exec:src/daemon.ts",
|
|
"@openclaw/voice-call:dangerous-exec:src/tunnel.ts",
|
|
"@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts",
|
|
]);
|
|
|
|
const OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS = new Set([
|
|
"@openclaw/acpx:dangerous-exec:dist/mcp-proxy.mjs",
|
|
"@openclaw/acpx:dangerous-exec:dist/service-<hash>.js",
|
|
"@openclaw/codex:dangerous-exec:dist/client-<hash>.js",
|
|
"@openclaw/google-meet:dangerous-exec:dist/index.js",
|
|
"@openclaw/slack:dynamic-code-execution:dist/outbound-payload.test-harness-<hash>.js",
|
|
"@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js",
|
|
]);
|
|
|
|
function parseNpmPackFiles(raw: string, packageName: string): string[] {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed) || parsed.length !== 1) {
|
|
throw new Error(`${packageName}: npm pack --dry-run did not return one package result.`);
|
|
}
|
|
|
|
const result = parsed[0] as NpmPackResult;
|
|
if (!Array.isArray(result.files)) {
|
|
throw new Error(`${packageName}: npm pack --dry-run did not return a files list.`);
|
|
}
|
|
|
|
return result.files
|
|
.map((entry) => (entry as NpmPackFile).path)
|
|
.filter((packedPath): packedPath is string => typeof packedPath === "string")
|
|
.toSorted();
|
|
}
|
|
|
|
async function collectNpmPackedFiles(packageDir: string, packageName: string): Promise<string[]> {
|
|
const { stdout } = await execFileAsync(
|
|
"npm",
|
|
["pack", "--dry-run", "--json", "--ignore-scripts"],
|
|
{
|
|
cwd: packageDir,
|
|
encoding: "utf8",
|
|
maxBuffer: 128 * 1024 * 1024,
|
|
},
|
|
);
|
|
return parseNpmPackFiles(stdout, packageName);
|
|
}
|
|
|
|
function isScannerWalkedPackedPath(packedPath: string): boolean {
|
|
return (
|
|
isScannable(packedPath) &&
|
|
packedPath.split(/[\\/]/).every((segment) => {
|
|
return segment.length > 0 && segment !== "node_modules" && !segment.startsWith(".");
|
|
})
|
|
);
|
|
}
|
|
|
|
function normalizePackedFindingPath(packedPath: string): string {
|
|
for (const prefix of ["client", "outbound-payload.test-harness", "runtime-entry", "service"]) {
|
|
if (packedPath.startsWith(`dist/${prefix}-`) && packedPath.endsWith(".js")) {
|
|
return `dist/${prefix}-<hash>.js`;
|
|
}
|
|
}
|
|
return packedPath;
|
|
}
|
|
|
|
function expectedOptionalReviewedFindingsForPackedPath(
|
|
packageName: string,
|
|
packedPath: string,
|
|
): string[] {
|
|
const normalizedPath = normalizePackedFindingPath(packedPath);
|
|
return [...OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS].filter(
|
|
(key) => key.startsWith(`${packageName}:`) && key.endsWith(`:${normalizedPath}`),
|
|
);
|
|
}
|
|
|
|
function stageScannerRelevantPackedFiles(
|
|
packageDir: string,
|
|
packedFiles: readonly string[],
|
|
): string {
|
|
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
|
|
|
|
for (const packedPath of packedFiles) {
|
|
if (!isScannerWalkedPackedPath(packedPath)) {
|
|
continue;
|
|
}
|
|
|
|
const source = resolve(packageDir, packedPath);
|
|
const target = join(stageDir, ...packedPath.split(/[\\/]/));
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
copyFileSync(source, target);
|
|
}
|
|
|
|
return stageDir;
|
|
}
|
|
|
|
function listPublishablePluginPackageDirs(): string[] {
|
|
const externalDirs = listExternalPluginPackageDirs();
|
|
if (externalDirs) {
|
|
return externalDirs;
|
|
}
|
|
return fs
|
|
.readdirSync("extensions", { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => join("extensions", entry.name))
|
|
.toSorted();
|
|
}
|
|
|
|
function listExternalPluginPackageDirs(): string[] | null {
|
|
const packageFiles = listGitExtensionPackageFiles() ?? listFindExtensionPackageFiles();
|
|
if (!packageFiles) {
|
|
return null;
|
|
}
|
|
return packageFiles
|
|
.flatMap((file) => {
|
|
const match = /^extensions\/([^/]+)\/package\.json$/u.exec(file);
|
|
return match?.[1] ? [join("extensions", match[1])] : [];
|
|
})
|
|
.toSorted();
|
|
}
|
|
|
|
function listGitExtensionPackageFiles(): string[] | null {
|
|
return listGitTrackedFiles({ pathspecs: "extensions/*/package.json" });
|
|
}
|
|
|
|
function listFindExtensionPackageFiles(): string[] | null {
|
|
const result = spawnSync(
|
|
"find",
|
|
[resolve("extensions"), "-maxdepth", "2", "-type", "f", "-name", "package.json"],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
maxBuffer: 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
return null;
|
|
}
|
|
return result.stdout
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((file) => toRepoRelativePath(process.cwd(), file))
|
|
.toSorted();
|
|
}
|
|
|
|
function collectPublishablePluginPackages(): PublishablePluginPackage[] {
|
|
return listPublishablePluginPackageDirs()
|
|
.flatMap((packageDir) => {
|
|
const packageJsonPath = join(packageDir, "package.json");
|
|
let packageJson: {
|
|
name?: unknown;
|
|
openclaw?: { release?: { publishToNpm?: unknown } };
|
|
};
|
|
try {
|
|
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as typeof packageJson;
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (packageJson.openclaw?.release?.publishToNpm !== true) {
|
|
return [];
|
|
}
|
|
if (typeof packageJson.name !== "string" || !packageJson.name.trim()) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
packageDir,
|
|
packageName: packageJson.name,
|
|
},
|
|
];
|
|
})
|
|
.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
|
|
}
|
|
|
|
async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): Promise<{
|
|
reviewedCriticalFindings: string[];
|
|
expectedReviewedCriticalFindings: string[];
|
|
unexpectedCriticalFindings: string[];
|
|
}> {
|
|
const reviewedCriticalFindings: string[] = [];
|
|
const expectedReviewedCriticalFindings: string[] = [];
|
|
const unexpectedCriticalFindings: string[] = [];
|
|
const packedFiles = await collectNpmPackedFiles(plugin.packageDir, plugin.packageName);
|
|
for (const packedFile of packedFiles) {
|
|
for (const key of expectedOptionalReviewedFindingsForPackedPath(
|
|
plugin.packageName,
|
|
packedFile,
|
|
)) {
|
|
expectedReviewedCriticalFindings.push(key);
|
|
}
|
|
}
|
|
const stageDir = stageScannerRelevantPackedFiles(plugin.packageDir, packedFiles);
|
|
let summary: Awaited<ReturnType<typeof scanDirectoryWithSummary>>;
|
|
try {
|
|
summary = await scanDirectoryWithSummary(stageDir, {
|
|
excludeTestFiles: true,
|
|
maxFiles: 10_000,
|
|
});
|
|
} finally {
|
|
rmSync(stageDir, { recursive: true, force: true });
|
|
}
|
|
|
|
for (const finding of summary.findings) {
|
|
if (finding.severity !== "critical") {
|
|
continue;
|
|
}
|
|
const packedPath = normalizePackedFindingPath(toRepoPath(relative(stageDir, finding.file)));
|
|
const key = `${plugin.packageName}:${finding.ruleId}:${packedPath}`;
|
|
if (
|
|
REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS.has(key) ||
|
|
OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS.has(key)
|
|
) {
|
|
reviewedCriticalFindings.push(key);
|
|
continue;
|
|
}
|
|
unexpectedCriticalFindings.push([key, `${finding.line}`, finding.evidence].join(":"));
|
|
}
|
|
|
|
return {
|
|
reviewedCriticalFindings,
|
|
expectedReviewedCriticalFindings,
|
|
unexpectedCriticalFindings,
|
|
};
|
|
}
|
|
|
|
describe("publishable plugin npm package install security scan", () => {
|
|
const publishablePluginPackages = collectPublishablePluginPackages();
|
|
const scanResultsByPackageName = new Map<
|
|
string,
|
|
Awaited<ReturnType<typeof scanPublishablePluginPackage>>
|
|
>();
|
|
|
|
beforeAll(async () => {
|
|
const results = await Promise.all(
|
|
publishablePluginPackages.map(async (plugin) => ({
|
|
packageName: plugin.packageName,
|
|
result: await scanPublishablePluginPackage(plugin),
|
|
})),
|
|
);
|
|
for (const { packageName, result } of results) {
|
|
scanResultsByPackageName.set(packageName, result);
|
|
}
|
|
});
|
|
|
|
it("covers every package with required reviewed critical findings", () => {
|
|
const publishablePackageNames = new Set(
|
|
publishablePluginPackages.map((plugin) => plugin.packageName),
|
|
);
|
|
const missingPackages = [
|
|
...new Set(
|
|
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].map((key) =>
|
|
key.slice(0, key.indexOf(":")),
|
|
),
|
|
),
|
|
].filter((packageName) => !publishablePackageNames.has(packageName));
|
|
|
|
expect(missingPackages.toSorted()).toStrictEqual([]);
|
|
});
|
|
|
|
it("lists publishable plugin packages without scanning extension directories in-process", () => {
|
|
expectNoReaddirSyncDuring(() => {
|
|
const packages = collectPublishablePluginPackages();
|
|
|
|
expect(packages.length).toBeGreaterThan(0);
|
|
expect(
|
|
packages.every((plugin) => toRepoPath(plugin.packageDir).startsWith("extensions/")),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
test.concurrent.each(publishablePluginPackages)(
|
|
"keeps $packageName files clear of unexpected critical hits",
|
|
async (plugin) => {
|
|
const result = scanResultsByPackageName.get(plugin.packageName);
|
|
if (!result) {
|
|
throw new Error(`Missing package scan result for ${plugin.packageName}`);
|
|
}
|
|
const expectedReviewedCriticalFindings = new Set(
|
|
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].filter((key) =>
|
|
key.startsWith(`${plugin.packageName}:`),
|
|
),
|
|
);
|
|
for (const key of result.expectedReviewedCriticalFindings) {
|
|
expectedReviewedCriticalFindings.add(key);
|
|
}
|
|
|
|
expect(result.unexpectedCriticalFindings.toSorted()).toStrictEqual([]);
|
|
expect(result.reviewedCriticalFindings.toSorted()).toEqual(
|
|
[...expectedReviewedCriticalFindings].toSorted(),
|
|
);
|
|
},
|
|
);
|
|
});
|