322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
|
* Morphit smoke — DB password placeholder guardrails.
|
|
*
|
|
* Two layers of defense are checked:
|
|
*
|
|
* 1. SOURCE-TEXT SCAN. None of the well-known placeholder password
|
|
* sentinels may appear anywhere in tracked source / config /
|
|
* operator-doc files except (a) this smoke itself, (b) the
|
|
* `init.sql` provisioning script that REJECTS them, (c) the
|
|
* `.env.example` files that document the sentinel as the
|
|
* required-replace value, (d) the indexer/relay config files
|
|
* that share the reject list, and (e) the historical audit
|
|
* log (`docs/AUDIT-2026-05.md`), which is append-only.
|
|
*
|
|
* 2. ZOD SCHEMA REJECTION. The indexer and relay config schemas
|
|
* refuse to parse a DATABASE_URL whose password component is
|
|
* one of the sentinels. We exercise each sentinel against
|
|
* each schema.
|
|
*
|
|
* Run via the standard smoke runner:
|
|
* bash scripts/run-smokes.sh
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
let scenarios = 0;
|
|
let failures = 0;
|
|
function scenario(name: string, fn: () => void): void {
|
|
scenarios++;
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
} catch (err) {
|
|
failures++;
|
|
console.log(` ✗ ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
console.log('\n── db-password-placeholder smoke ─────────────────────────\n');
|
|
|
|
// ─── Resolve repo root ────────────────────────────────────────────────
|
|
// This script lives at apps/indexer/scripts/<name>.ts, so the repo
|
|
// root is two levels up.
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
|
|
|
// ─── The reject list (must match init.sql + indexer config + relay config)
|
|
const PLACEHOLDERS = [
|
|
'CHANGEME',
|
|
'CHANGE_ME',
|
|
'CHANGE_ME_BEFORE_PRODUCTION',
|
|
'__SET_BEFORE_DEPLOY__'
|
|
] as const;
|
|
|
|
// ─── Files where mentions of the placeholders are EXPECTED ────────────
|
|
// Paths are relative to the repo root. This list is the trust
|
|
// boundary: a placeholder showing up anywhere NOT in this list
|
|
// is a smoke failure.
|
|
const ALLOWED_PATHS = new Set([
|
|
// The init.sql script REJECTS these strings — it has to
|
|
// contain them.
|
|
'ops/postgres/init.sql',
|
|
// Example .env files document the sentinel as a required-
|
|
// replace marker.
|
|
'ops/env/indexer.env.example',
|
|
'ops/env/relay.env.example',
|
|
// Indexer + relay config share the reject list.
|
|
'apps/indexer/src/config/index.ts',
|
|
'apps/relay/src/config/index.ts',
|
|
// This smoke itself contains the strings literally.
|
|
'apps/indexer/scripts/db-password-placeholder-smoke.ts',
|
|
// The relay HMAC-secret smoke (cp252) tests that these sentinels
|
|
// are REJECTED as HMAC secrets, so it contains them literally.
|
|
'apps/relay/scripts/hmac-secret-placeholder-smoke.ts',
|
|
// Historical audit log — append-only, mentions the past
|
|
// presence of the placeholders in fix narratives.
|
|
'docs/AUDIT-2026-05.md',
|
|
// Final report from the May 2026 audit campaign — names the
|
|
// sentinel literally in the "standing pre-launch action items"
|
|
// section so operators know what to rotate.
|
|
'docs/AUDIT-2026-05-FINAL-REPORT.md',
|
|
// cp138 deep-deep audit campaign — plan, findings, outside-
|
|
// scope docs all reference the placeholder sentinel in the
|
|
// context of pre-launch operator-actions. cp138 follows
|
|
// the same naming convention as the May 2026 audit.
|
|
'docs/AUDIT-cp138-PLAN.md',
|
|
'docs/AUDIT-cp138-FINDINGS.md',
|
|
'docs/AUDIT-OUTSIDE-SCOPE.md',
|
|
// Memorized facts in REVISIT-LIST mention placeholders by
|
|
// name in the operator-action checklist.
|
|
'docs/REVISIT-LIST.md',
|
|
// Operator-facing setup doc names the sentinel by spelling
|
|
// in step 7 + step 8 (so the operator recognizes it).
|
|
'docs/RUN-A-MORPHIT-NODE.md',
|
|
// Deep operator runbook §30 documents the reject list.
|
|
'docs/OPERATIONS.md',
|
|
// TARBALL.md is the per-checkpoint snapshot; the
|
|
// audit-trail commentary on the placeholder hardening
|
|
// names every sentinel by spelling.
|
|
'TARBALL.md',
|
|
// Brag list entry 230 (Part 71) names the sentinel
|
|
// in the audit-trail closure narrative explaining
|
|
// that the "rotate CHANGE_ME_BEFORE_PRODUCTION"
|
|
// standing-action item was based on a misreading
|
|
// (the string is in a denylist by design). Removing
|
|
// the literal would weaken the closure provenance.
|
|
'MORPHIT-BRAG-LIST.md',
|
|
// cp131 HIGH-001 — the backup script implements the
|
|
// SAME placeholder-denylist discipline for AGE_RECIPIENT
|
|
// / REMOTE_DESTINATION values that pre-cp131 only the
|
|
// indexer/relay Zod configs enforced. The literal
|
|
// CHANGE_ME / CHANGEME / REPLACE / XXXXX strings have
|
|
// to live in `is_placeholder()` so the script can
|
|
// MATCH them; the example env file names them as the
|
|
// values an operator must replace. Same trust posture
|
|
// as init.sql + the example env files above.
|
|
'ops/backup/morphit-backup.sh',
|
|
'ops/backup/backup.env.example',
|
|
// cp131 HIGH-001 — Ansible template's safe-empty
|
|
// defaults sit alongside operator-facing comments
|
|
// that NAME the placeholder strings so operators
|
|
// know what to type. Same trust posture as
|
|
// indexer.env.example.
|
|
'ops/ansible/group_vars/all.yml',
|
|
// cp131 HIGH-006 — PRE-LAUNCH-CHECKLIST.md was already
|
|
// in the implicit-allowlist set via docs/ blanket
|
|
// inclusion below; this entry just makes the
|
|
// rationale explicit.
|
|
'docs/PRE-LAUNCH-CHECKLIST.md'
|
|
// v1.8.0 housecleaning: the three persona-walkthrough docs
|
|
// (cp137/cp139/cp148) that used to be allowlisted here were
|
|
// deleted as point-in-time records — nothing left to scan.
|
|
]);
|
|
|
|
// ─── Directories to skip while walking ────────────────────────────────
|
|
const SKIP_DIRS = new Set([
|
|
'node_modules',
|
|
'.svelte-kit',
|
|
'build',
|
|
'dist',
|
|
'.vercel',
|
|
'.netlify',
|
|
'.git',
|
|
'.pnpm-store'
|
|
]);
|
|
|
|
// ─── File extensions to scan ──────────────────────────────────────────
|
|
// Configs, scripts, source, and docs. Binary files are skipped.
|
|
const SCAN_EXTENSIONS = new Set([
|
|
'.ts',
|
|
'.tsx',
|
|
'.js',
|
|
'.mjs',
|
|
'.cjs',
|
|
'.svelte',
|
|
'.json',
|
|
'.sql',
|
|
'.md',
|
|
'.sh',
|
|
'.yml',
|
|
'.yaml',
|
|
'.env',
|
|
'.example',
|
|
'.conf',
|
|
'.service',
|
|
'.timer'
|
|
]);
|
|
|
|
function shouldScan(filePath: string): boolean {
|
|
const ext = path.extname(filePath);
|
|
if (SCAN_EXTENSIONS.has(ext)) return true;
|
|
// Files with no extension that are clearly text (e.g. LICENSE,
|
|
// Dockerfile) — none of those are likely to contain DB secrets,
|
|
// so we skip extensionless files for performance.
|
|
return false;
|
|
}
|
|
|
|
function* walkRepo(dir: string): Generator<string> {
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = path.join(dir, entry);
|
|
// Skip dotfiles at the top level except .forgejo (CI configs).
|
|
if (
|
|
entry.startsWith('.') &&
|
|
entry !== '.forgejo' &&
|
|
entry !== '.gitignore' &&
|
|
entry !== '.editorconfig'
|
|
)
|
|
continue;
|
|
if (SKIP_DIRS.has(entry)) continue;
|
|
const st = statSync(full);
|
|
if (st.isDirectory()) {
|
|
yield* walkRepo(full);
|
|
} else if (st.isFile() && shouldScan(full)) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Source-text scan ─────────────────────────────────────────────────
|
|
|
|
scenario('no rogue placeholder strings in tracked source', () => {
|
|
const violations: { file: string; placeholder: string; line: number }[] = [];
|
|
for (const file of walkRepo(REPO_ROOT)) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
if (ALLOWED_PATHS.has(rel)) continue;
|
|
const text = readFileSync(file, 'utf8');
|
|
const lines = text.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
for (const ph of PLACEHOLDERS) {
|
|
if (lines[i].includes(ph)) {
|
|
violations.push({ file: rel, placeholder: ph, line: i + 1 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (violations.length > 0) {
|
|
const detail = violations
|
|
.map((v) => ` ${v.file}:${v.line} contains "${v.placeholder}"`)
|
|
.join('\n');
|
|
throw new Error(
|
|
`\n Found ${violations.length} unexpected placeholder reference(s):\n${detail}\n` +
|
|
` If a new file legitimately needs to mention the sentinel, add it to ALLOWED_PATHS in ` +
|
|
`apps/indexer/scripts/db-password-placeholder-smoke.ts.`
|
|
);
|
|
}
|
|
});
|
|
|
|
// ─── Verify init.sql rejects every placeholder ───────────────────────
|
|
|
|
scenario('init.sql reject list contains every placeholder', () => {
|
|
const initSql = readFileSync(path.join(REPO_ROOT, 'ops/postgres/init.sql'), 'utf8');
|
|
for (const ph of PLACEHOLDERS) {
|
|
if (!initSql.includes(`'${ph}'`)) {
|
|
throw new Error(`init.sql reject list missing '${ph}'`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Verify indexer config reject list matches ───────────────────────
|
|
|
|
scenario('indexer config PLACEHOLDER_DB_PASSWORDS contains every placeholder', () => {
|
|
const cfg = readFileSync(path.join(REPO_ROOT, 'apps/indexer/src/config/index.ts'), 'utf8');
|
|
for (const ph of PLACEHOLDERS) {
|
|
if (!cfg.includes(`'${ph}'`)) {
|
|
throw new Error(`indexer config PLACEHOLDER_DB_PASSWORDS missing '${ph}'`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Verify relay config reject list matches ─────────────────────────
|
|
|
|
scenario('relay config PLACEHOLDER_DB_PASSWORDS contains every placeholder', () => {
|
|
const cfg = readFileSync(path.join(REPO_ROOT, 'apps/relay/src/config/index.ts'), 'utf8');
|
|
for (const ph of PLACEHOLDERS) {
|
|
if (!cfg.includes(`'${ph}'`)) {
|
|
throw new Error(`relay config PLACEHOLDER_DB_PASSWORDS missing '${ph}'`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Verify the .env.example files use the canonical sentinel ──────────
|
|
|
|
scenario('indexer.env.example uses __SET_BEFORE_DEPLOY__ sentinel', () => {
|
|
const ex = readFileSync(path.join(REPO_ROOT, 'ops/env/indexer.env.example'), 'utf8');
|
|
if (!ex.includes('__SET_BEFORE_DEPLOY__')) {
|
|
throw new Error('indexer.env.example no longer contains the canonical sentinel');
|
|
}
|
|
// Negative: the older non-canonical spellings should be GONE
|
|
// from the example file (we keep them rejected by init.sql for
|
|
// belt-and-suspenders, but we don't ship them as the example).
|
|
for (const old of ['CHANGEME', 'CHANGE_ME', 'CHANGE_ME_BEFORE_PRODUCTION']) {
|
|
if (ex.includes(`:${old}@`)) {
|
|
throw new Error(
|
|
`indexer.env.example still ships the old sentinel '${old}' as the example value; ` +
|
|
`canonicalize on '__SET_BEFORE_DEPLOY__'`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
scenario('relay.env.example uses __SET_BEFORE_DEPLOY__ sentinel', () => {
|
|
const ex = readFileSync(path.join(REPO_ROOT, 'ops/env/relay.env.example'), 'utf8');
|
|
if (!ex.includes('__SET_BEFORE_DEPLOY__')) {
|
|
throw new Error('relay.env.example no longer contains the canonical sentinel');
|
|
}
|
|
for (const old of ['CHANGEME', 'CHANGE_ME', 'CHANGE_ME_BEFORE_PRODUCTION']) {
|
|
if (ex.includes(`:${old}@`)) {
|
|
throw new Error(
|
|
`relay.env.example still ships the old sentinel '${old}' as the example value; ` +
|
|
`canonicalize on '__SET_BEFORE_DEPLOY__'`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Lightweight schema check: build a URL containing each placeholder
|
|
// and verify the indexer/relay refinement strings are present ────────
|
|
|
|
scenario('indexer config refinement message references the sentinel', () => {
|
|
const cfg = readFileSync(path.join(REPO_ROOT, 'apps/indexer/src/config/index.ts'), 'utf8');
|
|
if (!/placeholder password sentinel/i.test(cfg)) {
|
|
throw new Error('indexer config missing placeholder-rejection refinement');
|
|
}
|
|
});
|
|
|
|
scenario('relay config refinement message references the sentinel', () => {
|
|
const cfg = readFileSync(path.join(REPO_ROOT, 'apps/relay/src/config/index.ts'), 'utf8');
|
|
if (!/placeholder password sentinel/i.test(cfg)) {
|
|
throw new Error('relay config missing placeholder-rejection refinement');
|
|
}
|
|
});
|
|
|
|
// ─── Final ────────────────────────────────────────────────────────────
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|