stash-supply-chain-security
GitHub用于管理npm供应链安全的技能,涵盖禁用生命周期脚本、安装冷却期、锁定文件完整性及CI冻结等策略。适用于修改CI工作流、pnpm配置、依赖更新或首次发布包到npm的场景。
Trigger Scenarios
Install
npx skills add cipherstash/stack --skill stash-supply-chain-security -g -y
SKILL.md
Frontmatter
{
"name": "stash-supply-chain-security",
"description": "Supply-chain security controls for the @cipherstash\/stack monorepo. Covers post-install script policy (onlyBuiltDependencies), install cooldown (minimumReleaseAge), lockfile integrity (blockExoticSubdeps + lockfile registry check), frozen-lockfile CI, registry pinning (.npmrc), Dependabot cooldown, CODEOWNERS, and npm OIDC trusted publishing \/ provenance (including claiming a new package name). Use when modifying CI workflows, pnpm config, dependency updates, .github\/dependabot.yml, release.yml, publishing a package to npm for the first time, or anything that touches how packages enter the build."
}
Supply Chain Security
Controls applied in this repo to limit blast radius from compromised npm packages, lockfile injection, dependency confusion, and rushed dependency upgrades. Sourced from lirantal/npm-security-best-practices and adapted for our pnpm workspace.
When to Use This Skill
- Modifying any file under
.github/workflows/ - Editing
pnpm-workspace.yaml,package.jsonpnpmblock, or.npmrc - Updating
.github/dependabot.ymlor.github/CODEOWNERS - Adding a dependency that needs a build script (i.e.
node-gyp,node-pty, prebuilt binaries) - Bypassing the install cooldown for a security fix
- Publishing a package to npm under a name that has never been published before
- Reviewing a PR that touches any of the above
What's Enforced (Config + Test Gate)
Each control below is validated by e2e/tests/supply-chain.e2e.test.ts — the test suite fails CI if a control regresses, so silent removal isn't possible.
1. Post-install scripts disabled by default — practice #1
pnpm 10+ disables lifecycle scripts globally and only runs them for packages on the onlyBuiltDependencies allowlist.
- Where:
package.jsonpnpm.onlyBuiltDependencies - Current allowlist:
["node-pty"](PTY tests need the native module built) - Test asserts: allowlist length ≤ 3 — adding a fourth entry forces explicit review
2. Install cooldown — practice #2
New package versions wait 7 days before they're eligible for install. Mirrors the Dependabot cooldown so manual + automated updates have the same community-discovery window.
- Where:
pnpm-workspace.yamlminimumReleaseAge: 10080(minutes) - Test asserts: ≥ 4320 minutes (3 days)
3. Lockfile injection prevented — practices #4, #16
Two layers:
pnpm-workspace.yamlblockExoticSubdeps: true— pnpm refuses to install transitive deps that come from git or direct tarballs (pnpm ≥ 10.26)- A test parses
pnpm-lock.yamland asserts every resolved tarball URL starts withhttps://registry.npmjs.org/
(Why not lockfile-lint? It only supports npm/yarn lockfiles. The pnpm-native test gives us the same protection.)
4. Frozen lockfile in CI — practice #5
CI uses pnpm install --frozen-lockfile. If pnpm-lock.yaml and any package.json drift, the install aborts — no silent registry fetches that bypass the locked versions.
- Where: every workflow under
.github/workflows/, and every local composite action under.github/actions/ - Test asserts: no
pnpm installstep anywhere in that graph is missing--frozen-lockfile
The check used to read tests.yml alone, and release.yml — the one workflow
that publishes to npm — ran a bare pnpm install the whole time. The single
install allowed to resolve outside the lockfile was the one whose output goes to
the registry.
5. Cooldown'd auto-updates — practice #6
Dependabot opens grouped, cooldown'd PRs (7 days minor/patch) for npm, cargo and github-actions. Major bumps are not proposed at all — every entry ignores version-update:semver-major, so majors are reviewed and applied by hand.
There is deliberately no semver-major-days cooldown on any entry. It would delay major version update PRs, which the ignore above means Dependabot never opens, and cooldown does not reach the security path either ("the cooldown option is only available for version updates, not security updates"). Don't add one back as a safety net for the day the ignore is dropped — dead config reads as policy, and the test below fails on the pair.
cargo covers the in-tree Rust workspace at packages/protect-ffi (not the repo root — that is where Cargo.toml/Cargo.lock live). It runs monthly rather than weekly because each bump costs a native rebuild to validate, and it ignores the exact-pinned CipherStash crates (cipherstash-client, cts-common, stack-auth, stack-profile, eql-bindings, vitaminc) — they share a release train with the @cipherstash/auth catalog and must be bumped together, manually.
- Where:
.github/dependabot.yml - Test asserts: cooldown ≥ 3 days on npm/github-actions; every entry ignores
version-update:semver-majorfor*and sets nosemver-major-days(both ends, so neither half can drift alone); every lockfile present in the repo maps to a monitoredpackage-ecosystem; every entry'sdirectoryactually contains the manifest its ecosystem reads
The ecosystem-coverage assertion is derived from the filesystem, so adding a lockfile for a new language fails the suite until dependabot.yml covers it. Two lockfiles are exempt because Dependabot has no ecosystem for them (e2e/wasm/deno.lock, .flox/env/manifest.lock); both are named with their reason in the test.
Note that ignore conditions suppress Dependabot security PRs too, not just version updates. The compensating control is OSV: .github/workflows/osv-scanner.yml scans --recursive ./, which reaches every lockfile in the tree (including Cargo.lock) and reports to code scanning.
6. Registry pinning — practice #16
.npmrc pins both the default registry and the @cipherstash scope to https://registry.npmjs.org/. Auth tokens stay in user-level ~/.npmrc or env vars — never committed.
- Test asserts:
.npmrccontains both pin lines and no_authToken/NPM_TOKEN
7. Governance (CODEOWNERS)
.github/CODEOWNERS requires @cipherstash/developers review for every supply-chain critical file. Combined with branch protection (configured in repo settings, not in this repo), this prevents single-actor changes to the chain.
- Test asserts: CODEOWNERS lists each critical path
What's Documented but Not Enforced
These controls depend on developer environment or org-level configuration — we describe them here but don't gate CI on them.
Harden installs locally — practice #3
For local installs of new packages, consider running them through one of:
npq— security checks, package age, typosquatting, provenance:npq install <pkg>- Socket Firewall (
sfw) — real-time blocker for known-malicious packages:sfw pnpm add <pkg>
Neither is required, but they're cheap insurance when adding a new direct dependency.
2FA on npm accounts — practice #10
Every maintainer with publish access to @cipherstash/* should have:
npm profile enable-2fa auth-and-writes
Releases no longer depend on this — release.yml publishes via OIDC and holds no
long-lived token (see "Publishing" below). 2FA still matters for the manual
publishes that OIDC can't cover: claiming a new package name, npm deprecate,
and npm dist-tag changes.
Reduce dependency tree — practice #13
Before adding a new direct dep, ask:
- Does Node ≥ 22 (our minimum) already provide this?
- Is the package actively maintained? Check Snyk's database (security.snyk.io) — practice #14
- What does
npm pack <pkg>show in the actual tarball? (npmjs.org's web view can lie — practice #15)
Secrets in CI
tests.yml writes .env files at CI time from GitHub Secrets. This is acceptable: secrets are never committed, scoped to the runner, and rotate via the GitHub UI. The .env files exist only for the lifetime of the job.
Do not commit any .env file to the repo.
Publishing — OIDC trusted publishing + provenance (practices #11, #12)
.github/workflows/release.yml publishes to npm with no NPM_TOKEN. It
authenticates via npm OIDC trusted publishing, and provenance attestations are
generated automatically as a side effect. Verify any published version with:
npm view <pkg>@<version> --json | grep -A3 attestations
Constraints baked into that workflow — don't undo them:
-
permissions: id-token: writeis what mints the OIDC token. Without it every publish fails — but it belongs on the publishing jobs, never at the workflow level. A trusted publisher is registered against a repository and a workflow filename, so oncerelease.ymlis the registered publisher, npm accepts a token minted by any job in that file: the registry cannot tell the cheap every-push gate apart from the publish job. Declared at the top, it reaches every job that does not override it, including the one added next month by someone who never read this page.release.ymltherefore grantscontents: readat the workflow level and escalates per job, so a new job has to ask for the credential in its own diff. Enforced byscripts/__tests__/workflow-publish-permissions.test.mjs, which also holds the list of jobs allowed to hold it. -
runs-on: ubuntu-latest, not a self-hosted/Blacksmith runner. npm rejects provenance from non-GitHub-hosted runners with E422. -
Never set
NPM_TOKEN.changesets/actionwrites a token.npmrcwhen it sees one, which shadows OIDC and fails every publish with E404 (npm/cli#8976). -
npm ≥ 11.5.1 and Node ≥ 22.14. Node 22 ships npm 10.x, so the workflow installs
npm@^11.5.1explicitly before publishing. -
No Actions cache in this workflow (no
cache:,package-manager-cache: false,pnpm/action-setupwithcache: false). A poisoned cache entry would execute in a credential-bearing job. Enforced byscripts/lint-no-workflow-caching.mjs, which also follows any local composite action or reusable workflow the job reaches — the rule is about the whole call tree, not the one file. -
Every published
uses:must be in that script'sAUDITED_ACTIONSallowlist. The gate cannot open a published action to check whether it caches, and the ones that do are not all named "cache" — asetup-<tool>action that caches by default has nocache:input and no telling name. So the list is what is permitted, and an action it has never met fails by default. Adding a step torelease.yml,_build-ffi-artifacts.ymlortests-supply-chain.ymlmeans auditing the action and adding it there with the reason, in the same PR. -
Three actions must disable caching explicitly, and the input differs for each. Allowlisting an action is not the same as it being safe by default:
Action Input Its default Required pnpm/action-setupcachefalsecache: falseactions/setup-nodepackage-manager-cachetruepackage-manager-cache: falsejdx/mise-actioncachetruecache: false- uses: jdx/mise-action@<sha> # v3.6.3 with: install: true working_directory: packages/protect-ffi cache: false # defaults to TRUE — omitting this restores the Actions cacheOmitting the key is not "no caching" for the bottom two, it is caching spelled invisibly. The gate's generic rule only fires on a truthy
cache:value, so a missing key is invisible to it — which is exactly how amise-actionstep with nocache:passed until each action got its own explicit-falseassertion.
The native-binding publish path
@cipherstash/protect-ffi and its six @cipherstash/protect-ffi-<platform>
packages ship compiled binaries, which changeset publish cannot produce: it
packs from the workspace, where index.node is a build output. So release.yml
publishes them itself, before changesets runs, and the same constraints apply to
that job — GitHub-hosted runner, id-token: write, no NPM_TOKEN,
npm ≥ 11.5.1, no Actions cache.
scripts/release-gate.mjsasks the registry which committed versions are missing. It is not a cost optimisation: if it wrongly reports nothing to publish, changesets publishes six platform packages with no binary in them. Every failure mode in it throws rather than answering "nothing to publish"._build-ffi-artifacts.ymlis a reusable workflow, and only builds. npm validates a trusted publish against the entry-point workflow's filename, and its docs call outworkflow_callas a known issue: "validation checks the calling workflow's name instead of the workflow that actually contains the publish command, which can cause configuration mismatches", withid-token: writerequired in both parent and child. A publish inside a reusable workflow is therefore validated against whichever workflow called it. Keeping it inrelease.yml— the registered filename, as an entry-point job rather than a call — is correct whichever way that resolves. The reusable workflow is on the no-caching gate's target list for the same reasonrelease.ymlis: everything it produces gets published.- Platform packages publish before the wrapper. The wrapper's six
optionalDependenciesare exact versions, so publishing it first exposes a version whose binaries do not exist yet. ffi-preflight.ymlis the dry run —changeset publishhas no--dry-run. Dispatch it against a Version Packages branch and it builds the real tarballs, checks each binary's architecture and libc, installs the host pair and loads it. It cannot publish, and "noid-token" is only half of why: that closes the OIDC path, while a plainNPM_TOKENwould still authenticate one. Both are absent — the workflow grantscontents: read, passes no secrets (nosecrets: inheriton its call into the build workflow), sets noregistry-url(which is what writes an_authTokenline into.npmrc), and names noNPM_TOKENorNODE_AUTH_TOKEN. Keep it that way; adding any one of them turns a dry run into a publisher.
Trusted publishing is configured per package on npmjs.com (package settings →
Trusted publisher → GitHub Actions): owner/repo cipherstash/stack, workflow
filename release.yml (filename only, with extension — not a path), environment
blank. npm does not validate this on save, so a typo only surfaces as a failed
publish. For configurations created after 2026-05-20 npm also requires an
explicit Allowed actions selection — pick npm publish.
repository.url must exactly match the publishing repository
(https://github.com/cipherstash/stack). npm checks it on a trusted publish and
rejects a mismatch; nothing warns beforehand. A package moved between
repositories needs its manifest updated in the same change as its publisher —
and for a package published from a subdirectory, repository.directory is
resolved from that repository's root, so it moves too.
Publishing a package name for the first time
A trusted publisher can only be attached to a package that already exists on the
registry, so a brand-new name can't be released by release.yml on its own —
the first publish has to be manual. This is why @cipherstash/stack-drizzle and
@cipherstash/stack-supabase each carry a 0.0.0 placeholder version.
Do this before the release that would first publish the name:
-
npm loginas a maintainer with publish rights on the@cipherstashscope. -
Publish a placeholder to claim the name. Use
pnpm publish, notnpm publish— workspace packages depend on each other viaworkspace:*and only pnpm rewrites that protocol on pack:pnpm --filter <pkg> build cd packages/<dir> npm version 0.0.0 --no-git-tag-version pnpm publish --tag bootstrap --access public --no-git-checks git checkout package.json # restore the real versionThis publish has no provenance — it predates the trusted-publisher config by definition. That's expected and is the only unattested version.
-
Register the trusted publisher on npmjs.com as described above.
-
npm deprecate <pkg>@0.0.0 "Placeholder package"so nothing installs it silently. -
After the real release lands, clean up the placeholder tags —
changeset publishnever removes a tag it didn't create:npm dist-tag rm <pkg> bootstrap
The first publish also sets latest to 0.0.0 regardless of --tag, so keep the
gap between the placeholder and the real release short, and confirm
npm view <pkg> dist-tags afterwards.
Also confirm the package's package.json has "publishConfig": {"access": "public"} —
.changeset/config.json sets access: "restricted" repo-wide, and the per-package
field is what overrides it.
Common Operations
Add a dependency that needs a build script
- Vet the package: latest version, active maintenance, reasonable download counts, source visible on GitHub.
- Run
npm pack <pkg>and inspect the tarball — confirm the install script is what you expect. - Add to
package.jsonpnpm.onlyBuiltDependencies:"pnpm": { "onlyBuiltDependencies": ["node-pty", "your-new-package"] } - Update the supply-chain test's allowlist threshold if you'd be adding the 4th entry — and explain in the PR why the count needs to grow.
- Run
pnpm installto confirm the build script executes.
Bypass the install cooldown for a security fix
When CVE response needs a patch faster than 7 days:
- Pin the exact patched version (a pnpm override scoped to the vulnerable range for transitive deps, or the manifest for direct deps) so the bypass run can only admit that one release.
- Run a one-off install with the cooldown disabled for that single run (pnpm ≥ 10 has no dedicated flag; the CLI config override is the one-off equivalent and does not persist — kebab-case is the canonical form, though pnpm 10.x accepts the camelCase spelling too):
pnpm install --config.minimum-release-age=0
Once the patched version is in pnpm-lock.yaml, normal and
--frozen-lockfile installs succeed without any bypass — locked versions are
not re-age-checked. Do NOT add third-party packages to
minimumReleaseAgeExclude: that list is for first-party packages only and a
name-scoped entry exempts every future release of the package until removed.
Document the bypass in the PR description (CVE ID, why the cooldown was the bottleneck) so the next reviewer can follow the reasoning.
Add a new dev dependency
No special steps — Dependabot will pick it up on the next weekly run (after the cooldown window). For immediate use, just pnpm add -D <pkg>.
Change a CI workflow
CODEOWNERS will request review from @cipherstash/developers. The supply-chain test will fail if the change drops --frozen-lockfile or downgrades Node.
Reference
- Source: lirantal/npm-security-best-practices
- Test gate:
e2e/tests/supply-chain.e2e.test.ts - pnpm config:
pnpm-workspace.yaml, rootpackage.jsonpnpmblock - CI:
.github/workflows/tests.yml - Updates:
.github/dependabot.yml - Governance:
.github/CODEOWNERS
Version History
- f2c5d32 Current 2026-08-20 10:21


