diff --git a/.github/actions/verify-npm-hardening/action.yml b/.github/actions/verify-npm-hardening/action.yml index 015dd53..c9784bd 100644 --- a/.github/actions/verify-npm-hardening/action.yml +++ b/.github/actions/verify-npm-hardening/action.yml @@ -4,10 +4,15 @@ # Usage: # # steps: -# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 -# - uses: ./.github/actions/verify-npm-hardening +# - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# with: +# persist-credentials: false +# - uses: scify/.github/.github/actions/verify-npm-hardening@ # v0.1 # - run: npm ci # +# In a SciFY repository, call the reusable security.yml instead. It runs this +# action. The organisation requires actions pinned by full commit SHA. +# # Inputs: # working-directory (optional, default: .) # Folder that holds package.json and .npmrc. @@ -18,134 +23,134 @@ name: Verify npm supply chain hardening description: Checks .npmrc settings and lockfile presence before npm ci inputs: - working-directory: - description: 'Folder that holds package.json, relative to the repository root. Example: ., frontend' - required: false - default: . - min-age: - description: Minimum acceptable min-release-age value in days - required: false - default: '7' + working-directory: + description: 'Folder that holds package.json, relative to the repository root. Example: ., frontend' + required: false + default: . + min-age: + description: Minimum acceptable min-release-age value in days + required: false + default: '7' runs: - using: composite - steps: - - name: Check required files - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - errors=0 - - if [ ! -f .npmrc ]; then - echo "::error::.npmrc is missing. See .github/actions/verify-npm-hardening/README.md for setup instructions." - errors=$((errors + 1)) - fi - - if [ ! -f package-lock.json ]; then - echo "::error::package-lock.json is missing. Run npm install locally, commit the lockfile, and push." - errors=$((errors + 1)) - fi - - if [ "$errors" -gt 0 ]; then - exit 1 - fi - - echo "Required files present: .npmrc, package-lock.json" - - - name: Verify .npmrc hardening settings - shell: bash - working-directory: ${{ inputs.working-directory }} - env: - MIN_AGE: ${{ inputs.min-age }} - run: | - if ! [ "$MIN_AGE" -ge 7 ] 2>/dev/null; then - echo "::error::Invalid min-age input: '${MIN_AGE}'. Must be a number >= 7." - exit 1 - fi - - errors=0 - - if ! grep -q '^ignore-scripts=true$' .npmrc; then - echo "::error::Missing .npmrc setting: ignore-scripts=true" - errors=$((errors + 1)) - fi - - if ! grep -q '^engine-strict=true$' .npmrc; then - echo "::error::Missing .npmrc setting: engine-strict=true" - errors=$((errors + 1)) - fi - - # grep exits 1 when the setting is missing. `|| true` keeps - # `bash -e -o pipefail` running, so the error below is printed. - age=$(grep '^min-release-age=' .npmrc | cut -d= -f2 || true) - if ! [ "$age" -ge "$MIN_AGE" ] 2>/dev/null; then - echo "::error::Missing or invalid .npmrc setting: min-release-age must be a number >= ${MIN_AGE}" - errors=$((errors + 1)) - fi - - if [ "$errors" -gt 0 ]; then - echo "::error::Supply chain hardening check failed. Do not remove these settings." - exit 1 - fi - - echo "Settings verified: ignore-scripts, engine-strict, min-release-age=${age} days" - - - name: Check for non-registry dependencies in package.json - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - # Git-hosted and URL-based dependencies bypass min-release-age - # entirely because they are not fetched from the npm registry. - git_deps=$(node -p " - const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); - const fields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - const hits = []; - fields.forEach(f => { - const deps = pkg[f] || {}; - Object.entries(deps).forEach(([name, spec]) => { - // npm: aliases (npm:@scope/pkg@1.0.0) still resolve through the registry. - if (spec.startsWith('npm:')) return; - if (/^(git[+:]|github:|https?:|file:)/.test(spec) || spec.includes('/') && !spec.startsWith('@')) - hits.push(f + ' | ' + name + ': ' + spec); - }); - }); - hits.length ? hits.join('\n') : ''; - ") - - if [ -n "$git_deps" ]; then - echo "::error::Non-registry dependencies detected. These bypass min-release-age:" - echo "$git_deps" | while IFS= read -r line; do - echo "::error:: $line" - done - exit 1 - fi - - echo "All package.json dependencies use the npm registry." - - - name: Check for non-registry URLs in lockfile - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - # A tampered lockfile could resolve packages to non-registry - # sources even if package.json looks clean. - non_registry=$(node -p " - const lock = JSON.parse(require('fs').readFileSync('package-lock.json', 'utf8')); - const packages = lock.packages || {}; - const hits = []; - Object.entries(packages).forEach(([path, meta]) => { - const r = meta.resolved || ''; - if (r && !/^https?:\/\/registry\.npmjs\.org\//.test(r) && !/^https?:\/\/registry\.npm\./.test(r) && path !== '') - hits.push(path + ' -> ' + r); - }); - hits.length ? hits.join('\n') : ''; - ") - - if [ -n "$non_registry" ]; then - echo "::error::Lockfile contains entries resolved outside the npm registry:" - echo "$non_registry" | while IFS= read -r line; do - echo "::error:: $line" - done - exit 1 - fi - - echo "All lockfile entries resolve to the npm registry." + using: composite + steps: + - name: Check required files + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + errors=0 + + if [ ! -f .npmrc ]; then + echo "::error::.npmrc is missing. See .github/actions/verify-npm-hardening/README.md for setup instructions." + errors=$((errors + 1)) + fi + + if [ ! -f package-lock.json ]; then + echo "::error::package-lock.json is missing. Run npm install locally, commit the lockfile, and push." + errors=$((errors + 1)) + fi + + if [ "$errors" -gt 0 ]; then + exit 1 + fi + + echo "Required files present: .npmrc, package-lock.json" + + - name: Verify .npmrc hardening settings + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + MIN_AGE: ${{ inputs.min-age }} + run: | + if ! [ "$MIN_AGE" -ge 7 ] 2>/dev/null; then + echo "::error::Invalid min-age input: '${MIN_AGE}'. Must be a number >= 7." + exit 1 + fi + + errors=0 + + if ! grep -q '^ignore-scripts=true$' .npmrc; then + echo "::error::Missing .npmrc setting: ignore-scripts=true" + errors=$((errors + 1)) + fi + + if ! grep -q '^engine-strict=true$' .npmrc; then + echo "::error::Missing .npmrc setting: engine-strict=true" + errors=$((errors + 1)) + fi + + # grep exits 1 when the setting is missing. `|| true` keeps + # `bash -e -o pipefail` running, so the error below is printed. + age=$(grep '^min-release-age=' .npmrc | cut -d= -f2 || true) + if ! [ "$age" -ge "$MIN_AGE" ] 2>/dev/null; then + echo "::error::Missing or invalid .npmrc setting: min-release-age must be a number >= ${MIN_AGE}" + errors=$((errors + 1)) + fi + + if [ "$errors" -gt 0 ]; then + echo "::error::Supply chain hardening check failed. Do not remove these settings." + exit 1 + fi + + echo "Settings verified: ignore-scripts, engine-strict, min-release-age=${age} days" + + - name: Check for non-registry dependencies in package.json + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + # Git-hosted and URL-based dependencies bypass min-release-age + # entirely because they are not fetched from the npm registry. + git_deps=$(node -p " + const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); + const fields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + const hits = []; + fields.forEach(f => { + const deps = pkg[f] || {}; + Object.entries(deps).forEach(([name, spec]) => { + // npm: aliases (npm:@scope/pkg@1.0.0) still resolve through the registry. + if (spec.startsWith('npm:')) return; + if (/^(git[+:]|github:|https?:|file:)/.test(spec) || spec.includes('/') && !spec.startsWith('@')) + hits.push(f + ' | ' + name + ': ' + spec); + }); + }); + hits.length ? hits.join('\n') : ''; + ") + + if [ -n "$git_deps" ]; then + echo "::error::Non-registry dependencies detected. These bypass min-release-age:" + echo "$git_deps" | while IFS= read -r line; do + echo "::error:: $line" + done + exit 1 + fi + + echo "All package.json dependencies use the npm registry." + + - name: Check for non-registry URLs in lockfile + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + # A tampered lockfile could resolve packages to non-registry + # sources even if package.json looks clean. + non_registry=$(node -p " + const lock = JSON.parse(require('fs').readFileSync('package-lock.json', 'utf8')); + const packages = lock.packages || {}; + const hits = []; + Object.entries(packages).forEach(([path, meta]) => { + const r = meta.resolved || ''; + if (r && !/^https?:\/\/registry\.npmjs\.org\//.test(r) && !/^https?:\/\/registry\.npm\./.test(r) && path !== '') + hits.push(path + ' -> ' + r); + }); + hits.length ? hits.join('\n') : ''; + ") + + if [ -n "$non_registry" ]; then + echo "::error::Lockfile contains entries resolved outside the npm registry:" + echo "$non_registry" | while IFS= read -r line; do + echo "::error:: $line" + done + exit 1 + fi + + echo "All lockfile entries resolve to the npm registry." diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 000a482..5b8fe4b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -311,7 +311,11 @@ npm-only and PHP-only repositories. | `allowed-dev-scripts` | `''` (no scripts allowed) | `.claude/hooks/*.sh` (one glob per line; `*` also matches `/`) | Run it on pull requests and once a week. The weekly run finds new advisories -for dependencies that did not change: +for dependencies that did not change. + +In a public repository, GitHub disables scheduled workflows after 60 days +without repository activity, and it sends only an email. After a quiet period, +check the **Actions** tab and enable the workflow again: ```yaml # .github/workflows/security.yml in your repository @@ -352,6 +356,7 @@ jobs: | Browser tests also run in `Backend tests` (Laravel) | Exclude the browser suite in `test-command`, for example `vendor/bin/pest --exclude-testsuite=Browser`. | | `Environment files are committed to the repository` | A real env file, such as `.env` or `.env.production`, is committed. Remove it and rotate its secrets. A template must end in `.example`, `.dist`, `.sample`, `.template`, `.tpl`, `.j2`, `.jinja` or `.jinja2`. | | `Found 1 abandoned package` fails the `Dependency audit` job | The caller sets `composer-abandoned: fail`. Replace the package, or set `composer-abandoned: report`. | +| The weekly security run stopped | GitHub disables scheduled workflows in a public repository after 60 days without activity. Open the workflow in the **Actions** tab and click **Enable workflow**. | | `Script files found in dev tool directories` | A script file is in `.vscode`, `.claude`, `.cursor` or `.idea`. Remove it, or review it and add it to `allowed-dev-scripts`. | | PHPStan or Rector re-analyse every file on each run | `analysis-cache-paths` does not match `tmpDir` in `phpstan.neon` or `cacheDirectory` in `rector.php`. |