Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/tighten-supply-chain-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
---

Apply supply-chain security best practices from [lirantal/npm-security-best-practices](https://github.com/lirantal/npm-security-best-practices) as enforced repo configuration plus a vitest gate that fails CI if any practice regresses.

Config: bump pnpm to 10.33.2; add `minimumReleaseAge: 10080` (7-day install cooldown) and `blockExoticSubdeps: true` to `pnpm-workspace.yaml`; pin default + `@cipherstash` registry to npmjs via committed `.npmrc`; switch CI to `pnpm install --frozen-lockfile` on Node 22; add `.github/dependabot.yml` with cooldown'd grouped updates for npm + github-actions; add `.github/CODEOWNERS` protecting supply-chain critical paths.

Test gate (`e2e/tests/supply-chain.e2e.test.ts`, 12 cases): asserts each invariant above, plus that every `pnpm-lock.yaml` entry resolves via `registry.npmjs.org` (substitutes for `lockfile-lint`, which doesn't support pnpm).

Docs: new `skills/stash-supply-chain-security/SKILL.md` with the full guide; `AGENTS.md` Supply Chain Security section.

No changes to any published package — release-side practices (#11 provenance, #12 OIDC trusted publishing) are deferred to a follow-up that requires npmjs.com Trusted Publisher configuration first.
12 changes: 12 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Default owners for everything in the repo.
* @cipherstash/developers

# Supply-chain critical paths — changes here require explicit review
# (lirantal/npm-security-best-practices; see skills/stash-supply-chain-security/).
/.github/workflows/ @cipherstash/developers
/.github/dependabot.yml @cipherstash/developers
/.github/CODEOWNERS @cipherstash/developers
/pnpm-workspace.yaml @cipherstash/developers
/pnpm-lock.yaml @cipherstash/developers
/.npmrc @cipherstash/developers
/skills/stash-supply-chain-security/ @cipherstash/developers
62 changes: 62 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
version: 2
updates:
# ── npm dependencies (pnpm workspace) ──────────────────────────
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
time: "08:00"
timezone: Australia/Sydney
# Wait for community discovery before opening PRs
# (lirantal/npm-security-best-practices #2: install with cooldown).
cooldown:
default-days: 7
semver-major-days: 14
open-pull-requests-limit: 10
labels:
- dependencies
- supply-chain
commit-message:
prefix: "chore(deps)"
include: scope
groups:
dev-dependencies:
dependency-type: development
update-types:
- minor
- patch
production-minor-patch:
dependency-type: production
update-types:
- minor
- patch
type-definitions:
patterns:
- "@types/*"
# Major bumps stay un-grouped → one PR each, easier to review.
ignore:
# Catalog-managed; bump manually via pnpm-workspace.yaml + changeset.
- dependency-name: "@cipherstash/auth"

# ── GitHub Actions ─────────────────────────────────────────────
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 5
labels:
- dependencies
- github-actions
commit-message:
prefix: "chore(actions)"
groups:
actions-minor-patch:
patterns:
- "*"
update-types:
- minor
- patch
8 changes: 4 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: 'pnpm'

- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

- name: Create .env file in ./packages/protect/
run: |
Expand Down Expand Up @@ -115,11 +115,11 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: 'pnpm'

- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

# Run the standalone `e2e/` workspace via turbo so the `^build`
# dep on the `test:e2e` task builds cli + wizard first. CLI's own
Expand Down
5 changes: 5 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Pin all registry traffic to npmjs (defence-in-depth against
# dependency-confusion: lirantal/npm-security-best-practices #16).
# Auth tokens MUST stay in user-level ~/.npmrc or env vars, never here.
@cipherstash:registry=https://registry.npmjs.org/
registry=https://registry.npmjs.org/
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ This is the Protect.js repository - End-to-end, per-value encryption for JavaScr
## Prerequisites

- **Node.js**: >= 22 (enforced in `package.json` engines)
- **pnpm**: 10.14.0 (this repo uses pnpm workspaces and catalogs)
- **pnpm**: 10.33.2 (this repo uses pnpm workspaces and catalogs)
- Internet access to install the prebuilt native module `@cipherstash/protect-ffi`

If running integration tests or examples, you will also need CipherStash credentials (see Environment variables below).
Expand Down Expand Up @@ -85,7 +85,17 @@ If these variables are missing, tests that require live encryption will fail or
- `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`)
- `examples/*`: Working apps (basic, drizzle, nextjs-clerk, next-drizzle-mysql, dynamo, hono-supabase)
- `docs/*`: Concepts, how-to guides (Next.js bundling, SST, npm lockfile v3), reference
- `skills/*`: Agent skills (`stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-secrets`, `stash-supabase`)
- `skills/*`: Agent skills (`stash-encryption`, `stash-drizzle`, `stash-dynamodb`, `stash-secrets`, `stash-supabase`, `stash-supply-chain-security`)

## Supply Chain Security

This repo applies a set of supply-chain controls (post-install script policy, install cooldown, frozen-lockfile CI, registry pinning, Dependabot cooldown, CODEOWNERS) sourced from [lirantal/npm-security-best-practices](https://github.com/lirantal/npm-security-best-practices). They're validated by `e2e/tests/supply-chain.e2e.test.ts` so silent regressions fail CI. See `skills/stash-supply-chain-security/SKILL.md` for the full guide.

Three rules to remember when editing CI or pnpm config:

1. **CI uses `pnpm install --frozen-lockfile`.** Don't drop the flag.
2. **Adding to `pnpm.onlyBuiltDependencies` is an audit decision** — vet the package and explain the addition in the PR.
3. **Don't commit auth tokens in `.npmrc`.** Tokens belong in user-level `~/.npmrc` or environment variables.

## Key Concepts and APIs

Expand Down
3 changes: 2 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
"vitest": "catalog:repo"
"vitest": "catalog:repo",
"yaml": "^2.8.3"
}
}
169 changes: 169 additions & 0 deletions e2e/tests/supply-chain.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'

// Supply-chain enforcement tests. Each `it` corresponds to a control
// from lirantal/npm-security-best-practices applied in this repo.
// See skills/stash-supply-chain-security/SKILL.md for the rationale and
// how to bypass any of these for legitimate reasons.

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '../..')

const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8')
const readJson = (p: string) => JSON.parse(read(p))
const readYaml = (p: string) => parseYaml(read(p))

describe('supply chain — pnpm configuration', () => {
it('packageManager is pnpm ≥ 10.26 (needed for blockExoticSubdeps)', () => {
const pm = readJson('package.json').packageManager as string
expect(pm).toMatch(/^pnpm@/)
const [maj, min] = pm.replace('pnpm@', '').split('.').map(Number)
expect(maj).toBeGreaterThanOrEqual(10)
if (maj === 10) expect(min).toBeGreaterThanOrEqual(26)
})

it('pnpm-workspace.yaml sets minimumReleaseAge ≥ 7 days', () => {
// Enforce the configured policy (7 days), not just the lirantal minimum
// (3 days). Mirrors the Dependabot cooldown so manual + automated
// updates have the same community-discovery window.
const ws = readYaml('pnpm-workspace.yaml') as { minimumReleaseAge?: number }
expect(ws.minimumReleaseAge).toBeGreaterThanOrEqual(10080) // 7 days in minutes
})

it('pnpm-workspace.yaml sets blockExoticSubdeps: true', () => {
const ws = readYaml('pnpm-workspace.yaml') as { blockExoticSubdeps?: boolean }
expect(ws.blockExoticSubdeps).toBe(true)
})

it('onlyBuiltDependencies remains a small explicit allowlist (≤3 entries)', () => {
const allow = (readJson('package.json').pnpm?.onlyBuiltDependencies ?? []) as string[]
expect(Array.isArray(allow)).toBe(true)
expect(allow.length).toBeLessThanOrEqual(3)
})
})

describe('supply chain — registry pinning (.npmrc)', () => {
it('pins @cipherstash scope and default registry to npmjs', () => {
const npmrc = read('.npmrc')
expect(npmrc).toMatch(/^@cipherstash:registry=https:\/\/registry\.npmjs\.org\/$/m)
expect(npmrc).toMatch(/^registry=https:\/\/registry\.npmjs\.org\/$/m)
})

it('does NOT contain auth tokens', () => {
const npmrc = read('.npmrc')
expect(npmrc).not.toMatch(/_authToken/i)
expect(npmrc).not.toMatch(/NPM_TOKEN/)
})
})

describe('supply chain — pnpm-lock.yaml integrity', () => {
it('every resolved package comes from registry.npmjs.org (no git/tarball deps)', () => {
const lock = readYaml('pnpm-lock.yaml') as {
packages?: Record<string, { resolution?: { tarball?: string; type?: string } }>
}
const offenders: string[] = []
for (const [name, entry] of Object.entries(lock.packages ?? {})) {
const resolution = entry.resolution
if (!resolution) continue
// Workspace `link:` entries appear as `directory` — those are first-party,
// not a supply-chain risk, and pnpm catalogs require them.
if (resolution.type === 'directory') continue
if (resolution.type === 'git') {
offenders.push(`${name} (type=git)`)
continue
}
const tarball = resolution.tarball
if (tarball && !tarball.startsWith('https://registry.npmjs.org/')) {
offenders.push(`${name} (tarball=${tarball})`)
}
}
expect(offenders).toEqual([])
})
})

describe('supply chain — CI hardening (.github/workflows/tests.yml)', () => {
const workflow = readYaml('.github/workflows/tests.yml') as {
jobs: Record<string, { steps: Array<{ run?: string; uses?: string; with?: Record<string, unknown> }> }>
}

it('every `pnpm install` invocation uses --frozen-lockfile', () => {
// Allow flag tokens (e.g. `pnpm --filter=foo install`, `pnpm -w install`)
// between `pnpm` and `install`, but not arbitrary words — that would
// false-match scripts like `pnpm run install-x`.
const PNPM_INSTALL = /\bpnpm\b(?:\s+-{1,2}\S+)*\s+install\b/
for (const [jobName, job] of Object.entries(workflow.jobs)) {
const installSteps = job.steps.filter(
(s) => typeof s.run === 'string' && PNPM_INSTALL.test(s.run),
)
for (const step of installSteps) {
expect(step.run, `${jobName} step "${step.run}"`).toMatch(/--frozen-lockfile/)
}
}
})

it('every pnpm-using job runs on Node 22', () => {
for (const [jobName, job] of Object.entries(workflow.jobs)) {
const usesPnpm = job.steps.some(
(s) =>
(typeof s.uses === 'string' && s.uses.startsWith('pnpm/action-setup')) ||
(typeof s.run === 'string' && /\bpnpm\b/.test(s.run)),
)
if (!usesPnpm) continue
const setup = job.steps.find(
(s) => typeof s.uses === 'string' && s.uses.startsWith('actions/setup-node'),
)
expect(setup, `${jobName} uses pnpm but lacks actions/setup-node`).toBeTruthy()
expect(String(setup?.with?.['node-version']), `${jobName} node version`).toBe('22')
}
})
})

describe('supply chain — automated dependency updates (Dependabot)', () => {
const db = readYaml('.github/dependabot.yml') as {
updates: Array<{
'package-ecosystem': string
cooldown?: { 'default-days'?: number; 'semver-major-days'?: number }
}>
}

it('npm ecosystem has a ≥ 3 day cooldown', () => {
const npm = db.updates.find((u) => u['package-ecosystem'] === 'npm')
expect(npm).toBeDefined()
expect(npm?.cooldown?.['default-days']).toBeGreaterThanOrEqual(3)
})

it('github-actions ecosystem is also covered with a ≥ 3 day cooldown', () => {
const gha = db.updates.find((u) => u['package-ecosystem'] === 'github-actions')
expect(gha).toBeDefined()
expect(gha?.cooldown?.['default-days']).toBeGreaterThanOrEqual(3)
})
})

describe('supply chain — governance (CODEOWNERS)', () => {
it('protects supply-chain critical paths and assigns @cipherstash/developers', () => {
// Substring-search comment lines too liberally — strip them first so a
// bare comment mentioning the path can't satisfy the assertion.
const rules = read('.github/CODEOWNERS')
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('#'))

for (const path of [
'pnpm-workspace.yaml',
'pnpm-lock.yaml',
'dependabot.yml',
'.npmrc',
'.github/workflows/',
'.github/CODEOWNERS',
'skills/stash-supply-chain-security/',
]) {
const rule = rules.find((l) => l.includes(path))
expect(rule, `no CODEOWNERS rule covers ${path}`).toBeDefined()
const owners = rule!.split(/\s+/).slice(1)
expect(owners, `${path} CODEOWNERS owners`).toContain('@cipherstash/developers')
}
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"rimraf": "^6.1.2",
"turbo": "2.1.1"
},
"packageManager": "pnpm@10.14.0",
"packageManager": "pnpm@10.33.2",
"engines": {
"node": ">=22"
},
Expand Down
Loading
Loading