ITADN

Proposal: Pin GitHub Actions to commit SHAs for supply-chain security

#1475Openysknsid25 创建于 2026-05-24
prioritygithubtooling
Y
ysknsid25commented
# Motivation GitHub Actions referenced by mutable tags (e.g. `actions/checkout@v6`) are vulnerable to supply-chain attacks: anyone with write access to the action's repository can force-push the tag to point at malicious code, and consumers will silently pull it on the next workflow run. ## Why this matters specifically for a widely-used OSS library This is not just a generic best practice — the threat model has sharper edges for an OSS library like valibot: - **Asymmetric blast radius.** A successful compromise of valibot's release pipeline propagates to every downstream consumer on npm and JSR. The asymmetry between "one compromised CI run" and "thousands of poisoned installs" is fundamentally different from internal/proprietary projects. - **The publish workflow is a high-value target.** `.github/workflows/publish.yml` runs with `id-token: write` for trusted publishing to npm and JSR. Any action invoked from that workflow (including `actions/checkout`) is in the trust boundary of the release artifact. - **Public attack surface.** The workflow files, action versions, and dependency graph are all public. An attacker can enumerate "which popular OSS libraries trust which actions" at zero reconnaissance cost. - **Recent incidents have targeted exactly this pattern.** The *Shai-Hulud* npm worm propagated by stealing maintainer publish tokens through GitHub Actions and re-publishing poisoned versions of the compromised maintainer's packages — the precise threat that an `id-token: write` workflow on a popular OSS package faces. Other recent OSS-targeted incidents (**axios**, **Mini Shai-Hulud**, **TanStack Router**) reinforce that being widely-used and well-maintained does not, by itself, provide protection. - **GitHub and OpenSSF recommend this for OSS maintainers.** Both publish this as standard guidance for OSS projects specifically because of the points above. The `actions/checkout` README itself recommends pinning to a full length commit SHA. CI is part of the same threat model: a single tag rewrite on a popular action can compromise every workflow that references it, and for an OSS library that means compromising every downstream user. OpenSSF Scorecard's [Pinned-Dependencies](https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies) check recommends pinning **all** GitHub Actions — first-party and third-party alike — to full-length commit SHAs. # Current state | Action | Current pin | Locations | | --- | --- | --- | | `actions/checkout` | `@v6` | `ci.yml` (×13), `publish.yml` (×5) | | `actions/setup-node` | `@v6` | `.github/actions/environment/action.yml` | | `pnpm/action-setup` | `@v4` | `.github/actions/environment/action.yml` | | `denoland/setup-deno` | `@v2` | `.github/actions/environment/action.yml` | No Dependabot or Renovate configuration is present, so action updates currently rely on manual review. # Proposal 1. **Pin every external action to a full-length commit SHA**, keeping the human-readable version in a trailing comment: ```yaml - uses: actions/checkout@<40-char-sha> # v6.x.x ``` 2. **Add `.github/dependabot.yml`** enabling the `github-actions` ecosystem with a weekly schedule and grouped updates, so SHAs stay current via a single weekly PR rather than going stale. 3. **Upgrade pnpm from v9 to v11**, which ships supply-chain hardening as default behavior (`minimumReleaseAge: 1 day`, `blockExoticSubdeps`, `strictDepBuilds`) — see the discussion below. A stricter 7-day `minimumReleaseAge` override is **prepared but left commented out** in `pnpm-workspace.yaml` so that adopting it is a one-line decision; the operational impact of switching it on is documented in this issue. A draft PR will follow this issue. ## How the SHAs in the PR were resolved Each action's current major-version tag was resolved to a full-length commit SHA using `gh api`. Reviewers can reproduce / verify the values by running: ```bash # Resolve a major-version tag to its commit SHA gh api repos/actions/checkout/commits/v6 --jq '.sha' gh api repos/actions/setup-node/commits/v6 --jq '.sha' gh api repos/pnpm/action-setup/commits/v4 --jq '.sha' gh api repos/denoland/setup-deno/commits/v2 --jq '.sha' # Optional: confirm which exact release the floating tag currently points at gh api repos/pnpm/action-setup/tags --paginate \ --jq '.[] | select(.name | startswith("v4")) | {name, sha: .commit.sha}' ``` The SHAs proposed in the PR map to: `actions/checkout` v6.0.2, `actions/setup-node` v6.4.0, `pnpm/action-setup` v4.3.0 (the `v4` floating tag intentionally points at v4.3.0 rather than v4.4.0, because v4.4.0 introduced a Node.js 24 breaking change that was reverted on the floating tag), `denoland/setup-deno` v2.0.4. # Release cooldown — included in this proposal SHA pinning protects against **tag rewrite** attacks, but not against a malicious version published as a legitimate release (as seen with the *Shai-Hulud* worm, where compromised maintainer tokens pushed poisoned versions through the normal release channel). In most of these incidents the community detects and yanks the bad release within days, so **delaying adoption of brand-new versions** acts as a complementary defense layer. ## npm side — bump pnpm from v9 to v11 (covered by this PR) pnpm v9 has no native supply-chain protections; v10 added `minimumReleaseAge` as opt-in; **v11 turns three supply-chain protections on by default**. Bumping to v11 is therefore the lowest-friction way to get layered defense against npm-side attacks. ### What each v11 default does and why it matters **1. `minimumReleaseAge` — default `1440` (1 day)** > Newly published packages are not *resolved* during `pnpm install` until they have been on the npm registry for at least this many minutes. - **What it stops:** Compromised-credential attacks (e.g. Shai-Hulud) typically publish a malicious version, get detected and yanked by the community within hours-to-days, but in the meantime they spread because CI/local installs pull immediately. A non-zero release age guarantees a *detection window* before any consumer accepts the new version. - **What it does not stop:** A long-lived compromise (where the bad version stays on the registry past the cooldown window). Cooldown is a buffer, not a guarantee. - **Trade-off:** Legitimate hotfix releases are also delayed by the cooldown period. In practice 1–7 days is reasonable for an OSS library; valibot does not have a hard SLA requiring sub-day patch adoption. **The PR ships with the default 1 day active**, and adds a *commented-out* `minimumReleaseAge: 10080` (7 days) in `pnpm-workspace.yaml` ready to be enabled with a single uncomment. 7 days matches the community consensus on how long is usually sufficient for npm-worm-class incidents to be detected and yanked, but turning it on has concrete operational consequences listed below; the call on when to flip it is left to the maintainer. ### Operational impact if you uncomment the 7-day override The lockfile in `main` always contains *some* dependencies that were published recently. Once the 7-day window is active, `pnpm install` refuses to resolve any lockfile entry whose publish date falls inside that window — which is the whole point of the protection, and also why the cost of enabling it is non-zero. **Concrete example with the current `pnpm-lock.yaml` (as of 2026-05-25):** ``` [ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 9 lockfile entries failed verification: @shikijs/core@4.1.0 published 2026-05-19 (6 days ago) @shikijs/engine-javascript@4.1.0 … @shikijs/engine-oniguruma@4.1.0 … @shikijs/langs@4.1.0 … @shikijs/primitive@4.1.0 … @shikijs/rehype@4.1.0 … @shikijs/themes@4.1.0 … @shikijs/types@4.1.0 … shiki@4.1.0 … ``` Implications: - **For *this* PR's merge timing.** If the 7-day override is enabled at merge time, the shiki@4.1.0 family in the current lockfile fails the check. The simplest path is to **merge on or after 2026-05-26**, the day shiki@4.1.0 ages past the 7-day cutoff. Alternatively, merge with the override left commented out (current state) and flip it on in a follow-up after the lockfile naturally ages. - **For ongoing development after the override is on.** Whenever a fresh upstream release lands inside the workspace, the next `pnpm install` will start failing for affected lockfile entries. There is no single "right" response — the choice depends on why the fresh version is in the lockfile: 1. **Wait it out.** Easiest. Re-run `pnpm install` after the cooldown expires (≤ 7 days). Best when the bump isn't time-sensitive. 2. **Pin to an older version.** Edit `package.json` / lockfile to use a previous release of the offending package. Best when the new version isn't actually needed (e.g. it landed via a transitive `^` range). 3. **Temporarily exclude.** Add the package to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`. Best when the new version *is* needed for a fix and you've manually reviewed it. **Remove the exclusion once the package ages past the window.** All three options are valid, with decreasing safety: (1) preserves the buffer in full, (2) preserves it for everything except the deliberately-older pin, (3) creates a per-package hole that should be temporary. These trade-offs are precisely why the override is *not* enabled by default in the PR — adopting a 7-day buffer is a meaningful change to the contributor workflow, not just a config tweak. **2. `blockExoticSubdeps` — default `true`** > Refuses to install registry packages whose sub-dependencies are declared as git URLs, http(s) tarballs, or local file paths. - **What it stops:** A common supply-chain trick is for an attacker who compromises an npm package to publish a new version that pulls additional code from a non-registry source (a git repo they control, a tarball they host). Those sources bypass npm's audit/yank machinery entirely. Blocking exotic sub-deps removes that escape hatch. - **What it does not stop:** Compromise of a "normal" npm-registry dependency. It only closes one specific class of evasion. - **Trade-off:** Legitimate but unconventional packages that depend on a git fork or a local link will fail to install. None of valibot's current dependency graph relies on exotic sub-deps, so this default is a free win. **3. `strictDepBuilds` — default `true`** > Every package install/postinstall script is denied by default; only packages explicitly listed in `allowBuilds` are permitted to execute scripts. - **What it stops:** install/postinstall scripts are the most common code-execution primitive in npm supply-chain attacks. By default-denying them, the attack surface shrinks from "every transitive dep that has a postinstall" to "only the audited allowlist." - **What it does not stop:** Code that runs when an already-installed dependency is *required* / *imported* at runtime. This guards installation, not execution at use. - **Trade-off:** Packages that genuinely need to run a postinstall (sharp, node-gyp-based deps, native bindings) need an explicit `allowBuilds: { name: true }` entry, and `pnpm install` will fail until they are added. This is intentional — every entry is a review checkpoint. This PR adds the minimum necessary allowlist: ```yaml allowBuilds: sharp: true # website only — fetches/builds native image-processing binaries ``` If `pnpm install` after the upgrade reports additional unapproved build scripts, **review each one individually and add it to `allowBuilds`** rather than disabling the feature. Disabling `strictDepBuilds` would remove the entire benefit of v11's default; the friction of adding entries is the point. ### Operational notes for the bump - **`packageManager` field added to root `package.json`** (`pnpm@11.3.0`). Contributors using a Corepack-aware toolchain will automatically install the matching version; otherwise they will need to bump pnpm locally. - **Lockfile.** `pnpm-lock.yaml` is still v9 format (no migration), but the first `pnpm install` on v11 may produce a small diff (metadata fields, etc.). This is expected and should be committed along with the PR. - **`.npmrc` rule change.** In v11, `.npmrc` is restricted to auth and registry settings; all other pnpm configuration must live in `pnpm-workspace.yaml`. That is where the new settings are placed in this PR — no `.npmrc` is needed for cooldown. - **Node.js requirement.** pnpm v11 requires Node.js ≥ 22. The repo already uses Node.js 24 in CI (`actions/setup-node` with `node-version: 24`), so this is already satisfied. ## GitHub Actions side — Dependabot `cooldown` (deferred to maintainer) Dependabot also supports a `cooldown:` block to delay PRs after a new release. This is **not enabled in the PR** because the optimal duration is a maintainer judgement call. Example configuration if you want to adopt it later: ```yaml cooldown: default-days: 7 semver-major-days: 14 semver-minor-days: 7 semver-patch-days: 3 ``` # References - OpenSSF Scorecard — Pinned-Dependencies - GitHub Docs — [Using third-party actions: pinning to a full length commit SHA](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions) - GitHub Docs — [Dependabot configuration options: `cooldown`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#cooldown) - pnpm Docs — `minimumReleaseAge` setting # PoC I've created a https://github.com/open-circle/valibot/pull/1474, and I'd like to discuss it based on that.
2 条评论