circle-ir-ai 2.7.17 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,92 @@
1
1
  /**
2
- * Secret Scanner Module
2
+ * Secret Scanner Module (Refactored)
3
3
  *
4
- * Scans code and Git history for secrets and credentials.
4
+ * Architecture:
5
+ * - SAST detection: Delegates to circle-ir's ScanSecretsPass (no regex duplication)
6
+ * - Git history: Scans commits for secrets introduced historically (circle-ir-ai domain)
7
+ * - LLM verification: Reduces false positives via context-aware analysis
8
+ *
9
+ * This module consumes CircleIR findings rather than reimplementing SAST logic.
5
10
  */
6
11
  import { execFileSync } from 'child_process';
7
12
  import * as fs from 'fs';
8
13
  import * as path from 'path';
9
14
  import { minimatch } from 'minimatch';
10
- import { SECRET_PATTERNS, } from './patterns.js';
15
+ import { initAnalyzer, analyze, isAnalyzerInitialized, detectLanguage } from 'circle-ir';
16
+ // Minimal patterns for git history scanning only.
17
+ // Working-tree scanning uses circle-ir's ScanSecretsPass.
18
+ import { HISTORY_SCAN_PATTERNS } from './history-patterns.js';
19
+ /**
20
+ * Map circle-ir severity to our severity type
21
+ */
22
+ function mapSeverity(severity) {
23
+ const map = {
24
+ critical: 'critical',
25
+ high: 'high',
26
+ medium: 'medium',
27
+ low: 'low',
28
+ info: 'low',
29
+ };
30
+ return map[severity.toLowerCase()] ?? 'medium';
31
+ }
32
+ /**
33
+ * Map circle-ir finding to DetectedSecret
34
+ */
35
+ function sastFindingToSecret(finding, fileContent) {
36
+ const lines = fileContent.split('\n');
37
+ const lineContent = lines[finding.line - 1] || '';
38
+ // Extract pattern info from evidence or rule_id
39
+ const evidence = finding.evidence || {};
40
+ const patternName = evidence.provider || finding.rule_id.replace('hardcoded-credential', 'Secret').replace(/-/g, ' ');
41
+ const match = evidence.match || '[redacted]';
42
+ // Determine category from evidence or pattern name
43
+ let category = 'generic';
44
+ if (evidence.provider) {
45
+ const provider = evidence.provider.toLowerCase();
46
+ if (provider.includes('aws'))
47
+ category = 'aws';
48
+ else if (provider.includes('github'))
49
+ category = 'github';
50
+ else if (provider.includes('stripe'))
51
+ category = 'stripe';
52
+ else if (provider.includes('openai'))
53
+ category = 'openai';
54
+ else if (provider.includes('anthropic'))
55
+ category = 'anthropic';
56
+ else if (provider.includes('slack'))
57
+ category = 'slack';
58
+ else if (provider.includes('google'))
59
+ category = 'gcp';
60
+ else if (provider.includes('jwt'))
61
+ category = 'jwt';
62
+ else if (provider.includes('pem') || provider.includes('private key'))
63
+ category = 'private-key';
64
+ else if (provider.includes('npm'))
65
+ category = 'npm';
66
+ }
67
+ else if (evidence.kind === 'entropy') {
68
+ category = 'high-entropy';
69
+ }
70
+ return {
71
+ patternId: finding.rule_id,
72
+ patternName,
73
+ file: finding.file,
74
+ line: finding.line,
75
+ column: 1,
76
+ match: redactSecret(match),
77
+ lineContent: truncateLine(lineContent),
78
+ severity: mapSeverity(finding.severity),
79
+ category,
80
+ presentInHead: true,
81
+ };
82
+ }
11
83
  /**
12
84
  * Secret Scanner class
13
85
  */
14
86
  export class SecretScanner {
15
- patterns;
16
87
  options;
17
88
  constructor(options = {}) {
18
89
  this.options = options;
19
- this.patterns = this.selectPatterns(options);
20
- }
21
- /**
22
- * Select patterns based on options
23
- */
24
- selectPatterns(options) {
25
- let patterns = options.patterns || SECRET_PATTERNS;
26
- // Filter by category
27
- if (options.includeCategories?.length) {
28
- patterns = patterns.filter((p) => options.includeCategories.includes(p.category));
29
- }
30
- if (options.excludeCategories?.length) {
31
- patterns = patterns.filter((p) => !options.excludeCategories.includes(p.category));
32
- }
33
- // Filter by severity
34
- if (options.minSeverity) {
35
- const severityOrder = ['low', 'medium', 'high', 'critical'];
36
- const minIndex = severityOrder.indexOf(options.minSeverity);
37
- patterns = patterns.filter((p) => severityOrder.indexOf(p.severity) >= minIndex);
38
- }
39
- return patterns;
40
90
  }
41
91
  /**
42
92
  * Scan a directory for secrets
@@ -44,6 +94,10 @@ export class SecretScanner {
44
94
  async scan(directory) {
45
95
  const startTime = Date.now();
46
96
  const secrets = [];
97
+ // Ensure analyzer is initialized
98
+ if (!isAnalyzerInitialized()) {
99
+ await initAnalyzer();
100
+ }
47
101
  const progress = {
48
102
  phase: 'indexing',
49
103
  filesScanned: 0,
@@ -56,11 +110,11 @@ export class SecretScanner {
56
110
  progress.totalFiles = files.length;
57
111
  progress.phase = 'scanning-files';
58
112
  this.options.onProgress?.(progress);
59
- // Scan current files
113
+ // Scan current files using circle-ir SAST
60
114
  for (const file of files) {
61
115
  progress.currentFile = file;
62
116
  this.options.onProgress?.(progress);
63
- const fileSecrets = await this.scanFile(file, directory);
117
+ const fileSecrets = await this.scanFileWithCircleIR(file);
64
118
  for (const secret of fileSecrets) {
65
119
  secret.presentInHead = true;
66
120
  secrets.push(secret);
@@ -86,6 +140,26 @@ export class SecretScanner {
86
140
  }
87
141
  commitsScanned = progress.commitsScanned || 0;
88
142
  }
143
+ // LLM verification (optional)
144
+ if (this.options.llmVerify && secrets.length > 0) {
145
+ progress.phase = 'verifying';
146
+ this.options.onProgress?.(progress);
147
+ await this.llmVerifySecrets(secrets, directory);
148
+ }
149
+ // Apply severity filter
150
+ let filteredSecrets = secrets;
151
+ if (this.options.minSeverity) {
152
+ const severityOrder = ['low', 'medium', 'high', 'critical'];
153
+ const minIndex = severityOrder.indexOf(this.options.minSeverity);
154
+ filteredSecrets = secrets.filter((s) => severityOrder.indexOf(s.severity) >= minIndex);
155
+ }
156
+ // Apply category filters
157
+ if (this.options.includeCategories?.length) {
158
+ filteredSecrets = filteredSecrets.filter((s) => this.options.includeCategories.includes(s.category));
159
+ }
160
+ if (this.options.excludeCategories?.length) {
161
+ filteredSecrets = filteredSecrets.filter((s) => !this.options.excludeCategories.includes(s.category));
162
+ }
89
163
  progress.phase = 'complete';
90
164
  this.options.onProgress?.(progress);
91
165
  // Calculate statistics
@@ -96,19 +170,19 @@ export class SecretScanner {
96
170
  low: 0,
97
171
  };
98
172
  const byCategory = {};
99
- for (const secret of secrets) {
173
+ for (const secret of filteredSecrets) {
100
174
  bySeverity[secret.severity]++;
101
175
  byCategory[secret.category] = (byCategory[secret.category] || 0) + 1;
102
176
  }
103
- const activeSecrets = secrets.filter((s) => s.presentInHead).length;
104
- const historicalSecrets = secrets.filter((s) => !s.presentInHead).length;
177
+ const activeSecrets = filteredSecrets.filter((s) => s.presentInHead).length;
178
+ const historicalSecrets = filteredSecrets.filter((s) => !s.presentInHead).length;
105
179
  // Generate .gitignore recommendations
106
- const gitignoreRecommendations = this.generateGitignoreRecommendations(secrets);
180
+ const gitignoreRecommendations = this.generateGitignoreRecommendations(filteredSecrets);
107
181
  return {
108
182
  directory,
109
183
  filesScanned: files.length,
110
184
  commitsScanned,
111
- secrets,
185
+ secrets: filteredSecrets,
112
186
  bySeverity,
113
187
  byCategory,
114
188
  activeSecrets,
@@ -118,112 +192,33 @@ export class SecretScanner {
118
192
  };
119
193
  }
120
194
  /**
121
- * Scan a single file for secrets
195
+ * Scan a single file using circle-ir's ScanSecretsPass
122
196
  */
123
- async scanFile(filePath, _baseDir) {
197
+ async scanFileWithCircleIR(filePath) {
124
198
  const secrets = [];
125
199
  try {
126
200
  const content = fs.readFileSync(filePath, 'utf-8');
127
- // #13: emit absolute paths in DetectedSecret.file. baseDir is no
128
- // longer needed for path formatting (kept in signature for callers).
129
- const absolutePath = path.resolve(filePath);
130
- const lines = content.split('\n');
131
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
132
- const line = lines[lineNum];
133
- const lineSecrets = this.scanLine(line, absolutePath, lineNum + 1);
134
- secrets.push(...lineSecrets);
201
+ const language = detectLanguage(filePath);
202
+ if (!language) {
203
+ return secrets; // Unsupported language
204
+ }
205
+ const result = await analyze(content, path.basename(filePath), language);
206
+ // Extract hardcoded-credential findings from circle-ir
207
+ const credentialFindings = (result.findings ?? []).filter((f) => f.rule_id?.includes('hardcoded-credential'));
208
+ for (const finding of credentialFindings) {
209
+ const secret = sastFindingToSecret(finding, content);
210
+ secret.file = path.resolve(filePath); // Absolute path
211
+ secrets.push(secret);
135
212
  }
136
213
  }
137
214
  catch {
138
- // Skip files that can't be read
139
- }
140
- return secrets;
141
- }
142
- /**
143
- * Scan a single line for secrets
144
- */
145
- scanLine(line, file, lineNum, commit, author, commitDate) {
146
- const secrets = [];
147
- // Quick keyword pre-filter
148
- const lineLower = line.toLowerCase();
149
- for (const pattern of this.patterns) {
150
- // Skip if no keywords match (optimization)
151
- if (pattern.keywords?.length) {
152
- const hasKeyword = pattern.keywords.some((k) => lineLower.includes(k.toLowerCase()));
153
- if (!hasKeyword)
154
- continue;
155
- }
156
- // Reset regex lastIndex for global patterns
157
- pattern.pattern.lastIndex = 0;
158
- let match;
159
- while ((match = pattern.pattern.exec(line)) !== null) {
160
- const matchedText = match[0];
161
- // Check false positive patterns
162
- if (pattern.falsePositivePatterns?.length) {
163
- const isFalsePositive = pattern.falsePositivePatterns.some((fp) => fp.test(line));
164
- if (isFalsePositive)
165
- continue;
166
- }
167
- // Run validator if present
168
- if (pattern.validator && !pattern.validator(matchedText)) {
169
- continue;
170
- }
171
- // Context-aware false positive detection:
172
- // 1. Match inside a regex literal (pattern definition, not real secret)
173
- if (this.isInsideRegexLiteral(line, match.index, match.index + matchedText.length)) {
174
- continue;
175
- }
176
- // 2. Match is a truncated example (e.g. "Bearer eyJ...", "sk-...")
177
- if (/\.{2,}["'`]?\s*[,;)}\]]?\s*$/.test(line.slice(match.index)) ||
178
- /\.{2,}["'`]/.test(matchedText) ||
179
- /\.{3}/.test(line.slice(match.index, match.index + matchedText.length + 5))) {
180
- continue;
181
- }
182
- // 3. Line is in a documentation/example context (comment with "Examples:", "e.g.")
183
- if (/^\s*(?:\*|\/\/|#)\s*(?:examples?:|e\.g\.|i\.e\.|sample|usage)/i.test(line)) {
184
- continue;
185
- }
186
- secrets.push({
187
- patternId: pattern.id,
188
- patternName: pattern.name,
189
- file,
190
- line: lineNum,
191
- column: match.index + 1,
192
- match: this.redactSecret(matchedText),
193
- lineContent: this.truncateLine(line),
194
- severity: pattern.severity,
195
- category: pattern.category,
196
- commit,
197
- author,
198
- commitDate,
199
- presentInHead: false, // Will be updated later
200
- });
201
- }
215
+ // Skip files that can't be read or analyzed
202
216
  }
203
217
  return secrets;
204
218
  }
205
- /**
206
- * Check if a match position falls inside a regex literal (/.../).
207
- * Looks for unescaped `/` delimiters surrounding the match range.
208
- */
209
- isInsideRegexLiteral(line, matchStart, matchEnd) {
210
- // Find regex literals in the line: look for /pattern/flags
211
- // A regex literal starts with / (not preceded by a word char or /) and
212
- // ends with / followed by optional flags [gimsuy]
213
- const regexLiteralPattern = /(?:^|[=(:,;!&|?+\-~^%<>[\s])(\/.+?\/[gimsuy]*)/g;
214
- let m;
215
- while ((m = regexLiteralPattern.exec(line)) !== null) {
216
- const regexStart = m.index + m[0].indexOf(m[1]);
217
- const regexEnd = regexStart + m[1].length;
218
- // If the secret match falls inside this regex literal, it's a pattern definition
219
- if (matchStart >= regexStart && matchEnd <= regexEnd) {
220
- return true;
221
- }
222
- }
223
- return false;
224
- }
225
219
  /**
226
220
  * Scan git history for secrets
221
+ * Uses minimal patterns since we can't run circle-ir on diffs
227
222
  */
228
223
  async scanGitHistory(directory, progress) {
229
224
  const secrets = [];
@@ -240,16 +235,9 @@ export class SecretScanner {
240
235
  // Get commit info
241
236
  const commitInfo = execFileSync('git', ['-C', directory, 'log', '-1', '--format=%an|%aI', commit], { encoding: 'utf-8' }).trim();
242
237
  const [author, commitDate] = commitInfo.split('|');
243
- // Get diff for this commit.
244
- // `--root` is required so the diff against the empty tree is
245
- // emitted for the repo's root commit — otherwise `git diff-tree`
246
- // returns empty for the initial commit and any secret added in
247
- // commit #1 is silently invisible to the scanner. Discovered
248
- // while writing the #60 history-exclude test.
249
238
  try {
250
239
  const diff = execFileSync('git', ['-C', directory, 'diff-tree', '--root', '--no-commit-id', '-r', '-p', commit], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
251
- // Parse diff and scan for secrets
252
- const diffSecrets = this.scanDiff(diff, commit, author, commitDate);
240
+ const diffSecrets = this.scanDiff(diff, commit, author, commitDate, directory);
253
241
  secrets.push(...diffSecrets);
254
242
  }
255
243
  catch {
@@ -266,32 +254,23 @@ export class SecretScanner {
266
254
  return secrets;
267
255
  }
268
256
  /**
269
- * Scan a git diff for secrets
257
+ * Scan a git diff for secrets using minimal history patterns
270
258
  */
271
- scanDiff(diff, commit, author, commitDate) {
259
+ scanDiff(diff, commit, author, commitDate, repoDir) {
272
260
  const secrets = [];
273
261
  let currentFile = '';
274
262
  let lineNum = 0;
275
- // #60: when the current file is excluded by user globs or built-in
276
- // regex skips, drop every line of that diff section until the next
277
- // `+++ b/<path>` marker. Working-tree walk applied excludes; history
278
- // walk did not, so e.g. Cargo.lock secrets would show up as
279
- // "Status: Historical" even with `--exclude Cargo.lock`.
280
263
  let currentFileExcluded = false;
281
264
  const lines = diff.split('\n');
282
265
  for (const line of lines) {
283
- // Track current file
284
266
  if (line.startsWith('+++ b/')) {
285
267
  currentFile = line.slice(6);
286
268
  lineNum = 0;
287
269
  currentFileExcluded = this.isPathExcluded(currentFile);
288
270
  continue;
289
271
  }
290
- // #60: while the active file is excluded, skip every line —
291
- // including hunk headers — until the next `+++ b/` marker.
292
272
  if (currentFileExcluded)
293
273
  continue;
294
- // Track line numbers from hunk headers
295
274
  const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
296
275
  if (hunkMatch) {
297
276
  lineNum = parseInt(hunkMatch[1], 10) - 1;
@@ -300,8 +279,9 @@ export class SecretScanner {
300
279
  // Only scan added lines
301
280
  if (line.startsWith('+') && !line.startsWith('+++')) {
302
281
  lineNum++;
303
- const content = line.slice(1); // Remove the '+' prefix
304
- const lineSecrets = this.scanLine(content, currentFile, lineNum, commit, author, commitDate);
282
+ const content = line.slice(1);
283
+ const absoluteFile = path.resolve(repoDir, currentFile);
284
+ const lineSecrets = this.scanLineWithPatterns(content, absoluteFile, lineNum, commit, author, commitDate);
305
285
  secrets.push(...lineSecrets);
306
286
  }
307
287
  else if (!line.startsWith('-')) {
@@ -311,12 +291,88 @@ export class SecretScanner {
311
291
  return secrets;
312
292
  }
313
293
  /**
314
- * Built-in path-skip regexes shared between the working-tree walk
315
- * (`getFiles`) and the git-history diff path (`scanDiff`). #60: previously
316
- * only the working-tree walk applied these — git-history scans walked
317
- * every diff including `Cargo.lock`, `*.lock`, binary blobs, etc.,
318
- * producing noisy "Status: Historical" findings on paths the user had
319
- * already excluded from their working-tree scan.
294
+ * Scan a single line using minimal history patterns
295
+ * This is only used for git history scanning where we can't use circle-ir
296
+ */
297
+ scanLineWithPatterns(line, file, lineNum, commit, author, commitDate) {
298
+ const secrets = [];
299
+ for (const pattern of HISTORY_SCAN_PATTERNS) {
300
+ pattern.pattern.lastIndex = 0;
301
+ let match;
302
+ while ((match = pattern.pattern.exec(line)) !== null) {
303
+ const matchedText = match[0];
304
+ // Skip false positives
305
+ if (pattern.falsePositivePatterns?.length) {
306
+ const isFalsePositive = pattern.falsePositivePatterns.some((fp) => fp.test(line));
307
+ if (isFalsePositive)
308
+ continue;
309
+ }
310
+ if (pattern.validator && !pattern.validator(matchedText)) {
311
+ continue;
312
+ }
313
+ secrets.push({
314
+ patternId: pattern.id,
315
+ patternName: pattern.name,
316
+ file,
317
+ line: lineNum,
318
+ column: match.index + 1,
319
+ match: redactSecret(matchedText),
320
+ lineContent: truncateLine(line),
321
+ severity: pattern.severity,
322
+ category: pattern.category,
323
+ commit,
324
+ author,
325
+ commitDate,
326
+ presentInHead: false,
327
+ });
328
+ }
329
+ }
330
+ return secrets;
331
+ }
332
+ /**
333
+ * LLM verification to reduce false positives
334
+ */
335
+ async llmVerifySecrets(secrets, _directory) {
336
+ // Import LLM client dynamically to avoid circular deps
337
+ try {
338
+ const { AxLLMClient } = await import('../llm/ax-client.js');
339
+ const { getDefaultLLMConfig } = await import('../llm/config.js');
340
+ const config = getDefaultLLMConfig();
341
+ const client = new AxLLMClient(config);
342
+ // Batch verify entropy-based findings (most likely FPs)
343
+ const entropySecrets = secrets.filter(s => s.category === 'high-entropy');
344
+ const systemPrompt = 'You are a security expert. Analyze code for hardcoded secrets. Respond with JSON only.';
345
+ for (const secret of entropySecrets) {
346
+ try {
347
+ const userPrompt = `Analyze this code line and determine if it contains a real hardcoded secret or credential.
348
+
349
+ Line: ${secret.lineContent}
350
+ Context: File ${path.basename(secret.file)}, line ${secret.line}
351
+
352
+ Respond with JSON: {"isSecret": true/false, "confidence": 0.0-1.0, "reason": "brief explanation"}
353
+
354
+ Consider:
355
+ - Is this a placeholder, example, or test value?
356
+ - Is this a hash, UUID, or non-sensitive identifier?
357
+ - Is this an actual API key, token, or credential?`;
358
+ const response = await client.chatJSON(systemPrompt, userPrompt, 'verification');
359
+ if (response) {
360
+ secret.llmVerified = response.isSecret;
361
+ secret.llmConfidence = response.confidence;
362
+ }
363
+ }
364
+ catch {
365
+ // LLM verification failed, keep the finding
366
+ secret.llmVerified = undefined;
367
+ }
368
+ }
369
+ }
370
+ catch {
371
+ // LLM not available, skip verification
372
+ }
373
+ }
374
+ /**
375
+ * Built-in path-skip patterns
320
376
  */
321
377
  static BUILTIN_EXCLUDE_PATTERNS = [
322
378
  /node_modules/,
@@ -348,9 +404,7 @@ export class SecretScanner {
348
404
  /\.wasm$/i,
349
405
  ];
350
406
  /**
351
- * #60: returns true when a path should be filtered out per built-in
352
- * regex excludes + user-supplied include/exclude minimatch globs.
353
- * Used by both the working-tree walk and the git-history diff parser.
407
+ * Check if path should be excluded
354
408
  */
355
409
  isPathExcluded(relativePath) {
356
410
  if (SecretScanner.BUILTIN_EXCLUDE_PATTERNS.some((p) => p.test(relativePath))) {
@@ -372,10 +426,6 @@ export class SecretScanner {
372
426
  getFiles(directory) {
373
427
  const files = [];
374
428
  const excludePatterns = SecretScanner.BUILTIN_EXCLUDE_PATTERNS;
375
- // #18: user-provided include/exclude patterns are globs (matching the
376
- // shape used by `cognium.config.json` and the CLI's `--include` /
377
- // `--exclude` flags). Match via minimatch — passing them to RegExp
378
- // crashed on `**` ("nothing to repeat").
379
429
  const userExcludeGlobs = this.options.excludeFiles ?? [];
380
430
  const userIncludeGlobs = this.options.includeFiles ?? [];
381
431
  const walk = (dir) => {
@@ -384,11 +434,9 @@ export class SecretScanner {
384
434
  for (const entry of entries) {
385
435
  const fullPath = path.join(dir, entry.name);
386
436
  const relativePath = path.relative(directory, fullPath);
387
- // Skip excluded patterns
388
437
  if (excludePatterns.some((p) => p.test(relativePath))) {
389
438
  continue;
390
439
  }
391
- // Apply user exclude globs
392
440
  if (userExcludeGlobs.length) {
393
441
  if (userExcludeGlobs.some((g) => minimatch(relativePath, g, { dot: true })))
394
442
  continue;
@@ -397,7 +445,6 @@ export class SecretScanner {
397
445
  walk(fullPath);
398
446
  }
399
447
  else if (entry.isFile()) {
400
- // Apply user include globs
401
448
  if (userIncludeGlobs.length) {
402
449
  if (!userIncludeGlobs.some((g) => minimatch(relativePath, g, { dot: true })))
403
450
  continue;
@@ -428,26 +475,6 @@ export class SecretScanner {
428
475
  return false;
429
476
  }
430
477
  }
431
- /**
432
- * Redact a secret for safe display
433
- */
434
- redactSecret(secret) {
435
- if (secret.length <= 8) {
436
- return '*'.repeat(secret.length);
437
- }
438
- const visibleChars = Math.min(4, Math.floor(secret.length / 4));
439
- return (secret.slice(0, visibleChars) +
440
- '*'.repeat(secret.length - visibleChars * 2) +
441
- secret.slice(-visibleChars));
442
- }
443
- /**
444
- * Truncate long lines
445
- */
446
- truncateLine(line, maxLength = 200) {
447
- if (line.length <= maxLength)
448
- return line;
449
- return line.slice(0, maxLength) + '...';
450
- }
451
478
  /**
452
479
  * Generate .gitignore recommendations
453
480
  */
@@ -455,7 +482,6 @@ export class SecretScanner {
455
482
  const recommendations = new Set();
456
483
  for (const secret of secrets) {
457
484
  const file = secret.file;
458
- // Common sensitive file patterns
459
485
  if (/\.env($|\.)/.test(file)) {
460
486
  recommendations.add('.env*');
461
487
  recommendations.add('!.env.example');
@@ -491,7 +517,6 @@ export class SecretScanner {
491
517
  recommendations.add('*.pfx');
492
518
  }
493
519
  }
494
- // Always recommend common patterns
495
520
  recommendations.add('.env');
496
521
  recommendations.add('.env.local');
497
522
  recommendations.add('*.pem');
@@ -499,6 +524,26 @@ export class SecretScanner {
499
524
  return [...recommendations].sort();
500
525
  }
501
526
  }
527
+ /**
528
+ * Redact a secret for safe display
529
+ */
530
+ function redactSecret(secret) {
531
+ if (secret.length <= 8) {
532
+ return '*'.repeat(secret.length);
533
+ }
534
+ const visibleChars = Math.min(4, Math.floor(secret.length / 4));
535
+ return (secret.slice(0, visibleChars) +
536
+ '*'.repeat(secret.length - visibleChars * 2) +
537
+ secret.slice(-visibleChars));
538
+ }
539
+ /**
540
+ * Truncate long lines
541
+ */
542
+ function truncateLine(line, maxLength = 200) {
543
+ if (line.length <= maxLength)
544
+ return line;
545
+ return line.slice(0, maxLength) + '...';
546
+ }
502
547
  /**
503
548
  * Scan a directory for secrets (convenience function)
504
549
  */
@@ -529,7 +574,6 @@ export function formatSecretReport(result) {
529
574
  lines.push(`Commits Scanned: ${result.commitsScanned}`);
530
575
  lines.push(`Duration: ${result.durationMs}ms`);
531
576
  lines.push('');
532
- // Summary
533
577
  lines.push('-'.repeat(40));
534
578
  lines.push('SUMMARY');
535
579
  lines.push('-'.repeat(40));
@@ -548,12 +592,10 @@ export function formatSecretReport(result) {
548
592
  lines.push(` ${category}: ${count}`);
549
593
  }
550
594
  lines.push('');
551
- // Detailed findings
552
595
  if (result.secrets.length > 0) {
553
596
  lines.push('-'.repeat(40));
554
597
  lines.push('FINDINGS');
555
598
  lines.push('-'.repeat(40));
556
- // Group by severity
557
599
  const grouped = new Map();
558
600
  for (const secret of result.secrets) {
559
601
  const list = grouped.get(secret.severity) || [];
@@ -577,10 +619,12 @@ export function formatSecretReport(result) {
577
619
  lines.push(` Date: ${secret.commitDate}`);
578
620
  }
579
621
  lines.push(` Status: ${secret.presentInHead ? 'ACTIVE' : 'Historical'}`);
622
+ if (secret.llmVerified !== undefined) {
623
+ lines.push(` LLM Verified: ${secret.llmVerified} (confidence: ${secret.llmConfidence?.toFixed(2)})`);
624
+ }
580
625
  }
581
626
  }
582
627
  }
583
- // .gitignore recommendations
584
628
  if (result.gitignoreRecommendations.length > 0) {
585
629
  lines.push('');
586
630
  lines.push('-'.repeat(40));