env-example-secrets.test.js
6.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/**
* 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);
});
});