codeant-cli 0.1.2 → 0.1.3
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 +45 -0
- package/package.json +1 -1
- package/src/commands/securityAnalysis.js +284 -0
- package/src/commands/staticAnalysis.js +405 -0
- package/src/index.js +70 -9
- package/src/utils/commonApiHelper.js +84 -0
- package/src/utils/secretsApiHelper.js +15 -73
- package/src/utils/securityAnalysisApiHelper.js +118 -0
- package/src/utils/staticAnalysisApiHelper.js +118 -0
package/README.md
CHANGED
|
@@ -115,6 +115,51 @@ Use `--include` and `--exclude` with glob patterns to filter files:
|
|
|
115
115
|
- `MEDIUM` - Medium confidence, may need review
|
|
116
116
|
- `FALSE_POSITIVE` - Detected but likely not a real secret (always ignored)
|
|
117
117
|
|
|
118
|
+
#### `static-analysis`
|
|
119
|
+
|
|
120
|
+
Run static code analysis to detect code quality issues, bugs, and code smells.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
codeant static-analysis [options]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Options:**
|
|
127
|
+
|
|
128
|
+
| Option | Description |
|
|
129
|
+
|--------|-------------|
|
|
130
|
+
| `--staged` | Scan only staged files (default) |
|
|
131
|
+
| `--all` | Scan all changed files compared to base branch |
|
|
132
|
+
| `--uncommitted` | Scan all uncommitted changes |
|
|
133
|
+
| `--last-commit` | Scan files from the last commit |
|
|
134
|
+
| `--fail-on <level>` | Fail on issues at or above this level (default: CRITICAL) |
|
|
135
|
+
| `--auto-fix` | Automatically apply fixes when available |
|
|
136
|
+
| `--include <patterns>` | Comma-separated glob patterns to include files |
|
|
137
|
+
| `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
|
|
138
|
+
|
|
139
|
+
**Issue Levels:** `BLOCKER` > `CRITICAL` > `MAJOR` > `MINOR` > `INFO`
|
|
140
|
+
|
|
141
|
+
#### `security-analysis`
|
|
142
|
+
|
|
143
|
+
Run security analysis to detect vulnerabilities in your code.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
codeant security-analysis [options]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Options:**
|
|
150
|
+
|
|
151
|
+
| Option | Description |
|
|
152
|
+
|--------|-------------|
|
|
153
|
+
| `--staged` | Scan only staged files (default) |
|
|
154
|
+
| `--all` | Scan all changed files compared to base branch |
|
|
155
|
+
| `--uncommitted` | Scan all uncommitted changes |
|
|
156
|
+
| `--last-commit` | Scan files from the last commit |
|
|
157
|
+
| `--fail-on <level>` | Fail on issues at or above this level (default: HIGH) |
|
|
158
|
+
| `--include <patterns>` | Comma-separated glob patterns to include files |
|
|
159
|
+
| `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
|
|
160
|
+
|
|
161
|
+
**Severity Levels:** `CRITICAL` > `HIGH` > `MEDIUM`
|
|
162
|
+
|
|
118
163
|
#### `set-base-url <url>`
|
|
119
164
|
|
|
120
165
|
Set a custom API base URL.
|
package/package.json
CHANGED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp } from 'ink';
|
|
3
|
+
import { getConfigValue } from '../utils/config.js';
|
|
4
|
+
import { fetchApi } from '../utils/fetchApi.js';
|
|
5
|
+
import SecurityAnalysisApiHelper from '../utils/securityAnalysisApiHelper.js';
|
|
6
|
+
|
|
7
|
+
export default function SecurityAnalysis({ scanType = 'staged-only', failOn = 'HIGH', include = [], exclude = [] }) {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const [status, setStatus] = useState('initializing');
|
|
10
|
+
const [issues, setIssues] = useState([]);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const [fileCount, setFileCount] = useState(0);
|
|
13
|
+
|
|
14
|
+
const apiKey = getConfigValue('apiKey');
|
|
15
|
+
|
|
16
|
+
// Handle not logged in state
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
const id = setTimeout(() => exit(new Error('Not logged in')), 0);
|
|
20
|
+
return () => clearTimeout(id);
|
|
21
|
+
}
|
|
22
|
+
}, [apiKey, exit]);
|
|
23
|
+
|
|
24
|
+
// Check if logged in
|
|
25
|
+
if (!apiKey) {
|
|
26
|
+
return React.createElement(
|
|
27
|
+
Box,
|
|
28
|
+
{ flexDirection: 'column', padding: 1 },
|
|
29
|
+
React.createElement(Text, { color: 'red' }, '✗ Not logged in.'),
|
|
30
|
+
React.createElement(Text, { color: 'gray' }, 'Run "codeant login" to authenticate.'),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Helper to check if an issue should cause failure based on failOn level
|
|
35
|
+
const shouldFailOn = (severity) => {
|
|
36
|
+
const score = severity?.toUpperCase();
|
|
37
|
+
if (score === 'FALSE_POSITIVE') return false;
|
|
38
|
+
if (failOn === 'CRITICAL') return score === 'CRITICAL';
|
|
39
|
+
if (failOn === 'HIGH') return score === 'HIGH' || score === 'CRITICAL';
|
|
40
|
+
if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
|
|
41
|
+
return true; // 'all' - fail on any non-false-positive
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
|
|
47
|
+
async function scanForIssues() {
|
|
48
|
+
try {
|
|
49
|
+
if (cancelled) return;
|
|
50
|
+
setStatus('scanning');
|
|
51
|
+
|
|
52
|
+
// Initialize git helper and get staged files
|
|
53
|
+
const helper = new SecurityAnalysisApiHelper(process.cwd());
|
|
54
|
+
await helper.init();
|
|
55
|
+
|
|
56
|
+
if (cancelled) return;
|
|
57
|
+
const requestBody = await helper.buildSecurityAnalysisApiRequest(scanType, include, exclude);
|
|
58
|
+
if (cancelled) return;
|
|
59
|
+
|
|
60
|
+
if (requestBody.files.length === 0) {
|
|
61
|
+
if (!cancelled) setStatus('no_files');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Call the security analysis API
|
|
66
|
+
const response = await fetchApi(
|
|
67
|
+
'/extension/pr-review/security-analysis',
|
|
68
|
+
'POST',
|
|
69
|
+
requestBody,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
|
|
74
|
+
// Transform security issues response to the expected format
|
|
75
|
+
const detectedIssues = (response.securityIssues || []).map(file => ({
|
|
76
|
+
file_path: file.file_path,
|
|
77
|
+
issues: (file.security_issues || []).map(issue => ({
|
|
78
|
+
line_number: issue.line_number,
|
|
79
|
+
end_line: issue.end_line,
|
|
80
|
+
type: issue.issue_text,
|
|
81
|
+
confidence_score: issue.false_positive ? 'FALSE_POSITIVE' : issue.severity,
|
|
82
|
+
test_id: issue.test_id,
|
|
83
|
+
confidence: issue.confidence,
|
|
84
|
+
likelihood: issue.likelihood,
|
|
85
|
+
cwe: issue.cwe,
|
|
86
|
+
owasp: issue.owasp,
|
|
87
|
+
})),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
// Filter to only include files with actual issues
|
|
91
|
+
const filesWithIssues = detectedIssues.filter(
|
|
92
|
+
file => file.issues && file.issues.length > 0,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (!cancelled) {
|
|
96
|
+
setIssues(filesWithIssues);
|
|
97
|
+
setStatus('done');
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (!cancelled) {
|
|
101
|
+
setError(err.message);
|
|
102
|
+
setStatus('error');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
scanForIssues();
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
cancelled = true;
|
|
111
|
+
};
|
|
112
|
+
}, [scanType, include, exclude]);
|
|
113
|
+
|
|
114
|
+
// Handle exit after status changes
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (status === 'done') {
|
|
117
|
+
// Check if any issues should cause failure based on failOn level
|
|
118
|
+
const hasBlockingIssues = issues.some(file =>
|
|
119
|
+
file.issues.some(issue => issue.false_positive !== true &&shouldFailOn(issue.severity)),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (hasBlockingIssues) {
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
exit(new Error('Security issues detected'));
|
|
126
|
+
}, 100);
|
|
127
|
+
} else {
|
|
128
|
+
setTimeout(() => exit(), 100);
|
|
129
|
+
}
|
|
130
|
+
} else if (status === 'no_files') {
|
|
131
|
+
setTimeout(() => exit(), 100);
|
|
132
|
+
} else if (status === 'error') {
|
|
133
|
+
setTimeout(() => exit(new Error(error)), 100);
|
|
134
|
+
}
|
|
135
|
+
}, [status, issues]);
|
|
136
|
+
|
|
137
|
+
// Render: Initializing
|
|
138
|
+
if (status === 'initializing') {
|
|
139
|
+
return React.createElement(
|
|
140
|
+
Box,
|
|
141
|
+
{ flexDirection: 'column', padding: 1 },
|
|
142
|
+
React.createElement(Text, { color: 'cyan' }, 'Initializing...'),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Render: Scanning
|
|
147
|
+
if (status === 'scanning') {
|
|
148
|
+
return React.createElement(
|
|
149
|
+
Box,
|
|
150
|
+
{ flexDirection: 'column', padding: 1 },
|
|
151
|
+
React.createElement(Text, { color: 'cyan' }, `Scanning ${fileCount} file(s) for security issues...`),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Render: No files to scan
|
|
156
|
+
if (status === 'no_files') {
|
|
157
|
+
const noFilesMessages = {
|
|
158
|
+
'staged-only': 'Stage some changes first with "git add".',
|
|
159
|
+
'branch-diff': 'No changes found compared to the base branch.',
|
|
160
|
+
'uncommitted': 'No uncommitted changes found.',
|
|
161
|
+
'last-commit': 'No files found in the last commit.',
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return React.createElement(
|
|
165
|
+
Box,
|
|
166
|
+
{ flexDirection: 'column', padding: 1 },
|
|
167
|
+
React.createElement(Text, { color: 'yellow' }, 'No files to scan.'),
|
|
168
|
+
React.createElement(Text, { color: 'gray' }, noFilesMessages[scanType] || 'No files found.'),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Render: Error
|
|
173
|
+
if (status === 'error') {
|
|
174
|
+
return React.createElement(
|
|
175
|
+
Box,
|
|
176
|
+
{ flexDirection: 'column', padding: 1 },
|
|
177
|
+
React.createElement(Text, { color: 'red' }, '✗ Error: ', error),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Render: Done with issues found
|
|
182
|
+
if (status === 'done' && issues.length > 0) {
|
|
183
|
+
const allIssues = issues.flatMap(file =>
|
|
184
|
+
file.issues.map(s => ({ ...s, file_path: file.file_path })),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const blockingIssues = allIssues.filter(s => s.false_positive !== true && shouldFailOn(s.severity));
|
|
188
|
+
const falsePositives = allIssues.filter(s => s.false_positive !== true && !shouldFailOn(s.severity));
|
|
189
|
+
|
|
190
|
+
const hasBlockingIssues = blockingIssues.length > 0;
|
|
191
|
+
|
|
192
|
+
// Group by file for display
|
|
193
|
+
const groupByFile = (issuesList) => {
|
|
194
|
+
const grouped = {};
|
|
195
|
+
issuesList.forEach(s => {
|
|
196
|
+
if (!grouped[s.file_path]) grouped[s.file_path] = [];
|
|
197
|
+
grouped[s.file_path].push(s);
|
|
198
|
+
});
|
|
199
|
+
return grouped;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const blockingByFile = groupByFile(blockingIssues);
|
|
203
|
+
const falsePositivesByFile = groupByFile(falsePositives);
|
|
204
|
+
|
|
205
|
+
const elements = [
|
|
206
|
+
// Header
|
|
207
|
+
React.createElement(
|
|
208
|
+
Text,
|
|
209
|
+
{ key: 'header', color: hasBlockingIssues ? 'red' : 'yellow', bold: true },
|
|
210
|
+
hasBlockingIssues
|
|
211
|
+
? `✗ ${blockingIssues.length} security issue(s) found!`
|
|
212
|
+
: `⚠ ${falsePositives.length} potential security issue(s) found (ignored)`,
|
|
213
|
+
),
|
|
214
|
+
React.createElement(Text, { key: 'spacer1' }, ''),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
// Blocking issues
|
|
218
|
+
if (blockingIssues.length > 0) {
|
|
219
|
+
Object.entries(blockingByFile).forEach(([filePath, fileIssues]) => {
|
|
220
|
+
elements.push(
|
|
221
|
+
React.createElement(Text, { key: `file-${filePath}`, color: 'yellow', bold: true }, filePath),
|
|
222
|
+
);
|
|
223
|
+
fileIssues.forEach((issue, idx) => {
|
|
224
|
+
elements.push(
|
|
225
|
+
React.createElement(
|
|
226
|
+
Text,
|
|
227
|
+
{ key: `issue-${filePath}-${idx}`, color: 'red' },
|
|
228
|
+
` Line ${issue.line_number}: ${issue.type} (${issue.confidence_score})`,
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// False positives / ignored
|
|
236
|
+
if (falsePositives.length > 0) {
|
|
237
|
+
if (blockingIssues.length > 0) {
|
|
238
|
+
elements.push(React.createElement(Text, { key: 'spacer2' }, ''));
|
|
239
|
+
}
|
|
240
|
+
elements.push(
|
|
241
|
+
React.createElement(Text, { key: 'fp-header', color: 'gray' }, 'Ignored (false positives):'),
|
|
242
|
+
);
|
|
243
|
+
Object.entries(falsePositivesByFile).forEach(([filePath, fileIssues]) => {
|
|
244
|
+
elements.push(
|
|
245
|
+
React.createElement(Text, { key: `fp-file-${filePath}`, color: 'gray' }, ` ${filePath}`),
|
|
246
|
+
);
|
|
247
|
+
fileIssues.forEach((issue, idx) => {
|
|
248
|
+
elements.push(
|
|
249
|
+
React.createElement(
|
|
250
|
+
Text,
|
|
251
|
+
{ key: `fp-issue-${filePath}-${idx}`, color: 'gray', dimColor: true },
|
|
252
|
+
` Line ${issue.line_number}: ${issue.type} (${issue.confidence_score})`,
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
elements.push(React.createElement(Text, { key: 'spacer3' }, ''));
|
|
260
|
+
|
|
261
|
+
if (hasBlockingIssues) {
|
|
262
|
+
elements.push(
|
|
263
|
+
React.createElement(Text, { key: 'footer', color: 'gray' }, 'Fix security issues before committing.'),
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
elements.push(
|
|
267
|
+
React.createElement(Text, { key: 'footer', color: 'green' }, '✓ Commit allowed (only false positives found)'),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return React.createElement(
|
|
272
|
+
Box,
|
|
273
|
+
{ flexDirection: 'column', padding: 1 },
|
|
274
|
+
...elements,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Render: Done with no issues
|
|
279
|
+
return React.createElement(
|
|
280
|
+
Box,
|
|
281
|
+
{ flexDirection: 'column', padding: 1 },
|
|
282
|
+
React.createElement(Text, { color: 'green' }, '✓ No security issues found'),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp } from 'ink';
|
|
3
|
+
import { getConfigValue } from '../utils/config.js';
|
|
4
|
+
import { fetchApi } from '../utils/fetchApi.js';
|
|
5
|
+
import StaticAnalysisApiHelper from '../utils/staticAnalysisApiHelper.js';
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
export default function StaticAnalysis({ scanType = 'staged-only', failOn = 'CRITICAL', include = [], exclude = [], autoFix = false }) {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [status, setStatus] = useState('initializing');
|
|
12
|
+
const [issues, setIssues] = useState([]);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const [fileCount, setFileCount] = useState(0);
|
|
15
|
+
const [fixedFiles, setFixedFiles] = useState([]);
|
|
16
|
+
const apiKey = getConfigValue('apiKey');
|
|
17
|
+
|
|
18
|
+
// Handle not logged in state
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!apiKey) {
|
|
21
|
+
const id = setTimeout(() => exit(new Error('Not logged in')), 0);
|
|
22
|
+
return () => clearTimeout(id);
|
|
23
|
+
}
|
|
24
|
+
}, [apiKey, exit]);
|
|
25
|
+
|
|
26
|
+
// Check if logged in
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
return React.createElement(
|
|
29
|
+
Box,
|
|
30
|
+
{ flexDirection: 'column', padding: 1 },
|
|
31
|
+
React.createElement(Text, { color: 'red' }, '✗ Not logged in.'),
|
|
32
|
+
React.createElement(Text, { color: 'gray' }, 'Run "codeant login" to authenticate.'),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Helper to check if an issue should cause failure based on failOn level
|
|
37
|
+
const shouldFailOn = (issueType) => {
|
|
38
|
+
const type = issueType?.toUpperCase();
|
|
39
|
+
const failOnUpper = failOn?.toUpperCase();
|
|
40
|
+
if (!failOnUpper || failOnUpper === 'NONE') return false;
|
|
41
|
+
if (failOnUpper === 'BLOCKER') return type === 'BLOCKER';
|
|
42
|
+
if (failOnUpper === 'CRITICAL') return type === 'BLOCKER' || type === 'CRITICAL';
|
|
43
|
+
if (failOnUpper === 'MAJOR') return type === 'BLOCKER' || type === 'CRITICAL' || type === 'MAJOR';
|
|
44
|
+
if (failOnUpper === 'MINOR') return type === 'BLOCKER' || type === 'CRITICAL' || type === 'MAJOR' || type === 'MINOR';
|
|
45
|
+
return true; // 'INFO' or 'ALL' - fail on any issue
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
|
|
51
|
+
async function scanCode() {
|
|
52
|
+
try {
|
|
53
|
+
if (cancelled) return;
|
|
54
|
+
setStatus('scanning');
|
|
55
|
+
|
|
56
|
+
// Initialize git helper and get files
|
|
57
|
+
const helper = new StaticAnalysisApiHelper(process.cwd());
|
|
58
|
+
await helper.init();
|
|
59
|
+
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
const requestBody = await helper.buildStaticAnalysisApiRequest(scanType, include, exclude);
|
|
62
|
+
setFileCount(requestBody.filesData.length);
|
|
63
|
+
|
|
64
|
+
if (requestBody.filesData.length === 0) {
|
|
65
|
+
if (!cancelled) setStatus('no_files');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Call the static analysis API
|
|
70
|
+
const response = await fetchApi(
|
|
71
|
+
'/extension/prReview/static-analysis',
|
|
72
|
+
'POST',
|
|
73
|
+
requestBody,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (cancelled) return;
|
|
77
|
+
const codeSuggestions = response.codeSuggestions || [];
|
|
78
|
+
|
|
79
|
+
// Filter to only include files with actual issues
|
|
80
|
+
const filesWithIssues = codeSuggestions.filter(
|
|
81
|
+
file => file.issues && file.issues.length > 0,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Auto-fix issues if enabled
|
|
85
|
+
const fixedFilesList = [];
|
|
86
|
+
if (autoFix && filesWithIssues.length > 0) {
|
|
87
|
+
const gitRoot = helper.getGitRoot();
|
|
88
|
+
|
|
89
|
+
for (const fileData of filesWithIssues) {
|
|
90
|
+
const fixableIssues = fileData.issues.filter(
|
|
91
|
+
issue => issue.fixAvailable && issue.fix?.inputFileEdits?.length > 0
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (fixableIssues.length > 0) {
|
|
95
|
+
try {
|
|
96
|
+
const filePath = path.join(gitRoot, fileData.file);
|
|
97
|
+
let fileContent = await fs.readFile(filePath, 'utf8');
|
|
98
|
+
const lines = fileContent.split('\n');
|
|
99
|
+
|
|
100
|
+
// Collect all text edits from all issues
|
|
101
|
+
const allEdits = [];
|
|
102
|
+
for (const issue of fixableIssues) {
|
|
103
|
+
for (const fileEdit of issue.fix.inputFileEdits) {
|
|
104
|
+
if (fileEdit.target === fileData.file) {
|
|
105
|
+
for (const textEdit of fileEdit.textEdits) {
|
|
106
|
+
allEdits.push({
|
|
107
|
+
textRange: textEdit.textRange,
|
|
108
|
+
newText: textEdit.newText,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Sort edits by position (descending) to avoid offset issues
|
|
116
|
+
allEdits.sort((a, b) => {
|
|
117
|
+
if (b.textRange.startLine !== a.textRange.startLine) {
|
|
118
|
+
return b.textRange.startLine - a.textRange.startLine;
|
|
119
|
+
}
|
|
120
|
+
if (b.textRange.startLineOffset !== a.textRange.startLineOffset) {
|
|
121
|
+
return b.textRange.startLineOffset - a.textRange.startLineOffset;
|
|
122
|
+
}
|
|
123
|
+
if (b.textRange.endLine !== a.textRange.endLine) {
|
|
124
|
+
return b.textRange.endLine - a.textRange.endLine;
|
|
125
|
+
}
|
|
126
|
+
return b.textRange.endLineOffset - a.textRange.endLineOffset;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Apply each edit
|
|
130
|
+
for (const edit of allEdits) {
|
|
131
|
+
const { startLine, startLineOffset, endLine, endLineOffset } = edit.textRange;
|
|
132
|
+
|
|
133
|
+
if (startLine === endLine) {
|
|
134
|
+
// Single line edit
|
|
135
|
+
const line = lines[startLine - 1];
|
|
136
|
+
lines[startLine - 1] =
|
|
137
|
+
line.substring(0, startLineOffset) +
|
|
138
|
+
edit.newText +
|
|
139
|
+
line.substring(endLineOffset);
|
|
140
|
+
} else {
|
|
141
|
+
// Multi-line edit
|
|
142
|
+
const firstLine = lines[startLine - 1];
|
|
143
|
+
const lastLine = lines[endLine - 1];
|
|
144
|
+
|
|
145
|
+
const newContent =
|
|
146
|
+
firstLine.substring(0, startLineOffset) +
|
|
147
|
+
edit.newText +
|
|
148
|
+
lastLine.substring(endLineOffset);
|
|
149
|
+
|
|
150
|
+
// Replace the lines
|
|
151
|
+
lines.splice(startLine - 1, endLine - startLine + 1, newContent);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await fs.writeFile(filePath, lines.join('\n'), 'utf8');
|
|
156
|
+
fixedFilesList.push({
|
|
157
|
+
file: fileData.file,
|
|
158
|
+
fixedCount: fixableIssues.length,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(`Failed to fix ${fileData.file}: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!cancelled) {
|
|
168
|
+
setFixedFiles(fixedFilesList);
|
|
169
|
+
setIssues(filesWithIssues);
|
|
170
|
+
setStatus('done');
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (!cancelled) {
|
|
174
|
+
setError(err.message);
|
|
175
|
+
setStatus('error');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
scanCode();
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
};
|
|
185
|
+
}, [scanType, include, exclude, autoFix]);
|
|
186
|
+
|
|
187
|
+
// Handle exit after status changes
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (status === 'done') {
|
|
190
|
+
// Check if any issues should cause failure based on failOn level
|
|
191
|
+
const hasBlockingIssues = issues.some(file =>
|
|
192
|
+
file.issues.some(issue => shouldFailOn(issue.type)),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (hasBlockingIssues) {
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
exit(new Error('Code issues detected'));
|
|
199
|
+
}, 100);
|
|
200
|
+
} else {
|
|
201
|
+
setTimeout(() => exit(), 100);
|
|
202
|
+
}
|
|
203
|
+
} else if (status === 'no_files') {
|
|
204
|
+
setTimeout(() => exit(), 100);
|
|
205
|
+
} else if (status === 'error') {
|
|
206
|
+
setTimeout(() => exit(new Error(error)), 100);
|
|
207
|
+
}
|
|
208
|
+
}, [status, issues, failOn, exit, shouldFailOn, error]);
|
|
209
|
+
|
|
210
|
+
// Render: Initializing
|
|
211
|
+
if (status === 'initializing') {
|
|
212
|
+
return React.createElement(
|
|
213
|
+
Box,
|
|
214
|
+
{ flexDirection: 'column', padding: 1 },
|
|
215
|
+
React.createElement(Text, { color: 'cyan' }, 'Initializing...'),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Render: Scanning
|
|
220
|
+
if (status === 'scanning') {
|
|
221
|
+
return React.createElement(
|
|
222
|
+
Box,
|
|
223
|
+
{ flexDirection: 'column', padding: 1 },
|
|
224
|
+
React.createElement(Text, { color: 'cyan' }, `Analyzing ${fileCount} file(s)...`),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Render: No files to scan
|
|
229
|
+
if (status === 'no_files') {
|
|
230
|
+
const noFilesMessages = {
|
|
231
|
+
'staged-only': 'Stage some changes first with "git add".',
|
|
232
|
+
'branch-diff': 'No changes found compared to the base branch.',
|
|
233
|
+
'uncommitted': 'No uncommitted changes found.',
|
|
234
|
+
'last-commit': 'No files found in the last commit.',
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return React.createElement(
|
|
238
|
+
Box,
|
|
239
|
+
{ flexDirection: 'column', padding: 1 },
|
|
240
|
+
React.createElement(Text, { color: 'yellow' }, 'No files to scan.'),
|
|
241
|
+
React.createElement(Text, { color: 'gray' }, noFilesMessages[scanType] || 'No files found.'),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Render: Error
|
|
246
|
+
if (status === 'error') {
|
|
247
|
+
return React.createElement(
|
|
248
|
+
Box,
|
|
249
|
+
{ flexDirection: 'column', padding: 1 },
|
|
250
|
+
React.createElement(Text, { color: 'red' }, '✗ Error: ', error),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Render: Done with issues found
|
|
255
|
+
if (status === 'done' && issues.length > 0) {
|
|
256
|
+
const allIssues = issues.flatMap(file =>
|
|
257
|
+
file.issues.map(i => ({ ...i, file_path: file.file })),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const blockingIssues = allIssues.filter(i => shouldFailOn(i.type));
|
|
261
|
+
const nonBlockingIssues = allIssues.filter(i => !shouldFailOn(i.type));
|
|
262
|
+
|
|
263
|
+
const hasBlockingIssues = blockingIssues.length > 0;
|
|
264
|
+
|
|
265
|
+
// Group by file for display
|
|
266
|
+
const groupByFile = (issuesList) => {
|
|
267
|
+
const grouped = Object.create(null);
|
|
268
|
+
issuesList.forEach(i => {
|
|
269
|
+
if (!grouped[i.file_path]) grouped[i.file_path] = [];
|
|
270
|
+
grouped[i.file_path].push(i);
|
|
271
|
+
});
|
|
272
|
+
return grouped;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const blockingByFile = groupByFile(blockingIssues);
|
|
276
|
+
const nonBlockingByFile = groupByFile(nonBlockingIssues);
|
|
277
|
+
|
|
278
|
+
// Helper to get color based on issue type
|
|
279
|
+
const getIssueColor = (type) => {
|
|
280
|
+
const t = type?.toUpperCase();
|
|
281
|
+
if (t === 'BLOCKER') return 'red';
|
|
282
|
+
if (t === 'CRITICAL') return 'red';
|
|
283
|
+
if (t === 'MAJOR') return 'red';
|
|
284
|
+
if (t === 'MINOR') return 'yellow';
|
|
285
|
+
return 'gray';
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const elements = [
|
|
289
|
+
// Header
|
|
290
|
+
React.createElement(
|
|
291
|
+
Text,
|
|
292
|
+
{ key: 'header', color: hasBlockingIssues ? 'red' : 'yellow', bold: true },
|
|
293
|
+
hasBlockingIssues
|
|
294
|
+
? `✗ ${blockingIssues.length} issue(s) found!`
|
|
295
|
+
: `⚠ ${nonBlockingIssues.length} issue(s) found (not blocking)`,
|
|
296
|
+
),
|
|
297
|
+
React.createElement(Text, { key: 'spacer1' }, ''),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
// Blocking issues
|
|
301
|
+
if (blockingIssues.length > 0) {
|
|
302
|
+
Object.entries(blockingByFile).forEach(([filePath, fileIssues]) => {
|
|
303
|
+
elements.push(
|
|
304
|
+
React.createElement(Text, { key: `file-${filePath}`, color: 'yellow', bold: true }, filePath),
|
|
305
|
+
);
|
|
306
|
+
fileIssues.forEach((issue, idx) => {
|
|
307
|
+
const color = getIssueColor(issue.type);
|
|
308
|
+
elements.push(
|
|
309
|
+
React.createElement(
|
|
310
|
+
Text,
|
|
311
|
+
{ key: `issue-${filePath}-${idx}`, color },
|
|
312
|
+
` Line ${issue.line_number}: ${issue.issue_text} (${issue.type})`,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
// Show fix suggestion if available
|
|
316
|
+
if (issue.fixAvailable && issue.fix?.message) {
|
|
317
|
+
elements.push(
|
|
318
|
+
React.createElement(
|
|
319
|
+
Text,
|
|
320
|
+
{ key: `fix-${filePath}-${idx}`, color: 'green', dimColor: true },
|
|
321
|
+
` 💡 ${issue.fix.message}`,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Non-blocking issues
|
|
330
|
+
if (nonBlockingIssues.length > 0) {
|
|
331
|
+
if (blockingIssues.length > 0) {
|
|
332
|
+
elements.push(React.createElement(Text, { key: 'spacer2' }, ''));
|
|
333
|
+
}
|
|
334
|
+
elements.push(
|
|
335
|
+
React.createElement(Text, { key: 'nb-header', color: 'gray' }, 'Additional issues (not blocking):')
|
|
336
|
+
);
|
|
337
|
+
Object.entries(nonBlockingByFile).forEach(([filePath, fileIssues]) => {
|
|
338
|
+
elements.push(
|
|
339
|
+
React.createElement(Text, { key: `nb-file-${filePath}`, color: 'gray' }, ` ${filePath}`)
|
|
340
|
+
);
|
|
341
|
+
fileIssues.forEach((issue, idx) => {
|
|
342
|
+
elements.push(
|
|
343
|
+
React.createElement(
|
|
344
|
+
Text,
|
|
345
|
+
{ key: `nb-issue-${filePath}-${idx}`, color: 'gray', dimColor: true },
|
|
346
|
+
` Line ${issue.line_number}: ${issue.issue_text} (${issue.type})`
|
|
347
|
+
)
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
elements.push(React.createElement(Text, { key: 'spacer3' }, ''));
|
|
354
|
+
|
|
355
|
+
// Show auto-fix summary
|
|
356
|
+
if (fixedFiles.length > 0) {
|
|
357
|
+
const totalFixed = fixedFiles.reduce((sum, f) => sum + f.fixedCount, 0);
|
|
358
|
+
elements.push(
|
|
359
|
+
React.createElement(Text, { key: 'autofix', color: 'green' }, `✓ Auto-fixed ${totalFixed} issue(s) in ${fixedFiles.length} file(s)`)
|
|
360
|
+
);
|
|
361
|
+
elements.push(React.createElement(Text, { key: 'spacer4' }, ''));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (hasBlockingIssues) {
|
|
365
|
+
// TODO: change message based on whether auto-fix fixed some issues or not
|
|
366
|
+
const message = (fixedFiles.length > 0)
|
|
367
|
+
? `✓ Fixed issues automatically. Please review changes before committing.`
|
|
368
|
+
: `Fix issues before committing.`;
|
|
369
|
+
elements.push(
|
|
370
|
+
React.createElement(Text, { key: 'footer', color: 'red' }, message)
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
elements.push(
|
|
374
|
+
React.createElement(Text, { key: 'footer', color: 'green' }, '✓ Commit allowed (no blocking issues)')
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return React.createElement(
|
|
379
|
+
Box,
|
|
380
|
+
{ flexDirection: 'column', padding: 1 },
|
|
381
|
+
...elements
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Render: Done with no issues
|
|
386
|
+
const elements = [];
|
|
387
|
+
|
|
388
|
+
if (fixedFiles.length > 0) {
|
|
389
|
+
const totalFixed = fixedFiles.reduce((sum, f) => sum + f.fixedCount, 0);
|
|
390
|
+
elements.push(
|
|
391
|
+
React.createElement(Text, { key: 'autofix', color: 'green' }, `✓ Auto-fixed ${totalFixed} issue(s) in ${fixedFiles.length} file(s)`)
|
|
392
|
+
);
|
|
393
|
+
elements.push(React.createElement(Text, { key: 'spacer' }, ''));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
elements.push(
|
|
397
|
+
React.createElement(Text, { key: 'no-issues', color: 'green' }, '✓ No issues found')
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return React.createElement(
|
|
401
|
+
Box,
|
|
402
|
+
{ flexDirection: 'column', padding: 1 },
|
|
403
|
+
...elements
|
|
404
|
+
);
|
|
405
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import SetBaseUrl from './commands/setBaseUrl.js';
|
|
|
8
8
|
import GetBaseUrl from './commands/getBaseUrl.js';
|
|
9
9
|
import Login from './commands/login.js';
|
|
10
10
|
import Logout from './commands/logout.js';
|
|
11
|
+
import StaticAnalysis from './commands/staticAnalysis.js';
|
|
12
|
+
import SecurityAnalysis from './commands/securityAnalysis.js';
|
|
11
13
|
import Welcome from './components/Welcome.js';
|
|
12
14
|
|
|
13
15
|
// Show welcome animation if no arguments provided
|
|
@@ -17,7 +19,7 @@ if (process.argv.length === 2) {
|
|
|
17
19
|
program
|
|
18
20
|
.name('codeant')
|
|
19
21
|
.description('Code review CLI tool')
|
|
20
|
-
.version('0.1.
|
|
22
|
+
.version('0.1.3');
|
|
21
23
|
|
|
22
24
|
program
|
|
23
25
|
.command('secrets')
|
|
@@ -36,11 +38,11 @@ program
|
|
|
36
38
|
else if (options.lastCommit) scanType = 'last-commit';
|
|
37
39
|
|
|
38
40
|
const include = options.include
|
|
39
|
-
? (Array.isArray(options.include) ? options.include : options.include.split(',')).map(s => s.trim()).filter(Boolean)
|
|
41
|
+
? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
|
|
40
42
|
: [];
|
|
41
43
|
|
|
42
44
|
const exclude = options.exclude
|
|
43
|
-
? (Array.isArray(options.exclude) ? options.exclude : options.exclude.split(',')).map(s => s.trim()).filter(Boolean)
|
|
45
|
+
? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
|
|
44
46
|
: [];
|
|
45
47
|
|
|
46
48
|
const failOn = options.failOn?.toUpperCase() || 'HIGH';
|
|
@@ -48,12 +50,71 @@ program
|
|
|
48
50
|
render(React.createElement(Secrets, { scanType, failOn, include, exclude }));
|
|
49
51
|
});
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
program
|
|
54
|
+
.command('static-analysis')
|
|
55
|
+
.description('Run static analysis on your code')
|
|
56
|
+
.option('--staged', 'Scan only staged files (default)')
|
|
57
|
+
.option('--all', 'Scan all changed files')
|
|
58
|
+
.option('--uncommitted', 'Scan uncommitted changes')
|
|
59
|
+
.option('--last-commit', 'Scan last commit')
|
|
60
|
+
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
61
|
+
.option('--auto-fix', 'Automatically apply fixes when available')
|
|
62
|
+
.option('--include <paths>', 'Comma-separated list of file paths regex to include')
|
|
63
|
+
.option('--exclude <paths>', 'Comma-separated list of file paths regex to exclude')
|
|
64
|
+
.action((options) => {
|
|
65
|
+
let scanType = 'staged-only';
|
|
66
|
+
if (options.all) scanType = 'branch-diff';
|
|
67
|
+
else if (options.uncommitted) scanType = 'uncommitted';
|
|
68
|
+
else if (options.lastCommit) scanType = 'last-commit';
|
|
69
|
+
|
|
70
|
+
const include = options.include
|
|
71
|
+
? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
|
|
72
|
+
: [];
|
|
73
|
+
|
|
74
|
+
const exclude = options.exclude
|
|
75
|
+
? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
|
|
79
|
+
const autoFix = options.autoFix || false;
|
|
80
|
+
render(React.createElement(StaticAnalysis, {scanType, failOn, include, exclude, autoFix}));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
program
|
|
84
|
+
.command('security-analysis')
|
|
85
|
+
.description('Run security analysis on your code')
|
|
86
|
+
.option('--staged', 'Scan only staged files (default)')
|
|
87
|
+
.option('--all', 'Scan all changed files')
|
|
88
|
+
.option('--uncommitted', 'Scan uncommitted changes')
|
|
89
|
+
.option('--last-commit', 'Scan last commit')
|
|
90
|
+
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
91
|
+
.option('--include <paths>', 'Comma-separated list of file paths regex to include')
|
|
92
|
+
.option('--exclude <paths>', 'Comma-separated list of file paths regex to exclude')
|
|
93
|
+
.action((options) => {
|
|
94
|
+
let scanType = 'staged-only';
|
|
95
|
+
if (options.all) scanType = 'branch-diff';
|
|
96
|
+
else if (options.uncommitted) scanType = 'uncommitted';
|
|
97
|
+
else if (options.lastCommit) scanType = 'last-commit';
|
|
98
|
+
|
|
99
|
+
const include = options.include
|
|
100
|
+
? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
|
|
101
|
+
: [];
|
|
102
|
+
|
|
103
|
+
const exclude = options.exclude
|
|
104
|
+
? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
const failOn = options.failOn?.toUpperCase() || 'HIGH';
|
|
108
|
+
|
|
109
|
+
render(React.createElement(SecurityAnalysis, { scanType, failOn, include, exclude }));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
program
|
|
113
|
+
.command('set-base-url <url>')
|
|
114
|
+
.description('Set the API base URL')
|
|
115
|
+
.action((url) => {
|
|
116
|
+
render(React.createElement(SetBaseUrl, { url }));
|
|
117
|
+
});
|
|
57
118
|
|
|
58
119
|
program
|
|
59
120
|
.command('get-base-url')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import GitDiffHelper from './gitDiffHelper.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common base class for API helpers that transform git diff data
|
|
5
|
+
* Contains shared functionality for filtering and retrieving files
|
|
6
|
+
*/
|
|
7
|
+
class CommonApiHelper {
|
|
8
|
+
constructor(workspacePath) {
|
|
9
|
+
this.workspacePath = workspacePath;
|
|
10
|
+
this.gitHelper = new GitDiffHelper(workspacePath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async init() {
|
|
14
|
+
await this.gitHelper.init();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get staged files formatted for the API
|
|
19
|
+
* This is used for pre-commit hooks
|
|
20
|
+
*/
|
|
21
|
+
async getStagedFilesForApi() {
|
|
22
|
+
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'staged-only' });
|
|
23
|
+
return this._transformDiffsToApiFormat(diffs);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get all changed files formatted for the API
|
|
28
|
+
*/
|
|
29
|
+
async getChangedFilesForApi() {
|
|
30
|
+
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'branch-diff' });
|
|
31
|
+
return this._transformDiffsToApiFormat(diffs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get uncommitted files formatted for the API
|
|
36
|
+
*/
|
|
37
|
+
async getUncommittedFilesForApi() {
|
|
38
|
+
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
|
|
39
|
+
return this._transformDiffsToApiFormat(diffs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get last commit files formatted for the API
|
|
44
|
+
*/
|
|
45
|
+
async getLastCommitFilesForApi() {
|
|
46
|
+
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'last-commit' });
|
|
47
|
+
return this._transformDiffsToApiFormat(diffs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Transform diff info array to API format
|
|
52
|
+
* Must be implemented by subclasses
|
|
53
|
+
*/
|
|
54
|
+
_transformDiffsToApiFormat(diffs) {
|
|
55
|
+
throw new Error('_transformDiffsToApiFormat must be implemented by subclass');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get files based on scan type
|
|
61
|
+
* Returns the raw array - child classes handle filtering and wrapping
|
|
62
|
+
* @param {string} type - Type of scan (staged-only, branch-diff, etc.)
|
|
63
|
+
*/
|
|
64
|
+
async getFilesForType(type = 'staged-only') {
|
|
65
|
+
switch (type) {
|
|
66
|
+
case 'staged-only':
|
|
67
|
+
return await this.getStagedFilesForApi();
|
|
68
|
+
case 'branch-diff':
|
|
69
|
+
return await this.getChangedFilesForApi();
|
|
70
|
+
case 'uncommitted':
|
|
71
|
+
return await this.getUncommittedFilesForApi();
|
|
72
|
+
case 'last-commit':
|
|
73
|
+
return await this.getLastCommitFilesForApi();
|
|
74
|
+
default:
|
|
75
|
+
return await this.getStagedFilesForApi();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getGitRoot() {
|
|
80
|
+
return this.gitHelper.getGitRoot();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default CommonApiHelper;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import CommonApiHelper from './commonApiHelper.js';
|
|
2
2
|
import { minimatch } from 'minimatch';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -15,49 +15,7 @@ import { minimatch } from 'minimatch';
|
|
|
15
15
|
* ]
|
|
16
16
|
* }
|
|
17
17
|
*/
|
|
18
|
-
class SecretsApiHelper {
|
|
19
|
-
constructor(workspacePath) {
|
|
20
|
-
this.workspacePath = workspacePath;
|
|
21
|
-
this.gitHelper = new GitDiffHelper(workspacePath);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async init() {
|
|
25
|
-
await this.gitHelper.init();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Get staged files formatted for the secrets API
|
|
30
|
-
* This is used for pre-commit hooks
|
|
31
|
-
*/
|
|
32
|
-
async getStagedFilesForApi() {
|
|
33
|
-
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'staged-only' });
|
|
34
|
-
return this._transformDiffsToApiFormat(diffs);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Get all changed files formatted for the secrets API
|
|
39
|
-
*/
|
|
40
|
-
async getChangedFilesForApi() {
|
|
41
|
-
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'branch-diff' });
|
|
42
|
-
return this._transformDiffsToApiFormat(diffs);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Get uncommitted files formatted for the secrets API
|
|
47
|
-
*/
|
|
48
|
-
async getUncommittedFilesForApi() {
|
|
49
|
-
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
|
|
50
|
-
return this._transformDiffsToApiFormat(diffs);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Get last commit files formatted for the secrets API
|
|
55
|
-
*/
|
|
56
|
-
async getLastCommitFilesForApi() {
|
|
57
|
-
const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'last-commit' });
|
|
58
|
-
return this._transformDiffsToApiFormat(diffs);
|
|
59
|
-
}
|
|
60
|
-
|
|
18
|
+
class SecretsApiHelper extends CommonApiHelper {
|
|
61
19
|
/**
|
|
62
20
|
* Transform diff info array to API format
|
|
63
21
|
* Groups by file and extracts diff ranges
|
|
@@ -69,6 +27,11 @@ class SecretsApiHelper {
|
|
|
69
27
|
for (const diff of diffs) {
|
|
70
28
|
const filePath = diff.filename_str;
|
|
71
29
|
|
|
30
|
+
// Skip diffs with missing or invalid filename
|
|
31
|
+
if (!filePath) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
72
35
|
if (!fileMap.has(filePath)) {
|
|
73
36
|
fileMap.set(filePath, {
|
|
74
37
|
file_path: filePath,
|
|
@@ -96,24 +59,21 @@ class SecretsApiHelper {
|
|
|
96
59
|
|
|
97
60
|
/**
|
|
98
61
|
* Filter files based on include and exclude glob patterns
|
|
99
|
-
* @param {Array} files - Array of file objects with file_path property
|
|
62
|
+
* @param {Array} files - Array of file objects with 'file_path' property
|
|
100
63
|
* @param {Array} includePatterns - Array of glob pattern strings to include
|
|
101
64
|
* @param {Array} excludePatterns - Array of glob pattern strings to exclude
|
|
102
|
-
* @returns {Array} Filtered array of files
|
|
103
65
|
*/
|
|
104
66
|
_filterFiles(files, includePatterns = [], excludePatterns = []) {
|
|
105
|
-
return files.filter(
|
|
106
|
-
const filePath =
|
|
67
|
+
return files.filter(fileObj => {
|
|
68
|
+
const filePath = fileObj.file_path;
|
|
107
69
|
|
|
108
70
|
// If include patterns are specified, file must match at least one
|
|
109
71
|
if (includePatterns.length > 0) {
|
|
110
72
|
const matchesInclude = includePatterns.some(pattern => {
|
|
111
73
|
try {
|
|
112
|
-
// If pattern is a RegExp, test it directly against the file path.
|
|
113
74
|
if (pattern instanceof RegExp) {
|
|
114
75
|
return pattern.test(filePath);
|
|
115
76
|
}
|
|
116
|
-
// Use matchBase option to match basename patterns like '*.js' for glob strings
|
|
117
77
|
return minimatch(filePath, pattern, { matchBase: true });
|
|
118
78
|
} catch (e) {
|
|
119
79
|
console.warn(`Invalid include pattern: ${pattern}`, e.message);
|
|
@@ -130,11 +90,9 @@ class SecretsApiHelper {
|
|
|
130
90
|
if (excludePatterns.length > 0) {
|
|
131
91
|
const matchesExclude = excludePatterns.some(pattern => {
|
|
132
92
|
try {
|
|
133
|
-
// If pattern is a RegExp, test it directly against the file path.
|
|
134
93
|
if (pattern instanceof RegExp) {
|
|
135
94
|
return pattern.test(filePath);
|
|
136
95
|
}
|
|
137
|
-
// Use matchBase option to match basename patterns like '*.js' for glob strings
|
|
138
96
|
return minimatch(filePath, pattern, { matchBase: true });
|
|
139
97
|
} catch (e) {
|
|
140
98
|
console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
|
|
@@ -158,23 +116,11 @@ class SecretsApiHelper {
|
|
|
158
116
|
* @param {Array} excludePatterns - Optional array of glob patterns to exclude files
|
|
159
117
|
*/
|
|
160
118
|
async buildSecretsApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
|
|
161
|
-
let files;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
break;
|
|
167
|
-
case 'branch-diff':
|
|
168
|
-
files = await this.getChangedFilesForApi();
|
|
169
|
-
break;
|
|
170
|
-
case 'uncommitted':
|
|
171
|
-
files = await this.getUncommittedFilesForApi();
|
|
172
|
-
break;
|
|
173
|
-
case 'last-commit':
|
|
174
|
-
files = await this.getLastCommitFilesForApi();
|
|
175
|
-
break;
|
|
176
|
-
default:
|
|
177
|
-
files = await this.getStagedFilesForApi();
|
|
119
|
+
let files = await this.getFilesForType(type);
|
|
120
|
+
|
|
121
|
+
// Handle null/undefined return
|
|
122
|
+
if (!files || !Array.isArray(files)) {
|
|
123
|
+
files = [];
|
|
178
124
|
}
|
|
179
125
|
|
|
180
126
|
// Apply include/exclude filters
|
|
@@ -182,10 +128,6 @@ class SecretsApiHelper {
|
|
|
182
128
|
|
|
183
129
|
return { files };
|
|
184
130
|
}
|
|
185
|
-
|
|
186
|
-
getGitRoot() {
|
|
187
|
-
return this.gitHelper.getGitRoot();
|
|
188
|
-
}
|
|
189
131
|
}
|
|
190
132
|
|
|
191
133
|
export default SecretsApiHelper;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import CommonApiHelper from './commonApiHelper.js';
|
|
2
|
+
import { minimatch } from 'minimatch';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transforms git diff data into the format expected by the static analysis API
|
|
6
|
+
*
|
|
7
|
+
* API Input Format:
|
|
8
|
+
* {
|
|
9
|
+
* "filesData": [
|
|
10
|
+
* {
|
|
11
|
+
* "file": str,
|
|
12
|
+
* "code": str
|
|
13
|
+
* }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
class SecurityAnalysisApiHelper extends CommonApiHelper {
|
|
18
|
+
/**
|
|
19
|
+
* Transform diff info array to API format
|
|
20
|
+
* Groups by file (without diff ranges for static analysis)
|
|
21
|
+
*/
|
|
22
|
+
_transformDiffsToApiFormat(diffs) {
|
|
23
|
+
// Group diffs by filename
|
|
24
|
+
const fileMap = new Map();
|
|
25
|
+
|
|
26
|
+
for (const diff of diffs) {
|
|
27
|
+
const filePath = diff.filename_str;
|
|
28
|
+
|
|
29
|
+
// Skip diffs with missing or invalid filename
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!fileMap.has(filePath)) {
|
|
35
|
+
fileMap.set(filePath, {
|
|
36
|
+
file_path: filePath,
|
|
37
|
+
code: diff.head_file_str || ''
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Array.from(fileMap.values());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Filter files based on include and exclude glob patterns
|
|
47
|
+
* @param {Array} files - Array of file objects with 'file' property
|
|
48
|
+
* @param {Array} includePatterns - Array of glob pattern strings to include
|
|
49
|
+
* @param {Array} excludePatterns - Array of glob pattern strings to exclude
|
|
50
|
+
*/
|
|
51
|
+
_filterFiles(files, includePatterns = [], excludePatterns = []) {
|
|
52
|
+
return files.filter(fileObj => {
|
|
53
|
+
const filePath = fileObj.file;
|
|
54
|
+
|
|
55
|
+
// If include patterns are specified, file must match at least one
|
|
56
|
+
if (includePatterns.length > 0) {
|
|
57
|
+
const matchesInclude = includePatterns.some(pattern => {
|
|
58
|
+
try {
|
|
59
|
+
if (pattern instanceof RegExp) {
|
|
60
|
+
return pattern.test(filePath);
|
|
61
|
+
}
|
|
62
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn(`Invalid include pattern: ${pattern}`, e.message);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!matchesInclude) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If exclude patterns are specified, file must not match any
|
|
75
|
+
if (excludePatterns.length > 0) {
|
|
76
|
+
const matchesExclude = excludePatterns.some(pattern => {
|
|
77
|
+
try {
|
|
78
|
+
if (pattern instanceof RegExp) {
|
|
79
|
+
return pattern.test(filePath);
|
|
80
|
+
}
|
|
81
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (matchesExclude) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the complete request body for the static analysis API
|
|
99
|
+
* @param {string} type - Type of scan (staged-only, branch-diff, etc.)
|
|
100
|
+
* @param {Array} includePatterns - Optional array of glob patterns to include files
|
|
101
|
+
* @param {Array} excludePatterns - Optional array of glob patterns to exclude files
|
|
102
|
+
*/
|
|
103
|
+
async buildSecurityAnalysisApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
|
|
104
|
+
let files = await this.getFilesForType(type);
|
|
105
|
+
|
|
106
|
+
// Handle null/undefined return
|
|
107
|
+
if (!files || !Array.isArray(files)) {
|
|
108
|
+
files = [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply include/exclude filters
|
|
112
|
+
files = this._filterFiles(files, includePatterns, excludePatterns);
|
|
113
|
+
|
|
114
|
+
return { files };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default SecurityAnalysisApiHelper;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import CommonApiHelper from './commonApiHelper.js';
|
|
2
|
+
import { minimatch } from 'minimatch';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transforms git diff data into the format expected by the static analysis API
|
|
6
|
+
*
|
|
7
|
+
* API Input Format:
|
|
8
|
+
* {
|
|
9
|
+
* "filesData": [
|
|
10
|
+
* {
|
|
11
|
+
* "file": str,
|
|
12
|
+
* "code": str
|
|
13
|
+
* }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
class StaticAnalysisApiHelper extends CommonApiHelper {
|
|
18
|
+
/**
|
|
19
|
+
* Transform diff info array to API format
|
|
20
|
+
* Groups by file (without diff ranges for static analysis)
|
|
21
|
+
*/
|
|
22
|
+
_transformDiffsToApiFormat(diffs) {
|
|
23
|
+
// Group diffs by filename
|
|
24
|
+
const fileMap = new Map();
|
|
25
|
+
|
|
26
|
+
for (const diff of diffs) {
|
|
27
|
+
const filePath = diff.filename_str;
|
|
28
|
+
|
|
29
|
+
// Skip diffs with missing or invalid filename
|
|
30
|
+
if (!filePath) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!fileMap.has(filePath)) {
|
|
35
|
+
fileMap.set(filePath, {
|
|
36
|
+
file: filePath,
|
|
37
|
+
code: diff.head_file_str || ''
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Array.from(fileMap.values());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Filter files based on include and exclude glob patterns
|
|
47
|
+
* @param {Array} files - Array of file objects with 'file' property
|
|
48
|
+
* @param {Array} includePatterns - Array of glob pattern strings to include
|
|
49
|
+
* @param {Array} excludePatterns - Array of glob pattern strings to exclude
|
|
50
|
+
*/
|
|
51
|
+
_filterFiles(files, includePatterns = [], excludePatterns = []) {
|
|
52
|
+
return files.filter(fileObj => {
|
|
53
|
+
const filePath = fileObj.file;
|
|
54
|
+
|
|
55
|
+
// If include patterns are specified, file must match at least one
|
|
56
|
+
if (includePatterns.length > 0) {
|
|
57
|
+
const matchesInclude = includePatterns.some(pattern => {
|
|
58
|
+
try {
|
|
59
|
+
if (pattern instanceof RegExp) {
|
|
60
|
+
return pattern.test(filePath);
|
|
61
|
+
}
|
|
62
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn(`Invalid include pattern: ${pattern}`, e.message);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!matchesInclude) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If exclude patterns are specified, file must not match any
|
|
75
|
+
if (excludePatterns.length > 0) {
|
|
76
|
+
const matchesExclude = excludePatterns.some(pattern => {
|
|
77
|
+
try {
|
|
78
|
+
if (pattern instanceof RegExp) {
|
|
79
|
+
return pattern.test(filePath);
|
|
80
|
+
}
|
|
81
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (matchesExclude) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the complete request body for the static analysis API
|
|
99
|
+
* @param {string} type - Type of scan (staged-only, branch-diff, etc.)
|
|
100
|
+
* @param {Array} includePatterns - Optional array of glob patterns to include files
|
|
101
|
+
* @param {Array} excludePatterns - Optional array of glob patterns to exclude files
|
|
102
|
+
*/
|
|
103
|
+
async buildStaticAnalysisApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
|
|
104
|
+
let files = await this.getFilesForType(type);
|
|
105
|
+
|
|
106
|
+
// Handle null/undefined return
|
|
107
|
+
if (!files || !Array.isArray(files)) {
|
|
108
|
+
files = [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply include/exclude filters
|
|
112
|
+
files = this._filterFiles(files, includePatterns, excludePatterns);
|
|
113
|
+
|
|
114
|
+
return { filesData: files };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default StaticAnalysisApiHelper;
|