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
+5 -5
View File
@@ -359,13 +359,13 @@ jobs:
persist-credentials: false
submodules: false
- name: Setup trusted inert pack environment
- name: Setup candidate package build environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
- name: Pack inert plugin package input
- name: Build publication-equivalent plugin artifact
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXTENSION_ID: ${{ matrix.extension_id }}
@@ -385,7 +385,7 @@ jobs:
--package-name "$PACKAGE_NAME" \
--tooling-sha "$TOOLING_SHA"
- name: Upload inert plugin package input
- name: Upload publication-equivalent plugin artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-${{ matrix.extension_id }}
@@ -426,14 +426,14 @@ jobs:
- name: Install trusted scanner dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
- name: Download inert plugin package inputs
- name: Download publication-equivalent plugin artifacts
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-*
path: ${{ runner.temp }}/plugin-npm-security-packages
- name: Scan inert plugin package inputs
- name: Scan publication-equivalent plugin artifacts
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
+1
View File
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress.
- **Plugin release security scan:** scan immutable publication-equivalent npm plugin tarballs in trusted tooling, while isolating candidate builds in secretless jobs and bounding malformed artifacts, archive bytes, findings, memory, and reports.
- **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts.
- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched.
- **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees.
-3
View File
@@ -2094,7 +2094,6 @@
"@lit/context": "1.1.6",
"@lit/task": "1.0.3",
"@mdx-js/mdx": "3.1.1",
"@npmcli/arborist": "9.8.0",
"@openclaw/session-url-contract": "workspace:*",
"@opentelemetry/sdk-node": "0.221.0",
"@shikijs/core": "4.3.1",
@@ -2119,7 +2118,6 @@
"lit-analyzer": "2.0.3",
"markdown-it": "14.3.0",
"marked": "18.0.7",
"npm-packlist": "10.0.4",
"oxfmt": "0.60.0",
"oxlint": "1.75.0",
"oxlint-tsgolint": "7.0.2001",
@@ -2135,7 +2133,6 @@
"tsx": "4.23.1",
"unrun": "0.3.1",
"vite": "8.1.5",
"validate-npm-package-name": "7.0.2",
"vitest": "4.1.10"
},
"optionalDependencies": {
-649
View File
File diff suppressed because it is too large Load Diff
-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;
+226 -156
View File
@@ -1,21 +1,20 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertCanonicalNpmPackageName,
assertCompleteScannerSummary,
buildPluginNpmSecurityScanReport,
collectNpmPackedFiles,
constrainPluginNpmSecurityScanReport,
loadPluginNpmSecurityArtifacts,
listPluginNpmSecurityArtifacts,
listPublishablePluginPackages,
normalizePackedFindingPath,
parsePacklistFiles,
resolveCandidatePluginPackageDir,
resolveReviewedSourceLayout,
scanPublishablePluginPackages,
stageScannerRelevantPackedFiles,
stageScannerRelevantPluginTarballFiles,
type PublishablePluginPackage,
type ScanPackageResult,
@@ -54,14 +53,21 @@ function writePublishableManifest(
}
function writePluginArtifact(params: {
artifactRoot?: string;
extensionId: string;
files: Record<string, string | Buffer>;
packageName: string;
version?: string;
}) {
const root = tempDirs.make("openclaw-plugin-npm-security-artifact-");
const packageRoot = join(root, "source");
const artifactDir = join(root, "artifacts", params.extensionId);
const root = params.artifactRoot
? join(params.artifactRoot, "..")
: tempDirs.make("openclaw-plugin-npm-security-artifact-");
const artifactRoot = params.artifactRoot ?? join(root, "artifacts");
const packageRoot = join(root, `source-${params.extensionId}`);
const artifactDir = join(
artifactRoot,
`plugin-npm-security-package-${CANDIDATE_SHA}-${params.extensionId}`,
);
const packageVersion = params.version ?? "1.0.0";
mkdirSync(packageRoot, { recursive: true });
mkdirSync(artifactDir, { recursive: true });
@@ -95,9 +101,10 @@ function writePluginArtifact(params: {
writeFileSync(
join(artifactDir, "plugin-npm-security-artifact.json"),
`${JSON.stringify({
artifactKind: "inert-package-input",
artifactKind: "publication-equivalent-plugin-tarball",
candidateSha: CANDIDATE_SHA,
extensionId: params.extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
@@ -110,10 +117,16 @@ function writePluginArtifact(params: {
);
return {
artifact: {
artifactKind: "inert-package-input" as const,
artifactKind: "publication-equivalent-plugin-tarball" as const,
artifactDir,
candidateSha: CANDIDATE_SHA,
compressedBytes: readFileSync(tarballPath).byteLength,
expandedBytes: Object.values(params.files).reduce(
(total, value) => total + Buffer.byteLength(value),
0,
),
extensionId: params.extensionId,
packOwner: "scripts/plugin-npm-publish.sh" as const,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
@@ -121,7 +134,7 @@ function writePluginArtifact(params: {
tarballSha256,
toolingSha: TOOLING_SHA,
},
artifactRoot: join(root, "artifacts"),
artifactRoot,
expectedPackage: {
extensionId: params.extensionId,
packageDir: `extensions/${params.extensionId}`,
@@ -179,92 +192,70 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined();
});
it("collects package files without running lifecycle or candidate replacement helpers", async () => {
const packageDir = tempDirs.make("openclaw-plugin-npm-security-pack-");
const replacementMarker = join(packageDir, "replacement-helper-ran");
it("scans malicious output generated by the canonical publication pack boundary", async () => {
const candidateRoot = tempDirs.make("openclaw-plugin-security-built-pack-");
const artifactRoot = tempDirs.make("openclaw-plugin-security-built-artifacts-");
const packageDir = join(candidateRoot, "extensions", "generated");
const trustedPackScript = join(
tempDirs.make("openclaw-plugin-security-trusted-pack-"),
"pack.sh",
);
const generatedPath = join(packageDir, "dist", "generated.js");
const lifecycleMarkers = ["prepare", "prepack", "postpack"].map((name) =>
join(packageDir, `${name}-ran`),
);
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({
name: "@openclaw/test-inert-package",
scripts: Object.fromEntries(
["prepare", "prepack", "postpack"].map((name, index) => [
name,
`node -e "require('node:fs').writeFileSync(${JSON.stringify(lifecycleMarkers[index])}, 'ran')"`,
]),
),
version: "1.0.0",
})}\n`,
"utf8",
);
writeFileSync(join(packageDir, "index.js"), "export const value = 1;\n", "utf8");
writeFileSync(
join(packageDir, "plugin-npm-pack-files.mjs"),
`import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(replacementMarker)}, "ran");\n`,
"utf8",
);
const trustedHelper = join(tempDirs.make("openclaw-plugin-npm-security-helper-"), "helper.mjs");
writeFileSync(
trustedHelper,
'process.stdout.write(JSON.stringify(["index.js", "package.json", "plugin-npm-pack-files.mjs"]));\n',
"utf8",
);
const packedFiles = await collectNpmPackedFiles(packageDir, "@openclaw/test-inert-package", {
helperPath: trustedHelper,
});
expect(packedFiles).toContain("index.js");
expect(packedFiles).toContain("package.json");
expect(existsSync(replacementMarker)).toBe(false);
for (const marker of lifecycleMarkers) {
expect(existsSync(marker)).toBe(false);
}
});
it("packs candidate input without running lifecycle or asset build hooks", () => {
const candidateRoot = tempDirs.make("openclaw-plugin-security-inert-pack-");
const outputDir = tempDirs.make("openclaw-plugin-security-inert-output-");
const packageDir = join(candidateRoot, "extensions", "inert");
const packlistHelper = join(candidateRoot, "trusted-packlist-helper.mjs");
const markers = Object.fromEntries(
["asset", "prepare", "prepack", "postpack"].map((name) => [
name,
join(candidateRoot, `${name}-ran`),
]),
join(candidateRoot, `${name}-ran`),
);
initGitRepo(candidateRoot);
mkdirSync(packageDir, { recursive: true });
const markerCommand = (marker: string) =>
`node -e "require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran')"`;
const generatedCode = 'const { execSync } = require("node:child_process");\nexecSync("id");\n';
const assetBuild = "node build-assets.mjs";
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({
name: "@openclaw/test-inert-pack",
files: ["dist", "openclaw.plugin.json", "package.json"],
name: "@openclaw/test-generated-pack",
openclaw: {
assetScripts: { build: markerCommand(markers.asset!) },
assetScripts: { build: assetBuild },
release: { publishToNpm: true },
},
scripts: {
postpack: markerCommand(markers.postpack!),
prepack: markerCommand(markers.prepack!),
prepare: markerCommand(markers.prepare!),
postpack: markerCommand(lifecycleMarkers[2]!),
prepack: markerCommand(lifecycleMarkers[1]!),
prepare: markerCommand(lifecycleMarkers[0]!),
},
version: "1.0.0",
version: "2026.8.1-beta.1",
})}\n`,
"utf8",
);
writeFileSync(join(packageDir, "index.ts"), "export const inert = true;\n", "utf8");
writeFileSync(
join(packageDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: "inert" })}\n`,
`${JSON.stringify({ id: "generated" })}\n`,
"utf8",
);
writeFileSync(
packlistHelper,
'process.stdout.write(JSON.stringify(["index.ts", "openclaw.plugin.json", "package.json"]));\n',
join(packageDir, "build-assets.mjs"),
[
'import { mkdirSync, writeFileSync } from "node:fs";',
'mkdirSync("dist", { recursive: true });',
`writeFileSync("dist/generated.js", ${JSON.stringify(generatedCode)});`,
"",
].join("\n"),
"utf8",
);
writeFileSync(
trustedPackScript,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'repo_root="$2"',
'package_dir="$4"',
'cd "$repo_root/$package_dir"',
'asset_build="$(node -p "require(\'./package.json\').openclaw.assetScripts.build")"',
'bash -c "$asset_build"',
'npm pack --json --ignore-scripts --pack-destination "$OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR"',
"",
].join("\n"),
"utf8",
);
execFileSync("git", ["-C", candidateRoot, "add", "."]);
@@ -272,8 +263,10 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
const candidateSha = execFileSync("git", ["-C", candidateRoot, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
const outputDir = join(artifactRoot, `plugin-npm-security-package-${candidateSha}-generated`);
const toolingSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
mkdirSync(outputDir, { recursive: true });
const result = spawnSync(
process.execPath,
[
@@ -286,13 +279,13 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
"--candidate-sha",
candidateSha,
"--extension-id",
"inert",
"generated",
"--output-dir",
outputDir,
"--package-dir",
"extensions/inert",
"extensions/generated",
"--package-name",
"@openclaw/test-inert-pack",
"@openclaw/test-generated-pack",
"--tooling-sha",
toolingSha,
],
@@ -302,78 +295,41 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
env: {
...process.env,
NODE_ENV: "test",
OPENCLAW_PLUGIN_SECURITY_TEST_PACKLIST_HELPER: packlistHelper,
OPENCLAW_PLUGIN_SECURITY_TEST_PUBLISH_SCRIPT: trustedPackScript,
},
},
);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
for (const marker of Object.values(markers)) {
expect(readFileSync(generatedPath, "utf8")).toBe(generatedCode);
for (const marker of lifecycleMarkers) {
expect(existsSync(marker)).toBe(false);
}
const metadata = JSON.parse(
readFileSync(join(outputDir, "plugin-npm-security-artifact.json"), "utf8"),
) as { artifactKind?: unknown; tarballName?: unknown };
expect(metadata.artifactKind).toBe("inert-package-input");
expect(metadata.artifactKind).toBe("publication-equivalent-plugin-tarball");
expect(typeof metadata.tarballName).toBe("string");
const staged = stageScannerRelevantPluginTarballFiles(
join(outputDir, String(metadata.tarballName)),
);
try {
expect(staged.packedFiles).toContain("index.ts");
expect(staged.packedFiles.some((file) => file.startsWith("dist/"))).toBe(false);
} finally {
rmSync(staged.stageDir, { force: true, recursive: true });
}
});
it("rejects malformed, unsafe, duplicate, and excessive packlist entries", () => {
for (const malformed of [
null,
{},
1,
"",
"/absolute.js",
"dir\\file.js",
"../escape.js",
".hidden.js",
"node_modules/dependency.js",
"a".repeat(4097),
]) {
expect(() =>
parsePacklistFiles(JSON.stringify(["valid.js", malformed]), "@openclaw/test"),
).toThrow("entry 1 has an invalid path");
}
expect(() =>
parsePacklistFiles(JSON.stringify(["index.js", "index.js"]), "@openclaw/test"),
).toThrow("duplicate path");
expect(() =>
parsePacklistFiles(
JSON.stringify(Array.from({ length: 20_001 }, (_, index) => `file-${index}.js`)),
"@openclaw/test",
),
).toThrow("file-count limit");
});
it("preserves bounded packlist helper failure categories", async () => {
const root = tempDirs.make("openclaw-plugin-packlist-helper-limits-");
const packageDir = tempDirs.make("openclaw-plugin-packlist-helper-package-");
const timeoutHelper = join(root, "timeout.mjs");
const failedHelper = join(root, "failed.mjs");
writeFileSync(timeoutHelper, "setInterval(() => {}, 1_000);\n", "utf8");
writeFileSync(failedHelper, "process.exit(7);\n", "utf8");
await expect(
collectNpmPackedFiles(packageDir, "@openclaw/test-timeout", {
helperPath: timeoutHelper,
timeoutMs: 25,
}),
).rejects.toThrow("trusted packlist helper timed out");
await expect(
collectNpmPackedFiles(packageDir, "@openclaw/test-failed", {
helperPath: failedHelper,
}),
).rejects.toThrow("trusted packlist helper failed");
const expectedPackage = {
extensionId: "generated",
packageDir: "extensions/generated",
packageName: "@openclaw/test-generated-pack",
packageVersion: "2026.8.1-beta.1",
};
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha,
expectedPackages: [expectedPackage],
toolingSha,
});
expect(loaded.ingestionErrors).toEqual([]);
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
expect(scanned.scanErrors).toEqual([]);
expect(scanned.packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
line: 2,
path: "dist/generated.js",
ruleId: "dangerous-exec",
});
});
it("bounds manifests and rejects noncanonical or duplicate package identities", async () => {
@@ -402,24 +358,27 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
).rejects.toThrow("manifest exceeds the byte limit");
});
it("fails closed on truncated scans, source escapes, and tarball symlinks", () => {
it("fails closed on truncated scans, candidate package escapes, and tarball symlinks", () => {
expect(() => assertCompleteScannerSummary("@openclaw/test", { truncated: true })).toThrow(
"security scan reached its file limit",
);
expect(() =>
stageScannerRelevantPackedFiles(tempDirs.make("openclaw-plugin-npm-security-path-"), [
"../escape.ts",
]),
).toThrow("npm pack returned an unsafe path");
const candidateRoot = tempDirs.make("openclaw-plugin-npm-security-candidate-");
const outsideDir = tempDirs.make("openclaw-plugin-npm-security-outside-");
mkdirSync(join(candidateRoot, "extensions"), { recursive: true });
writeFileSync(
join(outsideDir, "package.json"),
`${JSON.stringify({ name: "@openclaw/escape", version: "1.0.0" })}\n`,
"utf8",
);
symlinkSync(outsideDir, join(candidateRoot, "extensions", "escape"));
expect(() => resolveCandidatePluginPackageDir(candidateRoot, "escape")).toThrow(
"package directory is not a real directory",
);
const packageDir = tempDirs.make("openclaw-plugin-npm-security-symlink-");
const outsideDir = tempDirs.make("openclaw-plugin-npm-security-outside-");
const outsideFile = join(outsideDir, "outside.ts");
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
symlinkSync(outsideFile, join(packageDir, "escape.ts"));
expect(() => stageScannerRelevantPackedFiles(packageDir, ["escape.ts"])).toThrow(
"not a regular file",
);
const artifact = writePluginArtifact({
extensionId: "symlink",
@@ -429,7 +388,13 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
symlinkSync(outsideFile, join(artifact.packageRoot, "escape.ts"));
execFileSync(
"tar",
["-czf", artifact.tarballPath, "-C", join(artifact.packageRoot, ".."), "source"],
[
"-czf",
artifact.tarballPath,
"-C",
join(artifact.packageRoot, ".."),
basename(artifact.packageRoot),
],
{ env: { ...process.env, COPYFILE_DISABLE: "1" } },
);
expect(() => stageScannerRelevantPluginTarballFiles(artifact.tarballPath)).toThrow();
@@ -521,7 +486,102 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expectedPackages: [],
toolingSha: TOOLING_SHA,
}),
).toThrow("does not match the trusted package plan");
).toThrow("unexpected entries");
});
it("retains valid package scans when a sibling artifact is malformed", async () => {
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-mixed-");
const valid = writePluginArtifact({
artifactRoot,
extensionId: "valid",
files: { "index.js": "export const value = 1;\n" },
packageName: "@openclaw/test-valid",
});
const malformed = writePluginArtifact({
artifactRoot,
extensionId: "malformed",
files: { "index.js": "export const value = 2;\n" },
packageName: "@openclaw/test-malformed",
});
writeFileSync(
join(malformed.artifact.artifactDir, "plugin-npm-security-artifact.json"),
"{not-json}\n",
"utf8",
);
const expectedPackages = [malformed.expectedPackage, valid.expectedPackage].toSorted(
(left, right) => (left.packageName < right.packageName ? -1 : 1),
);
const loaded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
toolingSha: TOOLING_SHA,
});
expect(loaded.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-valid",
]);
expect(loaded.ingestionErrors).toEqual([
"@openclaw/test-malformed: Plugin security artifact metadata is not valid JSON.",
]);
const scanned = await scanPublishablePluginPackages(loaded.artifacts);
expect(scanned.scanErrors).toEqual([]);
expect(scanned.packageResults.map((result) => result.packageName)).toEqual([
"@openclaw/test-valid",
]);
});
it("bounds aggregate compressed and expanded artifact bytes deterministically", () => {
const artifactRoot = tempDirs.make("openclaw-plugin-npm-security-aggregate-");
const alpha = writePluginArtifact({
artifactRoot,
extensionId: "alpha",
files: { "alpha.js": Buffer.alloc(256, 1) },
packageName: "@openclaw/test-alpha",
});
const beta = writePluginArtifact({
artifactRoot,
extensionId: "beta",
files: { "beta.js": Buffer.alloc(256, 2) },
packageName: "@openclaw/test-beta",
});
const expectedPackages = [alpha.expectedPackage, beta.expectedPackage];
const baseline = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
toolingSha: TOOLING_SHA,
});
expect(baseline.ingestionErrors).toEqual([]);
const compressed = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
limits: { maxCompressedBytes: baseline.artifacts[0]!.compressedBytes },
toolingSha: TOOLING_SHA,
});
expect(compressed.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-alpha",
]);
expect(compressed.ingestionErrors).toEqual([
"@openclaw/test-beta: aggregate compressed-byte limit exceeded.",
]);
const expanded = loadPluginNpmSecurityArtifacts({
artifactRoot,
candidateSha: CANDIDATE_SHA,
expectedPackages,
limits: { maxExpandedBytes: baseline.artifacts[0]!.expandedBytes },
toolingSha: TOOLING_SHA,
});
expect(expanded.artifacts.map((artifact) => artifact.packageName)).toEqual([
"@openclaw/test-alpha",
]);
expect(expanded.ingestionErrors).toEqual([
"@openclaw/test-beta: aggregate expanded-byte limit exceeded.",
]);
});
it("caps total findings and emits byte-identical bounded reports", () => {
@@ -555,19 +615,26 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
]);
});
it("writes sanitized exact-identity reports when the bounded scanner times out or OOMs", () => {
it("writes sanitized exact-identity reports for timeout, heap, and RSS failures", () => {
const root = tempDirs.make("openclaw-plugin-npm-security-runner-");
const timeoutChild = join(root, "timeout.mjs");
const oomChild = join(root, "oom.mjs");
const rssChild = join(root, "rss.mjs");
writeFileSync(timeoutChild, "await new Promise(() => {});\n", "utf8");
writeFileSync(
oomChild,
"const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n",
"utf8",
);
for (const [label, child, timeoutMs] of [
["timeout", timeoutChild, "25"],
["oom", oomChild, "10000"],
writeFileSync(
rssChild,
"globalThis.value = Buffer.alloc(64 * 1024 * 1024, 1);\nsetInterval(() => {}, 1_000);\n",
"utf8",
);
for (const [label, child, timeoutMs, heapMb, rssMb, expectedError] of [
["timeout", timeoutChild, "25", "128", "1024", "timed out"],
["oom", oomChild, "10000", "16", "1024", "exceeded its process limit"],
["rss", rssChild, "10000", "128", "16", "exceeded its RSS limit"],
] as const) {
const reportPath = join(root, `${label}.json`);
const result = spawnSync(
@@ -592,7 +659,8 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
...process.env,
NODE_ENV: "test",
OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD: child,
OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB: "16",
OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB: heapMb,
OPENCLAW_PLUGIN_SECURITY_RUNNER_RSS_MB: rssMb,
OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS: timeoutMs,
},
timeout: 15_000,
@@ -600,6 +668,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
);
const report = JSON.parse(readFileSync(reportPath, "utf8")) as {
candidateSha: string;
errors: string[];
toolingSha: string;
};
expect(result.status).toBe(1);
@@ -607,6 +676,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
candidateSha: CANDIDATE_SHA,
toolingSha: TOOLING_SHA,
});
expect(report.errors).toContainEqual(expect.stringContaining(expectedError));
expect(`${result.stdout}${result.stderr}${JSON.stringify(report)}`).not.toContain(root);
}
}, 30_000);
@@ -300,7 +300,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
(step: WorkflowStep) => step.name === "Install trusted scanner dependencies",
);
const runSecurityScan = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Scan inert plugin package inputs",
(step: WorkflowStep) => step.name === "Scan publication-equivalent plugin artifacts",
);
const uploadReport = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Upload plugin npm security scan report",
@@ -310,6 +310,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
);
const releaseWorkflow = readFullReleaseValidationWorkflow();
const releaseSource = readFileSync(".github/workflows/full-release-validation.yml", "utf8");
const pluginNpmReleaseSource = readFileSync(".github/workflows/plugin-npm-release.yml", "utf8");
const pluginDispatch = releaseWorkflow.jobs.plugin_prerelease.steps.find(
(step: WorkflowStep) => step.name === "Dispatch and monitor plugin prerelease",
);
@@ -368,15 +369,15 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
expect(securityScan.needs).not.toContain("preflight");
expect(securityPackage.permissions).toEqual({ contents: "read" });
expect(securityPackage.secrets).toBeUndefined();
expect(JSON.stringify(securityPackage)).not.toContain("plugin-npm-publish.sh");
expect(JSON.stringify(securityPackage)).not.toContain("plugin-npm-runtime-build");
expect(JSON.stringify(securityPackage)).not.toContain("generate-npm-package-lock");
expect(securityPrepareSource).not.toContain("plugin-npm-publish.sh");
expect(securityPrepareSource).not.toContain("plugin-npm-runtime-build");
expect(securityPrepareSource).not.toContain("generate-npm-package-lock");
expect(securityPrepareSource).not.toMatch(/\bnpmArgs:\s*\[\s*["'](?:ci|install)["']/u);
expect(securityPrepareSource).toContain('"--ignore-scripts"');
expect(securityPrepareSource).toContain('"--workspaces=false"');
expect(JSON.stringify(securityPackage)).not.toContain("id-token");
expect(JSON.stringify(securityPackage)).not.toContain("packages: write");
expect(JSON.stringify(securityPackage)).not.toContain("${{ secrets.");
expect(securityPrepareSource).toContain('"scripts", "plugin-npm-publish.sh"');
expect(securityPrepareSource).toContain('"--pack"');
expect(securityPrepareSource).toContain("shell: false");
expect(securityPrepareSource).toContain("resolveCandidatePluginPackageDir");
expect(pluginNpmReleaseSource).toContain("bash .release-tooling/scripts/plugin-npm-publish.sh");
expect(pluginNpmReleaseSource).toContain('--pack "${PACKAGE_DIR}"');
expect(nodeShard.needs).toEqual(["resolve-candidate", "preflight"]);
expect(runNodeShard?.run).toContain('spawnSync("pnpm", ["test", "--", ...configs]');
expect(pluginSource).not.toContain("npm-install-security-scan.release.test.ts");