lcch-cli 1.0.1
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 +847 -0
- package/dist/backup.js +142 -0
- package/dist/export.js +1211 -0
- package/dist/index.js +3015 -0
- package/dist/scanner.js +1789 -0
- package/dist/types.js +5 -0
- package/package.json +49 -0
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 扫描器模块 - 负责检测无效项目
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
39
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.getConfigPath = getConfigPath;
|
|
43
|
+
exports.readConfig = readConfig;
|
|
44
|
+
exports.writeConfig = writeConfig;
|
|
45
|
+
exports.getSettingsPath = getSettingsPath;
|
|
46
|
+
exports.getProjectSettingsPath = getProjectSettingsPath;
|
|
47
|
+
exports.readSettingsAt = readSettingsAt;
|
|
48
|
+
exports.readSettings = readSettings;
|
|
49
|
+
exports.getAllHooksWithScope = getAllHooksWithScope;
|
|
50
|
+
exports.groupHooksByScope = groupHooksByScope;
|
|
51
|
+
exports.groupHooksByEvent = groupHooksByEvent;
|
|
52
|
+
exports.scanProjects = scanProjects;
|
|
53
|
+
exports.getProjectInfo = getProjectInfo;
|
|
54
|
+
exports.removeProjects = removeProjects;
|
|
55
|
+
exports.buildProjectTree = buildProjectTree;
|
|
56
|
+
exports.printProjectTree = printProjectTree;
|
|
57
|
+
exports.formatDate = formatDate;
|
|
58
|
+
exports.formatRelativeTime = formatRelativeTime;
|
|
59
|
+
exports.readSessionHistory = readSessionHistory;
|
|
60
|
+
exports.deleteSessions = deleteSessions;
|
|
61
|
+
exports.groupSessionsByProject = groupSessionsByProject;
|
|
62
|
+
exports.truncateText = truncateText;
|
|
63
|
+
exports.getProjectResumeInfo = getProjectResumeInfo;
|
|
64
|
+
exports.getOrphanedResumes = getOrphanedResumes;
|
|
65
|
+
exports.deleteProjectResumes = deleteProjectResumes;
|
|
66
|
+
exports.getAllMcpServers = getAllMcpServers;
|
|
67
|
+
exports.groupMcpServersByScope = groupMcpServersByScope;
|
|
68
|
+
exports.calculateGlobalStats = calculateGlobalStats;
|
|
69
|
+
exports.getCacheInfo = getCacheInfo;
|
|
70
|
+
exports.getPromptCacheStats = getPromptCacheStats;
|
|
71
|
+
exports.scanCacheSecrets = scanCacheSecrets;
|
|
72
|
+
exports.scanProjectCacheSecrets = scanProjectCacheSecrets;
|
|
73
|
+
exports.getAllProjectCacheDirs = getAllProjectCacheDirs;
|
|
74
|
+
exports.scanProjectSecrets = scanProjectSecrets;
|
|
75
|
+
exports.maskProjectSecrets = maskProjectSecrets;
|
|
76
|
+
const fs = __importStar(require("fs-extra"));
|
|
77
|
+
const path = __importStar(require("path"));
|
|
78
|
+
const os = __importStar(require("os"));
|
|
79
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
80
|
+
/**
|
|
81
|
+
* 获取 Claude 配置文件路径
|
|
82
|
+
*/
|
|
83
|
+
function getConfigPath() {
|
|
84
|
+
return path.join(os.homedir(), '.claude.json');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 读取 Claude 配置文件
|
|
88
|
+
*/
|
|
89
|
+
async function readConfig() {
|
|
90
|
+
const configPath = getConfigPath();
|
|
91
|
+
if (!await fs.pathExists(configPath)) {
|
|
92
|
+
throw new Error(`配置文件不存在: ${configPath}`);
|
|
93
|
+
}
|
|
94
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
95
|
+
return JSON.parse(content);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 写入 Claude 配置文件
|
|
99
|
+
*/
|
|
100
|
+
async function writeConfig(config) {
|
|
101
|
+
const configPath = getConfigPath();
|
|
102
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 获取 Claude settings 文件路径(全局)
|
|
106
|
+
*/
|
|
107
|
+
function getSettingsPath() {
|
|
108
|
+
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 获取项目级 settings 文件路径
|
|
112
|
+
*/
|
|
113
|
+
function getProjectSettingsPath(projectPath) {
|
|
114
|
+
return path.join(projectPath, '.claude', 'settings.json');
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 读取指定路径的 settings 文件
|
|
118
|
+
*/
|
|
119
|
+
async function readSettingsAt(settingsPath) {
|
|
120
|
+
if (!await fs.pathExists(settingsPath)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const content = await fs.readFile(settingsPath, 'utf-8');
|
|
124
|
+
return JSON.parse(content);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 读取 Claude settings 文件(全局)
|
|
128
|
+
*/
|
|
129
|
+
async function readSettings() {
|
|
130
|
+
const settingsPath = getSettingsPath();
|
|
131
|
+
return readSettingsAt(settingsPath);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 获取所有 hooks(全局 + 项目级)
|
|
135
|
+
*/
|
|
136
|
+
async function getAllHooksWithScope(config) {
|
|
137
|
+
const hookInfos = [];
|
|
138
|
+
// 读取全局 hooks
|
|
139
|
+
const globalSettings = await readSettings();
|
|
140
|
+
if (globalSettings && globalSettings.hooks) {
|
|
141
|
+
const hooks = globalSettings.hooks;
|
|
142
|
+
for (const [event, hookList] of Object.entries(hooks)) {
|
|
143
|
+
if (!Array.isArray(hookList))
|
|
144
|
+
continue;
|
|
145
|
+
for (let i = 0; i < hookList.length; i++) {
|
|
146
|
+
const hookGroup = hookList[i];
|
|
147
|
+
if (!hookGroup.hooks || !Array.isArray(hookGroup.hooks))
|
|
148
|
+
continue;
|
|
149
|
+
for (const hook of hookGroup.hooks) {
|
|
150
|
+
hookInfos.push({
|
|
151
|
+
event,
|
|
152
|
+
matcher: hookGroup.matcher,
|
|
153
|
+
type: hook.type,
|
|
154
|
+
command: hook.command,
|
|
155
|
+
index: i,
|
|
156
|
+
scope: 'global',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 读取项目级 hooks
|
|
163
|
+
for (const projectPath of Object.keys(config.projects)) {
|
|
164
|
+
const expandedPath = projectPath.replace(/^~/, os.homedir());
|
|
165
|
+
const projectSettingsPath = getProjectSettingsPath(expandedPath);
|
|
166
|
+
const projectSettings = await readSettingsAt(projectSettingsPath);
|
|
167
|
+
if (projectSettings && projectSettings.hooks) {
|
|
168
|
+
const hooks = projectSettings.hooks;
|
|
169
|
+
for (const [event, hookList] of Object.entries(hooks)) {
|
|
170
|
+
if (!Array.isArray(hookList))
|
|
171
|
+
continue;
|
|
172
|
+
for (let i = 0; i < hookList.length; i++) {
|
|
173
|
+
const hookGroup = hookList[i];
|
|
174
|
+
if (!hookGroup.hooks || !Array.isArray(hookGroup.hooks))
|
|
175
|
+
continue;
|
|
176
|
+
for (const hook of hookGroup.hooks) {
|
|
177
|
+
hookInfos.push({
|
|
178
|
+
event,
|
|
179
|
+
matcher: hookGroup.matcher,
|
|
180
|
+
type: hook.type,
|
|
181
|
+
command: hook.command,
|
|
182
|
+
index: i,
|
|
183
|
+
scope: 'project',
|
|
184
|
+
projectPath,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return hookInfos;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 按作用范围分组 hooks
|
|
195
|
+
*/
|
|
196
|
+
function groupHooksByScope(hooks) {
|
|
197
|
+
const global = [];
|
|
198
|
+
const project = new Map();
|
|
199
|
+
for (const hook of hooks) {
|
|
200
|
+
if (hook.scope === 'global') {
|
|
201
|
+
global.push(hook);
|
|
202
|
+
}
|
|
203
|
+
else if (hook.scope === 'project' && hook.projectPath) {
|
|
204
|
+
const list = project.get(hook.projectPath) || [];
|
|
205
|
+
list.push(hook);
|
|
206
|
+
project.set(hook.projectPath, list);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { global, project };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 按事件分组 hooks
|
|
213
|
+
*/
|
|
214
|
+
function groupHooksByEvent(hooks) {
|
|
215
|
+
const groups = new Map();
|
|
216
|
+
for (const hook of hooks) {
|
|
217
|
+
const list = groups.get(hook.event) || [];
|
|
218
|
+
list.push(hook);
|
|
219
|
+
groups.set(hook.event, list);
|
|
220
|
+
}
|
|
221
|
+
return groups;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 扫描项目,检查路径是否存在
|
|
225
|
+
*/
|
|
226
|
+
async function scanProjects(config) {
|
|
227
|
+
const projects = Object.keys(config.projects);
|
|
228
|
+
const validProjects = [];
|
|
229
|
+
const invalidProjects = [];
|
|
230
|
+
for (const projectPath of projects) {
|
|
231
|
+
// 处理路径中的 ~ 符号
|
|
232
|
+
const expandedPath = projectPath.replace(/^~/, os.homedir());
|
|
233
|
+
if (await fs.pathExists(expandedPath)) {
|
|
234
|
+
validProjects.push(projectPath);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
invalidProjects.push(projectPath);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
validProjects,
|
|
242
|
+
invalidProjects,
|
|
243
|
+
totalCount: projects.length,
|
|
244
|
+
validCount: validProjects.length,
|
|
245
|
+
invalidCount: invalidProjects.length,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 获取项目的详细信息
|
|
250
|
+
*/
|
|
251
|
+
function getProjectInfo(config, projectPath) {
|
|
252
|
+
return config.projects[projectPath];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* 从配置中删除项目
|
|
256
|
+
*/
|
|
257
|
+
function removeProjects(config, projectPaths) {
|
|
258
|
+
for (const projectPath of projectPaths) {
|
|
259
|
+
delete config.projects[projectPath];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 构建项目树结构
|
|
264
|
+
*/
|
|
265
|
+
function buildProjectTree(projectPaths) {
|
|
266
|
+
const tree = new Map();
|
|
267
|
+
for (const projectPath of projectPaths) {
|
|
268
|
+
// 统一使用 / 作为分隔符,并分割路径
|
|
269
|
+
const normalizedPath = projectPath.replace(/\\/g, '/');
|
|
270
|
+
const parts = normalizedPath.split('/').filter(p => p);
|
|
271
|
+
if (parts.length === 0)
|
|
272
|
+
continue;
|
|
273
|
+
let current = tree;
|
|
274
|
+
for (let i = 0; i < parts.length; i++) {
|
|
275
|
+
const part = parts[i];
|
|
276
|
+
const isLast = i === parts.length - 1;
|
|
277
|
+
if (isLast) {
|
|
278
|
+
// 最后一个部分是项目名
|
|
279
|
+
current.set(part, { _isLeaf: true, _fullPath: projectPath });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// 中间部分是目录
|
|
283
|
+
if (!current.has(part)) {
|
|
284
|
+
current.set(part, new Map());
|
|
285
|
+
}
|
|
286
|
+
const nextLevel = current.get(part);
|
|
287
|
+
if (nextLevel instanceof Map) {
|
|
288
|
+
current = nextLevel;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return tree;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 打印项目树
|
|
297
|
+
*/
|
|
298
|
+
function printProjectTree(tree, prefix = '', isLast = true, validProjects = new Set(), invalidProjects = new Set()) {
|
|
299
|
+
const entries = Array.from(tree.entries());
|
|
300
|
+
for (let i = 0; i < entries.length; i++) {
|
|
301
|
+
const [name, value] = entries[i];
|
|
302
|
+
const isLastItem = i === entries.length - 1;
|
|
303
|
+
const connector = isLastItem ? '└── ' : '├── ';
|
|
304
|
+
const childPrefix = isLastItem ? ' ' : '│ ';
|
|
305
|
+
if (value && typeof value === 'object' && value._isLeaf) {
|
|
306
|
+
// 叶子节点(项目)
|
|
307
|
+
const isValid = validProjects.has(value._fullPath);
|
|
308
|
+
const isInvalid = invalidProjects.has(value._fullPath);
|
|
309
|
+
if (isValid) {
|
|
310
|
+
console.log(`${prefix}${connector}${name} ${chalk_1.default.green('✓')}`);
|
|
311
|
+
}
|
|
312
|
+
else if (isInvalid) {
|
|
313
|
+
console.log(`${prefix}${connector}${name} ${chalk_1.default.red('✗')}`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
console.log(`${prefix}${connector}${name}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else if (value instanceof Map) {
|
|
320
|
+
// 目录节点
|
|
321
|
+
console.log(`${prefix}${connector}${chalk_1.default.white(name)}`);
|
|
322
|
+
printProjectTree(value, prefix + childPrefix, isLastItem, validProjects, invalidProjects);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 格式化日期
|
|
328
|
+
*/
|
|
329
|
+
function formatDate(date) {
|
|
330
|
+
return date.toLocaleString('zh-CN', {
|
|
331
|
+
year: 'numeric',
|
|
332
|
+
month: '2-digit',
|
|
333
|
+
day: '2-digit',
|
|
334
|
+
hour: '2-digit',
|
|
335
|
+
minute: '2-digit',
|
|
336
|
+
second: '2-digit',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* 格式化相对时间(如:2小时前)
|
|
341
|
+
*/
|
|
342
|
+
function formatRelativeTime(timestamp) {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
const diff = now - timestamp;
|
|
345
|
+
const minute = 60 * 1000;
|
|
346
|
+
const hour = 60 * minute;
|
|
347
|
+
const day = 24 * hour;
|
|
348
|
+
if (diff < minute) {
|
|
349
|
+
return '刚刚';
|
|
350
|
+
}
|
|
351
|
+
else if (diff < hour) {
|
|
352
|
+
return `${Math.floor(diff / minute)}分钟前`;
|
|
353
|
+
}
|
|
354
|
+
else if (diff < day) {
|
|
355
|
+
return `${Math.floor(diff / hour)}小时前`;
|
|
356
|
+
}
|
|
357
|
+
else if (diff < 7 * day) {
|
|
358
|
+
return `${Math.floor(diff / day)}天前`;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
return formatDate(new Date(timestamp));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* 读取会话历史
|
|
366
|
+
*/
|
|
367
|
+
async function readSessionHistory() {
|
|
368
|
+
const historyPath = path.join(os.homedir(), '.claude', 'history.jsonl');
|
|
369
|
+
if (!await fs.pathExists(historyPath)) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const content = await fs.readFile(historyPath, 'utf-8');
|
|
373
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
374
|
+
const sessions = [];
|
|
375
|
+
for (const line of lines) {
|
|
376
|
+
try {
|
|
377
|
+
const record = JSON.parse(line);
|
|
378
|
+
if (record.project && record.sessionId) {
|
|
379
|
+
sessions.push({
|
|
380
|
+
display: record.display || '',
|
|
381
|
+
timestamp: record.timestamp || 0,
|
|
382
|
+
project: record.project,
|
|
383
|
+
sessionId: record.sessionId,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// 忽略解析失败的行
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return sessions;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 删除指定会话
|
|
395
|
+
* @param sessionIds 要删除的会话ID列表
|
|
396
|
+
* @returns 实际删除的会话数量
|
|
397
|
+
*/
|
|
398
|
+
async function deleteSessions(sessionIds) {
|
|
399
|
+
const historyPath = path.join(os.homedir(), '.claude', 'history.jsonl');
|
|
400
|
+
if (!await fs.pathExists(historyPath)) {
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
const content = await fs.readFile(historyPath, 'utf-8');
|
|
404
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
405
|
+
const remainingLines = [];
|
|
406
|
+
let deletedCount = 0;
|
|
407
|
+
for (const line of lines) {
|
|
408
|
+
try {
|
|
409
|
+
const record = JSON.parse(line);
|
|
410
|
+
if (record.sessionId && sessionIds.has(record.sessionId)) {
|
|
411
|
+
deletedCount++;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
remainingLines.push(line);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// 保留解析失败的行
|
|
419
|
+
remainingLines.push(line);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// 写回文件
|
|
423
|
+
await fs.writeFile(historyPath, remainingLines.join('\n') + (remainingLines.length > 0 ? '\n' : ''));
|
|
424
|
+
return deletedCount;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* 按项目分组会话
|
|
428
|
+
*/
|
|
429
|
+
function groupSessionsByProject(sessions) {
|
|
430
|
+
const groups = new Map();
|
|
431
|
+
for (const session of sessions) {
|
|
432
|
+
const list = groups.get(session.project) || [];
|
|
433
|
+
list.push(session);
|
|
434
|
+
groups.set(session.project, list);
|
|
435
|
+
}
|
|
436
|
+
// 对每个项目的会话按时间倒序排列
|
|
437
|
+
for (const [projectPath, list] of groups) {
|
|
438
|
+
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
439
|
+
groups.set(projectPath, list);
|
|
440
|
+
}
|
|
441
|
+
return groups;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* 截断文本到指定长度
|
|
445
|
+
*/
|
|
446
|
+
function truncateText(text, maxLength) {
|
|
447
|
+
if (text.length <= maxLength) {
|
|
448
|
+
return text;
|
|
449
|
+
}
|
|
450
|
+
return text.substring(0, maxLength - 3) + '...';
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 获取项目的 resume 信息
|
|
454
|
+
* 扫描 ~/.claude/projects/<project-path>/<session-id>.jsonl 文件
|
|
455
|
+
*/
|
|
456
|
+
async function getProjectResumeInfo(projectPath) {
|
|
457
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
458
|
+
// 将项目路径转换为目录名(Claude Code 使用的格式)
|
|
459
|
+
// 例如: /Users/lrk/workspace/project -> -Users-lrk-workspace-project
|
|
460
|
+
const normalizedPath = projectPath.replace(/\//g, '-');
|
|
461
|
+
const projectDir = path.join(projectsDir, normalizedPath);
|
|
462
|
+
if (!await fs.pathExists(projectDir)) {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
const entries = await fs.readdir(projectDir, { withFileTypes: true });
|
|
466
|
+
const resumeInfos = [];
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
// 只处理 .jsonl 文件
|
|
469
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const sessionId = entry.name.replace('.jsonl', '');
|
|
473
|
+
const filePath = path.join(projectDir, entry.name);
|
|
474
|
+
const stats = await fs.stat(filePath);
|
|
475
|
+
// 读取文件内容统计信息
|
|
476
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
477
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
478
|
+
let messageCount = 0;
|
|
479
|
+
let compactCount = 0;
|
|
480
|
+
let lastActivity = stats.mtime;
|
|
481
|
+
for (const line of lines) {
|
|
482
|
+
try {
|
|
483
|
+
const record = JSON.parse(line);
|
|
484
|
+
messageCount++;
|
|
485
|
+
// 检测 compact_boundary 记录
|
|
486
|
+
if (record.subtype === 'compact_boundary' || record.type === 'compact_boundary') {
|
|
487
|
+
compactCount++;
|
|
488
|
+
}
|
|
489
|
+
// 获取最后活动时间
|
|
490
|
+
if (record.timestamp) {
|
|
491
|
+
const recordTime = new Date(record.timestamp);
|
|
492
|
+
if (recordTime > lastActivity) {
|
|
493
|
+
lastActivity = recordTime;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// 忽略解析失败的行
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
resumeInfos.push({
|
|
502
|
+
sessionId,
|
|
503
|
+
createdAt: stats.birthtime || stats.ctime,
|
|
504
|
+
messageCount,
|
|
505
|
+
compactCount,
|
|
506
|
+
lastActivity,
|
|
507
|
+
fileSize: stats.size,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// 按最后活动时间倒序排列
|
|
511
|
+
return resumeInfos.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* 扫描所有孤立的 resume 目录(不在 config 中的项目)
|
|
515
|
+
* @param configProjects config 中的项目路径集合
|
|
516
|
+
* @returns 孤立的 resume 信息列表
|
|
517
|
+
*/
|
|
518
|
+
async function getOrphanedResumes(configProjects) {
|
|
519
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
520
|
+
if (!await fs.pathExists(projectsDir)) {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
// 将 config 中的项目路径转换为 normalized 形式
|
|
524
|
+
const normalizedConfigProjects = new Set();
|
|
525
|
+
for (const projectPath of configProjects) {
|
|
526
|
+
normalizedConfigProjects.add(projectPath.replace(/\//g, '-'));
|
|
527
|
+
}
|
|
528
|
+
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
529
|
+
const orphanedResumes = [];
|
|
530
|
+
for (const entry of entries) {
|
|
531
|
+
if (!entry.isDirectory()) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const normalizedPath = entry.name;
|
|
535
|
+
// 如果这个 normalized path 不在 config 中,则是孤立的
|
|
536
|
+
if (!normalizedConfigProjects.has(normalizedPath)) {
|
|
537
|
+
const projectDir = path.join(projectsDir, normalizedPath);
|
|
538
|
+
const resumeInfos = await scanResumesInDir(projectDir);
|
|
539
|
+
if (resumeInfos.length > 0) {
|
|
540
|
+
orphanedResumes.push({
|
|
541
|
+
normalizedPath,
|
|
542
|
+
resumes: resumeInfos,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return orphanedResumes;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 扫描指定目录中的 resume 文件
|
|
551
|
+
*/
|
|
552
|
+
async function scanResumesInDir(dirPath) {
|
|
553
|
+
if (!await fs.pathExists(dirPath)) {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
557
|
+
const resumeInfos = [];
|
|
558
|
+
for (const entry of entries) {
|
|
559
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const sessionId = entry.name.replace('.jsonl', '');
|
|
563
|
+
const filePath = path.join(dirPath, entry.name);
|
|
564
|
+
const stats = await fs.stat(filePath);
|
|
565
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
566
|
+
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
567
|
+
let messageCount = 0;
|
|
568
|
+
let compactCount = 0;
|
|
569
|
+
let lastActivity = stats.mtime;
|
|
570
|
+
for (const line of lines) {
|
|
571
|
+
try {
|
|
572
|
+
const record = JSON.parse(line);
|
|
573
|
+
messageCount++;
|
|
574
|
+
// 检测 compact_boundary 记录
|
|
575
|
+
if (record.subtype === 'compact_boundary' || record.type === 'compact_boundary') {
|
|
576
|
+
compactCount++;
|
|
577
|
+
}
|
|
578
|
+
// 获取最后活动时间
|
|
579
|
+
if (record.timestamp) {
|
|
580
|
+
const recordTime = new Date(record.timestamp);
|
|
581
|
+
if (recordTime > lastActivity) {
|
|
582
|
+
lastActivity = recordTime;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// 忽略解析失败的行
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
resumeInfos.push({
|
|
591
|
+
sessionId,
|
|
592
|
+
createdAt: stats.birthtime,
|
|
593
|
+
lastActivity,
|
|
594
|
+
messageCount,
|
|
595
|
+
compactCount,
|
|
596
|
+
fileSize: stats.size,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return resumeInfos.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* 删除指定 resume 会话文件
|
|
603
|
+
* @param projectPath 项目路径
|
|
604
|
+
* @param sessionIds 要删除的会话ID列表
|
|
605
|
+
* @returns 实际删除的文件数量
|
|
606
|
+
*/
|
|
607
|
+
async function deleteProjectResumes(projectPath, sessionIds) {
|
|
608
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
609
|
+
const normalizedPath = projectPath.replace(/\//g, '-');
|
|
610
|
+
const projectDir = path.join(projectsDir, normalizedPath);
|
|
611
|
+
if (!await fs.pathExists(projectDir)) {
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
let deletedCount = 0;
|
|
615
|
+
for (const sessionId of sessionIds) {
|
|
616
|
+
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
617
|
+
if (await fs.pathExists(filePath)) {
|
|
618
|
+
await fs.remove(filePath);
|
|
619
|
+
deletedCount++;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// 如果目录为空,删除目录
|
|
623
|
+
const remainingFiles = await fs.readdir(projectDir);
|
|
624
|
+
if (remainingFiles.length === 0) {
|
|
625
|
+
await fs.remove(projectDir);
|
|
626
|
+
}
|
|
627
|
+
return deletedCount;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* 获取所有 MCP 服务器信息
|
|
631
|
+
*/
|
|
632
|
+
function getAllMcpServers(config) {
|
|
633
|
+
const mcpServers = [];
|
|
634
|
+
// 全局 MCP 服务器
|
|
635
|
+
if (config.mcpServers) {
|
|
636
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
637
|
+
mcpServers.push({
|
|
638
|
+
name,
|
|
639
|
+
config: serverConfig,
|
|
640
|
+
scope: 'global',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// 项目级 MCP 服务器
|
|
645
|
+
for (const [projectPath, projectConfig] of Object.entries(config.projects)) {
|
|
646
|
+
if (projectConfig.mcpServers) {
|
|
647
|
+
for (const [name, serverConfig] of Object.entries(projectConfig.mcpServers)) {
|
|
648
|
+
mcpServers.push({
|
|
649
|
+
name,
|
|
650
|
+
config: serverConfig,
|
|
651
|
+
scope: 'project',
|
|
652
|
+
projectPath,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return mcpServers;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* 按范围分组 MCP 服务器
|
|
661
|
+
*/
|
|
662
|
+
function groupMcpServersByScope(mcpServers) {
|
|
663
|
+
const global = [];
|
|
664
|
+
const project = new Map();
|
|
665
|
+
for (const server of mcpServers) {
|
|
666
|
+
if (server.scope === 'global') {
|
|
667
|
+
global.push(server);
|
|
668
|
+
}
|
|
669
|
+
else if (server.scope === 'project' && server.projectPath) {
|
|
670
|
+
const list = project.get(server.projectPath) || [];
|
|
671
|
+
list.push(server);
|
|
672
|
+
project.set(server.projectPath, list);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { global, project };
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* 计算全局统计信息
|
|
679
|
+
*/
|
|
680
|
+
async function calculateGlobalStats(config) {
|
|
681
|
+
// 获取配置中的所有项目路径
|
|
682
|
+
const configProjects = new Set(Object.keys(config.projects));
|
|
683
|
+
// 会话历史统计
|
|
684
|
+
const sessions = await readSessionHistory();
|
|
685
|
+
const totalSessionHistory = sessions.length;
|
|
686
|
+
// 按项目状态分类统计会话历史
|
|
687
|
+
const sessionHistoryByCategory = {
|
|
688
|
+
valid: 0,
|
|
689
|
+
invalid: 0,
|
|
690
|
+
orphaned: 0,
|
|
691
|
+
};
|
|
692
|
+
for (const session of sessions) {
|
|
693
|
+
const projectPath = session.project;
|
|
694
|
+
if (!projectPath) {
|
|
695
|
+
sessionHistoryByCategory.orphaned++;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (configProjects.has(projectPath)) {
|
|
699
|
+
// 项目在配置中,需要检查路径是否有效
|
|
700
|
+
const expandedPath = projectPath.replace(/^~/, os.homedir());
|
|
701
|
+
if (await fs.pathExists(expandedPath)) {
|
|
702
|
+
sessionHistoryByCategory.valid++;
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
sessionHistoryByCategory.invalid++;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
// 项目不在配置中(孤立会话)
|
|
710
|
+
sessionHistoryByCategory.orphaned++;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Resume 会话统计
|
|
714
|
+
let totalResumeSessions = 0;
|
|
715
|
+
const resumeSessionsByCategory = {
|
|
716
|
+
valid: 0,
|
|
717
|
+
invalid: 0,
|
|
718
|
+
orphaned: 0,
|
|
719
|
+
};
|
|
720
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
721
|
+
if (await fs.pathExists(projectsDir)) {
|
|
722
|
+
const projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
723
|
+
for (const dir of projectDirs) {
|
|
724
|
+
if (dir.isDirectory()) {
|
|
725
|
+
// 将目录名还原为项目路径(Claude 使用 - 替换 /)
|
|
726
|
+
// 例如: -Users-lrk-workspace-project -> /Users/lrk/workspace/project
|
|
727
|
+
const normalizedPath = dir.name.replace(/-/g, '/');
|
|
728
|
+
// 检查是否是有效项目
|
|
729
|
+
let category;
|
|
730
|
+
if (configProjects.has(normalizedPath)) {
|
|
731
|
+
if (await fs.pathExists(normalizedPath)) {
|
|
732
|
+
category = 'valid';
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
category = 'invalid';
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
category = 'orphaned';
|
|
740
|
+
}
|
|
741
|
+
const projectDir = path.join(projectsDir, dir.name);
|
|
742
|
+
const files = await fs.readdir(projectDir);
|
|
743
|
+
const resumeCount = files.filter(f => f.endsWith('.jsonl')).length;
|
|
744
|
+
totalResumeSessions += resumeCount;
|
|
745
|
+
resumeSessionsByCategory[category] += resumeCount;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// 费用统计(按项目状态分类)
|
|
750
|
+
let totalCost = 0;
|
|
751
|
+
let validCost = 0;
|
|
752
|
+
let invalidCost = 0;
|
|
753
|
+
let totalInputTokens = 0;
|
|
754
|
+
let totalOutputTokens = 0;
|
|
755
|
+
let totalCacheCreationTokens = 0;
|
|
756
|
+
let totalCacheReadTokens = 0;
|
|
757
|
+
let lastActivity;
|
|
758
|
+
for (const [projectPath, projectConfig] of Object.entries(config.projects)) {
|
|
759
|
+
const cost = projectConfig.lastCost || 0;
|
|
760
|
+
totalCost += cost;
|
|
761
|
+
// 判断项目状态并累加到相应分类
|
|
762
|
+
const expandedPath = projectPath.replace(/^~/, os.homedir());
|
|
763
|
+
if (await fs.pathExists(expandedPath)) {
|
|
764
|
+
validCost += cost;
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
invalidCost += cost;
|
|
768
|
+
}
|
|
769
|
+
totalInputTokens += projectConfig.lastTotalInputTokens || 0;
|
|
770
|
+
totalOutputTokens += projectConfig.lastTotalOutputTokens || 0;
|
|
771
|
+
totalCacheCreationTokens += projectConfig.lastTotalCacheCreationInputTokens || 0;
|
|
772
|
+
totalCacheReadTokens += projectConfig.lastTotalCacheReadInputTokens || 0;
|
|
773
|
+
// 获取最后活动时间
|
|
774
|
+
const metrics = projectConfig.lastSessionMetrics;
|
|
775
|
+
if (metrics?.timestamp) {
|
|
776
|
+
const activityTime = new Date(metrics.timestamp);
|
|
777
|
+
if (!lastActivity || activityTime > lastActivity) {
|
|
778
|
+
lastActivity = activityTime;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const projectCount = Object.keys(config.projects).length;
|
|
783
|
+
// 工具使用统计
|
|
784
|
+
const toolUsage = config.toolUsage || {};
|
|
785
|
+
const totalToolUsage = Object.values(toolUsage).reduce((sum, usage) => sum + (usage.usageCount || 0), 0);
|
|
786
|
+
const toolDetails = Object.entries(toolUsage)
|
|
787
|
+
.map(([name, usage]) => ({
|
|
788
|
+
name,
|
|
789
|
+
count: usage.usageCount || 0,
|
|
790
|
+
lastUsedAt: usage.lastUsedAt || 0,
|
|
791
|
+
}))
|
|
792
|
+
.sort((a, b) => b.count - a.count);
|
|
793
|
+
const skillUsage = config.skillUsage || {};
|
|
794
|
+
const totalSkillUsage = Object.values(skillUsage).reduce((sum, usage) => sum + (usage.usageCount || 0), 0);
|
|
795
|
+
const skillDetails = Object.entries(skillUsage)
|
|
796
|
+
.map(([name, usage]) => ({
|
|
797
|
+
name,
|
|
798
|
+
count: usage.usageCount || 0,
|
|
799
|
+
lastUsedAt: usage.lastUsedAt || 0,
|
|
800
|
+
}))
|
|
801
|
+
.sort((a, b) => b.count - a.count);
|
|
802
|
+
// 时间统计
|
|
803
|
+
let usageDays = 0;
|
|
804
|
+
if (config.firstStartTime) {
|
|
805
|
+
const firstStart = new Date(config.firstStartTime);
|
|
806
|
+
const now = new Date();
|
|
807
|
+
usageDays = Math.floor((now.getTime() - firstStart.getTime()) / (1000 * 60 * 60 * 24));
|
|
808
|
+
}
|
|
809
|
+
// GitHub 统计
|
|
810
|
+
const totalGithubRepos = Object.values(config.githubRepoPaths || {}).reduce((sum, repos) => sum + repos.length, 0);
|
|
811
|
+
// Companion 信息
|
|
812
|
+
let companionInfo;
|
|
813
|
+
if (config.companion) {
|
|
814
|
+
companionInfo = {
|
|
815
|
+
name: config.companion.name,
|
|
816
|
+
personality: config.companion.personality,
|
|
817
|
+
hatchedAt: new Date(config.companion.hatchedAt),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
totalSessionHistory,
|
|
822
|
+
sessionHistoryByCategory,
|
|
823
|
+
totalResumeSessions,
|
|
824
|
+
resumeSessionsByCategory,
|
|
825
|
+
totalCost,
|
|
826
|
+
avgCostPerProject: projectCount > 0 ? totalCost / projectCount : 0,
|
|
827
|
+
costByCategory: {
|
|
828
|
+
valid: validCost,
|
|
829
|
+
invalid: invalidCost,
|
|
830
|
+
},
|
|
831
|
+
totalInputTokens,
|
|
832
|
+
totalOutputTokens,
|
|
833
|
+
totalCacheCreationTokens,
|
|
834
|
+
totalCacheReadTokens,
|
|
835
|
+
totalToolUsage,
|
|
836
|
+
totalSkillUsage,
|
|
837
|
+
toolDetails,
|
|
838
|
+
skillDetails,
|
|
839
|
+
usageDays,
|
|
840
|
+
lastActivity,
|
|
841
|
+
totalGithubRepos,
|
|
842
|
+
companionInfo,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* 获取各类缓存信息
|
|
847
|
+
*/
|
|
848
|
+
async function getCacheInfo() {
|
|
849
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
850
|
+
const caches = [];
|
|
851
|
+
// 1. 统计缓存 (stats-cache.json)
|
|
852
|
+
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
|
|
853
|
+
if (await fs.pathExists(statsCachePath)) {
|
|
854
|
+
const stat = await fs.stat(statsCachePath);
|
|
855
|
+
const content = await fs.readFile(statsCachePath, 'utf-8');
|
|
856
|
+
const data = JSON.parse(content);
|
|
857
|
+
caches.push({
|
|
858
|
+
name: 'stats-cache',
|
|
859
|
+
description: '使用统计缓存(每日活动、Token 消耗、会话统计等)',
|
|
860
|
+
path: statsCachePath,
|
|
861
|
+
fileCount: 1,
|
|
862
|
+
totalSize: stat.size,
|
|
863
|
+
lastModified: stat.mtime,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
// 2. 文件历史缓存 (file-history/)
|
|
867
|
+
const fileHistoryPath = path.join(claudeDir, 'file-history');
|
|
868
|
+
if (await fs.pathExists(fileHistoryPath)) {
|
|
869
|
+
const { fileCount, totalSize, lastModified } = await getDirectorySize(fileHistoryPath);
|
|
870
|
+
caches.push({
|
|
871
|
+
name: 'file-history',
|
|
872
|
+
description: '文件修改历史版本缓存',
|
|
873
|
+
path: fileHistoryPath,
|
|
874
|
+
fileCount,
|
|
875
|
+
totalSize,
|
|
876
|
+
lastModified,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// 3. 粘贴缓存 (paste-cache/)
|
|
880
|
+
const pasteCachePath = path.join(claudeDir, 'paste-cache');
|
|
881
|
+
if (await fs.pathExists(pasteCachePath)) {
|
|
882
|
+
const { fileCount, totalSize, lastModified } = await getDirectorySize(pasteCachePath);
|
|
883
|
+
caches.push({
|
|
884
|
+
name: 'paste-cache',
|
|
885
|
+
description: '粘贴内容临时缓存',
|
|
886
|
+
path: pasteCachePath,
|
|
887
|
+
fileCount,
|
|
888
|
+
totalSize,
|
|
889
|
+
lastModified,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
// 4. Shell 快照缓存 (shell-snapshots/)
|
|
893
|
+
const shellSnapshotsPath = path.join(claudeDir, 'shell-snapshots');
|
|
894
|
+
if (await fs.pathExists(shellSnapshotsPath)) {
|
|
895
|
+
const { fileCount, totalSize, lastModified } = await getDirectorySize(shellSnapshotsPath);
|
|
896
|
+
caches.push({
|
|
897
|
+
name: 'shell-snapshots',
|
|
898
|
+
description: 'Shell 环境快照缓存',
|
|
899
|
+
path: shellSnapshotsPath,
|
|
900
|
+
fileCount,
|
|
901
|
+
totalSize,
|
|
902
|
+
lastModified,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
// 5. 通用缓存目录 (cache/)
|
|
906
|
+
const cacheDirPath = path.join(claudeDir, 'cache');
|
|
907
|
+
if (await fs.pathExists(cacheDirPath)) {
|
|
908
|
+
const { fileCount, totalSize, lastModified } = await getDirectorySize(cacheDirPath);
|
|
909
|
+
caches.push({
|
|
910
|
+
name: 'cache',
|
|
911
|
+
description: '通用缓存目录(更新日志等)',
|
|
912
|
+
path: cacheDirPath,
|
|
913
|
+
fileCount,
|
|
914
|
+
totalSize,
|
|
915
|
+
lastModified,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
// 6. 会话环境缓存 (session-env/)
|
|
919
|
+
const sessionEnvPath = path.join(claudeDir, 'session-env');
|
|
920
|
+
if (await fs.pathExists(sessionEnvPath)) {
|
|
921
|
+
const { fileCount, totalSize, lastModified } = await getDirectorySize(sessionEnvPath);
|
|
922
|
+
caches.push({
|
|
923
|
+
name: 'session-env',
|
|
924
|
+
description: '会话环境变量缓存',
|
|
925
|
+
path: sessionEnvPath,
|
|
926
|
+
fileCount,
|
|
927
|
+
totalSize,
|
|
928
|
+
lastModified,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return caches;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* 获取目录的总大小和文件数
|
|
935
|
+
*/
|
|
936
|
+
async function getDirectorySize(dirPath) {
|
|
937
|
+
let fileCount = 0;
|
|
938
|
+
let totalSize = 0;
|
|
939
|
+
let lastModified;
|
|
940
|
+
async function walkDir(dir) {
|
|
941
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
942
|
+
for (const entry of entries) {
|
|
943
|
+
const fullPath = path.join(dir, entry.name);
|
|
944
|
+
if (entry.isDirectory()) {
|
|
945
|
+
await walkDir(fullPath);
|
|
946
|
+
}
|
|
947
|
+
else if (entry.isFile()) {
|
|
948
|
+
fileCount++;
|
|
949
|
+
const stat = await fs.stat(fullPath);
|
|
950
|
+
totalSize += stat.size;
|
|
951
|
+
if (!lastModified || stat.mtime > lastModified) {
|
|
952
|
+
lastModified = stat.mtime;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
await walkDir(dirPath);
|
|
958
|
+
return { fileCount, totalSize, lastModified };
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* 获取 API Prompt 缓存统计
|
|
962
|
+
*/
|
|
963
|
+
async function getPromptCacheStats() {
|
|
964
|
+
const statsCachePath = path.join(os.homedir(), '.claude', 'stats-cache.json');
|
|
965
|
+
if (!await fs.pathExists(statsCachePath)) {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
const content = await fs.readFile(statsCachePath, 'utf-8');
|
|
970
|
+
const data = JSON.parse(content);
|
|
971
|
+
if (!data.modelUsage) {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
// 处理模型名称中的特殊字符
|
|
975
|
+
const modelUsage = {};
|
|
976
|
+
for (const [modelKey, usage] of Object.entries(data.modelUsage)) {
|
|
977
|
+
const key = modelKey.replace(/@@/g, '.'); // Claude 可能用 @@ 替代 .
|
|
978
|
+
modelUsage[key] = {
|
|
979
|
+
inputTokens: usage.inputTokens || 0,
|
|
980
|
+
outputTokens: usage.outputTokens || 0,
|
|
981
|
+
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
|
|
982
|
+
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
|
|
983
|
+
costUSD: usage.costUSD || 0,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
// 获取缓存读取和创建的总数
|
|
987
|
+
let cacheReadInputTokens = 0;
|
|
988
|
+
let cacheCreationInputTokens = 0;
|
|
989
|
+
for (const usage of Object.values(modelUsage)) {
|
|
990
|
+
cacheReadInputTokens += usage.cacheReadInputTokens;
|
|
991
|
+
cacheCreationInputTokens += usage.cacheCreationInputTokens;
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
cacheReadInputTokens,
|
|
995
|
+
cacheCreationInputTokens,
|
|
996
|
+
modelUsage,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const SECRET_PATTERNS = [
|
|
1004
|
+
{
|
|
1005
|
+
name: 'Anthropic API Key',
|
|
1006
|
+
description: 'Anthropic/Claude API 密钥',
|
|
1007
|
+
patterns: [
|
|
1008
|
+
/sk-(?:sp-)?[a-zA-Z0-9]{20,}/,
|
|
1009
|
+
/ANTHROPIC_AUTH_TOKEN["']?\s*[:=]\s*["']?(sk-[^"'$\s]+)/i,
|
|
1010
|
+
],
|
|
1011
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt'],
|
|
1012
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
name: 'OpenAI API Key',
|
|
1016
|
+
description: 'OpenAI API 密钥',
|
|
1017
|
+
patterns: [
|
|
1018
|
+
/sk-[a-zA-Z0-9]{20,}/,
|
|
1019
|
+
/OPENAI_API_KEY["']?\s*[:=]\s*["']?(sk-[^"'$\s]+)/i,
|
|
1020
|
+
],
|
|
1021
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt'],
|
|
1022
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
name: 'GitHub Token',
|
|
1026
|
+
description: 'GitHub 访问令牌',
|
|
1027
|
+
patterns: [
|
|
1028
|
+
/ghp_[a-zA-Z0-9]{36}/,
|
|
1029
|
+
/gho_[a-zA-Z0-9]{36}/,
|
|
1030
|
+
/ghu_[a-zA-Z0-9]{36}/,
|
|
1031
|
+
/ghs_[a-zA-Z0-9]{36}/,
|
|
1032
|
+
/ghr_[a-zA-Z0-9]{36}/,
|
|
1033
|
+
/github_pat_[a-zA-Z0-9_]{22,}/,
|
|
1034
|
+
/GITHUB_TOKEN["']?\s*[:=]\s*["']?(ghp_[^"'$\s]+)/i,
|
|
1035
|
+
],
|
|
1036
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt', '.sh'],
|
|
1037
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
name: 'AWS Access Key',
|
|
1041
|
+
description: 'AWS 访问密钥',
|
|
1042
|
+
patterns: [
|
|
1043
|
+
/AKIA[0-9A-Z]{16}/,
|
|
1044
|
+
/aws_access_key_id["']?\s*[:=]\s*["']?(AKIA[^"'$\s]+)/i,
|
|
1045
|
+
],
|
|
1046
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt'],
|
|
1047
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
name: 'AWS Secret Key',
|
|
1051
|
+
description: 'AWS 秘密密钥',
|
|
1052
|
+
patterns: [
|
|
1053
|
+
/aws_secret_access_key["']?\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})/i,
|
|
1054
|
+
],
|
|
1055
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt'],
|
|
1056
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
name: 'Bearer Token',
|
|
1060
|
+
description: 'Bearer 认证令牌(包含 JWT Token)',
|
|
1061
|
+
patterns: [
|
|
1062
|
+
// Bearer Token 格式
|
|
1063
|
+
/Bearer\s+[a-zA-Z0-9_\-\.]+/i,
|
|
1064
|
+
/Authorization["']?\s*[:=]\s*["']?Bearer\s+([a-zA-Z0-9_\-\.]+)/i,
|
|
1065
|
+
// JWT Token 格式 (eyJ...eyJ...xxx)
|
|
1066
|
+
/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/,
|
|
1067
|
+
/JWT["']?\s*[:=]\s*["']?(eyJ[^"'$\s]+)/i,
|
|
1068
|
+
],
|
|
1069
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.txt', '.jsonl'],
|
|
1070
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
name: 'Database URL',
|
|
1074
|
+
description: '数据库连接字符串',
|
|
1075
|
+
patterns: [
|
|
1076
|
+
// 移除了 mysql,避免误报 MCP 资源描述中的 mysql:// 前缀
|
|
1077
|
+
/(?:mongodb(?:\+srv)?|postgres(?:ql)?|redis):\/\/[^\s"']+/i,
|
|
1078
|
+
/(?:DATABASE_URL|DB_CONNECTION|MONGO_URI|POSTGRES_URL|REDIS_URL)["']?\s*[:=]\s*["']?([^\s"']+)/i,
|
|
1079
|
+
],
|
|
1080
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt'],
|
|
1081
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
name: 'Slack Token',
|
|
1085
|
+
description: 'Slack 访问令牌',
|
|
1086
|
+
patterns: [
|
|
1087
|
+
/xox[baprs]-[0-9a-zA-Z-]+/,
|
|
1088
|
+
/SLACK_TOKEN["']?\s*[:=]\s*["']?(xox[^"'$\s]+)/i,
|
|
1089
|
+
],
|
|
1090
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.txt'],
|
|
1091
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
name: 'Stripe API Key',
|
|
1095
|
+
description: 'Stripe 支付密钥',
|
|
1096
|
+
patterns: [
|
|
1097
|
+
/sk_live_[a-zA-Z0-9]{24,}/,
|
|
1098
|
+
/pk_live_[a-zA-Z0-9]{24,}/,
|
|
1099
|
+
/STRIPE_SECRET_KEY["']?\s*[:=]\s*["']?(sk_live[^"'$\s]+)/i,
|
|
1100
|
+
],
|
|
1101
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.txt'],
|
|
1102
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
name: 'API Key',
|
|
1106
|
+
description: '通用 API 密钥(包含 API_KEY、APIKEY 等格式)',
|
|
1107
|
+
patterns: [
|
|
1108
|
+
// 匹配 API_KEY 或 APIKEY 结尾的键名,后面跟着等号和值
|
|
1109
|
+
// 支持: MY_API_KEY="value", "MY_APIKEY": "value", API_KEY=value
|
|
1110
|
+
/[A-Za-z0-9_]*(?:API_KEY|APIKEY)["']?\s*[:=]\s*["']?([A-Za-z0-9_\-]+)/gi,
|
|
1111
|
+
],
|
|
1112
|
+
allowedExtensions: ['.json', '.jsonc', '.env', '.yaml', '.yml', '.toml', '.txt', '.jsonl'],
|
|
1113
|
+
excludePatterns: ['node_modules', '.git', 'dist', 'build'],
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
name: 'Private Key',
|
|
1117
|
+
description: '私钥文件',
|
|
1118
|
+
patterns: [
|
|
1119
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
1120
|
+
],
|
|
1121
|
+
allowedExtensions: ['.pem', '.key', '.pub', '.txt'],
|
|
1122
|
+
excludePatterns: ['.git'],
|
|
1123
|
+
},
|
|
1124
|
+
];
|
|
1125
|
+
/**
|
|
1126
|
+
* 模糊化密钥
|
|
1127
|
+
*/
|
|
1128
|
+
function maskSecret(secret, type) {
|
|
1129
|
+
if (secret.length <= 8) {
|
|
1130
|
+
return '*'.repeat(secret.length);
|
|
1131
|
+
}
|
|
1132
|
+
if (type === 'Private Key') {
|
|
1133
|
+
return '-----BEGIN PRIVATE KEY-----\n [密钥内容已隐藏]\n-----END PRIVATE KEY-----';
|
|
1134
|
+
}
|
|
1135
|
+
// 保留前4位和后4位,中间模糊
|
|
1136
|
+
const prefix = secret.substring(0, 4);
|
|
1137
|
+
const suffix = secret.substring(secret.length - 4);
|
|
1138
|
+
const masked = '*'.repeat(Math.min(secret.length - 8, 20));
|
|
1139
|
+
return `${prefix}${masked}${suffix}`;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* 扫描缓存目录中的密钥
|
|
1143
|
+
*/
|
|
1144
|
+
async function scanCacheSecrets() {
|
|
1145
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
1146
|
+
const findings = [];
|
|
1147
|
+
// 需要扫描的目录/文件
|
|
1148
|
+
const scanTargets = [
|
|
1149
|
+
// 全局设置
|
|
1150
|
+
{ path: path.join(claudeDir, 'settings.json'), isDir: false },
|
|
1151
|
+
{ path: path.join(claudeDir, 'settings.json.bak'), isDir: false },
|
|
1152
|
+
// 项目会话
|
|
1153
|
+
{ path: path.join(claudeDir, 'projects'), isDir: true },
|
|
1154
|
+
// 历史记录
|
|
1155
|
+
{ path: path.join(claudeDir, 'history.jsonl'), isDir: false },
|
|
1156
|
+
// 会话环境
|
|
1157
|
+
{ path: path.join(claudeDir, 'session-env'), isDir: true },
|
|
1158
|
+
];
|
|
1159
|
+
for (const target of scanTargets) {
|
|
1160
|
+
if (target.isDir) {
|
|
1161
|
+
await scanDirectoryForSecrets(target.path, findings);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
if (await fs.pathExists(target.path)) {
|
|
1165
|
+
await scanFileForSecrets(target.path, findings);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// 去除重复项(基于路径、类型、行号和匹配内容)
|
|
1170
|
+
const uniqueFindings = new Map();
|
|
1171
|
+
for (const finding of findings) {
|
|
1172
|
+
const key = `${finding.path}:${finding.type}:${finding.line}:${finding.context}`;
|
|
1173
|
+
if (!uniqueFindings.has(key)) {
|
|
1174
|
+
uniqueFindings.set(key, finding);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return Array.from(uniqueFindings.values());
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* 扫描目录中的密钥
|
|
1181
|
+
*/
|
|
1182
|
+
async function scanDirectoryForSecrets(dirPath, findings) {
|
|
1183
|
+
if (!await fs.pathExists(dirPath)) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
async function walkDir(dir, depth = 0) {
|
|
1187
|
+
if (depth > 5)
|
|
1188
|
+
return; // 限制递归深度
|
|
1189
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1190
|
+
for (const entry of entries) {
|
|
1191
|
+
const fullPath = path.join(dir, entry.name);
|
|
1192
|
+
// 跳过特定目录
|
|
1193
|
+
if (entry.isDirectory()) {
|
|
1194
|
+
if (entry.name === 'node_modules' || entry.name === '.git') {
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
await walkDir(fullPath, depth + 1);
|
|
1198
|
+
}
|
|
1199
|
+
else if (entry.isFile()) {
|
|
1200
|
+
// 检查文件扩展名
|
|
1201
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1202
|
+
// 跳过二进制文件和日志
|
|
1203
|
+
if (['.log', '.bin', '.dat', '.sqlite', '.db'].includes(ext)) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
// 跳过过大的文件(> 10MB)
|
|
1207
|
+
const stat = await fs.stat(fullPath);
|
|
1208
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
await scanFileForSecrets(fullPath, findings);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
await walkDir(dirPath);
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* 扫描单个文件中的密钥
|
|
1219
|
+
*/
|
|
1220
|
+
async function scanFileForSecrets(filePath, findings) {
|
|
1221
|
+
// 检查是否应该扫描此文件
|
|
1222
|
+
const fileName = path.basename(filePath);
|
|
1223
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1224
|
+
// 检查每个模式
|
|
1225
|
+
for (const patternDef of SECRET_PATTERNS) {
|
|
1226
|
+
// 检查扩展名
|
|
1227
|
+
if (patternDef.allowedExtensions.length > 0 &&
|
|
1228
|
+
!patternDef.allowedExtensions.includes(ext) &&
|
|
1229
|
+
!patternDef.allowedExtensions.includes('*')) {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
// 检查排除模式
|
|
1233
|
+
const shouldExclude = patternDef.excludePatterns.some(exc => filePath.includes(`/node_modules/`) ||
|
|
1234
|
+
filePath.includes(`/${exc}/`) ||
|
|
1235
|
+
fileName === exc);
|
|
1236
|
+
if (shouldExclude) {
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1241
|
+
// 对于 JSON 文件,尝试解析并只扫描值
|
|
1242
|
+
if (ext === '.json' || ext === '.jsonc') {
|
|
1243
|
+
await scanJsonContent(filePath, content, patternDef, findings);
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
await scanTextContent(filePath, content, patternDef, findings);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
// 跳过无法读取的文件(二进制文件等)
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 扫描 JSON 内容
|
|
1256
|
+
*/
|
|
1257
|
+
async function scanJsonContent(filePath, content, patternDef, findings) {
|
|
1258
|
+
try {
|
|
1259
|
+
// 简化处理:将 JSON 转换为行以便逐行扫描
|
|
1260
|
+
const lines = content.split('\n');
|
|
1261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1262
|
+
const line = lines[i];
|
|
1263
|
+
for (const pattern of patternDef.patterns) {
|
|
1264
|
+
const matches = line.matchAll(new RegExp(pattern, 'gi'));
|
|
1265
|
+
for (const match of matches) {
|
|
1266
|
+
// 提取密钥(从匹配组或完整匹配)
|
|
1267
|
+
let secret = match[1] || match[0];
|
|
1268
|
+
// 验证是否真的是密钥(排除误报)
|
|
1269
|
+
// Bearer Token、Authorization 等模式需要特殊处理,因为捕获组可能只包含 token 部分
|
|
1270
|
+
const isAuthPattern = patternDef.name === 'Bearer Token' ||
|
|
1271
|
+
patternDef.name === 'JWT Token';
|
|
1272
|
+
const minLength = isAuthPattern ? 4 : 10;
|
|
1273
|
+
if (secret.length < minLength)
|
|
1274
|
+
continue;
|
|
1275
|
+
if (secret.includes('$') || secret.includes('${'))
|
|
1276
|
+
continue;
|
|
1277
|
+
// 提取上下文(周围 100 个字符)
|
|
1278
|
+
const start = Math.max(0, match.index - 50);
|
|
1279
|
+
const end = Math.min(line.length, match.index + secret.length + 50);
|
|
1280
|
+
const context = line.substring(start, end);
|
|
1281
|
+
// 计算完整匹配内容
|
|
1282
|
+
const fullMatch = match[0];
|
|
1283
|
+
findings.push({
|
|
1284
|
+
type: patternDef.name,
|
|
1285
|
+
description: patternDef.description,
|
|
1286
|
+
path: filePath,
|
|
1287
|
+
line: i + 1,
|
|
1288
|
+
context: context.replace(/\s+/g, ' ').trim(),
|
|
1289
|
+
masked: maskSecret(secret, patternDef.name),
|
|
1290
|
+
originalSecret: secret, // 保存原始密钥
|
|
1291
|
+
fullMatch,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
catch {
|
|
1298
|
+
// JSON 解析失败,尝试作为纯文本处理
|
|
1299
|
+
await scanTextContent(filePath, content, patternDef, findings);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* 扫描纯文本内容
|
|
1304
|
+
*/
|
|
1305
|
+
async function scanTextContent(filePath, content, patternDef, findings) {
|
|
1306
|
+
const lines = content.split('\n');
|
|
1307
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1308
|
+
const line = lines[i];
|
|
1309
|
+
for (const pattern of patternDef.patterns) {
|
|
1310
|
+
const matches = line.matchAll(new RegExp(pattern, 'gi'));
|
|
1311
|
+
for (const match of matches) {
|
|
1312
|
+
let secret = match[1] || match[0];
|
|
1313
|
+
// 验证
|
|
1314
|
+
// Bearer Token、Authorization 等模式需要特殊处理,因为捕获组可能只包含 token 部分
|
|
1315
|
+
const isAuthPattern = patternDef.name === 'Bearer Token' ||
|
|
1316
|
+
patternDef.name === 'JWT Token';
|
|
1317
|
+
const minLength = isAuthPattern ? 4 : 10;
|
|
1318
|
+
if (secret.length < minLength)
|
|
1319
|
+
continue;
|
|
1320
|
+
if (secret.includes('$') || secret.includes('${'))
|
|
1321
|
+
continue;
|
|
1322
|
+
// 排除常见的示例/占位符值
|
|
1323
|
+
const lowerSecret = secret.toLowerCase();
|
|
1324
|
+
if (lowerSecret === 'your_key_here' ||
|
|
1325
|
+
lowerSecret === 'your-api-key' ||
|
|
1326
|
+
lowerSecret === 'your_api_key' ||
|
|
1327
|
+
lowerSecret === 'xxx' ||
|
|
1328
|
+
lowerSecret === 'xxxx' ||
|
|
1329
|
+
lowerSecret === 'xxxxx' ||
|
|
1330
|
+
lowerSecret === 'placeholder' ||
|
|
1331
|
+
lowerSecret === 'example' ||
|
|
1332
|
+
lowerSecret === 'sample' ||
|
|
1333
|
+
lowerSecret === 'test' ||
|
|
1334
|
+
lowerSecret === 'demo' ||
|
|
1335
|
+
lowerSecret === 'mock' ||
|
|
1336
|
+
lowerSecret === 'fake' ||
|
|
1337
|
+
lowerSecret === 'dummy' ||
|
|
1338
|
+
lowerSecret === 'invalid' ||
|
|
1339
|
+
lowerSecret === 'none' ||
|
|
1340
|
+
lowerSecret === 'null' ||
|
|
1341
|
+
lowerSecret === 'undefined' ||
|
|
1342
|
+
lowerSecret === 'default' ||
|
|
1343
|
+
lowerSecret === 'changeme' ||
|
|
1344
|
+
lowerSecret === 'password' ||
|
|
1345
|
+
lowerSecret === 'secret' ||
|
|
1346
|
+
lowerSecret === 'secret_123' ||
|
|
1347
|
+
lowerSecret === 'secret123' ||
|
|
1348
|
+
lowerSecret === 'token' ||
|
|
1349
|
+
lowerSecret === 'key' ||
|
|
1350
|
+
// Bearer Token 相关占位符
|
|
1351
|
+
lowerSecret === 'bearer' || // 单独的 "Bearer"
|
|
1352
|
+
lowerSecret.startsWith('bearer ') || // "Bearer xxx" 整个被匹配
|
|
1353
|
+
lowerSecret === 'abc' || // Bearer abc
|
|
1354
|
+
lowerSecret === 'token' || // Bearer token
|
|
1355
|
+
lowerSecret.startsWith('token') || // token456, token123 等
|
|
1356
|
+
lowerSecret === 'tok' || // 部分 "token"
|
|
1357
|
+
lowerSecret === 'oken' || // 部分 "token"
|
|
1358
|
+
// 模糊化后的密钥(扫描结果又被扫描)
|
|
1359
|
+
secret.includes('****') || // 已模糊化的内容
|
|
1360
|
+
// 标准 JWT 示例(完整的示例,不是以该header开头的所有JWT)
|
|
1361
|
+
// 注意:只排除完整的示例JWT,不排除使用相同header的真实JWT
|
|
1362
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' ||
|
|
1363
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ' ||
|
|
1364
|
+
// 仅header部分的示例
|
|
1365
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' ||
|
|
1366
|
+
// 文档中的截断示例
|
|
1367
|
+
secret === 'eyJhbG...' ||
|
|
1368
|
+
secret === 'eyJxxxx' ||
|
|
1369
|
+
// Anthropic 测试密钥示例
|
|
1370
|
+
lowerSecret.startsWith('sk-test-') ||
|
|
1371
|
+
lowerSecret.startsWith('sk-tes') || // 部分匹配
|
|
1372
|
+
lowerSecret.startsWith('sk-te') ||
|
|
1373
|
+
lowerSecret === '1234567890' ||
|
|
1374
|
+
lowerSecret === '12345678901234567890' ||
|
|
1375
|
+
lowerSecret === 'abcdefghijklmnopqrstuvwxyz' ||
|
|
1376
|
+
lowerSecret === 'cdefghijklmnopqrstuvwxyzab' ||
|
|
1377
|
+
lowerSecret === 'defghijklmnopqrstuvwxyzabc' ||
|
|
1378
|
+
lowerSecret === 'eyj' || // JWT 开头片段
|
|
1379
|
+
// JWT 模糊化示例
|
|
1380
|
+
lowerSecret.includes('xxxx') ||
|
|
1381
|
+
lowerSecret.includes('****') ||
|
|
1382
|
+
// 纯星号(已被模糊化的内容)
|
|
1383
|
+
/^\*+$/.test(secret) ||
|
|
1384
|
+
// API Key 示例
|
|
1385
|
+
lowerSecret.startsWith('my_api_') ||
|
|
1386
|
+
lowerSecret.startsWith('test_') ||
|
|
1387
|
+
lowerSecret.endsWith('_123') || // secret_123
|
|
1388
|
+
lowerSecret === 'secret123') {
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
const start = Math.max(0, match.index - 50);
|
|
1392
|
+
const end = Math.min(line.length, match.index + secret.length + 50);
|
|
1393
|
+
const context = line.substring(start, end);
|
|
1394
|
+
// 计算完整匹配内容
|
|
1395
|
+
const fullMatch = match[0];
|
|
1396
|
+
findings.push({
|
|
1397
|
+
type: patternDef.name,
|
|
1398
|
+
description: patternDef.description,
|
|
1399
|
+
path: filePath,
|
|
1400
|
+
line: i + 1,
|
|
1401
|
+
context: context.replace(/\s+/g, ' ').trim(),
|
|
1402
|
+
masked: maskSecret(secret, patternDef.name),
|
|
1403
|
+
originalSecret: secret,
|
|
1404
|
+
fullMatch,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* 扫描项目缓存目录中的敏感信息(~/.claude/projects/<normalized-path>/)
|
|
1412
|
+
* 使用与 scanCacheSecrets 相同的扫描规则
|
|
1413
|
+
* @param normalizedPath 项目规范化路径(如 "-Users-lrk-project")
|
|
1414
|
+
* @returns 敏感信息发现列表
|
|
1415
|
+
*/
|
|
1416
|
+
async function scanProjectCacheSecrets(normalizedPath) {
|
|
1417
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
1418
|
+
const projectDir = path.join(claudeDir, 'projects', normalizedPath);
|
|
1419
|
+
const findings = [];
|
|
1420
|
+
if (!await fs.pathExists(projectDir)) {
|
|
1421
|
+
return findings;
|
|
1422
|
+
}
|
|
1423
|
+
// 使用与 scanDirectoryForSecrets 相同的扫描逻辑
|
|
1424
|
+
await scanDirectoryForSecrets(projectDir, findings);
|
|
1425
|
+
return findings;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* 获取所有项目缓存目录(包括孤立的)
|
|
1429
|
+
* @returns 所有规范化路径列表
|
|
1430
|
+
*/
|
|
1431
|
+
async function getAllProjectCacheDirs() {
|
|
1432
|
+
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
1433
|
+
const dirs = [];
|
|
1434
|
+
if (!await fs.pathExists(projectsDir)) {
|
|
1435
|
+
return dirs;
|
|
1436
|
+
}
|
|
1437
|
+
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
1438
|
+
for (const entry of entries) {
|
|
1439
|
+
if (entry.isDirectory()) {
|
|
1440
|
+
dirs.push(entry.name);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return dirs.sort();
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* 扫描项目目录中的敏感信息(真实项目目录)
|
|
1447
|
+
* 使用与 scanCacheSecrets 相同的扫描规则
|
|
1448
|
+
*/
|
|
1449
|
+
async function scanProjectSecrets(projectPath) {
|
|
1450
|
+
const findings = [];
|
|
1451
|
+
// 递归扫描目录
|
|
1452
|
+
async function walkDir(dir, depth = 0) {
|
|
1453
|
+
if (depth > 10)
|
|
1454
|
+
return; // 限制递归深度
|
|
1455
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1456
|
+
for (const entry of entries) {
|
|
1457
|
+
const fullPath = path.join(dir, entry.name);
|
|
1458
|
+
// 跳过特定目录
|
|
1459
|
+
if (entry.isDirectory()) {
|
|
1460
|
+
if (entry.name === 'node_modules' ||
|
|
1461
|
+
entry.name === '.git' ||
|
|
1462
|
+
entry.name === 'dist' ||
|
|
1463
|
+
entry.name === 'build' ||
|
|
1464
|
+
entry.name === '.next' ||
|
|
1465
|
+
entry.name === 'coverage') {
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
await walkDir(fullPath, depth + 1);
|
|
1469
|
+
}
|
|
1470
|
+
else if (entry.isFile()) {
|
|
1471
|
+
// 使用与 scanFileForSecrets 相同的扫描逻辑
|
|
1472
|
+
await scanFileForProjectSecrets(fullPath, findings);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (await fs.pathExists(projectPath)) {
|
|
1477
|
+
await walkDir(projectPath);
|
|
1478
|
+
}
|
|
1479
|
+
return findings;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* 扫描单个文件中的敏感信息(用于项目扫描)
|
|
1483
|
+
* 使用与 scanFileForSecrets 相同的扫描规则和过滤逻辑
|
|
1484
|
+
*/
|
|
1485
|
+
async function scanFileForProjectSecrets(filePath, findings) {
|
|
1486
|
+
// 检查是否应该扫描此文件
|
|
1487
|
+
const fileName = path.basename(filePath);
|
|
1488
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1489
|
+
// 跳过二进制文件和日志
|
|
1490
|
+
if (['.log', '.bin', '.dat', '.sqlite', '.db'].includes(ext)) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
// 跳过过大的文件(> 10MB)
|
|
1494
|
+
try {
|
|
1495
|
+
const stat = await fs.stat(filePath);
|
|
1496
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
catch {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
// 检查每个模式
|
|
1504
|
+
for (const patternDef of SECRET_PATTERNS) {
|
|
1505
|
+
// 检查扩展名
|
|
1506
|
+
if (patternDef.allowedExtensions.length > 0 &&
|
|
1507
|
+
!patternDef.allowedExtensions.includes(ext) &&
|
|
1508
|
+
!patternDef.allowedExtensions.includes('*')) {
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
// 检查排除模式
|
|
1512
|
+
const shouldExclude = patternDef.excludePatterns.some(exc => filePath.includes(`/node_modules/`) ||
|
|
1513
|
+
filePath.includes(`/${exc}/`) ||
|
|
1514
|
+
fileName === exc);
|
|
1515
|
+
if (shouldExclude) {
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1520
|
+
// 对于 JSON 文件,尝试解析并只扫描值
|
|
1521
|
+
if (ext === '.json' || ext === '.jsonc') {
|
|
1522
|
+
await scanJsonContentForProject(filePath, content, patternDef, findings);
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
await scanTextContentForProject(filePath, content, patternDef, findings);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
// 跳过无法读取的文件(二进制文件等)
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* 扫描 JSON 内容(用于项目扫描)
|
|
1535
|
+
*/
|
|
1536
|
+
async function scanJsonContentForProject(filePath, content, patternDef, findings) {
|
|
1537
|
+
try {
|
|
1538
|
+
// 简化处理:将 JSON 转换为行以便逐行扫描
|
|
1539
|
+
const lines = content.split('\n');
|
|
1540
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1541
|
+
const line = lines[i];
|
|
1542
|
+
for (const pattern of patternDef.patterns) {
|
|
1543
|
+
const matches = line.matchAll(new RegExp(pattern, 'gi'));
|
|
1544
|
+
for (const match of matches) {
|
|
1545
|
+
// 提取密钥(从匹配组或完整匹配)
|
|
1546
|
+
let secret = match[1] || match[0];
|
|
1547
|
+
// 验证是否真的是密钥(排除误报)
|
|
1548
|
+
// Bearer Token、Authorization 等模式需要特殊处理,因为捕获组可能只包含 token 部分
|
|
1549
|
+
const isAuthPattern = patternDef.name === 'Bearer Token' ||
|
|
1550
|
+
patternDef.name === 'JWT Token';
|
|
1551
|
+
const minLength = isAuthPattern ? 4 : 10;
|
|
1552
|
+
if (secret.length < minLength)
|
|
1553
|
+
continue;
|
|
1554
|
+
if (secret.includes('$') || secret.includes('${'))
|
|
1555
|
+
continue;
|
|
1556
|
+
// 排除常见的示例/占位符值(使用与 cache 扫描相同的逻辑)
|
|
1557
|
+
const lowerSecret = secret.toLowerCase();
|
|
1558
|
+
if (shouldExcludeSecret(lowerSecret, secret)) {
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
// 生成替换值
|
|
1562
|
+
const replacement = generateReplacement(secret);
|
|
1563
|
+
// 提取上下文(周围 100 个字符)
|
|
1564
|
+
const start = Math.max(0, match.index - 50);
|
|
1565
|
+
const end = Math.min(line.length, match.index + secret.length + 50);
|
|
1566
|
+
const context = line.substring(start, end);
|
|
1567
|
+
// 计算完整匹配内容
|
|
1568
|
+
const fullMatch = match[0];
|
|
1569
|
+
findings.push({
|
|
1570
|
+
type: patternDef.name,
|
|
1571
|
+
description: patternDef.description,
|
|
1572
|
+
path: filePath,
|
|
1573
|
+
line: i + 1,
|
|
1574
|
+
context: context.replace(/\s+/g, ' ').trim(),
|
|
1575
|
+
masked: maskSecret(secret, patternDef.name),
|
|
1576
|
+
originalSecret: secret,
|
|
1577
|
+
fullMatch,
|
|
1578
|
+
replacement,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
// JSON 解析失败,尝试作为纯文本处理
|
|
1586
|
+
await scanTextContentForProject(filePath, content, patternDef, findings);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* 扫描纯文本内容(用于项目扫描)
|
|
1591
|
+
*/
|
|
1592
|
+
async function scanTextContentForProject(filePath, content, patternDef, findings) {
|
|
1593
|
+
const lines = content.split('\n');
|
|
1594
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1595
|
+
const line = lines[i];
|
|
1596
|
+
for (const pattern of patternDef.patterns) {
|
|
1597
|
+
const matches = line.matchAll(new RegExp(pattern, 'gi'));
|
|
1598
|
+
for (const match of matches) {
|
|
1599
|
+
let secret = match[1] || match[0];
|
|
1600
|
+
// 验证
|
|
1601
|
+
// Bearer Token、Authorization 等模式需要特殊处理,因为捕获组可能只包含 token 部分
|
|
1602
|
+
const isAuthPattern = patternDef.name === 'Bearer Token' ||
|
|
1603
|
+
patternDef.name === 'JWT Token';
|
|
1604
|
+
const minLength = isAuthPattern ? 4 : 10;
|
|
1605
|
+
if (secret.length < minLength)
|
|
1606
|
+
continue;
|
|
1607
|
+
if (secret.includes('$') || secret.includes('${'))
|
|
1608
|
+
continue;
|
|
1609
|
+
// 排除常见的示例/占位符值(使用与 cache 扫描相同的逻辑)
|
|
1610
|
+
const lowerSecret = secret.toLowerCase();
|
|
1611
|
+
if (shouldExcludeSecret(lowerSecret, secret)) {
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
// 生成替换值
|
|
1615
|
+
const replacement = generateReplacement(secret);
|
|
1616
|
+
const start = Math.max(0, match.index - 50);
|
|
1617
|
+
const end = Math.min(line.length, match.index + secret.length + 50);
|
|
1618
|
+
const context = line.substring(start, end);
|
|
1619
|
+
// 计算完整匹配内容
|
|
1620
|
+
const fullMatch = match[0];
|
|
1621
|
+
findings.push({
|
|
1622
|
+
type: patternDef.name,
|
|
1623
|
+
description: patternDef.description,
|
|
1624
|
+
path: filePath,
|
|
1625
|
+
line: i + 1,
|
|
1626
|
+
context: context.replace(/\s+/g, ' ').trim(),
|
|
1627
|
+
masked: maskSecret(secret, patternDef.name),
|
|
1628
|
+
originalSecret: secret,
|
|
1629
|
+
fullMatch,
|
|
1630
|
+
replacement,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* 判断是否应排除某个密钥(避免误报)
|
|
1638
|
+
*/
|
|
1639
|
+
function shouldExcludeSecret(lowerSecret, secret) {
|
|
1640
|
+
return lowerSecret === 'your_key_here' ||
|
|
1641
|
+
lowerSecret === 'your-api-key' ||
|
|
1642
|
+
lowerSecret === 'your_api_key' ||
|
|
1643
|
+
lowerSecret === 'xxx' ||
|
|
1644
|
+
lowerSecret === 'xxxx' ||
|
|
1645
|
+
lowerSecret === 'xxxxx' ||
|
|
1646
|
+
lowerSecret === 'placeholder' ||
|
|
1647
|
+
lowerSecret === 'example' ||
|
|
1648
|
+
lowerSecret === 'sample' ||
|
|
1649
|
+
lowerSecret === 'test' ||
|
|
1650
|
+
lowerSecret === 'demo' ||
|
|
1651
|
+
lowerSecret === 'mock' ||
|
|
1652
|
+
lowerSecret === 'fake' ||
|
|
1653
|
+
lowerSecret === 'dummy' ||
|
|
1654
|
+
lowerSecret === 'invalid' ||
|
|
1655
|
+
lowerSecret === 'none' ||
|
|
1656
|
+
lowerSecret === 'null' ||
|
|
1657
|
+
lowerSecret === 'undefined' ||
|
|
1658
|
+
lowerSecret === 'default' ||
|
|
1659
|
+
lowerSecret === 'changeme' ||
|
|
1660
|
+
lowerSecret === 'password' ||
|
|
1661
|
+
lowerSecret === 'secret' ||
|
|
1662
|
+
lowerSecret === 'secret_123' ||
|
|
1663
|
+
lowerSecret === 'secret123' ||
|
|
1664
|
+
lowerSecret === 'token' ||
|
|
1665
|
+
lowerSecret === 'key' ||
|
|
1666
|
+
lowerSecret === 'bearer' ||
|
|
1667
|
+
lowerSecret.startsWith('bearer ') ||
|
|
1668
|
+
lowerSecret === 'abc' ||
|
|
1669
|
+
lowerSecret === 'token' ||
|
|
1670
|
+
lowerSecret.startsWith('token') ||
|
|
1671
|
+
lowerSecret === 'tok' ||
|
|
1672
|
+
lowerSecret === 'oken' ||
|
|
1673
|
+
secret.includes('****') ||
|
|
1674
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' ||
|
|
1675
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ' ||
|
|
1676
|
+
secret === 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' ||
|
|
1677
|
+
secret === 'eyJhbG...' ||
|
|
1678
|
+
secret === 'eyJxxxx' ||
|
|
1679
|
+
lowerSecret.startsWith('sk-test-') ||
|
|
1680
|
+
lowerSecret.startsWith('sk-tes') ||
|
|
1681
|
+
lowerSecret.startsWith('sk-te') ||
|
|
1682
|
+
lowerSecret === '1234567890' ||
|
|
1683
|
+
lowerSecret === '12345678901234567890' ||
|
|
1684
|
+
lowerSecret === 'abcdefghijklmnopqrstuvwxyz' ||
|
|
1685
|
+
lowerSecret === 'cdefghijklmnopqrstuvwxyzab' ||
|
|
1686
|
+
lowerSecret === 'defghijklmnopqrstuvwxyzabc' ||
|
|
1687
|
+
lowerSecret === 'eyj' ||
|
|
1688
|
+
lowerSecret.includes('xxxx') ||
|
|
1689
|
+
lowerSecret.includes('****') ||
|
|
1690
|
+
/^\*+$/.test(secret) ||
|
|
1691
|
+
lowerSecret.startsWith('my_api_') ||
|
|
1692
|
+
lowerSecret.startsWith('test_') ||
|
|
1693
|
+
lowerSecret.endsWith('_123') ||
|
|
1694
|
+
lowerSecret === 'secret123';
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* 生成替换值(保留前4位,其余用 **** 替代)
|
|
1698
|
+
*/
|
|
1699
|
+
function generateReplacement(secret) {
|
|
1700
|
+
if (secret.length <= 8) {
|
|
1701
|
+
return secret.substring(0, Math.min(4, secret.length)) + '****';
|
|
1702
|
+
}
|
|
1703
|
+
return secret.substring(0, 4) + '****';
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* 执行敏感信息掩码替换
|
|
1707
|
+
* 只在包含敏感信息的文件旁边创建备份(aaa.jsonl.backup.时间戳)
|
|
1708
|
+
*/
|
|
1709
|
+
async function maskProjectSecrets(projectPath, findings) {
|
|
1710
|
+
const result = {
|
|
1711
|
+
projectPath,
|
|
1712
|
+
backups: [],
|
|
1713
|
+
findings,
|
|
1714
|
+
filesModified: [],
|
|
1715
|
+
totalReplacements: 0,
|
|
1716
|
+
};
|
|
1717
|
+
// 按文件分组
|
|
1718
|
+
const byFile = new Map();
|
|
1719
|
+
for (const finding of findings) {
|
|
1720
|
+
const list = byFile.get(finding.path) || [];
|
|
1721
|
+
list.push(finding);
|
|
1722
|
+
byFile.set(finding.path, list);
|
|
1723
|
+
}
|
|
1724
|
+
// 处理每个文件
|
|
1725
|
+
for (const [filePath, fileFindings] of byFile) {
|
|
1726
|
+
try {
|
|
1727
|
+
// 1. 先创建文件备份(在相同目录下)
|
|
1728
|
+
const timestamp = Date.now();
|
|
1729
|
+
const backupPath = `${filePath}.backup.${timestamp}`;
|
|
1730
|
+
await fs.copy(filePath, backupPath);
|
|
1731
|
+
result.backups.push({
|
|
1732
|
+
originalPath: filePath,
|
|
1733
|
+
backupPath,
|
|
1734
|
+
});
|
|
1735
|
+
// 2. 读取文件内容并执行掩码替换
|
|
1736
|
+
let content = await fs.readFile(filePath, 'utf-8');
|
|
1737
|
+
let modified = false;
|
|
1738
|
+
// 按行号排序(从后往前替换,避免位置变化)
|
|
1739
|
+
const sortedFindings = [...fileFindings].sort((a, b) => (b.line || 0) - (a.line || 0));
|
|
1740
|
+
// 按行处理
|
|
1741
|
+
const lines = content.split('\n');
|
|
1742
|
+
for (const finding of sortedFindings) {
|
|
1743
|
+
if (finding.line && finding.line <= lines.length) {
|
|
1744
|
+
const lineIndex = finding.line - 1;
|
|
1745
|
+
const originalLine = lines[lineIndex];
|
|
1746
|
+
let newLine = originalLine;
|
|
1747
|
+
// 查找并替换所有敏感信息
|
|
1748
|
+
for (const patternDef of SECRET_PATTERNS) {
|
|
1749
|
+
if (patternDef.name === finding.type) {
|
|
1750
|
+
for (const pattern of patternDef.patterns) {
|
|
1751
|
+
const matches = originalLine.matchAll(new RegExp(pattern, 'gi'));
|
|
1752
|
+
for (const match of matches) {
|
|
1753
|
+
const matchedSecret = match[1] || match[0];
|
|
1754
|
+
if (matchedSecret.length >= 4) {
|
|
1755
|
+
// 检查这个匹配是否应该被排除
|
|
1756
|
+
const lowerSecret = matchedSecret.toLowerCase();
|
|
1757
|
+
if (!shouldExcludeSecret(lowerSecret, matchedSecret)) {
|
|
1758
|
+
// 执行替换
|
|
1759
|
+
const replacement = generateReplacement(matchedSecret);
|
|
1760
|
+
newLine = newLine.replace(matchedSecret, replacement);
|
|
1761
|
+
modified = true;
|
|
1762
|
+
result.totalReplacements++;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
lines[lineIndex] = newLine;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
if (modified) {
|
|
1773
|
+
await fs.writeFile(filePath, lines.join('\n'), 'utf-8');
|
|
1774
|
+
result.filesModified.push(filePath);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
catch (error) {
|
|
1778
|
+
// 跳过无法处理的文件
|
|
1779
|
+
console.error(`处理文件失败: ${filePath}`, error);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return result;
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* 转义正则表达式特殊字符
|
|
1786
|
+
*/
|
|
1787
|
+
function escapeRegExp(string) {
|
|
1788
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1789
|
+
}
|