mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-31 20:47:00 +00:00
Record the exact reviewed runtime findings surfaced by plugin npm preview, including a content-pinned OpenShell setup bundle. Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
395 lines
18 KiB
TypeScript
395 lines
18 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isScannable, scanSource, type SkillScanFinding } from "../src/skills/security/scanner.js";
|
|
import { inspectPackageTarballBytes } from "./plugin-publication-artifact.mjs";
|
|
import { isPluginTestFixturePath } from "./verify-plugin-npm-published-runtime.mts";
|
|
|
|
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
|
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
const MAX_FILES = 10_000;
|
|
const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
|
|
// This shrink-only inventory covers reviewed production behavior across supported
|
|
// release lines. Package-owned test and fixture paths are rejected before
|
|
// scanning and never belong in this inventory.
|
|
const REVIEWED_CRITICAL_FINDING_LIMITS = new Map<string, number>([
|
|
["setup:@openclaw/acpx:dangerous-exec:dist/.setup/service-<hash>.mjs", 1],
|
|
["dist:@openclaw/acpx:dangerous-exec:dist/mcp-proxy.mjs", 1],
|
|
["dist:@openclaw/acpx:dangerous-exec:dist/service-<hash>.js", 1],
|
|
["source:@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts", 1],
|
|
["source:@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs", 1],
|
|
["setup:@openclaw/codex:dangerous-exec:dist/.setup/dynamic-tools-<hash>.mjs", 2],
|
|
["setup:@openclaw/codex:dangerous-exec:dist/.setup/installed-probe-<hash>.mjs", 1],
|
|
["setup:@openclaw/codex:dangerous-exec:dist/.setup/transport-stdio-<hash>.mjs", 3],
|
|
["dist:@openclaw/codex:dangerous-exec:dist/api.js", 1],
|
|
["dist:@openclaw/codex:dangerous-exec:dist/client-<hash>.js", 1],
|
|
["dist:@openclaw/codex:dangerous-exec:dist/dynamic-tools-<hash>.js", 2],
|
|
["dist:@openclaw/codex:dangerous-exec:dist/session-catalog-<hash>.js", 1],
|
|
["dist:@openclaw/codex:dangerous-exec:dist/transport-stdio-<hash>.js", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/managed-launcher-failure.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/http.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/processes.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/transport-process-snapshot.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/app-server/transport-stdio.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/doctor.ts", 1],
|
|
["source:@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts", 1],
|
|
["setup:@openclaw/discord:dangerous-exec:dist/.setup/receive-recovery-<hash>.mjs", 1],
|
|
["source:@openclaw/discord:dangerous-exec:src/voice/audio.ts", 1],
|
|
["dist:@openclaw/diffs:env-harvesting:dist/assets/viewer-runtime.js", 1],
|
|
["dist:@openclaw/diffs-language-pack:env-harvesting:dist/assets/viewer-runtime.js", 1],
|
|
["dist:@openclaw/facetime:dangerous-exec:dist/runtime-api.js", 1],
|
|
["source:@openclaw/facetime:dangerous-exec:src/audio-pump.ts", 1],
|
|
["dist:@openclaw/google-meet:dangerous-exec:dist/index.js", 1],
|
|
["source:@openclaw/google-meet:dangerous-exec:src/node-host.ts", 3],
|
|
["source:@openclaw/google-meet:dangerous-exec:src/realtime.ts", 2],
|
|
["setup:@openclaw/imessage:dangerous-exec:dist/.setup/client-<hash>.mjs", 1],
|
|
["setup:@openclaw/imessage:dangerous-exec:dist/.setup/sanitize-outbound-<hash>.mjs", 1],
|
|
["source:@openclaw/imessage:dangerous-exec:src/client.ts", 1],
|
|
["setup:@openclaw/llama-cpp-provider:dangerous-exec:dist/.setup/managed-server-<hash>.mjs", 2],
|
|
["setup:@openclaw/llama-cpp-provider:dangerous-exec:dist/.setup/media-catalog-<hash>.mjs", 1],
|
|
["dist:@openclaw/llama-cpp-provider:dangerous-exec:dist/index.js", 3],
|
|
["source:@openclaw/llama-cpp-provider:dangerous-exec:src/hardware.ts", 1],
|
|
["source:@openclaw/llama-cpp-provider:dangerous-exec:src/llama-server-install.ts", 1],
|
|
["source:@openclaw/matrix:dangerous-exec:src/matrix/deps.ts", 1],
|
|
["setup:@openclaw/memory-lancedb:dangerous-exec:dist/.setup/dist-<hash>.mjs", 1],
|
|
["setup:@openclaw/memory-lancedb:dynamic-code-execution:dist/.setup/dist-<hash>.mjs", 1],
|
|
["dist:@openclaw/meta-provider:dangerous-exec:dist/muse-login.js", 1],
|
|
["dist:@openclaw/mxc-sandbox:dangerous-exec:dist/index.js", 2],
|
|
["source:@openclaw/mxc-sandbox:dangerous-exec:src/readiness.ts", 2],
|
|
["setup:@openclaw/onnx:dangerous-exec:dist/.setup/worker-client-<hash>.mjs", 1],
|
|
["setup:@openclaw/openshell-sandbox:dangerous-exec:dist/.setup/worker-cli-<hash>.mjs", 1],
|
|
["source:@openclaw/onnx:dangerous-exec:src/worker-client.ts", 1],
|
|
["dist:@openclaw/raft:dangerous-exec:dist/channel-plugin-api.js", 1],
|
|
["source:@openclaw/raft:dangerous-exec:src/gateway.ts", 1],
|
|
["setup:@openclaw/signal:dangerous-exec:dist/.setup/monitor-<hash>.mjs", 1],
|
|
["source:@openclaw/signal:dangerous-exec:src/daemon.ts", 1],
|
|
["setup:@openclaw/voice-call:dangerous-exec:dist/.setup/runtime-entry-<hash>.mjs", 1],
|
|
["dist:@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js", 1],
|
|
["source:@openclaw/voice-call:dangerous-exec:src/tunnel.ts", 4],
|
|
["source:@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts", 1],
|
|
]);
|
|
|
|
const REVIEWED_CRITICAL_FINDING_CONTENT_SHA256 = new Map<string, string>([
|
|
[
|
|
"setup:@openclaw/imessage:dangerous-exec:dist/.setup/sanitize-outbound-<hash>.mjs",
|
|
"34833261f1e012015e6e28f35903d78f77efcfdac68f924310b8a3761264878b",
|
|
],
|
|
[
|
|
"setup:@openclaw/openshell-sandbox:dangerous-exec:dist/.setup/worker-cli-<hash>.mjs",
|
|
"799a34cf8a82fed6725a5ac5322242becb394804b1ba6ea62d189aa8a1fa5f02",
|
|
],
|
|
]);
|
|
|
|
// Vendored runtime findings are owned by dependency path instead of every plugin
|
|
// that happens to carry the same dependency. Tests, declarations, examples, and
|
|
// benchmarks are excluded before this shrink-only inventory is consulted.
|
|
const REVIEWED_DEPENDENCY_FINDING_LIMITS = new Map<string, number>([
|
|
["dangerous-exec:node_modules/@anthropic-ai/sdk/bin/cli", 1],
|
|
["dangerous-exec:node_modules/@anthropic-ai/sdk/tools/agent-toolset/node.browser.js", 1],
|
|
["dangerous-exec:node_modules/@anthropic-ai/sdk/tools/agent-toolset/node.browser.mjs", 1],
|
|
["dangerous-exec:node_modules/@anthropic-ai/sdk/tools/agent-toolset/node.js", 4],
|
|
["dangerous-exec:node_modules/@anthropic-ai/sdk/tools/agent-toolset/node.mjs", 5],
|
|
["dangerous-exec:node_modules/@clawdbot/lobster/bin/invoke.js", 1],
|
|
["dangerous-exec:node_modules/@clawdbot/lobster/dist/src/abortable_process.js", 3],
|
|
["dangerous-exec:node_modules/@matrix-org/matrix-sdk-crypto-nodejs/index.js", 1],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/bwrap-probe-anchor.js", 1],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/bwrap-probe-helper.js", 1],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/bwrap-probe-worker.js", 1],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/diagnostic.js", 1],
|
|
// 4 covers mxc-sdk 0.8.0 on the supported stable line; 0.9.0 has 3.
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/platform.js", 4],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/policy.js", 1],
|
|
["dangerous-exec:node_modules/@microsoft/mxc-sdk/dist/sandbox.js", 2],
|
|
["dangerous-exec:node_modules/@openclaw/fs-safe/dist/windows-security-command.js", 2],
|
|
["dangerous-exec:node_modules/@snazzah/davey/index.js", 1],
|
|
["dangerous-exec:node_modules/baileys/lib/Utils/messages-media.js", 1],
|
|
["dangerous-exec:node_modules/google-auth-library/build/src/auth/pluggable-auth-handler.js", 1],
|
|
["dangerous-exec:node_modules/node-addon-api/tools/check-napi.js", 1],
|
|
["dangerous-exec:node_modules/node-addon-api/tools/clang-format.js", 1],
|
|
["dangerous-exec:node_modules/node-addon-api/tools/eslint-format.js", 3],
|
|
["dangerous-exec:node_modules/openai/helpers/audio.mjs", 2],
|
|
["dangerous-exec:node_modules/playwright-core/lib/coreBundle.js", 4],
|
|
["dangerous-exec:node_modules/playwright-core/lib/utilsBundle.js", 10],
|
|
["dangerous-exec:node_modules/prism-media/src/core/FFmpeg.js", 2],
|
|
["dangerous-exec:node_modules/tokenjuice/dist/core/cli-client.js", 1],
|
|
["dangerous-exec:node_modules/tokenjuice/dist/core/wrap.js", 1],
|
|
["dangerous-exec:node_modules/tokenjuice/dist/hosts/codex/index.js", 1],
|
|
["dangerous-exec:node_modules/tokenjuice/dist/hosts/localcode/index.js", 2],
|
|
["dynamic-code-execution:node_modules/@napi-rs/wasm-runtime/dist/fs.js", 1],
|
|
["dynamic-code-execution:node_modules/@noble/curves/abstract/fft.js", 2],
|
|
["dynamic-code-execution:node_modules/apache-arrow/Arrow.es2015.min.js", 1],
|
|
["dynamic-code-execution:node_modules/apache-arrow/Arrow.esnext.min.js", 1],
|
|
["dynamic-code-execution:node_modules/ajv/dist/compile/index.js", 1],
|
|
["dynamic-code-execution:node_modules/ajv/dist/compile/jtd/parse.js", 1],
|
|
["dynamic-code-execution:node_modules/ajv/dist/compile/jtd/serialize.js", 1],
|
|
["dynamic-code-execution:node_modules/depd/index.js", 1],
|
|
["dynamic-code-execution:node_modules/jszip/dist/jszip.js", 1],
|
|
["dynamic-code-execution:node_modules/jszip/dist/jszip.min.js", 1],
|
|
["dynamic-code-execution:node_modules/playwright-core/lib/coreBundle.js", 27],
|
|
["dynamic-code-execution:node_modules/playwright-core/lib/utilsBundle.js", 2],
|
|
[
|
|
"dynamic-code-execution:node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-<hash>.js",
|
|
5,
|
|
],
|
|
["dynamic-code-execution:node_modules/real-require/src/index.js", 1],
|
|
["dynamic-code-execution:node_modules/setimmediate/setImmediate.js", 1],
|
|
["env-harvesting:node_modules/@tybys/wasm-util/dist/wasm-util.esm.min.js", 1],
|
|
["env-harvesting:node_modules/@tybys/wasm-util/dist/wasm-util.min.js", 1],
|
|
["env-harvesting:node_modules/playwright-core/lib/bootstrap.js", 1],
|
|
]);
|
|
|
|
type CriticalFinding = Pick<SkillScanFinding, "line" | "ruleId"> & {
|
|
contentSha256: string;
|
|
path: string;
|
|
};
|
|
|
|
function normalizeFindingPath(file: string): string {
|
|
const name = path.posix.basename(file);
|
|
for (const prefix of [
|
|
"client",
|
|
"defaultSettingsView",
|
|
"dist",
|
|
"dynamic-tools",
|
|
"installed-probe",
|
|
"managed-server",
|
|
"media-catalog",
|
|
"monitor",
|
|
"receive-recovery",
|
|
"runtime-entry",
|
|
"sanitize-outbound",
|
|
"service",
|
|
"session-catalog",
|
|
"transport-stdio",
|
|
"worker-client",
|
|
"worker-cli",
|
|
]) {
|
|
const match = new RegExp(`^${prefix}-[A-Za-z0-9_-]{8}\\.(m?js)$`, "u").exec(name);
|
|
if (match?.[1]) {
|
|
return `${path.posix.dirname(file)}/${prefix}-<hash>.${match[1]}`;
|
|
}
|
|
}
|
|
return file;
|
|
}
|
|
|
|
function normalizeExecutablePath(value: unknown): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.includes("\\")) {
|
|
throw new Error("Plugin npm executable path is invalid.");
|
|
}
|
|
const withoutPrefix = value.replace(/^(?:\.\/)+/u, "");
|
|
const normalized = path.posix.normalize(withoutPrefix);
|
|
if (
|
|
normalized !== withoutPrefix ||
|
|
normalized === "." ||
|
|
normalized === ".." ||
|
|
normalized.startsWith("../") ||
|
|
path.posix.isAbsolute(normalized)
|
|
) {
|
|
throw new Error("Plugin npm executable path is invalid.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function declaredExecutablePaths(
|
|
manifest: Record<string, unknown>,
|
|
packedFiles: readonly string[],
|
|
): Set<string> {
|
|
const packed = new Set(packedFiles);
|
|
const declared = new Set<string>();
|
|
const add = (value: unknown) => {
|
|
const target = normalizeExecutablePath(value);
|
|
if (!packed.has(target)) {
|
|
throw new Error(`Plugin npm executable is absent from the tarball: ${target}`);
|
|
}
|
|
declared.add(target);
|
|
};
|
|
if (typeof manifest.bin === "string") {
|
|
add(manifest.bin);
|
|
} else if (manifest.bin && typeof manifest.bin === "object" && !Array.isArray(manifest.bin)) {
|
|
for (const value of Object.values(manifest.bin)) {
|
|
add(value);
|
|
}
|
|
} else if (manifest.bin !== undefined) {
|
|
throw new Error("Plugin npm bin declaration is invalid.");
|
|
}
|
|
if (manifest.directories && typeof manifest.directories === "object") {
|
|
const binDirectory = (manifest.directories as Record<string, unknown>).bin;
|
|
if (binDirectory !== undefined) {
|
|
const directory = normalizeExecutablePath(binDirectory);
|
|
const prefix = `${directory}/`;
|
|
const matches = packedFiles.filter((file) => file.startsWith(prefix));
|
|
if (matches.length === 0) {
|
|
throw new Error(`Plugin npm executable directory is absent from the tarball: ${directory}`);
|
|
}
|
|
for (const file of matches) {
|
|
declared.add(file);
|
|
}
|
|
}
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
function isVendoredDependencyTestPath(packagePath: string): boolean {
|
|
if (!packagePath.startsWith("node_modules/")) {
|
|
return false;
|
|
}
|
|
if (
|
|
/(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/]+\/(?:benchmarks?|docs?|examples?|test|tests|__fixtures__|__tests__)\//u.test(
|
|
packagePath,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (/\.(?:[cm]?ts|tsx)$/u.test(packagePath)) {
|
|
return true;
|
|
}
|
|
return /(?:^|[.-])(?:fixture|fixtures|mock|mocks|spec|test|test-helper|test-helpers|test-harness|test-support)(?:[.-]|$)/u.test(
|
|
path.posix.basename(packagePath),
|
|
);
|
|
}
|
|
|
|
function dependencyFindingPath(file: string): string | undefined {
|
|
const marker = "node_modules/";
|
|
const index = file.lastIndexOf(marker);
|
|
return index === -1 ? undefined : file.slice(index);
|
|
}
|
|
|
|
export function scanPluginNpmArtifactSecurity(params: {
|
|
packageName: string;
|
|
packageVersion: string;
|
|
tarball: Buffer;
|
|
}) {
|
|
const initial = inspectPackageTarballBytes(params.tarball, {
|
|
maxArchiveBytes: MAX_ARCHIVE_BYTES,
|
|
});
|
|
if (
|
|
initial.packageManifest.name !== params.packageName ||
|
|
initial.packageManifest.version !== params.packageVersion
|
|
) {
|
|
throw new Error("Plugin npm security scan package identity mismatch.");
|
|
}
|
|
const packedFiles = initial.inventory
|
|
.filter((entry) => entry.type === "file")
|
|
.map((entry) => entry.path.replace(/^package\//u, ""));
|
|
const fixturePaths = packedFiles.filter(isPluginTestFixturePath).toSorted();
|
|
if (fixturePaths.length > 0) {
|
|
throw new Error(
|
|
`Plugin npm artifact contains test or fixture files: ${fixturePaths.join(", ")}`,
|
|
);
|
|
}
|
|
const executables = declaredExecutablePaths(initial.packageManifest, packedFiles);
|
|
const critical: CriticalFinding[] = [];
|
|
let scannedFiles = 0;
|
|
let scannedBytes = 0;
|
|
inspectPackageTarballBytes(params.tarball, {
|
|
maxArchiveBytes: MAX_ARCHIVE_BYTES,
|
|
onFile: ({ content, path: archivePath }: { content: Uint8Array; path: string }) => {
|
|
const packedPath = archivePath.replace(/^package\//u, "");
|
|
if (isVendoredDependencyTestPath(packedPath)) {
|
|
return;
|
|
}
|
|
if (
|
|
!isScannable(packedPath) &&
|
|
!executables.has(packedPath) &&
|
|
path.posix.extname(packedPath) !== ""
|
|
) {
|
|
return;
|
|
}
|
|
if (content.byteLength > MAX_FILE_BYTES) {
|
|
throw new Error(`${packedPath}: plugin security scan file exceeds the byte limit.`);
|
|
}
|
|
scannedFiles += 1;
|
|
scannedBytes += content.byteLength;
|
|
if (scannedFiles > MAX_FILES || scannedBytes > MAX_TOTAL_BYTES) {
|
|
throw new Error("Plugin npm security scan exceeds its artifact limits.");
|
|
}
|
|
for (const finding of scanSource(Buffer.from(content).toString("utf8"), packedPath)) {
|
|
if (finding.severity === "critical") {
|
|
critical.push({
|
|
contentSha256: createHash("sha256").update(content).digest("hex"),
|
|
line: finding.line,
|
|
path: normalizeFindingPath(packedPath),
|
|
ruleId: finding.ruleId,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
});
|
|
const observed = new Map<string, number>();
|
|
const unexpected: CriticalFinding[] = [];
|
|
for (const finding of critical) {
|
|
const dependencyPath = dependencyFindingPath(finding.path);
|
|
const layout = finding.path.startsWith("dist/.setup/")
|
|
? "setup"
|
|
: finding.path.startsWith("dist/")
|
|
? "dist"
|
|
: "source";
|
|
const key = dependencyPath
|
|
? `${finding.ruleId}:${dependencyPath}`
|
|
: `${layout}:${params.packageName}:${finding.ruleId}:${finding.path}`;
|
|
const count = (observed.get(key) ?? 0) + 1;
|
|
observed.set(key, count);
|
|
const reviewedLimit = dependencyPath
|
|
? REVIEWED_DEPENDENCY_FINDING_LIMITS.get(key)
|
|
: REVIEWED_CRITICAL_FINDING_LIMITS.get(key);
|
|
const reviewedContentSha256 = dependencyPath
|
|
? undefined
|
|
: REVIEWED_CRITICAL_FINDING_CONTENT_SHA256.get(key);
|
|
if (
|
|
count > (reviewedLimit ?? 0) ||
|
|
(reviewedContentSha256 !== undefined && finding.contentSha256 !== reviewedContentSha256)
|
|
) {
|
|
unexpected.push(finding);
|
|
}
|
|
}
|
|
if (unexpected.length > 0) {
|
|
throw new Error(
|
|
`${params.packageName}: unreviewed critical findings in exact npm artifact: ${JSON.stringify(unexpected)}`,
|
|
);
|
|
}
|
|
return {
|
|
packageName: params.packageName,
|
|
packageVersion: params.packageVersion,
|
|
scannedFiles,
|
|
criticalFindingCount: critical.length,
|
|
tarballSha256: initial.tarballSha256,
|
|
};
|
|
}
|
|
|
|
function parseArgs(argv: string[]) {
|
|
const values = new Map<string, string>();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const name = argv[index];
|
|
const value = argv[index + 1];
|
|
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
|
|
throw new Error(`Invalid plugin npm security scan argument near ${String(name)}.`);
|
|
}
|
|
values.set(name, value);
|
|
}
|
|
const packageName = values.get("--package-name") ?? "";
|
|
const packageVersion = values.get("--package-version") ?? "";
|
|
const tarball = values.get("--tarball") ?? "";
|
|
if (!packageName || !packageVersion || !tarball || values.size !== 3) {
|
|
throw new Error("Plugin npm security scan requires package name, version, and tarball.");
|
|
}
|
|
return { packageName, packageVersion, tarball };
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const report = scanPluginNpmArtifactSecurity({
|
|
packageName: args.packageName,
|
|
packageVersion: args.packageVersion,
|
|
tarball: readFileSync(args.tarball),
|
|
});
|
|
console.log(JSON.stringify(report));
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|