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
36 changes: 36 additions & 0 deletions .github/workflows/env-examples.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions scripts/check-env-examples.mjs
Original file line number Diff line number Diff line change
@@ -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.');
}
78 changes: 78 additions & 0 deletions scripts/check-env-examples.test.mjs
Original file line number Diff line number Diff line change
@@ -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="<YOUR_API_KEY>"',
'SECOND_API_KEY="<YOUR_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/);
});
Loading