diff --git a/.github/actions/verify-npm-hardening/README.md b/.github/actions/verify-npm-hardening/README.md index 7631a76..f1b5b22 100644 --- a/.github/actions/verify-npm-hardening/README.md +++ b/.github/actions/verify-npm-hardening/README.md @@ -88,6 +88,7 @@ steps: | Name | Default | Description | |-----------|---------|-------------------------------------------------------------------| | `min-age` | `7` | Minimum acceptable `min-release-age` value in days. Must be >= 7. | +| `working-directory` | `.` | Folder that holds `package.json` and `.npmrc`, relative to the repository root. Example: `frontend` | ```yaml - uses: ./.github/actions/verify-npm-hardening diff --git a/.github/actions/verify-npm-hardening/action.yml b/.github/actions/verify-npm-hardening/action.yml index d918bc7..015dd53 100644 --- a/.github/actions/verify-npm-hardening/action.yml +++ b/.github/actions/verify-npm-hardening/action.yml @@ -9,6 +9,8 @@ # - run: npm ci # # Inputs: +# working-directory (optional, default: .) +# Folder that holds package.json and .npmrc. # min-age (optional, default: 7) # Minimum acceptable min-release-age value in days. Must be >= 7. @@ -16,6 +18,10 @@ 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 @@ -26,6 +32,7 @@ runs: steps: - name: Check required files shell: bash + working-directory: ${{ inputs.working-directory }} run: | errors=0 @@ -47,6 +54,7 @@ runs: - name: Verify .npmrc hardening settings shell: bash + working-directory: ${{ inputs.working-directory }} env: MIN_AGE: ${{ inputs.min-age }} run: | @@ -84,6 +92,7 @@ runs: - 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. @@ -115,6 +124,7 @@ runs: - 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. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b996acd..7690e96 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -296,13 +296,14 @@ npm-only and PHP-only repositories. | Job | What it checks | | --- | --- | -| `Secrets` | Committed `.env` files, and Gitleaks over the full git history | +| `Secrets` | Committed `.env` files anywhere in the repository, and Gitleaks over 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` and `npm audit` against the lock files | +| `Dependency audit` | `composer audit --locked` and `npm audit` against the lock files. No install is needed | | Input | Default | Example values | | --- | --- | --- | +| `working-directory` | `.` | `frontend`, `apps/web`. The npm hardening and audit checks run there | | `php-version` | `'8.4'` | `'8.3'` | | `npm-audit-level` | `high` | `low`, `moderate`, `critical` | | `strict-dev-configs` | `false` (warn only) | `true` (fail on suspicious commands) | @@ -348,6 +349,7 @@ jobs: | `Codecov` fails on your own pull requests | The `CODECOV_TOKEN` secret is missing or not passed under `secrets:`. Pull requests from forks get no secrets, so there the upload failure does not fail the job. | | `Environment file '...' not found` (Laravel) | Your repository has no `.env.testing` and no `.env.example`, or `env-file` points to a missing file. | | 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`. | | `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`. | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 85e2499..113d008 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,6 +24,10 @@ name: Security on: workflow_call: inputs: + working-directory: + description: 'Folder that holds composer.json and package.json, relative to the repository root. The npm hardening and audit checks run there. Example: ., frontend, apps/web' + type: string + default: . php-version: description: PHP version used to run `composer audit`. type: string @@ -56,12 +60,16 @@ jobs: fetch-depth: 0 persist-credentials: false + # The whole repository, not only working-directory. Examples, test files + # and templates (.dist, .sample, .template, .tpl, Jinja) are allowed. - name: Check for committed environment files run: | found=$(find . -type f -name '.env*' \ - -not -path './vendor/*' -not -path './node_modules/*' -not -path './.git/*' \ - -not -name '.env.example' -not -name '.env.*.example' \ - -not -name '.env.testing' -not -name '.env.ci') + -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path './.git/*' \ + -not -name '.env.testing' -not -name '.env.ci' \ + -not -name '.env*.example' -not -name '.env*.dist' -not -name '.env*.sample' \ + -not -name '.env*.template' -not -name '.env*.tpl' \ + -not -name '.env*.j2' -not -name '.env*.jinja' -not -name '.env*.jinja2') if [ -n "$found" ]; then echo "::error::Environment files are committed to the repository:" echo "$found" @@ -123,6 +131,9 @@ jobs: name: npm supply chain hardening runs-on: ubuntu-latest timeout-minutes: 5 + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -141,17 +152,22 @@ jobs: persist-credentials: false - name: Verify npm hardening - if: hashFiles('package.json') != '' + if: hashFiles(format('{0}/package.json', inputs.working-directory)) != '' uses: ./.scify-github/.github/actions/verify-npm-hardening + with: + working-directory: ${{ inputs.working-directory }} - name: No package.json - if: hashFiles('package.json') == '' + if: hashFiles(format('{0}/package.json', inputs.working-directory)) == '' run: echo "No package.json, skipping." audit: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -159,23 +175,24 @@ jobs: persist-credentials: false - name: Set up PHP - if: hashFiles('composer.lock') != '' + if: hashFiles(format('{0}/composer.lock', inputs.working-directory)) != '' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} coverage: none tools: composer:v2 + # --locked reads composer.lock, so no `composer install` is needed. - name: Composer audit - if: hashFiles('composer.lock') != '' - run: composer audit --format=table + if: hashFiles(format('{0}/composer.lock', inputs.working-directory)) != '' + run: composer audit --locked --format=table - name: npm audit - if: hashFiles('package-lock.json') != '' + if: hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' env: LEVEL: ${{ inputs.npm-audit-level }} run: npm audit --audit-level="$LEVEL" - name: Nothing to audit - if: hashFiles('composer.lock', 'package-lock.json') == '' + if: hashFiles(format('{0}/composer.lock', inputs.working-directory), format('{0}/package-lock.json', inputs.working-directory)) == '' run: echo "No composer.lock or package-lock.json, skipping." diff --git a/.github/workflows/self-check.yml b/.github/workflows/self-check.yml index 21cf82b..af67c73 100644 --- a/.github/workflows/self-check.yml +++ b/.github/workflows/self-check.yml @@ -7,7 +7,8 @@ # - every workflow template .properties.json file parses # - 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) +# - 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) # - smoke tests: laravel-ci.yml and node-ci.yml run against the fixture # projects in tests/fixtures/, once with defaults and once with commands # @@ -160,6 +161,12 @@ jobs: name: Security workflow on this repository uses: ./.github/workflows/security.yml + security-fixture: + name: Security workflow on the fixture + uses: ./.github/workflows/security.yml + with: + working-directory: tests/fixtures/security + laravel-defaults: name: Laravel CI, defaults uses: ./.github/workflows/laravel-ci.yml diff --git a/README.md b/README.md index 4c84670..4cd72a0 100644 --- a/README.md +++ b/README.md @@ -71,22 +71,32 @@ template does not reach existing copies. Re-run the command to pick it up. ### Versioning -This repository is work in progress. Releases are tagged `v0.x` and callers -pin to the current one, `v0.1`. Any `v0.x` release may change inputs or -defaults. When the workflows have run in real repositories for a while, `v1` -becomes the first stable tag and moves forward only for backwards-compatible -fixes. +This repository is work in progress. Releases follow the usual GitHub Actions +convention: -To publish a new release: +- Each release has its own tag, for example `v0.1.1`. It never moves. +- The short tag, for example `v0.1`, moves to the latest release of that line. + Callers pin to it and receive fixes without a change. +- Any `v0.x` line may change inputs or defaults. A change that breaks callers + starts a new line, for example `v0.2`. -1. Update every `@v0.x` reference in the documentation and comments to the new - tag. The self-check fails when they differ. -2. Tag and push: +When the workflows have run in real repositories for a while, `v1` becomes the +first stable line. + +To publish a fix release on the `v0.1` line: + +1. Merge the fix to `main` and wait for a green Self-check. +2. Tag the release and move the short tag: ```bash - git tag v0.2 && git push origin v0.2 + git tag -a v0.1.1 -m v0.1.1 origin/main && git push origin v0.1.1 + git tag -f -a v0.1 -m v0.1 origin/main && git push -f origin v0.1 + gh release create v0.1.1 --title v0.1.1 --notes "..." ``` +To start a new line, update every `@v0.x` reference in the documentation and +comments to the new tag first. The self-check fails when they differ. + ### Composite actions and the release tag The organisation requires every action to be pinned to a full commit SHA, and diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 05e8646..54e938e 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -2,8 +2,9 @@ Sample projects for the CI of this repository. No other repository uses them. -`.github/workflows/self-check.yml` calls `laravel-ci.yml` and `node-ci.yml` by -local path, with `working-directory` set to a folder here. A pull request +`.github/workflows/self-check.yml` calls `laravel-ci.yml`, `node-ci.yml` and +`security.yml` by local path, with `working-directory` set to a folder here. It +also runs the composite actions against these folders. A pull request therefore runs its own workflow changes against a real project before any caller receives them. actionlint checks the syntax. These runs catch the runtime errors that actionlint cannot see, such as wrong paths or a wrong step @@ -12,7 +13,9 @@ order. | Folder | Contents | Smoke-test jobs | | --- | --- | --- | | `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` | +| `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` | ## Refresh the Laravel fixture diff --git a/tests/fixtures/security/.env.example b/tests/fixtures/security/.env.example new file mode 100644 index 0000000..616c8f7 --- /dev/null +++ b/tests/fixtures/security/.env.example @@ -0,0 +1,2 @@ +APP_NAME="Security fixture" +APP_KEY= diff --git a/tests/fixtures/security/.npmrc b/tests/fixtures/security/.npmrc new file mode 100644 index 0000000..0a69032 --- /dev/null +++ b/tests/fixtures/security/.npmrc @@ -0,0 +1,3 @@ +engine-strict=true +ignore-scripts=true +min-release-age=7 diff --git a/tests/fixtures/security/composer.json b/tests/fixtures/security/composer.json new file mode 100644 index 0000000..4920c75 --- /dev/null +++ b/tests/fixtures/security/composer.json @@ -0,0 +1,7 @@ +{ + "name": "scify/security-fixture", + "description": "Fixture for the security workflow self-check", + "type": "project", + "license": "Apache-2.0", + "require": {} +} diff --git a/tests/fixtures/security/composer.lock b/tests/fixtures/security/composer.lock new file mode 100644 index 0000000..7939c47 --- /dev/null +++ b/tests/fixtures/security/composer.lock @@ -0,0 +1,18 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "aa94b3b05483d6457b1c60388d6288fe", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/tests/fixtures/security/deploy/.env.j2 b/tests/fixtures/security/deploy/.env.j2 new file mode 100644 index 0000000..bd1b508 --- /dev/null +++ b/tests/fixtures/security/deploy/.env.j2 @@ -0,0 +1,4 @@ +APP_NAME="Security fixture" +APP_ENV=production +APP_KEY={{ app_key }} +DB_PASSWORD={{ db_password }} diff --git a/tests/fixtures/security/package-lock.json b/tests/fixtures/security/package-lock.json new file mode 100644 index 0000000..1c52649 --- /dev/null +++ b/tests/fixtures/security/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "scify-security-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scify-security-fixture" + } + } +} diff --git a/tests/fixtures/security/package.json b/tests/fixtures/security/package.json new file mode 100644 index 0000000..28dd367 --- /dev/null +++ b/tests/fixtures/security/package.json @@ -0,0 +1,4 @@ +{ + "name": "scify-security-fixture", + "private": true +}