fix(release): scan publication-equivalent plugin artifacts

This commit is contained in:
Vincent Koc
2026-08-20 21:39:18 -07:00
parent dc15c4f660
commit dd9677012a
10 changed files with 556 additions and 1137 deletions
-11
View File
@@ -1,11 +0,0 @@
import Arborist from "@npmcli/arborist";
import packlist from "npm-packlist";
const packageDir = process.argv[2];
if (!packageDir) {
throw new Error("package directory is required");
}
const tree = await new Arborist({ path: packageDir }).loadActual();
const files = await packlist(tree, { path: packageDir });
process.stdout.write(JSON.stringify(files));
+227 -241
View File
@@ -1,6 +1,5 @@
import { execFile } from "node:child_process";
import {
copyFileSync,
lstatSync,
mkdirSync,
mkdtempSync,
@@ -10,10 +9,8 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import {
isScannable,
@@ -51,9 +48,12 @@ export type ScanPackageResult = {
};
type PluginNpmSecurityArtifact = PublishablePluginPackage & {
artifactKind: "inert-package-input";
artifactKind: "publication-equivalent-plugin-tarball";
artifactDir: string;
candidateSha: string;
compressedBytes: number;
expandedBytes: number;
packOwner: "scripts/plugin-npm-publish.sh";
tarballPath: string;
tarballSha256: string;
toolingSha: string;
@@ -76,10 +76,6 @@ export type PluginNpmSecurityScanReport = {
};
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const validateNpmPackageName = require("validate-npm-package-name") as (name: unknown) => {
validForNewPackages: boolean;
};
export const MAX_PUBLISHABLE_PLUGIN_PACKAGES = 256;
export const MAX_PLUGIN_PACKAGE_MANIFEST_BYTES = 256 * 1024;
export const MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE = 10_000;
@@ -87,26 +83,16 @@ export const MAX_PLUGIN_SCAN_TOTAL_FINDINGS = 50_000;
export 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 MAX_PACKED_PATH_BYTES = 4096;
const PACKLIST_HELPER_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PACKLIST_HELPER_MAX_OLD_SPACE_MB = 256;
const PACKLIST_HELPER_TIMEOUT_MS = 60_000;
const PACKAGE_SCAN_CONCURRENCY = 4;
const PACKLIST_HELPER_PATH = fileURLToPath(new URL("./plugin-npm-pack-files.mjs", import.meta.url));
const DEFAULT_SCANNER_INPUT_LIMITS = {
maxPackedFileBytes: MAX_PACKED_FILE_BYTES,
maxPackedFiles: MAX_PACKED_FILES_PER_PACKAGE,
maxPackedTotalBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE,
maxFileBytes: MAX_SCANNABLE_FILE_BYTES,
maxFiles: MAX_SCANNABLE_FILES_PER_PACKAGE,
maxTotalBytes: MAX_SCANNABLE_TOTAL_BYTES_PER_PACKAGE,
};
const CANONICAL_NPM_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
const COMMON_REVIEWED_CRITICAL_FINDING_COUNTS = new Map<string, number>([
["@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts", 1],
@@ -197,89 +183,6 @@ export function resolveReviewedSourceLayout(
);
}
export function parsePacklistFiles(raw: string, packageName: string): string[] {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error(`${packageName}: packlist helper did not return a files list.`);
}
if (parsed.length > MAX_PACKED_FILES_PER_PACKAGE) {
throw new Error(`${packageName}: packlist exceeds the file-count limit.`);
}
const packedPaths: string[] = [];
const seenPaths = new Set<string>();
for (const [index, packedPath] of parsed.entries()) {
if (
typeof packedPath !== "string" ||
!isSafePackedPath(packedPath) ||
Buffer.byteLength(packedPath, "utf8") > MAX_PACKED_PATH_BYTES
) {
throw new Error(`${packageName}: packlist entry ${index} has an invalid path.`);
}
if (seenPaths.has(packedPath)) {
throw new Error(`${packageName}: packlist returned a duplicate path: ${packedPath}`);
}
seenPaths.add(packedPath);
packedPaths.push(packedPath);
}
return packedPaths.toSorted();
}
type PacklistHelperLimits = {
helperPath?: string;
maxBufferBytes?: number;
maxOldSpaceMb?: number;
timeoutMs?: number;
};
export async function collectNpmPackedFiles(
packageDir: string,
packageName: string,
limits: PacklistHelperLimits = {},
): Promise<string[]> {
const helperPath = limits.helperPath ?? PACKLIST_HELPER_PATH;
const maxOldSpaceMb = limits.maxOldSpaceMb ?? PACKLIST_HELPER_MAX_OLD_SPACE_MB;
const timeoutMs = limits.timeoutMs ?? PACKLIST_HELPER_TIMEOUT_MS;
try {
const { stdout } = await execFileAsync(
process.execPath,
[`--max-old-space-size=${maxOldSpaceMb}`, helperPath, packageDir],
{
cwd: dirname(PACKLIST_HELPER_PATH),
encoding: "utf8",
env: {
CI: "1",
HOME: tmpdir(),
PATH: process.env.PATH,
},
killSignal: "SIGKILL",
maxBuffer: limits.maxBufferBytes ?? PACKLIST_HELPER_MAX_BUFFER_BYTES,
signal: AbortSignal.timeout(timeoutMs),
},
);
return parsePacklistFiles(stdout, packageName);
} catch (error) {
const failure =
error && typeof error === "object"
? (error as { code?: unknown; killed?: unknown; signal?: unknown })
: {};
if (failure.code === "ABORT_ERR" || failure.code === "ETIMEDOUT") {
throw new Error(`${packageName}: trusted packlist helper timed out.`);
}
if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
throw new Error(`${packageName}: trusted packlist helper exceeded its output limit.`);
}
if (failure.killed === true || typeof failure.signal === "string") {
throw new Error(`${packageName}: trusted packlist helper exceeded its process limit.`);
}
if (typeof failure.code === "number") {
throw new Error(`${packageName}: trusted packlist helper failed.`);
}
throw new Error(`${packageName}: trusted packlist helper could not start.`);
}
}
export function normalizePackedFindingPath(packedPath: string): string {
for (const prefix of [
"dynamic-tools",
@@ -319,96 +222,6 @@ function isReviewedCriticalFinding(key: string): boolean {
);
}
function isSafePackedPath(packedPath: string): boolean {
if (
!packedPath ||
isAbsolute(packedPath) ||
packedPath.includes("\\") ||
/[\u0000-\u001f\u007f]/u.test(packedPath) ||
packedPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
) {
return false;
}
return !packedPath.split("/").some((segment) => {
return segment === "node_modules" || segment.startsWith(".");
});
}
function assertPathInside(parentPath: string, childPath: string): void {
const relativePath = relative(parentPath, childPath);
if (relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== "..")) {
return;
}
throw new Error(`Packed file escaped its plugin package: ${relativePath}`);
}
export function stageScannerRelevantPackedFiles(
packageDir: string,
packedFiles: readonly string[],
limits = DEFAULT_SCANNER_INPUT_LIMITS,
): {
fileCount: number;
packedFileCount: number;
packedTotalBytes: number;
stageDir: string;
totalBytes: number;
} {
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
const realPackageDir = realpathSync(packageDir);
let fileCount = 0;
let packedFileCount = 0;
let packedTotalBytes = 0;
let totalBytes = 0;
try {
for (const packedPath of packedFiles) {
if (!isSafePackedPath(packedPath)) {
throw new Error(`npm pack returned an unsafe path: ${packedPath}`);
}
const source = resolve(realPackageDir, packedPath);
const sourceStat = lstatSync(source);
if (!sourceStat.isFile()) {
throw new Error(`Packed scanner input is not a regular file: ${packedPath}`);
}
packedFileCount += 1;
packedTotalBytes += sourceStat.size;
if (sourceStat.size > limits.maxPackedFileBytes) {
throw new Error(`Packed input exceeds the per-file byte limit: ${packedPath}`);
}
if (packedFileCount > limits.maxPackedFiles) {
throw new Error("Packed input exceeds the file-count limit.");
}
if (packedTotalBytes > limits.maxPackedTotalBytes) {
throw new Error("Packed input exceeds the total-byte limit.");
}
const realSource = realpathSync(source);
assertPathInside(realPackageDir, realSource);
if (!isScannable(packedPath)) {
continue;
}
if (sourceStat.size > limits.maxFileBytes) {
throw new Error(`Packed scanner input exceeds the per-file byte limit: ${packedPath}`);
}
fileCount += 1;
totalBytes += sourceStat.size;
if (fileCount > limits.maxFiles) {
throw new Error("Packed scanner input exceeds the file-count limit.");
}
if (totalBytes > limits.maxTotalBytes) {
throw new Error("Packed scanner input exceeds the total-byte limit.");
}
const target = join(stageDir, ...packedPath.split("/"));
mkdirSync(dirname(target), { recursive: true });
copyFileSync(realSource, target);
}
return { fileCount, packedFileCount, packedTotalBytes, stageDir, totalBytes };
} catch (error) {
rmSync(stageDir, { recursive: true, force: true });
throw error;
}
}
async function gitOutput(rootDir: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", rootDir, ...args], {
encoding: "utf8",
@@ -421,13 +234,39 @@ export function assertCanonicalNpmPackageName(packageName: unknown, label: strin
if (
typeof packageName !== "string" ||
packageName.trim() !== packageName ||
!validateNpmPackageName(packageName).validForNewPackages
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: {
@@ -454,12 +293,9 @@ export async function listPublishablePluginPackages(
if (!match?.[1]) {
return [];
}
const packageDir = resolve(candidateDir, "extensions", match[1]);
const packageDir = resolveCandidatePluginPackageDir(candidateDir, match[1]);
const packageJsonPath = join(packageDir, "package.json");
const packageStat = lstatSync(packageJsonPath);
if (!packageStat.isFile()) {
throw new Error(`${packageFile}: package manifest is not a regular file.`);
}
if (
packageStat.size === 0 ||
packageStat.size > (limits.maxManifestBytes ?? MAX_PLUGIN_PACKAGE_MANIFEST_BYTES)
@@ -504,6 +340,19 @@ export async function listPublishablePluginPackages(
}
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) {
@@ -548,6 +397,7 @@ function parseExpectedPackages(value: unknown): PublishablePluginPackage[] {
function readPluginSecurityArtifact(
artifactDir: string,
expectedPackage: PublishablePluginPackage,
expectedCandidateSha: string,
expectedToolingSha: string,
): PluginNpmSecurityArtifact {
@@ -555,16 +405,23 @@ function readPluginSecurityArtifact(
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.");
}
const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
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",
"packOwner",
"packageDir",
"packageName",
"packageVersion",
@@ -574,7 +431,8 @@ function readPluginSecurityArtifact(
"toolingSha",
];
if (
metadata.artifactKind !== "inert-package-input" ||
metadata.artifactKind !== "publication-equivalent-plugin-tarball" ||
metadata.packOwner !== "scripts/plugin-npm-publish.sh" ||
metadata.schemaVersion !== 1 ||
JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys)
) {
@@ -592,12 +450,10 @@ function readPluginSecurityArtifact(
if (
metadata.candidateSha !== expectedCandidateSha ||
metadata.toolingSha !== expectedToolingSha ||
typeof extensionId !== "string" ||
!/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) ||
packageDir !== `extensions/${extensionId}` ||
typeof packageVersion !== "string" ||
!packageVersion ||
packageVersion.trim() !== packageVersion ||
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 ||
@@ -612,6 +468,7 @@ function readPluginSecurityArtifact(
artifactEntries.some(
(entry) =>
!entry.isFile() ||
entry.isSymbolicLink() ||
(entry.name !== PLUGIN_SECURITY_ARTIFACT_METADATA && entry.name !== tarballName),
)
) {
@@ -621,16 +478,44 @@ function readPluginSecurityArtifact(
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: "inert-package-input",
artifactKind: "publication-equivalent-plugin-tarball",
artifactDir,
candidateSha: expectedCandidateSha,
compressedBytes: tarballStat.size,
expandedBytes: inspection.totalFileBytes,
extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir,
packageName,
packageVersion,
@@ -640,48 +525,147 @@ function readPluginSecurityArtifact(
};
}
export function listPluginNpmSecurityArtifacts(params: {
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;
}): PluginNpmSecurityArtifact[] {
}): 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) {
throw new Error("Plugin security artifact set exceeds the package-count limit.");
ingestionErrors.push("Plugin security artifact set exceeds the package-count limit.");
}
const artifacts = entries.map((entry) => {
if (!entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error("Plugin security artifact root contains a non-directory entry.");
}
return readPluginSecurityArtifact(
join(artifactRoot, entry.name),
params.candidateSha,
params.toolingSha,
const unexpectedEntryCount = entries.filter((entry) => !expectedNames.has(entry.name)).length;
if (unexpectedEntryCount > 0) {
ingestionErrors.push(
`Plugin security artifact root contains ${unexpectedEntryCount} unexpected entries.`,
);
});
const sorted = artifacts.toSorted((left, right) =>
compareCodeUnits(left.packageName, right.packageName),
);
if (new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length) {
throw new Error("Plugin security artifact set contains duplicate package names.");
}
const observedPackages = sorted.map(
({ extensionId, packageDir, packageName, packageVersion }) => ({
extensionId,
packageDir,
packageName,
packageVersion,
}),
);
if (JSON.stringify(observedPackages) !== JSON.stringify(expectedPackages)) {
throw new Error("Plugin security artifact set does not match the trusted package plan.");
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 sorted;
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): {
@@ -787,7 +771,7 @@ async function scanPublishablePluginArtifact(
staged.inspection.packageManifest.version !== plugin.packageVersion ||
staged.inspection.tarballSha256 !== plugin.tarballSha256
) {
throw new Error(`${plugin.packageName}: inert package input identity mismatch.`);
throw new Error(`${plugin.packageName}: publication artifact identity mismatch.`);
}
for (const packedFile of staged.packedFiles) {
expectedReviewedCriticalFindings.push(
@@ -999,6 +983,7 @@ export async function runPluginNpmSecurityScan(params: {
artifactRoot: string;
candidateSha: string;
expectedPackages: unknown;
limits?: PluginNpmSecurityArtifactLimits;
toolingDir: string;
toolingSha: string;
}): Promise<PluginNpmSecurityScanReport> {
@@ -1007,18 +992,19 @@ export async function runPluginNpmSecurityScan(params: {
if (toolingSha !== params.toolingSha) {
throw new Error("Trusted scanner tooling checkout differs from the expected commit.");
}
const packages = listPluginNpmSecurityArtifacts({
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot: params.artifactRoot,
candidateSha: params.candidateSha,
expectedPackages: params.expectedPackages,
limits: params.limits,
toolingSha,
});
const { packageResults, scanErrors } = await scanPublishablePluginPackages(packages);
const { packageResults, scanErrors } = await scanPublishablePluginPackages(loaded.artifacts);
return constrainPluginNpmSecurityScanReport(
buildPluginNpmSecurityScanReport({
candidateSha: params.candidateSha,
packageResults,
scanErrors,
scanErrors: [...loaded.ingestionErrors, ...scanErrors],
toolingSha,
}),
);
+30 -56
View File
@@ -11,11 +11,10 @@ import { basename, join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts";
import {
collectNpmPackedFiles,
listPublishablePluginPackages,
resolveCandidatePluginPackageDir,
type PublishablePluginPackage,
} from "./lib/plugin-npm-security-scan.mts";
import { resolveNpmRunner } from "./npm-runner.mts";
import {
inspectPackageTarballBytes,
readBoundedRegularFile,
@@ -156,16 +155,7 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
if (!selected) {
throw new Error("Selected plugin package is absent from the trusted package plan.");
}
const testPacklistHelper =
process.env.NODE_ENV === "test"
? process.env.OPENCLAW_PLUGIN_SECURITY_TEST_PACKLIST_HELPER
: undefined;
const expectedPackedFiles = await collectNpmPackedFiles(
selected.packageDir,
selected.packageName,
testPacklistHelper ? { helperPath: testPacklistHelper } : {},
);
resolveCandidatePluginPackageDir(candidateRoot, args.extensionId);
if (existsSync(args.outputDir)) {
if (readdirSync(args.outputDir).length !== 0) {
throw new Error("Plugin security artifact output directory must be empty.");
@@ -174,39 +164,32 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
mkdirSync(args.outputDir, { recursive: true });
}
// Qualification keeps the candidate inert. This packs checked-in package input
// without release builds, asset hooks, installs, lock generation, or overlays.
const npm = resolveNpmRunner({
env: {
...process.env,
NPM_CONFIG_AUDIT: "false",
NPM_CONFIG_FUND: "false",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
NPM_CONFIG_PROVENANCE: "false",
NPM_CONFIG_WORKSPACES: "false",
// This unprivileged job is the only candidate-code execution boundary. It
// uses the same pack owner as publication so the trusted scanner sees built,
// overlaid, dependency-complete package bytes without executing candidate code.
const publishScript =
process.env.NODE_ENV === "test" && process.env.OPENCLAW_PLUGIN_SECURITY_TEST_PUBLISH_SCRIPT
? process.env.OPENCLAW_PLUGIN_SECURITY_TEST_PUBLISH_SCRIPT
: join(toolingRoot, "scripts", "plugin-npm-publish.sh");
const result = spawnSync(
"bash",
[publishScript, "--repo-root", candidateRoot, "--pack", args.packageDir],
{
cwd: toolingRoot,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR: args.outputDir,
},
killSignal: "SIGKILL",
maxBuffer: MAX_PACK_STDOUT_BYTES,
shell: false,
stdio: ["ignore", "pipe", "inherit"],
timeout: PACK_TIMEOUT_MS,
},
npmArgs: [
"pack",
"--json",
"--ignore-scripts",
"--workspaces=false",
"--pack-destination",
args.outputDir,
],
});
const result = spawnSync(npm.command, npm.args, {
cwd: selected.packageDir,
encoding: "utf8",
env: npm.env ?? process.env,
killSignal: "SIGKILL",
maxBuffer: MAX_PACK_STDOUT_BYTES,
shell: npm.shell,
stdio: ["ignore", "pipe", "inherit"],
timeout: PACK_TIMEOUT_MS,
windowsVerbatimArguments: npm.windowsVerbatimArguments,
});
);
if (result.status !== 0 || result.signal || result.error) {
throw new Error(`${selected.packageName}: trusted inert plugin pack failed.`);
throw new Error(`${selected.packageName}: publication-equivalent plugin pack failed.`);
}
const packEntries = parsePackOutput(result.stdout);
if (packEntries.length !== 1) {
@@ -228,30 +211,21 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
const inspection = inspectPackageTarballBytes(tarballBytes, {
maxArchiveBytes: MAX_TARBALL_BYTES,
});
const packedFiles = inspection.inventory
.filter((entry) => entry.type === "file")
.map((entry) => {
if (!entry.path.startsWith("package/")) {
throw new Error(`${selected.packageName}: inert package input escaped package/.`);
}
return entry.path.slice("package/".length);
})
.toSorted();
if (
inspection.packageManifest.name !== selected.packageName ||
inspection.packageManifest.version !== selected.packageVersion ||
JSON.stringify(packedFiles) !== JSON.stringify(expectedPackedFiles)
inspection.packageManifest.version !== selected.packageVersion
) {
throw new Error(`${selected.packageName}: inert package input identity mismatch.`);
throw new Error(`${selected.packageName}: publication-equivalent package identity mismatch.`);
}
const artifactEntries = readdirSync(args.outputDir);
if (artifactEntries.length !== 1 || artifactEntries[0] !== tarballName) {
throw new Error(`${selected.packageName}: packaging produced unexpected artifact files.`);
}
const metadata = {
artifactKind: "inert-package-input",
artifactKind: "publication-equivalent-plugin-tarball",
candidateSha: args.candidateSha,
extensionId: selected.extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir: args.packageDir,
packageName: selected.packageName,
packageVersion: selected.packageVersion,
+56 -6
View File
@@ -1,9 +1,10 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_HEAP_MB = 768;
const DEFAULT_RSS_MB = 1024;
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_CAPTURE_BYTES = 512 * 1024;
const MAX_REPORT_BYTES = 1024 * 1024;
@@ -53,6 +54,38 @@ function boundedAppend(current, chunk) {
return Buffer.concat([current, chunk]).subarray(0, MAX_CAPTURE_BYTES);
}
function processGroupRssBytes(pid) {
if (process.platform === "win32") {
return null;
}
const result = spawnSync("ps", ["-o", "rss=", "-g", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
});
if (result.status !== 0) {
return null;
}
const samples = result.stdout
.trim()
.split(/\s+/u)
.filter(Boolean)
.map((value) => Number(value));
if (samples.length === 0 || samples.some((value) => !Number.isSafeInteger(value) || value <= 0)) {
return null;
}
return samples.reduce((total, value) => total + value, 0) * 1024;
}
function killProcessGroup(child) {
if (!child.pid) {
return;
}
try {
process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
} catch {}
}
function sanitizeOutput(value, args) {
let output = value.toString("utf8");
for (const [source, replacement] of [
@@ -121,6 +154,7 @@ async function run(argv) {
const args = parseArgs(argv);
const scannerPath = testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD", SCANNER_PATH);
const heapMb = Number(testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB", DEFAULT_HEAP_MB));
const rssMb = Number(testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_RSS_MB", DEFAULT_RSS_MB));
const timeoutMs = Number(
testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
);
@@ -128,6 +162,9 @@ async function run(argv) {
!Number.isSafeInteger(heapMb) ||
heapMb < 16 ||
heapMb > 4096 ||
!Number.isSafeInteger(rssMb) ||
rssMb < 16 ||
rssMb > 4096 ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 10 ||
timeoutMs > DEFAULT_TIMEOUT_MS
@@ -137,6 +174,7 @@ async function run(argv) {
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let rssExceeded = false;
let timedOut = false;
const child = spawn(
process.execPath,
@@ -159,19 +197,27 @@ async function run(argv) {
child.stderr.on("data", (chunk) => {
stderr = boundedAppend(stderr, chunk);
});
const rssLimitBytes = rssMb * 1024 * 1024;
const rssTimer = setInterval(() => {
if (child.exitCode !== null || child.signalCode !== null || !child.pid) {
return;
}
const rssBytes = processGroupRssBytes(child.pid);
if (rssBytes !== null && rssBytes > rssLimitBytes) {
rssExceeded = true;
killProcessGroup(child);
}
}, 250);
const timer = setTimeout(() => {
timedOut = true;
if (child.pid) {
try {
process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
} catch {}
}
killProcessGroup(child);
}, timeoutMs);
const result = await new Promise((resolve) => {
child.on("error", (error) => resolve({ error, status: null }));
child.on("close", (status, signal) => resolve({ error: undefined, signal, status }));
});
clearTimeout(timer);
clearInterval(rssTimer);
const safeStdout = sanitizeOutput(stdout, args);
const safeStderr = sanitizeOutput(stderr, args);
@@ -185,6 +231,10 @@ async function run(argv) {
writeFailureReport(args, "timed out");
return 1;
}
if (rssExceeded) {
writeFailureReport(args, "exceeded its RSS limit");
return 1;
}
if (result.error) {
writeFailureReport(args, "could not start");
return 1;