diff --git a/.github/workflows/env-examples.yml b/.github/workflows/env-examples.yml new file mode 100644 index 00000000..16d63a74 --- /dev/null +++ b/.github/workflows/env-examples.yml @@ -0,0 +1,36 @@ +name: Environment template check + +on: + push: + paths: + - '.env.example' + - 'apps/web/.env.example' + - 'scripts/check-env-examples.mjs' + - 'scripts/check-env-examples.test.mjs' + - 'package.json' + - '.github/workflows/env-examples.yml' + pull_request: + paths: + - '.env.example' + - 'apps/web/.env.example' + - 'scripts/check-env-examples.mjs' + - 'scripts/check-env-examples.test.mjs' + - 'package.json' + - '.github/workflows/env-examples.yml' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Test environment template validation + run: node --test scripts/check-env-examples.test.mjs + - name: Validate both environment templates + run: node scripts/check-env-examples.mjs diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index e8a118b5..ac941379 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -3,6 +3,11 @@ Sub Rosa **does not require a committed `.env` file**. Secrets stay out of git; you inject them where each layer runs. +CI runs `node scripts/check-env-examples.mjs` on the root and web `.env.example` +files. It rejects duplicate active names within either file and populated +secret-shaped values; keep credential fields empty or use clear placeholders. +Run `node --test scripts/check-env-examples.test.mjs` for the focused tests. + ## Three layers | Layer | Needs env? | When vars are read | diff --git a/package.json b/package.json index 8620f7d9..095a0a29 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ } }, "scripts": { + "env:check": "node scripts/check-env-examples.mjs", + "env:test": "node --test scripts/check-env-examples.test.mjs", "logging:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-logging.test.mjs", "logging:check": "node scripts/check-logging.mjs", "contract:build": "stellar contract build", diff --git a/scripts/check-env-examples.mjs b/scripts/check-env-examples.mjs new file mode 100644 index 00000000..e227bc98 --- /dev/null +++ b/scripts/check-env-examples.mjs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const TEMPLATES = [ + ['.env.example', new URL('../.env.example', import.meta.url)], + ['apps/web/.env.example', new URL('../apps/web/.env.example', import.meta.url)], +]; + +// Public IDs, URLs and network passphrases are valid template values. Credential +// fields must stay empty or contain an unmistakable placeholder. +const SECRET_KEY = /(?:^|_)(?:SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_TOKEN|AUTH_TOKEN|REFRESH_TOKEN|CREDENTIAL|MNEMONIC|SEED)(?:_|$)/i; +const PLACEHOLDER = /^(?:<[^<>]+>|\$\{[A-Z][A-Z0-9_]*\}|(?:YOUR|EXAMPLE|PLACEHOLDER)[_-][A-Z0-9_-]+|REPLACE_ME|CHANGE_ME|CHANGEME|\.\.\.|…|S…|G…)$/i; +const SECRET_VALUE = /(?:^|[^A-Za-z0-9_])(?:S[A-Z2-7]{55}|AKIA[A-Z0-9]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{12,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)(?:$|[^A-Za-z0-9_])/; + +export function checkTemplate(source, file) { + const seen = new Map(); + const errors = []; + + for (const [index, line] of source.split(/\r?\n/).entries()) { + // Never source or expand template text. Commented examples are documentation. + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/); + if (!match) continue; + + const [, key, rawValue] = match; + const lineNumber = index + 1; + if (seen.has(key)) { + errors.push(`${file}:${lineNumber}: duplicate ${key} (first at line ${seen.get(key)})`); + } else { + seen.set(key, lineNumber); + } + + let value = rawValue.trim(); + const quoted = value.match(/^(['"])(.*)\1(?:\s+#.*)?$/); + if (quoted) { + value = quoted[2]; + } else { + value = value.replace(/(?:^|\s+)#.*$/, '').trim(); + } + + if (value && !PLACEHOLDER.test(value) && (SECRET_KEY.test(key) || SECRET_VALUE.test(value))) { + errors.push(`${file}:${lineNumber}: unsafe secret-shaped value for ${key}`); + } + } + + return errors; +} + +export function checkTemplates(templates = TEMPLATES) { + const errors = []; + for (const [file, url] of templates) { + try { + errors.push(...checkTemplate(readFileSync(url, 'utf8'), file)); + } catch { + errors.push(`${file}: unable to read required environment template`); + } + } + return errors; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const errors = checkTemplates(); + for (const error of errors) console.error(error); + if (errors.length) process.exitCode = 1; + else console.log('Environment templates are valid.'); +} diff --git a/scripts/check-env-examples.test.mjs b/scripts/check-env-examples.test.mjs new file mode 100644 index 00000000..dc9d379e --- /dev/null +++ b/scripts/check-env-examples.test.mjs @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { checkTemplate, checkTemplates } from './check-env-examples.mjs'; + +test('ignores comments and blank lines, and permits public values and placeholders', () => { + const source = [ + '# API_KEY=commented', + '', + 'PUBLIC_ID=C123', + 'RPC_URL=https://example.com', + 'NETWORK_PASSPHRASE="Test SDF Network ; September 2015"', + 'API_KEY=""', + 'SECOND_API_KEY="" # set at runtime', + 'KEEPER_SECRET= # supplied at runtime', + 'OTHER_SECRET=${OTHER_SECRET}', + 'export PASSWORD=CHANGE_ME', + ].join('\n'); + assert.deepEqual(checkTemplate(source, 'test.env'), []); +}); + +test('reports duplicate active names with both line numbers, including quoted assignments', () => { + const errors = checkTemplate('# A=old\nA="one"\n\nexport A=two\n', 'test.env'); + assert.deepEqual(errors, ['test.env:4: duplicate A (first at line 2)']); +}); + +test('rejects populated credential fields without revealing their values', () => { + const value = 'sensitive-material-123'; + const errors = checkTemplate(`STELLAR_SECRET_KEY='${value}'\n`, 'test.env'); + assert.deepEqual(errors, ['test.env:1: unsafe secret-shaped value for STELLAR_SECRET_KEY']); + assert.doesNotMatch(errors.join(' '), /sensitive-material-123/); +}); + +test('rejects recognizable secret values even under an innocuous key', () => { + const samples = [ + `MISC=S${'A'.repeat(55)}`, + `MISC=AKIA${'A'.repeat(16)}`, + `MISC=ghp_${'a'.repeat(24)}`, + `MISC=sk_live_${'a'.repeat(24)}`, + `MISC=sk-${'a'.repeat(24)}`, + 'MISC="-----BEGIN PRIVATE KEY-----"', + ]; + for (const sample of samples) { + assert.match(checkTemplate(sample, 'test.env')[0], /unsafe secret-shaped value for MISC/); + } +}); + +test('checks each required template independently and fails when one is missing', () => { + const dir = mkdtempSync(join(tmpdir(), 'env-example-check-')); + try { + const root = join(dir, 'root.env'); + const web = join(dir, 'web.env'); + writeFileSync(root, 'SHARED=public\nSHARED=again\n'); + writeFileSync(web, 'SHARED=public\nAPI_KEY=unsafe-value\n'); + assert.deepEqual(checkTemplates([['root', root], ['web', web]]), [ + 'root:2: duplicate SHARED (first at line 1)', + 'web:2: unsafe secret-shaped value for API_KEY', + ]); + assert.deepEqual(checkTemplates([['missing', join(dir, 'missing.env')]]), [ + 'missing: unable to read required environment template', + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('repository templates pass the command line check', () => { + const result = spawnSync(process.execPath, [fileURLToPath(new URL('./check-env-examples.mjs', import.meta.url))], { + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Environment templates are valid/); +});