codeant-cli 0.3.3 → 0.3.5
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/package.json +8 -10
- package/src/commands/getBaseUrl.js +21 -0
- package/src/commands/login.js +120 -0
- package/src/commands/logout.js +30 -0
- package/src/commands/review.js +294 -0
- package/src/commands/secrets.js +107 -0
- package/src/commands/setBaseUrl.js +37 -0
- package/src/components/ReviewUI.js +410 -0
- package/src/components/SecretsUI.js +366 -0
- package/src/components/Welcome.js +144 -0
- package/src/index.js +390 -0
- package/src/reviewHeadless.js +198 -0
- package/src/rules/secrets.toml +4994 -0
- package/src/scm/azure/client.js +21 -0
- package/src/scm/azure/getCodeReview.js +20 -0
- package/src/scm/azure/getPullRequest.js +30 -0
- package/src/scm/azure/index.js +7 -0
- package/src/scm/azure/listCodeReviews.js +17 -0
- package/src/scm/azure/listPullRequestComments.js +41 -0
- package/src/scm/azure/listPullRequests.js +26 -0
- package/src/scm/azure/resolveConversation.js +22 -0
- package/src/scm/azure/searchComments.js +35 -0
- package/src/scm/bitbucket/client.js +18 -0
- package/src/scm/bitbucket/getCodeReview.js +3 -0
- package/src/scm/bitbucket/getPullRequest.js +26 -0
- package/src/scm/bitbucket/index.js +7 -0
- package/src/scm/bitbucket/listCodeReviews.js +17 -0
- package/src/scm/bitbucket/listPullRequestComments.js +33 -0
- package/src/scm/bitbucket/listPullRequests.js +33 -0
- package/src/scm/bitbucket/resolveConversation.js +21 -0
- package/src/scm/bitbucket/searchComments.js +29 -0
- package/src/scm/github/client.js +17 -0
- package/src/scm/github/getCodeReview.js +26 -0
- package/src/scm/github/getPullRequest.js +27 -0
- package/src/scm/github/index.js +7 -0
- package/src/scm/github/listCodeReviews.js +17 -0
- package/src/scm/github/listPullRequestComments.js +51 -0
- package/src/scm/github/listPullRequests.js +32 -0
- package/src/scm/github/resolveConversation.js +85 -0
- package/src/scm/github/searchComments.js +30 -0
- package/src/scm/gitlab/client.js +16 -0
- package/src/scm/gitlab/getCodeReview.js +18 -0
- package/src/scm/gitlab/getPullRequest.js +24 -0
- package/src/scm/gitlab/index.js +7 -0
- package/src/scm/gitlab/listCodeReviews.js +21 -0
- package/src/scm/gitlab/listPullRequestComments.js +44 -0
- package/src/scm/gitlab/listPullRequests.js +26 -0
- package/src/scm/gitlab/resolveConversation.js +14 -0
- package/src/scm/gitlab/searchComments.js +28 -0
- package/src/scm/index.js +97 -0
- package/src/tools/executeTool.js +31 -0
- package/src/tools/globTool.js +9 -0
- package/src/tools/grepTool.js +18 -0
- package/src/tools/lsTool.js +7 -0
- package/src/tools/readTool.js +12 -0
- package/src/utils/analytics.js +56 -0
- package/src/utils/baseUrl.js +11 -0
- package/src/utils/commonApiHelper.js +143 -0
- package/src/utils/config.js +42 -0
- package/src/utils/fetchApi.js +39 -0
- package/src/utils/gitDiffHelper.js +1203 -0
- package/src/utils/reviewApiHelper.js +291 -0
- package/src/utils/scmAuth.js +136 -0
- package/src/utils/secretsApiHelper.js +252 -0
- package/src/utils/secretsDetector.js +210 -0
- package/dist/178.index.js +0 -1
- package/dist/239.index.js +0 -1
- package/dist/701.index.js +0 -1
- package/dist/880.index.js +0 -1
- package/dist/935.index.js +0 -1
- package/dist/966.index.js +0 -1
- package/dist/index.js +0 -28
- package/dist/package.json +0 -1
- package/dist/review/239.index.js +0 -1
- package/dist/review/index.js +0 -8
- package/dist/review/package.json +0 -3
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import CommonApiHelper from './commonApiHelper.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { minimatch } from 'minimatch';
|
|
5
|
+
|
|
6
|
+
const MAX_REVIEW_FILES = 10;
|
|
7
|
+
const MAX_FILE_LINES = 5000;
|
|
8
|
+
const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200 KB
|
|
9
|
+
|
|
10
|
+
const EXCLUDED_FILENAMES = new Set([
|
|
11
|
+
'package-lock.json',
|
|
12
|
+
'yarn.lock',
|
|
13
|
+
'pnpm-lock.yaml',
|
|
14
|
+
'composer.lock',
|
|
15
|
+
'Gemfile.lock',
|
|
16
|
+
'Cargo.lock',
|
|
17
|
+
'poetry.lock',
|
|
18
|
+
'Pipfile.lock',
|
|
19
|
+
'go.sum',
|
|
20
|
+
'.DS_Store',
|
|
21
|
+
'Thumbs.db',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const EXCLUDED_EXTENSIONS = new Set([
|
|
25
|
+
// Images
|
|
26
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.avif',
|
|
27
|
+
// Fonts
|
|
28
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
29
|
+
// Binary / compiled
|
|
30
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc', '.pyo',
|
|
31
|
+
'.wasm', '.jar', '.war', '.ear',
|
|
32
|
+
// Archives
|
|
33
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
34
|
+
// Media
|
|
35
|
+
'.mp3', '.mp4', '.wav', '.avi', '.mov', '.flv', '.wmv', '.ogg', '.webm',
|
|
36
|
+
// Data / generated
|
|
37
|
+
'.min.js', '.min.css', '.map',
|
|
38
|
+
'.pb', '.proto.bin',
|
|
39
|
+
// PDF / docs
|
|
40
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
41
|
+
// Data / config (often large, auto-generated)
|
|
42
|
+
'.csv', '.tsv', '.parquet', '.avro', '.ndjson',
|
|
43
|
+
// Database
|
|
44
|
+
'.sqlite', '.db',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function matchesPatterns(filePath, includePatterns, excludePatterns) {
|
|
48
|
+
if (includePatterns.length > 0) {
|
|
49
|
+
const included = includePatterns.some(pattern => minimatch(filePath, pattern, { matchBase: true }));
|
|
50
|
+
if (!included) return false;
|
|
51
|
+
}
|
|
52
|
+
if (excludePatterns.length > 0) {
|
|
53
|
+
const excluded = excludePatterns.some(pattern => minimatch(filePath, pattern, { matchBase: true }));
|
|
54
|
+
if (excluded) return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isReviewableFile(filePath) {
|
|
60
|
+
const basename = path.basename(filePath);
|
|
61
|
+
|
|
62
|
+
if (EXCLUDED_FILENAMES.has(basename)) return false;
|
|
63
|
+
|
|
64
|
+
const ext = path.extname(basename).toLowerCase();
|
|
65
|
+
if (EXCLUDED_EXTENSIONS.has(ext)) return false;
|
|
66
|
+
|
|
67
|
+
// Check for .min.js / .min.css (double extension)
|
|
68
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
|
|
69
|
+
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Transforms git diff data into the format expected by the agentic review API
|
|
75
|
+
*/
|
|
76
|
+
class ReviewApiHelper extends CommonApiHelper {
|
|
77
|
+
/**
|
|
78
|
+
* Pass structured diffs through so buildReviewApiRequest can filter them
|
|
79
|
+
*/
|
|
80
|
+
_transformDiffsToApiFormat(diffs) {
|
|
81
|
+
return diffs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the complete request body for the agentic review API
|
|
86
|
+
*/
|
|
87
|
+
async buildReviewApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = [], options = {}) {
|
|
88
|
+
const diffs = await this.getFilesForType(type, options);
|
|
89
|
+
|
|
90
|
+
// diffs is either a string (from getAllDiffs) or an array of structured diff objects
|
|
91
|
+
if (typeof diffs === 'string') {
|
|
92
|
+
// Split the raw diff into per-file sections and filter out deleted files
|
|
93
|
+
const gitRoot = this.getGitRoot() || process.cwd();
|
|
94
|
+
const fileContents = {};
|
|
95
|
+
const fileSections = diffs.split(/(?=^diff --git )/m).filter(Boolean);
|
|
96
|
+
const keptSections = [];
|
|
97
|
+
const skipped = [];
|
|
98
|
+
|
|
99
|
+
for (const section of fileSections) {
|
|
100
|
+
const nameMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
101
|
+
if (!nameMatch) continue;
|
|
102
|
+
const f = nameMatch[1];
|
|
103
|
+
if (!isReviewableFile(f)) {
|
|
104
|
+
skipped.push({ file: f, reason: 'binary/generated' });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Apply user-specified --include/--exclude glob patterns
|
|
108
|
+
if (!matchesPatterns(f, includePatterns, excludePatterns)) {
|
|
109
|
+
skipped.push({ file: f, reason: 'excluded by pattern' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Skip deleted files — no file content to review, diff alone isn't actionable
|
|
114
|
+
if (/deleted file mode/m.test(section) || /\+\+\+ \/dev\/null/m.test(section)) {
|
|
115
|
+
skipped.push({ file: f, reason: 'deleted' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Skip files exceeding size or line limits to avoid blowing up the AI context window
|
|
120
|
+
try {
|
|
121
|
+
const fullPath = path.resolve(gitRoot, f);
|
|
122
|
+
if (fs.existsSync(fullPath)) {
|
|
123
|
+
const stats = fs.statSync(fullPath);
|
|
124
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) {
|
|
125
|
+
skipped.push({ file: f, reason: `too large (${(stats.size / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
129
|
+
const lineCount = content.split('\n').length;
|
|
130
|
+
if (lineCount > MAX_FILE_LINES) {
|
|
131
|
+
skipped.push({ file: f, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
keptSections.push(section);
|
|
135
|
+
fileContents[f] = content;
|
|
136
|
+
} else {
|
|
137
|
+
keptSections.push(section);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip files that can't be read
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const reviewedFiles = Object.keys(fileContents);
|
|
145
|
+
return {
|
|
146
|
+
diff_content: keptSections.join(''),
|
|
147
|
+
file_contents: fileContents,
|
|
148
|
+
_meta: {
|
|
149
|
+
reviewed_files: reviewedFiles,
|
|
150
|
+
total_changed: fileSections.length,
|
|
151
|
+
skipped,
|
|
152
|
+
capped: false,
|
|
153
|
+
max_files: MAX_REVIEW_FILES,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Filter and track skipped files
|
|
159
|
+
const skipped = [];
|
|
160
|
+
const allUniqueFiles = new Set(diffs.map(d => d.filename_str));
|
|
161
|
+
const reviewable = [];
|
|
162
|
+
|
|
163
|
+
for (const d of diffs) {
|
|
164
|
+
if (!isReviewableFile(d.filename_str)) {
|
|
165
|
+
skipped.push({ file: d.filename_str, reason: 'binary/generated' });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (d.edit_type_str === 'DELETED') {
|
|
169
|
+
skipped.push({ file: d.filename_str, reason: 'deleted' });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!matchesPatterns(d.filename_str, includePatterns, excludePatterns)) {
|
|
173
|
+
skipped.push({ file: d.filename_str, reason: 'excluded by pattern' });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
reviewable.push(d);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Dedupe skipped entries
|
|
180
|
+
const seenSkipped = new Set();
|
|
181
|
+
const dedupedSkipped = skipped.filter(s => {
|
|
182
|
+
if (seenSkipped.has(s.file)) return false;
|
|
183
|
+
seenSkipped.add(s.file);
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Dedupe by filename, keeping all hunks, but limit unique files to MAX_REVIEW_FILES
|
|
188
|
+
const fileOrder = [];
|
|
189
|
+
const fileHunks = new Map();
|
|
190
|
+
let capped = false;
|
|
191
|
+
for (const d of reviewable) {
|
|
192
|
+
const f = d.filename_str;
|
|
193
|
+
if (!fileHunks.has(f)) {
|
|
194
|
+
if (fileOrder.length >= MAX_REVIEW_FILES) {
|
|
195
|
+
capped = true;
|
|
196
|
+
if (!seenSkipped.has(f)) {
|
|
197
|
+
dedupedSkipped.push({ file: f, reason: `exceeded ${MAX_REVIEW_FILES}-file limit` });
|
|
198
|
+
seenSkipped.add(f);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
fileOrder.push(f);
|
|
203
|
+
fileHunks.set(f, []);
|
|
204
|
+
}
|
|
205
|
+
fileHunks.get(f).push(d);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Reconstruct a combined diff string from the filtered hunks
|
|
209
|
+
const parts = [];
|
|
210
|
+
for (const f of fileOrder) {
|
|
211
|
+
const hunks = fileHunks.get(f);
|
|
212
|
+
// Build a file-level diff header + all hunk patches
|
|
213
|
+
const header = `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}`;
|
|
214
|
+
const hunkPatches = hunks.map(h => h.patch_str).filter(Boolean);
|
|
215
|
+
if (hunkPatches.length > 0) {
|
|
216
|
+
parts.push(header + '\n' + hunkPatches.join('\n'));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Use head_file_str from diffs so file content matches the diff version (avoids working tree mismatch for --last-commit etc.)
|
|
221
|
+
const fileContents = {};
|
|
222
|
+
const skippedLargeFiles = new Set();
|
|
223
|
+
for (const f of fileOrder) {
|
|
224
|
+
const hunks = fileHunks.get(f);
|
|
225
|
+
const content = hunks[0]?.head_file_str || '';
|
|
226
|
+
if (content) {
|
|
227
|
+
const contentSize = Buffer.byteLength(content, 'utf-8');
|
|
228
|
+
if (contentSize > MAX_FILE_SIZE_BYTES) {
|
|
229
|
+
skippedLargeFiles.add(f);
|
|
230
|
+
dedupedSkipped.push({ file: f, reason: `too large (${(contentSize / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const lineCount = content.split('\n').length;
|
|
234
|
+
if (lineCount > MAX_FILE_LINES) {
|
|
235
|
+
skippedLargeFiles.add(f);
|
|
236
|
+
dedupedSkipped.push({ file: f, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
fileContents[f] = content;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Remove diff sections for skipped files
|
|
244
|
+
const filteredParts = parts.filter((_, i) => !skippedLargeFiles.has(fileOrder[i]));
|
|
245
|
+
|
|
246
|
+
const reviewedFiles = fileOrder.filter(f => !skippedLargeFiles.has(f));
|
|
247
|
+
return {
|
|
248
|
+
diff_content: filteredParts.join('\n'),
|
|
249
|
+
file_contents: fileContents,
|
|
250
|
+
_meta: {
|
|
251
|
+
reviewed_files: reviewedFiles,
|
|
252
|
+
total_changed: allUniqueFiles.size,
|
|
253
|
+
skipped: dedupedSkipped,
|
|
254
|
+
capped,
|
|
255
|
+
max_files: MAX_REVIEW_FILES,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Split a combined review request into per-file payloads.
|
|
262
|
+
* Each payload has a single file's diff and content.
|
|
263
|
+
*/
|
|
264
|
+
static splitIntoPerFileRequests(requestBody) {
|
|
265
|
+
if (!requestBody?.diff_content?.length) return [];
|
|
266
|
+
|
|
267
|
+
const fileSections = requestBody.diff_content.split(/(?=^diff --git )/m).filter(Boolean);
|
|
268
|
+
const perFileRequests = [];
|
|
269
|
+
|
|
270
|
+
for (const section of fileSections) {
|
|
271
|
+
const nameMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
272
|
+
if (!nameMatch) continue;
|
|
273
|
+
const filename = nameMatch[1];
|
|
274
|
+
|
|
275
|
+
const fileContents = {};
|
|
276
|
+
if (requestBody.file_contents?.[filename]) {
|
|
277
|
+
fileContents[filename] = requestBody.file_contents[filename];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
perFileRequests.push({
|
|
281
|
+
diff_content: section,
|
|
282
|
+
file_contents: fileContents,
|
|
283
|
+
_filename: filename,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return perFileRequests;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export default ReviewApiHelper;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { getConfigValue } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves auth tokens and base URLs for each SCM platform.
|
|
6
|
+
* Priority: env var → platform CLI → ~/.codeant/config.json → auto-detect from git origin
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function tryExec(cmd) {
|
|
10
|
+
try {
|
|
11
|
+
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Auto-detect host from git remote origin ───
|
|
18
|
+
|
|
19
|
+
const PUBLIC_HOSTS = {
|
|
20
|
+
'github.com': 'github',
|
|
21
|
+
'gitlab.com': 'gitlab',
|
|
22
|
+
'bitbucket.org': 'bitbucket',
|
|
23
|
+
'dev.azure.com': 'azure',
|
|
24
|
+
'visualstudio.com': 'azure',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let _originInfo = undefined; // cached
|
|
28
|
+
|
|
29
|
+
function getOriginInfo() {
|
|
30
|
+
if (_originInfo !== undefined) return _originInfo;
|
|
31
|
+
_originInfo = null;
|
|
32
|
+
try {
|
|
33
|
+
const origin = execSync('git remote get-url origin', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
34
|
+
|
|
35
|
+
// Parse host from SSH (git@host:owner/repo) or HTTPS (https://host/owner/repo)
|
|
36
|
+
let host = null;
|
|
37
|
+
const sshMatch = origin.match(/^[^@]+@([^:]+):/);
|
|
38
|
+
const httpsMatch = origin.match(/^https?:\/\/([^/]+)/);
|
|
39
|
+
if (sshMatch) host = sshMatch[1];
|
|
40
|
+
else if (httpsMatch) host = httpsMatch[1];
|
|
41
|
+
|
|
42
|
+
if (!host) return _originInfo;
|
|
43
|
+
|
|
44
|
+
// Determine platform from host
|
|
45
|
+
let platform = null;
|
|
46
|
+
for (const [publicHost, p] of Object.entries(PUBLIC_HOSTS)) {
|
|
47
|
+
if (host === publicHost || host.endsWith('.' + publicHost)) {
|
|
48
|
+
platform = p;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Heuristic for self-hosted: check if hostname contains platform name
|
|
53
|
+
if (!platform) {
|
|
54
|
+
if (host.includes('github')) platform = 'github';
|
|
55
|
+
else if (host.includes('gitlab')) platform = 'gitlab';
|
|
56
|
+
else if (host.includes('bitbucket')) platform = 'bitbucket';
|
|
57
|
+
else if (host.includes('azure') || host.includes('visualstudio')) platform = 'azure';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isPublic = Object.keys(PUBLIC_HOSTS).some(h => host === h);
|
|
61
|
+
const baseUrl = isPublic ? null : `https://${host}`;
|
|
62
|
+
|
|
63
|
+
_originInfo = { host, platform, baseUrl, origin };
|
|
64
|
+
} catch {}
|
|
65
|
+
return _originInfo;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectBaseUrlForPlatform(platform) {
|
|
69
|
+
const info = getOriginInfo();
|
|
70
|
+
if (info && info.platform === platform && info.baseUrl) return info.baseUrl;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Token resolvers ───
|
|
75
|
+
|
|
76
|
+
export function getGitHubToken() {
|
|
77
|
+
return process.env.GITHUB_TOKEN
|
|
78
|
+
|| process.env.GH_TOKEN
|
|
79
|
+
|| tryExec('gh auth token')
|
|
80
|
+
|| getConfigValue('githubToken')
|
|
81
|
+
|| null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getGitLabToken() {
|
|
85
|
+
return process.env.GITLAB_TOKEN
|
|
86
|
+
|| tryExec('glab auth token 2>/dev/null')
|
|
87
|
+
|| getConfigValue('gitlabToken')
|
|
88
|
+
|| null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getBitbucketToken() {
|
|
92
|
+
return process.env.BITBUCKET_TOKEN
|
|
93
|
+
|| getConfigValue('bitbucketToken')
|
|
94
|
+
|| null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getAzureDevOpsToken() {
|
|
98
|
+
return process.env.AZURE_DEVOPS_TOKEN
|
|
99
|
+
|| process.env.AZURE_DEVOPS_PAT
|
|
100
|
+
|| getConfigValue('azureDevOpsToken')
|
|
101
|
+
|| null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getTokenForRemote(remote) {
|
|
105
|
+
switch (remote) {
|
|
106
|
+
case 'github': return getGitHubToken();
|
|
107
|
+
case 'gitlab': return getGitLabToken();
|
|
108
|
+
case 'bitbucket': return getBitbucketToken();
|
|
109
|
+
case 'azure': return getAzureDevOpsToken();
|
|
110
|
+
default: throw new Error(`Unknown remote: ${remote}. Use: github, gitlab, bitbucket, azure`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Base URL resolvers for self-hosted instances ───
|
|
115
|
+
// Priority: env var → config → auto-detect from git origin
|
|
116
|
+
|
|
117
|
+
export function getGitHubBaseUrl() {
|
|
118
|
+
return process.env.GITHUB_API_URL
|
|
119
|
+
|| process.env.GH_ENTERPRISE_URL
|
|
120
|
+
|| getConfigValue('githubBaseUrl')
|
|
121
|
+
|| detectBaseUrlForPlatform('github');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getGitLabBaseUrl() {
|
|
125
|
+
return process.env.GITLAB_URL
|
|
126
|
+
|| process.env.GITLAB_HOST
|
|
127
|
+
|| getConfigValue('gitlabBaseUrl')
|
|
128
|
+
|| detectBaseUrlForPlatform('gitlab');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getBitbucketBaseUrl() {
|
|
132
|
+
return process.env.BITBUCKET_URL
|
|
133
|
+
|| process.env.BITBUCKET_SERVER_URL
|
|
134
|
+
|| getConfigValue('bitbucketBaseUrl')
|
|
135
|
+
|| detectBaseUrlForPlatform('bitbucket');
|
|
136
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import CommonApiHelper from './commonApiHelper.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transforms git diff data into the format expected by the secrets detection API
|
|
7
|
+
*
|
|
8
|
+
* API Input Format:
|
|
9
|
+
* {
|
|
10
|
+
* "files": [
|
|
11
|
+
* {
|
|
12
|
+
* "file_path": str,
|
|
13
|
+
* "code": str,
|
|
14
|
+
* "diffs": [{ "start_line": int, "end_line": int }]
|
|
15
|
+
* }
|
|
16
|
+
* ]
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const EXCLUDED_FILENAMES = new Set([
|
|
21
|
+
'package-lock.json',
|
|
22
|
+
'yarn.lock',
|
|
23
|
+
'pnpm-lock.yaml',
|
|
24
|
+
'composer.lock',
|
|
25
|
+
'Gemfile.lock',
|
|
26
|
+
'Cargo.lock',
|
|
27
|
+
'poetry.lock',
|
|
28
|
+
'Pipfile.lock',
|
|
29
|
+
'go.sum',
|
|
30
|
+
'.DS_Store',
|
|
31
|
+
'Thumbs.db',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const EXCLUDED_EXTENSIONS = new Set([
|
|
35
|
+
// Images
|
|
36
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.avif',
|
|
37
|
+
// Fonts
|
|
38
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
39
|
+
// Binary / compiled
|
|
40
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc', '.pyo',
|
|
41
|
+
'.wasm', '.jar', '.war', '.ear',
|
|
42
|
+
// Archives
|
|
43
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
44
|
+
// Media
|
|
45
|
+
'.mp3', '.mp4', '.wav', '.avi', '.mov', '.flv', '.wmv', '.ogg', '.webm',
|
|
46
|
+
// Data / generated
|
|
47
|
+
'.min.js', '.min.css', '.map',
|
|
48
|
+
'.pb', '.proto.bin',
|
|
49
|
+
// PDF / docs
|
|
50
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
51
|
+
// Data / config (often large, auto-generated)
|
|
52
|
+
'.parquet', '.avro',
|
|
53
|
+
// Database
|
|
54
|
+
'.sqlite', '.db',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const MAX_FILE_LINES = 5000;
|
|
58
|
+
const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200 KB
|
|
59
|
+
|
|
60
|
+
function isScannableFile(filePath) {
|
|
61
|
+
const basename = path.basename(filePath);
|
|
62
|
+
|
|
63
|
+
if (EXCLUDED_FILENAMES.has(basename)) return false;
|
|
64
|
+
|
|
65
|
+
const ext = path.extname(basename).toLowerCase();
|
|
66
|
+
if (EXCLUDED_EXTENSIONS.has(ext)) return false;
|
|
67
|
+
|
|
68
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
|
|
69
|
+
|
|
70
|
+
// Skip markdown files
|
|
71
|
+
if (ext === '.md') return false;
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class SecretsApiHelper extends CommonApiHelper {
|
|
77
|
+
/**
|
|
78
|
+
* Transform diff info array to API format
|
|
79
|
+
* Groups by file and extracts diff ranges
|
|
80
|
+
*/
|
|
81
|
+
_transformDiffsToApiFormat(diffs) {
|
|
82
|
+
// Group diffs by filename
|
|
83
|
+
const fileMap = new Map();
|
|
84
|
+
|
|
85
|
+
for (const diff of diffs) {
|
|
86
|
+
const filePath = diff.filename_str;
|
|
87
|
+
|
|
88
|
+
// Skip diffs with missing or invalid filename
|
|
89
|
+
if (!filePath) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!fileMap.has(filePath)) {
|
|
94
|
+
fileMap.set(filePath, {
|
|
95
|
+
file_path: filePath,
|
|
96
|
+
code: diff.head_file_str || '',
|
|
97
|
+
diffs: []
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add diff range if available
|
|
102
|
+
if (diff.start_line_str && diff.end_line_str) {
|
|
103
|
+
const startLine = parseInt(diff.start_line_str, 10);
|
|
104
|
+
const endLine = parseInt(diff.end_line_str, 10);
|
|
105
|
+
|
|
106
|
+
if (!isNaN(startLine) && !isNaN(endLine)) {
|
|
107
|
+
fileMap.get(filePath).diffs.push({
|
|
108
|
+
start_line: startLine,
|
|
109
|
+
end_line: endLine
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Array.from(fileMap.values());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Filter files based on include and exclude glob patterns
|
|
120
|
+
* @param {Array} files - Array of file objects with 'file_path' property
|
|
121
|
+
* @param {Array} includePatterns - Array of glob pattern strings to include
|
|
122
|
+
* @param {Array} excludePatterns - Array of glob pattern strings to exclude
|
|
123
|
+
*/
|
|
124
|
+
_filterFiles(files, includePatterns = [], excludePatterns = []) {
|
|
125
|
+
return files.filter(fileObj => {
|
|
126
|
+
const filePath = fileObj.file_path;
|
|
127
|
+
|
|
128
|
+
// If include patterns are specified, file must match at least one
|
|
129
|
+
if (includePatterns.length > 0) {
|
|
130
|
+
const matchesInclude = includePatterns.some(pattern => {
|
|
131
|
+
try {
|
|
132
|
+
if (pattern instanceof RegExp) {
|
|
133
|
+
return pattern.test(filePath);
|
|
134
|
+
}
|
|
135
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.warn(`Invalid include pattern: ${pattern}`, e.message);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!matchesInclude) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If exclude patterns are specified, file must not match any
|
|
148
|
+
if (excludePatterns.length > 0) {
|
|
149
|
+
const matchesExclude = excludePatterns.some(pattern => {
|
|
150
|
+
try {
|
|
151
|
+
if (pattern instanceof RegExp) {
|
|
152
|
+
return pattern.test(filePath);
|
|
153
|
+
}
|
|
154
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (matchesExclude) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the complete request body for the secrets API
|
|
172
|
+
* @param {string} type - Type of scan (staged-only, branch-diff, etc.)
|
|
173
|
+
* @param {Array} includePatterns - Optional array of glob patterns to include files
|
|
174
|
+
* @param {Array} excludePatterns - Optional array of glob patterns to exclude files
|
|
175
|
+
* @param {Object} options - Additional options like lastNCommits
|
|
176
|
+
*/
|
|
177
|
+
async buildSecretsApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = [], options = {}) {
|
|
178
|
+
let files = await this.getFilesForType(type, options);
|
|
179
|
+
|
|
180
|
+
// Handle null/undefined return
|
|
181
|
+
if (!files || !Array.isArray(files)) {
|
|
182
|
+
files = [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const totalChanged = files.length;
|
|
186
|
+
const skipped = [];
|
|
187
|
+
|
|
188
|
+
// Filter out non-scannable files (binary, lock files, markdown, etc.)
|
|
189
|
+
files = files.filter(fileObj => {
|
|
190
|
+
const filePath = fileObj.file_path;
|
|
191
|
+
|
|
192
|
+
if (!isScannableFile(filePath)) {
|
|
193
|
+
skipped.push({ file: filePath, reason: 'binary/generated' });
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Skip test files — backend filters these out anyway
|
|
198
|
+
if (filePath.toLowerCase().includes('test')) {
|
|
199
|
+
skipped.push({ file: filePath, reason: 'test file' });
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Skip deleted files (empty code)
|
|
204
|
+
if (fileObj.code === '' || fileObj.code === undefined) {
|
|
205
|
+
skipped.push({ file: filePath, reason: 'deleted' });
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Skip files exceeding size or line limits
|
|
210
|
+
if (fileObj.code) {
|
|
211
|
+
const contentSize = Buffer.byteLength(fileObj.code, 'utf-8');
|
|
212
|
+
if (contentSize > MAX_FILE_SIZE_BYTES) {
|
|
213
|
+
skipped.push({ file: filePath, reason: `too large (${(contentSize / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const lineCount = fileObj.code.split('\n').length;
|
|
217
|
+
if (lineCount > MAX_FILE_LINES) {
|
|
218
|
+
skipped.push({ file: filePath, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Apply include/exclude filters
|
|
227
|
+
const beforePatternFilter = files.length;
|
|
228
|
+
files = this._filterFiles(files, includePatterns, excludePatterns);
|
|
229
|
+
|
|
230
|
+
// Track pattern-excluded files
|
|
231
|
+
if (files.length < beforePatternFilter) {
|
|
232
|
+
// We don't have exact file names for pattern-excluded, but the count is in skipped
|
|
233
|
+
const patternExcluded = beforePatternFilter - files.length;
|
|
234
|
+
if (patternExcluded > 0) {
|
|
235
|
+
skipped.push({ file: `${patternExcluded} file(s)`, reason: 'excluded by pattern' });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const scannedFiles = files.map(f => f.file_path);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
files,
|
|
243
|
+
_meta: {
|
|
244
|
+
scanned_files: scannedFiles,
|
|
245
|
+
total_changed: totalChanged,
|
|
246
|
+
skipped,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default SecretsApiHelper;
|