@ultime/codecraft 1.3.0 → 1.5.0
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/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +51 -81
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/adapters/README.md +3 -4
- package/codecraft.config.example.json +5 -1
- package/docs/00-constitution.md +2 -2
- package/docs/02-ai-contract.md +12 -1
- package/docs/04-gates.md +21 -6
- package/docs/07-ai-prompts.md +9 -8
- package/docs/08-audit-contract.md +141 -0
- package/evals/cases.json +110 -0
- package/evals/fixtures/a11-marker/packages/core/dist/tokens.css +4 -0
- package/evals/fixtures/a11-marker/packages/core/package.json +1 -0
- package/evals/fixtures/a11-marker/packages/core/src/index.ts +1 -0
- package/evals/fixtures/a17-boundaries/codecraft.config.json +5 -0
- package/evals/fixtures/a17-boundaries/src/pages/home.ts +1 -0
- package/evals/fixtures/a17-default/src/pages/home.ts +1 -0
- package/evals/fixtures/a17-generated-data/src/icons/icons.ts +2 -0
- package/evals/fixtures/a17-generated-data/src/pages/home.ts +3 -0
- package/evals/fixtures/a17-generated-header/src/pages/home.ts +5 -0
- package/evals/fixtures/a17-generated-header/src/types/project.ts +4 -0
- package/evals/fixtures/a17-local/src/pages/home.ts +4 -0
- package/evals/fixtures/a17-local/src/types/local.ts +3 -0
- package/evals/fixtures/a17-local/src/utils/format.ts +3 -0
- package/evals/fixtures/a2-declared/packages/core/package.json +6 -0
- package/evals/fixtures/a2-declared/packages/core/src/index.ts +3 -0
- package/evals/fixtures/a2-declared/packages/kit/package.json +1 -0
- package/evals/fixtures/a2-workspace/packages/core/package.json +1 -0
- package/evals/fixtures/a2-workspace/packages/core/src/index.ts +3 -0
- package/evals/fixtures/a2-workspace/packages/kit/package.json +1 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/README.md +5 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/package.json +1 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/src/index.ts +1 -0
- package/evals/fixtures/a21-placeholders/.env +2 -0
- package/evals/fixtures/a21-placeholders/server/application.yml +9 -0
- package/evals/fixtures/a21-placeholders/src/pages/home.ts +1 -0
- package/evals/fixtures/a21-plaintext/application.yml +5 -0
- package/evals/fixtures/a21-plaintext/src/main/java/Ali.java +3 -0
- package/evals/fixtures/a21-plaintext/src/pages/home.ts +1 -0
- package/evals/fixtures/a6-clean/src/pages/handled.ts +8 -0
- package/evals/fixtures/a6-empty-catch/src/pages/empty.ts +3 -0
- package/evals/fixtures/ignore-active/codecraft.config.json +5 -0
- package/evals/fixtures/ignore-active/src/pages/home.ts +1 -0
- package/evals/fixtures/ignore-expired/codecraft.config.json +5 -0
- package/evals/fixtures/ignore-expired/src/pages/home.ts +1 -0
- package/evals/fixtures/library-a11/dist/tokens.css +3 -0
- package/evals/fixtures/library-a11/package.json +4 -0
- package/evals/fixtures/library-a11/src/index.ts +1 -0
- package/evals/fixtures/zero-files/package.json +4 -0
- package/evals/golden-tasks.md +9 -5
- package/evals/run.mjs +75 -0
- package/package.json +6 -4
- package/prompts/output.md +15 -4
- package/scenarios/audit-existing.md +15 -8
- package/src/check.mjs +290 -40
- package/src/cli.mjs +159 -143
- package/src/gates.mjs +28 -1
- package/src/plan.mjs +2 -0
- package/adapters/claude/SKILL.md +0 -42
- package/adapters/trae/SKILL.md +0 -42
- package/src/install.mjs +0 -172
package/src/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { matchPath } from './ignore.mjs'
|
|
3
4
|
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
'node_modules',
|
|
@@ -16,6 +17,10 @@ const SKIP_DIRS = new Set([
|
|
|
16
17
|
'.output',
|
|
17
18
|
'.turbo',
|
|
18
19
|
'.cache',
|
|
20
|
+
'.agents',
|
|
21
|
+
'.trae',
|
|
22
|
+
'.claude',
|
|
23
|
+
'.cursor',
|
|
19
24
|
])
|
|
20
25
|
|
|
21
26
|
const GENERATED_SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '.turbo', '.cache'])
|
|
@@ -23,6 +28,9 @@ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue'
|
|
|
23
28
|
const GENERATED_EXTENSIONS = new Set(['.ts', '.js', '.css', '.json', '.md'])
|
|
24
29
|
const ALLOWED_SEGMENTS = new Set(['external', 'generated', 'api', 'adapters'])
|
|
25
30
|
const GENERATED_MARKER = /生成物|generated|do not edit|勿手改|auto-generated|派生/i
|
|
31
|
+
/** 只认文件头里的只读声明,避免把正文里的「派生」当成生成物。 */
|
|
32
|
+
const GENERATED_HEADER =
|
|
33
|
+
/@generated|auto-generated|generated by|do not edit|本文件由.{0,60}生成|此文件由.{0,60}生成|本文件[^\n]{0,20}勿手改|此文件[^\n]{0,20}勿手改|由.{0,60}生成[^\n]{0,10}勿手改/i
|
|
26
34
|
const SECRET_EXTENSIONS = new Set([
|
|
27
35
|
'.ts',
|
|
28
36
|
'.tsx',
|
|
@@ -37,6 +45,11 @@ const SECRET_EXTENSIONS = new Set([
|
|
|
37
45
|
'.sh',
|
|
38
46
|
'.toml',
|
|
39
47
|
'.ini',
|
|
48
|
+
'.properties',
|
|
49
|
+
'.xml',
|
|
50
|
+
'.java',
|
|
51
|
+
'.kt',
|
|
52
|
+
'.gradle',
|
|
40
53
|
])
|
|
41
54
|
const SECRET_SKIP_FILES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
|
|
42
55
|
/** 文档里公开的示例值,不当作真实凭据。 */
|
|
@@ -44,10 +57,33 @@ const SECRET_ALLOW = new Set(['AKIAIOSFODNN7EXAMPLE'])
|
|
|
44
57
|
const SECRET_PATTERNS = [
|
|
45
58
|
{ id: 'private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
|
|
46
59
|
{ id: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
60
|
+
{ id: 'Aliyun access key', re: /\bLTAI[0-9A-Za-z]{12,}\b/ },
|
|
47
61
|
{ id: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
48
62
|
{ id: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
49
63
|
{ id: 'API secret key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
|
|
50
64
|
]
|
|
65
|
+
/** 只在配置 / 后端源码里找明文赋值,避免把业务代码里的普通字符串当密钥。 */
|
|
66
|
+
const ASSIGNMENT_EXTENSIONS = new Set([
|
|
67
|
+
'.yml',
|
|
68
|
+
'.yaml',
|
|
69
|
+
'.properties',
|
|
70
|
+
'.ini',
|
|
71
|
+
'.toml',
|
|
72
|
+
'.env',
|
|
73
|
+
'.conf',
|
|
74
|
+
'.config',
|
|
75
|
+
'.java',
|
|
76
|
+
'.kt',
|
|
77
|
+
'.gradle',
|
|
78
|
+
'.sh',
|
|
79
|
+
])
|
|
80
|
+
const SECRET_ASSIGN = /(?:^|[\s(])([A-Za-z0-9_.-]+)\s*(:|=)(?!=)\s*(.+?)\s*$/
|
|
81
|
+
const SECRET_KEY = /(?:^|[._-])(password|passwd|pwd|secret|appsecret|api[._-]?key|access[._-]?key|token)(?:$|[._-])/i
|
|
82
|
+
/** 代码里的 token 多数是运行时变量;后端源码只认强口令 / 密钥类字段。 */
|
|
83
|
+
const SECRET_KEY_CODE = /(?:^|[._-])(password|passwd|pwd|secret|appsecret|api[._-]?key|access[._-]?key)(?:$|[._-])/i
|
|
84
|
+
const SECRET_KEY_EXCLUDE = /(url|uri|type|name|expires?|expiry|ttl|header|prefix|suffix|length|count|timeout)$/i
|
|
85
|
+
const SECRET_PLACEHOLDER =
|
|
86
|
+
/^(?:\$\{.*\}|\$[A-Za-z_][A-Za-z0-9_]*|#\{.*\}|%.*%|<.*>|\*+|-+|null|true|false|none|undefined|todo.*|xxx+|[a-z]*example[a-z]*|placeholder.*|your[-_].*|change[-_]?me|process\.env\..*|os\.environ.*|System\..*|getenv.*)$/i
|
|
51
87
|
/** 只认内部文档特征:向上一级找根 docs,或 Ultime 的内部目录名;包自带的 docs/ 不算。 */
|
|
52
88
|
const DOC_PATH = /(?:^|[\s(`'"])(?:(?:\.\.\/)+docs\/|(?:docs\/)?(?:开发计划|设计系统|工程机制)\/)/
|
|
53
89
|
|
|
@@ -57,48 +93,84 @@ const IMPORT_PATTERNS = [
|
|
|
57
93
|
/^\s*import\s+['"]([^'"]+)['"]/gm,
|
|
58
94
|
]
|
|
59
95
|
|
|
96
|
+
const EMPTY_CATCH = /\bcatch\s*(?:\([^)]*\))?\s*\{([\s\S]*?)\}/g
|
|
97
|
+
|
|
60
98
|
export function check(root, options = {}) {
|
|
61
99
|
const absRoot = path.resolve(root)
|
|
62
100
|
const pack = options.pack ?? 'app'
|
|
63
|
-
if (pack === 'library') return checkLibrary(absRoot)
|
|
64
|
-
if (pack === 'design-system') return checkDesignSystem(absRoot)
|
|
65
|
-
return checkApp(absRoot)
|
|
101
|
+
if (pack === 'library') return checkLibrary(absRoot, options)
|
|
102
|
+
if (pack === 'design-system') return checkDesignSystem(absRoot, options)
|
|
103
|
+
return checkApp(absRoot, options)
|
|
66
104
|
}
|
|
67
105
|
|
|
68
|
-
function checkApp(root) {
|
|
69
|
-
const
|
|
106
|
+
function checkApp(root, options = {}) {
|
|
107
|
+
const isExcluded = createExcluder(root, options.exclude)
|
|
108
|
+
const files = collectAppFiles(root).filter((file) => !isExcluded(file) && !isMinified(file))
|
|
109
|
+
const generated = collectGeneratedFiles(files)
|
|
70
110
|
const findings = []
|
|
71
|
-
for (const file of files) findings.push(...scanAppFile(file, root))
|
|
72
|
-
findings.push(...scanSecrets(root))
|
|
111
|
+
for (const file of files) findings.push(...scanAppFile(file, root, options.boundaries, generated))
|
|
112
|
+
findings.push(...scanSecrets(root, isExcluded))
|
|
73
113
|
return { root, files: files.length, findings }
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
function checkLibrary(root) {
|
|
77
|
-
return checkPackages(root, {
|
|
116
|
+
function checkLibrary(root, options = {}) {
|
|
117
|
+
return checkPackages(root, {
|
|
118
|
+
docPaths: false,
|
|
119
|
+
boundaries: options.boundaries,
|
|
120
|
+
exclude: options.exclude,
|
|
121
|
+
})
|
|
78
122
|
}
|
|
79
123
|
|
|
80
|
-
function checkDesignSystem(root) {
|
|
81
|
-
return checkPackages(root, {
|
|
124
|
+
function checkDesignSystem(root, options = {}) {
|
|
125
|
+
return checkPackages(root, {
|
|
126
|
+
docPaths: true,
|
|
127
|
+
boundaries: options.boundaries,
|
|
128
|
+
exclude: options.exclude,
|
|
129
|
+
})
|
|
82
130
|
}
|
|
83
131
|
|
|
84
132
|
function checkPackages(root, options) {
|
|
85
|
-
const
|
|
133
|
+
const isExcluded = createExcluder(root, options.exclude)
|
|
134
|
+
const packages = findPackages(root).filter((pkg) => !isExcluded(pkg))
|
|
86
135
|
const manifests = packages.map((pkg) => readJson(path.join(pkg, 'package.json')))
|
|
87
136
|
const workspaceNames = new Set(manifests.map((manifest) => manifest?.name).filter(Boolean))
|
|
88
137
|
const findings = []
|
|
89
138
|
let files = 0
|
|
90
139
|
|
|
91
140
|
for (let i = 0; i < packages.length; i += 1) {
|
|
92
|
-
const result = checkPackage(packages[i], manifests[i], workspaceNames)
|
|
141
|
+
const result = checkPackage(packages[i], manifests[i], workspaceNames, isExcluded)
|
|
93
142
|
files += result.files
|
|
94
143
|
findings.push(...result.findings)
|
|
95
|
-
if (options.docPaths) findings.push(...scanPackageDocs(packages[i]))
|
|
144
|
+
if (options.docPaths) findings.push(...scanPackageDocs(packages[i], isExcluded))
|
|
96
145
|
}
|
|
97
146
|
|
|
98
|
-
findings.push(...scanSecrets(root))
|
|
147
|
+
findings.push(...scanSecrets(root, isExcluded))
|
|
99
148
|
return { root, files, findings }
|
|
100
149
|
}
|
|
101
150
|
|
|
151
|
+
/**
|
|
152
|
+
* 扫描级排除:在收集文件时就跳过,不产生 findings。
|
|
153
|
+
* 与 ignore 不同,它不能豁免已发现的问题,只用于不扫描(例如测试 fixture、第三方目录)。
|
|
154
|
+
*/
|
|
155
|
+
function createExcluder(root, patterns = []) {
|
|
156
|
+
const list = (patterns ?? [])
|
|
157
|
+
.map((pattern) => String(pattern).replace(/\\/g, '/').replace(/^\.\//, ''))
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
if (!list.length) return () => false
|
|
160
|
+
|
|
161
|
+
return (file) => {
|
|
162
|
+
const relative = path.relative(root, file).split(path.sep).join('/')
|
|
163
|
+
return list.some((pattern) => matchesExclude(pattern, relative))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function matchesExclude(pattern, relative) {
|
|
168
|
+
if (matchPath(pattern, relative)) return true
|
|
169
|
+
if (/[*?]/.test(pattern)) return false
|
|
170
|
+
const base = pattern.replace(/\/$/, '')
|
|
171
|
+
return relative === base || relative.startsWith(`${base}/`)
|
|
172
|
+
}
|
|
173
|
+
|
|
102
174
|
function findPackages(root) {
|
|
103
175
|
const packagesDir = path.join(root, 'packages')
|
|
104
176
|
if (isDir(packagesDir)) {
|
|
@@ -112,7 +184,7 @@ function findPackages(root) {
|
|
|
112
184
|
return []
|
|
113
185
|
}
|
|
114
186
|
|
|
115
|
-
function checkPackage(pkg, manifest, workspaceNames) {
|
|
187
|
+
function checkPackage(pkg, manifest, workspaceNames, isExcluded = () => false) {
|
|
116
188
|
const name = manifest?.name ?? ''
|
|
117
189
|
const deps = new Set([
|
|
118
190
|
...Object.keys(manifest?.dependencies ?? {}),
|
|
@@ -120,7 +192,9 @@ function checkPackage(pkg, manifest, workspaceNames) {
|
|
|
120
192
|
...Object.keys(manifest?.devDependencies ?? {}),
|
|
121
193
|
])
|
|
122
194
|
const src = path.join(pkg, 'src')
|
|
123
|
-
const sourceFiles = isDir(src) ? walkFiles(src) : []
|
|
195
|
+
const sourceFiles = (isDir(src) ? walkFiles(src) : []).filter(
|
|
196
|
+
(file) => !isExcluded(file) && !isMinified(file),
|
|
197
|
+
)
|
|
124
198
|
const findings = []
|
|
125
199
|
|
|
126
200
|
for (const file of sourceFiles) {
|
|
@@ -137,9 +211,10 @@ function checkPackage(pkg, manifest, workspaceNames) {
|
|
|
137
211
|
message,
|
|
138
212
|
})
|
|
139
213
|
}
|
|
214
|
+
findings.push(...scanEmptyCatch(text, file, pkg))
|
|
140
215
|
}
|
|
141
216
|
|
|
142
|
-
for (const file of collectGeneratedCandidates(pkg)) {
|
|
217
|
+
for (const file of collectGeneratedCandidates(pkg).filter((file) => !isExcluded(file))) {
|
|
143
218
|
const text = read(file)
|
|
144
219
|
if (text == null || hasGeneratedMarker(text)) continue
|
|
145
220
|
findings.push({
|
|
@@ -180,6 +255,21 @@ function collectAppFiles(root) {
|
|
|
180
255
|
return files
|
|
181
256
|
}
|
|
182
257
|
|
|
258
|
+
function collectGeneratedFiles(files) {
|
|
259
|
+
const generated = new Map()
|
|
260
|
+
for (const file of files) {
|
|
261
|
+
const text = read(file)
|
|
262
|
+
if (text == null || !hasGeneratedHeader(text)) continue
|
|
263
|
+
generated.set(path.resolve(file), { types: exportsTypes(text) })
|
|
264
|
+
}
|
|
265
|
+
return generated
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A17 只关心外部类型契约;生成的数据 / 资源模块不算。 */
|
|
269
|
+
function exportsTypes(text) {
|
|
270
|
+
return /\bexport\s+(?:declare\s+)?(?:interface|type|enum)\b/.test(text)
|
|
271
|
+
}
|
|
272
|
+
|
|
183
273
|
function collectAppRoots(root) {
|
|
184
274
|
const roots = []
|
|
185
275
|
const apps = path.join(root, 'apps')
|
|
@@ -264,21 +354,42 @@ function walkGenerated(dir, files, seen) {
|
|
|
264
354
|
}
|
|
265
355
|
}
|
|
266
356
|
|
|
267
|
-
function scanAppFile(file, root) {
|
|
268
|
-
if (isAllowedImporter(file, root)) return []
|
|
269
|
-
|
|
357
|
+
function scanAppFile(file, root, boundaries, generated) {
|
|
270
358
|
const text = read(file)
|
|
271
359
|
if (text == null) return []
|
|
360
|
+
if (generated?.has(path.resolve(file))) return []
|
|
272
361
|
|
|
273
362
|
const findings = []
|
|
274
|
-
|
|
275
|
-
|
|
363
|
+
if (!isAllowedImporter(file, root)) {
|
|
364
|
+
for (const { spec, line } of collectImports(stripComments(text))) {
|
|
365
|
+
const generatedImport = generated?.size ? resolveGeneratedImport(spec, file, root, generated) : false
|
|
366
|
+
if (!generatedImport && !isExternalSpec(spec, boundaries)) continue
|
|
367
|
+
findings.push({
|
|
368
|
+
rule: 'A17',
|
|
369
|
+
severity: 'warn',
|
|
370
|
+
file: path.relative(root, file),
|
|
371
|
+
line,
|
|
372
|
+
message: generatedImport
|
|
373
|
+
? `imports generated type "${spec}"; access it through Wrapper/API (A17)`
|
|
374
|
+
: `imports external type "${spec}"; only Wrapper/API may import external/generated (A17)`,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
findings.push(...scanEmptyCatch(text, file, root))
|
|
379
|
+
return findings
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function scanEmptyCatch(text, file, root) {
|
|
383
|
+
const findings = []
|
|
384
|
+
const source = stripComments(text)
|
|
385
|
+
for (const match of source.matchAll(EMPTY_CATCH)) {
|
|
386
|
+
if (match[1].trim()) continue
|
|
276
387
|
findings.push({
|
|
277
|
-
rule: '
|
|
388
|
+
rule: 'A6',
|
|
278
389
|
severity: 'warn',
|
|
279
390
|
file: path.relative(root, file),
|
|
280
|
-
line,
|
|
281
|
-
message:
|
|
391
|
+
line: lineAt(source, match.index ?? 0),
|
|
392
|
+
message: 'empty catch swallows failures; return a Result or handle the error (A6)',
|
|
282
393
|
})
|
|
283
394
|
}
|
|
284
395
|
return findings
|
|
@@ -315,41 +426,174 @@ function hasGeneratedMarker(text) {
|
|
|
315
426
|
return GENERATED_MARKER.test(text.split('\n').slice(0, 20).join('\n'))
|
|
316
427
|
}
|
|
317
428
|
|
|
429
|
+
function hasGeneratedHeader(text) {
|
|
430
|
+
return GENERATED_HEADER.test(text.split('\n').slice(0, 3).join('\n'))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function resolveGeneratedImport(spec, importer, root, generated) {
|
|
434
|
+
const target = resolveImportFile(spec, importer, root)
|
|
435
|
+
if (!target) return false
|
|
436
|
+
return generated.get(path.resolve(target))?.types === true
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function resolveImportFile(spec, importer, root) {
|
|
440
|
+
const bases = []
|
|
441
|
+
if (spec.startsWith('.')) {
|
|
442
|
+
bases.push(path.resolve(path.dirname(importer), spec))
|
|
443
|
+
} else if (/^[@~#]{1,2}\//.test(spec)) {
|
|
444
|
+
const rest = spec.replace(/^[@~#]{1,2}\//, '')
|
|
445
|
+
const srcRoot = findSrcRoot(importer)
|
|
446
|
+
if (srcRoot) bases.push(path.resolve(srcRoot, rest))
|
|
447
|
+
bases.push(path.resolve(root, rest), path.resolve(root, 'src', rest))
|
|
448
|
+
} else {
|
|
449
|
+
return null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.d.ts']
|
|
453
|
+
for (const base of bases) {
|
|
454
|
+
for (const extension of extensions) {
|
|
455
|
+
if (isFile(`${base}${extension}`)) return `${base}${extension}`
|
|
456
|
+
}
|
|
457
|
+
for (const extension of extensions) {
|
|
458
|
+
const index = path.join(base, `index${extension}`)
|
|
459
|
+
if (isFile(index)) return index
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function findSrcRoot(file) {
|
|
466
|
+
const parts = path.dirname(file).split(path.sep)
|
|
467
|
+
const index = parts.lastIndexOf('src')
|
|
468
|
+
return index === -1 ? null : parts.slice(0, index + 1).join(path.sep) || path.sep
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isFile(target) {
|
|
472
|
+
try {
|
|
473
|
+
return fs.statSync(target).isFile()
|
|
474
|
+
} catch {
|
|
475
|
+
return false
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
318
479
|
function isAllowedImporter(file, root) {
|
|
319
480
|
const segments = path.relative(root, file).split(path.sep)
|
|
320
481
|
return segments.some((segment) => ALLOWED_SEGMENTS.has(segment))
|
|
321
482
|
}
|
|
322
483
|
|
|
323
|
-
function isExternalSpec(spec) {
|
|
324
|
-
|
|
484
|
+
function isExternalSpec(spec, boundaries) {
|
|
485
|
+
if (/(^|[/@])(external|generated)([/]|$)/.test(spec)) return true
|
|
486
|
+
const patterns = [...(boundaries?.external ?? []), ...(boundaries?.generated ?? [])]
|
|
487
|
+
return patterns.some((pattern) => matchesBoundary(spec, pattern))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function matchesBoundary(spec, pattern) {
|
|
491
|
+
if (!pattern) return false
|
|
492
|
+
if (spec === pattern || spec.startsWith(`${pattern.replace(/\/$/, '')}/`)) return true
|
|
493
|
+
return /[*?]/.test(pattern) && matchPath(pattern, spec)
|
|
325
494
|
}
|
|
326
495
|
|
|
327
496
|
function isTestFile(file) {
|
|
328
497
|
return /(^|[/\\])(__tests__|tests?)[/\\]/.test(file) || /\.(test|spec)\.[^.]+$/.test(file)
|
|
329
498
|
}
|
|
330
499
|
|
|
331
|
-
|
|
500
|
+
/** 压缩 / 构建产物:结构规则跳过(A21 仍扫描),避免与源码重复报同一问题。 */
|
|
501
|
+
function isMinified(file) {
|
|
502
|
+
const text = read(file)
|
|
503
|
+
if (text == null) return false
|
|
504
|
+
const lines = text.split('\n')
|
|
505
|
+
if (lines.length > 3) return false
|
|
506
|
+
return lines.some((line) => line.length > 1000)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function scanSecrets(root, isExcluded = () => false) {
|
|
332
510
|
const findings = []
|
|
511
|
+
const seen = new Set()
|
|
333
512
|
for (const file of collectScanFiles(root)) {
|
|
513
|
+
if (isExcluded(file)) continue
|
|
334
514
|
if (SECRET_SKIP_FILES.has(path.basename(file))) continue
|
|
515
|
+
if (isTestFile(file)) continue
|
|
335
516
|
const text = read(file)
|
|
336
517
|
if (text == null) continue
|
|
518
|
+
|
|
337
519
|
for (const pattern of SECRET_PATTERNS) {
|
|
338
|
-
const match
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
520
|
+
for (const match of text.matchAll(globalize(pattern.re))) {
|
|
521
|
+
if (SECRET_ALLOW.has(match[0])) continue
|
|
522
|
+
pushSecret(findings, seen, {
|
|
523
|
+
rule: 'A21',
|
|
524
|
+
severity: 'warn',
|
|
525
|
+
file: path.relative(root, file),
|
|
526
|
+
line: lineAt(text, match.index ?? 0),
|
|
527
|
+
message: `possible ${pattern.id}; remove it and rotate the credential (A21)`,
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const kind = assignmentKind(file)
|
|
533
|
+
if (!kind) continue
|
|
534
|
+
text.split('\n').forEach((line, index) => {
|
|
535
|
+
const assignment = parseSecretAssignment(line, kind)
|
|
536
|
+
if (!assignment) return
|
|
537
|
+
pushSecret(findings, seen, {
|
|
342
538
|
rule: 'A21',
|
|
343
539
|
severity: 'warn',
|
|
344
540
|
file: path.relative(root, file),
|
|
345
|
-
line:
|
|
346
|
-
message: `
|
|
541
|
+
line: index + 1,
|
|
542
|
+
message: `plaintext ${assignment.key}; move it to env / config center and rotate (A21)`,
|
|
347
543
|
})
|
|
348
|
-
}
|
|
544
|
+
})
|
|
349
545
|
}
|
|
350
546
|
return findings
|
|
351
547
|
}
|
|
352
548
|
|
|
549
|
+
function globalize(re) {
|
|
550
|
+
return new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function pushSecret(findings, seen, finding) {
|
|
554
|
+
const key = `${finding.file}:${finding.line}`
|
|
555
|
+
if (seen.has(key)) return
|
|
556
|
+
seen.add(key)
|
|
557
|
+
findings.push(finding)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function parseSecretAssignment(line, kind) {
|
|
561
|
+
const trimmed = line.trim()
|
|
562
|
+
if (!trimmed || /^(#|\/\/|\*|<!--)/.test(trimmed)) return null
|
|
563
|
+
const match = SECRET_ASSIGN.exec(line)
|
|
564
|
+
if (!match) return null
|
|
565
|
+
const key = match[1]
|
|
566
|
+
const keyPattern = kind === 'code' ? SECRET_KEY_CODE : SECRET_KEY
|
|
567
|
+
if (!keyPattern.test(key) || SECRET_KEY_EXCLUDE.test(key)) return null
|
|
568
|
+
if (kind === 'code' && !/^["']/.test(match[3].trim())) return null
|
|
569
|
+
const value = cleanSecretValue(match[3])
|
|
570
|
+
if (value.length < 6 || SECRET_PLACEHOLDER.test(value)) return null
|
|
571
|
+
return { key }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function cleanSecretValue(raw) {
|
|
575
|
+
const trimmed = raw.trim()
|
|
576
|
+
const quoted = /^(["'`])([\s\S]*?)\1/.exec(trimmed)
|
|
577
|
+
const value = quoted ? quoted[2] : trimmed.split(/\s+/)[0]
|
|
578
|
+
return value
|
|
579
|
+
.replace(/\s+#.*$/, '')
|
|
580
|
+
.replace(/[;,]\s*$/, '')
|
|
581
|
+
.trim()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function assignmentKind(file) {
|
|
585
|
+
const base = path.basename(file)
|
|
586
|
+
if (base === '.env' || base.startsWith('.env.')) return 'config'
|
|
587
|
+
const extension = path.extname(base)
|
|
588
|
+
if (extension === '.java' || extension === '.kt') return 'code'
|
|
589
|
+
return ASSIGNMENT_EXTENSIONS.has(extension) ? 'config' : null
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function isSecretFile(name) {
|
|
593
|
+
if (name === '.env' || name.startsWith('.env.')) return true
|
|
594
|
+
return SECRET_EXTENSIONS.has(path.extname(name))
|
|
595
|
+
}
|
|
596
|
+
|
|
353
597
|
function collectScanFiles(root) {
|
|
354
598
|
const files = []
|
|
355
599
|
walkScan(root, files, new Set())
|
|
@@ -374,11 +618,11 @@ function walkScan(dir, files, seen) {
|
|
|
374
618
|
continue
|
|
375
619
|
}
|
|
376
620
|
if (!entry.isFile()) continue
|
|
377
|
-
if (
|
|
621
|
+
if (isSecretFile(entry.name)) files.push(full)
|
|
378
622
|
}
|
|
379
623
|
}
|
|
380
624
|
|
|
381
|
-
function scanPackageDocs(pkg) {
|
|
625
|
+
function scanPackageDocs(pkg, isExcluded = () => false) {
|
|
382
626
|
const findings = []
|
|
383
627
|
const files = ['README.md', 'USAGE.md', 'CHANGELOG.md', 'compat.md']
|
|
384
628
|
.map((name) => path.join(pkg, name))
|
|
@@ -392,6 +636,7 @@ function scanPackageDocs(pkg) {
|
|
|
392
636
|
}
|
|
393
637
|
|
|
394
638
|
for (const file of files) {
|
|
639
|
+
if (isExcluded(file)) continue
|
|
395
640
|
const text = read(file)
|
|
396
641
|
if (text == null) continue
|
|
397
642
|
text.split('\n').forEach((line, index) => {
|
|
@@ -411,11 +656,16 @@ function scanPackageDocs(pkg) {
|
|
|
411
656
|
|
|
412
657
|
function stripComments(text) {
|
|
413
658
|
return text
|
|
414
|
-
.replace(/<!--[\s\S]*?-->/g,
|
|
415
|
-
.replace(/\/\*[\s\S]*?\*\//g,
|
|
659
|
+
.replace(/<!--[\s\S]*?-->/g, maskComment)
|
|
660
|
+
.replace(/\/\*[\s\S]*?\*\//g, maskComment)
|
|
416
661
|
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
417
662
|
}
|
|
418
663
|
|
|
664
|
+
/** 用等长空白替换注释,保留换行,避免行号漂移。 */
|
|
665
|
+
function maskComment(match) {
|
|
666
|
+
return match.replace(/[^\n]/g, ' ')
|
|
667
|
+
}
|
|
668
|
+
|
|
419
669
|
function lineAt(text, index) {
|
|
420
670
|
let line = 1
|
|
421
671
|
for (let i = 0; i < index; i += 1) {
|