fix(release): keep plugin security packing inert

This commit is contained in:
Vincent Koc
2026-08-20 21:06:51 -07:00
parent bf65391b7e
commit dc15c4f660
5 changed files with 181 additions and 33 deletions
+5 -5
View File
@@ -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 }}
+5 -1
View File
@@ -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<string, unknown>;
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(
+54 -25
View File
@@ -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<void> {
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<void> {
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<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
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,
+106 -1
View File
@@ -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",
@@ -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");