mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(release): scan immutable plugin artifacts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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, number>): 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<string[]> {
|
||||
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<string> {
|
||||
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<PublishablePluginPackage[]> {
|
||||
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<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<ScanPackageResult> {
|
||||
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, "<candidate-package>"],
|
||||
@@ -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<PluginNpmSecurityScanReport> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<void> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>>;
|
||||
}
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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, "<artifacts>"],
|
||||
[path.dirname(args.report), "<report-dir>"],
|
||||
[process.cwd(), "<tooling>"],
|
||||
]) {
|
||||
output = output.replaceAll(source, replacement);
|
||||
}
|
||||
return output
|
||||
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
|
||||
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const name = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined || values.has(name)) {
|
||||
throw new Error(`Invalid plugin 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<void> {
|
||||
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<typeof parseArgs> | 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, "<candidate>"],
|
||||
[args?.artifactRoot, "<artifacts>"],
|
||||
[args?.outputPath ? dirname(args.outputPath) : undefined, "<report-dir>"],
|
||||
[process.cwd(), "<tooling>"],
|
||||
] as const) {
|
||||
if (path) {
|
||||
message = message.replaceAll(path, replacement);
|
||||
}
|
||||
}
|
||||
return message.replaceAll(
|
||||
/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu,
|
||||
"<scanner-stage>",
|
||||
);
|
||||
return message
|
||||
.replaceAll(/\/(?:private\/)?tmp\/openclaw-plugin-npm-scan-[^/\s:]+/gu, "<scanner-stage>")
|
||||
.replaceAll(/(^|[\s:(])\/[^ \t\n\r:,)\]}]+/gu, "$1<path>");
|
||||
}
|
||||
|
||||
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<number> {
|
||||
let args: ReturnType<typeof parseArgs> | 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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
): 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<string, string | Buffer>;
|
||||
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> = {},
|
||||
): 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-<hash>.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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user