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.
- package/CHANGELOG.md +171 -0
- package/dist/secret-scan/history-patterns.d.ts +30 -0
- package/dist/secret-scan/history-patterns.d.ts.map +1 -0
- package/dist/secret-scan/history-patterns.js +162 -0
- package/dist/secret-scan/history-patterns.js.map +1 -0
- package/dist/secret-scan/index.d.ts +14 -7
- package/dist/secret-scan/index.d.ts.map +1 -1
- package/dist/secret-scan/index.js +15 -8
- package/dist/secret-scan/index.js.map +1 -1
- package/dist/secret-scan/patterns.d.ts +14 -1
- package/dist/secret-scan/patterns.d.ts.map +1 -1
- package/dist/secret-scan/patterns.js +14 -1
- package/dist/secret-scan/patterns.js.map +1 -1
- package/dist/secret-scan/scanner.d.ts +29 -39
- package/dist/secret-scan/scanner.d.ts.map +1 -1
- package/dist/secret-scan/scanner.js +233 -189
- package/dist/secret-scan/scanner.js.map +1 -1
- package/dist/security-scan/scanner.d.ts.map +1 -1
- package/dist/security-scan/scanner.js +13 -5
- package/dist/security-scan/scanner.js.map +1 -1
- package/dist/trust/passes/hardcoded-secrets.d.ts +11 -1
- package/dist/trust/passes/hardcoded-secrets.d.ts.map +1 -1
- package/dist/trust/passes/hardcoded-secrets.js +19 -5
- package/dist/trust/passes/hardcoded-secrets.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,42 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secret Scanner Module
|
|
2
|
+
* Secret Scanner Module (Refactored)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
|
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 =
|
|
104
|
-
const historicalSecrets =
|
|
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(
|
|
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
|
|
195
|
+
* Scan a single file using circle-ir's ScanSecretsPass
|
|
122
196
|
*/
|
|
123
|
-
async
|
|
197
|
+
async scanFileWithCircleIR(filePath) {
|
|
124
198
|
const secrets = [];
|
|
125
199
|
try {
|
|
126
200
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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);
|
|
304
|
-
const
|
|
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
|
-
*
|
|
315
|
-
*
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
*
|
|
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));
|