env-example-secrets.test.js 6.42 KB
/**
 * Unit Tests — 防漂移闸:**有非空硬编码兜底的安全类 env,必须写进 .env.example**
 *
 * 挡的是已经发生过的这一条:`server/middleware/jwt-auth.js` 里
 * `process.env.JWT_SECRET || 'lemon-dev-secret-change-in-production'`,
 * 而 `.env.example` 里**没有** JWT_SECRET 这一项 —— 偏偏 `npm run setup` 就是
 * 从 .env.example 生成 .env(见 CLAUDE.md「快速启动」),所以照文档装出来的部署
 * **必然**用那个提交在仓库里的密钥签会话,而且启动时一句告警都没有。
 *
 * 危害不止「密钥弱」:extractUser 拿 decoded.userId 回库重查(伪造 role 这条路是堵住的),
 * 但 user-db 的 id 是自增小整数、不是 uuid,从 1 试起就能命中真实账号 ——
 * 已知密钥 = 任意账号会话伪造,命中 admin 就是完整后台权限。
 *
 * ── 规则 ────────────────────────────────────────────────────────
 * 只管**非空**兜底。空串兜底(`process.env.FEISHU_ENCRYPT_KEY || ''`、
 * `OPEN_API_KEYS || ''`)是「未配置」的正当写法,缺了就 fail closed,不构成风险;
 * 把它们也要求进 example 只会制造噪音,然后被加白名单,然后白名单腐烂。
 * 危险的恰恰是「缺了也能跑,而且跑得像配好了一样」那一类。
 *
 * ponytail: 静态正则扫源码(先剥注释 —— 注释里举例不算代码,这个坑本仓库踩过两次)。
 * 认两种兜底写法:内联字面量,以及同文件内 `const X = '字面量'` 的命名常量
 * (后者是必须的 —— jwt-auth.js 就把兜底提成了常量,只认内联字面量时这条 guard
 *  对它诞生的那个 case 恰好失明)。`??`、跨文件常量、拼接出来的兜底仍查不出来。
 * 名字匹配 SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL —— 宁可范围窄而准,
 * 也不要宽到需要维护例外表。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const SECRETISH = /SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL/;

function stripComments(src) {
  return src
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .split('\n')
    .map((l) => l.replace(/\/\/.*$/, ''))
    .join('\n');
}

function walk(dir, out = []) {
  if (!fs.existsSync(dir)) return out;
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    if (e.name === 'node_modules') continue;
    const p = path.join(dir, e.name);
    if (e.isDirectory()) walk(p, out);
    else if (p.endsWith('.js')) out.push(p);
  }
  return out;
}

/** 生产代码里「process.env.X || '兜底'」的全部命中 */
function fallbacks() {
  const dirs = ['server/services', 'server/src', 'server/routes', 'server/middleware', 'packages'];
  const files = dirs.flatMap((d) => walk(path.join(ROOT, d)));
  for (const f of ['server/config.js', 'server/index.js', 'server/agent-server.js']) {
    files.push(path.join(ROOT, f));
  }

  const hits = [];
  for (const f of files) {
    if (!fs.existsSync(f)) continue;
    const src = stripComments(fs.readFileSync(f, 'utf-8'));

    // 同文件内的 `const X = '字面量'` 绑定:兜底值经常先提成命名常量再用
    // (jwt-auth.js 就是这样 —— 只认内联字面量的话,这条 guard 对它诞生的那个
    //  case 恰好失明,实测被自检当场抓到)
    const consts = new Map();
    for (const c of src.matchAll(/\bconst\s+([A-Za-z0-9_$]+)\s*=\s*(['"`])([^'"`]*)\2\s*;/g)) {
      consts.set(c[1], c[3]);
    }

    // 兜底既可能是字面量,也可能是标识符
    for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)\s*\|\|\s*(?:(['"`])([^'"`]*)\2|([A-Za-z0-9_$]+))/g)) {
      const literal = m[2] !== undefined ? m[3] : undefined;
      const viaConst = m[4] !== undefined && consts.has(m[4]) ? consts.get(m[4]) : undefined;
      const fallback = literal !== undefined ? literal : viaConst;
      // 兜底是个查不出值的表达式(函数调用、另一个 env、数字…)→ 不猜,跳过
      if (fallback === undefined) continue;
      hits.push({ name: m[1], fallback, file: path.relative(ROOT, f) });
    }
  }
  return hits;
}

/** .env.example 里声明过的键(同时认 `X=` 和 `export X=` 两种写法) */
function exampleKeys() {
  const p = path.join(ROOT, '.env.example');
  const src = fs.readFileSync(p, 'utf-8');
  const keys = new Set();
  for (const line of src.split('\n')) {
    const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/);
    if (m) keys.add(m[1]);
  }
  return keys;
}

describe('.env.example 必须覆盖有非空兜底的安全类 env', () => {
  const hits = fallbacks();
  const keys = exampleKeys();

  it('两侧都不是空的(防止 guard 因为路径/正则失效而静默空跑)', () => {
    expect(hits.length).toBeGreaterThan(0);
    expect(keys.size).toBeGreaterThan(3);
    // JWT_SECRET 是本条 guard 的由来,它从任一侧消失都说明扫描口径坏了
    expect(hits.some((h) => h.name === 'JWT_SECRET')).toBe(true);
    expect(keys.has('JWT_SECRET')).toBe(true);
  });

  it('每个「安全类 env + 非空硬编码兜底」都在 .env.example 里有一行', () => {
    const missing = hits
      .filter((h) => SECRETISH.test(h.name) && h.fallback !== '')
      .filter((h) => !keys.has(h.name))
      .map((h) => `${h.name}(${h.file},兜底非空)未出现在 .env.example`);

    expect(missing).toEqual([]);
  });

  it('.env.example 的 PORT 与 config.js 默认值、vite 代理目标三者一致', () => {
    // 这三处对不上时,npm run setup 装出来的环境前端每个 API 调用都 502,
    // 而且症状(502)离原因(example 里端口写错)很远。实际发生过:example 写 3000。
    const example = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf-8');
    const cfg = fs.readFileSync(path.join(ROOT, 'server/config.js'), 'utf-8');
    const vite = fs.readFileSync(path.join(ROOT, 'web/vite.config.js'), 'utf-8');

    const examplePort = example.match(/^\s*(?:export\s+)?PORT\s*=\s*(\d+)/m)?.[1];
    const cfgPort = cfg.match(/process\.env\.PORT\s*\|\|\s*(\d+)/)?.[1];
    const vitePort = vite.match(/target:\s*['"]https?:\/\/localhost:(\d+)/)?.[1];

    expect(examplePort).toBeTruthy();
    expect(cfgPort).toBeTruthy();
    expect(vitePort).toBeTruthy();
    expect(examplePort).toBe(cfgPort);
    expect(examplePort).toBe(vitePort);
  });
});