diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index acf7bf05a..3f47cb26e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -95,6 +95,15 @@ on: type: boolean required: false default: false + node-package-manager-allow-scripts: + description: | + Allow npm/yarn/pnpm lifecycle scripts (preinstall, install, postinstall, prepare, etc.) + to run during dependency installation. + Disabled by default for security. Only enable as a last resort if a plugin truly + requires install-time scripts and cannot work without them. + type: boolean + required: false + default: false allow-unsigned: description: | Allow to publish unsigned plugins. @@ -757,6 +766,7 @@ jobs: plugin-directory: ${{ inputs.plugin-directory }} plugin-version-suffix: ${{ needs.setup.outputs.plugin-version-suffix }} npm-registry-auth: ${{ inputs.npm-registry-auth }} + node-package-manager-allow-scripts: ${{ inputs.node-package-manager-allow-scripts }} go-version: ${{ inputs.go-version }} go-setup-caching: ${{ inputs.go-setup-caching }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2893dcde5..dbb808a9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,15 @@ on: type: boolean required: false default: false + node-package-manager-allow-scripts: + description: | + Allow npm/yarn/pnpm lifecycle scripts (preinstall, install, postinstall, prepare, etc.) + to run during dependency installation. + Disabled by default for security. Only enable as a last resort if a plugin truly + requires install-time scripts and cannot work without them. + type: boolean + required: false + default: false # Playwright run-playwright: @@ -490,7 +499,7 @@ jobs: - name: Spellcheck run: | if [ -f "./cspell.config.json" ]; then - npx --yes cspell@6.13.3 -c cspell.config.json "**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}" + npx --yes --ignore-scripts cspell@6.13.3 -c cspell.config.json "**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}" else echo "❌ Spellcheck is not configured." fi @@ -534,6 +543,7 @@ jobs: plugin-directory: ${{ inputs.plugin-directory }} secrets: ${{ (fromJson(steps.workflow-context.outputs.result).isTrusted && inputs.frontend-secrets != '') && inputs.frontend-secrets || '' }} npm-registry-auth: ${{ inputs.npm-registry-auth }} + allow-scripts: ${{ inputs.node-package-manager-allow-scripts }} - name: Test and build backend id: backend @@ -741,6 +751,7 @@ jobs: secrets: ${{ (fromJson(needs.test-and-build.outputs.workflow-context).isTrusted && inputs.playwright-secrets != '') && inputs.playwright-secrets || '' }} node-version: ${{ inputs.node-version }} npm-registry-auth: ${{ inputs.npm-registry-auth }} + node-package-manager-allow-scripts: ${{ inputs.node-package-manager-allow-scripts }} playwright-docker: name: Plugins - Dockerized Playwright E2E tests diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 7d03092f5..1244d9059 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -83,6 +83,15 @@ on: type: boolean required: false default: false + node-package-manager-allow-scripts: + description: | + Allow npm/yarn/pnpm lifecycle scripts (preinstall, install, postinstall, prepare, etc.) + to run during dependency installation. + Disabled by default for security. Only enable as a last resort if a plugin truly + requires install-time scripts and cannot work without them. + type: boolean + required: false + default: false permissions: contents: read id-token: write @@ -146,8 +155,40 @@ jobs: working-directory: ${{ inputs.plugin-directory }} run: GOOGLE_APPLICATION_CREDENTIALS=${{ env.GOOGLE_APPLICATION_CREDENTIALS }} npx google-artifactregistry-auth --credential-config ./.npmrc + - name: Configure package manager script policy + id: install-cmd + shell: bash + run: | + INSTALL_CMD="${FROZEN_INSTALL_CMD}" + if [ "${ALLOW_SCRIPTS}" != "true" ]; then + # Yarn Berry does not support --ignore-scripts on the CLI (it errors with YN0050). + # For Yarn Berry, scripts are suppressed via the YARN_ENABLE_SCRIPTS env var. + # npm, pnpm, and Yarn Classic (agent=yarn) all accept --ignore-scripts on the CLI. + if [ "${PACKAGE_MANAGER_AGENT}" != "yarnBerry" ]; then + INSTALL_CMD="${INSTALL_CMD} --ignore-scripts" + fi + # Belt-and-suspenders: also export env vars so that scripts are blocked even if a + # wrapper or sub-process re-invokes the package manager without our CLI flags. + # npm and pnpm both honour NPM_CONFIG_IGNORE_SCRIPTS. + # Yarn Berry honours YARN_ENABLE_SCRIPTS (its env-var form of enableScripts config). + # Yarn Classic also honours npm_config_ignore_scripts. + echo "NPM_CONFIG_IGNORE_SCRIPTS=true" >> "$GITHUB_ENV" + echo "YARN_ENABLE_SCRIPTS=false" >> "$GITHUB_ENV" + printf '::act-debug::msg="npm-config-ignore-scripts" value=true\n' + printf '::act-debug::msg="yarn-enable-scripts" value=false\n' + else + printf '::act-debug::msg="npm-config-ignore-scripts" value=not-set\n' + printf '::act-debug::msg="yarn-enable-scripts" value=not-set\n' + fi + echo "cmd=${INSTALL_CMD}" >> "$GITHUB_OUTPUT" + printf '::act-debug::msg="install-cmd" cmd=%s\n' "${INSTALL_CMD}" + env: + FROZEN_INSTALL_CMD: ${{ steps.setup.outputs.node-package-manager-frozen-install-cmd }} + PACKAGE_MANAGER_AGENT: ${{ steps.setup.outputs.node-package-manager-agent }} + ALLOW_SCRIPTS: ${{ inputs.node-package-manager-allow-scripts }} + - name: Install npm dependencies - run: ${{ steps.setup.outputs.node-package-manager-frozen-install-cmd }} + run: ${{ steps.install-cmd.outputs.cmd }} shell: bash working-directory: ${{ inputs.plugin-directory }} diff --git a/actions/internal/plugins/frontend/action.yml b/actions/internal/plugins/frontend/action.yml index 43bd0e6a0..5076065fc 100644 --- a/actions/internal/plugins/frontend/action.yml +++ b/actions/internal/plugins/frontend/action.yml @@ -16,6 +16,14 @@ inputs: If true, the root of the plugin repository must contain a `.npmrc` file. required: false default: "false" + allow-scripts: + description: | + Allow npm/yarn/pnpm lifecycle scripts (preinstall, install, postinstall, prepare, etc.) + to run during dependency installation. + Disabled by default for security. Only enable as a last resort if a plugin truly + requires install-time scripts and cannot work without them. + required: false + default: "false" runs: using: composite @@ -47,10 +55,42 @@ runs: with: working-directory: ${{ inputs.plugin-directory }} + - name: Configure package manager script policy + id: install-cmd + shell: bash + run: | + INSTALL_CMD="${FROZEN_INSTALL_CMD}" + if [ "${ALLOW_SCRIPTS}" != "true" ]; then + # Yarn Berry does not support --ignore-scripts on the CLI (it errors with YN0050). + # For Yarn Berry, scripts are suppressed via the YARN_ENABLE_SCRIPTS env var. + # npm, pnpm, and Yarn Classic (agent=yarn) all accept --ignore-scripts on the CLI. + if [ "${PACKAGE_MANAGER_AGENT}" != "yarnBerry" ]; then + INSTALL_CMD="${INSTALL_CMD} --ignore-scripts" + fi + # Belt-and-suspenders: also export env vars so that scripts are blocked even if a + # wrapper or sub-process re-invokes the package manager without our CLI flags. + # npm and pnpm both honour NPM_CONFIG_IGNORE_SCRIPTS. + # Yarn Berry honours YARN_ENABLE_SCRIPTS (its env-var form of enableScripts config). + # Yarn Classic also honours npm_config_ignore_scripts. + echo "NPM_CONFIG_IGNORE_SCRIPTS=true" >> "$GITHUB_ENV" + echo "YARN_ENABLE_SCRIPTS=false" >> "$GITHUB_ENV" + printf '::act-debug::msg="npm-config-ignore-scripts" value=true\n' + printf '::act-debug::msg="yarn-enable-scripts" value=false\n' + else + printf '::act-debug::msg="npm-config-ignore-scripts" value=not-set\n' + printf '::act-debug::msg="yarn-enable-scripts" value=not-set\n' + fi + echo "cmd=${INSTALL_CMD}" >> "$GITHUB_OUTPUT" + printf '::act-debug::msg="install-cmd" cmd=%s\n' "${INSTALL_CMD}" + env: + FROZEN_INSTALL_CMD: ${{ steps.package-manager.outputs.frozenInstallCmd }} + PACKAGE_MANAGER_AGENT: ${{ steps.package-manager.outputs.agent }} + ALLOW_SCRIPTS: ${{ inputs.allow-scripts }} + - name: Install dependencies shell: bash working-directory: ${{ inputs.plugin-directory }} - run: ${{ steps.package-manager.outputs.frozenInstallCmd }} + run: ${{ steps.install-cmd.outputs.cmd }} - name: Lint shell: bash diff --git a/actions/internal/plugins/setup/action.yml b/actions/internal/plugins/setup/action.yml index eea2cd47a..a3012fda4 100644 --- a/actions/internal/plugins/setup/action.yml +++ b/actions/internal/plugins/setup/action.yml @@ -76,6 +76,9 @@ outputs: node-package-manager-exec-local-cmd: description: The command to execute a locally installed package (e.g. npx, yarn dlx, pnpm exec). value: ${{ steps.package-manager.outputs.execLocalCmd }} + node-package-manager-agent: + description: The agent identifier for the detected Node.js package manager (e.g. npm, pnpm, yarn, yarnBerry). + value: ${{ steps.package-manager.outputs.agent }} runs: using: composite diff --git a/tests/act/internal/workflow/ci/ci.go b/tests/act/internal/workflow/ci/ci.go index 4b5f10ab8..2d1bfcd50 100644 --- a/tests/act/internal/workflow/ci/ci.go +++ b/tests/act/internal/workflow/ci/ci.go @@ -113,6 +113,8 @@ type WorkflowInputs struct { Testing *bool BackendBuildTarget *string + NodePackageManagerAllowScripts *bool + DistArtifactsRetentionDays *int } @@ -141,6 +143,8 @@ func SetCIInputs(dst *workflow.Job, inputs WorkflowInputs) { workflow.SetJobInput(dst, "testing", inputs.Testing) workflow.SetJobInput(dst, "backend-build-target", inputs.BackendBuildTarget) + workflow.SetJobInput(dst, "node-package-manager-allow-scripts", inputs.NodePackageManagerAllowScripts) + workflow.SetJobInput(dst, "dist-artifacts-retention-days", inputs.DistArtifactsRetentionDays) } diff --git a/tests/act/main_install_scripts_test.go b/tests/act/main_install_scripts_test.go new file mode 100644 index 000000000..5baaa1c61 --- /dev/null +++ b/tests/act/main_install_scripts_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/grafana/plugin-ci-workflows/tests/act/internal/act" + "github.com/grafana/plugin-ci-workflows/tests/act/internal/workflow" + "github.com/grafana/plugin-ci-workflows/tests/act/internal/workflow/ci" + "github.com/stretchr/testify/require" +) + +// TestInstallScripts verifies the "Configure package manager script policy" logic +// in the frontend action against the three real test plugins (npm, yarn, pnpm), +// with allow-scripts both true and false. +// +// TODO: add a simple-frontend-yarn-berry test plugin and cover the yarnBerry agent path, +// where --ignore-scripts is NOT appended to the CLI (Berry rejects it) and scripts are +// suppressed solely via the YARN_ENABLE_SCRIPTS env var. +func TestInstallScripts(t *testing.T) { + type testCase struct { + folder string + allowScripts bool + wantCmdContains string + wantCmdNotContains string + wantNPMIgnore bool + wantYarnDisable bool + } + + for _, tc := range []testCase{ + {folder: "simple-frontend", allowScripts: false, wantCmdContains: "--ignore-scripts", wantNPMIgnore: true, wantYarnDisable: true}, + {folder: "simple-frontend-yarn", allowScripts: false, wantCmdContains: "--ignore-scripts", wantNPMIgnore: true, wantYarnDisable: true}, + {folder: "simple-frontend-pnpm", allowScripts: false, wantCmdContains: "--ignore-scripts", wantNPMIgnore: true, wantYarnDisable: true}, + {folder: "simple-frontend", allowScripts: true, wantCmdNotContains: "--ignore-scripts", wantNPMIgnore: false, wantYarnDisable: false}, + {folder: "simple-frontend-yarn", allowScripts: true, wantCmdNotContains: "--ignore-scripts", wantNPMIgnore: false, wantYarnDisable: false}, + {folder: "simple-frontend-pnpm", allowScripts: true, wantCmdNotContains: "--ignore-scripts", wantNPMIgnore: false, wantYarnDisable: false}, + } { + t.Run(fmt.Sprintf("%s/allow-scripts=%v", tc.folder, tc.allowScripts), func(t *testing.T) { + t.Parallel() + + runner, err := act.NewRunner(t) + require.NoError(t, err) + + wf, err := ci.NewWorkflow( + ci.WithWorkflowInputs(ci.WorkflowInputs{ + PluginDirectory: workflow.Input(filepath.Join("tests", tc.folder)), + DistArtifactsPrefix: workflow.Input(tc.folder + "-"), + RunPlaywright: workflow.Input(false), + NodePackageManagerAllowScripts: workflow.Input(tc.allowScripts), + }), + ci.MutateCIWorkflow().With( + workflow.WithOnlyOneJob(t, "test-and-build", true), + workflow.WithRemoveAllStepsAfter(t, "test-and-build", "frontend"), + ), + ) + require.NoError(t, err) + + r, err := runner.Run(wf, act.NewPushEventPayload("main")) + require.NoError(t, err) + require.True(t, r.Success, "workflow should succeed") + + var installCmdAnnotation *act.Annotation + for i, ann := range r.Annotations { + if ann.Level == act.AnnotationLevelDebug && strings.Contains(ann.Message, `msg="install-cmd"`) { + installCmdAnnotation = &r.Annotations[i] + break + } + } + require.NotNil(t, installCmdAnnotation, "install-cmd annotation should be present") + if tc.wantCmdContains != "" { + require.Contains(t, installCmdAnnotation.Message, tc.wantCmdContains) + } + if tc.wantCmdNotContains != "" { + require.NotContains(t, installCmdAnnotation.Message, tc.wantCmdNotContains) + } + + npmIgnoreVal := "not-set" + if tc.wantNPMIgnore { + npmIgnoreVal = "true" + } + require.Contains(t, r.Annotations, act.Annotation{ + Level: act.AnnotationLevelDebug, + Message: fmt.Sprintf(`msg="npm-config-ignore-scripts" value=%s`, npmIgnoreVal), + }) + + yarnVal := "not-set" + if tc.wantYarnDisable { + yarnVal = "false" + } + require.Contains(t, r.Annotations, act.Annotation{ + Level: act.AnnotationLevelDebug, + Message: fmt.Sprintf(`msg="yarn-enable-scripts" value=%s`, yarnVal), + }) + }) + } +}