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
9 changes: 9 additions & 0 deletions .changeset/wizard-anthropic-sdk-security-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cipherstash/wizard": patch
---

Add `@anthropic-ai/sdk` `^0.106.0` as a direct dependency so the
auto-installed peer of `@anthropic-ai/claude-agent-sdk` resolves to a release
patched against GHSA-p7fg-763f-g4gf, instead of the vulnerable 0.81.0 the
peer range alone would select. The wizard never imports the SDK directly —
this is a peer-resolution pin only; no behaviour change.
2 changes: 2 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
"@types/semver": "7.5.8",
"semver": "^7.8.0",
"vitest": "catalog:repo",
"yaml": "^2.9.0"
}
Expand Down
123 changes: 123 additions & 0 deletions e2e/tests/supply-chain.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import semver from 'semver'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'

Expand All @@ -16,6 +17,27 @@ 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))

// Map every package in the lockfile's `packages:` section to its resolved
// versions. Keys there are bare `name@version` (peer suffixes live under
// `snapshots:`), and scoped names keep their leading `@`, so split on the
// LAST `@`; strip any stray peer suffix defensively.
const resolvedVersionsByName = (): Map<string, string[]> => {
const lock = readYaml('pnpm-lock.yaml') as {
packages?: Record<string, unknown>
}
const byName = new Map<string, string[]>()
for (const key of Object.keys(lock.packages ?? {})) {
const at = key.lastIndexOf('@')
if (at <= 0) continue // no scope-only or malformed keys
const name = key.slice(0, at)
const version = key.slice(at + 1).split('(')[0]
const list = byName.get(name)
if (list) list.push(version)
else byName.set(name, [version])
}
return byName
}

describe('supply chain — pnpm configuration', () => {
it('packageManager is pnpm ≥ 10.26 (needed for blockExoticSubdeps)', () => {
const pm = readJson('package.json').packageManager as string
Expand Down Expand Up @@ -46,6 +68,44 @@ describe('supply chain — pnpm configuration', () => {
expect(Array.isArray(allow)).toBe(true)
expect(allow.length).toBeLessThanOrEqual(3)
})

it('minimumReleaseAgeExclude contains only first-party packages', () => {
// The cooldown exclusion list exists for first-party packages that ship
// on their own release cadence. Third-party security fixes must use the
// one-off bypass (`pnpm install --config.minimum-release-age=0` with an
// exact pin) instead — a name-scoped exclusion exempts every future
// release of the package. See SKILL.md "Bypass the install cooldown".
const ws = readYaml('pnpm-workspace.yaml') as {
minimumReleaseAgeExclude?: string[]
}
const FIRST_PARTY = [/^@prisma-next\//, /^@cipherstash\//]
for (const entry of ws.minimumReleaseAgeExclude ?? []) {
expect(
FIRST_PARTY.some((re) => re.test(entry)),
`"${entry}" is not a first-party cooldown exclusion`,
).toBe(true)
}
})

it('security overrides stay range-scoped and remain a small allowlist (≤12 entries)', () => {
Comment thread
coderdan marked this conversation as resolved.
// Every override must be scoped to the advisory's vulnerable range
// (`pkg@<range>`), never a blanket `pkg` pin — a blanket pin silently
// rewrites versions outside the vulnerable range forever. The count cap
// mirrors onlyBuiltDependencies: growth forces a conscious review.
const ws = readYaml('pnpm-workspace.yaml') as {
overrides?: Record<string, string>
}
const selectors = Object.keys(ws.overrides ?? {})
expect(selectors.length).toBeLessThanOrEqual(12)
for (const selector of selectors) {
// A version-scoped selector has an `@` after the package name
// (position > 0 handles `@scope/pkg@range`).
expect(
selector.lastIndexOf('@') > 0,
`override "${selector}" is not scoped to a version range`,
).toBe(true)
}
})
})

describe('supply chain — registry pinning (.npmrc)', () => {
Expand Down Expand Up @@ -90,6 +150,69 @@ describe('supply chain — pnpm-lock.yaml integrity', () => {
}
expect(offenders).toEqual([])
})

it('every security override actually took effect (nothing left in a vulnerable range)', () => {
// The shape test ("security overrides stay range-scoped") proves the
// selectors are well-formed; this proves they *worked*. For each override
// `selector -> target`, no package may resolve to a version that still
// matches the vulnerable `selector` yet fails the `target` — that pair is
// exactly a silent regression (e.g. a re-resolve demoting fast-uri below
// its pin, un-fixing the advisory). Note `target` can sit inside its own
// selector range (js-yaml@>=4.0.0 <5 -> 4.2.0 normalises all 4.x to the
// patched release), so the check is "matched but not raised", not the
// stricter "no version matches the selector".
const ws = readYaml('pnpm-workspace.yaml') as {
overrides?: Record<string, string>
}
const overrides = Object.entries(ws.overrides ?? {})
// Guard the vacuous case: an empty/removed block would pass every loop
// below with zero iterations.
expect(overrides.length).toBeGreaterThan(0)

const byName = resolvedVersionsByName()
const offenders: string[] = []
for (const [selector, target] of overrides) {
const at = selector.lastIndexOf('@')
const name = selector.slice(0, at)
const vulnerableRange = selector.slice(at + 1)
for (const version of byName.get(name) ?? []) {
if (
semver.satisfies(version, vulnerableRange) &&
!semver.satisfies(version, target)
) {
offenders.push(
`${name}@${version} still matches vulnerable "${vulnerableRange}" (override target "${target}" not applied)`,
)
}
}
}
expect(offenders).toEqual([])
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('package.json has no top-level `overrides` (pnpm only reads pnpm-workspace.yaml)', () => {
// pnpm silently ignores a top-level npm-format `overrides` block; the
// security pins must live in pnpm-workspace.yaml `overrides`. Guards
// against the block being moved back here, where it would look applied
// but do nothing.
const pkg = readJson('package.json') as { overrides?: unknown }
expect(pkg.overrides).toBeUndefined()
})

it('@anthropic-ai/sdk resolves to the peer-pinned patched version (≥ 0.106.0)', () => {
// Not an override but a peer-resolution pin: packages/wizard depends on
// @anthropic-ai/sdk@^0.106.0 to force the auto-installed peer of
// @anthropic-ai/claude-agent-sdk past the advisory-vulnerable 0.81.0
// (GHSA-p7fg-763f-g4gf). The override-effect test cannot cover a peer
// pin, so assert the resolved version directly.
const versions = resolvedVersionsByName().get('@anthropic-ai/sdk') ?? []
expect(versions.length).toBeGreaterThan(0)
for (const version of versions) {
expect(
semver.gte(version, '0.106.0'),
`@anthropic-ai/sdk@${version} is below the patched 0.106.0`,
).toBe(true)
}
})
})

describe('supply chain — CI hardening (.github/workflows/tests.yml)', () => {
Expand Down
75 changes: 1 addition & 74 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,6 @@
"url": "git+https://github.com/cipherstash/stack.git"
},
"license": "MIT",
"workspaces": {
"packages": [
"packages/*",
"examples/*"
],
"catalogs": {
"repo": {
"@cipherstash/auth": "0.40.0",
"tsup": "8.4.0",
"tsx": "4.19.3",
"typescript": "5.6.3",
"vitest": "3.1.3"
},
"security": {
"@clerk/nextjs": "6.39.2",
"next": "15.5.10",
"vite": "6.4.1"
}
}
},
"scripts": {
"build": "turbo build --filter './packages/*'",
"build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'",
Expand All @@ -59,7 +39,7 @@
"@biomejs/biome": "^2.4.15",
"@changesets/cli": "^2.31.0",
"@types/node": "^22.19.19",
"js-yaml": "^4.1.1",
"js-yaml": "^4.2.0",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"rimraf": "^6.1.3",
"turbo": "2.9.14",
"vitest": "catalog:repo"
Expand All @@ -83,58 +63,5 @@
"onlyBuiltDependencies": [
"node-pty"
]
},
"overrides": {
"@babel/runtime": "7.26.10",
"brace-expansion@^5": ">=5.0.5",
"body-parser": "2.2.1",
"vite": "catalog:security",
"pg": "^8.16.3",
"postgres": "^3.4.7",
"js-yaml": "4.1.1",
"test-exclude": "^7.0.1",
"glob": ">=11.1.0",
"qs": ">=6.14.1",
"lodash": ">=4.18.0",
"minimatch": ">=10.2.3",
"@isaacs/brace-expansion": ">=5.0.1",
"fast-xml-parser": ">=5.3.4",
"next": ">=15.5.15",
"ajv": ">=8.18.0",
"esbuild@<=0.24.2": ">=0.25.0",
"picomatch@^4": ">=4.0.4",
"picomatch@^2": ">=2.3.2",
"rollup@>=4.0.0 <4.59.0": ">=4.59.0",
"drizzle-orm": ">=0.45.2",
"postcss": ">=8.5.10",
"hono": ">=4.12.14",
"@hono/node-server": ">=1.19.13",
"@prisma-next/adapter-postgres": "0.6.0-dev.8",
"@prisma-next/cli": "0.6.0-dev.8",
"@prisma-next/config": "0.6.0-dev.8",
"@prisma-next/contract": "0.6.0-dev.8",
"@prisma-next/contract-authoring": "0.6.0-dev.8",
"@prisma-next/driver-postgres": "0.6.0-dev.8",
"@prisma-next/emitter": "0.6.0-dev.8",
"@prisma-next/errors": "0.6.0-dev.8",
"@prisma-next/family-sql": "0.6.0-dev.8",
"@prisma-next/framework-components": "0.6.0-dev.8",
"@prisma-next/ids": "0.6.0-dev.8",
"@prisma-next/migration-tools": "0.6.0-dev.8",
"@prisma-next/operations": "0.6.0-dev.8",
"@prisma-next/psl-parser": "0.6.0-dev.8",
"@prisma-next/psl-printer": "0.6.0-dev.8",
"@prisma-next/sql-contract": "0.6.0-dev.8",
"@prisma-next/sql-contract-emitter": "0.6.0-dev.8",
"@prisma-next/sql-contract-psl": "0.6.0-dev.8",
"@prisma-next/sql-contract-ts": "0.6.0-dev.8",
"@prisma-next/sql-errors": "0.6.0-dev.8",
"@prisma-next/sql-operations": "0.6.0-dev.8",
"@prisma-next/sql-relational-core": "0.6.0-dev.8",
"@prisma-next/sql-runtime": "0.6.0-dev.8",
"@prisma-next/sql-schema-ir": "0.6.0-dev.8",
"@prisma-next/target-postgres": "0.6.0-dev.8",
"@prisma-next/ts-render": "0.6.0-dev.8",
"@prisma-next/utils": "0.6.0-dev.8"
}
}
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"devDependencies": {
"@clerk/nextjs": "catalog:security",
"dotenv": "^17.4.2",
"next": "catalog:security",
Comment thread
auxesis marked this conversation as resolved.
"tsup": "catalog:repo",
"typescript": "catalog:repo",
"vitest": "catalog:repo"
Expand Down
1 change: 1 addition & 0 deletions packages/wizard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.143",
"@anthropic-ai/sdk": "^0.106.0",
Comment thread
coderdan marked this conversation as resolved.
"@cipherstash/auth": "catalog:repo",
Comment thread
coderdan marked this conversation as resolved.
"@clack/prompts": "1.4.0",
"dotenv": "17.4.2",
Expand Down
Loading
Loading