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.
- package/README.md +78 -5
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +144 -0
- package/dist/commands/compliance.d.ts +2 -0
- package/dist/commands/compliance.js +21 -0
- package/dist/commands/index.js +6 -2
- package/dist/compliance/audit.d.ts +57 -0
- package/dist/compliance/audit.js +234 -0
- package/dist/compliance/project-scan.d.ts +23 -0
- package/dist/compliance/project-scan.js +225 -0
- package/dist/compliance/regulations.d.ts +35 -0
- package/dist/compliance/regulations.js +218 -0
- package/dist/compliance/report.d.ts +9 -0
- package/dist/compliance/report.js +167 -0
- package/dist/mcp-server.js +36 -0
- package/dist/rules/overseas-llm.d.ts +37 -0
- package/dist/rules/overseas-llm.js +147 -0
- package/package.json +4 -3
- package/src/cli.ts +154 -0
- package/src/commands/compliance.ts +25 -0
- package/src/commands/index.ts +6 -2
- package/src/compliance/audit.ts +310 -0
- package/src/compliance/project-scan.ts +263 -0
- package/src/compliance/regulations.ts +260 -0
- package/src/compliance/report.ts +189 -0
- package/src/mcp-server.ts +37 -0
- package/src/rules/overseas-llm.ts +174 -0
|
@@ -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
|
+
}
|