diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8406052a..15883445 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,17 +21,24 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v6 - + - uses: pnpm/action-setup@v6 name: Install pnpm with: run_install: false + # Supply-chain hardening — never cache the pnpm store; a poisoned + # cache entry would execute in this credential-bearing workflow. + cache: false - name: Install Node.js uses: actions/setup-node@v6 with: node-version: 22 - cache: 'pnpm' + # No `cache:`, and package-manager-cache disabled. release.yml + # publishes to npm (NPM_TOKEN + OIDC) and must not restore the + # GitHub Actions cache — a cache-poisoning / supply-chain vector. + # Enforced by .github/workflows/tests-supply-chain.yml. + package-manager-cache: false # node-pty's install hook falls back to `node-gyp rebuild` when no # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships @@ -50,4 +57,4 @@ jobs: commitMode: 'github-api' env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests-supply-chain.yml b/.github/workflows/tests-supply-chain.yml new file mode 100644 index 00000000..ce114e3e --- /dev/null +++ b/.github/workflows/tests-supply-chain.yml @@ -0,0 +1,80 @@ +name: Test supply chain security + +# Supply-chain gate: asserts that release.yml (and this workflow) never +# restore the GitHub Actions cache. A workflow that both restores a cache +# and holds publish credentials is a cache-poisoning target — see +# https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/ +# +# The check (scripts/lint-no-workflow-caching.mjs) requires caching to be +# disabled *explicitly*, not just left at its default: +# - `pnpm/action-setup` must set `cache: false` +# - `actions/setup-node` must set `package-manager-cache: false` +# - no `cache:` input and no `actions/cache` step anywhere +# See the "CI/CD Supply-Chain Hardening" section of SECURITY.md. +# +# Deliberately minimal: +# - GitHub-hosted runner (no Blacksmith transparent cache) +# - contents:read only +# - no secrets +# - no caching + +on: + push: + branches: + - main + paths: + - '.github/workflows/release.yml' + - '.github/workflows/tests-supply-chain.yml' + - 'scripts/lint-no-workflow-caching.mjs' + - 'scripts/__tests__/lint-no-workflow-caching.test.mjs' + - 'scripts/__tests__/fixtures/lint-no-workflow-caching/**' + pull_request: + branches: + - '**' + paths: + - '.github/workflows/release.yml' + - '.github/workflows/tests-supply-chain.yml' + - 'scripts/lint-no-workflow-caching.mjs' + - 'scripts/__tests__/lint-no-workflow-caching.test.mjs' + - 'scripts/__tests__/fixtures/lint-no-workflow-caching/**' + +permissions: + contents: read + +jobs: + verify-no-caching-in-release-workflows: + name: Verify no caching in release workflows + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + name: Install pnpm + with: + run_install: false + # Do not use caching in this cache-testing workflow + cache: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + # Do not use caching in this cache-testing workflow + package-manager-cache: false + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships + # node-gyp on PATH, so install it explicitly. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run lint script self-tests + run: pnpm run test:scripts + + - name: Verify no caching in release.yml and tests-supply-chain.yml + run: pnpm run lint:workflow-cache diff --git a/SECURITY.md b/SECURITY.md index e5dc967b..2bba6976 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -CipherStash takes the security of our software, infrastructure, and customers extremely seriously. +CipherStash takes the security of our software, infrastructure, and customers extremely seriously. This document describes the security posture, reporting process, and guidelines for this repository and associated packages. ## Supported Packages @@ -80,13 +80,13 @@ We will acknowledge receipt within **48 hours** and provide regular updates unti CipherStash follows a **coordinated responsible disclosure** process: -1. **Submit report** privately via `security@cipherstash.com`. -2. **Acknowledgement** within 48 hours. -3. **Assessment** of severity using CVSS and internal risk models. -4. **Fix development** and patch release in a private branch. +1. **Submit report** privately via `security@cipherstash.com`. +2. **Acknowledgement** within 48 hours. +3. **Assessment** of severity using CVSS and internal risk models. +4. **Fix development** and patch release in a private branch. 5. **Coordinated disclosure**, including: - New patch release(s) - - Security advisory on GitHub + - Security advisory on GitHub - Credit to reporter (optional) We will never take legal action against good-faith security researchers who follow this policy. @@ -102,14 +102,14 @@ The following are **in scope**: - Protect.js cryptographic implementations, configuration layers, and CLI tooling - Key-handling, authenticated encryption behaviour, JSON/JSONB field-level encryption flows - Documentation or code examples that could lead to insecure usage -- CipherStash’s internal infrastructure +- CipherStash’s internal infrastructure - CipherStash Proxy, ZeroKMS, or other backend products The following are **out of scope**: - Example applications in the `examples` dir (though we are still grateful for any relevant disclosires there) -- Social engineering, physical attacks, or denial-of-service -- Attacks requiring privileged access to developer machines or CI/CD infrastructure +- Social engineering, physical attacks, or denial-of-service +- Attacks requiring privileged access to developer machines or CI/CD infrastructure --- @@ -118,23 +118,46 @@ The following are **out of scope**: To maintain a strong security posture, contributors MUST: ### ⚙️ Follow cryptographic safety rules -- Do **not** modify cryptographic primitives without prior discussion -- Avoid introducing new crypto dependencies without prior discussion -- Never check in test keys, secrets, or example credentials +- Do **not** modify cryptographic primitives without prior discussion +- Avoid introducing new crypto dependencies without prior discussion +- Never check in test keys, secrets, or example credentials ### 🛡 Coding & dependency hygiene -- Avoid adding dependencies unless necessary -- Keep dependencies updated and vetted -- Use TypeScript for all new code -- Ensure all code paths that handle keys or encrypted data include type-safe boundaries +- Avoid adding dependencies unless necessary +- Keep dependencies updated and vetted +- Use TypeScript for all new code +- Ensure all code paths that handle keys or encrypted data include type-safe boundaries ### 🔍 Testing & review -- Submit PRs with tests covering edge cases and misuse-resistant behaviour -- Flag any changes involving key derivation, key wrapping, AAD, or encryption modes for mandatory security review +- Submit PRs with tests covering edge cases and misuse-resistant behaviour +- Flag any changes involving key derivation, key wrapping, AAD, or encryption modes for mandatory security review - Do not merge PRs that downgrade security controls or introduce unsafe defaults --- +## CI/CD Supply-Chain Hardening + +The `release.yml` workflow publishes packages to npm using OIDC trusted +publishing and an `NPM_TOKEN`. + +[GitHub Actions cache poisoning is a known attack][1] against credential-bearing +workflows. The mechanism is: + +- A lower-privileged workflow run plants a malicious entry under a deterministic + cache key +- A privileged workflow restores a cache from that deterministic cache key +- The malicious entry is executed by the privileged workflow, and secrets + are exfiltrated + +We mitigate this by: + +- Explicitly disabling all caching in `release.yml` +- Automated checks for disabled caching on high-risk workflows + +[1]: https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/ + +--- + ## Questions? For general questions about CipherStash security practices (not security incidents), contact: diff --git a/package.json b/package.json index 92a9d961..3eadfc99 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", + "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", "test": "turbo test --filter './packages/*'", "test:e2e": "turbo run test:e2e", @@ -57,6 +58,7 @@ "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.29.6", "@types/node": "^22.15.12", + "js-yaml": "^3.14.2", "rimraf": "^6.1.2", "turbo": "2.1.1", "vitest": "catalog:repo" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2368c2dd..c3564cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/node': specifier: ^22.15.12 version: 22.19.3 + js-yaml: + specifier: ^3.14.2 + version: 3.14.2 rimraf: specifier: ^6.1.2 version: 6.1.2 diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-restore.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-restore.yml new file mode 100644 index 00000000..a52084c2 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-restore.yml @@ -0,0 +1,15 @@ +name: Actions Cache Restore +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Restore build cache + uses: actions/cache/restore@v4 + with: + path: .turbo + key: turbo-${{ github.sha }} + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-save.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-save.yml new file mode 100644 index 00000000..2d55ece7 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache-save.yml @@ -0,0 +1,15 @@ +name: Actions Cache Save +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Save build cache + uses: actions/cache/save@v4 + with: + path: .turbo + key: turbo-${{ github.sha }} + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache.yml new file mode 100644 index 00000000..685c9b0f --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/actions-cache.yml @@ -0,0 +1,15 @@ +name: Actions Cache +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Restore build cache + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ github.sha }} + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/clean.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/clean.yml new file mode 100644 index 00000000..f6517a47 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/clean.yml @@ -0,0 +1,19 @@ +name: Clean +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + run_install: false + cache: false + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + package-manager-cache: false + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/missing-explicit-false.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/missing-explicit-false.yml new file mode 100644 index 00000000..e9671b1e --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/missing-explicit-false.yml @@ -0,0 +1,17 @@ +name: Missing Explicit False +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + run_install: false + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/no-actions-cache.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/no-actions-cache.yml new file mode 100644 index 00000000..a8542172 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/no-actions-cache.yml @@ -0,0 +1,13 @@ +name: No Actions Cache +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # `actions/cache` is named here in prose but never used as a step — + # the check must only match a real `uses:` reference. + - name: Note — actions/cache is intentionally avoided + run: echo "this project does not use actions/cache" diff --git a/scripts/__tests__/fixtures/lint-no-workflow-caching/setup-node-cache.yml b/scripts/__tests__/fixtures/lint-no-workflow-caching/setup-node-cache.yml new file mode 100644 index 00000000..be5ca542 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-workflow-caching/setup-node-cache.yml @@ -0,0 +1,15 @@ +name: Setup Node Cache +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile diff --git a/scripts/__tests__/lint-no-workflow-caching.test.mjs b/scripts/__tests__/lint-no-workflow-caching.test.mjs new file mode 100644 index 00000000..5d993d19 --- /dev/null +++ b/scripts/__tests__/lint-no-workflow-caching.test.mjs @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +// Workflows the supply-chain gate is responsible for. +const TARGET_WORKFLOWS = [ + '.github/workflows/release.yml', + '.github/workflows/tests-supply-chain.yml', +] + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-no-workflow-caching.mjs', +) +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +function run(...targets) { + try { + execFileSync('node', [SCRIPT, ...targets], { encoding: 'utf8' }) + return { exitCode: 0, output: '' } + } catch (err) { + return { exitCode: err.status, output: String(err.stdout) + String(err.stderr) } + } +} + +describe('lint-no-workflow-caching', () => { + const fx = (name) => + resolve(fileURLToPath(import.meta.url), `../fixtures/lint-no-workflow-caching/${name}`) + + it('defaults to checking release.yml and tests-supply-chain.yml', () => { + expect(run().exitCode).toBe(0) + }) + + it('passes on a workflow with no caching', () => { + expect(run(fx('clean.yml')).exitCode).toBe(0) + }) + + it('fails on `cache:` under a setup-node step', () => { + const r = run(fx('setup-node-cache.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/with\.cache/) + }) + + it('fails on an `actions/cache` step', () => { + const r = run(fx('actions-cache.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/actions\/cache@/) + }) + + it('fails on an `actions/cache/restore` step', () => { + const r = run(fx('actions-cache-restore.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/actions\/cache\/restore@/) + }) + + it('fails on an `actions/cache/save` step', () => { + const r = run(fx('actions-cache-save.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/actions\/cache\/save@/) + }) + + it('passes when `actions/cache` appears only as prose, not a step', () => { + expect(run(fx('no-actions-cache.yml')).exitCode).toBe(0) + }) + + it('fails when caching is not disabled explicitly', () => { + const r = run(fx('missing-explicit-false.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/\bcache\b/) + expect(r.output).toMatch(/package-manager-cache/) + }) + + it('keeps release.yml free of GitHub Actions caching', () => { + expect(run(resolve(REPO_ROOT, '.github/workflows/release.yml')).exitCode).toBe(0) + }) + + it('keeps tests-supply-chain.yml free of GitHub Actions caching', () => { + expect( + run(resolve(REPO_ROOT, '.github/workflows/tests-supply-chain.yml')).exitCode, + ).toBe(0) + }) + + it('the target workflows contain no `actions/cache` step', () => { + for (const target of TARGET_WORKFLOWS) { + const doc = yaml.load(readFileSync(resolve(REPO_ROOT, target), 'utf8')) + for (const [jobName, job] of Object.entries(doc?.jobs ?? {})) { + for (const step of job?.steps ?? []) { + if (typeof step?.uses === 'string') { + expect( + step.uses, + `${target} job "${jobName}" must not use actions/cache`, + ).not.toMatch(/^actions\/cache(\/(restore|save))?@/) + } + } + } + } + }) +}) diff --git a/scripts/lint-no-workflow-caching.mjs b/scripts/lint-no-workflow-caching.mjs new file mode 100644 index 00000000..875bdaf4 --- /dev/null +++ b/scripts/lint-no-workflow-caching.mjs @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import yaml from 'js-yaml' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Default targets — the workflows the supply-chain gate covers. Override with +// argv[2..] for tests / ad-hoc multi-file checks. +const TARGETS = process.argv.slice(2).length + ? process.argv.slice(2) + : ['.github/workflows/release.yml', '.github/workflows/tests-supply-chain.yml'] + +// `uses:` values that pull in the GitHub Actions cache directly. +const CACHE_ACTION = /^actions\/cache(\/(restore|save))?@/ + +// Steps that must disable their built-in caching *explicitly* — leaving the +// key off and relying on the default is not enough: the gate asserts intent. +const PNPM_ACTION_SETUP = /^pnpm\/action-setup(@|$)/ +const SETUP_NODE = /^actions\/setup-node(@|$)/ + +function stepLabel(step, idx) { + return step?.name || step?.uses || `step #${idx + 1}` +} + +// Returns a reason string if `inputName` is not explicitly set to boolean +// `false` on the step's `with:`, otherwise null. +function explicitFalseReason(step, inputName) { + const w = step?.with + if (!w || !Object.hasOwn(w, inputName)) { + return `must set \`${inputName}: false\` explicitly (key missing)` + } + if (w[inputName] !== false) { + return `\`${inputName}\` must be \`false\`, found ${JSON.stringify(w[inputName])}` + } + return null +} + +const offenders = [] +for (const target of TARGETS) { + const abs = resolve(REPO_ROOT, target) + const rel = relative(REPO_ROOT, abs) + const doc = yaml.load(readFileSync(abs, 'utf8')) + const jobs = doc?.jobs ?? {} + for (const [jobName, job] of Object.entries(jobs)) { + const steps = Array.isArray(job?.steps) ? job.steps : [] + steps.forEach((step, idx) => { + const label = stepLabel(step, idx) + const at = `${rel}: job "${jobName}" step "${label}"` + + // `cache:` under a step's `with:` — covers actions/setup-node, + // actions/setup-python, etc. An explicit falsy value does not count. + if (step?.with && Object.hasOwn(step.with, 'cache') && step.with.cache) { + offenders.push( + `${at}: \`with.cache: ${JSON.stringify(step.with.cache)}\` restores the GitHub Actions cache`, + ) + } + + // `uses: actions/cache...` + if (typeof step?.uses === 'string' && CACHE_ACTION.test(step.uses)) { + offenders.push(`${at}: uses \`${step.uses}\` (GitHub Actions cache)`) + } + + // Explicit-disable assertions for the package-manager setup actions. + if (typeof step?.uses === 'string') { + if (PNPM_ACTION_SETUP.test(step.uses)) { + const reason = explicitFalseReason(step, 'cache') + if (reason) offenders.push(`${at}: pnpm/action-setup ${reason}`) + } + if (SETUP_NODE.test(step.uses)) { + const reason = explicitFalseReason(step, 'package-manager-cache') + if (reason) offenders.push(`${at}: actions/setup-node ${reason}`) + } + } + }) + } +} + +if (offenders.length > 0) { + console.error(`Found ${offenders.length} caching issue(s) in workflow(s):\n`) + for (const o of offenders) console.error(` ${o}`) + console.error( + '\nThese workflows must not restore the GitHub Actions cache — it is a\n' + + 'cache-poisoning / supply-chain vector for credential-bearing jobs.\n' + + 'Caching must be disabled explicitly (`cache: false`,\n' + + '`package-manager-cache: false`). See the "CI/CD Supply-Chain\n' + + 'Hardening" section of SECURITY.md.', + ) + process.exit(1) +} + +console.log('OK — GitHub Actions caching is explicitly disabled in:\n') +for (const target of TARGETS) console.log(target)