mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): keep plugin security packing inert
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user