fix(release): keep prerelease scanner supplemental

This commit is contained in:
Vincent Koc
2026-08-20 22:00:09 -07:00
parent 60ab3c666e
commit a313476fa2
9 changed files with 115 additions and 109 deletions
+6 -5
View File
@@ -340,11 +340,12 @@ lanes are intentionally reserved for the separate `Plugin Prerelease` child so
PRs, main pushes, and ad hoc broad CI checks do not spend Docker/package time or
all-plugin runtime time on release-only product coverage.
`Plugin Prerelease` may execute candidate code only in secretless packaging
jobs. The trusted scanner treats uploaded immutable tarballs as inert data and
scans the exact post-build bytes; publication must consume those verified bytes
or require an identical digest. Artifact ingestion stays fail-slow so one
malformed package records its own error without hiding other package reports.
`Plugin Prerelease` performs a supplemental scan of checked-in npm package input
as inert data; it never runs candidate lifecycle, asset, build, install, or
replacement scanner code. This scan does not approve post-build or publication
bytes. The publication workflow must independently scan its exact final artifact
and publish the same digest. Ingestion stays fail-slow so one malformed package
cannot hide other package reports.
Use one operator, one transition-only watcher, and at most one investigator for
the current failed surface. Parent timeout or cancellation leaves adopted exact
+5 -5
View File
@@ -359,13 +359,13 @@ jobs:
persist-credentials: false
submodules: false
- name: Setup candidate package build environment
- name: Setup trusted inert pack environment
uses: ./.github/actions/setup-node-env
with:
node-version: "24.x"
install-bun: "false"
- name: Build publication-equivalent plugin artifact
- name: Pack supplemental inert plugin 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 publication-equivalent plugin artifact
- name: Upload supplemental inert plugin 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 publication-equivalent plugin artifacts
- name: Download supplemental inert plugin 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 publication-equivalent plugin artifacts
- name: Scan supplemental inert plugin inputs
env:
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
+1 -1
View File
@@ -7,7 +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.
- **Plugin release security scan:** add a trusted supplemental scan of inert npm plugin package inputs without running candidate code, with bounded fail-slow handling for 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.
+6 -7
View File
@@ -48,12 +48,11 @@ export type ScanPackageResult = {
};
type PluginNpmSecurityArtifact = PublishablePluginPackage & {
artifactKind: "publication-equivalent-plugin-tarball";
artifactKind: "supplemental-inert-package-input";
artifactDir: string;
candidateSha: string;
compressedBytes: number;
expandedBytes: number;
packOwner: "scripts/plugin-npm-publish.sh";
tarballPath: string;
tarballSha256: string;
toolingSha: string;
@@ -64,6 +63,7 @@ export type PluginNpmSecurityScanReport = {
errors: string[];
layout: string | null;
packages: ScanPackageResult[];
scanScope: "supplemental-inert-package-input";
schemaVersion: 1;
status: "pass" | "fail";
summary: {
@@ -421,7 +421,6 @@ function readPluginSecurityArtifact(
"artifactKind",
"candidateSha",
"extensionId",
"packOwner",
"packageDir",
"packageName",
"packageVersion",
@@ -431,8 +430,7 @@ function readPluginSecurityArtifact(
"toolingSha",
];
if (
metadata.artifactKind !== "publication-equivalent-plugin-tarball" ||
metadata.packOwner !== "scripts/plugin-npm-publish.sh" ||
metadata.artifactKind !== "supplemental-inert-package-input" ||
metadata.schemaVersion !== 1 ||
JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys)
) {
@@ -509,13 +507,12 @@ function readPluginSecurityArtifact(
throw new Error("Plugin security artifact tarball identity is invalid.");
}
return {
artifactKind: "publication-equivalent-plugin-tarball",
artifactKind: "supplemental-inert-package-input",
artifactDir,
candidateSha: expectedCandidateSha,
compressedBytes: tarballStat.size,
expandedBytes: inspection.totalFileBytes,
extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir,
packageName,
packageVersion,
@@ -912,6 +909,7 @@ export function buildPluginNpmSecurityScanReport(params: {
errors: sortStrings(errors),
layout: layout?.id ?? null,
packages: sortedPackages,
scanScope: "supplemental-inert-package-input",
schemaVersion: 1,
status: errors.length === 0 ? "pass" : "fail",
summary: {
@@ -937,6 +935,7 @@ export function constrainPluginNpmSecurityScanReport(
errors: ["Plugin npm security scan report exceeded the byte limit."],
layout: null,
packages: [],
scanScope: "supplemental-inert-package-input",
schemaVersion: 1,
status: "fail",
summary: report.summary,
+41 -27
View File
@@ -7,6 +7,7 @@ import {
realpathSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts";
@@ -15,6 +16,7 @@ import {
resolveCandidatePluginPackageDir,
type PublishablePluginPackage,
} from "./lib/plugin-npm-security-scan.mts";
import { resolveNpmRunner } from "./npm-runner.mts";
import {
inspectPackageTarballBytes,
readBoundedRegularFile,
@@ -164,32 +166,45 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
mkdirSync(args.outputDir, { recursive: true });
}
// 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,
// Supplemental qualification keeps candidate code inert. Publication builds
// and scans its final artifact separately before any registry mutation.
const npm = resolveNpmRunner({
env: {
CI: "1",
HOME: tmpdir(),
NODE_OPTIONS: "--max-old-space-size=512",
NPM_CONFIG_AUDIT: "false",
NPM_CONFIG_FUND: "false",
NPM_CONFIG_GLOBALCONFIG: "/dev/null",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
NPM_CONFIG_PROVENANCE: "false",
NPM_CONFIG_USERCONFIG: join(tmpdir(), "openclaw-plugin-security-empty-npmrc"),
NPM_CONFIG_WORKSPACES: "false",
PATH: process.env.PATH,
TMPDIR: tmpdir(),
},
);
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,
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}: publication-equivalent plugin pack failed.`);
throw new Error(`${selected.packageName}: trusted inert plugin pack failed.`);
}
const packEntries = parsePackOutput(result.stdout);
if (packEntries.length !== 1) {
@@ -215,17 +230,16 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
inspection.packageManifest.name !== selected.packageName ||
inspection.packageManifest.version !== selected.packageVersion
) {
throw new Error(`${selected.packageName}: publication-equivalent package 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: "publication-equivalent-plugin-tarball",
artifactKind: "supplemental-inert-package-input",
candidateSha: args.candidateSha,
extensionId: selected.extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir: args.packageDir,
packageName: selected.packageName,
packageVersion: selected.packageVersion,
@@ -106,6 +106,7 @@ function compactFailureReport(args, category) {
errors: [`Plugin npm security scanner ${category}.`],
layout: null,
packages: [],
scanScope: "supplemental-inert-package-input",
schemaVersion: 1,
status: "fail",
summary: {
@@ -140,6 +141,7 @@ function existingReportStatus(args) {
report?.candidateSha === args.candidateSha &&
Array.isArray(report?.errors) &&
Array.isArray(report?.packages) &&
report?.scanScope === "supplemental-inert-package-input" &&
(report?.status === "pass" || report?.status === "fail") &&
typeof report?.summary === "object" &&
report?.toolingSha === args.toolingSha &&
+1
View File
@@ -95,6 +95,7 @@ function failureReport(args: ParsedArgs, message: string): PluginNpmSecurityScan
errors: [message],
layout: null,
packages: [],
scanScope: "supplemental-inert-package-input",
schemaVersion: 1,
status: "fail",
summary: {
+38 -58
View File
@@ -101,10 +101,9 @@ function writePluginArtifact(params: {
writeFileSync(
join(artifactDir, "plugin-npm-security-artifact.json"),
`${JSON.stringify({
artifactKind: "publication-equivalent-plugin-tarball",
artifactKind: "supplemental-inert-package-input",
candidateSha: CANDIDATE_SHA,
extensionId: params.extensionId,
packOwner: "scripts/plugin-npm-publish.sh",
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
@@ -117,7 +116,7 @@ function writePluginArtifact(params: {
);
return {
artifact: {
artifactKind: "publication-equivalent-plugin-tarball" as const,
artifactKind: "supplemental-inert-package-input" as const,
artifactDir,
candidateSha: CANDIDATE_SHA,
compressedBytes: readFileSync(tarballPath).byteLength,
@@ -126,7 +125,6 @@ function writePluginArtifact(params: {
0,
),
extensionId: params.extensionId,
packOwner: "scripts/plugin-npm-publish.sh" as const,
packageDir: `extensions/${params.extensionId}`,
packageName: params.packageName,
packageVersion,
@@ -192,37 +190,37 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined();
});
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(candidateRoot, `${name}-ran`),
it("scans checked-in malicious code without running candidate hooks or helpers", async () => {
const candidateRoot = tempDirs.make("openclaw-plugin-security-inert-pack-");
const artifactRoot = tempDirs.make("openclaw-plugin-security-inert-artifacts-");
const packageDir = join(candidateRoot, "extensions", "inert");
const executionMarkers = ["asset", "prepare", "prepack", "postpack", "replacement"].map(
(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')"`;
const generatedCode = 'const { execSync } = require("node:child_process");\nexecSync("id");\n';
const assetBuild = "node build-assets.mjs";
const maliciousCode = 'const { execSync } = require("node:child_process");\nexecSync("id");\n';
writeFileSync(
join(packageDir, "package.json"),
`${JSON.stringify({
files: ["dist", "openclaw.plugin.json", "package.json"],
name: "@openclaw/test-generated-pack",
files: [
"build-assets.mjs",
"index.js",
"openclaw.plugin.json",
"package.json",
"plugin-npm-security-scan.mjs",
],
name: "@openclaw/test-inert-pack",
openclaw: {
assetScripts: { build: assetBuild },
assetScripts: { build: markerCommand(executionMarkers[0]!) },
release: { publishToNpm: true },
},
scripts: {
postpack: markerCommand(lifecycleMarkers[2]!),
prepack: markerCommand(lifecycleMarkers[1]!),
prepare: markerCommand(lifecycleMarkers[0]!),
postpack: markerCommand(executionMarkers[3]!),
prepack: markerCommand(executionMarkers[2]!),
prepare: markerCommand(executionMarkers[1]!),
},
version: "2026.8.1-beta.1",
})}\n`,
@@ -230,32 +228,18 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
);
writeFileSync(
join(packageDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: "generated" })}\n`,
`${JSON.stringify({ id: "inert" })}\n`,
"utf8",
);
writeFileSync(join(packageDir, "index.js"), maliciousCode, "utf8");
writeFileSync(
join(packageDir, "build-assets.mjs"),
[
'import { mkdirSync, writeFileSync } from "node:fs";',
'mkdirSync("dist", { recursive: true });',
`writeFileSync("dist/generated.js", ${JSON.stringify(generatedCode)});`,
"",
].join("\n"),
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[0])}, "ran");\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"),
join(packageDir, "plugin-npm-security-scan.mjs"),
`require("node:fs").writeFileSync(${JSON.stringify(executionMarkers[4])}, "ran");\n`,
"utf8",
);
execFileSync("git", ["-C", candidateRoot, "add", "."]);
@@ -263,7 +247,7 @@ 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 outputDir = join(artifactRoot, `plugin-npm-security-package-${candidateSha}-inert`);
const toolingSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
mkdirSync(outputDir, { recursive: true });
@@ -279,41 +263,36 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
"--candidate-sha",
candidateSha,
"--extension-id",
"generated",
"inert",
"--output-dir",
outputDir,
"--package-dir",
"extensions/generated",
"extensions/inert",
"--package-name",
"@openclaw/test-generated-pack",
"@openclaw/test-inert-pack",
"--tooling-sha",
toolingSha,
],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
NODE_ENV: "test",
OPENCLAW_PLUGIN_SECURITY_TEST_PUBLISH_SCRIPT: trustedPackScript,
},
env: { ...process.env, NODE_ENV: "test" },
},
);
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(readFileSync(generatedPath, "utf8")).toBe(generatedCode);
for (const marker of lifecycleMarkers) {
for (const marker of executionMarkers) {
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("publication-equivalent-plugin-tarball");
expect(metadata.artifactKind).toBe("supplemental-inert-package-input");
expect(typeof metadata.tarballName).toBe("string");
const expectedPackage = {
extensionId: "generated",
packageDir: "extensions/generated",
packageName: "@openclaw/test-generated-pack",
extensionId: "inert",
packageDir: "extensions/inert",
packageName: "@openclaw/test-inert-pack",
packageVersion: "2026.8.1-beta.1",
};
const loaded = loadPluginNpmSecurityArtifacts({
@@ -327,7 +306,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expect(scanned.scanErrors).toEqual([]);
expect(scanned.packageResults[0]?.unexpectedCriticalFindings).toContainEqual({
line: 2,
path: "dist/generated.js",
path: "index.js",
ruleId: "dangerous-exec",
});
});
@@ -600,6 +579,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
expect(report.errors).toContain(
"Plugin npm security scan exceeded the total finding-count limit.",
);
expect(report.scanScope).toBe("supplemental-inert-package-input");
expect(JSON.stringify(report)).toBe(
JSON.stringify(
buildPluginNpmSecurityScanReport({
@@ -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 publication-equivalent plugin artifacts",
(step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs",
);
const uploadReport = securityScan.steps.find(
(step: WorkflowStep) => step.name === "Upload plugin npm security scan report",
@@ -372,12 +372,21 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
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(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.toContain("...process.env");
expect(securityPrepareSource).toContain('"--ignore-scripts"');
expect(securityPrepareSource).toContain('"--workspaces=false"');
expect(securityPrepareSource).toContain(
'NPM_CONFIG_USERCONFIG: join(tmpdir(), "openclaw-plugin-security-empty-npmrc")',
);
expect(securityPrepareSource).not.toContain("GITHUB_OUTPUT: process.env.GITHUB_OUTPUT");
expect(securityPrepareSource).toContain("resolveCandidatePluginPackageDir");
expect(pluginNpmReleaseSource).toContain("bash .release-tooling/scripts/plugin-npm-publish.sh");
expect(pluginNpmReleaseSource).toContain('--pack "${PACKAGE_DIR}"');
expect(pluginNpmReleaseSource).toContain("plugin-publication-artifact.mjs verify");
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");