shellward 0.6.1 → 0.6.2

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.
@@ -0,0 +1,310 @@
1
+ // src/compliance/audit.ts — 合规体检引擎
2
+ //
3
+ // 跑遍 COMPLIANCE_CONTROLS,对每个控制项给出 pass / warn / fail / manual,
4
+ // 汇总成红黄绿评分卡。这是「一键合规体检报告」(月1 获客钩子) 的核心。
5
+ //
6
+ // 设计为可注入 (EnvFacts):测试可直接喂事实,运行时则从真实环境采集。
7
+
8
+ import { readFileSync, statSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { getHomeDir } from '../utils.js'
11
+ import { detectOverseasLLM } from '../rules/overseas-llm.js'
12
+ import type { OverseasMatch } from '../rules/overseas-llm.js'
13
+ import { scanProject } from './project-scan.js'
14
+ import type { ProjectScanResult } from './project-scan.js'
15
+ import { COMPLIANCE_CONTROLS } from './regulations.js'
16
+ import type { ComplianceControl, Regulation, Severity } from './regulations.js'
17
+ import type { ShellWardConfig } from '../types.js'
18
+
19
+ const LOG_FILE = join(getHomeDir(), '.openclaw', 'shellward', 'audit.jsonl')
20
+ const SIX_MONTHS_MS = 182 * 24 * 60 * 60 * 1000
21
+
22
+ export type ControlStatus = 'pass' | 'warn' | 'fail' | 'manual'
23
+
24
+ export interface ControlResult {
25
+ control: ComplianceControl
26
+ status: ControlStatus
27
+ detail_zh: string
28
+ detail_en: string
29
+ }
30
+
31
+ export interface AuditLogFacts {
32
+ exists: boolean
33
+ entryCount: number
34
+ /** 最早一条记录时间戳 (ISO),用于判断是否覆盖 6 个月留存 */
35
+ oldestTs?: string
36
+ newestTs?: string
37
+ }
38
+
39
+ export interface EnvFacts {
40
+ isRoot: boolean
41
+ auditLog: AuditLogFacts
42
+ /** 从环境/配置中探测到的境外大模型端点 */
43
+ overseas: OverseasMatch[]
44
+ }
45
+
46
+ export interface ComplianceReport {
47
+ /** 0-100 合规得分 */
48
+ score: number
49
+ /** 总评级 */
50
+ grade: 'A' | 'B' | 'C' | 'D'
51
+ passed: number
52
+ warned: number
53
+ failed: number
54
+ manual: number
55
+ total: number
56
+ results: ControlResult[]
57
+ generatedAt: string
58
+ /** 项目实测风险造成的扣分(仅项目体检路径);0 表示纯控制项评分 */
59
+ projectPenalty?: number
60
+ }
61
+
62
+ /** 层能力映射:控制项 id → 必须启用的层(全部启用才 pass,部分启用 warn,全关 fail) */
63
+ const CAPABILITY_LAYERS: Record<string, (keyof ShellWardConfig['layers'])[]> = {
64
+ 'csl-content-block': ['outputScanner', 'outboundGuard'],
65
+ 'csl-intrusion': ['inputAuditor', 'toolBlocker'],
66
+ 'pipl-spi-detect': ['outputScanner'],
67
+ 'pipl-minimize': ['dataFlowGuard'],
68
+ 'pipl-auto-decision': ['securityGate'],
69
+ 'mlps-access-control': ['securityGate'],
70
+ 'cbdt-redact-before-export': ['dataFlowGuard'],
71
+ }
72
+
73
+ /** 采集真实环境事实(运行时调用;测试可绕过直接注入 EnvFacts) */
74
+ export function gatherEnvFacts(): EnvFacts {
75
+ // 1. root 检测
76
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
77
+
78
+ // 2. 审计日志事实
79
+ const auditLog = readAuditLogFacts()
80
+
81
+ // 3. 出境端点探测:扫描常见环境变量中的 base_url / 端点
82
+ const overseas: OverseasMatch[] = []
83
+ const seen = new Set<string>()
84
+ for (const [k, v] of Object.entries(process.env)) {
85
+ if (!v) continue
86
+ if (!/(_BASE_URL|_API_BASE|_ENDPOINT|_URL|OPENAI|ANTHROPIC|GEMINI|LLM)/i.test(k)) continue
87
+ const m = detectOverseasLLM(v)
88
+ if (m.isOverseas && m.endpointId && !seen.has(m.endpointId)) {
89
+ seen.add(m.endpointId)
90
+ overseas.push(m)
91
+ }
92
+ }
93
+
94
+ return { isRoot, auditLog, overseas }
95
+ }
96
+
97
+ function readAuditLogFacts(): AuditLogFacts {
98
+ try {
99
+ statSync(LOG_FILE)
100
+ const content = readFileSync(LOG_FILE, 'utf-8')
101
+ const lines = content.trim().split('\n').filter(Boolean)
102
+ if (lines.length === 0) return { exists: true, entryCount: 0 }
103
+ const firstTs = extractTs(lines[0])
104
+ const lastTs = extractTs(lines[lines.length - 1])
105
+ return { exists: true, entryCount: lines.length, oldestTs: firstTs, newestTs: lastTs }
106
+ } catch {
107
+ return { exists: false, entryCount: 0 }
108
+ }
109
+ }
110
+
111
+ function extractTs(line: string): string | undefined {
112
+ const m = line.match(/"ts":"([^"]+)"/)
113
+ return m?.[1]
114
+ }
115
+
116
+ /**
117
+ * 运行合规体检。
118
+ * @param config ShellWard 当前配置
119
+ * @param facts 环境事实;不传则从真实环境采集
120
+ */
121
+ export function runComplianceAudit(config: ShellWardConfig, facts?: EnvFacts): ComplianceReport {
122
+ const env = facts ?? gatherEnvFacts()
123
+ const results: ControlResult[] = COMPLIANCE_CONTROLS.map(c => checkControl(c, config, env))
124
+
125
+ let passed = 0, warned = 0, failed = 0, manual = 0
126
+ for (const r of results) {
127
+ if (r.status === 'pass') passed++
128
+ else if (r.status === 'warn') warned++
129
+ else if (r.status === 'fail') failed++
130
+ else manual++
131
+ }
132
+
133
+ const score = computeScore(results)
134
+ return {
135
+ score,
136
+ grade: gradeOf(score),
137
+ passed, warned, failed, manual,
138
+ total: results.length,
139
+ results,
140
+ generatedAt: new Date().toISOString(),
141
+ }
142
+ }
143
+
144
+ export interface ProjectComplianceResult {
145
+ report: ComplianceReport
146
+ scan: ProjectScanResult
147
+ }
148
+
149
+ /**
150
+ * 面向真实项目的体检:扫描项目目录的真实风险,并入评分,再跑控制项体检。
151
+ * 这是 CLI (`shellward scan`) 的入口 —— 报告关于「用户项目」而非「ShellWard 开关」。
152
+ */
153
+ export function runProjectComplianceAudit(config: ShellWardConfig, root: string): ProjectComplianceResult {
154
+ const scan = scanProject(root)
155
+ const env = gatherEnvFacts()
156
+
157
+ // 把文件中实测到的境外端点/依赖并入 facts(按 endpointId 或 provider 去重),
158
+ // 使数据出境项基于真实证据(含 SDK 依赖通道)
159
+ const seen = new Set(env.overseas.map(o => o.endpointId || o.provider_en))
160
+ for (const f of scan.findings) {
161
+ if (f.kind !== 'overseas') continue
162
+ const key = f.endpointId || f.provider_en || ''
163
+ if (!key || seen.has(key)) continue
164
+ seen.add(key)
165
+ env.overseas.push({
166
+ isOverseas: true,
167
+ endpointId: f.endpointId,
168
+ provider_zh: f.provider_zh,
169
+ provider_en: f.provider_en,
170
+ })
171
+ }
172
+
173
+ const report = runComplianceAudit(config, env)
174
+
175
+ // 发现驱动评分:项目实测风险按严重度扣分(封顶 40),使分数反映"你的真实风险"
176
+ const penalty = computeProjectPenalty(scan)
177
+ if (penalty > 0) {
178
+ report.score = Math.max(0, report.score - penalty)
179
+ report.grade = gradeOf(report.score)
180
+ report.projectPenalty = penalty
181
+ }
182
+
183
+ return { report, scan }
184
+ }
185
+
186
+ const FINDING_PENALTY = { critical: 8, high: 4, medium: 1 } as const
187
+ const MAX_PROJECT_PENALTY = 40
188
+
189
+ function computeProjectPenalty(scan: ProjectScanResult): number {
190
+ let p = 0
191
+ for (const f of scan.findings) p += FINDING_PENALTY[f.severity]
192
+ return Math.min(MAX_PROJECT_PENALTY, p)
193
+ }
194
+
195
+ function checkControl(c: ComplianceControl, config: ShellWardConfig, env: EnvFacts): ControlResult {
196
+ switch (c.method) {
197
+ case 'capability': return checkCapability(c, config)
198
+ case 'config': return checkConfig(c, config)
199
+ case 'audit': return checkAudit(c, env)
200
+ case 'env': return checkEnv(c, env)
201
+ case 'manual': return mk(c, 'manual',
202
+ '需人工确认 / 路线图功能:' + c.remediation_zh,
203
+ 'Manual / roadmap: ' + c.remediation_en)
204
+ }
205
+ }
206
+
207
+ function checkCapability(c: ComplianceControl, config: ShellWardConfig): ControlResult {
208
+ const required = CAPABILITY_LAYERS[c.id]
209
+ if (!required) {
210
+ // 未显式映射的能力项:以 enforce 模式作为兜底信号
211
+ return config.mode === 'enforce'
212
+ ? mk(c, 'pass', '能力已启用 (enforce 模式)', 'Capability active (enforce mode)')
213
+ : mk(c, 'warn', 'audit 模式仅记录不拦截,建议切换 enforce', 'Audit mode logs only; switch to enforce')
214
+ }
215
+ const on = required.filter(l => config.layers[l])
216
+ if (on.length === required.length) {
217
+ const tail = config.mode === 'enforce' ? '' : '(注意:audit 模式仅记录不拦截)'
218
+ return mk(c, config.mode === 'enforce' ? 'pass' : 'warn',
219
+ `已启用: ${required.join(', ')}${tail}`,
220
+ `Enabled: ${required.join(', ')}${config.mode === 'enforce' ? '' : ' (audit mode: log-only)'}`)
221
+ }
222
+ if (on.length > 0) {
223
+ return mk(c, 'warn',
224
+ `部分启用: ${on.join(', ')};缺少: ${required.filter(l => !on.includes(l)).join(', ')}`,
225
+ `Partially enabled; missing: ${required.filter(l => !on.includes(l)).join(', ')}`)
226
+ }
227
+ return mk(c, 'fail', `未启用: ${required.join(', ')}`, `Not enabled: ${required.join(', ')}`)
228
+ }
229
+
230
+ function checkConfig(c: ComplianceControl, config: ShellWardConfig): ControlResult {
231
+ return config.mode === 'enforce'
232
+ ? mk(c, 'pass', 'enforce 模式', 'enforce mode')
233
+ : mk(c, 'warn', 'audit 模式仅记录', 'audit mode logs only')
234
+ }
235
+
236
+ function checkAudit(c: ComplianceControl, env: EnvFacts): ControlResult {
237
+ const a = env.auditLog
238
+ if (!a.exists || a.entryCount === 0) {
239
+ return mk(c, 'fail',
240
+ '未发现审计日志或日志为空 — 无法满足留存与举证要求',
241
+ 'No audit log found or empty — retention/evidence requirement unmet')
242
+ }
243
+ // 判断留存跨度是否覆盖 6 个月
244
+ if (a.oldestTs) {
245
+ const span = Date.now() - new Date(a.oldestTs).getTime()
246
+ if (span >= SIX_MONTHS_MS) {
247
+ return mk(c, 'pass',
248
+ `审计日志 ${a.entryCount} 条,最早 ${a.oldestTs.slice(0, 10)},已覆盖 ≥6 个月`,
249
+ `${a.entryCount} entries since ${a.oldestTs.slice(0, 10)}, ≥6 months covered`)
250
+ }
251
+ }
252
+ return mk(c, 'warn',
253
+ `审计日志已启用 (${a.entryCount} 条),但留存尚未满 6 个月 — 需持续运行积累`,
254
+ `Audit log active (${a.entryCount} entries) but <6 months retained — keep running`)
255
+ }
256
+
257
+ function checkEnv(c: ComplianceControl, env: EnvFacts): ControlResult {
258
+ if (c.id === 'mlps-not-root') {
259
+ return env.isRoot
260
+ ? mk(c, 'fail', '正在以 root 运行 — 违反最小权限原则', 'Running as root — violates least privilege')
261
+ : mk(c, 'pass', '非 root 运行', 'Not running as root')
262
+ }
263
+ if (c.id === 'cbdt-overseas-llm') {
264
+ if (env.overseas.length > 0) {
265
+ const names = env.overseas.map(o => o.provider_zh).join(', ')
266
+ const namesEn = env.overseas.map(o => o.provider_en).join(', ')
267
+ return mk(c, 'fail',
268
+ `检测到境外大模型端点配置: ${names} — 若向其发送个人信息/重要数据即构成数据出境,须走合规路径`,
269
+ `Overseas LLM endpoint(s) configured: ${namesEn} — sending PI/important data = cross-border export`)
270
+ }
271
+ return mk(c, 'pass', '未检测到境外大模型端点配置', 'No overseas LLM endpoint detected')
272
+ }
273
+ return mk(c, 'manual', '需人工确认', 'Manual check required')
274
+ }
275
+
276
+ function mk(control: ComplianceControl, status: ControlStatus, detail_zh: string, detail_en: string): ControlResult {
277
+ return { control, status, detail_zh, detail_en }
278
+ }
279
+
280
+ // ===== 评分 =====
281
+
282
+ const SEVERITY_WEIGHT: Record<Severity, number> = {
283
+ critical: 4, high: 3, medium: 2, low: 1,
284
+ }
285
+
286
+ /**
287
+ * 加权得分:manual 项不计入分母(不惩罚路线图/人工项)。
288
+ * pass=满分, warn=半分, fail=0。
289
+ */
290
+ function computeScore(results: ControlResult[]): number {
291
+ let earned = 0, possible = 0
292
+ for (const r of results) {
293
+ if (r.status === 'manual') continue
294
+ const w = SEVERITY_WEIGHT[r.control.severity]
295
+ possible += w
296
+ if (r.status === 'pass') earned += w
297
+ else if (r.status === 'warn') earned += w * 0.5
298
+ }
299
+ if (possible === 0) return 0
300
+ return Math.round((earned / possible) * 100)
301
+ }
302
+
303
+ function gradeOf(score: number): 'A' | 'B' | 'C' | 'D' {
304
+ if (score >= 90) return 'A'
305
+ if (score >= 75) return 'B'
306
+ if (score >= 60) return 'C'
307
+ return 'D'
308
+ }
309
+
310
+ export type { Regulation }
@@ -0,0 +1,263 @@
1
+ // src/compliance/project-scan.ts — 项目真实风险扫描
2
+ //
3
+ // 体检的灵魂:报告的是「用户项目的真实风险」,不是「ShellWard 的开关」。
4
+ // 零依赖遍历当前项目目录,找出可截图、可定位 (文件:行) 的合规风险:
5
+ // ① 境外大模型端点(硬编码 base_url/key)→ 数据出境风险
6
+ // ② 硬编码密钥(API key / 私钥 / 口令 / 连接串)
7
+ // ③ 文件中的中文 PII(身份证 / 手机号 / 银行卡)
8
+ // ④ .env 等敏感文件权限过宽
9
+
10
+ import { readdirSync, statSync, readFileSync } from 'fs'
11
+ import { join, relative, basename } from 'path'
12
+ import { detectOverseasLLM, detectOverseasDeps, isDependencyManifest } from '../rules/overseas-llm.js'
13
+ import { SENSITIVE_PATTERNS } from '../rules/sensitive-patterns.js'
14
+
15
+ export type FindingKind = 'overseas' | 'secret' | 'pii' | 'env-perm'
16
+
17
+ export interface ProjectFinding {
18
+ kind: FindingKind
19
+ /** 相对项目根的路径 */
20
+ file: string
21
+ line?: number
22
+ /** 人类可读结论 */
23
+ detail: string
24
+ severity: 'critical' | 'high' | 'medium'
25
+ /** 仅 overseas:命中的境外端点信息,供体检评分并入 */
26
+ endpointId?: string
27
+ provider_zh?: string
28
+ provider_en?: string
29
+ }
30
+
31
+ export interface ProjectScanResult {
32
+ root: string
33
+ filesScanned: number
34
+ truncated: boolean
35
+ findings: ProjectFinding[]
36
+ counts: Record<FindingKind, number>
37
+ }
38
+
39
+ // ===== 扫描边界(零依赖、可控) =====
40
+ const SKIP_DIRS = new Set([
41
+ 'node_modules', '.git', 'dist', 'build', '.next', 'out',
42
+ 'vendor', 'coverage', '.venv', 'venv', '__pycache__', '.cache', 'target',
43
+ ])
44
+ const SCAN_EXT = new Set([
45
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
46
+ '.py', '.go', '.rb', '.java', '.php', '.rs',
47
+ '.json', '.yaml', '.yml', '.toml', '.ini', '.conf', '.sh', '.txt', '.csv',
48
+ ])
49
+ const MAX_FILES = 3000
50
+ const MAX_FILE_BYTES = 512 * 1024
51
+ const MAX_FINDINGS_PER_FILE = 20
52
+ const MAX_TOTAL_FINDINGS = 500
53
+
54
+ // 密钥类 vs PII 类(按 sensitive-patterns 的 id 归类)
55
+ const SECRET_IDS = new Set([
56
+ 'openai_key', 'anthropic_key', 'aws_access', 'github_token',
57
+ 'generic_api_key', 'private_key', 'jwt', 'password', 'conn_string',
58
+ ])
59
+ const PII_IDS = new Set(['id_card_cn', 'phone_cn', 'bank_card_cn', 'ssn_us', 'credit_card'])
60
+
61
+ /** 扫描项目目录,返回真实风险发现 */
62
+ export function scanProject(root: string): ProjectScanResult {
63
+ const findings: ProjectFinding[] = []
64
+ const state = { files: 0, truncated: false }
65
+
66
+ walk(root, root, findings, state)
67
+
68
+ const counts: Record<FindingKind, number> = { overseas: 0, secret: 0, pii: 0, 'env-perm': 0 }
69
+ for (const f of findings) counts[f.kind]++
70
+
71
+ return {
72
+ root,
73
+ filesScanned: state.files,
74
+ truncated: state.truncated,
75
+ findings,
76
+ counts,
77
+ }
78
+ }
79
+
80
+ function walk(
81
+ dir: string,
82
+ root: string,
83
+ findings: ProjectFinding[],
84
+ state: { files: number; truncated: boolean },
85
+ ): void {
86
+ if (state.files >= MAX_FILES || findings.length >= MAX_TOTAL_FINDINGS) {
87
+ state.truncated = true
88
+ return
89
+ }
90
+ let entries: string[]
91
+ try {
92
+ entries = readdirSync(dir)
93
+ } catch {
94
+ return
95
+ }
96
+
97
+ for (const name of entries) {
98
+ if (state.files >= MAX_FILES || findings.length >= MAX_TOTAL_FINDINGS) {
99
+ state.truncated = true
100
+ return
101
+ }
102
+ if (name.startsWith('.') && !name.startsWith('.env')) {
103
+ // 跳过隐藏文件/目录,但保留 .env*
104
+ if (name !== '.') continue
105
+ }
106
+ const full = join(dir, name)
107
+ let st
108
+ try {
109
+ st = statSync(full)
110
+ } catch {
111
+ continue
112
+ }
113
+
114
+ if (st.isDirectory()) {
115
+ if (SKIP_DIRS.has(name)) continue
116
+ walk(full, root, findings, state)
117
+ continue
118
+ }
119
+
120
+ if (!st.isFile()) continue
121
+
122
+ // .env 权限检查(任意大小)
123
+ if (/^\.env(\..+)?$/.test(name)) {
124
+ checkEnvPerm(full, root, st.mode, findings)
125
+ }
126
+
127
+ // 内容扫描:文本类扩展 + .env* + 依赖清单(含 go.mod),且大小受限
128
+ const isEnv = /^\.env(\..+)?$/.test(name)
129
+ const isDep = isDependencyManifest(name)
130
+ const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''
131
+ if (!isEnv && !isDep && !SCAN_EXT.has(ext)) continue
132
+ if (st.size > MAX_FILE_BYTES) continue
133
+
134
+ let content: string
135
+ try {
136
+ content = readFileSync(full, 'utf-8')
137
+ } catch {
138
+ continue
139
+ }
140
+ state.files++
141
+ if (isDep) scanDependencies(name, content, full, root, findings)
142
+ scanContent(content, full, root, findings)
143
+ }
144
+ }
145
+
146
+ function scanDependencies(
147
+ name: string,
148
+ content: string,
149
+ full: string,
150
+ root: string,
151
+ findings: ProjectFinding[],
152
+ ): void {
153
+ const deps = detectOverseasDeps(name, content)
154
+ if (deps.length === 0) return
155
+ const file = rel(root, full)
156
+ const lines = content.split('\n')
157
+ for (const d of deps) {
158
+ if (findings.length >= MAX_TOTAL_FINDINGS) break
159
+ // 粗定位包名所在行
160
+ let line: number | undefined
161
+ for (let i = 0; i < lines.length; i++) {
162
+ if (lines[i].toLowerCase().includes(d.pkg.toLowerCase())) { line = i + 1; break }
163
+ }
164
+ findings.push({
165
+ kind: 'overseas',
166
+ file,
167
+ line,
168
+ detail: `境外大模型 SDK 依赖: ${d.pkg} (${d.provider_zh}) — 项目内含数据出境通道,调用即可能构成出境`,
169
+ severity: 'high',
170
+ provider_zh: d.provider_zh,
171
+ provider_en: d.provider_en,
172
+ })
173
+ }
174
+ }
175
+
176
+ function checkEnvPerm(full: string, root: string, mode: number, findings: ProjectFinding[]): void {
177
+ // 仅 POSIX 有意义;Windows 上 mode 不可靠,跳过
178
+ if (process.platform === 'win32') return
179
+ const perm = mode & 0o777
180
+ if (perm > 0o600) {
181
+ findings.push({
182
+ kind: 'env-perm',
183
+ file: rel(root, full),
184
+ detail: `权限过宽 (${perm.toString(8)}),建议 chmod 600 — 含密钥的 .env 不应组/其他可读`,
185
+ severity: 'high',
186
+ })
187
+ }
188
+ }
189
+
190
+ function scanContent(
191
+ content: string,
192
+ full: string,
193
+ root: string,
194
+ findings: ProjectFinding[],
195
+ ): void {
196
+ const file = rel(root, full)
197
+ const lines = content.split('\n')
198
+ let perFile = 0
199
+ const dedup = new Set<string>()
200
+
201
+ for (let i = 0; i < lines.length; i++) {
202
+ if (perFile >= MAX_FINDINGS_PER_FILE || findings.length >= MAX_TOTAL_FINDINGS) break
203
+ const line = lines[i]
204
+ if (!line || line.length > 4000) continue
205
+
206
+ // ① 境外大模型端点
207
+ const ov = detectOverseasLLM(line)
208
+ if (ov.isOverseas) {
209
+ const key = `overseas:${ov.endpointId}`
210
+ if (!dedup.has(key)) {
211
+ dedup.add(key)
212
+ findings.push({
213
+ kind: 'overseas',
214
+ file,
215
+ line: i + 1,
216
+ detail: `境外大模型端点: ${ov.provider_zh} — 向其发送个人信息/重要数据即构成数据出境`,
217
+ severity: 'critical',
218
+ endpointId: ov.endpointId,
219
+ provider_zh: ov.provider_zh,
220
+ provider_en: ov.provider_en,
221
+ })
222
+ perFile++
223
+ }
224
+ }
225
+
226
+ // ② / ③ 密钥与 PII
227
+ for (const pat of SENSITIVE_PATTERNS) {
228
+ if (perFile >= MAX_FINDINGS_PER_FILE) break
229
+ const isSecret = SECRET_IDS.has(pat.id)
230
+ const isPII = PII_IDS.has(pat.id)
231
+ if (!isSecret && !isPII) continue
232
+ const re = new RegExp(pat.regex.source, pat.regex.flags)
233
+ let m: RegExpExecArray | null
234
+ while ((m = re.exec(line)) !== null) {
235
+ if (pat.validate && !pat.validate(m[0])) continue
236
+ const key = `${pat.id}:${i}`
237
+ if (dedup.has(key)) break
238
+ dedup.add(key)
239
+ findings.push({
240
+ kind: isSecret ? 'secret' : 'pii',
241
+ file,
242
+ line: i + 1,
243
+ detail: isSecret
244
+ ? `硬编码${pat.name}: ${preview(m[0])} — 凭据不应写入源码/配置`
245
+ : `${pat.name}: ${preview(m[0])} — 个人信息出现在文件中,需评估最小必要与脱敏`,
246
+ severity: isSecret ? 'critical' : 'high',
247
+ })
248
+ perFile++
249
+ if (perFile >= MAX_FINDINGS_PER_FILE) break
250
+ if (!re.global) break
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ function preview(s: string): string {
257
+ return s.length > 10 ? s.slice(0, 6) + '***' : s.slice(0, 3) + '***'
258
+ }
259
+
260
+ function rel(root: string, full: string): string {
261
+ const r = relative(root, full)
262
+ return r || basename(full)
263
+ }