← back to Secrets Manager
derive-manifests.js
136 lines
#!/usr/bin/env node
// derive-manifests.js — READ-ONLY. Builds a central, values-free per-project
// secret manifest (manifests.json) for the least-privilege scoping (TK-10045).
//
// For each LOCAL destination path in routes.json, greps the owning project dir
// for the secret keys its CODE actually references, so `cli.js regen` can write
// each project a .env = manifest ∩ master instead of the broadcast fan-out that
// put GEORGE_AUTH in 52 processes. Projects with dynamic `process.env[var]`
// access are flagged review_required and NOT narrowed (a grep can't prove which
// key a runtime lookup needs). Writes NO secret values — only key NAMES.
//
// Usage: node derive-manifests.js (writes manifests.json + prints summary)
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const ROOT = path.dirname(__filename);
const HOME = os.homedir();
const ROUTES = JSON.parse(fs.readFileSync(path.join(ROOT, 'routes.json'), 'utf8'));
const MASTER = new Set(
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
.map(l => (l.match(/^([A-Z_][A-Z0-9_]*)=/) || [])[1]).filter(Boolean)
);
const expand = p => p.replace(/^~/, HOME);
// Non-secret operational vars — always allowed, never counted as secret exposure.
const BASE_KEYS = new Set(['PORT','NODE_ENV','BIND','BIND_HOST','HOST','HOSTNAME',
'PUBLIC_BASE_URL','PUBLIC_URL','BASE_URL','HTTPS','LOG_LEVEL','TZ','PM2_HOME','PATH','HOME']);
// ── 1. collect every local destination path → owning project dir ────────────
// key = project dir (for grep); value = the .env path regen will write.
const projects = new Map(); // dir -> { envPath, dir }
function addPath(envPath) {
const p = expand(envPath);
// project dir = the .env's directory (…/foo/.env → …/foo). For a nested
// …/foo/app/.env.local the grep root is that app dir.
const dir = path.dirname(p);
if (!projects.has(dir)) projects.set(dir, { envPath: p, dir });
}
function walkRoutes(obj) {
for (const k of Object.keys(obj)) {
const entry = obj[k];
if (!entry || !entry.destinations) continue;
for (const d of entry.destinations) {
if (d.host) continue; // remote → handled by push-remote, skip local derive
if (d.type === 'project' || d.type === 'env_file') addPath(d.path);
else if (d.type === 'skill') addPath(`~/.claude/skills/${d.name}/.env`);
else if (d.type === 'site_local') for (const dom of (d.domains||[])) addPath(`~/Projects/site-factory/sites/${dom}/app/.env.local`);
// mcp (~/.claude.json) is handled separately — not a project .env
}
}
}
walkRoutes(ROUTES.services || {});
walkRoutes(ROUTES); // legacy top-level routes
// ── 2. derive each project's referenced keys ────────────────────────────────
const STATIC_RE = /process\.env\.[A-Z_][A-Z0-9_]*|process\.env\[['"][A-Z_][A-Z0-9_]*['"]\]/g;
function deriveKeys(dir) {
if (!fs.existsSync(dir)) return { keys: [], dynamic: false, missing: true };
let out = '';
try {
out = execSync(
`grep -rhoE "process\\.env\\.[A-Z_][A-Z0-9_]*|process\\.env\\[['\\"][A-Z_][A-Z0-9_]*['\\"]\\]" ` +
`"${dir}" --include='*.js' --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' ` +
`--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist --exclude-dir=.git --exclude-dir=build 2>/dev/null`,
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
);
} catch { /* grep exit 1 = no matches */ }
const keys = new Set();
let m;
const re = new RegExp(STATIC_RE.source, 'g');
while ((m = re.exec(out)) !== null) {
const k = m[0].replace(/^process\.env\.?/, '').replace(/^\[['"]|['"]\]$/g, '');
if (/^[A-Z_][A-Z0-9_]*$/.test(k)) keys.add(k);
}
// Python + shell consumers are invisible to the JS grep — a Python enrichment
// script reading os.environ['GOOGLE_PLACES_API_KEY'] would otherwise get the key
// stripped on --apply (contrarian Hole 1, reproduced). Union those in. Shell
// $VAR is noisy but harmless: the master-key filter downstream drops non-secrets.
const addFrom = (cmd) => {
try {
for (const line of execSync(cmd, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\n')) {
const k = line.replace(/[^A-Z0-9_]/g, '');
if (/^[A-Z_][A-Z0-9_]*$/.test(k)) keys.add(k);
}
} catch { /* no matches */ }
};
addFrom(`grep -rhoE "os\\.environ(\\.get\\()?\\[?['\\"][A-Z_][A-Z0-9_]*['\\"]|os\\.getenv\\(['\\"][A-Z_][A-Z0-9_]*['\\"]" "${dir}" --include='*.py' --exclude-dir=node_modules --exclude-dir=.git 2>/dev/null | grep -oE "[A-Z_][A-Z0-9_]*['\\"]?$"`);
addFrom(`grep -rhoE "\\\$\\{?[A-Z_][A-Z0-9_]*\\}?" "${dir}" --include='*.sh' --exclude-dir=node_modules --exclude-dir=.git 2>/dev/null | grep -oE "[A-Z_][A-Z0-9_]+"`);
// dynamic access: process.env[ <not a quote> ...] — a runtime-computed key.
// Includes the manual-parse loader loops (process.env[k]=v); conservatively
// flags the whole project review_required so regen won't narrow it.
let dynamic = false;
try {
execSync(`grep -rlE "process\\.env\\[[^'\\"]" "${dir}" --include='*.js' --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.cjs' --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git 2>/dev/null | head -1`,
{ encoding: 'utf8' }).trim() && (dynamic = true);
} catch { /* none */ }
return { keys: [...keys], dynamic, missing: false };
}
// ── 3. build manifests ──────────────────────────────────────────────────────
const manifests = {};
let stat = { total: 0, dynamic: 0, missing: 0, narrowable: 0 };
for (const { envPath, dir } of projects.values()) {
const { keys, dynamic, missing } = deriveKeys(dir);
const derivedSecrets = keys.filter(k => MASTER.has(k)).sort(); // master keys the code uses
const name = dir.replace(HOME, '~');
// Skills' .env is consumed by CLAUDE at invocation time, not by a scannable
// script — grep can't see that usage. So NEVER auto-narrow a skill (it stripped
// real keys from cloudflare-manager/heygen/etc. before this guard). Force review.
const isSkill = dir.includes('/.claude/skills/');
manifests[name] = {
env_path: envPath.replace(HOME, '~'),
derived: derivedSecrets, // master keys referenced in code
other_keys: keys.filter(k => !MASTER.has(k) && !BASE_KEYS.has(k)).sort(), // non-master refs (kept, informational)
dynamic,
review_required: dynamic || missing || isSkill, // don't auto-narrow these
claude_consumed: isSkill,
missing_dir: missing,
};
stat.total++;
if (missing) stat.missing++;
else if (dynamic) stat.dynamic++;
else stat.narrowable++;
}
fs.writeFileSync(path.join(ROOT, 'manifests.json'),
JSON.stringify({ _generated: 'derive-manifests.js', _note: 'values-free per-project secret allowlist; keys only', base_keys: [...BASE_KEYS], projects: manifests }, null, 2));
console.log(`derive-manifests: ${stat.total} projects`);
console.log(` narrowable (clean, static-only): ${stat.narrowable}`);
console.log(` review_required (dynamic env access): ${stat.dynamic}`);
console.log(` missing dir (routed but no code found): ${stat.missing}`);
console.log(` wrote manifests.json (values-free)`);