/** * 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); }); });