diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index dc7cb39c847c..7d666faab229 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -42,12 +42,55 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: + resolve-candidate: + name: Resolve plugin prerelease candidate + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + checkout_revision: ${{ steps.resolve.outputs.checkout_revision }} + steps: + - name: Checkout trusted workflow helper + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Resolve and validate candidate + id: resolve + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + TARGET_REF: ${{ inputs.target_ref }} + shell: bash + run: | + set -euo pipefail + resolver_output="$RUNNER_TEMP/plugin-prerelease-resolved-ref" + bash scripts/github/resolve-openclaw-ref.sh \ + --ref "$TARGET_REF" \ + --expected-sha "$EXPECTED_SHA" \ + --github-output "$resolver_output" + checkout_revision="$(sed -n 's/^sha=//p' "$resolver_output")" + [[ "$checkout_revision" =~ ^[0-9a-f]{40}$ ]] || { + echo "Resolved plugin prerelease candidate is not a full lowercase commit SHA." >&2 + exit 1 + } + timeout --signal=TERM --kill-after=10s 120s \ + git fetch --no-tags --no-recurse-submodules --depth=1 origin "$checkout_revision" + fetched_revision="$(git rev-parse FETCH_HEAD)" + [[ "$fetched_revision" == "$checkout_revision" ]] || { + echo "Fetched plugin prerelease candidate differs from the resolved commit." >&2 + exit 1 + } + echo "checkout_revision=$checkout_revision" >> "$GITHUB_OUTPUT" + preflight: name: Build plugin prerelease plan + needs: [resolve-candidate] runs-on: ubuntu-24.04 timeout-minutes: 15 outputs: - checkout_revision: ${{ steps.manifest.outputs.checkout_revision }} run_plugin_prerelease_suite: ${{ steps.manifest.outputs.run_plugin_prerelease_suite }} run_plugin_prerelease_static: ${{ steps.manifest.outputs.run_plugin_prerelease_static }} plugin_prerelease_static_matrix: ${{ steps.manifest.outputs.plugin_prerelease_static_matrix }} @@ -61,7 +104,7 @@ jobs: - name: Checkout target uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ inputs.target_ref }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false @@ -83,26 +126,14 @@ jobs: - name: Build plugin prerelease manifest id: manifest env: - EXPECTED_SHA: ${{ inputs.expected_sha }} FULL_RELEASE_VALIDATION: ${{ inputs.full_release_validation && 'true' || 'false' }} run: | node --import tsx --input-type=module <<'EOF' import { appendFileSync, existsSync } from "node:fs"; - import { execFileSync } from "node:child_process"; const createMatrix = (include) => ({ include }); const outputPath = process.env.GITHUB_OUTPUT; - const checkoutRevision = execFileSync("git", ["rev-parse", "HEAD"], { - encoding: "utf8", - }).trim(); - const expectedSha = (process.env.EXPECTED_SHA ?? "").trim(); const fullReleaseValidation = process.env.FULL_RELEASE_VALIDATION === "true"; - if (expectedSha && expectedSha !== checkoutRevision) { - console.error( - `target_ref resolved to ${checkoutRevision}, expected ${expectedSha}`, - ); - process.exit(1); - } let pluginPrereleasePlan = { staticChecks: [], dockerLanes: [] }; let extensionShards = []; @@ -227,7 +258,6 @@ jobs: const runSuite = runStatic || runNode || runExtensions || runDocker; const manifest = { - checkout_revision: checkoutRevision, run_plugin_prerelease_suite: runSuite, run_plugin_prerelease_static: runStatic, plugin_prerelease_static_matrix: createMatrix(staticChecks), @@ -248,13 +278,16 @@ jobs: } EOF - plugin-npm-security-scan: + plugin-npm-security-plan: permissions: contents: read - name: plugin-npm-security-scan - needs: [preflight] + name: Plan plugin npm security artifacts + needs: [resolve-candidate] runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 10 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + packages_json: ${{ steps.plan.outputs.packages_json }} steps: - name: Checkout trusted scanner tooling uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -268,7 +301,7 @@ jobs: - name: Checkout candidate as inert data uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} path: .release-candidate fetch-depth: 1 fetch-tags: false @@ -288,17 +321,132 @@ jobs: - name: Install trusted scanner dependencies run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - - name: Scan candidate plugin packages + - name: Build trusted plugin package plan + id: plan + run: | + node --import tsx scripts/plugin-npm-security-prepare.mts plan \ + --candidate-root .release-candidate \ + --github-output "$GITHUB_OUTPUT" + + plugin-npm-security-package: + permissions: + contents: read + name: Package ${{ matrix.package_name }} + needs: [resolve-candidate, plugin-npm-security-plan] + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 24 + matrix: ${{ fromJson(needs.plugin-npm-security-plan.outputs.matrix) }} + steps: + - name: Checkout trusted packaging tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Checkout candidate package source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} + path: .release-candidate + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Setup trusted packaging environment + uses: ./.github/actions/setup-node-env + with: + node-version: "24.x" + install-bun: "false" + + - name: Prepare immutable plugin tarball env: - CANDIDATE_SHA: ${{ needs.preflight.outputs.checkout_revision }} + CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }} + EXTENSION_ID: ${{ matrix.extension_id }} + PACKAGE_DIR: ${{ matrix.package_dir }} + PACKAGE_NAME: ${{ matrix.package_name }} + TOOLING_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + output_dir="$RUNNER_TEMP/plugin-npm-security-package" + node --import tsx scripts/plugin-npm-security-prepare.mts prepare \ + --candidate-root .release-candidate \ + --candidate-sha "$CANDIDATE_SHA" \ + --extension-id "$EXTENSION_ID" \ + --output-dir "$output_dir" \ + --package-dir "$PACKAGE_DIR" \ + --package-name "$PACKAGE_NAME" \ + --tooling-sha "$TOOLING_SHA" + + - name: Upload immutable plugin tarball + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: plugin-npm-security-package-${{ needs.resolve-candidate.outputs.checkout_revision }}-${{ matrix.extension_id }} + path: ${{ runner.temp }}/plugin-npm-security-package/ + compression-level: 0 + if-no-files-found: error + retention-days: 14 + + plugin-npm-security-scan: + permissions: + actions: read + contents: read + name: plugin-npm-security-scan + needs: [resolve-candidate, plugin-npm-security-plan, plugin-npm-security-package] + if: ${{ !cancelled() && always() && needs.resolve-candidate.result == 'success' && needs.plugin-npm-security-plan.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout trusted scanner tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Setup trusted scanner TypeScript runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.x" + + - name: Setup trusted scanner pnpm + uses: ./.github/actions/setup-pnpm-store-cache + with: + node-version: "24.x" + + - name: Install trusted scanner dependencies + run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts + + - name: Download immutable plugin tarballs + 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 + env: + CANDIDATE_SHA: ${{ needs.resolve-candidate.outputs.checkout_revision }} + EXPECTED_PACKAGES_JSON: ${{ needs.plugin-npm-security-plan.outputs.packages_json }} TOOLING_SHA: ${{ github.sha }} shell: bash run: | set -euo pipefail report="$RUNNER_TEMP/plugin-npm-security-scan.json" - node --import tsx scripts/plugin-npm-security-scan.mts \ - --candidate-root .release-candidate \ + mkdir -p "$RUNNER_TEMP/plugin-npm-security-packages" + node scripts/plugin-npm-security-scan-runner.mjs \ + --artifact-root "$RUNNER_TEMP/plugin-npm-security-packages" \ --candidate-sha "$CANDIDATE_SHA" \ + --expected-packages-json "$EXPECTED_PACKAGES_JSON" \ --tooling-sha "$TOOLING_SHA" \ --report "$report" @@ -314,7 +462,7 @@ jobs: permissions: contents: read name: ${{ matrix.check_name }} - needs: [preflight] + needs: [resolve-candidate, preflight] if: needs.preflight.outputs.run_plugin_prerelease_static == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 45 @@ -325,7 +473,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false @@ -350,7 +498,7 @@ jobs: permissions: contents: read name: ${{ matrix.check_name }} - needs: [preflight] + needs: [resolve-candidate, preflight] if: needs.preflight.outputs.run_plugin_prerelease_node == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04') }} timeout-minutes: 60 @@ -361,7 +509,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false @@ -419,7 +567,7 @@ jobs: permissions: contents: read name: ${{ matrix.check_name }} - needs: [preflight] + needs: [resolve-candidate, preflight] if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true' runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner }} timeout-minutes: 60 @@ -430,7 +578,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false @@ -453,7 +601,7 @@ jobs: permissions: contents: read name: plugin-prerelease-inspector - needs: [preflight] + needs: [resolve-candidate, preflight] if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true' continue-on-error: true runs-on: ubuntu-24.04 @@ -462,7 +610,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} fetch-depth: 1 fetch-tags: false persist-credentials: false @@ -630,7 +778,7 @@ jobs: plugin-prerelease-docker-suite: name: plugin-prerelease-docker-suite - needs: [preflight] + needs: [resolve-candidate, preflight] if: ${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }} permissions: actions: read @@ -639,7 +787,7 @@ jobs: pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: - ref: ${{ needs.preflight.outputs.checkout_revision }} + ref: ${{ needs.resolve-candidate.outputs.checkout_revision }} include_repo_e2e: false include_release_path_suites: false include_openwebui: false @@ -678,6 +826,7 @@ jobs: contents: read name: plugin-prerelease-suite needs: + - resolve-candidate - preflight - plugin-npm-security-scan - plugin-prerelease-static-shard diff --git a/config/knip.config.ts b/config/knip.config.ts index fa9ee8ff5ded..20bff4f28d1e 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -77,7 +77,9 @@ const repositoryScriptEntries = [ "scripts/memory-index-manager.sync-repro.ts!", "scripts/openclaw-release-clawhub-plan.ts!", "scripts/openclaw-release-clawhub-runtime-state.ts!", - // Plugin Prerelease invokes this trusted scanner against an inert candidate checkout. + // Plugin Prerelease builds immutable package artifacts, then scans them in a bounded child. + "scripts/plugin-npm-security-prepare.mts!", + "scripts/plugin-npm-security-scan-runner.mjs!", "scripts/plugin-npm-security-scan.mts!", // Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json. "scripts/oxlint-boundary-guards.mjs!", diff --git a/package.json b/package.json index 15baab5d8c1d..a05f3e936e42 100644 --- a/package.json +++ b/package.json @@ -2135,6 +2135,7 @@ "tsx": "4.23.1", "unrun": "0.3.1", "vite": "8.1.5", + "validate-npm-package-name": "7.0.2", "vitest": "4.1.10" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 683a9a8c3f61..efc7e0b22190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: npm-packlist: specifier: 10.0.4 version: 10.0.4 + validate-npm-package-name: + specifier: 7.0.2 + version: 7.0.2 oxfmt: specifier: 0.60.0 version: 0.60.0 diff --git a/scripts/lib/plugin-npm-security-scan.mts b/scripts/lib/plugin-npm-security-scan.mts index 7650799ed89f..e6f5cfc8e1ca 100644 --- a/scripts/lib/plugin-npm-security-scan.mts +++ b/scripts/lib/plugin-npm-security-scan.mts @@ -4,12 +4,15 @@ import { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, + writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { @@ -18,10 +21,16 @@ import { type SkillScanFinding, } from "../../src/skills/security/scanner.js"; import { runTasksWithConcurrency } from "../../src/utils/run-with-concurrency.js"; +import { + inspectPackageTarballBytes, + readBoundedRegularFile, +} from "../plugin-publication-artifact.mjs"; -type PublishablePluginPackage = { +export type PublishablePluginPackage = { + extensionId: string; packageDir: string; packageName: string; + packageVersion: string; }; type CriticalFindingRecord = { @@ -30,14 +39,25 @@ type CriticalFindingRecord = { ruleId: string; }; -type ScanPackageResult = { +export type ScanPackageResult = { expectedReviewedCriticalFindings: string[]; packageName: string; + packageVersion: string; packedFileCount: number; reviewedCriticalFindings: string[]; + scanFindingCount: number; + tarballSha256: string; unexpectedCriticalFindings: CriticalFindingRecord[]; }; +type PluginNpmSecurityArtifact = PublishablePluginPackage & { + artifactDir: string; + candidateSha: string; + tarballPath: string; + tarballSha256: string; + toolingSha: string; +}; + export type PluginNpmSecurityScanReport = { candidateSha: string; errors: string[]; @@ -46,6 +66,7 @@ export type PluginNpmSecurityScanReport = { schemaVersion: 1; status: "pass" | "fail"; summary: { + findingCount: number; packageCount: number; reviewedCriticalFindingCount: number; unexpectedCriticalFindingCount: number; @@ -54,7 +75,18 @@ export type PluginNpmSecurityScanReport = { }; const execFileAsync = promisify(execFile); -const MAX_PACKED_FILES_PER_PACKAGE = 50_000; +const require = createRequire(import.meta.url); +const validateNpmPackageName = require("validate-npm-package-name") as (name: unknown) => { + validForNewPackages: boolean; +}; +export const MAX_PUBLISHABLE_PLUGIN_PACKAGES = 256; +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; +const MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES = 64 * 1024; +const MAX_PLUGIN_TARBALL_BYTES = 128 * 1024 * 1024; +const MAX_PACKED_FILES_PER_PACKAGE = 20_000; const MAX_PACKED_FILE_BYTES = 64 * 1024 * 1024; const MAX_PACKED_TOTAL_BYTES_PER_PACKAGE = 256 * 1024 * 1024; const MAX_SCANNABLE_FILES_PER_PACKAGE = 10_000; @@ -141,8 +173,12 @@ function expandFindingCounts(counts: ReadonlyMap): string[] { return [...counts].flatMap(([key, count]) => Array.from({ length: count }, () => key)); } +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function sortStrings(values: readonly string[]): string[] { - return [...values].toSorted((left, right) => left.localeCompare(right)); + return [...values].toSorted(compareCodeUnits); } function arraysEqual(left: readonly string[], right: readonly string[]): boolean { @@ -203,6 +239,7 @@ export async function collectNpmPackedFiles( ): Promise { const helperPath = limits.helperPath ?? PACKLIST_HELPER_PATH; const maxOldSpaceMb = limits.maxOldSpaceMb ?? PACKLIST_HELPER_MAX_OLD_SPACE_MB; + const timeoutMs = limits.timeoutMs ?? PACKLIST_HELPER_TIMEOUT_MS; try { const { stdout } = await execFileAsync( process.execPath, @@ -217,12 +254,28 @@ export async function collectNpmPackedFiles( }, killSignal: "SIGKILL", maxBuffer: limits.maxBufferBytes ?? PACKLIST_HELPER_MAX_BUFFER_BYTES, - timeout: limits.timeoutMs ?? PACKLIST_HELPER_TIMEOUT_MS, + signal: AbortSignal.timeout(timeoutMs), }, ); return parsePacklistFiles(stdout, packageName); - } catch { - throw new Error(`${packageName}: trusted packlist helper exceeded its resource limits.`); + } catch (error) { + const failure = + error && typeof error === "object" + ? (error as { code?: unknown; killed?: unknown; signal?: unknown }) + : {}; + if (failure.code === "ABORT_ERR" || failure.code === "ETIMEDOUT") { + throw new Error(`${packageName}: trusted packlist helper timed out.`); + } + if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") { + throw new Error(`${packageName}: trusted packlist helper exceeded its output limit.`); + } + if (failure.killed === true || typeof failure.signal === "string") { + throw new Error(`${packageName}: trusted packlist helper exceeded its process limit.`); + } + if (typeof failure.code === "number") { + throw new Error(`${packageName}: trusted packlist helper failed.`); + } + throw new Error(`${packageName}: trusted packlist helper could not start.`); } } @@ -363,8 +416,23 @@ async function gitOutput(rootDir: string, args: string[]): Promise { return stdout.trim(); } -async function listPublishablePluginPackages( +export function assertCanonicalNpmPackageName(packageName: unknown, label: string): string { + if ( + typeof packageName !== "string" || + packageName.trim() !== packageName || + !validateNpmPackageName(packageName).validForNewPackages + ) { + throw new Error(`${label}: publishable plugin has an invalid npm package name.`); + } + return packageName; +} + +export async function listPublishablePluginPackages( candidateDir: string, + limits: { + maxManifestBytes?: number; + maxPackageManifests?: number; + } = {}, ): Promise { const { stdout } = await execFileAsync( "git", @@ -375,8 +443,12 @@ async function listPublishablePluginPackages( }, ); const packageFiles = stdout.split("\0").filter(Boolean).toSorted(); + const maxPackageManifests = limits.maxPackageManifests ?? MAX_PUBLISHABLE_PLUGIN_PACKAGES; + if (packageFiles.length > maxPackageManifests) { + throw new Error("Candidate exceeds the plugin package-count limit."); + } - return packageFiles.flatMap((packageFile) => { + const publishablePackages = packageFiles.flatMap((packageFile) => { const match = /^extensions\/([^/]+)\/package\.json$/u.exec(packageFile); if (!match?.[1]) { return []; @@ -387,18 +459,294 @@ async function listPublishablePluginPackages( if (!packageStat.isFile()) { throw new Error(`${packageFile}: package manifest is not a regular file.`); } + if ( + packageStat.size === 0 || + packageStat.size > (limits.maxManifestBytes ?? MAX_PLUGIN_PACKAGE_MANIFEST_BYTES) + ) { + throw new Error(`${packageFile}: package manifest exceeds the byte limit.`); + } const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown; + version?: unknown; openclaw?: { release?: { publishToNpm?: unknown } }; }; if (packageJson.openclaw?.release?.publishToNpm !== true) { return []; } - if (typeof packageJson.name !== "string" || !packageJson.name.trim()) { - throw new Error(`${packageFile}: publishable plugin is missing its package name.`); + const packageName = assertCanonicalNpmPackageName(packageJson.name, packageFile); + if ( + typeof packageJson.version !== "string" || + !packageJson.version || + packageJson.version.trim() !== packageJson.version + ) { + throw new Error(`${packageFile}: publishable plugin has an invalid package version.`); } - return [{ packageDir, packageName: packageJson.name }]; + return [ + { + extensionId: match[1], + packageDir, + packageName, + packageVersion: packageJson.version, + }, + ]; }); + const seenNames = new Set(); + for (const plugin of publishablePackages) { + if (seenNames.has(plugin.packageName)) { + throw new Error(`Candidate contains duplicate publishable package ${plugin.packageName}.`); + } + seenNames.add(plugin.packageName); + } + return publishablePackages.toSorted((left, right) => + compareCodeUnits(left.packageName, right.packageName), + ); +} + +const PLUGIN_SECURITY_ARTIFACT_METADATA = "plugin-npm-security-artifact.json"; + +function parseExpectedPackages(value: unknown): PublishablePluginPackage[] { + if (!Array.isArray(value) || value.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) { + throw new Error("Expected plugin package inventory is invalid."); + } + const packages = value.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Expected plugin package entry ${index} is invalid.`); + } + const candidate = entry as Record; + const extensionId = candidate.extensionId; + const packageDir = candidate.packageDir; + const packageName = assertCanonicalNpmPackageName( + candidate.packageName, + `Expected plugin package entry ${index}`, + ); + const packageVersion = candidate.packageVersion; + if ( + typeof extensionId !== "string" || + !/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) || + packageDir !== `extensions/${extensionId}` || + typeof packageVersion !== "string" || + !packageVersion || + packageVersion.trim() !== packageVersion + ) { + throw new Error(`Expected plugin package entry ${index} is invalid.`); + } + return { extensionId, packageDir, packageName, packageVersion }; + }); + const sorted = packages.toSorted((left, right) => + compareCodeUnits(left.packageName, right.packageName), + ); + if ( + new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length || + new Set(sorted.map((plugin) => plugin.extensionId)).size !== sorted.length || + JSON.stringify(sorted) !== JSON.stringify(packages) + ) { + throw new Error("Expected plugin package inventory must be unique and sorted."); + } + return sorted; +} + +function readPluginSecurityArtifact( + artifactDir: string, + expectedCandidateSha: string, + expectedToolingSha: string, +): PluginNpmSecurityArtifact { + const metadataPath = join(artifactDir, PLUGIN_SECURITY_ARTIFACT_METADATA); + const metadataStat = lstatSync(metadataPath); + if ( + !metadataStat.isFile() || + metadataStat.size === 0 || + metadataStat.size > MAX_PLUGIN_SECURITY_ARTIFACT_METADATA_BYTES + ) { + throw new Error("Plugin security artifact metadata is outside the byte limit."); + } + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as Record; + const expectedKeys = [ + "candidateSha", + "extensionId", + "packageDir", + "packageName", + "packageVersion", + "schemaVersion", + "tarballName", + "tarballSha256", + "toolingSha", + ]; + if ( + metadata.schemaVersion !== 1 || + JSON.stringify(Object.keys(metadata).toSorted()) !== JSON.stringify(expectedKeys) + ) { + throw new Error("Plugin security artifact metadata has an invalid shape."); + } + const packageName = assertCanonicalNpmPackageName( + metadata.packageName, + "Plugin security artifact metadata", + ); + const extensionId = metadata.extensionId; + const packageDir = metadata.packageDir; + const packageVersion = metadata.packageVersion; + const tarballName = metadata.tarballName; + const tarballSha256 = metadata.tarballSha256; + if ( + metadata.candidateSha !== expectedCandidateSha || + metadata.toolingSha !== expectedToolingSha || + typeof extensionId !== "string" || + !/^[a-z0-9][a-z0-9._-]*$/u.test(extensionId) || + packageDir !== `extensions/${extensionId}` || + typeof packageVersion !== "string" || + !packageVersion || + packageVersion.trim() !== packageVersion || + typeof tarballName !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(tarballName) || + basename(tarballName) !== tarballName || + typeof tarballSha256 !== "string" || + !/^[0-9a-f]{64}$/u.test(tarballSha256) + ) { + throw new Error("Plugin security artifact metadata identity is invalid."); + } + const artifactEntries = readdirSync(artifactDir, { withFileTypes: true }); + if ( + artifactEntries.length !== 2 || + artifactEntries.some( + (entry) => + !entry.isFile() || + (entry.name !== PLUGIN_SECURITY_ARTIFACT_METADATA && entry.name !== tarballName), + ) + ) { + throw new Error("Plugin security artifact contains unexpected entries."); + } + const tarballPath = join(artifactDir, tarballName); + const tarballStat = lstatSync(tarballPath); + if ( + !tarballStat.isFile() || + tarballStat.size === 0 || + tarballStat.size > MAX_PLUGIN_TARBALL_BYTES + ) { + throw new Error(`${packageName}: plugin tarball is outside the byte limit.`); + } + return { + artifactDir, + candidateSha: expectedCandidateSha, + extensionId, + packageDir, + packageName, + packageVersion, + tarballPath, + tarballSha256, + toolingSha: expectedToolingSha, + }; +} + +export function listPluginNpmSecurityArtifacts(params: { + artifactRoot: string; + candidateSha: string; + expectedPackages: unknown; + toolingSha: string; +}): PluginNpmSecurityArtifact[] { + const expectedPackages = parseExpectedPackages(params.expectedPackages); + const artifactRoot = realpathSync(params.artifactRoot); + const entries = readdirSync(artifactRoot, { withFileTypes: true }).toSorted((left, right) => + compareCodeUnits(left.name, right.name), + ); + if (entries.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES) { + throw new Error("Plugin security artifact set exceeds the package-count limit."); + } + const artifacts = entries.map((entry) => { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error("Plugin security artifact root contains a non-directory entry."); + } + return readPluginSecurityArtifact( + join(artifactRoot, entry.name), + params.candidateSha, + params.toolingSha, + ); + }); + const sorted = artifacts.toSorted((left, right) => + compareCodeUnits(left.packageName, right.packageName), + ); + if (new Set(sorted.map((plugin) => plugin.packageName)).size !== sorted.length) { + throw new Error("Plugin security artifact set contains duplicate package names."); + } + const observedPackages = sorted.map( + ({ extensionId, packageDir, packageName, packageVersion }) => ({ + extensionId, + packageDir, + packageName, + packageVersion, + }), + ); + if (JSON.stringify(observedPackages) !== JSON.stringify(expectedPackages)) { + throw new Error("Plugin security artifact set does not match the trusted package plan."); + } + return sorted; +} + +export function stageScannerRelevantPluginTarballFiles(tarballPath: string): { + fileCount: number; + inspection: { + inventory: Array<{ path: string; sizeBytes: number; type: string }>; + packageManifest: Record; + tarballSha256: string; + }; + packedFiles: string[]; + stageDir: string; + totalBytes: number; +} { + const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-")); + let fileCount = 0; + let totalBytes = 0; + const packedFiles: string[] = []; + try { + const tarballBytes = readBoundedRegularFile(tarballPath, { + label: "Plugin security tarball", + maxBytes: MAX_PLUGIN_TARBALL_BYTES, + }); + const inspection = inspectPackageTarballBytes(tarballBytes, { + maxArchiveBytes: MAX_PLUGIN_TARBALL_BYTES, + maxEntries: MAX_PACKED_FILES_PER_PACKAGE, + maxEntryBytes: MAX_PACKED_FILE_BYTES, + maxExpandedBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE, + maxPathBytes: 4 * 1024 * 1024, + maxTotalFileBytes: MAX_PACKED_TOTAL_BYTES_PER_PACKAGE, + onFile: ({ content, path }: { content: Uint8Array; path: string }) => { + if (!path.startsWith("package/")) { + throw new Error("Plugin tarball file escaped package/."); + } + const packedPath = path.slice("package/".length); + packedFiles.push(packedPath); + if (!isScannable(packedPath)) { + return; + } + if (content.byteLength > MAX_SCANNABLE_FILE_BYTES) { + throw new Error(`Packed scanner input exceeds the per-file byte limit: ${packedPath}`); + } + fileCount += 1; + totalBytes += content.byteLength; + if (fileCount > MAX_SCANNABLE_FILES_PER_PACKAGE) { + throw new Error("Packed scanner input exceeds the file-count limit."); + } + if (totalBytes > MAX_SCANNABLE_TOTAL_BYTES_PER_PACKAGE) { + throw new Error("Packed scanner input exceeds the total-byte limit."); + } + const target = join(stageDir, ...packedPath.split("/")); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + }, + }) as { + inventory: Array<{ path: string; sizeBytes: number; type: string }>; + packageManifest: Record; + tarballSha256: string; + }; + return { + fileCount, + inspection, + packedFiles: packedFiles.toSorted(), + stageDir, + totalBytes, + }; + } catch (error) { + rmSync(stageDir, { recursive: true, force: true }); + throw error; + } } function findingRecord(stageDir: string, finding: SkillScanFinding): CriticalFindingRecord { @@ -421,27 +769,37 @@ export function assertCompleteScannerSummary( } } -async function scanPublishablePluginPackage( - plugin: PublishablePluginPackage, +async function scanPublishablePluginArtifact( + plugin: PluginNpmSecurityArtifact, ): Promise { const reviewedCriticalFindings: string[] = []; const expectedReviewedCriticalFindings: string[] = []; const unexpectedCriticalFindings: CriticalFindingRecord[] = []; - const packedFiles = await collectNpmPackedFiles(plugin.packageDir, plugin.packageName); - for (const packedFile of packedFiles) { - expectedReviewedCriticalFindings.push( - ...expectedOptionalReviewedFindingsForPackedPath(plugin.packageName, packedFile), - ); - } - - const staged = stageScannerRelevantPackedFiles(plugin.packageDir, packedFiles); + let scanFindingCount = 0; + const staged = stageScannerRelevantPluginTarballFiles(plugin.tarballPath); try { + if ( + staged.inspection.packageManifest.name !== plugin.packageName || + staged.inspection.packageManifest.version !== plugin.packageVersion || + staged.inspection.tarballSha256 !== plugin.tarballSha256 + ) { + throw new Error(`${plugin.packageName}: immutable plugin tarball identity mismatch.`); + } + for (const packedFile of staged.packedFiles) { + expectedReviewedCriticalFindings.push( + ...expectedOptionalReviewedFindingsForPackedPath(plugin.packageName, packedFile), + ); + } const summary = await scanDirectoryWithSummary(staged.stageDir, { excludeTestFiles: false, maxFileBytes: MAX_SCANNABLE_FILE_BYTES, maxFiles: MAX_SCANNABLE_FILES_PER_PACKAGE, }); assertCompleteScannerSummary(plugin.packageName, summary); + if (summary.findings.length > MAX_PLUGIN_SCAN_FINDINGS_PER_PACKAGE) { + throw new Error(`${plugin.packageName}: security scan exceeded the finding-count limit.`); + } + scanFindingCount = summary.findings.length; if (summary.scannedFiles !== staged.fileCount) { throw new Error( `${plugin.packageName}: security scan processed ${summary.scannedFiles} of ${staged.fileCount} staged files.`, @@ -466,10 +824,13 @@ async function scanPublishablePluginPackage( return { expectedReviewedCriticalFindings: sortStrings(expectedReviewedCriticalFindings), packageName: plugin.packageName, - packedFileCount: staged.packedFileCount, + packageVersion: plugin.packageVersion, + packedFileCount: staged.inspection.inventory.filter((entry) => entry.type === "file").length, reviewedCriticalFindings: sortStrings(reviewedCriticalFindings), + scanFindingCount, + tarballSha256: plugin.tarballSha256, unexpectedCriticalFindings: unexpectedCriticalFindings.toSorted((left, right) => - JSON.stringify(left).localeCompare(JSON.stringify(right)), + compareCodeUnits(JSON.stringify(left), JSON.stringify(right)), ), }; } @@ -485,15 +846,23 @@ function expectedRequiredFindingsForPackage( export function buildPluginNpmSecurityScanReport(params: { candidateSha: string; + maxTotalFindings?: number; packageResults: ScanPackageResult[]; scanErrors?: readonly string[]; toolingSha: string; }): PluginNpmSecurityScanReport { const { candidateSha, packageResults, toolingSha } = params; const allReviewedFindings = packageResults.flatMap((result) => result.reviewedCriticalFindings); + const totalFindingCount = packageResults.reduce( + (total, result) => total + result.scanFindingCount, + 0, + ); const layout = resolveReviewedSourceLayout(allReviewedFindings); const errors: string[] = sortStrings(params.scanErrors ?? []); + if (totalFindingCount > (params.maxTotalFindings ?? MAX_PLUGIN_SCAN_TOTAL_FINDINGS)) { + errors.push("Plugin npm security scan exceeded the total finding-count limit."); + } if (!layout) { errors.push("Reviewed critical findings do not match exactly one supported release layout."); } @@ -546,10 +915,10 @@ export function buildPluginNpmSecurityScanReport(params: { expectedReviewedCriticalFindings: sortStrings(result.expectedReviewedCriticalFindings), reviewedCriticalFindings: sortStrings(result.reviewedCriticalFindings), unexpectedCriticalFindings: result.unexpectedCriticalFindings.toSorted((left, right) => - JSON.stringify(left).localeCompare(JSON.stringify(right)), + compareCodeUnits(JSON.stringify(left), JSON.stringify(right)), ), })) - .toSorted((left, right) => left.packageName.localeCompare(right.packageName)); + .toSorted((left, right) => compareCodeUnits(left.packageName, right.packageName)); return { candidateSha, errors: sortStrings(errors), @@ -558,6 +927,7 @@ export function buildPluginNpmSecurityScanReport(params: { schemaVersion: 1, status: errors.length === 0 ? "pass" : "fail", summary: { + findingCount: totalFindingCount, packageCount: packageResults.length, reviewedCriticalFindingCount: allReviewedFindings.length, unexpectedCriticalFindingCount, @@ -566,7 +936,27 @@ export function buildPluginNpmSecurityScanReport(params: { }; } -function sanitizePackageScanError(plugin: PublishablePluginPackage, error: unknown): string { +export function constrainPluginNpmSecurityScanReport( + report: PluginNpmSecurityScanReport, + maxBytes = MAX_PLUGIN_SCAN_REPORT_BYTES, +): PluginNpmSecurityScanReport { + const serializedBytes = Buffer.byteLength(`${JSON.stringify(report)}\n`, "utf8"); + if (serializedBytes <= maxBytes) { + return report; + } + return { + candidateSha: report.candidateSha, + errors: ["Plugin npm security scan report exceeded the byte limit."], + layout: null, + packages: [], + schemaVersion: 1, + status: "fail", + summary: report.summary, + toolingSha: report.toolingSha, + }; +} + +function sanitizePackageScanError(plugin: PluginNpmSecurityArtifact, error: unknown): string { let message = error instanceof Error ? error.message : "Unknown package scan failure."; for (const [path, replacement] of [ [plugin.packageDir, ""], @@ -581,7 +971,7 @@ function sanitizePackageScanError(plugin: PublishablePluginPackage, error: unkno } export async function scanPublishablePluginPackages( - packages: readonly PublishablePluginPackage[], + packages: readonly PluginNpmSecurityArtifact[], ): Promise<{ packageResults: ScanPackageResult[]; scanErrors: string[] }> { const scanErrors: string[] = []; const { results } = await runTasksWithConcurrency({ @@ -593,7 +983,7 @@ export async function scanPublishablePluginPackages( plugin ? sanitizePackageScanError(plugin, error) : "Unknown package: package scan failed.", ); }, - tasks: packages.map((plugin) => () => scanPublishablePluginPackage(plugin)), + tasks: packages.map((plugin) => () => scanPublishablePluginArtifact(plugin)), }); return { packageResults: results.filter((result): result is ScanPackageResult => result !== undefined), @@ -602,21 +992,30 @@ export async function scanPublishablePluginPackages( } export async function runPluginNpmSecurityScan(params: { - candidateDir: string; + artifactRoot: string; + candidateSha: string; + expectedPackages: unknown; toolingDir: string; + toolingSha: string; }): Promise { - const candidateDir = realpathSync(params.candidateDir); const toolingDir = realpathSync(params.toolingDir); - const [candidateSha, toolingSha, packages] = await Promise.all([ - gitOutput(candidateDir, ["rev-parse", "HEAD"]), - gitOutput(toolingDir, ["rev-parse", "HEAD"]), - listPublishablePluginPackages(candidateDir), - ]); - const { packageResults, scanErrors } = await scanPublishablePluginPackages(packages); - return buildPluginNpmSecurityScanReport({ - candidateSha, - packageResults, - scanErrors, + const toolingSha = await gitOutput(toolingDir, ["rev-parse", "HEAD"]); + if (toolingSha !== params.toolingSha) { + throw new Error("Trusted scanner tooling checkout differs from the expected commit."); + } + const packages = listPluginNpmSecurityArtifacts({ + artifactRoot: params.artifactRoot, + candidateSha: params.candidateSha, + expectedPackages: params.expectedPackages, toolingSha, }); + const { packageResults, scanErrors } = await scanPublishablePluginPackages(packages); + return constrainPluginNpmSecurityScanReport( + buildPluginNpmSecurityScanReport({ + candidateSha: params.candidateSha, + packageResults, + scanErrors, + toolingSha, + }), + ); } diff --git a/scripts/plugin-npm-security-prepare.mts b/scripts/plugin-npm-security-prepare.mts new file mode 100644 index 000000000000..a0e65c4ebad5 --- /dev/null +++ b/scripts/plugin-npm-security-prepare.mts @@ -0,0 +1,257 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; +import { + collectNpmPackedFiles, + listPublishablePluginPackages, + type PublishablePluginPackage, +} from "./lib/plugin-npm-security-scan.mts"; +import { + inspectPackageTarballBytes, + readBoundedRegularFile, +} from "./plugin-publication-artifact.mjs"; + +const MAX_PACK_STDOUT_BYTES = 8 * 1024 * 1024; +const MAX_TARBALL_BYTES = 128 * 1024 * 1024; +const PACK_TIMEOUT_MS = 20 * 60 * 1000; + +type ParsedArgs = { + candidateRoot: string; + candidateSha: string; + command: "plan" | "prepare"; + extensionId: string; + githubOutput: string; + outputDir: string; + packageDir: string; + packageName: string; + toolingSha: string; +}; + +function parseArgs(argv: string[]): ParsedArgs { + const command = argv[0]; + if (command !== "plan" && command !== "prepare") { + throw new Error("Expected plugin npm security prepare command: plan or prepare."); + } + const values = new Map(); + for (let index = 1; 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 npm security prepare argument near ${String(name)}.`); + } + values.set(name, value); + } + const candidateRoot = values.get("--candidate-root") ?? ""; + if (!candidateRoot) { + throw new Error("--candidate-root is required."); + } + const parsed: ParsedArgs = { + candidateRoot: resolve(candidateRoot), + candidateSha: values.get("--candidate-sha") ?? "", + command, + extensionId: values.get("--extension-id") ?? "", + githubOutput: values.get("--github-output") ?? "", + outputDir: values.get("--output-dir") ?? "", + packageDir: values.get("--package-dir") ?? "", + packageName: values.get("--package-name") ?? "", + toolingSha: values.get("--tooling-sha") ?? "", + }; + if (command === "plan") { + if (!parsed.githubOutput) { + throw new Error("plan requires --github-output."); + } + return parsed; + } + if ( + !/^[0-9a-f]{40}$/u.test(parsed.candidateSha) || + !/^[0-9a-f]{40}$/u.test(parsed.toolingSha) || + !/^[a-z0-9][a-z0-9._-]*$/u.test(parsed.extensionId) || + parsed.packageDir !== `extensions/${parsed.extensionId}` || + !parsed.packageName || + !parsed.outputDir + ) { + throw new Error("prepare received an invalid package or commit identity."); + } + parsed.outputDir = resolve(parsed.outputDir); + return parsed; +} + +function relativePackage(plugin: PublishablePluginPackage, candidateRoot: string) { + const packageDir = relative(candidateRoot, plugin.packageDir).split(sep).join("/"); + if (packageDir !== `extensions/${plugin.extensionId}`) { + throw new Error(`${plugin.packageName}: package directory escaped the candidate checkout.`); + } + return { + extensionId: plugin.extensionId, + packageDir, + packageName: plugin.packageName, + packageVersion: plugin.packageVersion, + }; +} + +async function planPackages(args: ParsedArgs): Promise { + const candidateRoot = realpathSync(args.candidateRoot); + const packages = (await listPublishablePluginPackages(candidateRoot)).map((plugin) => + relativePackage(plugin, candidateRoot), + ); + const matrix = { + include: packages.map((plugin) => ({ + extension_id: plugin.extensionId, + package_dir: plugin.packageDir, + package_name: plugin.packageName, + })), + }; + appendFileSync(args.githubOutput, `matrix=${JSON.stringify(matrix)}\n`, "utf8"); + appendFileSync(args.githubOutput, `packages_json=${JSON.stringify(packages)}\n`, "utf8"); +} + +function gitSha(root: string): string { + return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function parsePackOutput(stdout: string): Array> { + const raw = stdout.trim(); + for (let index = raw.length - 1; index >= 0; index -= 1) { + if (raw[index] !== "[" && raw[index] !== "{") { + continue; + } + try { + const entries = resolveNpmJsonEntries(JSON.parse(raw.slice(index))); + if (entries.length > 0) { + return entries as Array>; + } + } catch { + // npm can print bundled dependency diagnostics before its JSON result. + } + } + throw new Error("Trusted plugin packaging did not emit npm pack JSON."); +} + +async function preparePackage(args: ParsedArgs): Promise { + const toolingRoot = realpathSync(process.cwd()); + const candidateRoot = realpathSync(args.candidateRoot); + if (gitSha(toolingRoot) !== args.toolingSha || gitSha(candidateRoot) !== args.candidateSha) { + throw new Error("Plugin packaging checkout identity differs from the trusted plan."); + } + const packages = await listPublishablePluginPackages(candidateRoot); + const selected = packages.find( + (plugin) => + plugin.extensionId === args.extensionId && + plugin.packageName === args.packageName && + relativePackage(plugin, candidateRoot).packageDir === args.packageDir, + ); + if (!selected) { + throw new Error("Selected plugin package is absent from the trusted package plan."); + } + + await collectNpmPackedFiles(selected.packageDir, selected.packageName); + if (existsSync(args.outputDir)) { + if (readdirSync(args.outputDir).length !== 0) { + throw new Error("Plugin security artifact output directory must be empty."); + } + } else { + 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, + }, + ); + if (result.status !== 0 || result.signal || result.error) { + throw new Error(`${selected.packageName}: trusted plugin packaging failed.`); + } + const packEntries = parsePackOutput(result.stdout); + if (packEntries.length !== 1) { + throw new Error(`${selected.packageName}: npm pack returned an invalid result count.`); + } + const tarballName = packEntries[0]?.filename; + if ( + typeof tarballName !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(tarballName) || + basename(tarballName) !== tarballName + ) { + throw new Error(`${selected.packageName}: npm pack returned an unsafe tarball name.`); + } + const tarballPath = join(args.outputDir, tarballName); + const tarballBytes = readBoundedRegularFile(tarballPath, { + label: "Prepared plugin tarball", + maxBytes: MAX_TARBALL_BYTES, + }); + const inspection = inspectPackageTarballBytes(tarballBytes, { + maxArchiveBytes: MAX_TARBALL_BYTES, + }); + if ( + inspection.packageManifest.name !== selected.packageName || + inspection.packageManifest.version !== selected.packageVersion + ) { + throw new Error(`${selected.packageName}: prepared tarball 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 = { + candidateSha: args.candidateSha, + extensionId: selected.extensionId, + packageDir: args.packageDir, + packageName: selected.packageName, + packageVersion: selected.packageVersion, + schemaVersion: 1, + tarballName, + tarballSha256: inspection.tarballSha256, + toolingSha: args.toolingSha, + }; + writeFileSync( + join(args.outputDir, "plugin-npm-security-artifact.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.command === "plan") { + await planPackages(args); + } else { + await preparePackage(args); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/plugin-npm-security-scan-runner.mjs b/scripts/plugin-npm-security-scan-runner.mjs new file mode 100644 index 000000000000..4260ddd7a2e7 --- /dev/null +++ b/scripts/plugin-npm-security-scan-runner.mjs @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_HEAP_MB = 768; +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const MAX_CAPTURE_BYTES = 512 * 1024; +const MAX_REPORT_BYTES = 1024 * 1024; +const SCANNER_PATH = fileURLToPath(new URL("./plugin-npm-security-scan.mts", import.meta.url)); + +function parseArgs(argv) { + const values = new Map(); + 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 npm security runner argument near ${String(name)}.`); + } + values.set(name, value); + } + const artifactRoot = values.get("--artifact-root") ?? ""; + const candidateSha = values.get("--candidate-sha") ?? ""; + const report = values.get("--report") ?? ""; + const toolingSha = values.get("--tooling-sha") ?? ""; + if ( + !artifactRoot || + !/^[0-9a-f]{40}$/u.test(candidateSha) || + !report || + !/^[0-9a-f]{40}$/u.test(toolingSha) + ) { + throw new Error("Plugin npm security runner received an invalid identity or path."); + } + return { + artifactRoot: path.resolve(artifactRoot), + candidateSha, + report: path.resolve(report), + toolingSha, + }; +} + +function testOverride(name, fallback) { + if (process.env.NODE_ENV !== "test") { + return fallback; + } + return process.env[name] || fallback; +} + +function boundedAppend(current, chunk) { + if (current.length >= MAX_CAPTURE_BYTES) { + return current; + } + return Buffer.concat([current, chunk]).subarray(0, MAX_CAPTURE_BYTES); +} + +function sanitizeOutput(value, args) { + let output = value.toString("utf8"); + for (const [source, replacement] of [ + [args.artifactRoot, ""], + [path.dirname(args.report), ""], + [process.cwd(), ""], + ]) { + output = output.replaceAll(source, replacement); + } + return output + .replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "") + .replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1"); +} + +function compactFailureReport(args, category) { + return { + candidateSha: args.candidateSha, + errors: [`Plugin npm security scanner ${category}.`], + layout: null, + packages: [], + schemaVersion: 1, + status: "fail", + summary: { + findingCount: 0, + packageCount: 0, + reviewedCriticalFindingCount: 0, + unexpectedCriticalFindingCount: 0, + }, + toolingSha: args.toolingSha, + }; +} + +function writeFailureReport(args, category) { + mkdirSync(path.dirname(args.report), { recursive: true }); + writeFileSync(args.report, `${JSON.stringify(compactFailureReport(args, category))}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +function existingReportStatus(args) { + if (!existsSync(args.report)) { + return null; + } + const stat = lstatSync(args.report); + if (!stat.isFile() || stat.size === 0 || stat.size > MAX_REPORT_BYTES) { + return null; + } + try { + const report = JSON.parse(readFileSync(args.report, "utf8")); + const valid = + report?.candidateSha === args.candidateSha && + Array.isArray(report?.errors) && + Array.isArray(report?.packages) && + (report?.status === "pass" || report?.status === "fail") && + typeof report?.summary === "object" && + report?.toolingSha === args.toolingSha && + report?.schemaVersion === 1; + return valid ? report.status : null; + } catch { + return null; + } +} + +async function run(argv) { + const args = parseArgs(argv); + const scannerPath = testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD", SCANNER_PATH); + const heapMb = Number(testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB", DEFAULT_HEAP_MB)); + const timeoutMs = Number( + testOverride("OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS", DEFAULT_TIMEOUT_MS), + ); + if ( + !Number.isSafeInteger(heapMb) || + heapMb < 16 || + heapMb > 4096 || + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 10 || + timeoutMs > DEFAULT_TIMEOUT_MS + ) { + throw new Error("Plugin npm security runner limits are invalid."); + } + + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let timedOut = false; + const child = spawn( + process.execPath, + [`--max-old-space-size=${heapMb}`, "--import", "tsx", scannerPath, ...argv], + { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: { + CI: "1", + HOME: process.env.HOME, + NODE_ENV: process.env.NODE_ENV, + PATH: process.env.PATH, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout.on("data", (chunk) => { + stdout = boundedAppend(stdout, chunk); + }); + child.stderr.on("data", (chunk) => { + stderr = boundedAppend(stderr, chunk); + }); + const timer = setTimeout(() => { + timedOut = true; + if (child.pid) { + try { + process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL"); + } catch {} + } + }, timeoutMs); + const result = await new Promise((resolve) => { + child.on("error", (error) => resolve({ error, status: null })); + child.on("close", (status, signal) => resolve({ error: undefined, signal, status })); + }); + clearTimeout(timer); + + const safeStdout = sanitizeOutput(stdout, args); + const safeStderr = sanitizeOutput(stderr, args); + if (safeStdout) { + process.stdout.write(safeStdout); + } + if (safeStderr) { + process.stderr.write(safeStderr); + } + if (timedOut) { + writeFailureReport(args, "timed out"); + return 1; + } + if (result.error) { + writeFailureReport(args, "could not start"); + return 1; + } + const reportStatus = existingReportStatus(args); + if (!reportStatus) { + writeFailureReport( + args, + result.signal + ? "exceeded its process limit" + : existsSync(args.report) + ? "wrote an invalid report" + : "did not write a report", + ); + return 1; + } + return result.status === 0 && reportStatus === "pass" ? 0 : 1; +} + +try { + process.exitCode = await run(process.argv.slice(2)); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/scripts/plugin-npm-security-scan.mts b/scripts/plugin-npm-security-scan.mts index ec6d08f0aec3..ff937c775a96 100644 --- a/scripts/plugin-npm-security-scan.mts +++ b/scripts/plugin-npm-security-scan.mts @@ -2,102 +2,122 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + constrainPluginNpmSecurityScanReport, + MAX_PUBLISHABLE_PLUGIN_PACKAGES, runPluginNpmSecurityScan, type PluginNpmSecurityScanReport, } from "./lib/plugin-npm-security-scan.mts"; -function parseArgs(argv: string[]): { - candidateRoot: string; +const MAX_EXPECTED_PACKAGES_JSON_BYTES = 256 * 1024; + +type ParsedArgs = { + artifactRoot: string; candidateSha: string; + expectedPackages: unknown; outputPath: string; toolingSha: string; -} { - let candidateRoot = ""; - let candidateSha = ""; - let toolingSha = ""; - let outputPath = ""; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--candidate-root") { - candidateRoot = argv[index + 1] ?? ""; - index += 1; - } else if (arg === "--candidate-sha") { - candidateSha = argv[index + 1] ?? ""; - index += 1; - } else if (arg === "--tooling-sha") { - toolingSha = argv[index + 1] ?? ""; - index += 1; - } else if (arg === "--report") { - outputPath = argv[index + 1] ?? ""; - index += 1; - } else { - throw new Error(`Unknown argument: ${arg}`); +}; + +function parseArgs(argv: string[]): ParsedArgs { + const values = new Map(); + 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 npm security scan argument near ${String(name)}.`); } + values.set(name, value); } - if (!candidateRoot) { - throw new Error("--candidate-root is required"); + const artifactRoot = values.get("--artifact-root") ?? ""; + const candidateSha = values.get("--candidate-sha") ?? ""; + const expectedPackagesJson = values.get("--expected-packages-json") ?? ""; + const outputPath = values.get("--report") ?? ""; + const toolingSha = values.get("--tooling-sha") ?? ""; + if (!artifactRoot) { + throw new Error("--artifact-root is required."); } if (!/^[0-9a-f]{40}$/u.test(candidateSha)) { - throw new Error("--candidate-sha must be a full lowercase commit SHA"); + throw new Error("--candidate-sha must be a full lowercase commit SHA."); } if (!/^[0-9a-f]{40}$/u.test(toolingSha)) { - throw new Error("--tooling-sha must be a full lowercase commit SHA"); + throw new Error("--tooling-sha must be a full lowercase commit SHA."); + } + if ( + !expectedPackagesJson || + Buffer.byteLength(expectedPackagesJson, "utf8") > MAX_EXPECTED_PACKAGES_JSON_BYTES + ) { + throw new Error("--expected-packages-json is outside the byte limit."); } if (!outputPath) { - throw new Error("--report is required"); + throw new Error("--report is required."); + } + const expectedPackages = JSON.parse(expectedPackagesJson) as unknown; + if ( + !Array.isArray(expectedPackages) || + expectedPackages.length > MAX_PUBLISHABLE_PLUGIN_PACKAGES + ) { + throw new Error("--expected-packages-json is not a bounded package inventory."); } return { - candidateRoot: resolve(candidateRoot), + artifactRoot: resolve(artifactRoot), candidateSha, + expectedPackages, outputPath: resolve(outputPath), toolingSha, }; } async function writeReport(outputPath: string, report: PluginNpmSecurityScanReport): Promise { + const constrained = constrainPluginNpmSecurityScanReport(report); await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + await writeFile(outputPath, `${JSON.stringify(constrained)}\n`, "utf8"); } -function sanitizeErrorMessage( - error: unknown, - args: ReturnType | undefined, -): string { +function sanitizeErrorMessage(error: unknown, args: ParsedArgs | undefined): string { let message = error instanceof Error ? error.message : String(error); for (const [path, replacement] of [ - [args?.candidateRoot, ""], + [args?.artifactRoot, ""], + [args?.outputPath ? dirname(args.outputPath) : undefined, ""], [process.cwd(), ""], ] as const) { if (path) { message = message.replaceAll(path, replacement); } } - return message.replaceAll( - /\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, - "", - ); + return message + .replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "") + .replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1"); +} + +function failureReport(args: ParsedArgs, message: string): PluginNpmSecurityScanReport { + return { + candidateSha: args.candidateSha, + errors: [message], + layout: null, + packages: [], + schemaVersion: 1, + status: "fail", + summary: { + findingCount: 0, + packageCount: 0, + reviewedCriticalFindingCount: 0, + unexpectedCriticalFindingCount: 0, + }, + toolingSha: args.toolingSha, + }; } async function main(argv = process.argv.slice(2)): Promise { - let args: ReturnType | undefined; + let args: ParsedArgs | undefined; try { args = parseArgs(argv); const report = await runPluginNpmSecurityScan({ - candidateDir: args.candidateRoot, + artifactRoot: args.artifactRoot, + candidateSha: args.candidateSha, + expectedPackages: args.expectedPackages, toolingDir: process.cwd(), + toolingSha: args.toolingSha, }); - if (report.candidateSha !== args.candidateSha) { - report.errors.push( - `Candidate checkout resolved to ${report.candidateSha}, expected ${args.candidateSha}.`, - ); - report.status = "fail"; - } - if (report.toolingSha !== args.toolingSha) { - report.errors.push( - `Tooling checkout resolved to ${report.toolingSha}, expected ${args.toolingSha}.`, - ); - report.status = "fail"; - } await writeReport(args.outputPath, report); console.log( `Plugin npm security scan ${report.status}: ${report.summary.packageCount} packages, layout=${report.layout ?? "unknown"}, candidate=${report.candidateSha}, tooling=${report.toolingSha}`, @@ -110,20 +130,7 @@ async function main(argv = process.argv.slice(2)): Promise { const message = sanitizeErrorMessage(error, args); console.error(`Plugin npm security scan failed: ${message}`); if (args) { - await writeReport(args.outputPath, { - candidateSha: args.candidateSha, - errors: [message], - layout: null, - packages: [], - schemaVersion: 1, - status: "fail", - summary: { - packageCount: 0, - reviewedCriticalFindingCount: 0, - unexpectedCriticalFindingCount: 0, - }, - toolingSha: args.toolingSha, - }); + await writeReport(args.outputPath, failureReport(args, message)); } return 1; } diff --git a/scripts/plugin-publication-artifact.mjs b/scripts/plugin-publication-artifact.mjs index 9ab8e31ec3d8..8891215050ce 100644 --- a/scripts/plugin-publication-artifact.mjs +++ b/scripts/plugin-publication-artifact.mjs @@ -442,6 +442,10 @@ export function inspectPackageTarballBytes(inputBytes, options = {}) { if (!(inputBytes instanceof Uint8Array)) { throw new Error("Plugin tarball bytes must be a Uint8Array."); } + const onFile = options.onFile; + if (onFile !== undefined && typeof onFile !== "function") { + throw new Error("Plugin tarball onFile option must be a function."); + } const tarballBytes = Buffer.from(inputBytes.buffer, inputBytes.byteOffset, inputBytes.byteLength); const limits = normalizeTarInspectionOptions(options); if (tarballBytes.length === 0 || tarballBytes.length > limits.maxArchiveBytes) { @@ -585,6 +589,7 @@ export function inspectPackageTarballBytes(inputBytes, options = {}) { type: "file", }; inventory.push(entry); + onFile?.({ content, path: safePath }); if (safePath === "package/package.json") { if (content.length === 0 || content.length > MAX_MANIFEST_BYTES) { throw new Error( diff --git a/test/scripts/plugin-npm-security-scan.test.ts b/test/scripts/plugin-npm-security-scan.test.ts index 23569b4c4a05..5730204a885a 100644 --- a/test/scripts/plugin-npm-security-scan.test.ts +++ b/test/scripts/plugin-npm-security-scan.test.ts @@ -1,32 +1,167 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + assertCanonicalNpmPackageName, assertCompleteScannerSummary, buildPluginNpmSecurityScanReport, collectNpmPackedFiles, + constrainPluginNpmSecurityScanReport, + listPluginNpmSecurityArtifacts, + listPublishablePluginPackages, normalizePackedFindingPath, parsePacklistFiles, resolveReviewedSourceLayout, - runPluginNpmSecurityScan, scanPublishablePluginPackages, stageScannerRelevantPackedFiles, + stageScannerRelevantPluginTarballFiles, + type PublishablePluginPackage, + type ScanPackageResult, } from "../../scripts/lib/plugin-npm-security-scan.mts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; +const CANDIDATE_SHA = "1".repeat(40); +const TOOLING_SHA = "2".repeat(40); const tempDirs = useAutoCleanupTempDirTracker(afterEach); +function initGitRepo(root: string): void { + execFileSync("git", ["init", "--quiet", root]); + execFileSync("git", ["-C", root, "config", "user.email", "test@example.invalid"]); + execFileSync("git", ["-C", root, "config", "user.name", "OpenClaw Test"]); +} + +function writePublishableManifest( + root: string, + extensionId: string, + packageName: string, + extra: Record = {}, +): void { + const packageDir = join(root, "extensions", extensionId); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + `${JSON.stringify({ + name: packageName, + openclaw: { release: { publishToNpm: true } }, + version: "1.0.0", + ...extra, + })}\n`, + "utf8", + ); + execFileSync("git", ["-C", root, "add", `extensions/${extensionId}/package.json`]); +} + +function writePluginArtifact(params: { + extensionId: string; + files: Record; + packageName: string; + version?: string; +}) { + const root = tempDirs.make("openclaw-plugin-npm-security-artifact-"); + const packageRoot = join(root, "source"); + const artifactDir = join(root, "artifacts", params.extensionId); + const packageVersion = params.version ?? "1.0.0"; + mkdirSync(packageRoot, { recursive: true }); + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + `${JSON.stringify({ name: params.packageName, version: packageVersion })}\n`, + "utf8", + ); + writeFileSync( + join(packageRoot, "openclaw.plugin.json"), + `${JSON.stringify({ id: params.extensionId })}\n`, + "utf8", + ); + for (const [relativePath, content] of Object.entries(params.files)) { + const filePath = join(packageRoot, relativePath); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content); + } + const packOutput = execFileSync( + "npm", + ["pack", "--json", "--ignore-scripts", "--pack-destination", artifactDir], + { cwd: packageRoot, encoding: "utf8" }, + ); + const packEntries = JSON.parse(packOutput) as Array<{ filename?: unknown }>; + const tarballName = packEntries[0]?.filename; + if (typeof tarballName !== "string") { + throw new Error("npm pack fixture did not return a tarball filename"); + } + const tarballPath = join(artifactDir, tarballName); + const tarballSha256 = createHash("sha256").update(readFileSync(tarballPath)).digest("hex"); + writeFileSync( + join(artifactDir, "plugin-npm-security-artifact.json"), + `${JSON.stringify({ + candidateSha: CANDIDATE_SHA, + extensionId: params.extensionId, + packageDir: `extensions/${params.extensionId}`, + packageName: params.packageName, + packageVersion, + schemaVersion: 1, + tarballName, + tarballSha256, + toolingSha: TOOLING_SHA, + })}\n`, + "utf8", + ); + return { + artifact: { + artifactDir, + candidateSha: CANDIDATE_SHA, + extensionId: params.extensionId, + packageDir: `extensions/${params.extensionId}`, + packageName: params.packageName, + packageVersion, + tarballPath, + tarballSha256, + toolingSha: TOOLING_SHA, + }, + artifactRoot: join(root, "artifacts"), + expectedPackage: { + extensionId: params.extensionId, + packageDir: `extensions/${params.extensionId}`, + packageName: params.packageName, + packageVersion, + } satisfies PublishablePluginPackage, + packageRoot, + tarballPath, + }; +} + +function currentLayoutFindings(): string[] { + return [ + "@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts", + "@openclaw/codex:dangerous-exec:src/app-server/transport-process-containment.ts", + ...Array.from( + { length: 13 }, + () => "@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts", + ), + ]; +} + +function syntheticResult( + packageName: string, + overrides: Partial = {}, +): ScanPackageResult { + return { + expectedReviewedCriticalFindings: [], + packageName, + packageVersion: "1.0.0", + packedFileCount: 1, + reviewedCriticalFindings: [], + scanFindingCount: 0, + tarballSha256: "a".repeat(64), + unexpectedCriticalFindings: [], + ...overrides, + }; +} + describe("scripts/lib/plugin-npm-security-scan.mts", () => { it("accepts only the complete current and frozen-legacy source layouts", () => { - const current = [ - "@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/sandbox-child.ts", - "@openclaw/codex:dangerous-exec:src/app-server/transport-process-containment.ts", - ...Array.from( - { length: 13 }, - () => "@openclaw/codex:dangerous-exec:src/app-server/transport.process.test.ts", - ), - ]; + const current = currentLayoutFindings(); const frozenLegacy = [ "@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/http.ts", "@openclaw/codex:dangerous-exec:src/app-server/sandbox-exec-server/processes.ts", @@ -42,7 +177,7 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { expect(resolveReviewedSourceLayout([...current, current[0]!])).toBeUndefined(); }); - it("collects candidate package files without running lifecycle scripts", async () => { + it("collects package files without running lifecycle or candidate replacement helpers", async () => { const packageDir = tempDirs.make("openclaw-plugin-npm-security-pack-"); const replacementMarker = join(packageDir, "replacement-helper-ran"); const lifecycleMarkers = ["prepare", "prepack", "postpack"].map((name) => @@ -50,37 +185,34 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { ); writeFileSync( join(packageDir, "package.json"), - `${JSON.stringify( - { - name: "@openclaw/test-inert-package", - version: "1.0.0", - scripts: { - prepare: `node -e "require('node:fs').writeFileSync(${JSON.stringify( - lifecycleMarkers[0], - )}, 'ran')"`, - prepack: `node -e "require('node:fs').writeFileSync(${JSON.stringify( - lifecycleMarkers[1], - )}, 'ran')"`, - postpack: `node -e "require('node:fs').writeFileSync(${JSON.stringify( - lifecycleMarkers[2], - )}, 'ran')"`, - }, - }, - null, - 2, - )}\n`, + `${JSON.stringify({ + name: "@openclaw/test-inert-package", + scripts: Object.fromEntries( + ["prepare", "prepack", "postpack"].map((name, index) => [ + name, + `node -e "require('node:fs').writeFileSync(${JSON.stringify(lifecycleMarkers[index])}, 'ran')"`, + ]), + ), + version: "1.0.0", + })}\n`, "utf8", ); writeFileSync(join(packageDir, "index.js"), "export const value = 1;\n", "utf8"); writeFileSync( join(packageDir, "plugin-npm-pack-files.mjs"), - `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify( - replacementMarker, - )}, "ran");\n`, + `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(replacementMarker)}, "ran");\n`, "utf8", ); - const packedFiles = await collectNpmPackedFiles(packageDir, "@openclaw/test-inert-package"); + const trustedHelper = join(tempDirs.make("openclaw-plugin-npm-security-helper-"), "helper.mjs"); + writeFileSync( + trustedHelper, + 'process.stdout.write(JSON.stringify(["index.js", "package.json", "plugin-npm-pack-files.mjs"]));\n', + "utf8", + ); + const packedFiles = await collectNpmPackedFiles(packageDir, "@openclaw/test-inert-package", { + helperPath: trustedHelper, + }); expect(packedFiles).toContain("index.js"); expect(packedFiles).toContain("package.json"); @@ -112,40 +244,60 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { ).toThrow("duplicate path"); expect(() => parsePacklistFiles( - JSON.stringify(Array.from({ length: 50_001 }, (_, index) => `file-${index}.js`)), + JSON.stringify(Array.from({ length: 20_001 }, (_, index) => `file-${index}.js`)), "@openclaw/test", ), ).toThrow("file-count limit"); }); - it("fails closed when the trusted packlist helper exceeds process limits", async () => { + it("preserves bounded packlist helper failure categories", async () => { const root = tempDirs.make("openclaw-plugin-packlist-helper-limits-"); const packageDir = tempDirs.make("openclaw-plugin-packlist-helper-package-"); const timeoutHelper = join(root, "timeout.mjs"); - const oomHelper = join(root, "oom.mjs"); + const failedHelper = join(root, "failed.mjs"); writeFileSync(timeoutHelper, "await new Promise(() => {});\n", "utf8"); - writeFileSync( - oomHelper, - "const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n", - "utf8", - ); + writeFileSync(failedHelper, "process.exit(7);\n", "utf8"); await expect( collectNpmPackedFiles(packageDir, "@openclaw/test-timeout", { helperPath: timeoutHelper, timeoutMs: 25, }), - ).rejects.toThrow("trusted packlist helper exceeded its resource limits"); + ).rejects.toThrow("trusted packlist helper timed out"); await expect( - collectNpmPackedFiles(packageDir, "@openclaw/test-oom", { - helperPath: oomHelper, - maxOldSpaceMb: 16, - timeoutMs: 10_000, + collectNpmPackedFiles(packageDir, "@openclaw/test-failed", { + helperPath: failedHelper, }), - ).rejects.toThrow("trusted packlist helper exceeded its resource limits"); - }, 15_000); + ).rejects.toThrow("trusted packlist helper failed"); + }); - it("fails closed on truncated scans and packed-file path escapes", () => { + it("bounds manifests and rejects noncanonical or duplicate package identities", async () => { + expect(() => assertCanonicalNpmPackageName("OpenClaw/Bad", "fixture")).toThrow( + "invalid npm package name", + ); + + const duplicateRoot = tempDirs.make("openclaw-plugin-npm-security-duplicates-"); + initGitRepo(duplicateRoot); + writePublishableManifest(duplicateRoot, "one", "@openclaw/duplicate"); + writePublishableManifest(duplicateRoot, "two", "@openclaw/duplicate"); + await expect(listPublishablePluginPackages(duplicateRoot)).rejects.toThrow( + "duplicate publishable package", + ); + await expect( + listPublishablePluginPackages(duplicateRoot, { maxPackageManifests: 1 }), + ).rejects.toThrow("package-count limit"); + + const manifestRoot = tempDirs.make("openclaw-plugin-npm-security-manifest-"); + initGitRepo(manifestRoot); + writePublishableManifest(manifestRoot, "large", "@openclaw/large", { + description: "x".repeat(1024), + }); + await expect( + listPublishablePluginPackages(manifestRoot, { maxManifestBytes: 256 }), + ).rejects.toThrow("manifest exceeds the byte limit"); + }); + + it("fails closed on truncated scans, source escapes, and tarball symlinks", () => { expect(() => assertCompleteScannerSummary("@openclaw/test", { truncated: true })).toThrow( "security scan reached its file limit", ); @@ -160,182 +312,209 @@ describe("scripts/lib/plugin-npm-security-scan.mts", () => { const outsideFile = join(outsideDir, "outside.ts"); writeFileSync(outsideFile, "export const value = 1;\n", "utf8"); symlinkSync(outsideFile, join(packageDir, "escape.ts")); - expect(() => stageScannerRelevantPackedFiles(packageDir, ["escape.ts"])).toThrow( "not a regular file", ); - const oversizeDir = tempDirs.make("openclaw-plugin-npm-security-oversize-"); - writeFileSync(join(oversizeDir, "oversize.ts"), Buffer.alloc(1024 * 1024 + 1)); - expect(() => stageScannerRelevantPackedFiles(oversizeDir, ["oversize.ts"])).toThrow( - "per-file byte limit", + const artifact = writePluginArtifact({ + extensionId: "symlink", + files: { "index.js": "export const value = 1;\n" }, + packageName: "@openclaw/test-symlink", + }); + symlinkSync(outsideFile, join(artifact.packageRoot, "escape.ts")); + execFileSync( + "tar", + ["-czf", artifact.tarballPath, "-C", join(artifact.packageRoot, ".."), "source"], + { env: { ...process.env, COPYFILE_DISABLE: "1" } }, ); - - const boundedDir = tempDirs.make("openclaw-plugin-npm-security-bounds-"); - writeFileSync(join(boundedDir, "one.ts"), "1", "utf8"); - writeFileSync(join(boundedDir, "two.ts"), "22", "utf8"); - expect(() => - stageScannerRelevantPackedFiles(boundedDir, ["one.ts", "two.ts"], { - maxPackedFileBytes: 10, - maxPackedFiles: 10, - maxPackedTotalBytes: 10, - maxFileBytes: 10, - maxFiles: 1, - maxTotalBytes: 10, - }), - ).toThrow("file-count limit"); - expect(() => - stageScannerRelevantPackedFiles(boundedDir, ["two.ts"], { - maxPackedFileBytes: 10, - maxPackedFiles: 10, - maxPackedTotalBytes: 10, - maxFileBytes: 10, - maxFiles: 10, - maxTotalBytes: 1, - }), - ).toThrow("total-byte limit"); - - const assetDir = tempDirs.make("openclaw-plugin-npm-security-asset-bounds-"); - writeFileSync(join(assetDir, "asset.bin"), Buffer.alloc(11)); - expect(() => - stageScannerRelevantPackedFiles(assetDir, ["asset.bin"], { - maxPackedFileBytes: 20, - maxPackedFiles: 10, - maxPackedTotalBytes: 10, - maxFileBytes: 10, - maxFiles: 10, - maxTotalBytes: 10, - }), - ).toThrow("Packed input exceeds the total-byte limit"); + expect(() => stageScannerRelevantPluginTarballFiles(artifact.tarballPath)).toThrow(); }); - it("normalizes only exact bundler hash filenames", () => { + it("accounts for all packed bytes and scans only exact bundler hash filenames", () => { + const artifact = writePluginArtifact({ + extensionId: "packed", + files: { + "asset.bin": Buffer.alloc(128), + "dist/service-BaCqPs_5.js": "export const value = 1;\n", + "dist/service-malware.js": "export const value = 2;\n", + }, + packageName: "@openclaw/test-packed", + }); + const staged = stageScannerRelevantPluginTarballFiles(artifact.tarballPath); + try { + expect(staged.inspection.inventory.map((entry) => entry.path)).toContain("package/asset.bin"); + expect(staged.packedFiles).toContain("asset.bin"); + } finally { + rmSync(staged.stageDir, { force: true, recursive: true }); + } expect(normalizePackedFindingPath("dist/service-BaCqPs_5.js")).toBe("dist/service-.js"); expect(normalizePackedFindingPath("dist/service-malware.js")).toBe("dist/service-malware.js"); }); - it("retains expected SHAs and redacts candidate paths in failure reports", () => { - const root = tempDirs.make("openclaw-plugin-npm-security-failure-"); - const candidateRoot = join(root, "missing-candidate"); - const reportPath = join(root, "report.json"); - const candidateSha = "1".repeat(40); - const toolingSha = "2".repeat(40); - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - "scripts/plugin-npm-security-scan.mts", - "--candidate-root", - candidateRoot, - "--candidate-sha", - candidateSha, - "--tooling-sha", - toolingSha, - "--report", - reportPath, - ], - { cwd: process.cwd(), encoding: "utf8" }, - ); - const report = JSON.parse(readFileSync(reportPath, "utf8")) as { - candidateSha: string; - toolingSha: string; - }; - - expect(result.status).toBe(1); - expect(report.candidateSha).toBe(candidateSha); - expect(report.toolingSha).toBe(toolingSha); - expect(JSON.stringify(report)).not.toContain(candidateRoot); - expect(result.stderr).not.toContain(candidateRoot); - }); - - it("finds malicious packed code and retains it when another package fails", async () => { - const maliciousDir = tempDirs.make("openclaw-plugin-npm-security-malicious-"); - writeFileSync( - join(maliciousDir, "package.json"), - `${JSON.stringify({ - files: ["index.js"], - name: "@openclaw/test-malicious", - version: "1.0.0", - })}\n`, - "utf8", - ); - writeFileSync( - join(maliciousDir, "index.js"), - `const { execSync } = require("node:child_process");\nexecSync("id");\n`, - "utf8", - ); - - const oversizedDir = tempDirs.make("openclaw-plugin-npm-security-failing-"); - writeFileSync( - join(oversizedDir, "package.json"), - `${JSON.stringify({ - files: ["oversized.js"], - name: "@openclaw/test-oversized", - version: "1.0.0", - })}\n`, - "utf8", - ); - writeFileSync(join(oversizedDir, "oversized.js"), Buffer.alloc(1024 * 1024 + 1)); + it("finds malicious final-artifact 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", + files: { + "index.js": `const { execSync } = require("node:child_process");\nexecSync("id");\n`, + "scripts/plugin-npm-security-scan.mts": `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");\n`, + }, + packageName: "@openclaw/test-malicious", + }); + const oversized = writePluginArtifact({ + extensionId: "oversized", + files: { "oversized.js": Buffer.alloc(1024 * 1024 + 1) }, + packageName: "@openclaw/test-oversized", + }); const { packageResults, scanErrors } = await scanPublishablePluginPackages([ - { packageDir: oversizedDir, packageName: "@openclaw/test-oversized" }, - { packageDir: maliciousDir, packageName: "@openclaw/test-malicious" }, + oversized.artifact, + malicious.artifact, ]); + expect(existsSync(marker)).toBe(false); expect(packageResults).toHaveLength(1); - expect(packageResults[0]?.unexpectedCriticalFindings).toEqual([ - { - line: 2, - path: "index.js", - ruleId: "dangerous-exec", - }, - ]); + expect(packageResults[0]?.unexpectedCriticalFindings).toContainEqual({ + line: 2, + path: "index.js", + ruleId: "dangerous-exec", + }); expect(scanErrors).toHaveLength(1); expect(scanErrors[0]).toContain("@openclaw/test-oversized"); - expect(scanErrors[0]).not.toContain(oversizedDir); + expect(scanErrors[0]).not.toContain(oversized.artifact.tarballPath); const report = buildPluginNpmSecurityScanReport({ - candidateSha: "1".repeat(40), + candidateSha: CANDIDATE_SHA, packageResults, scanErrors, - toolingSha: "2".repeat(40), + toolingSha: TOOLING_SHA, }); expect(report.status).toBe("fail"); expect(report.errors).toContainEqual(expect.stringContaining("unexpected critical findings")); expect(report.errors).toContainEqual(expect.stringContaining("package scan failed")); expect(JSON.stringify(report)).not.toContain("execSync"); + }); + + it("validates immutable artifact identity and exact package plans", () => { + const artifact = writePluginArtifact({ + extensionId: "identity", + files: { "index.js": "export const value = 1;\n" }, + packageName: "@openclaw/test-identity", + }); + expect( + listPluginNpmSecurityArtifacts({ + artifactRoot: artifact.artifactRoot, + candidateSha: CANDIDATE_SHA, + expectedPackages: [artifact.expectedPackage], + toolingSha: TOOLING_SHA, + }).map((entry) => entry.packageName), + ).toEqual(["@openclaw/test-identity"]); + expect(() => + listPluginNpmSecurityArtifacts({ + artifactRoot: artifact.artifactRoot, + candidateSha: CANDIDATE_SHA, + expectedPackages: [], + toolingSha: TOOLING_SHA, + }), + ).toThrow("does not match the trusted package plan"); + }); + + it("caps total findings and emits byte-identical bounded reports", () => { + const packageResults = [ + syntheticResult("@openclaw/codex", { + reviewedCriticalFindings: currentLayoutFindings(), + scanFindingCount: 51, + }), + ]; + const report = buildPluginNpmSecurityScanReport({ + candidateSha: CANDIDATE_SHA, + maxTotalFindings: 50, + packageResults, + toolingSha: TOOLING_SHA, + }); + expect(report.errors).toContain( + "Plugin npm security scan exceeded the total finding-count limit.", + ); expect(JSON.stringify(report)).toBe( JSON.stringify( buildPluginNpmSecurityScanReport({ - candidateSha: report.candidateSha, + candidateSha: CANDIDATE_SHA, + maxTotalFindings: 50, packageResults: structuredClone(packageResults).reverse(), - scanErrors: structuredClone(scanErrors).reverse(), - toolingSha: report.toolingSha, + toolingSha: TOOLING_SHA, }), ), ); + expect(constrainPluginNpmSecurityScanReport(report, 64).errors).toEqual([ + "Plugin npm security scan report exceeded the byte limit.", + ]); }); - it("scans the complete current-root publishable plugin inventory", async () => { - const report = await runPluginNpmSecurityScan({ - candidateDir: process.cwd(), - toolingDir: process.cwd(), - }); + it("writes sanitized exact-identity reports when the bounded scanner times out or OOMs", () => { + const root = tempDirs.make("openclaw-plugin-npm-security-runner-"); + const timeoutChild = join(root, "timeout.mjs"); + const oomChild = join(root, "oom.mjs"); + writeFileSync(timeoutChild, "await new Promise(() => {});\n", "utf8"); + writeFileSync( + oomChild, + "const values = [];\nwhile (true) values.push(new Array(100000).fill(Math.random()));\n", + "utf8", + ); + for (const [label, child, timeoutMs] of [ + ["timeout", timeoutChild, "25"], + ["oom", oomChild, "10000"], + ] as const) { + const reportPath = join(root, `${label}.json`); + const result = spawnSync( + process.execPath, + [ + "scripts/plugin-npm-security-scan-runner.mjs", + "--artifact-root", + join(root, "artifacts"), + "--candidate-sha", + CANDIDATE_SHA, + "--expected-packages-json", + "[]", + "--tooling-sha", + TOOLING_SHA, + "--report", + reportPath, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NODE_ENV: "test", + OPENCLAW_PLUGIN_SECURITY_RUNNER_CHILD: child, + OPENCLAW_PLUGIN_SECURITY_RUNNER_HEAP_MB: "16", + OPENCLAW_PLUGIN_SECURITY_RUNNER_TIMEOUT_MS: timeoutMs, + }, + timeout: 15_000, + }, + ); + const report = JSON.parse(readFileSync(reportPath, "utf8")) as { + candidateSha: string; + toolingSha: string; + }; + expect(result.status).toBe(1); + expect(report).toMatchObject({ + candidateSha: CANDIDATE_SHA, + toolingSha: TOOLING_SHA, + }); + expect(`${result.stdout}${result.stderr}${JSON.stringify(report)}`).not.toContain(root); + } + }, 30_000); - expect(report).toMatchObject({ - layout: "current", - status: "pass", - summary: { - unexpectedCriticalFindingCount: 0, - }, - }); - expect(report.summary.packageCount).toBe(report.packages.length); - expect(report.summary.packageCount).toBeGreaterThan(0); - expect( - report.packages - .find((entry) => entry.packageName === "@openclaw/acpx") - ?.reviewedCriticalFindings.some((finding) => finding.endsWith(".test.ts")), - ).toBe(true); - }, 120_000); + it("retains the complete current-root publishable plugin inventory contract", async () => { + const packages = await listPublishablePluginPackages(process.cwd()); + expect(packages.length).toBeGreaterThan(0); + expect(packages.map((plugin) => plugin.packageName)).toContain("@openclaw/acpx"); + expect(new Set(packages.map((plugin) => plugin.packageName)).size).toBe(packages.length); + expect(packages).toEqual( + packages.toSorted((left, right) => + left.packageName < right.packageName ? -1 : left.packageName > right.packageName ? 1 : 0, + ), + ); + }); }); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 0773bb7b8994..4179f0bd4310 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -1,6 +1,8 @@ // Plugin Prerelease Test Plan tests cover plugin prerelease test plan script behavior. import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; import { findLaneByName } from "../../scripts/lib/docker-e2e-plan.mts"; @@ -279,19 +281,25 @@ 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 resolver = pluginWorkflow.jobs["resolve-candidate"]; + const securityPlan = pluginWorkflow.jobs["plugin-npm-security-plan"]; + const securityPackage = pluginWorkflow.jobs["plugin-npm-security-package"]; const securityScan = pluginWorkflow.jobs["plugin-npm-security-scan"]; const nodeShard = pluginWorkflow.jobs["plugin-prerelease-node-shard"]; const trustedCheckout = securityScan.steps.find( (step: WorkflowStep) => step.name === "Checkout trusted scanner tooling", ); - const candidateCheckout = securityScan.steps.find( + const candidateCheckout = securityPlan.steps.find( (step: WorkflowStep) => step.name === "Checkout candidate as inert data", ); + const packageCandidateCheckout = securityPackage.steps.find( + (step: WorkflowStep) => step.name === "Checkout candidate package source", + ); const installDependencies = securityScan.steps.find( (step: WorkflowStep) => step.name === "Install trusted scanner dependencies", ); const runSecurityScan = securityScan.steps.find( - (step: WorkflowStep) => step.name === "Scan candidate plugin packages", + (step: WorkflowStep) => step.name === "Scan immutable plugin tarballs", ); const uploadReport = securityScan.steps.find( (step: WorkflowStep) => step.name === "Upload plugin npm security scan report", @@ -316,20 +324,29 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { uses: CHECKOUT_V6, with: { "persist-credentials": false, - ref: "${{ needs.preflight.outputs.checkout_revision }}", + ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}", path: ".release-candidate", }, }); + expect(packageCandidateCheckout).toMatchObject({ + uses: CHECKOUT_V6, + with: { + "persist-credentials": false, + ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}", + path: ".release-candidate", + }, + }); + expect( + securityScan.steps.find( + (step: WorkflowStep) => step.name === "Checkout candidate as inert data", + ), + ).toBeUndefined(); expect(installDependencies?.run).toBe( "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts", ); - expect(runSecurityScan?.run).toContain( - "node --import tsx scripts/plugin-npm-security-scan.mts", - ); - expect(runSecurityScan?.run).not.toContain( - ".release-candidate/scripts/plugin-npm-security-scan.mts", - ); - expect(runSecurityScan?.run).toContain("--candidate-root .release-candidate"); + expect(runSecurityScan?.run).toContain("node scripts/plugin-npm-security-scan-runner.mjs"); + expect(runSecurityScan?.run).toContain("--artifact-root"); + expect(runSecurityScan?.run).not.toContain("--candidate-root"); expect(runSecurityScan?.run).toContain('--candidate-sha "$CANDIDATE_SHA"'); expect(uploadReport).toMatchObject({ if: "always()", @@ -339,7 +356,18 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { path: "${{ runner.temp }}/plugin-npm-security-scan.json", }, }); - expect(nodeShard.needs).toEqual(["preflight"]); + expect(resolver.outputs).toEqual({ + checkout_revision: "${{ steps.resolve.outputs.checkout_revision }}", + }); + expect(securityScan.needs).toEqual([ + "resolve-candidate", + "plugin-npm-security-plan", + "plugin-npm-security-package", + ]); + expect(securityScan.needs).not.toContain("preflight"); + expect(securityPackage.permissions).toEqual({ contents: "read" }); + expect(securityPackage.secrets).toBeUndefined(); + 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"); expect(pluginSource).not.toContain("node_test_exclude_patterns_json"); @@ -348,6 +376,56 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(pluginDispatch?.run).not.toContain("node_test_exclude_patterns_json"); }); + it("keeps late candidate GITHUB_OUTPUT writes outside trusted candidate identity", () => { + const pluginWorkflow = readPluginPrereleaseWorkflow(); + const preflight = pluginWorkflow.jobs.preflight; + const resolver = pluginWorkflow.jobs["resolve-candidate"]; + const securityScan = pluginWorkflow.jobs["plugin-npm-security-scan"]; + const root = mkdtempSync(join(tmpdir(), "openclaw-plugin-late-output-")); + try { + const candidateModule = join(root, "candidate.mjs"); + const githubOutput = join(root, "github-output"); + writeFileSync( + candidateModule, + [ + 'import { appendFileSync } from "node:fs";', + `process.on("exit", () => appendFileSync(process.env.GITHUB_OUTPUT, ${JSON.stringify(`checkout_revision=${"f".repeat(40)}\n`)}));`, + ].join("\n"), + "utf8", + ); + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + 'await import(process.argv[1]); const { appendFileSync } = await import("node:fs"); appendFileSync(process.env.GITHUB_OUTPUT, "run_plugin_prerelease_suite=true\\n");', + candidateModule, + ], + { env: { ...process.env, GITHUB_OUTPUT: githubOutput } }, + ); + const lateCandidateOutput = new Map( + readFileSync(githubOutput, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=", 2)), + ); + + expect(lateCandidateOutput.get("checkout_revision")).toBe("f".repeat(40)); + expect(preflight.outputs).not.toHaveProperty("checkout_revision"); + expect(resolver.outputs.checkout_revision).toBe( + "${{ steps.resolve.outputs.checkout_revision }}", + ); + expect(JSON.stringify(securityScan)).toContain( + "needs.resolve-candidate.outputs.checkout_revision", + ); + expect(JSON.stringify(securityScan)).not.toContain( + "needs.preflight.outputs.checkout_revision", + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("wires the full plugin prerelease plan into its release workflow", () => { const workflow = readCiWorkflow(); const preflight = workflow.jobs.preflight; @@ -402,7 +480,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(staticShard).toEqual({ if: "needs.preflight.outputs.run_plugin_prerelease_static == 'true'", name: "${{ matrix.check_name }}", - needs: ["preflight"], + needs: ["resolve-candidate", "preflight"], permissions: { contents: "read", }, @@ -416,7 +494,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { "fetch-depth": 1, "fetch-tags": false, "persist-credentials": false, - ref: "${{ needs.preflight.outputs.checkout_revision }}", + ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}", submodules: false, }, }, @@ -595,7 +673,6 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { type: "string", }); expect(pluginManifestEnv).toEqual({ - EXPECTED_SHA: "${{ inputs.expected_sha }}", FULL_RELEASE_VALIDATION: "${{ inputs.full_release_validation && 'true' || 'false' }}", }); expect(pluginManifestScript).toContain( @@ -605,7 +682,6 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { "const runDocker = fullReleaseValidation && dockerLanes.length > 0;", ); expect(pluginPreflight.outputs).toEqual({ - checkout_revision: "${{ steps.manifest.outputs.checkout_revision }}", plugin_prerelease_docker_lanes: "${{ steps.manifest.outputs.plugin_prerelease_docker_lanes }}", plugin_prerelease_extension_matrix: @@ -625,8 +701,8 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { ); expect(securityScan).toMatchObject({ name: "plugin-npm-security-scan", - needs: ["preflight"], - permissions: { contents: "read" }, + needs: ["resolve-candidate", "plugin-npm-security-plan", "plugin-npm-security-package"], + permissions: { actions: "read", contents: "read" }, "runs-on": "ubuntu-24.04", "timeout-minutes": 20, }); @@ -643,7 +719,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { extensionShard.steps.find((step: WorkflowStep) => step.name === "Run extension shard").run, ).toContain("--retry=1"); expect(inspector.name).toBe("plugin-prerelease-inspector"); - expect(inspector.needs).toEqual(["preflight"]); + expect(inspector.needs).toEqual(["resolve-candidate", "preflight"]); expect(inspector.if).toBe("needs.preflight.outputs.run_plugin_prerelease_suite == 'true'"); expect(inspector["continue-on-error"]).toBe(true); expect(inspector["runs-on"]).toBe("ubuntu-24.04"); @@ -687,7 +763,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(dockerSuite).toMatchObject({ if: "${{ inputs.full_release_validation && needs.preflight.outputs.run_plugin_prerelease_docker == 'true' }}", name: "plugin-prerelease-docker-suite", - needs: ["preflight"], + needs: ["resolve-candidate", "preflight"], permissions: { actions: "read", contents: "read", @@ -703,7 +779,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { include_repo_e2e: false, live_models_only: false, allow_unreleased_changelog: true, - ref: "${{ needs.preflight.outputs.checkout_revision }}", + ref: "${{ needs.resolve-candidate.outputs.checkout_revision }}", shared_image_artifact_namespace: "plugin-prerelease", shared_image_policy: "no-push-artifact", targeted_docker_lane_group_size: 2, @@ -711,6 +787,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { }); expect(dockerSuite.secrets).toBeUndefined(); expect(suite.needs).toEqual([ + "resolve-candidate", "preflight", "plugin-npm-security-scan", "plugin-prerelease-static-shard",