diff --git a/.github/actions/verify-npm-hardening/README.md b/.github/actions/verify-npm-hardening/README.md index f1b5b22..89b0d47 100644 --- a/.github/actions/verify-npm-hardening/README.md +++ b/.github/actions/verify-npm-hardening/README.md @@ -2,35 +2,22 @@ Composite GitHub Action that verifies supply chain security settings before `npm ci` runs. Catches missing or misconfigured settings before they reach production. -## What it checks - -**Required files:** - -| File | Why | -|---------------------|------------------------------------------------------------| -| `.npmrc` | Contains the hardening settings below | -| `package-lock.json` | Locks exact dependency versions for deterministic installs | - -**Required `.npmrc` settings:** - -| Setting | Purpose | -|-----------------------|-------------------------------------------------------------------------------| -| `ignore-scripts=true` | Blocks postinstall hooks (the main vector for npm supply chain attacks) | -| `engine-strict=true` | Refuses to install on unsupported Node/npm versions | -| `min-release-age=N` | Quarantines packages published less than N days ago (requires npm >= 11.10.0) | - -**Non-registry dependency checks:** - -| Check | Purpose | -|-------|---------| -| Git/URL deps in `package.json` | Detects dependencies that bypass `min-release-age` (git, GitHub, HTTP, file sources) | -| Non-registry URLs in `package-lock.json` | Detects tampered lockfiles resolving packages outside the npm registry | - -**Recommended (not enforced):** - -| Setting | Purpose | -|-------------------|---------------------------------------------------------------| -| `save-exact=true` | Pins new dependencies to exact versions instead of `^` ranges | +## Policy + +This table is the canonical SciFY npm supply chain policy. The action enforces +every rule marked "fails". The `npm-harden` skill in the `scify-devops` Claude +Code plugin applies the same rules to a repository. + +| # | Rule | Value | CI result | Why | +| --- | --- | --- | --- | --- | +| 1 | `.npmrc` is committed | file exists | fails | Holds the settings below | +| 2 | `package-lock.json` is committed | file exists | fails | `npm ci` needs it for a deterministic install | +| 3 | `ignore-scripts` | `true` | fails | Blocks install scripts, the main vector of npm supply chain attacks | +| 4 | `engine-strict` | `true` | fails | Refuses to install on Node or npm versions outside `engines` | +| 5 | `min-release-age` | a plain integer, at least 7 (days); the `min-age` input can raise the minimum | fails | Quarantines new releases. Needs npm 11.10.0 or later. `7d` is invalid and disables the protection | +| 6 | No git, URL, GitHub or `file:` dependencies in `package.json` | none allowed; `npm:` aliases are allowed | fails | These sources bypass `min-release-age` | +| 7 | Every `resolved` URL in `package-lock.json` points to the public npm registry | starts with `https://registry.npmjs.org/` (no other host, no `http://`) | fails | Detects a tampered lockfile | +| 8 | `save-exact` | `true` | not checked (recommended) | New dependencies get exact versions instead of `^` ranges | ## Setup diff --git a/.github/actions/verify-npm-hardening/action.yml b/.github/actions/verify-npm-hardening/action.yml index c9784bd..13707c6 100644 --- a/.github/actions/verify-npm-hardening/action.yml +++ b/.github/actions/verify-npm-hardening/action.yml @@ -139,7 +139,9 @@ runs: 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 !== '') + // Only the public npm registry over HTTPS. A looser prefix such as + // registry.npm. would also accept registry.npm.attacker.example. + if (r && !/^https:\/\/registry\.npmjs\.org\//.test(r) && path !== '') hits.push(path + ' -> ' + r); }); hits.length ? hits.join('\n') : ''; diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1524d83..70b9d55 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,9 +13,16 @@ application, which inputs it accepts, and how to solve common problems. Each workflow file also starts with a comment block that shows a full example call. `self-check.yml` is the CI of this repository. Do not call it. +> **AI coding agents:** read [`AGENTS.md`](../../AGENTS.md) at the repository +> root first. To add these workflows to a repository, follow +> [Adopt in an existing repository](#adopt-in-an-existing-repository). SciFY +> developers with the `scify-devops` Claude Code plugin can run `/ci-setup`, +> which follows the same procedure. + ## Contents - [How a call works](#how-a-call-works) +- [Adopt in an existing repository](#adopt-in-an-existing-repository) - [Laravel CI](#laravel-ci) - [Node CI](#node-ci) - [Security](#security) @@ -53,8 +60,14 @@ jobs: Rules that apply to every workflow here: -- **Pin to a tag.** Use `@v0.1`, not `@main`. A `v0.x` release can change - inputs, so read the release notes before you move to a new tag. +- **Workflows by tag, actions by SHA.** Call the reusable workflows with the + release tag, `@v0.1`, not `@main`. The tag moves to each fix release, so you + receive fixes without a change. Do not pin a workflow by SHA: you then stop + receiving fixes. Actions are different: the organisation requires every + action in a step (`steps: - uses:`) to be pinned by full commit SHA. This + includes actions from `scify/.github`, so + `scify/.github/.github/actions/@v0.1` fails. A `v0.x` release can + change inputs, so read the release notes before you move to a new line. - **Your file owns the triggers.** `on:`, `concurrency:` and `permissions:` go in your file. The shared workflow only defines the jobs. - **Inputs have defaults.** Set only the inputs that you want to change. A @@ -99,6 +112,56 @@ by hand. Replace `$default-branch` with `main`. require only `ci / CI`. When you add or remove jobs later, the rule stays the same. +## Adopt in an existing repository + +Use this procedure when a repository already has its own CI or security +workflows. It applies to people and to AI coding agents. + +1. **Read what the repository runs today.** Read `.github/workflows/*.yml`, + `.github/actions/*`, the `scripts` in `composer.json` and `package.json`, + the test suites in `phpunit.xml`, `tmpDir` in `phpstan.neon`, + `cacheDirectory` in `rector.php`, and the files `.env.testing`, `.nvmrc` + and `.npmrc`. +2. **Choose the workflows.** A Laravel application uses `laravel-ci.yml` and + `security.yml`. An npm-only application uses `node-ci.yml` and + `security.yml`. +3. **Start from the template at the release tag.** For example: + + ```bash + gh api 'repos/scify/.github/contents/workflow-templates/laravel-ci.yml?ref=v0.1' \ + -H 'Accept: application/vnd.github.raw' > .github/workflows/ci.yml + ``` + + Replace `$default-branch` with the default branch, for example `main`. +4. **Map each existing command to an input.** Keep the repository's own + commands where they exist, for example `lint-command: composer check`. Leave + an input commented out when its default already does the same thing. +5. **Remove what the shared workflows replace.** Delete the old workflow + files and every local copy of `scan-dev-configs` or `verify-npm-hardening`. +6. **Run the repository's own checks on the new files.** Run its formatters and + linters, for example `npm run check` or `composer check`, and + `actionlint .github/workflows/*.yml`. +7. **Open a pull request.** Every `ci / ...` and `security / ...` check must + pass. Open the job logs and confirm that the expected steps ran. +8. **Update the required check.** In the branch ruleset or branch protection, + require `ci / CI` instead of the old check name. Do this together with the + merge. +9. **Check the first run on the default branch** after the merge. + +### Pitfalls + +| Pitfall | What happens | Do this | +| --- | --- | --- | +| An action from `scify/.github` pinned by tag | The run fails: the organisation requires actions pinned by SHA | Call `security.yml`, which loads its actions by itself. For a direct use, pin the action by SHA | +| A reusable workflow pinned by SHA | The repository stops receiving fixes | Use `@v0.1` | +| The repository's formatter does not run before the push | CI fails, for example Prettier on the new workflow file | Run the repository's own checks first (step 6) | +| A commented-out input under `# with:` | Prettier moves the comment, and the uncommented input no longer sits under `with:` | Keep the extra indentation after the `#`, as in the templates: `# input: value` | +| Two inputs run the same tool | Double run time, for example `composer check` that already includes `check:types` | Give each input a command that does not overlap with the others | +| Browser tests in `test-command` | They run in `Backend tests`, which has no browser, and fail | Exclude the browser suite, for example `--exclude-testsuite=Browser` | +| The required check still has the old name | Every new pull request waits for a check that never runs; Dependabot auto-merge stops | Require `ci / CI` (step 8) | +| A green job with nothing checked | For example "No package.json, skipping" | Read the job logs (step 7) | +| Old local copies of the actions stay | They drift and keep bugs that the shared copies fixed | Delete them (step 5) | + ## Laravel CI `laravel-ci.yml` runs static checks, backend tests and optional browser tests diff --git a/.github/workflows/self-check.yml b/.github/workflows/self-check.yml index af67c73..de0b72e 100644 --- a/.github/workflows/self-check.yml +++ b/.github/workflows/self-check.yml @@ -98,7 +98,7 @@ jobs: - name: Internal references use one release tag run: | tags=$(grep -rhoE --exclude-dir=.git --exclude-dir=fixtures \ - 'scify/\.github/\.github/(workflows|actions)/[A-Za-z0-9_-][A-Za-z0-9/_.-]*@v[0-9]+(\.[0-9]+)*|raw\.githubusercontent\.com/scify/\.github/v[0-9]+(\.[0-9]+)*' . \ + 'scify/\.github/\.github/(workflows|actions)/[A-Za-z0-9_-][A-Za-z0-9/_.-]*@v[0-9]+(\.[0-9]+)*|raw\.githubusercontent\.com/scify/\.github/v[0-9]+(\.[0-9]+)*|\?ref=v[0-9]+(\.[0-9]+)*' . \ | grep -oE 'v[0-9]+(\.[0-9]+)*$' | sort | uniq -c) echo "$tags" if [ "$(echo "$tags" | wc -l)" -ne 1 ]; then @@ -137,8 +137,26 @@ jobs: - name: verify-npm-hardening passes with the hardened .npmrc uses: ./.github/actions/verify-npm-hardening - - name: Remove min-release-age from .npmrc - run: sed -i '/^min-release-age=/d' .npmrc + - name: Point a lockfile entry to a look-alike registry + run: | + cp package-lock.json package-lock.json.orig + node -e " + const fs = require('fs'); + const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); + lock.packages['node_modules/left-pad'] = { version: '1.3.0', + resolved: 'https://registry.npm.evil.example/left-pad/-/left-pad-1.3.0.tgz' }; + fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2)); + " + + - name: verify-npm-hardening with a look-alike registry + id: npm-lookalike + continue-on-error: true + uses: ./.github/actions/verify-npm-hardening + + - name: Restore the lockfile and remove min-release-age from .npmrc + run: | + mv package-lock.json.orig package-lock.json + sed -i '/^min-release-age=/d' .npmrc - name: verify-npm-hardening without min-release-age id: npm-weak @@ -149,10 +167,12 @@ jobs: env: SCAN: ${{ steps.scan-blocked.outcome }} NPM: ${{ steps.npm-weak.outcome }} + LOOKALIKE: ${{ steps.npm-lookalike.outcome }} run: | echo "scan-dev-configs without allowlist: $SCAN" echo "verify-npm-hardening without min-release-age: $NPM" - if [ "$SCAN" != failure ] || [ "$NPM" != failure ]; then + echo "verify-npm-hardening with a look-alike registry: $LOOKALIKE" + if [ "$SCAN" != failure ] || [ "$NPM" != failure ] || [ "$LOOKALIKE" != failure ]; then echo "::error::An action passed on a fixture that it must reject." exit 1 fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11d4c24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,144 @@ +# AGENTS.md + +Instructions for AI coding agents (Claude Code, Codex, Copilot, Cursor and +others). People read [README.md](README.md) first. + +This repository holds the shared GitHub material of the SciFY organisation: +reusable workflows, composite actions, workflow templates, Dependabot templates +and the organisation's default community files. It is public. Agent skills live +in a different repository, `scify/scify-agent-tools`. + +| Your task | Read | +| --- | --- | +| Add CI or security checks to **another** SciFY repository | [Part 1](#part-1-use-the-shared-workflows-in-another-repository) | +| Change **this** repository | [Part 2](#part-2-change-this-repository) | + +## Part 1: Use the shared workflows in another repository + +**Current release tag: `v0.1`.** Call every workflow with `@v0.1`. + +| Repository type | Workflows to call | Template | +| --- | --- | --- | +| Laravel (has `artisan` and `composer.json`) | `laravel-ci.yml` and `security.yml` | `workflow-templates/laravel-ci.yml`, `workflow-templates/security.yml` | +| npm only (has `package.json`, no `composer.json`) | `node-ci.yml` and `security.yml` | `workflow-templates/node-ci.yml`, `workflow-templates/security.yml` | +| Other (PHP library, WordPress, static site) | `security.yml` only | `workflow-templates/security.yml` | + +Follow the procedure in +[Adopt in an existing repository](.github/workflows/README.md#adopt-in-an-existing-repository). +The [workflow guide](.github/workflows/README.md) lists every input with its +default and example values. If you have the `scify-devops` Claude Code plugin, +the `/ci-setup` skill runs the same procedure. + +Read these files from the release tag, not from `main`: + +```bash +gh api 'repos/scify/.github/contents/workflow-templates/laravel-ci.yml?ref=v0.1' \ + -H 'Accept: application/vnd.github.raw' +gh api 'repos/scify/.github/contents/.github/workflows/README.md?ref=v0.1' \ + -H 'Accept: application/vnd.github.raw' +``` + +Rules that cause failures when you break them: + +1. **Workflows by tag, actions by SHA.** Call a reusable workflow with `@v0.1`. + The organisation requires every action in a step to be pinned by full commit + SHA, also actions from `scify/.github`. Do not call + `scify/.github/.github/actions/@v0.1`: it fails. Use `security.yml` + instead, which runs both actions. +2. **Run the target repository's own checks before you push.** Its formatters + and linters also check the new workflow files. Then run + `actionlint .github/workflows/*.yml`. +3. **The required check becomes `ci / CI`.** Tell the user to update the branch + ruleset or branch protection. Do not change repository settings without the + user's approval. +4. **Read the job logs.** A green job can mean that a step was skipped, for + example "No package.json, skipping". +5. **Delete replaced files**: old CI or security workflows, and local copies of + `scan-dev-configs` or `verify-npm-hardening`. + +The npm rules that `security.yml` enforces are in the +[npm policy](.github/actions/verify-npm-hardening/README.md#policy). + +## Part 2: Change this repository + +### Layout + +| Path | Contents | +| --- | --- | +| `.github/workflows/laravel-ci.yml`, `node-ci.yml`, `security.yml` | Reusable workflows (`on: workflow_call`) | +| `.github/workflows/self-check.yml` | CI of this repository. Not reusable | +| `.github/workflows/README.md` | Workflow guide for callers | +| `.github/actions/scan-dev-configs/`, `verify-npm-hardening/` | Composite actions that `security.yml` runs | +| `workflow-templates/` | Starter workflows for the "New workflow" page, with `.properties.json` files | +| `templates/` | Dependabot templates that repositories copy | +| `scripts/` | Checker scripts that the self-check runs | +| `tests/fixtures/` | Sample projects for the smoke tests (see its README) | +| `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE.md`, `profile/` | Organisation defaults. GitHub applies them to every SciFY repository without its own copy | +| `tasks/` | Local notes. Git ignores this folder | + +### Design rules + +Keep these rules. Each one fixes a failure that happened. + +1. **Scope.** Only content that is safe to publish. No secrets, hostnames or + deployment workflows: deployment lives in private repositories. +2. **Pin every third-party action by full commit SHA**, with the version in a + trailing comment: `uses: actions/checkout@ # v7.0.1`. +3. **No tag-pinned own actions.** `security.yml` checks out this repository at + `job.workflow_sha` and runs the actions from `./.scify-github/...`. Do not + change this to `scify/.github/.github/actions/@v0.1`. +4. **No composite actions for setup in the CI workflows.** `laravel-ci.yml` and + `node-ci.yml` share setup steps through YAML anchors (`&name` in the first + job, `*name` in the others). A setup step changes in the first job only. +5. **Tolerant defaults.** An empty `*-command` input means auto-detect: a tool + runs only when the repository has its config file. +6. **No `${{ }}` inside `run:`.** Pass inputs through `env:` and run custom + commands with `bash -c "$VAR"`. +7. **`shell: bash` runs with `-e -o pipefail`.** `x=$(grep ...)` stops the step + when grep finds nothing. Add `|| true` where no match is a valid result. +8. **Every input description ends with example values.** The guide documents + every input and default. `scripts/check-workflow-docs.py` fails otherwise. +9. **Templates list every input**, commented out, with the extra indentation + after the `#` (`# input: value`), so Prettier in a caller repository does + not move them. +10. **Every internal `@vX` reference uses the same tag.** The self-check fails + otherwise. +11. **Every check needs a case that must fail.** The `security-actions` job runs + each action on a broken fixture and expects a failure. +12. **Write documentation in ASD-STE100 Simplified Technical English**: active + voice, short sentences, one instruction per sentence. + +### Check your change locally + +The `Self-check` workflow runs these on every pull request. Run them before you +push: + +```bash +actionlint .github/workflows/*.yml # needs shellcheck on PATH for the run: scripts +python3 scripts/shellcheck-actions.py +python3 scripts/check-workflow-docs.py +``` + +The smoke tests (the fixtures in `tests/fixtures/`) run only on GitHub. Open a +pull request and read the logs of the jobs that your change affects. + +### Release + +Releases follow [Versioning](README.md#versioning) in the README: + +1. Merge to `main` and wait for a green `Self-check`. +2. Tag an immutable release, for example `v0.1.4`, and move the short tag + `v0.1` to the same commit. The ruleset `protect-release-tags` allows this + only for organisation owners. +3. Publish GitHub release notes for the new tag. + +Changes to documentation, templates or fixtures only need no release. GitHub +reads templates from `main`. + +### Commits and pull requests + +- One logical change per commit. The subject is an imperative sentence of at + most 72 characters. +- Do not add AI attribution lines (`Co-Authored-By`, "Generated with"). +- Pull request descriptions explain what changed and why. Do not add a "Test + plan" section. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 20ca8aa..e436a00 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This repository is public. GitHub requires that for the default files, the profi README and the workflow templates to take effect. Do not commit secrets, hostnames or internal URLs here. +**Using an AI coding agent?** Point it to [AGENTS.md](AGENTS.md). It tells the +agent how to add these workflows to a repository and how to change this one. + ## Scope This repository holds only content that is safe to publish: diff --git a/profile/README.md b/profile/README.md index c1d1b9b..db61677 100644 --- a/profile/README.md +++ b/profile/README.md @@ -15,6 +15,10 @@ accessibility, education, and inclusion. Every public repository accepts issues and pull requests. Start with the organisation [contributing guide](https://github.com/scify/.github/blob/main/CONTRIBUTING.md). +SciFY repositories share their CI and security checks through +[scify/.github](https://github.com/scify/.github). AI coding agents start with +its [AGENTS.md](https://github.com/scify/.github/blob/main/AGENTS.md). + ## Contact Website: