mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-03 22:57:12 +00:00
1208 lines
44 KiB
TypeScript
1208 lines
44 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import {
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import {
|
|
isScannable,
|
|
scanSource,
|
|
scanDirectoryWithSummary,
|
|
type SkillScanFinding,
|
|
} from "../../src/skills/security/scanner.js";
|
|
import { runTasksWithConcurrency } from "../../src/utils/run-with-concurrency.js";
|
|
import {
|
|
inspectPackageTarballBytes,
|
|
readBoundedRegularFile,
|
|
} from "../plugin-publication-artifact.mjs";
|
|
|
|
export type PublishablePluginPackage = {
|
|
extensionId: string;
|
|
packageDir: string;
|
|
packageName: string;
|
|
packageVersion: string;
|
|
};
|
|
|
|
type CriticalFindingRecord = {
|
|
line: number;
|
|
path: string;
|
|
ruleId: string;
|
|
};
|
|
|
|
export type ScanPackageResult = {
|
|
expectedReviewedCriticalFindings: string[];
|
|
packageName: string;
|
|
packageVersion: string;
|
|
packedFileCount: number;
|
|
reviewedCriticalFindings: string[];
|
|
scanFindingCount: number;
|
|
tarballSha256: string;
|
|
unexpectedCriticalFindings: CriticalFindingRecord[];
|
|
};
|
|
|
|
type PluginNpmSecurityArtifact = PublishablePluginPackage & {
|
|
artifactKind: "supplemental-inert-package-input";
|
|
artifactDir: string;
|
|
candidateSha: string;
|
|
compressedBytes: number;
|
|
expandedBytes: number;
|
|
tarballPath: string;
|
|
tarballSha256: string;
|
|
toolingSha: string;
|
|
};
|
|
|
|
export type PluginNpmSecurityScanReport = {
|
|
candidateSha: string;
|
|
errors: string[];
|
|
layout: string | null;
|
|
packages: ScanPackageResult[];
|
|
scanScope: "supplemental-inert-package-input";
|
|
schemaVersion: 1;
|
|
status: "pass" | "fail";
|
|
summary: {
|
|
findingCount: number;
|
|
packageCount: number;
|
|
reviewedCriticalFindingCount: number;
|
|
unexpectedCriticalFindingCount: number;
|
|
};
|
|
toolingSha: string;
|
|
};
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
export const MAX_PUBLISHABLE_PLUGIN_PACKAGES = 256;
|
|
const MAX_PLUGIN_PACKAGE_MANIFEST_BYTES = 256 * 1024;
|
|
const MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE = 10_000;
|
|
const MAX_PLUGIN_SCAN_TOTAL_FINDINGS = 50_000;
|
|
const MAX_PLUGIN_SCAN_REPORT_BYTES = 1024 * 1024;
|
|
const MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES = 64 * 1024;
|
|
const MAX_PLUGIN_TARBALL_BYTES = 128 * 1024 * 1024;
|
|
const MAX_PLUGIN_TARBALL_TOTAL_BYTES = 512 * 1024 * 1024;
|
|
const MAX_PLUGIN_EXPANDED_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
const MAX_PACKED_FILES_PER_PACKAGE = 20_000;
|
|
const MAX_PACKED_FILE_BYTES = 64 * 1024 * 1024;
|
|
const MAX_PACKED_TOTAL_BYTES_PER_PACKAGE = 256 * 1024 * 1024;
|
|
const MAX_SCANNABLE_FILES_PER_PACKAGE = 10_000;
|
|
const MAX_SCANNABLE_FILE_BYTES = 1024 * 1024;
|
|
const MAX_SCANNABLE_TOTAL_BYTES_PER_PACKAGE = 64 * 1024 * 1024;
|
|
const PACKAGE_SCAN_CONCURRENCY = 4;
|
|
const CANONICAL_NPM_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
|
|
|
|
const CURRENT_REQUIRED_REVIEWED_SOURCE_FINDING_COUNTS = new Map<string, number>([
|
|
["@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts", 1],
|
|
["@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-stdio.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/doctor.ts", 1],
|
|
["@openclaw/discord:dangerous-exec:src/voice/audio.ts", 1],
|
|
["@openclaw/imessage:dangerous-exec:src/client.ts", 1],
|
|
["@openclaw/llama-cpp-provider:dangerous-exec:src/llama-server-install.ts", 1],
|
|
["@openclaw/mxc-sandbox:dangerous-exec:src/readiness.ts", 2],
|
|
["@openclaw/raft:dangerous-exec:src/gateway.ts", 1],
|
|
["@openclaw/signal:dangerous-exec:src/daemon.ts", 1],
|
|
["@openclaw/voice-call:dangerous-exec:src/tunnel.ts", 1],
|
|
]);
|
|
|
|
type ReviewedReleaseLayout = {
|
|
id: string;
|
|
findings: ReadonlyMap<string, number>;
|
|
};
|
|
|
|
type PluginSecurityInventoryPolicy = {
|
|
layout: ReviewedReleaseLayout;
|
|
optionalPackedFindingCounts: ReadonlyMap<string, number>;
|
|
requiredSourceFindingCounts: ReadonlyMap<string, number>;
|
|
};
|
|
|
|
const CURRENT_REVIEWED_RELEASE_LAYOUT = {
|
|
id: "current",
|
|
findings: new Map<string, number>([
|
|
["@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-process-snapshot.ts", 1],
|
|
]),
|
|
};
|
|
|
|
const CURRENT_OPTIONAL_REVIEWED_PACKED_FINDING_COUNTS = new Map<string, number>([
|
|
["@openclaw/acpx:dangerous-exec:dist/mcp-proxy.mjs", 1],
|
|
["@openclaw/acpx:dangerous-exec:dist/service-<hash>.js", 1],
|
|
["@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.test.ts", 3],
|
|
["@openclaw/codex:dangerous-exec:dist/api.js", 1],
|
|
["@openclaw/codex:dangerous-exec:dist/dynamic-tools-<hash>.js", 2],
|
|
["@openclaw/codex:dangerous-exec:dist/session-catalog-<hash>.js", 1],
|
|
["@openclaw/codex:dangerous-exec:dist/transport-stdio-<hash>.js", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/attempt-startup-retry.test.ts", 6],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server.http.test.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-orphan.test-helper.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-orphan.test.ts", 3],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-process-snapshot.test.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-startup.test.ts", 2],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts", 10],
|
|
["@openclaw/diagnostics-prometheus:dangerous-exec:src/install-runtime.e2e.test.ts", 2],
|
|
["@openclaw/google-meet:dangerous-exec:src/cli-artifacts.test.ts", 1],
|
|
["@openclaw/google-meet:dangerous-exec:src/realtime.process.test.ts", 1],
|
|
["@openclaw/imessage:dangerous-exec:src/client.test.ts", 3],
|
|
["@openclaw/llama-cpp-provider:dangerous-exec:dist/index.js", 1],
|
|
["@openclaw/memory-lancedb:dangerous-exec:memory-lancedb.concurrent.test.ts", 1],
|
|
["@openclaw/opencode-go-provider:env-harvesting:opencode-go.live.test.ts", 1],
|
|
["@openclaw/slack:dynamic-code-execution:dist/outbound-payload.test-harness-<hash>.js", 1],
|
|
["@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js", 1],
|
|
]);
|
|
|
|
const CURRENT_SECURITY_INVENTORY_POLICY: PluginSecurityInventoryPolicy = {
|
|
layout: CURRENT_REVIEWED_RELEASE_LAYOUT,
|
|
optionalPackedFindingCounts: CURRENT_OPTIONAL_REVIEWED_PACKED_FINDING_COUNTS,
|
|
requiredSourceFindingCounts: CURRENT_REQUIRED_REVIEWED_SOURCE_FINDING_COUNTS,
|
|
};
|
|
|
|
const FROZEN_RELEASE_REQUIRED_REVIEWED_SOURCE_FINDING_COUNTS = new Map<string, number>([
|
|
["@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts", 1],
|
|
["@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/transport-stdio.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts", 1],
|
|
["@openclaw/discord:dangerous-exec:src/voice/audio.ts", 1],
|
|
["@openclaw/google-meet:dangerous-exec:src/node-host.ts", 3],
|
|
["@openclaw/google-meet:dangerous-exec:src/realtime.ts", 2],
|
|
["@openclaw/matrix:dangerous-exec:src/matrix/deps.ts", 1],
|
|
["@openclaw/raft:dangerous-exec:src/gateway.ts", 1],
|
|
["@openclaw/signal:dangerous-exec:src/daemon.ts", 1],
|
|
["@openclaw/voice-call:dangerous-exec:src/tunnel.ts", 4],
|
|
["@openclaw/voice-call:dangerous-exec:src/webhook/tailscale.ts", 1],
|
|
]);
|
|
|
|
const FROZEN_RELEASE_OPTIONAL_REVIEWED_PACKED_FINDING_COUNTS = new Map<string, number>([
|
|
["@openclaw/acpx:dangerous-exec:dist/mcp-proxy.mjs", 1],
|
|
["@openclaw/acpx:dangerous-exec:dist/service-<hash>.js", 1],
|
|
["@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.test.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:dist/client-<hash>.js", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server.http.test.ts", 1],
|
|
["@openclaw/google-meet:dangerous-exec:dist/index.js", 1],
|
|
["@openclaw/google-meet:dangerous-exec:src/realtime.process.test.ts", 1],
|
|
["@openclaw/openshell-sandbox:dangerous-exec:src/backend.e2e.test.ts", 1],
|
|
["@openclaw/openshell-sandbox:dangerous-exec:src/openshell-core.test.ts", 2],
|
|
["@openclaw/slack:dynamic-code-execution:dist/outbound-payload.test-harness-<hash>.js", 1],
|
|
["@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js", 1],
|
|
]);
|
|
|
|
const FROZEN_EXTENDED_STABLE_2026_6_33_LAYOUT = {
|
|
id: "extended-stable-2026.6.33",
|
|
findings: new Map<string, number>([
|
|
["@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/http.ts", 1],
|
|
["@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/processes.ts", 1],
|
|
]),
|
|
};
|
|
|
|
const FROZEN_RELEASE_SECURITY_INVENTORY_POLICIES = new Map<string, PluginSecurityInventoryPolicy>([
|
|
["release/2026.9.1", CURRENT_SECURITY_INVENTORY_POLICY],
|
|
[
|
|
"extended-stable/2026.6.33",
|
|
{
|
|
layout: FROZEN_EXTENDED_STABLE_2026_6_33_LAYOUT,
|
|
optionalPackedFindingCounts: FROZEN_RELEASE_OPTIONAL_REVIEWED_PACKED_FINDING_COUNTS,
|
|
requiredSourceFindingCounts: FROZEN_RELEASE_REQUIRED_REVIEWED_SOURCE_FINDING_COUNTS,
|
|
},
|
|
],
|
|
]);
|
|
|
|
function selectPluginSecurityInventoryPolicy(
|
|
targetContextRef: string,
|
|
): PluginSecurityInventoryPolicy | undefined {
|
|
return targetContextRef === ""
|
|
? CURRENT_SECURITY_INVENTORY_POLICY
|
|
: FROZEN_RELEASE_SECURITY_INVENTORY_POLICIES.get(targetContextRef);
|
|
}
|
|
|
|
const REVIEWED_LAYOUT_FINDING_COUNTS = new Map<string, number>([
|
|
...CURRENT_REVIEWED_RELEASE_LAYOUT.findings,
|
|
...FROZEN_EXTENDED_STABLE_2026_6_33_LAYOUT.findings,
|
|
]);
|
|
|
|
function expandFindingCounts(counts: ReadonlyMap<string, number>): string[] {
|
|
return [...counts].flatMap(([key, count]) => Array.from({ length: count }, () => key));
|
|
}
|
|
|
|
function compareCodeUnits(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function sortStrings(values: readonly string[]): string[] {
|
|
return [...values].toSorted(compareCodeUnits);
|
|
}
|
|
|
|
function arraysEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
export function resolveReviewedSourceLayout(
|
|
reviewedCriticalFindings: readonly string[],
|
|
targetContextRef = "",
|
|
): ReviewedReleaseLayout | undefined {
|
|
const layout = selectPluginSecurityInventoryPolicy(targetContextRef)?.layout;
|
|
if (!layout) {
|
|
return undefined;
|
|
}
|
|
const observedLayoutFindings = sortStrings(
|
|
reviewedCriticalFindings.filter((key) => REVIEWED_LAYOUT_FINDING_COUNTS.has(key)),
|
|
);
|
|
return arraysEqual(observedLayoutFindings, sortStrings(expandFindingCounts(layout.findings)))
|
|
? layout
|
|
: undefined;
|
|
}
|
|
|
|
export function normalizePackedFindingPath(packedPath: string): string {
|
|
for (const prefix of [
|
|
"client",
|
|
"dynamic-tools",
|
|
"outbound-payload.test-harness",
|
|
"run-attempt",
|
|
"runtime-entry",
|
|
"service",
|
|
"session-catalog",
|
|
"shared-client",
|
|
"transport-stdio",
|
|
]) {
|
|
if (new RegExp(`^dist/${prefix}-[A-Za-z0-9_-]{8}\\.js$`, "u").test(packedPath)) {
|
|
return `dist/${prefix}-<hash>.js`;
|
|
}
|
|
}
|
|
return packedPath;
|
|
}
|
|
|
|
function expectedOptionalReviewedFindingsForPackedPath(
|
|
packageName: string,
|
|
packedPath: string,
|
|
policy: PluginSecurityInventoryPolicy | undefined,
|
|
): string[] {
|
|
const normalizedPath = normalizePackedFindingPath(packedPath);
|
|
const keyPrefix = `${packageName}:`;
|
|
const keySuffix = `:${normalizedPath}`;
|
|
return [...(policy?.optionalPackedFindingCounts ?? [])].flatMap(([key, count]) =>
|
|
key.startsWith(keyPrefix) && key.endsWith(keySuffix)
|
|
? Array.from({ length: count }, () => key)
|
|
: [],
|
|
);
|
|
}
|
|
|
|
function isReviewedCriticalFinding(
|
|
key: string,
|
|
policy: PluginSecurityInventoryPolicy | undefined,
|
|
): boolean {
|
|
return (
|
|
policy?.requiredSourceFindingCounts.has(key) === true ||
|
|
policy?.layout.findings.has(key) === true ||
|
|
policy?.optionalPackedFindingCounts.has(key) === true
|
|
);
|
|
}
|
|
|
|
async function gitOutput(rootDir: string, args: string[]): Promise<string> {
|
|
const { stdout } = await execFileAsync("git", ["-C", rootDir, ...args], {
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
return stdout.trim();
|
|
}
|
|
|
|
export function assertCanonicalNpmPackageName(packageName: unknown, label: string): string {
|
|
if (
|
|
typeof packageName !== "string" ||
|
|
packageName.trim() !== packageName ||
|
|
packageName.length > 214 ||
|
|
!CANONICAL_NPM_PACKAGE_NAME.test(packageName)
|
|
) {
|
|
throw new Error(`${label}: publishable plugin has an invalid npm package name.`);
|
|
}
|
|
return packageName;
|
|
}
|
|
|
|
export function resolveCandidatePluginPackageDir(
|
|
candidateDir: string,
|
|
extensionId: string,
|
|
): string {
|
|
const candidateRoot = realpathSync(candidateDir);
|
|
const packageDir = resolve(candidateRoot, "extensions", extensionId);
|
|
const relativePackageDir = relative(candidateRoot, packageDir);
|
|
if (relativePackageDir !== `extensions${sep}${extensionId}`) {
|
|
throw new Error(`extensions/${extensionId}: package directory escaped the candidate checkout.`);
|
|
}
|
|
const packageStat = lstatSync(packageDir);
|
|
if (!packageStat.isDirectory() || packageStat.isSymbolicLink()) {
|
|
throw new Error(`extensions/${extensionId}: package directory is not a real directory.`);
|
|
}
|
|
if (realpathSync(packageDir) !== packageDir) {
|
|
throw new Error(`extensions/${extensionId}: package directory resolves outside its path.`);
|
|
}
|
|
const packageJsonPath = join(packageDir, "package.json");
|
|
const packageJsonStat = lstatSync(packageJsonPath);
|
|
if (!packageJsonStat.isFile() || packageJsonStat.isSymbolicLink()) {
|
|
throw new Error(`extensions/${extensionId}/package.json: manifest is not a regular file.`);
|
|
}
|
|
return packageDir;
|
|
}
|
|
|
|
export async function listPublishablePluginPackages(
|
|
candidateDir: string,
|
|
limits: {
|
|
maxManifestBytes?: number;
|
|
maxPackageManifests?: number;
|
|
} = {},
|
|
): Promise<PublishablePluginPackage[]> {
|
|
const { stdout } = await execFileAsync(
|
|
"git",
|
|
["-C", candidateDir, "ls-files", "-z", "--", ":(glob)extensions/*/package.json"],
|
|
{
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
},
|
|
);
|
|
const packageFiles = stdout.split("\0").filter(Boolean).toSorted();
|
|
const maxPackageManifests = limits.maxPackageManifests ?? MAX_PUBLISHABLE_PLUGIN_PACKAGES;
|
|
if (packageFiles.length > maxPackageManifests) {
|
|
throw new Error("Candidate exceeds the plugin package-count limit.");
|
|
}
|
|
|
|
const publishablePackages = packageFiles.flatMap((packageFile) => {
|
|
const match = /^extensions\/([^/]+)\/package\.json$/u.exec(packageFile);
|
|
if (!match?.[1]) {
|
|
return [];
|
|
}
|
|
const packageDir = resolveCandidatePluginPackageDir(candidateDir, match[1]);
|
|
const packageJsonPath = join(packageDir, "package.json");
|
|
const packageStat = lstatSync(packageJsonPath);
|
|
if (
|
|
packageStat.size === 0 ||
|
|
packageStat.size > (limits.maxManifestBytes ?? MAX_PLUGIN_PACKAGE_MANIFEST_BYTES)
|
|
) {
|
|
throw new Error(`${packageFile}: package manifest exceeds the byte limit.`);
|
|
}
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
name?: unknown;
|
|
version?: unknown;
|
|
openclaw?: { release?: { publishToNpm?: unknown } };
|
|
};
|
|
if (packageJson.openclaw?.release?.publishToNpm !== true) {
|
|
return [];
|
|
}
|
|
const packageName = assertCanonicalNpmPackageName(packageJson.name, packageFile);
|
|
if (
|
|
typeof packageJson.version !== "string" ||
|
|
!packageJson.version ||
|
|
packageJson.version.trim() !== packageJson.version
|
|
) {
|
|
throw new Error(`${packageFile}: publishable plugin has an invalid package version.`);
|
|
}
|
|
return [
|
|
{
|
|
extensionId: match[1],
|
|
packageDir,
|
|
packageName,
|
|
packageVersion: packageJson.version,
|
|
},
|
|
];
|
|
});
|
|
const seenNames = new Set<string>();
|
|
for (const plugin of publishablePackages) {
|
|
if (seenNames.has(plugin.packageName)) {
|
|
throw new Error(`Candidate contains duplicate publishable package ${plugin.packageName}.`);
|
|
}
|
|
seenNames.add(plugin.packageName);
|
|
}
|
|
return publishablePackages.toSorted((left, right) =>
|
|
compareCodeUnits(left.packageName, right.packageName),
|
|
);
|
|
}
|
|
|
|
const PLUGIN_SECURITY_ARTIFACT_METADATA = "plugin-npm-security-artifact.json";
|
|
const PLUGIN_SECURITY_ARTIFACT_PREFIX = "plugin-npm-security-package-";
|
|
|
|
type PluginNpmSecurityArtifactLimits = {
|
|
maxCompressedBytes?: number;
|
|
maxExpandedBytes?: number;
|
|
};
|
|
|
|
export type PluginNpmSecurityArtifactLoadResult = {
|
|
artifacts: PluginNpmSecurityArtifact[];
|
|
compressedBytes: number;
|
|
expandedBytes: number;
|
|
ingestionErrors: string[];
|
|
};
|
|
|
|
function parseExpectedPackages(value: unknown): PublishablePluginPackage[] {
|
|
if (!Array.isArray(value) || value.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
|
|
throw new Error("Expected plugin package inventory is invalid.");
|
|
}
|
|
const packages = value.map((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
throw new Error(`Expected plugin package entry ${index} is invalid.`);
|
|
}
|
|
const candidate = entry as Record<string, unknown>;
|
|
const extensionId = candidate.extensionId;
|
|
const packageDir = candidate.packageDir;
|
|
const packageName = assertCanonicalNpmPackageName(
|
|
candidate.packageName,
|
|
`Expected plugin package entry ${index}`,
|
|
);
|
|
const packageVersion = candidate.packageVersion;
|
|
if (
|
|
typeof extensionId !== "string" ||
|
|
!/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) ||
|
|
packageDir !== `extensions/${extensionId}` ||
|
|
typeof packageVersion !== "string" ||
|
|
!packageVersion ||
|
|
packageVersion.trim() !== packageVersion
|
|
) {
|
|
throw new Error(`Expected plugin package entry ${index} is invalid.`);
|
|
}
|
|
return { extensionId, packageDir, packageName, packageVersion };
|
|
});
|
|
const sorted = packages.toSorted((left, right) =>
|
|
compareCodeUnits(left.packageName, right.packageName),
|
|
);
|
|
if (
|
|
new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length ||
|
|
new Set(sorted.map((plugin) => plugin.extensionId)).size !== sorted.length ||
|
|
JSON.stringify(sorted) !== JSON.stringify(packages)
|
|
) {
|
|
throw new Error("Expected plugin package inventory must be unique and sorted.");
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function readPluginSecurityArtifact(
|
|
artifactDir: string,
|
|
expectedPackage: PublishablePluginPackage,
|
|
expectedCandidateSha: string,
|
|
expectedToolingSha: string,
|
|
): PluginNpmSecurityArtifact {
|
|
const metadataPath = join(artifactDir, PLUGIN_SECURITY_ARTIFACT_METADATA);
|
|
const metadataStat = lstatSync(metadataPath);
|
|
if (
|
|
!metadataStat.isFile() ||
|
|
metadataStat.isSymbolicLink() ||
|
|
metadataStat.size === 0 ||
|
|
metadataStat.size > MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES
|
|
) {
|
|
throw new Error("Plugin security artifact metadata is outside the byte limit.");
|
|
}
|
|
let metadata: Record<string, unknown>;
|
|
try {
|
|
metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
|
|
} catch {
|
|
throw new Error("Plugin security artifact metadata is not valid JSON.");
|
|
}
|
|
const expectedKeys = [
|
|
"artifactKind",
|
|
"candidateSha",
|
|
"extensionId",
|
|
"packageDir",
|
|
"packageName",
|
|
"packageVersion",
|
|
"schemaVersion",
|
|
"tarballName",
|
|
"tarballSha256",
|
|
"toolingSha",
|
|
];
|
|
if (
|
|
metadata.artifactKind !== "supplemental-inert-package-input" ||
|
|
metadata.schemaVersion !== 1 ||
|
|
JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys)
|
|
) {
|
|
throw new Error("Plugin security artifact metadata has an invalid shape.");
|
|
}
|
|
const packageName = assertCanonicalNpmPackageName(
|
|
metadata.packageName,
|
|
"Plugin security artifact metadata",
|
|
);
|
|
const extensionId = metadata.extensionId;
|
|
const packageDir = metadata.packageDir;
|
|
const packageVersion = metadata.packageVersion;
|
|
const tarballName = metadata.tarballName;
|
|
const tarballSha256 = metadata.tarballSha256;
|
|
if (
|
|
metadata.candidateSha !== expectedCandidateSha ||
|
|
metadata.toolingSha !== expectedToolingSha ||
|
|
extensionId !== expectedPackage.extensionId ||
|
|
packageDir !== expectedPackage.packageDir ||
|
|
packageName !== expectedPackage.packageName ||
|
|
packageVersion !== expectedPackage.packageVersion ||
|
|
typeof tarballName !== "string" ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(tarballName) ||
|
|
basename(tarballName) !== tarballName ||
|
|
typeof tarballSha256 !== "string" ||
|
|
!/^[0-9a-f]{64}$/u.test(tarballSha256)
|
|
) {
|
|
throw new Error("Plugin security artifact metadata identity is invalid.");
|
|
}
|
|
const artifactEntries = readdirSync(artifactDir, { withFileTypes: true });
|
|
if (
|
|
artifactEntries.length !== 2 ||
|
|
artifactEntries.some(
|
|
(entry) =>
|
|
!entry.isFile() ||
|
|
entry.isSymbolicLink() ||
|
|
(entry.name !== PLUGIN_SECURITY_ARTIFACT_METADATA && entry.name !== tarballName),
|
|
)
|
|
) {
|
|
throw new Error("Plugin security artifact contains unexpected entries.");
|
|
}
|
|
const tarballPath = join(artifactDir, tarballName);
|
|
const tarballStat = lstatSync(tarballPath);
|
|
if (
|
|
!tarballStat.isFile() ||
|
|
tarballStat.isSymbolicLink() ||
|
|
tarballStat.size === 0 ||
|
|
tarballStat.size > MAX_PLUGIN_TARBALL_BYTES
|
|
) {
|
|
throw new Error(`${packageName}: plugin tarball is outside the byte limit.`);
|
|
}
|
|
const tarballBytes = readBoundedRegularFile(tarballPath, {
|
|
label: "Plugin security tarball",
|
|
maxBytes: MAX_PLUGIN_TARBALL_BYTES,
|
|
});
|
|
let inspection: ReturnType<typeof inspectPackageTarballBytes>;
|
|
try {
|
|
inspection = inspectPackageTarballBytes(tarballBytes, {
|
|
maxArchiveBytes: MAX_PLUGIN_TARBALL_BYTES,
|
|
maxEntries: MAX_PACKED_FILES_PER_PACKAGE,
|
|
maxEntryBytes: MAX_PACKED_FILE_BYTES,
|
|
maxExpandedBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
maxPathBytes: 4 * 1024 * 1024,
|
|
maxTotalFileBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
});
|
|
} catch {
|
|
throw new Error("Plugin security artifact tarball structure is invalid.");
|
|
}
|
|
if (
|
|
inspection.tarballSha256 !== tarballSha256 ||
|
|
inspection.packageManifest.name !== packageName ||
|
|
inspection.packageManifest.version !== packageVersion
|
|
) {
|
|
throw new Error("Plugin security artifact tarball identity is invalid.");
|
|
}
|
|
return {
|
|
artifactKind: "supplemental-inert-package-input",
|
|
artifactDir,
|
|
candidateSha: expectedCandidateSha,
|
|
compressedBytes: tarballStat.size,
|
|
expandedBytes: inspection.totalFileBytes,
|
|
extensionId,
|
|
packageDir,
|
|
packageName,
|
|
packageVersion,
|
|
tarballPath,
|
|
tarballSha256,
|
|
toolingSha: expectedToolingSha,
|
|
};
|
|
}
|
|
|
|
function resolveAggregateLimit(value: number | undefined, fallback: number, label: string): number {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) {
|
|
throw new Error(`${label} must be a positive integer no larger than ${fallback}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function artifactDirectoryName(candidateSha: string, extensionId: string): string {
|
|
return `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${candidateSha}-${extensionId}`;
|
|
}
|
|
|
|
function sanitizeArtifactIngestionError(
|
|
expectedPackage: PublishablePluginPackage,
|
|
error: unknown,
|
|
): string {
|
|
const knownCategories = new Set([
|
|
"Plugin security artifact metadata is outside the byte limit.",
|
|
"Plugin security artifact metadata is not valid JSON.",
|
|
"Plugin security artifact metadata has an invalid shape.",
|
|
"Plugin security artifact metadata identity is invalid.",
|
|
"Plugin security artifact contains unexpected entries.",
|
|
"Plugin security artifact tarball structure is invalid.",
|
|
"Plugin security artifact tarball identity is invalid.",
|
|
]);
|
|
const message = error instanceof Error ? error.message : "";
|
|
const category =
|
|
knownCategories.has(message) || message.endsWith("plugin tarball is outside the byte limit.")
|
|
? message.replace(`${expectedPackage.packageName}: `, "")
|
|
: "Plugin security artifact validation failed.";
|
|
return `${expectedPackage.packageName}: ${category}`;
|
|
}
|
|
|
|
export function loadPluginNpmSecurityArtifacts(params: {
|
|
artifactRoot: string;
|
|
candidateSha: string;
|
|
expectedPackages: unknown;
|
|
limits?: PluginNpmSecurityArtifactLimits;
|
|
toolingSha: string;
|
|
}): PluginNpmSecurityArtifactLoadResult {
|
|
const expectedPackages = parseExpectedPackages(params.expectedPackages);
|
|
const rootStat = lstatSync(params.artifactRoot);
|
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
throw new Error("Plugin security artifact root is not a real directory.");
|
|
}
|
|
const artifactRoot = realpathSync(params.artifactRoot);
|
|
const entries = readdirSync(artifactRoot, { withFileTypes: true }).toSorted((left, right) =>
|
|
compareCodeUnits(left.name, right.name),
|
|
);
|
|
const maxCompressedBytes = resolveAggregateLimit(
|
|
params.limits?.maxCompressedBytes,
|
|
MAX_PLUGIN_TARBALL_TOTAL_BYTES,
|
|
"Plugin security compressed-byte limit",
|
|
);
|
|
const maxExpandedBytes = resolveAggregateLimit(
|
|
params.limits?.maxExpandedBytes,
|
|
MAX_PLUGIN_EXPANDED_TOTAL_BYTES,
|
|
"Plugin security expanded-byte limit",
|
|
);
|
|
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
const expectedNames = new Set(
|
|
expectedPackages.map((plugin) =>
|
|
artifactDirectoryName(params.candidateSha, plugin.extensionId),
|
|
),
|
|
);
|
|
const ingestionErrors: string[] = [];
|
|
if (entries.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
|
|
ingestionErrors.push("Plugin security artifact set exceeds the package-count limit.");
|
|
}
|
|
const unexpectedEntryCount = entries.filter((entry) => !expectedNames.has(entry.name)).length;
|
|
if (unexpectedEntryCount > 0) {
|
|
ingestionErrors.push(
|
|
`Plugin security artifact root contains ${unexpectedEntryCount} unexpected entries.`,
|
|
);
|
|
}
|
|
|
|
const artifacts: PluginNpmSecurityArtifact[] = [];
|
|
let compressedBytes = 0;
|
|
let expandedBytes = 0;
|
|
for (const expectedPackage of expectedPackages) {
|
|
const entryName = artifactDirectoryName(params.candidateSha, expectedPackage.extensionId);
|
|
const entry = entriesByName.get(entryName);
|
|
if (!entry) {
|
|
ingestionErrors.push(`${expectedPackage.packageName}: plugin security artifact is missing.`);
|
|
continue;
|
|
}
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
ingestionErrors.push(
|
|
`${expectedPackage.packageName}: plugin security artifact is not a real directory.`,
|
|
);
|
|
continue;
|
|
}
|
|
try {
|
|
const artifact = readPluginSecurityArtifact(
|
|
join(artifactRoot, entry.name),
|
|
expectedPackage,
|
|
params.candidateSha,
|
|
params.toolingSha,
|
|
);
|
|
if (compressedBytes + artifact.compressedBytes > maxCompressedBytes) {
|
|
ingestionErrors.push(
|
|
`${expectedPackage.packageName}: aggregate compressed-byte limit exceeded.`,
|
|
);
|
|
continue;
|
|
}
|
|
if (expandedBytes + artifact.expandedBytes > maxExpandedBytes) {
|
|
ingestionErrors.push(
|
|
`${expectedPackage.packageName}: aggregate expanded-byte limit exceeded.`,
|
|
);
|
|
continue;
|
|
}
|
|
compressedBytes += artifact.compressedBytes;
|
|
expandedBytes += artifact.expandedBytes;
|
|
artifacts.push(artifact);
|
|
} catch (error) {
|
|
ingestionErrors.push(sanitizeArtifactIngestionError(expectedPackage, error));
|
|
}
|
|
}
|
|
|
|
return {
|
|
artifacts,
|
|
compressedBytes,
|
|
expandedBytes,
|
|
ingestionErrors: sortStrings(ingestionErrors),
|
|
};
|
|
}
|
|
|
|
export function listPluginNpmSecurityArtifacts(params: {
|
|
artifactRoot: string;
|
|
candidateSha: string;
|
|
expectedPackages: unknown;
|
|
limits?: PluginNpmSecurityArtifactLimits;
|
|
toolingSha: string;
|
|
}): PluginNpmSecurityArtifact[] {
|
|
const result = loadPluginNpmSecurityArtifacts(params);
|
|
if (result.ingestionErrors.length > 0) {
|
|
throw new Error(result.ingestionErrors.join("\n"));
|
|
}
|
|
return result.artifacts;
|
|
}
|
|
|
|
export function stageScannerRelevantPluginTarballFiles(tarballPath: string): {
|
|
directlyScannedFileCount: number;
|
|
directlyScannedFindings: SkillScanFinding[];
|
|
fileCount: number;
|
|
inspection: {
|
|
inventory: Array<{ path: string; sizeBytes: number; type: string }>;
|
|
packageManifest: Record<string, unknown>;
|
|
tarballSha256: string;
|
|
};
|
|
packedFiles: string[];
|
|
stageDir: string;
|
|
totalBytes: number;
|
|
} {
|
|
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
|
|
let directlyScannedFileCount = 0;
|
|
const directlyScannedFindings: SkillScanFinding[] = [];
|
|
let fileCount = 0;
|
|
let totalBytes = 0;
|
|
const packedFiles: string[] = [];
|
|
try {
|
|
const tarballBytes = readBoundedRegularFile(tarballPath, {
|
|
label: "Plugin security tarball",
|
|
maxBytes: MAX_PLUGIN_TARBALL_BYTES,
|
|
});
|
|
const inspection = inspectPackageTarballBytes(tarballBytes, {
|
|
maxArchiveBytes: MAX_PLUGIN_TARBALL_BYTES,
|
|
maxEntries: MAX_PACKED_FILES_PER_PACKAGE,
|
|
maxEntryBytes: MAX_PACKED_FILE_BYTES,
|
|
maxExpandedBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
maxPathBytes: 4 * 1024 * 1024,
|
|
maxTotalFileBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
}) as {
|
|
inventory: Array<{ path: string; sizeBytes: number; type: string }>;
|
|
packageManifest: Record<string, unknown>;
|
|
tarballSha256: string;
|
|
};
|
|
for (const entry of inspection.inventory) {
|
|
if (entry.type !== "file") {
|
|
continue;
|
|
}
|
|
if (!entry.path.startsWith("package/")) {
|
|
throw new Error("Plugin tarball file escaped package/.");
|
|
}
|
|
packedFiles.push(entry.path.slice("package/".length));
|
|
}
|
|
const declaredExecutablePaths = resolveDeclaredPackedExecutablePaths(
|
|
inspection.packageManifest,
|
|
packedFiles,
|
|
);
|
|
inspectPackageTarballBytes(tarballBytes, {
|
|
maxArchiveBytes: MAX_PLUGIN_TARBALL_BYTES,
|
|
maxEntries: MAX_PACKED_FILES_PER_PACKAGE,
|
|
maxEntryBytes: MAX_PACKED_FILE_BYTES,
|
|
maxExpandedBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
maxPathBytes: 4 * 1024 * 1024,
|
|
maxTotalFileBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
|
|
onFile: ({ content, path }: { content: Uint8Array; path: string }) => {
|
|
if (!path.startsWith("package/")) {
|
|
throw new Error("Plugin tarball file escaped package/.");
|
|
}
|
|
const packedPath = path.slice("package/".length);
|
|
const extensionless = posix.extname(posix.basename(packedPath)) === "";
|
|
const directlyScan =
|
|
!isScannable(packedPath) && (extensionless || declaredExecutablePaths.has(packedPath));
|
|
if (!directlyScan && !isScannable(packedPath)) {
|
|
return;
|
|
}
|
|
if (content.byteLength > MAX_SCANNABLE_FILE_BYTES) {
|
|
throw new Error(`Packed scanner input exceeds the per-file byte limit: ${packedPath}`);
|
|
}
|
|
fileCount += 1;
|
|
totalBytes += content.byteLength;
|
|
if (fileCount > MAX_SCANNABLE_FILES_PER_PACKAGE) {
|
|
throw new Error("Packed scanner input exceeds the file-count limit.");
|
|
}
|
|
if (totalBytes > MAX_SCANNABLE_TOTAL_BYTES_PER_PACKAGE) {
|
|
throw new Error("Packed scanner input exceeds the total-byte limit.");
|
|
}
|
|
const target = join(stageDir, ...packedPath.split("/"));
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
writeFileSync(target, content);
|
|
if (directlyScan) {
|
|
directlyScannedFileCount += 1;
|
|
directlyScannedFindings.push(
|
|
...scanSource(Buffer.from(content).toString("utf8"), target),
|
|
);
|
|
}
|
|
},
|
|
});
|
|
return {
|
|
directlyScannedFileCount,
|
|
directlyScannedFindings,
|
|
fileCount,
|
|
inspection,
|
|
packedFiles: packedFiles.toSorted(),
|
|
stageDir,
|
|
totalBytes,
|
|
};
|
|
} catch (error) {
|
|
rmSync(stageDir, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeDeclaredExecutablePath(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.includes("\\")) {
|
|
throw new Error(`${label} is not a safe packed path.`);
|
|
}
|
|
const withoutDotPrefix = value.replace(/^(?:\.\/)+/u, "");
|
|
const normalized = posix.normalize(withoutDotPrefix);
|
|
if (
|
|
!withoutDotPrefix ||
|
|
normalized !== withoutDotPrefix ||
|
|
normalized === "." ||
|
|
normalized === ".." ||
|
|
normalized.startsWith("../") ||
|
|
posix.isAbsolute(normalized)
|
|
) {
|
|
throw new Error(`${label} is not a safe packed path.`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function resolveDeclaredPackedExecutablePaths(
|
|
manifest: Record<string, unknown>,
|
|
packedFiles: readonly string[],
|
|
): Set<string> {
|
|
const packedFileSet = new Set(packedFiles);
|
|
const declared = new Set<string>();
|
|
const addBinTarget = (value: unknown) => {
|
|
const packedPath = normalizeDeclaredExecutablePath(value, "Plugin npm bin target");
|
|
if (!packedFileSet.has(packedPath)) {
|
|
throw new Error(`Plugin npm bin target is absent from the tarball: ${packedPath}`);
|
|
}
|
|
declared.add(packedPath);
|
|
};
|
|
|
|
if (manifest.bin !== undefined) {
|
|
if (typeof manifest.bin === "string") {
|
|
addBinTarget(manifest.bin);
|
|
} else if (isRecord(manifest.bin)) {
|
|
for (const value of Object.values(manifest.bin)) {
|
|
addBinTarget(value);
|
|
}
|
|
} else {
|
|
throw new Error("Plugin npm bin declaration is invalid.");
|
|
}
|
|
}
|
|
|
|
if (manifest.directories !== undefined) {
|
|
if (!isRecord(manifest.directories)) {
|
|
throw new Error("Plugin npm directories declaration is invalid.");
|
|
}
|
|
if (manifest.directories.bin !== undefined) {
|
|
const binDirectory = normalizeDeclaredExecutablePath(
|
|
manifest.directories.bin,
|
|
"Plugin npm directories.bin",
|
|
).replace(/\/$/u, "");
|
|
const prefix = `${binDirectory}/`;
|
|
for (const packedPath of packedFiles) {
|
|
if (packedPath.startsWith(prefix)) {
|
|
declared.add(packedPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
function findingRecord(stageDir: string, finding: SkillScanFinding): CriticalFindingRecord {
|
|
const packedPath = normalizePackedFindingPath(
|
|
relative(stageDir, finding.file).split(sep).join("/"),
|
|
);
|
|
return { line: finding.line, path: packedPath, ruleId: finding.ruleId };
|
|
}
|
|
|
|
function findingKey(packageName: string, finding: CriticalFindingRecord): string {
|
|
return `${packageName}:${finding.ruleId}:${finding.path}`;
|
|
}
|
|
|
|
export function assertCompleteScannerSummary(
|
|
packageName: string,
|
|
summary: { truncated: boolean },
|
|
): void {
|
|
if (summary.truncated) {
|
|
throw new Error(`${packageName}: security scan reached its file limit.`);
|
|
}
|
|
}
|
|
|
|
async function scanSupplementalInertPluginInput(
|
|
plugin: PluginNpmSecurityArtifact,
|
|
policy: PluginSecurityInventoryPolicy | undefined,
|
|
): Promise<ScanPackageResult> {
|
|
const reviewedCriticalFindings: string[] = [];
|
|
const expectedReviewedCriticalFindings: string[] = [];
|
|
const unexpectedCriticalFindings: CriticalFindingRecord[] = [];
|
|
let scanFindingCount = 0;
|
|
const staged = stageScannerRelevantPluginTarballFiles(plugin.tarballPath);
|
|
try {
|
|
if (
|
|
staged.inspection.packageManifest.name !== plugin.packageName ||
|
|
staged.inspection.packageManifest.version !== plugin.packageVersion ||
|
|
staged.inspection.tarballSha256 !== plugin.tarballSha256
|
|
) {
|
|
throw new Error(`${plugin.packageName}: supplemental inert package input identity mismatch.`);
|
|
}
|
|
const normalizedPackedPaths = new Set<string>();
|
|
for (const packedFile of staged.packedFiles) {
|
|
const normalizedPackedPath = normalizePackedFindingPath(packedFile);
|
|
if (normalizedPackedPaths.has(normalizedPackedPath)) {
|
|
throw new Error(`multiple packed files normalize to ${normalizedPackedPath}.`);
|
|
}
|
|
normalizedPackedPaths.add(normalizedPackedPath);
|
|
expectedReviewedCriticalFindings.push(
|
|
...expectedOptionalReviewedFindingsForPackedPath(plugin.packageName, packedFile, policy),
|
|
);
|
|
}
|
|
const summary = await scanDirectoryWithSummary(staged.stageDir, {
|
|
includeHiddenDirectories: true,
|
|
includeNodeModules: true,
|
|
maxFileBytes: MAX_SCANNABLE_FILE_BYTES,
|
|
maxFiles: MAX_SCANNABLE_FILES_PER_PACKAGE,
|
|
});
|
|
assertCompleteScannerSummary(plugin.packageName, summary);
|
|
const findings = [...summary.findings, ...staged.directlyScannedFindings];
|
|
if (findings.length > MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE) {
|
|
throw new Error(`${plugin.packageName}: security scan exceeded the finding-count limit.`);
|
|
}
|
|
scanFindingCount = findings.length;
|
|
if (summary.scannedFiles + staged.directlyScannedFileCount !== staged.fileCount) {
|
|
throw new Error(
|
|
`${plugin.packageName}: security scan processed ${summary.scannedFiles + staged.directlyScannedFileCount} of ${staged.fileCount} staged files.`,
|
|
);
|
|
}
|
|
for (const finding of findings) {
|
|
if (finding.severity !== "critical") {
|
|
continue;
|
|
}
|
|
const record = findingRecord(staged.stageDir, finding);
|
|
const key = findingKey(plugin.packageName, record);
|
|
if (isReviewedCriticalFinding(key, policy)) {
|
|
reviewedCriticalFindings.push(key);
|
|
} else {
|
|
unexpectedCriticalFindings.push(record);
|
|
}
|
|
}
|
|
} finally {
|
|
rmSync(staged.stageDir, { recursive: true, force: true });
|
|
}
|
|
|
|
return {
|
|
expectedReviewedCriticalFindings: sortStrings(expectedReviewedCriticalFindings),
|
|
packageName: plugin.packageName,
|
|
packageVersion: plugin.packageVersion,
|
|
packedFileCount: staged.inspection.inventory.filter((entry) => entry.type === "file").length,
|
|
reviewedCriticalFindings: sortStrings(reviewedCriticalFindings),
|
|
scanFindingCount,
|
|
tarballSha256: plugin.tarballSha256,
|
|
unexpectedCriticalFindings: unexpectedCriticalFindings.toSorted((left, right) =>
|
|
compareCodeUnits(JSON.stringify(left), JSON.stringify(right)),
|
|
),
|
|
};
|
|
}
|
|
|
|
function expectedRequiredFindingsForPackage(
|
|
packageName: string,
|
|
policy: PluginSecurityInventoryPolicy,
|
|
): string[] {
|
|
return [...policy.requiredSourceFindingCounts, ...policy.layout.findings].flatMap(
|
|
([key, count]) =>
|
|
key.startsWith(`${packageName}:`) ? Array.from({ length: count }, () => key) : [],
|
|
);
|
|
}
|
|
|
|
export function buildPluginNpmSecurityScanReport(params: {
|
|
candidateSha: string;
|
|
maxTotalFindings?: number;
|
|
packageResults: ScanPackageResult[];
|
|
scanErrors?: readonly string[];
|
|
targetContextRef?: string;
|
|
toolingSha: string;
|
|
}): PluginNpmSecurityScanReport {
|
|
const { candidateSha, packageResults, toolingSha } = params;
|
|
const allReviewedFindings = packageResults.flatMap((result) => result.reviewedCriticalFindings);
|
|
const totalFindingCount = packageResults.reduce(
|
|
(total, result) => total + result.scanFindingCount,
|
|
0,
|
|
);
|
|
const targetContextRef = params.targetContextRef ?? "";
|
|
const policy = selectPluginSecurityInventoryPolicy(targetContextRef);
|
|
const layout = resolveReviewedSourceLayout(allReviewedFindings, targetContextRef);
|
|
const errors: string[] = sortStrings(params.scanErrors ?? []);
|
|
|
|
if (totalFindingCount > (params.maxTotalFindings ?? MAX_PLUGIN_SCAN_TOTAL_FINDINGS)) {
|
|
errors.push("Plugin npm security scan exceeded the total finding-count limit.");
|
|
}
|
|
if (!layout) {
|
|
errors.push("Reviewed critical findings do not match exactly one supported release layout.");
|
|
}
|
|
if (packageResults.length === 0) {
|
|
errors.push("No publishable npm plugins were found in the candidate checkout.");
|
|
}
|
|
|
|
const publishablePackageNames = new Set(packageResults.map((result) => result.packageName));
|
|
const requiredFindingCounts = new Map<string, number>([
|
|
...(policy?.requiredSourceFindingCounts ?? []),
|
|
...(policy?.layout.findings ?? []),
|
|
]);
|
|
const missingPackages = [
|
|
...new Set([...requiredFindingCounts.keys()].map((key) => key.slice(0, key.indexOf(":")))),
|
|
].filter((packageName) => !publishablePackageNames.has(packageName));
|
|
if (missingPackages.length > 0) {
|
|
errors.push(
|
|
`Reviewed inventory references unpublished packages: ${missingPackages.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
for (const result of packageResults) {
|
|
if (result.unexpectedCriticalFindings.length > 0) {
|
|
errors.push(
|
|
`${result.packageName}: unexpected critical findings: ${JSON.stringify(result.unexpectedCriticalFindings)}`,
|
|
);
|
|
}
|
|
if (!policy || !layout) {
|
|
continue;
|
|
}
|
|
const expected = sortStrings([
|
|
...expectedRequiredFindingsForPackage(result.packageName, policy),
|
|
...result.expectedReviewedCriticalFindings,
|
|
]);
|
|
const observed = sortStrings(result.reviewedCriticalFindings);
|
|
if (!arraysEqual(expected, observed)) {
|
|
errors.push(
|
|
`${result.packageName}: reviewed critical inventory mismatch; expected ${JSON.stringify(expected)}, observed ${JSON.stringify(observed)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const unexpectedCriticalFindingCount = packageResults.reduce(
|
|
(total, result) => total + result.unexpectedCriticalFindings.length,
|
|
0,
|
|
);
|
|
const sortedPackages = packageResults
|
|
.map((result) => ({
|
|
...result,
|
|
expectedReviewedCriticalFindings: sortStrings(result.expectedReviewedCriticalFindings),
|
|
reviewedCriticalFindings: sortStrings(result.reviewedCriticalFindings),
|
|
unexpectedCriticalFindings: result.unexpectedCriticalFindings.toSorted((left, right) =>
|
|
compareCodeUnits(JSON.stringify(left), JSON.stringify(right)),
|
|
),
|
|
}))
|
|
.toSorted((left, right) => compareCodeUnits(left.packageName, right.packageName));
|
|
return {
|
|
candidateSha,
|
|
errors: sortStrings(errors),
|
|
layout: layout?.id ?? null,
|
|
packages: sortedPackages,
|
|
scanScope: "supplemental-inert-package-input",
|
|
schemaVersion: 1,
|
|
status: errors.length === 0 ? "pass" : "fail",
|
|
summary: {
|
|
findingCount: totalFindingCount,
|
|
packageCount: packageResults.length,
|
|
reviewedCriticalFindingCount: allReviewedFindings.length,
|
|
unexpectedCriticalFindingCount,
|
|
},
|
|
toolingSha,
|
|
};
|
|
}
|
|
|
|
export function constrainPluginNpmSecurityScanReport(
|
|
report: PluginNpmSecurityScanReport,
|
|
maxBytes = MAX_PLUGIN_SCAN_REPORT_BYTES,
|
|
): PluginNpmSecurityScanReport {
|
|
const serializedBytes = Buffer.byteLength(`${JSON.stringify(report)}\n`, "utf8");
|
|
if (serializedBytes <= maxBytes) {
|
|
return report;
|
|
}
|
|
return {
|
|
candidateSha: report.candidateSha,
|
|
errors: ["Plugin npm security scan report exceeded the byte limit."],
|
|
layout: null,
|
|
packages: [],
|
|
scanScope: "supplemental-inert-package-input",
|
|
schemaVersion: 1,
|
|
status: "fail",
|
|
summary: report.summary,
|
|
toolingSha: report.toolingSha,
|
|
};
|
|
}
|
|
|
|
function sanitizePackageScanError(plugin: PluginNpmSecurityArtifact, error: unknown): string {
|
|
let message = error instanceof Error ? error.message : "Unknown package scan failure.";
|
|
for (const [path, replacement] of [
|
|
[plugin.packageDir, "<candidate-package>"],
|
|
[tmpdir(), "<tmp>"],
|
|
] as const) {
|
|
message = message.replaceAll(path, replacement);
|
|
}
|
|
message = message
|
|
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
|
|
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
|
|
return `${plugin.packageName}: package scan failed: ${message}`;
|
|
}
|
|
|
|
export async function scanPublishablePluginPackages(
|
|
packages: readonly PluginNpmSecurityArtifact[],
|
|
targetContextRef = "",
|
|
): Promise<{ packageResults: ScanPackageResult[]; scanErrors: string[] }> {
|
|
const scanErrors: string[] = [];
|
|
const policy = selectPluginSecurityInventoryPolicy(targetContextRef);
|
|
const { results } = await runTasksWithConcurrency({
|
|
errorMode: "continue",
|
|
limit: PACKAGE_SCAN_CONCURRENCY,
|
|
onTaskError: (error, index) => {
|
|
const plugin = packages[index];
|
|
scanErrors.push(
|
|
plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.",
|
|
);
|
|
},
|
|
tasks: packages.map((plugin) => () => scanSupplementalInertPluginInput(plugin, policy)),
|
|
});
|
|
return {
|
|
packageResults: results.filter((result): result is ScanPackageResult => result !== undefined),
|
|
scanErrors: sortStrings(scanErrors),
|
|
};
|
|
}
|
|
|
|
export async function runPluginNpmSecurityScan(params: {
|
|
artifactRoot: string;
|
|
candidateSha: string;
|
|
expectedPackages: unknown;
|
|
limits?: PluginNpmSecurityArtifactLimits;
|
|
targetContextRef?: string;
|
|
toolingDir: string;
|
|
toolingSha: string;
|
|
}): Promise<PluginNpmSecurityScanReport> {
|
|
const toolingDir = realpathSync(params.toolingDir);
|
|
const toolingSha = await gitOutput(toolingDir, ["rev-parse", "HEAD"]);
|
|
if (toolingSha !== params.toolingSha) {
|
|
throw new Error("Trusted scanner tooling checkout differs from the expected commit.");
|
|
}
|
|
const loaded = loadPluginNpmSecurityArtifacts({
|
|
artifactRoot: params.artifactRoot,
|
|
candidateSha: params.candidateSha,
|
|
expectedPackages: params.expectedPackages,
|
|
limits: params.limits,
|
|
toolingSha,
|
|
});
|
|
const { packageResults, scanErrors } = await scanPublishablePluginPackages(
|
|
loaded.artifacts,
|
|
params.targetContextRef,
|
|
);
|
|
return constrainPluginNpmSecurityScanReport(
|
|
buildPluginNpmSecurityScanReport({
|
|
candidateSha: params.candidateSha,
|
|
packageResults,
|
|
scanErrors: [...loaded.ingestionErrors, ...scanErrors],
|
|
targetContextRef: params.targetContextRef,
|
|
toolingSha,
|
|
}),
|
|
);
|
|
}
|