mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(release): bound plugin artifact downloads
This commit is contained in:
@@ -343,9 +343,10 @@ all-plugin runtime time on release-only product coverage.
|
||||
`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.
|
||||
bytes. A future publisher redesign must scan the exact final bytes and publish
|
||||
that identical digest before this can become a publication gate; the current
|
||||
publisher does not provide that guarantee. 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
|
||||
|
||||
@@ -426,12 +426,60 @@ jobs:
|
||||
- name: Install trusted scanner dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
|
||||
|
||||
- name: Download supplemental inert plugin inputs
|
||||
- name: Bound supplemental inert plugin input downloads
|
||||
id: artifact-download-plan
|
||||
continue-on-error: true
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }}
|
||||
EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
metadata="$RUNNER_TEMP/plugin-npm-security-artifact-api.json"
|
||||
plan="$RUNNER_TEMP/plugin-npm-security-artifact-download-plan.json"
|
||||
gh api --paginate --slurp \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2026-03-10" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100&direction=asc" \
|
||||
> "$metadata"
|
||||
node --import tsx scripts/plugin-npm-security-artifact-plan.mts \
|
||||
--artifact-metadata-json "$metadata" \
|
||||
--candidate-sha "$CANDIDATE_SHA" \
|
||||
--expected-packages-json "$EXPECTED_PACKAGES_JSON" \
|
||||
--output "$plan"
|
||||
artifact_ids="$(jq -r '[.artifacts[].id | tostring] | join(",")' "$plan")"
|
||||
echo "artifact_ids=$artifact_ids" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The pinned action validates server digests and downloads ID-bound artifacts
|
||||
# in batches of five only after the trusted metadata plan enforces byte bounds.
|
||||
- name: Download bounded supplemental inert plugin inputs
|
||||
id: download-bounded-artifacts
|
||||
if: steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-*
|
||||
artifact-ids: ${{ steps.artifact-download-plan.outputs.artifact_ids }}
|
||||
digest-mismatch: error
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/plugin-npm-security-packages
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.run_id }}
|
||||
|
||||
- name: Normalize single supplemental inert plugin input
|
||||
if: steps.download-bounded-artifacts.outcome == 'success'
|
||||
env:
|
||||
ARTIFACT_ROOT: ${{ runner.temp }}/plugin-npm-security-packages
|
||||
PLAN_PATH: ${{ runner.temp }}/plugin-npm-security-artifact-download-plan.json
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]] || exit 0
|
||||
artifact_name="$(jq -r '.artifacts[0].name' "$PLAN_PATH")"
|
||||
staging="$RUNNER_TEMP/plugin-npm-security-single-artifact"
|
||||
mkdir -p "$staging" "$ARTIFACT_ROOT/$artifact_name"
|
||||
find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$staging/" \;
|
||||
find "$staging" -mindepth 1 -maxdepth 1 -type f -exec mv {} "$ARTIFACT_ROOT/$artifact_name/" \;
|
||||
|
||||
- name: Scan supplemental inert plugin inputs
|
||||
env:
|
||||
|
||||
@@ -78,6 +78,7 @@ const repositoryScriptEntries = [
|
||||
"scripts/openclaw-release-clawhub-plan.ts!",
|
||||
"scripts/openclaw-release-clawhub-runtime-state.ts!",
|
||||
// Plugin Prerelease builds immutable package artifacts, then scans them in a bounded child.
|
||||
"scripts/plugin-npm-security-artifact-plan.mts!",
|
||||
"scripts/plugin-npm-security-prepare.mts!",
|
||||
"scripts/plugin-npm-security-scan-runner.mjs!",
|
||||
"scripts/plugin-npm-security-scan.mts!",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
isScannable,
|
||||
scanDirectoryWithSummary,
|
||||
@@ -81,6 +82,8 @@ export const MAX_PLUGIN_PACKAGE_MANIFEST_BYTES = 256 * 1024;
|
||||
export const MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE = 10_000;
|
||||
export const MAX_PLUGIN_SCAN_TOTAL_FINDINGS = 50_000;
|
||||
export const MAX_PLUGIN_SCAN_REPORT_BYTES = 1024 * 1024;
|
||||
export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES = 512 * 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;
|
||||
@@ -536,6 +539,104 @@ function artifactDirectoryName(candidateSha: string, extensionId: string): strin
|
||||
return `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${candidateSha}-${extensionId}`;
|
||||
}
|
||||
|
||||
export type PluginNpmSecurityArtifactDownloadPlan = {
|
||||
artifacts: Array<{
|
||||
digest: string;
|
||||
id: number;
|
||||
name: string;
|
||||
sizeInBytes: number;
|
||||
}>;
|
||||
expectedArtifactCount: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export function planPluginNpmSecurityArtifactDownloads(params: {
|
||||
artifactPages: unknown;
|
||||
candidateSha: string;
|
||||
expectedPackages: unknown;
|
||||
}): PluginNpmSecurityArtifactDownloadPlan {
|
||||
if (!/^[0-9a-f]{40}$/u.test(params.candidateSha)) {
|
||||
throw new Error("Plugin security artifact download candidate identity is invalid.");
|
||||
}
|
||||
const expectedPackages = parseExpectedPackages(params.expectedPackages);
|
||||
const expectedNames = new Set(
|
||||
expectedPackages.map((plugin) =>
|
||||
artifactDirectoryName(params.candidateSha, plugin.extensionId),
|
||||
),
|
||||
);
|
||||
const expectedPrefix = `${PLUGIN_SECURITY_ARTIFACT_PREFIX}${params.candidateSha}-`;
|
||||
if (
|
||||
!Array.isArray(params.artifactPages) ||
|
||||
params.artifactPages.length === 0 ||
|
||||
params.artifactPages.length > 100
|
||||
) {
|
||||
throw new Error("Plugin security artifact metadata pages are invalid.");
|
||||
}
|
||||
|
||||
const artifacts: PluginNpmSecurityArtifactDownloadPlan["artifacts"] = [];
|
||||
const seenIds = new Set<number>();
|
||||
const seenNames = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const page of params.artifactPages) {
|
||||
if (!isRecord(page) || !Array.isArray(page.artifacts) || page.artifacts.length > 100) {
|
||||
throw new Error("Plugin security artifact metadata page is invalid.");
|
||||
}
|
||||
for (const entry of page.artifacts) {
|
||||
if (!isRecord(entry) || typeof entry.name !== "string") {
|
||||
throw new Error("Plugin security artifact metadata entry is invalid.");
|
||||
}
|
||||
if (!entry.name.startsWith(expectedPrefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!expectedNames.has(entry.name)) {
|
||||
throw new Error("Plugin security artifact metadata contains an unexpected package.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(entry.id) ||
|
||||
(entry.id as number) <= 0 ||
|
||||
!Number.isSafeInteger(entry.size_in_bytes) ||
|
||||
(entry.size_in_bytes as number) <= 0 ||
|
||||
entry.expired !== false ||
|
||||
typeof entry.digest !== "string" ||
|
||||
!/^sha256:[0-9a-f]{64}$/u.test(entry.digest)
|
||||
) {
|
||||
throw new Error("Plugin security artifact metadata entry is invalid.");
|
||||
}
|
||||
const id = entry.id as number;
|
||||
const sizeInBytes = entry.size_in_bytes as number;
|
||||
if (seenIds.has(id) || seenNames.has(entry.name)) {
|
||||
throw new Error("Plugin security artifact metadata contains a duplicate package.");
|
||||
}
|
||||
if (sizeInBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES) {
|
||||
throw new Error("Plugin security artifact exceeds the pre-download byte limit.");
|
||||
}
|
||||
totalBytes += sizeInBytes;
|
||||
if (
|
||||
!Number.isSafeInteger(totalBytes) ||
|
||||
totalBytes > MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES
|
||||
) {
|
||||
throw new Error("Plugin security artifacts exceed the aggregate pre-download byte limit.");
|
||||
}
|
||||
seenIds.add(id);
|
||||
seenNames.add(entry.name);
|
||||
artifacts.push({
|
||||
digest: entry.digest,
|
||||
id,
|
||||
name: entry.name,
|
||||
sizeInBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (artifacts.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) {
|
||||
throw new Error("Plugin security artifact metadata exceeds the package-count limit.");
|
||||
}
|
||||
return {
|
||||
artifacts: artifacts.toSorted((left, right) => compareCodeUnits(left.name, right.name)),
|
||||
expectedArtifactCount: expectedPackages.length,
|
||||
totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeArtifactIngestionError(
|
||||
expectedPackage: PublishablePluginPackage,
|
||||
error: unknown,
|
||||
@@ -754,7 +855,7 @@ export function assertCompleteScannerSummary(
|
||||
}
|
||||
}
|
||||
|
||||
async function scanPublishablePluginArtifact(
|
||||
async function scanSupplementalInertPluginInput(
|
||||
plugin: PluginNpmSecurityArtifact,
|
||||
): Promise<ScanPackageResult> {
|
||||
const reviewedCriticalFindings: string[] = [];
|
||||
@@ -768,7 +869,7 @@ async function scanPublishablePluginArtifact(
|
||||
staged.inspection.packageManifest.version !== plugin.packageVersion ||
|
||||
staged.inspection.tarballSha256 !== plugin.tarballSha256
|
||||
) {
|
||||
throw new Error(`${plugin.packageName}: publication artifact identity mismatch.`);
|
||||
throw new Error(`${plugin.packageName}: supplemental inert package input identity mismatch.`);
|
||||
}
|
||||
for (const packedFile of staged.packedFiles) {
|
||||
expectedReviewedCriticalFindings.push(
|
||||
@@ -970,7 +1071,7 @@ export async function scanPublishablePluginPackages(
|
||||
plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.",
|
||||
);
|
||||
},
|
||||
tasks: packages.map((plugin) => () => scanPublishablePluginArtifact(plugin)),
|
||||
tasks: packages.map((plugin) => () => scanSupplementalInertPluginInput(plugin)),
|
||||
});
|
||||
return {
|
||||
packageResults: results.filter((result): result is ScanPackageResult => result !== undefined),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
planPluginNpmSecurityArtifactDownloads,
|
||||
type PluginNpmSecurityArtifactDownloadPlan,
|
||||
} from "./lib/plugin-npm-security-scan.mts";
|
||||
import { readBoundedRegularFile } from "./plugin-publication-artifact.mjs";
|
||||
|
||||
const MAX_ARTIFACT_METADATA_JSON_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024;
|
||||
|
||||
type ParsedArgs = {
|
||||
artifactMetadataPath: string;
|
||||
candidateSha: string;
|
||||
expectedPackages: unknown;
|
||||
outputPath: string;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const name = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
|
||||
throw new Error(`Invalid plugin security artifact plan argument near ${name}.`);
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const artifactMetadataPath = values.get("--artifact-metadata-json") ?? "";
|
||||
const candidateSha = values.get("--candidate-sha") ?? "";
|
||||
const expectedPackagesJson = values.get("--expected-packages-json") ?? "";
|
||||
const outputPath = values.get("--output") ?? "";
|
||||
if (
|
||||
!artifactMetadataPath ||
|
||||
!/^[0-9a-f]{40}$/u.test(candidateSha) ||
|
||||
!expectedPackagesJson ||
|
||||
Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES ||
|
||||
!outputPath
|
||||
) {
|
||||
throw new Error("Plugin security artifact plan received an invalid identity or path.");
|
||||
}
|
||||
return {
|
||||
artifactMetadataPath: resolve(artifactMetadataPath),
|
||||
candidateSha,
|
||||
expectedPackages: JSON.parse(expectedPackagesJson) as unknown,
|
||||
outputPath: resolve(outputPath),
|
||||
};
|
||||
}
|
||||
|
||||
function writePlan(outputPath: string, plan: PluginNpmSecurityArtifactDownloadPlan): void {
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(plan)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)): number {
|
||||
const args = parseArgs(argv);
|
||||
const artifactPages = JSON.parse(
|
||||
readBoundedRegularFile(args.artifactMetadataPath, {
|
||||
label: "Plugin security artifact API metadata",
|
||||
maxBytes: MAX_ARTIFACT_METADATA_JSON_BYTES,
|
||||
}).toString("utf8"),
|
||||
) as unknown;
|
||||
const plan = planPluginNpmSecurityArtifactDownloads({
|
||||
artifactPages,
|
||||
candidateSha: args.candidateSha,
|
||||
expectedPackages: args.expectedPackages,
|
||||
});
|
||||
writePlan(args.outputPath, plan);
|
||||
console.log(
|
||||
`Plugin security artifact download plan: ${plan.artifacts.length}/${plan.expectedArtifactCount} artifacts, ${plan.totalBytes} bytes.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error("[plugin-npm-security-artifact-plan] FAILED (exit 1)");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
const name = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
|
||||
throw new Error(`Invalid plugin npm security prepare argument near ${String(name)}.`);
|
||||
throw new Error(`Invalid plugin npm security prepare argument near ${name}.`);
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
@@ -166,8 +166,9 @@ async function preparePackage(args: ParsedArgs): Promise<void> {
|
||||
mkdirSync(args.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Supplemental qualification keeps candidate code inert. Publication builds
|
||||
// and scans its final artifact separately before any registry mutation.
|
||||
// This is supplemental inert checked-in npm input, never a final or
|
||||
// publication artifact. Exact-byte scanning is a future publisher redesign,
|
||||
// not a capability of this workflow.
|
||||
const npm = resolveNpmRunner({
|
||||
env: {
|
||||
CI: "1",
|
||||
|
||||
@@ -355,7 +355,6 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => {
|
||||
"package directory is not a real directory",
|
||||
);
|
||||
|
||||
const packageDir = tempDirs.make("openclaw-plugin-npm-security-symlink-");
|
||||
const outsideFile = join(outsideDir, "outside.ts");
|
||||
writeFileSync(outsideFile, "export const value = 1;\n", "utf8");
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts";
|
||||
import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS } from "../../scripts/lib/docker-e2e-scenarios.mts";
|
||||
import {
|
||||
MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES,
|
||||
MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES,
|
||||
MAX_PUBLISHABLE_PLUGIN_PACKAGES,
|
||||
planPluginNpmSecurityArtifactDownloads,
|
||||
} from "../../scripts/lib/plugin-npm-security-scan.mts";
|
||||
import {
|
||||
PLUGIN_PRERELEASE_REQUIRED_SURFACES,
|
||||
assertPluginPrereleaseTestPlanComplete,
|
||||
@@ -21,7 +27,10 @@ const CHECKOUT_V6 = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10";
|
||||
const UPLOAD_ARTIFACT_V7 = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
|
||||
|
||||
type WorkflowStep = {
|
||||
"continue-on-error"?: boolean;
|
||||
env?: Record<string, string>;
|
||||
id?: string;
|
||||
if?: string;
|
||||
name?: string;
|
||||
run?: string;
|
||||
uses?: string;
|
||||
@@ -282,6 +291,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
const pluginWorkflow = readPluginPrereleaseWorkflow();
|
||||
const pluginSource = readFileSync(".github/workflows/plugin-prerelease.yml", "utf8");
|
||||
const securityPrepareSource = readFileSync("scripts/plugin-npm-security-prepare.mts", "utf8");
|
||||
const securityScannerSource = readFileSync("scripts/lib/plugin-npm-security-scan.mts", "utf8");
|
||||
const testingSkillSource = readFileSync(".agents/skills/openclaw-testing/SKILL.md", "utf8");
|
||||
const resolver = pluginWorkflow.jobs["resolve-candidate"];
|
||||
const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"];
|
||||
const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"];
|
||||
@@ -302,6 +313,15 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
const runSecurityScan = securityScan.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Scan supplemental inert plugin inputs",
|
||||
);
|
||||
const artifactDownloadPlan = securityScan.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Bound supplemental inert plugin input downloads",
|
||||
);
|
||||
const downloadArtifacts = securityScan.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Download bounded supplemental inert plugin inputs",
|
||||
);
|
||||
const normalizeSingleArtifact = securityScan.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Normalize single supplemental inert plugin input",
|
||||
);
|
||||
const uploadReport = securityScan.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Upload plugin npm security scan report",
|
||||
);
|
||||
@@ -350,6 +370,35 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
expect(runSecurityScan?.run).toContain("--artifact-root");
|
||||
expect(runSecurityScan?.run).not.toContain("--candidate-root");
|
||||
expect(runSecurityScan?.run).toContain('--candidate-sha "$CANDIDATE_SHA"');
|
||||
expect(artifactDownloadPlan).toMatchObject({
|
||||
"continue-on-error": true,
|
||||
id: "artifact-download-plan",
|
||||
});
|
||||
expect(artifactDownloadPlan?.run).toContain("gh api --paginate --slurp");
|
||||
expect(artifactDownloadPlan?.run).toContain("scripts/plugin-npm-security-artifact-plan.mts");
|
||||
expect(artifactDownloadPlan?.run).toContain('echo "artifact_ids=$artifact_ids"');
|
||||
expect(downloadArtifacts).toMatchObject({
|
||||
"continue-on-error": true,
|
||||
id: "download-bounded-artifacts",
|
||||
if: "steps.artifact-download-plan.outcome == 'success' && steps.artifact-download-plan.outputs.artifact_ids != ''",
|
||||
uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
|
||||
with: {
|
||||
"artifact-ids": "${{ steps.artifact-download-plan.outputs.artifact_ids }}",
|
||||
"digest-mismatch": "error",
|
||||
"github-token": "${{ github.token }}",
|
||||
path: "${{ runner.temp }}/plugin-npm-security-packages",
|
||||
repository: "${{ github.repository }}",
|
||||
"run-id": "${{ github.run_id }}",
|
||||
},
|
||||
});
|
||||
expect(downloadArtifacts?.with).not.toHaveProperty("pattern");
|
||||
expect(normalizeSingleArtifact?.if).toBe(
|
||||
"steps.download-bounded-artifacts.outcome == 'success'",
|
||||
);
|
||||
expect(normalizeSingleArtifact?.run).toContain(
|
||||
`[[ "$(jq '.artifacts | length' "$PLAN_PATH")" == "1" ]]`,
|
||||
);
|
||||
expect(normalizeSingleArtifact?.run).toContain('"$ARTIFACT_ROOT/$artifact_name/"');
|
||||
expect(uploadReport).toMatchObject({
|
||||
if: "always()",
|
||||
uses: UPLOAD_ARTIFACT_V7,
|
||||
@@ -386,6 +435,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
);
|
||||
expect(securityPrepareSource).not.toContain("GITHUB_OUTPUT: process.env.GITHUB_OUTPUT");
|
||||
expect(securityPrepareSource).toContain("resolveCandidatePluginPackageDir");
|
||||
expect(securityPrepareSource).toContain("supplemental inert checked-in npm input");
|
||||
expect(securityPrepareSource).toContain("future publisher redesign");
|
||||
expect(securityPrepareSource).not.toContain("scans its final artifact separately");
|
||||
expect(securityScannerSource).toContain("scanSupplementalInertPluginInput");
|
||||
expect(securityScannerSource).toContain("supplemental inert package input identity mismatch");
|
||||
expect(securityScannerSource).not.toContain("publication artifact identity mismatch");
|
||||
expect(testingSkillSource).toContain(
|
||||
"A future publisher redesign must scan the exact final bytes",
|
||||
);
|
||||
expect(testingSkillSource).toContain("the current\npublisher does not provide that guarantee");
|
||||
expect(testingSkillSource).not.toContain(
|
||||
"The publication workflow must independently scan its exact final artifact",
|
||||
);
|
||||
expect(pluginNpmReleaseSource).toContain("plugin-publication-artifact.mjs verify");
|
||||
expect(nodeShard.needs).toEqual(["resolve-candidate", "preflight"]);
|
||||
expect(runNodeShard?.run).toContain('spawnSync("pnpm", ["test", "--", ...configs]');
|
||||
@@ -396,6 +458,72 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
expect(pluginDispatch?.run).not.toContain("node_test_exclude_patterns_json");
|
||||
});
|
||||
|
||||
it("rejects oversized plugin security artifacts before the workflow download action", () => {
|
||||
const candidateSha = "1".repeat(40);
|
||||
const packageEntry = (index: number) => {
|
||||
const id = `plugin-${String(index).padStart(3, "0")}`;
|
||||
return {
|
||||
extensionId: id,
|
||||
packageDir: `extensions/${id}`,
|
||||
packageName: `@openclaw/${id}`,
|
||||
packageVersion: "1.0.0",
|
||||
};
|
||||
};
|
||||
const artifactEntry = (
|
||||
plugin: ReturnType<typeof packageEntry>,
|
||||
index: number,
|
||||
sizeInBytes: number,
|
||||
) => ({
|
||||
digest: `sha256:${String(index + 1).padStart(64, "0")}`,
|
||||
expired: false,
|
||||
id: index + 1,
|
||||
name: `plugin-npm-security-package-${candidateSha}-${plugin.extensionId}`,
|
||||
size_in_bytes: sizeInBytes,
|
||||
});
|
||||
|
||||
const onePackage = [packageEntry(0)];
|
||||
expect(() =>
|
||||
planPluginNpmSecurityArtifactDownloads({
|
||||
artifactPages: [
|
||||
{
|
||||
artifacts: [
|
||||
artifactEntry(onePackage[0]!, 0, MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_BYTES + 1),
|
||||
],
|
||||
},
|
||||
],
|
||||
candidateSha,
|
||||
expectedPackages: onePackage,
|
||||
}),
|
||||
).toThrow("pre-download byte limit");
|
||||
|
||||
const aggregatePackages = Array.from({ length: 5 }, (_, index) => packageEntry(index));
|
||||
const aggregateArtifactBytes =
|
||||
Math.floor(MAX_PLUGIN_SECURITY_WORKFLOW_ARTIFACT_TOTAL_BYTES / aggregatePackages.length) + 1;
|
||||
expect(() =>
|
||||
planPluginNpmSecurityArtifactDownloads({
|
||||
artifactPages: [
|
||||
{
|
||||
artifacts: aggregatePackages.map((plugin, index) =>
|
||||
artifactEntry(plugin, index, aggregateArtifactBytes),
|
||||
),
|
||||
},
|
||||
],
|
||||
candidateSha,
|
||||
expectedPackages: aggregatePackages,
|
||||
}),
|
||||
).toThrow("aggregate pre-download byte limit");
|
||||
|
||||
expect(() =>
|
||||
planPluginNpmSecurityArtifactDownloads({
|
||||
artifactPages: [{ artifacts: [] }],
|
||||
candidateSha,
|
||||
expectedPackages: Array.from({ length: MAX_PUBLISHABLE_PLUGIN_PACKAGES + 1 }, (_, index) =>
|
||||
packageEntry(index),
|
||||
),
|
||||
}),
|
||||
).toThrow("Expected plugin package inventory is invalid");
|
||||
});
|
||||
|
||||
it("keeps late candidate GITHUB_OUTPUT writes outside trusted candidate identity", () => {
|
||||
const pluginWorkflow = readPluginPrereleaseWorkflow();
|
||||
const preflight = pluginWorkflow.jobs.preflight;
|
||||
@@ -427,7 +555,10 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
readFileSync(githubOutput, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.split("=", 2)),
|
||||
.map((line): [string, string] => {
|
||||
const [key = "", value = ""] = line.split("=", 2);
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lateCandidateOutput.get("checkout_revision")).toBe("f".repeat(40));
|
||||
|
||||
Reference in New Issue
Block a user