← 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)`);