@ranger1/dx 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -18
- package/bin/dx.js +34 -0
- package/lib/backend-package.js +18 -1
- package/lib/cli/dx-cli.js +18 -1
- package/lib/env-policy.js +135 -0
- package/lib/exec.js +32 -11
- package/lib/validate-env.js +192 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,9 +47,7 @@ dx/
|
|
|
47
47
|
config/
|
|
48
48
|
commands.json
|
|
49
49
|
env-layers.json
|
|
50
|
-
|
|
51
|
-
local-env-allowlist.jsonc
|
|
52
|
-
exempted-keys.jsonc
|
|
50
|
+
env-policy.jsonc
|
|
53
51
|
```
|
|
54
52
|
|
|
55
53
|
可选覆盖:
|
|
@@ -115,25 +113,17 @@ DX_CONFIG_DIR=/path/to/your-repo/dx/config dx status
|
|
|
115
113
|
}
|
|
116
114
|
```
|
|
117
115
|
|
|
118
|
-
### 3) dx/config/
|
|
116
|
+
### 3) dx/config/env-policy.jsonc
|
|
119
117
|
|
|
120
|
-
|
|
118
|
+
统一的 env 策略配置(jsonc),同时覆盖:
|
|
121
119
|
|
|
122
|
-
|
|
120
|
+
- env 文件布局约束(禁用 `.env` / `.env.local`;禁止子目录散落 `.env*`,仅允许少数特例路径)
|
|
121
|
+
- 机密键策略:机密 key 只能在 `.env.<env>.local` 放真实值;对应的 `.env.<env>` 必须存在同名 key 且为占位符 `__SET_IN_env.local__`
|
|
122
|
+
- 必填校验:按环境 + 按 target(端)定义 required keys,执行命令前校验是否缺失/仍为占位符
|
|
123
123
|
|
|
124
|
-
- `
|
|
125
|
-
- `backend`: 当命令配置里 `app` 是 `backend` 时会校验
|
|
126
|
-
- `frontend`: 当命令配置里 `app` 是 `front`/`admin-front` 等前端应用时会校验
|
|
127
|
-
- `development`/`production`/`staging`/`test`/`e2e`: 按当前环境额外补充
|
|
124
|
+
target(端)不写死,由 `env-policy.jsonc.targets` 定义;`commands.json` 里的 `app` 通过 `env-policy.jsonc.appToTarget` 映射到某个 target。
|
|
128
125
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
这是为了防止误提交机密:
|
|
132
|
-
|
|
133
|
-
- `local-env-allowlist.jsonc`:允许出现在 `.env.*.local` 里的键(这些被认为是“机密”)
|
|
134
|
-
- `exempted-keys.jsonc`:豁免键(允许在非 local 文件中出现真实值)
|
|
135
|
-
|
|
136
|
-
非 local 的 `.env.*` 文件里,机密键必须使用占位符:`__SET_IN_env.local__`。
|
|
126
|
+
注:若未提供 `env-policy.jsonc`,dx 会继续使用旧的 `required-env.jsonc` / `local-env-allowlist.jsonc` / `exempted-keys.jsonc` 逻辑(兼容模式)。
|
|
137
127
|
|
|
138
128
|
## 示例工程
|
|
139
129
|
|
package/bin/dx.js
CHANGED
|
@@ -31,6 +31,33 @@ function stripConfigDirArgs(argv) {
|
|
|
31
31
|
return out
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function isVersionInvocation(argv) {
|
|
35
|
+
const raw = Array.isArray(argv) ? argv : []
|
|
36
|
+
const filtered = stripConfigDirArgs(raw)
|
|
37
|
+
|
|
38
|
+
const flags = []
|
|
39
|
+
const positionals = []
|
|
40
|
+
for (const token of filtered) {
|
|
41
|
+
if (token === '--') break
|
|
42
|
+
if (token.startsWith('-')) flags.push(token)
|
|
43
|
+
else positionals.push(token)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Keep current semantics:
|
|
47
|
+
// - -V/--version always prints version and exits.
|
|
48
|
+
// - -v is verbose in commands, but `dx -v` is commonly expected to show version.
|
|
49
|
+
const hasCanonicalVersionFlag = flags.includes('-V') || flags.includes('--version')
|
|
50
|
+
if (hasCanonicalVersionFlag) return true
|
|
51
|
+
|
|
52
|
+
const isBareLowerV = flags.length === 1 && flags[0] === '-v' && positionals.length === 0
|
|
53
|
+
if (isBareLowerV) return true
|
|
54
|
+
|
|
55
|
+
const isVersionCommand = positionals.length === 1 && positionals[0] === 'version'
|
|
56
|
+
if (isVersionCommand) return true
|
|
57
|
+
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
function findProjectRootFrom(startDir) {
|
|
35
62
|
let current = resolve(startDir)
|
|
36
63
|
while (true) {
|
|
@@ -67,6 +94,13 @@ function inferProjectRootFromConfigDir(configDir, startDir) {
|
|
|
67
94
|
|
|
68
95
|
async function main() {
|
|
69
96
|
const rawArgs = process.argv.slice(2)
|
|
97
|
+
|
|
98
|
+
if (isVersionInvocation(rawArgs)) {
|
|
99
|
+
const { getPackageVersion } = await import('../lib/version.js')
|
|
100
|
+
console.log(getPackageVersion())
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
const overrideConfigDir = parseConfigDir(rawArgs)
|
|
71
105
|
const filteredArgs = stripConfigDirArgs(rawArgs)
|
|
72
106
|
|
package/lib/backend-package.js
CHANGED
|
@@ -20,6 +20,11 @@ import { parse as parseYaml } from 'yaml'
|
|
|
20
20
|
import { logger } from './logger.js'
|
|
21
21
|
import { execManager } from './exec.js'
|
|
22
22
|
import { envManager } from './env.js'
|
|
23
|
+
import {
|
|
24
|
+
loadEnvPolicy,
|
|
25
|
+
resolvePolicyTargetId,
|
|
26
|
+
resolveTargetRequiredVars,
|
|
27
|
+
} from './env-policy.js'
|
|
23
28
|
|
|
24
29
|
class BackendPackager {
|
|
25
30
|
constructor(options = {}) {
|
|
@@ -233,7 +238,19 @@ class BackendPackager {
|
|
|
233
238
|
|
|
234
239
|
async prepareEnvSnapshot() {
|
|
235
240
|
logger.info('校验并快照环境变量')
|
|
236
|
-
const
|
|
241
|
+
const policy = loadEnvPolicy(envManager.configDir)
|
|
242
|
+
let requiredVars = []
|
|
243
|
+
if (policy) {
|
|
244
|
+
const targetId = resolvePolicyTargetId(policy, 'backend')
|
|
245
|
+
if (!targetId) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
'env-policy.jsonc 已启用,但缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)',
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
requiredVars = resolveTargetRequiredVars(policy, targetId, this.layerEnv)
|
|
251
|
+
} else {
|
|
252
|
+
requiredVars = envManager.getRequiredEnvVars(this.layerEnv, 'backend')
|
|
253
|
+
}
|
|
237
254
|
const collected = envManager.collectEnvFromLayers('backend', this.layerEnv)
|
|
238
255
|
const effectiveEnv = { ...collected, ...process.env }
|
|
239
256
|
const { valid, missing, placeholders } = envManager.validateRequiredVars(
|
package/lib/cli/dx-cli.js
CHANGED
|
@@ -5,6 +5,11 @@ import { logger } from '../logger.js'
|
|
|
5
5
|
import { envManager } from '../env.js'
|
|
6
6
|
import { execManager } from '../exec.js'
|
|
7
7
|
import { validateEnvironment } from '../validate-env.js'
|
|
8
|
+
import {
|
|
9
|
+
loadEnvPolicy,
|
|
10
|
+
resolvePolicyTargetId,
|
|
11
|
+
resolveTargetRequiredVars,
|
|
12
|
+
} from '../env-policy.js'
|
|
8
13
|
import { FLAG_DEFINITIONS, parseFlags } from './flags.js'
|
|
9
14
|
import { getCleanArgs } from './args.js'
|
|
10
15
|
import { showHelp, showCommandHelp } from './help.js'
|
|
@@ -215,7 +220,19 @@ class DxCli {
|
|
|
215
220
|
// 非 CI 环境下,检查 backend 组的环境变量
|
|
216
221
|
if (process.env.CI !== '1') {
|
|
217
222
|
const effectiveEnv = { ...process.env, ...layeredEnv }
|
|
218
|
-
const
|
|
223
|
+
const policy = loadEnvPolicy(envManager.configDir)
|
|
224
|
+
let requiredVars = []
|
|
225
|
+
if (policy) {
|
|
226
|
+
const targetId = resolvePolicyTargetId(policy, 'backend')
|
|
227
|
+
if (!targetId) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
'env-policy.jsonc 已启用,但缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
|
|
233
|
+
} else {
|
|
234
|
+
requiredVars = envManager.getRequiredEnvVars(environment, 'backend')
|
|
235
|
+
}
|
|
219
236
|
if (requiredVars.length > 0) {
|
|
220
237
|
const { valid, missing, placeholders } = envManager.validateRequiredVars(
|
|
221
238
|
requiredVars,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_POLICY_PATH = 'env-policy.jsonc'
|
|
5
|
+
|
|
6
|
+
let cachedPolicy = null
|
|
7
|
+
let cachedPolicyDir = null
|
|
8
|
+
|
|
9
|
+
function sanitizeJsonc(raw) {
|
|
10
|
+
return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertStringArray(value, fieldPath) {
|
|
14
|
+
if (!Array.isArray(value)) {
|
|
15
|
+
throw new Error(`${fieldPath} 必须是数组`)
|
|
16
|
+
}
|
|
17
|
+
const invalid = value.filter(v => typeof v !== 'string' || v.trim().length === 0)
|
|
18
|
+
if (invalid.length > 0) {
|
|
19
|
+
throw new Error(`${fieldPath} 中存在非法值,请使用非空字符串`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertRecord(value, fieldPath) {
|
|
24
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
25
|
+
throw new Error(`${fieldPath} 必须是对象`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadEnvPolicy(configDir) {
|
|
30
|
+
if (cachedPolicy && cachedPolicyDir === configDir) return cachedPolicy
|
|
31
|
+
|
|
32
|
+
const policyPath = join(configDir, DEFAULT_POLICY_PATH)
|
|
33
|
+
if (!existsSync(policyPath)) {
|
|
34
|
+
cachedPolicy = null
|
|
35
|
+
cachedPolicyDir = configDir
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let parsed
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(policyPath, 'utf8')
|
|
42
|
+
parsed = JSON.parse(sanitizeJsonc(raw) || '{}')
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`无法解析 ${DEFAULT_POLICY_PATH}: ${error.message}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
validateEnvPolicy(parsed)
|
|
48
|
+
cachedPolicy = parsed
|
|
49
|
+
cachedPolicyDir = configDir
|
|
50
|
+
return cachedPolicy
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateEnvPolicy(policy) {
|
|
54
|
+
assertRecord(policy, 'env-policy')
|
|
55
|
+
|
|
56
|
+
if (policy.version !== 1) {
|
|
57
|
+
throw new Error('env-policy.jsonc.version 必须为 1')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
assertStringArray(policy.environments, 'env-policy.jsonc.environments')
|
|
61
|
+
|
|
62
|
+
if (typeof policy.secretPlaceholder !== 'string' || policy.secretPlaceholder.trim().length === 0) {
|
|
63
|
+
throw new Error('env-policy.jsonc.secretPlaceholder 必须为非空字符串')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assertRecord(policy.keys, 'env-policy.jsonc.keys')
|
|
67
|
+
assertStringArray(policy.keys.secret || [], 'env-policy.jsonc.keys.secret')
|
|
68
|
+
assertStringArray(policy.keys.localOnly || [], 'env-policy.jsonc.keys.localOnly')
|
|
69
|
+
assertStringArray(policy.keys.localOverride || [], 'env-policy.jsonc.keys.localOverride')
|
|
70
|
+
|
|
71
|
+
assertRecord(policy.layout, 'env-policy.jsonc.layout')
|
|
72
|
+
assertStringArray(policy.layout.forbidExact || [], 'env-policy.jsonc.layout.forbidExact')
|
|
73
|
+
assertStringArray(policy.layout.allowRoot || [], 'env-policy.jsonc.layout.allowRoot')
|
|
74
|
+
assertStringArray(policy.layout.allowSubdirGlobs || [], 'env-policy.jsonc.layout.allowSubdirGlobs')
|
|
75
|
+
|
|
76
|
+
assertRecord(policy.appToTarget || {}, 'env-policy.jsonc.appToTarget')
|
|
77
|
+
for (const [app, target] of Object.entries(policy.appToTarget || {})) {
|
|
78
|
+
if (typeof app !== 'string' || app.trim().length === 0) {
|
|
79
|
+
throw new Error('env-policy.jsonc.appToTarget 中存在非法 app key')
|
|
80
|
+
}
|
|
81
|
+
if (typeof target !== 'string' || target.trim().length === 0) {
|
|
82
|
+
throw new Error('env-policy.jsonc.appToTarget 中存在非法 target value')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
assertRecord(policy.targets, 'env-policy.jsonc.targets')
|
|
87
|
+
const targetIds = Object.keys(policy.targets)
|
|
88
|
+
if (targetIds.length === 0) {
|
|
89
|
+
throw new Error('env-policy.jsonc.targets 不能为空')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [targetId, target] of Object.entries(policy.targets)) {
|
|
93
|
+
if (typeof targetId !== 'string' || targetId.trim().length === 0) {
|
|
94
|
+
throw new Error('env-policy.jsonc.targets 中存在非法 targetId')
|
|
95
|
+
}
|
|
96
|
+
assertRecord(target, `env-policy.jsonc.targets.${targetId}`)
|
|
97
|
+
assertRecord(target.files, `env-policy.jsonc.targets.${targetId}.files`)
|
|
98
|
+
if (typeof target.files.committed !== 'string' || target.files.committed.trim().length === 0) {
|
|
99
|
+
throw new Error(`env-policy.jsonc.targets.${targetId}.files.committed 必须为非空字符串`)
|
|
100
|
+
}
|
|
101
|
+
if (typeof target.files.local !== 'string' || target.files.local.trim().length === 0) {
|
|
102
|
+
throw new Error(`env-policy.jsonc.targets.${targetId}.files.local 必须为非空字符串`)
|
|
103
|
+
}
|
|
104
|
+
assertRecord(target.required || {}, `env-policy.jsonc.targets.${targetId}.required`)
|
|
105
|
+
|
|
106
|
+
for (const [group, keys] of Object.entries(target.required || {})) {
|
|
107
|
+
assertStringArray(keys, `env-policy.jsonc.targets.${targetId}.required.${group}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Ensure appToTarget does not point to missing targets.
|
|
112
|
+
for (const [app, target] of Object.entries(policy.appToTarget || {})) {
|
|
113
|
+
if (!policy.targets[target]) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`env-policy.jsonc.appToTarget.${app} 指向不存在的 target: ${target}(请在 env-policy.jsonc.targets 中定义)`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resolvePolicyTargetId(policy, app) {
|
|
122
|
+
if (!policy || !app) return null
|
|
123
|
+
const mapped = policy.appToTarget?.[app]
|
|
124
|
+
return mapped || null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function resolveTargetRequiredVars(policy, targetId, environment) {
|
|
128
|
+
if (!policy || !targetId) return []
|
|
129
|
+
const target = policy.targets?.[targetId]
|
|
130
|
+
if (!target) return []
|
|
131
|
+
const required = target.required || {}
|
|
132
|
+
const common = Array.isArray(required._common) ? required._common : []
|
|
133
|
+
const envSpecific = Array.isArray(required[environment]) ? required[environment] : []
|
|
134
|
+
return Array.from(new Set([...common, ...envSpecific]))
|
|
135
|
+
}
|
package/lib/exec.js
CHANGED
|
@@ -3,6 +3,11 @@ import { promisify } from 'node:util'
|
|
|
3
3
|
import { logger } from './logger.js'
|
|
4
4
|
import { envManager } from './env.js'
|
|
5
5
|
import { validateEnvironment } from './validate-env.js'
|
|
6
|
+
import {
|
|
7
|
+
loadEnvPolicy,
|
|
8
|
+
resolvePolicyTargetId,
|
|
9
|
+
resolveTargetRequiredVars,
|
|
10
|
+
} from './env-policy.js'
|
|
6
11
|
import { confirmManager } from './confirm.js'
|
|
7
12
|
|
|
8
13
|
const execPromise = promisify(nodeExec)
|
|
@@ -69,17 +74,33 @@ export class ExecManager {
|
|
|
69
74
|
})
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
const effectiveEnv = { ...process.env, ...layeredEnv }
|
|
78
|
+
const isCI = process.env.CI === '1'
|
|
79
|
+
|
|
80
|
+
const policy = loadEnvPolicy(envManager.configDir)
|
|
81
|
+
let requiredVars = []
|
|
82
|
+
|
|
83
|
+
if (!isCI && policy && app) {
|
|
84
|
+
const targetId = resolvePolicyTargetId(policy, app)
|
|
85
|
+
if (!targetId) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`未找到 app 对应的 target 配置: app=${app}\n请在 dx/config/env-policy.jsonc 中配置 appToTarget.${app}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
|
|
91
|
+
} else if (!policy) {
|
|
92
|
+
// Backward-compatible behavior
|
|
93
|
+
// CI 环境跳过后端环境变量校验(CI 中 build backend 只生成 OpenAPI,不需要数据库连接)
|
|
94
|
+
// 根据 app 参数确定需要检查的环境变量组
|
|
95
|
+
const appType = isCI ? null : (app === 'backend' ? 'backend' : app ? 'frontend' : null)
|
|
96
|
+
requiredVars = envManager.getRequiredEnvVars(environment, appType)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (requiredVars.length > 0) {
|
|
100
|
+
const { valid, missing, placeholders } = envManager.validateRequiredVars(
|
|
101
|
+
requiredVars,
|
|
102
|
+
effectiveEnv,
|
|
103
|
+
)
|
|
83
104
|
if (!valid) {
|
|
84
105
|
const problems = ['环境变量校验未通过']
|
|
85
106
|
if (missing.length > 0) {
|
package/lib/validate-env.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
+
import { loadEnvPolicy } from './env-policy.js'
|
|
3
4
|
|
|
4
5
|
const ROOT_DIR = process.env.DX_PROJECT_ROOT || process.cwd()
|
|
5
6
|
const CONFIG_DIR = process.env.DX_CONFIG_DIR || join(ROOT_DIR, 'dx', 'config')
|
|
@@ -41,22 +42,34 @@ export function validateEnvironment() {
|
|
|
41
42
|
delete process.env.APP_ENV
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const policy = loadEnvPolicy(CONFIG_DIR)
|
|
46
|
+
|
|
47
|
+
enforceRootOnlyEnvFiles(policy)
|
|
46
48
|
enforceGlobalLocalFileProhibited()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
|
|
50
|
+
if (policy) {
|
|
51
|
+
enforceSecretPolicy(policy)
|
|
52
|
+
enforceEnvExamplePolicy(policy)
|
|
53
|
+
} else {
|
|
54
|
+
// Backward-compatible behavior
|
|
55
|
+
enforceLocalSecretWhitelist()
|
|
56
|
+
enforceNonLocalSecretPlaceholders()
|
|
57
|
+
enforceEnvExampleSecrets()
|
|
58
|
+
}
|
|
49
59
|
|
|
50
60
|
return { nodeEnv: process.env.NODE_ENV, appEnv: process.env.APP_ENV }
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
function enforceRootOnlyEnvFiles() {
|
|
63
|
+
function enforceRootOnlyEnvFiles(policy) {
|
|
54
64
|
if (existsSync(ROOT_ENV_FILE)) {
|
|
55
65
|
throw new Error(
|
|
56
66
|
'检测到根目录存在 .env 文件,请迁移到 .env.<env> / .env.<env>.local 并删除 .env',
|
|
57
67
|
)
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
// 保留现有规则:禁止子目录出现任意 .env* 文件(除特例路径)
|
|
71
|
+
// 注:该规则对是否启用 env-policy 都有效。
|
|
72
|
+
|
|
60
73
|
const violations = []
|
|
61
74
|
const queue = ['.']
|
|
62
75
|
|
|
@@ -84,6 +97,11 @@ function enforceRootOnlyEnvFiles() {
|
|
|
84
97
|
|
|
85
98
|
if (EXTRA_ENV_ALLOWED_PATHS.has(relativePath)) continue
|
|
86
99
|
|
|
100
|
+
if (policy) {
|
|
101
|
+
const globs = Array.isArray(policy.layout?.allowSubdirGlobs) ? policy.layout.allowSubdirGlobs : []
|
|
102
|
+
if (isAllowedBySimpleGlob(relativePath, globs)) continue
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
violations.push(relativePath)
|
|
88
106
|
}
|
|
89
107
|
}
|
|
@@ -133,6 +151,175 @@ function enforceGlobalLocalFileProhibited() {
|
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
function enforceSecretPolicy(policy) {
|
|
155
|
+
const placeholder = String(policy.secretPlaceholder || '').trim()
|
|
156
|
+
const secretKeys = new Set(policy.keys?.secret || [])
|
|
157
|
+
const localOnlyKeys = new Set(policy.keys?.localOnly || [])
|
|
158
|
+
const localOverrideKeys = new Set(policy.keys?.localOverride || [])
|
|
159
|
+
|
|
160
|
+
const invalidOverlap = findOverlaps([
|
|
161
|
+
{ name: 'keys.secret', set: secretKeys },
|
|
162
|
+
{ name: 'keys.localOnly', set: localOnlyKeys },
|
|
163
|
+
{ name: 'keys.localOverride', set: localOverrideKeys },
|
|
164
|
+
])
|
|
165
|
+
if (invalidOverlap.length > 0) {
|
|
166
|
+
throw new Error(`env-policy.jsonc keys 分类存在重复: ${invalidOverlap.join(', ')}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const errors = []
|
|
170
|
+
const filePairs = listTargetEnvFilePairs(policy)
|
|
171
|
+
|
|
172
|
+
for (const envName of policy.environments || []) {
|
|
173
|
+
for (const pair of filePairs) {
|
|
174
|
+
const committed = replaceEnvToken(pair.committed, envName)
|
|
175
|
+
const local = replaceEnvToken(pair.local, envName)
|
|
176
|
+
|
|
177
|
+
const committedPath = join(ROOT_DIR, committed)
|
|
178
|
+
const localPath = join(ROOT_DIR, local)
|
|
179
|
+
|
|
180
|
+
const committedExists = existsSync(committedPath)
|
|
181
|
+
const localExists = existsSync(localPath)
|
|
182
|
+
|
|
183
|
+
if (!committedExists && !localExists) continue
|
|
184
|
+
if (!committedExists && localExists) {
|
|
185
|
+
errors.push(`${committed} 缺失(但存在 ${local}),请补充 committed 模板文件`)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const committedEntries = committedExists ? parseEnvFile(committedPath) : new Map()
|
|
190
|
+
const localEntries = localExists ? parseEnvFile(localPath) : new Map()
|
|
191
|
+
|
|
192
|
+
// Committed: secret keys must exist and be placeholder.
|
|
193
|
+
for (const key of secretKeys) {
|
|
194
|
+
if (!committedEntries.has(key)) {
|
|
195
|
+
errors.push(`${committed}: 缺少机密键模板 ${key}`)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const rawValue = String(committedEntries.get(key) ?? '')
|
|
199
|
+
if (rawValue.trim() !== placeholder) {
|
|
200
|
+
errors.push(`${committed}: 机密键 ${key} 必须使用占位符 ${placeholder}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Committed: localOnly must not appear.
|
|
205
|
+
for (const key of localOnlyKeys) {
|
|
206
|
+
if (committedEntries.has(key)) {
|
|
207
|
+
errors.push(`${committed}: localOnly 键 ${key} 不允许出现在非 local 文件中`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (localExists) {
|
|
212
|
+
const allowedInLocal = new Set([...secretKeys, ...localOnlyKeys, ...localOverrideKeys])
|
|
213
|
+
|
|
214
|
+
for (const [key, value] of localEntries.entries()) {
|
|
215
|
+
if (!allowedInLocal.has(key)) {
|
|
216
|
+
errors.push(`${local}: 包含未声明的键 ${key}(请加入 env-policy.jsonc.keys.* 或迁移到 committed 文件)`)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (secretKeys.has(key)) {
|
|
221
|
+
if (String(value ?? '').trim() === placeholder) {
|
|
222
|
+
errors.push(`${local}: 机密键 ${key} 不允许使用占位符,请设置真实值`)
|
|
223
|
+
}
|
|
224
|
+
if (!committedEntries.has(key)) {
|
|
225
|
+
errors.push(`${committed}: 缺少机密键模板 ${key}(因为 ${local} 中存在该键)`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (localOnlyKeys.has(key) && committedEntries.has(key)) {
|
|
230
|
+
errors.push(`${committed}: localOnly 键 ${key} 不允许出现在 committed 文件中(已在 ${local} 中存在)`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (errors.length > 0) {
|
|
238
|
+
throw new Error(`环境变量机密策略校验未通过:\n${errors.join('\n')}`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function enforceEnvExamplePolicy(policy) {
|
|
243
|
+
if (!existsSync(ENV_EXAMPLE_FILE)) return
|
|
244
|
+
|
|
245
|
+
const placeholder = String(policy.secretPlaceholder || '').trim()
|
|
246
|
+
const secretKeys = new Set(policy.keys?.secret || [])
|
|
247
|
+
const localOnlyKeys = new Set(policy.keys?.localOnly || [])
|
|
248
|
+
const entries = parseEnvFile(ENV_EXAMPLE_FILE)
|
|
249
|
+
const errors = []
|
|
250
|
+
|
|
251
|
+
for (const [key, value] of entries.entries()) {
|
|
252
|
+
if (localOnlyKeys.has(key)) {
|
|
253
|
+
errors.push(`.env.example 不允许包含 localOnly 键: ${key}`)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (secretKeys.has(key)) {
|
|
257
|
+
if (String(value ?? '').trim() !== placeholder) {
|
|
258
|
+
errors.push(`.env.example 中机密键 ${key} 必须使用占位符 ${placeholder}`)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (errors.length > 0) {
|
|
264
|
+
throw new Error(errors.join('\n'))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function listTargetEnvFilePairs(policy) {
|
|
269
|
+
const pairs = []
|
|
270
|
+
const targets = policy.targets || {}
|
|
271
|
+
for (const target of Object.values(targets)) {
|
|
272
|
+
const committed = target?.files?.committed
|
|
273
|
+
const local = target?.files?.local
|
|
274
|
+
if (typeof committed !== 'string' || typeof local !== 'string') continue
|
|
275
|
+
pairs.push({ committed, local })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// De-dup
|
|
279
|
+
const seen = new Set()
|
|
280
|
+
return pairs.filter(p => {
|
|
281
|
+
const key = `${p.committed}@@${p.local}`
|
|
282
|
+
if (seen.has(key)) return false
|
|
283
|
+
seen.add(key)
|
|
284
|
+
return true
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function replaceEnvToken(template, envName) {
|
|
289
|
+
return String(template).replace(/\{env\}/g, envName)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isAllowedBySimpleGlob(path, globs) {
|
|
293
|
+
for (const raw of globs) {
|
|
294
|
+
const glob = String(raw)
|
|
295
|
+
if (!glob.includes('*')) {
|
|
296
|
+
if (glob === path) return true
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
// Only support a single trailing '*' for now.
|
|
300
|
+
if (glob.endsWith('*')) {
|
|
301
|
+
const prefix = glob.slice(0, -1)
|
|
302
|
+
if (path.startsWith(prefix)) return true
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function findOverlaps(namedSets) {
|
|
309
|
+
const seen = new Map()
|
|
310
|
+
const overlaps = []
|
|
311
|
+
for (const { name, set } of namedSets) {
|
|
312
|
+
for (const key of set) {
|
|
313
|
+
if (seen.has(key)) {
|
|
314
|
+
overlaps.push(`${key} (${seen.get(key)} & ${name})`)
|
|
315
|
+
} else {
|
|
316
|
+
seen.set(key, name)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return overlaps
|
|
321
|
+
}
|
|
322
|
+
|
|
136
323
|
function enforceNonLocalSecretPlaceholders() {
|
|
137
324
|
const allowlist = loadLocalAllowlist()
|
|
138
325
|
const exemptedKeys = loadExemptedKeys()
|