From 27e9dc269eb9e77dff22a43e1fc6019f574ddee7 Mon Sep 17 00:00:00 2001 From: Paul Isaris Date: Thu, 22 Oct 2026 09:22:18 +0300 Subject: [PATCH] Run npm audit on the repository's Node.js and allow --omit=dev The audit job ran npm audit on the runner's Node.js. A repository with devEngines.runtime (the npm-harden policy) refuses an unpatched runner version, so the audit failed with EBADDEVENGINES before it checked anything. - The audit job now sets up Node.js from the new input node-version-file, or from the .nvmrc in working-directory when the input is empty. Without a version file it keeps the runner's Node.js. - New input npm-audit-omit-dev runs npm audit --omit=dev. Use it when a dev tool has an advisory without a fixed release, such as braces through stylelint. - The security fixture now fails unless both work: its .nvmrc and devEngines allow only Node.js 24.22.1, and its dev dependency braces 3.0.3 has a high advisory without a fix. --- .github/workflows/README.md | 17 ++++++- .github/workflows/security.yml | 44 +++++++++++++++++- .github/workflows/self-check.yml | 7 ++- tests/fixtures/README.md | 2 +- tests/fixtures/security/.nvmrc | 1 + tests/fixtures/security/package-lock.json | 54 ++++++++++++++++++++++- tests/fixtures/security/package.json | 12 ++++- workflow-templates/security.yml | 2 + 8 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/security/.nvmrc diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 005da41..6200aad 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -416,7 +416,7 @@ npm-only and PHP-only repositories. | `Secrets` | Committed `.env` files anywhere in the repository, and Gitleaks. On a pull request, Gitleaks scans only the pull request's commits. On push and schedule, it scans the full git history. Examples, `.env.testing`, `.env.ci` and templates (`.dist`, `.sample`, `.template`, `.tpl`, `.j2`, `.jinja`, `.jinja2`) are allowed | | `Dev tool configs` | Script files and suspicious commands in `.vscode`, `.claude`, `.cursor` and `.idea`. See [`scan-dev-configs`](../actions/scan-dev-configs/README.md) | | `npm supply chain hardening` | `.npmrc` settings and lockfile. See [`verify-npm-hardening`](../actions/verify-npm-hardening/README.md) | -| `Dependency audit` | `composer audit --locked` and `npm audit` against the lock files. No install is needed | +| `Dependency audit` | `composer audit --locked` and `npm audit` against the lock files. No install is needed. `npm audit` runs on the Node.js version from `.nvmrc`, so `engines` and `devEngines` in `package.json` apply | | Input | Default | Example values | | --- | --- | --- | @@ -426,6 +426,8 @@ npm-only and PHP-only repositories. | `gitleaks-full-history` | `false` (pull requests scan their own commits) | `true` (every run scans the full history) | | `npm-min-release-age` | `'7'` | `'14'` (days; 7 or more) | | `npm-audit-level` | `high` | `low`, `moderate`, `critical` | +| `npm-audit-omit-dev` | `false` (audit all dependencies) | `true` (audit only the production dependencies, `npm audit --omit=dev`) | +| `node-version-file` | `''` (the `.nvmrc` in `working-directory` when it exists, else the Node.js of the runner) | `.nvmrc`, `frontend/.nvmrc`, `package.json` | | `strict-dev-configs` | `false` (warn only) | `true` (fail on suspicious commands) | | `allowed-dev-scripts` | `''` (no scripts allowed) | `.claude/hooks/*.sh` (one glob per line; `*` also matches `/`) | @@ -456,6 +458,19 @@ jobs: uses: scify/.github/.github/workflows/security.yml@v0.1 ``` +When a dev tool has an advisory without a fixed release, for example `braces` +through `stylelint`, audit only the production dependencies. The dev tools do +not reach the production assets. Use this only when no fix exists, and check +the dev dependencies again when a fix appears: + +```yaml +jobs: + security: + uses: scify/.github/.github/workflows/security.yml@v0.1 + with: + npm-audit-omit-dev: true +``` + When your repository keeps hook scripts in `.claude/hooks/`, review them and allow them: ```yaml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a97fef9..d941fef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -20,7 +20,8 @@ # dev-configs scripts and suspicious commands in .vscode/.claude/.cursor/.idea # (scan-dev-configs action; allow reviewed scripts with allowed-dev-scripts) # npm-hardening .npmrc and lockfile checks (verify-npm-hardening action) -# audit `composer audit` and `npm audit` against the lock files +# audit `composer audit` and `npm audit` against the lock files; npm audit +# runs on the Node.js version from .nvmrc when the repository has one # # Each check runs only when the repository has the matching files, so the # same call works for Laravel, npm-only and PHP-only repositories. @@ -45,6 +46,14 @@ on: description: Lowest npm advisory severity that fails the audit (low, moderate, high, critical). type: string default: high + npm-audit-omit-dev: + description: 'Audit only the production dependencies (npm audit --omit=dev). Use it when a dev tool has an advisory without a fix. Example: true, false' + type: boolean + default: false + node-version-file: + description: 'File that pins the Node.js version for npm audit, relative to the repository root. Empty: the .nvmrc in working-directory when it exists, else the Node.js of the runner. Example: .nvmrc, frontend/.nvmrc, package.json' + type: string + default: '' strict-dev-configs: description: Fail on suspicious commands in dev tool configs. Default only warns. type: boolean @@ -221,11 +230,42 @@ jobs: ABANDONED: ${{ inputs.composer-abandoned }} run: composer audit --locked --abandoned="$ABANDONED" --format=table + # npm checks engines and devEngines in package.json, so npm audit needs the + # repository's Node.js version, not the one on the runner. + - name: Find the Node.js version file + id: node + if: hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' + env: + NODE_VERSION_FILE: ${{ inputs.node-version-file }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + if [ -n "$NODE_VERSION_FILE" ]; then + echo "file=$NODE_VERSION_FILE" >> "$GITHUB_OUTPUT" + elif [ -f .nvmrc ]; then + echo "file=$WORKING_DIRECTORY/.nvmrc" >> "$GITHUB_OUTPUT" + else + echo "No .nvmrc, npm audit uses the Node.js of the runner." + fi + + - name: Set up Node.js + if: steps.node.outputs.file != '' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ${{ steps.node.outputs.file }} + # npm audit reads the lock file only; there is nothing to cache. + package-manager-cache: false + - name: npm audit if: hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' env: LEVEL: ${{ inputs.npm-audit-level }} - run: npm audit --audit-level="$LEVEL" + OMIT_DEV: ${{ inputs.npm-audit-omit-dev }} + run: | + args=(--audit-level="$LEVEL") + if [ "$OMIT_DEV" = true ]; then + args+=(--omit=dev) + fi + npm audit "${args[@]}" - name: Nothing to audit if: hashFiles(format('{0}/composer.lock', inputs.working-directory), format('{0}/package-lock.json', inputs.working-directory)) == '' diff --git a/.github/workflows/self-check.yml b/.github/workflows/self-check.yml index de0b72e..7c9a8ca 100644 --- a/.github/workflows/self-check.yml +++ b/.github/workflows/self-check.yml @@ -8,7 +8,9 @@ # - every internal `scify/.github/...@vX` reference uses the same release tag # - the composite actions pass on good fixtures and fail on bad ones # - security.yml runs on this repository (it loads its actions by job.workflow_sha), -# and on tests/fixtures/security (composer.lock, package-lock.json, .env template) +# and on tests/fixtures/security (composer.lock, package-lock.json, .env template). +# The fixture fails unless npm audit runs on the Node.js from its .nvmrc +# (devEngines) and skips its dev dependency braces (an advisory without a fix) # - smoke tests: laravel-ci.yml and node-ci.yml run against the fixture # projects in tests/fixtures/, once with defaults and once with commands # @@ -186,6 +188,9 @@ jobs: uses: ./.github/workflows/security.yml with: working-directory: tests/fixtures/security + # The fixture's dev dependency braces has a high advisory without a fix. + # Without this input the audit fails. + npm-audit-omit-dev: true laravel-defaults: name: Laravel CI, defaults diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 54e938e..ff66bda 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -15,7 +15,7 @@ order. | `laravel/` | The `laravel/laravel` skeleton, with `pint.json` and `.nvmrc` added | `laravel-defaults`, `laravel-commands` | | `node/` | A project without dependencies, with `lint`, `type-check`, `test` and `build` scripts | `node-defaults`, `node-commands`, `security-actions` | | `dev-configs/` | A `.claude` hook script and editor settings | `security-actions` | -| `security/` | `composer.lock` and `package-lock.json` without dependencies, a hardened `.npmrc`, and a Jinja `.env` template | `security-fixture` | +| `security/` | `composer.lock` without dependencies; `package-lock.json` with one dev dependency, `braces` 3.0.3, which has a high advisory without a fix; `.nvmrc` and a `devEngines` rule that allows only that Node.js version; a hardened `.npmrc`; and a Jinja `.env` template. The audit fails unless it runs on the `.nvmrc` version and with `npm-audit-omit-dev: true` | `security-fixture` | ## Refresh the Laravel fixture diff --git a/tests/fixtures/security/.nvmrc b/tests/fixtures/security/.nvmrc new file mode 100644 index 0000000..ce62013 --- /dev/null +++ b/tests/fixtures/security/.nvmrc @@ -0,0 +1 @@ +24.22.1 diff --git a/tests/fixtures/security/package-lock.json b/tests/fixtures/security/package-lock.json index 1c52649..f54d485 100644 --- a/tests/fixtures/security/package-lock.json +++ b/tests/fixtures/security/package-lock.json @@ -4,7 +4,59 @@ "requires": true, "packages": { "": { - "name": "scify-security-fixture" + "name": "scify-security-fixture", + "devDependencies": { + "braces": "3.0.3" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } } } } diff --git a/tests/fixtures/security/package.json b/tests/fixtures/security/package.json index 28dd367..555395c 100644 --- a/tests/fixtures/security/package.json +++ b/tests/fixtures/security/package.json @@ -1,4 +1,14 @@ { "name": "scify-security-fixture", - "private": true + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "24.22.1", + "onFail": "error" + } + }, + "devDependencies": { + "braces": "3.0.3" + } } diff --git a/workflow-templates/security.yml b/workflow-templates/security.yml index 99c1149..1ab2645 100644 --- a/workflow-templates/security.yml +++ b/workflow-templates/security.yml @@ -24,6 +24,8 @@ jobs: # php-version: '8.4' # e.g. '8.3' (runs composer audit) # composer-abandoned: report # e.g. fail, ignore # npm-audit-level: high # e.g. moderate, critical + # npm-audit-omit-dev: false # true: audit only production dependencies + # node-version-file: '' # e.g. .nvmrc (default: the .nvmrc in working-directory) # npm-min-release-age: '7' # e.g. '14' (days, 7 or more) # gitleaks-full-history: false # true: pull requests scan the full history too # strict-dev-configs: false # true: fail on suspicious commands in dev tool configs