diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index 7d666faab229..d0ce013e0190 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -359,13 +359,13 @@ jobs: persist-credentials: false submodules: false - - name: Setup trusted packaging environment + - name: Setup trusted inert pack environment uses: ./.github/actions/setup-node-env with: node-version: "24.x" install-bun: "false" - - name: Prepare immutable plugin tarball + - name: Pack inert plugin package input 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 immutable plugin tarball + - name: Upload inert plugin package input 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 immutable plugin tarballs + - name: Download inert plugin package inputs 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 immutable plugin tarballs + - name: Scan inert plugin package inputs env: CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }} EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }} diff --git a/scripts/lib/plugin-npm-security-scan.mts b/scripts/lib/plugin-npm-security-scan.mts index e6f5cfc8e1ca..9c64cb4cc6e7 100644 --- a/scripts/lib/plugin-npm-security-scan.mts +++ b/scripts/lib/plugin-npm-security-scan.mts @@ -51,6 +51,7 @@ export type ScanPackageResult = { }; type PluginNpmSecurityArtifact = PublishablePluginPackage & { + artifactKind: "inert-package-input"; artifactDir: string; candidateSha: string; tarballPath: string; @@ -561,6 +562,7 @@ function readPluginSecurityArtifact( } const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as Record; const expectedKeys = [ + "artifactKind", "candidateSha", "extensionId", "packageDir", @@ -572,6 +574,7 @@ function readPluginSecurityArtifact( "toolingSha", ]; if ( + metadata.artifactKind !== "inert-package-input" || metadata.schemaVersion !== 1 || JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys) ) { @@ -624,6 +627,7 @@ function readPluginSecurityArtifact( throw new Error(`${packageName}: plugin tarball is outside the byte limit.`); } return { + artifactKind: "inert-package-input", artifactDir, candidateSha: expectedCandidateSha, extensionId, @@ -783,7 +787,7 @@ async function scanPublishablePluginArtifact( staged.inspection.packageManifest.version !== plugin.packageVersion || staged.inspection.tarballSha256 !== plugin.tarballSha256 ) { - throw new Error(`${plugin.packageName}: immutable plugin tarball identity mismatch.`); + throw new Error(`${plugin.packageName}: inert package input identity mismatch.`); } for (const packedFile of staged.packedFiles) { expectedReviewedCriticalFindings.push( diff --git a/scripts/plugin-npm-security-prepare.mts b/scripts/plugin-npm-security-prepare.mts index a0e65c4ebad5..c408d034680b 100644 --- a/scripts/plugin-npm-security-prepare.mts +++ b/scripts/plugin-npm-security-prepare.mts @@ -15,6 +15,7 @@ import { listPublishablePluginPackages, type PublishablePluginPackage, } from "./lib/plugin-npm-security-scan.mts"; +import { resolveNpmRunner } from "./npm-runner.mts"; import { inspectPackageTarballBytes, readBoundedRegularFile, @@ -156,7 +157,15 @@ async function preparePackage(args: ParsedArgs): Promise { throw new Error("Selected plugin package is absent from the trusted package plan."); } - await collectNpmPackedFiles(selected.packageDir, selected.packageName); + 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 } : {}, + ); if (existsSync(args.outputDir)) { if (readdirSync(args.outputDir).length !== 0) { throw new Error("Plugin security artifact output directory must be empty."); @@ -165,30 +174,39 @@ async function preparePackage(args: ParsedArgs): Promise { mkdirSync(args.outputDir, { recursive: true }); } - const result = spawnSync( - "bash", - [ - join(toolingRoot, "scripts/plugin-npm-publish.sh"), - "--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, - stdio: ["ignore", "pipe", "inherit"], - timeout: PACK_TIMEOUT_MS, + // 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", }, - ); + 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 plugin packaging failed.`); + throw new Error(`${selected.packageName}: trusted inert plugin pack failed.`); } const packEntries = parsePackOutput(result.stdout); if (packEntries.length !== 1) { @@ -210,17 +228,28 @@ async function preparePackage(args: ParsedArgs): Promise { 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 + inspection.packageManifest.version !== selected.packageVersion || + JSON.stringify(packedFiles) !== JSON.stringify(expectedPackedFiles) ) { - throw new Error(`${selected.packageName}: prepared tarball identity mismatch.`); + throw new Error(`${selected.packageName}: inert package input 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", candidateSha: args.candidateSha, extensionId: selected.extensionId, packageDir: args.packageDir, diff --git a/test/scripts/plugin-npm-security-scan.test.ts b/test/scripts/plugin-npm-security-scan.test.ts index 9ac75601dc37..c9ad959948d5 100644 --- a/test/scripts/plugin-npm-security-scan.test.ts +++ b/test/scripts/plugin-npm-security-scan.test.ts @@ -95,6 +95,7 @@ function writePluginArtifact(params: { writeFileSync( join(artifactDir, "plugin-npm-security-artifact.json"), `${JSON.stringify({ + artifactKind: "inert-package-input", candidateSha: CANDIDATE_SHA, extensionId: params.extensionId, packageDir: `extensions/${params.extensionId}`, @@ -109,6 +110,7 @@ function writePluginArtifact(params: { ); return { artifact: { + artifactKind: "inert-package-input" as const, artifactDir, candidateSha: CANDIDATE_SHA, extensionId: params.extensionId, @@ -222,6 +224,109 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { } }); + 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`), + ]), + ); + initGitRepo(candidateRoot); + mkdirSync(packageDir, { recursive: true }); + const markerCommand = (marker: string) => + `node -e "require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran')"`; + writeFileSync( + join(packageDir, "package.json"), + `${JSON.stringify({ + name: "@openclaw/test-inert-pack", + openclaw: { + assetScripts: { build: markerCommand(markers.asset!) }, + release: { publishToNpm: true }, + }, + scripts: { + postpack: markerCommand(markers.postpack!), + prepack: markerCommand(markers.prepack!), + prepare: markerCommand(markers.prepare!), + }, + version: "1.0.0", + })}\n`, + "utf8", + ); + writeFileSync(join(packageDir, "index.ts"), "export const inert = true;\n", "utf8"); + writeFileSync( + join(packageDir, "openclaw.plugin.json"), + `${JSON.stringify({ id: "inert" })}\n`, + "utf8", + ); + writeFileSync( + packlistHelper, + 'process.stdout.write(JSON.stringify(["index.ts", "openclaw.plugin.json", "package.json"]));\n', + "utf8", + ); + execFileSync("git", ["-C", candidateRoot, "add", "."]); + execFileSync("git", ["-C", candidateRoot, "commit", "--quiet", "-m", "fixture"]); + const candidateSha = execFileSync("git", ["-C", candidateRoot, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + const toolingSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/plugin-npm-security-prepare.mts", + "prepare", + "--candidate-root", + candidateRoot, + "--candidate-sha", + candidateSha, + "--extension-id", + "inert", + "--output-dir", + outputDir, + "--package-dir", + "extensions/inert", + "--package-name", + "@openclaw/test-inert-pack", + "--tooling-sha", + toolingSha, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NODE_ENV: "test", + OPENCLAW_PLUGIN_SECURITY_TEST_PACKLIST_HELPER: packlistHelper, + }, + }, + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + for (const marker of Object.values(markers)) { + 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(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, @@ -351,7 +456,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { expect(normalizePackedFindingPath("dist/service-malware.js")).toBe("dist/service-malware.js"); }); - it("finds malicious final-artifact code, ignores candidate scanner replacements, and fails slow", async () => { + it("finds malicious packed input code, ignores candidate scanner replacements, and fails slow", async () => { const marker = join(tempDirs.make("openclaw-plugin-npm-security-marker-"), "ran"); const malicious = writePluginArtifact({ extensionId: "malicious", diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 4179f0bd4310..86a4fc4ea27c 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -281,6 +281,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { it("keeps the trusted security scanner outside the candidate test process", () => { const pluginWorkflow = readPluginPrereleaseWorkflow(); const pluginSource = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8"); + const securityPrepareSource = readFileSync("scripts/plugin-npm-security-prepare.mts", "utf8"); const resolver = pluginWorkflow.jobs["resolve-candidate"]; const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"]; const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"]; @@ -299,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 immutable plugin tarballs", + (step: WorkflowStep) => step.name === "Scan inert plugin package inputs", ); const uploadReport = securityScan.steps.find( (step: WorkflowStep) => step.name === "Upload plugin npm security scan report", @@ -367,6 +368,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(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");