codeant-cli 0.3.7 → 0.3.8
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 +1 -1
- package/src/commands/secrets.js +5 -13
- package/src/components/SecretsUI.js +6 -44
- package/src/index.js +21 -6
- package/src/reviewHeadless.js +30 -16
- package/src/utils/fetchApi.js +5 -0
- package/src/utils/installPushProtectionHook.js +15 -19
- package/src/utils/secretsDetector.js +0 -1
package/package.json
CHANGED
package/src/commands/secrets.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
renderDone,
|
|
12
12
|
} from '../components/SecretsUI.js';
|
|
13
13
|
|
|
14
|
-
export default function Secrets({ scanType = 'all',
|
|
14
|
+
export default function Secrets({ scanType = 'all', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
|
|
15
15
|
const { exit } = useApp();
|
|
16
16
|
const [status, setStatus] = useState('initializing');
|
|
17
17
|
const [secrets, setSecrets] = useState([]);
|
|
@@ -20,14 +20,6 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
20
20
|
const [scanMeta, setScanMeta] = useState(null);
|
|
21
21
|
const [startTime] = useState(() => Date.now());
|
|
22
22
|
|
|
23
|
-
const shouldFailOn = (confidenceScore) => {
|
|
24
|
-
const score = confidenceScore?.toUpperCase();
|
|
25
|
-
if (score === 'FALSE_POSITIVE') return false;
|
|
26
|
-
if (failOn === 'HIGH') return score === 'HIGH';
|
|
27
|
-
if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
|
|
28
|
-
return true;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
23
|
useEffect(() => {
|
|
32
24
|
let cancelled = false;
|
|
33
25
|
|
|
@@ -81,10 +73,10 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
81
73
|
|
|
82
74
|
useEffect(() => {
|
|
83
75
|
if (status === 'done') {
|
|
84
|
-
const
|
|
85
|
-
file.secrets.
|
|
76
|
+
const hasSecrets = secrets.some(file =>
|
|
77
|
+
file.secrets.length > 0
|
|
86
78
|
);
|
|
87
|
-
if (
|
|
79
|
+
if (hasSecrets) {
|
|
88
80
|
setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
|
|
89
81
|
} else {
|
|
90
82
|
setTimeout(() => exit(), 100);
|
|
@@ -101,7 +93,7 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
101
93
|
if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
|
|
102
94
|
if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
|
|
103
95
|
if (status === 'error') return renderError(error);
|
|
104
|
-
if (status === 'done') return renderDone(secrets,
|
|
96
|
+
if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
|
|
105
97
|
|
|
106
98
|
return null;
|
|
107
99
|
}
|
|
@@ -7,19 +7,8 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧',
|
|
|
7
7
|
const DIVIDER = '─'.repeat(55);
|
|
8
8
|
const STEPS = ['Init', 'Fetch', 'Scan'];
|
|
9
9
|
|
|
10
|
-
const CONFIDENCE_COLORS = {
|
|
11
|
-
HIGH: 'red',
|
|
12
|
-
MEDIUM: 'yellow',
|
|
13
|
-
LOW: 'cyan',
|
|
14
|
-
FALSE_POSITIVE: 'gray',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
10
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
11
|
|
|
19
|
-
function getConfidenceColor(score) {
|
|
20
|
-
return CONFIDENCE_COLORS[score?.toUpperCase()] || 'gray';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
function padEnd(str, len) {
|
|
24
13
|
return str + ' '.repeat(Math.max(0, len - str.length));
|
|
25
14
|
}
|
|
@@ -211,22 +200,13 @@ export function renderNotLoggedIn() {
|
|
|
211
200
|
);
|
|
212
201
|
}
|
|
213
202
|
|
|
214
|
-
export function renderDone(secrets,
|
|
203
|
+
export function renderDone(secrets, startTime, fileCount, meta) {
|
|
215
204
|
const allSecrets = secrets.flatMap(file =>
|
|
216
205
|
file.secrets.map(s => ({ ...s, file_path: file.file_path }))
|
|
217
206
|
);
|
|
218
207
|
|
|
219
|
-
const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
|
|
220
|
-
const hasBlocking = blockingSecrets.length > 0;
|
|
221
208
|
const elapsed = startTime ? formatElapsed(startTime) : null;
|
|
222
209
|
|
|
223
|
-
// Count by confidence
|
|
224
|
-
const counts = {};
|
|
225
|
-
allSecrets.forEach(s => {
|
|
226
|
-
const conf = s.confidence_score?.toUpperCase() || 'UNKNOWN';
|
|
227
|
-
counts[conf] = (counts[conf] || 0) + 1;
|
|
228
|
-
});
|
|
229
|
-
|
|
230
210
|
// Group by file
|
|
231
211
|
const grouped = {};
|
|
232
212
|
allSecrets.forEach(s => {
|
|
@@ -269,9 +249,6 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
269
249
|
));
|
|
270
250
|
|
|
271
251
|
fileSecrets.forEach((secret, si) => {
|
|
272
|
-
const conf = secret.confidence_score?.toUpperCase() || 'UNKNOWN';
|
|
273
|
-
const confColor = getConfidenceColor(conf);
|
|
274
|
-
const isBlocking = shouldFailOn(secret.confidence_score);
|
|
275
252
|
const isLast = si === fileSecrets.length - 1;
|
|
276
253
|
const branch = isLast ? '└─' : '├─';
|
|
277
254
|
const lineStr = secret.line_number ? `L${secret.line_number}` : '';
|
|
@@ -279,12 +256,8 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
279
256
|
els.push(React.createElement(
|
|
280
257
|
Box, { key: `s-${fi}-${si}` },
|
|
281
258
|
React.createElement(Text, { color: 'gray' }, ` ${branch} `),
|
|
282
|
-
React.createElement(Text, {
|
|
283
|
-
color: confColor,
|
|
284
|
-
bold: isBlocking,
|
|
285
|
-
}, padEnd(conf, 16)),
|
|
286
259
|
React.createElement(Text, { color: 'gray' }, padEnd(lineStr, 6)),
|
|
287
|
-
React.createElement(Text, { color:
|
|
260
|
+
React.createElement(Text, { color: 'white' }, secret.type || ''),
|
|
288
261
|
));
|
|
289
262
|
});
|
|
290
263
|
|
|
@@ -330,28 +303,17 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
330
303
|
|
|
331
304
|
// Secret stats line
|
|
332
305
|
if (allSecrets.length > 0) {
|
|
333
|
-
const stats = [`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`];
|
|
334
|
-
for (const conf of ['HIGH', 'MEDIUM', 'LOW', 'FALSE_POSITIVE']) {
|
|
335
|
-
if (counts[conf]) {
|
|
336
|
-
const label = conf === 'FALSE_POSITIVE' ? 'false positive' : conf.toLowerCase();
|
|
337
|
-
stats.push(`${counts[conf]} ${label}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
306
|
els.push(React.createElement(
|
|
341
|
-
Text, { key: 'stats', color: 'gray' },
|
|
307
|
+
Text, { key: 'stats', color: 'gray' },
|
|
308
|
+
`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`,
|
|
342
309
|
));
|
|
343
310
|
}
|
|
344
311
|
|
|
345
312
|
// Status line
|
|
346
|
-
if (
|
|
313
|
+
if (allSecrets.length > 0) {
|
|
347
314
|
els.push(React.createElement(
|
|
348
315
|
Text, { key: 'status', color: 'red', bold: true },
|
|
349
|
-
`✗ ${
|
|
350
|
-
));
|
|
351
|
-
} else if (allSecrets.length > 0) {
|
|
352
|
-
els.push(React.createElement(
|
|
353
|
-
Text, { key: 'status', color: 'green', bold: true },
|
|
354
|
-
'✓ No blocking secrets found',
|
|
316
|
+
`✗ ${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found — remove before committing`,
|
|
355
317
|
));
|
|
356
318
|
} else {
|
|
357
319
|
els.push(React.createElement(
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import GetBaseUrl from './commands/getBaseUrl.js';
|
|
|
10
10
|
import Login from './commands/login.js';
|
|
11
11
|
import Logout from './commands/logout.js';
|
|
12
12
|
import Review from './commands/review.js';
|
|
13
|
+
import { runReviewHeadless } from './reviewHeadless.js';
|
|
13
14
|
import Welcome from './components/Welcome.js';
|
|
14
15
|
import * as scm from './scm/index.js';
|
|
15
16
|
import { setConfigValue } from './utils/config.js';
|
|
@@ -58,7 +59,6 @@ program
|
|
|
58
59
|
.option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
|
|
59
60
|
.option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
|
|
60
61
|
.option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
|
|
61
|
-
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
62
62
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
63
63
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
64
64
|
.action((options) => {
|
|
@@ -95,9 +95,7 @@ program
|
|
|
95
95
|
? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
|
|
96
96
|
: [];
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
98
|
+
render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
101
99
|
});
|
|
102
100
|
|
|
103
101
|
program
|
|
@@ -114,7 +112,8 @@ program
|
|
|
114
112
|
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
115
113
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
116
114
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
117
|
-
.
|
|
115
|
+
.option('--headless', 'Output clean JSON with no spinners (for agents and CI)')
|
|
116
|
+
.action(async (options) => {
|
|
118
117
|
let scanType = 'all';
|
|
119
118
|
let lastNCommits = 1;
|
|
120
119
|
let baseBranch = null;
|
|
@@ -150,7 +149,23 @@ program
|
|
|
150
149
|
|
|
151
150
|
const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
if (options.headless) {
|
|
153
|
+
const result = await runReviewHeadless({
|
|
154
|
+
workspacePath: process.cwd(),
|
|
155
|
+
scanType,
|
|
156
|
+
lastNCommits,
|
|
157
|
+
include,
|
|
158
|
+
exclude,
|
|
159
|
+
baseBranch,
|
|
160
|
+
baseCommit,
|
|
161
|
+
onProgress: (msg) => console.error(`[progress] ${msg}`),
|
|
162
|
+
onFilesReady: (files, meta) => console.error(`[files] Reviewing ${files.length} file(s)`),
|
|
163
|
+
});
|
|
164
|
+
console.log(JSON.stringify(result, null, 2));
|
|
165
|
+
process.exit(result.error ? 1 : 0);
|
|
166
|
+
} else {
|
|
167
|
+
render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
|
|
168
|
+
}
|
|
154
169
|
});
|
|
155
170
|
|
|
156
171
|
program
|
package/src/reviewHeadless.js
CHANGED
|
@@ -126,9 +126,9 @@ export async function runReviewHeadless(options = {}) {
|
|
|
126
126
|
|
|
127
127
|
onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
|
|
128
128
|
|
|
129
|
-
// ── Per-file agent turn loops (parallel)
|
|
129
|
+
// ── Per-file agent turn loops (parallel, fault-tolerant) ─────────────
|
|
130
130
|
const perFileResults = await Promise.all(
|
|
131
|
-
perFileRequests.map((fileReq) => {
|
|
131
|
+
perFileRequests.map(async (fileReq) => {
|
|
132
132
|
const filename = fileReq._filename;
|
|
133
133
|
delete fileReq._filename;
|
|
134
134
|
|
|
@@ -138,7 +138,15 @@ export async function runReviewHeadless(options = {}) {
|
|
|
138
138
|
}
|
|
139
139
|
delete fileReq.file_contents;
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
try {
|
|
142
|
+
onProgress(`Reviewing ${filename}...`);
|
|
143
|
+
const result = await runTurnLoop(fileReq, gitRoot, false);
|
|
144
|
+
onProgress(`Done reviewing ${filename}`);
|
|
145
|
+
return result;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(`[error] Failed to review ${filename}: ${err.message}`);
|
|
148
|
+
return { finalMessage: null, finalOutput: null };
|
|
149
|
+
}
|
|
142
150
|
})
|
|
143
151
|
);
|
|
144
152
|
|
|
@@ -149,20 +157,26 @@ export async function runReviewHeadless(options = {}) {
|
|
|
149
157
|
output: perFileResults[i].finalOutput,
|
|
150
158
|
})).filter(r => r.output?.code_suggestions?.length > 0);
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
onProgress(`${perFileWithSuggestions.length} file(s) have suggestions, running reflector...`);
|
|
161
|
+
|
|
162
|
+
// ── Per-file reflector loops (parallel, fault-tolerant) ──────────────
|
|
154
163
|
const reflectorResults = await Promise.all(
|
|
155
|
-
perFileWithSuggestions.map(({ diff_content, suggestions }) =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
perFileWithSuggestions.map(async ({ diff_content, suggestions }, i) => {
|
|
165
|
+
try {
|
|
166
|
+
return await runTurnLoop(
|
|
167
|
+
{
|
|
168
|
+
diff_content,
|
|
169
|
+
prompt_template_name: 'reflector',
|
|
170
|
+
extra_variables: { suggestion_str: suggestions },
|
|
171
|
+
},
|
|
172
|
+
gitRoot,
|
|
173
|
+
true
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[error] Reflector failed for file ${i}: ${err.message}`);
|
|
177
|
+
return { finalMessage: null, finalOutput: null };
|
|
178
|
+
}
|
|
179
|
+
})
|
|
166
180
|
);
|
|
167
181
|
|
|
168
182
|
// ── Parse results ───────────────────────────────────────────────────
|
package/src/utils/fetchApi.js
CHANGED
|
@@ -23,6 +23,11 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
|
|
|
23
23
|
|
|
24
24
|
try {
|
|
25
25
|
const response = await fetch(url, options);
|
|
26
|
+
|
|
27
|
+
if (response.status === 403) {
|
|
28
|
+
throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
const data = await response.json();
|
|
27
32
|
|
|
28
33
|
if (!response.ok) {
|
|
@@ -3,32 +3,29 @@ import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync, mkdirSy
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
5
|
const HOOK_MARKER = '# codeant-push-protection';
|
|
6
|
+
const HOOK_MARKER_END = '# end-codeant-push-protection';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Build the full pre-push hook script (with shebang).
|
|
9
|
-
* @param {string} failOn - Severity threshold (e.g. "HIGH", "CRITICAL")
|
|
10
10
|
*/
|
|
11
|
-
function buildHookScript(
|
|
11
|
+
function buildHookScript() {
|
|
12
12
|
return `#!/bin/sh
|
|
13
|
-
${buildHookBlock(
|
|
13
|
+
${buildHookBlock()}
|
|
14
14
|
`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Build just the CodeAnt block (no shebang), used when appending to an existing hook.
|
|
19
|
-
* @param {string} failOn - Severity threshold
|
|
20
19
|
*/
|
|
21
|
-
function buildHookBlock(
|
|
20
|
+
function buildHookBlock() {
|
|
22
21
|
return `${HOOK_MARKER}
|
|
23
22
|
# Auto-installed by CodeAnt AI — blocks pushes containing secrets.
|
|
24
23
|
# To disable: delete this hook or run "codeant push-protection disable"
|
|
25
24
|
command -v codeant >/dev/null 2>&1 || exit 0
|
|
26
|
-
codeant secrets --committed
|
|
25
|
+
codeant secrets --committed
|
|
27
26
|
${HOOK_MARKER_END}`;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
const HOOK_MARKER_END = '# end-codeant-push-protection';
|
|
31
|
-
|
|
32
29
|
/**
|
|
33
30
|
* Replace the CodeAnt block in a hook file with new content (or remove it).
|
|
34
31
|
*/
|
|
@@ -37,7 +34,6 @@ function replaceCodeAntBlock(fileContent, newBlock) {
|
|
|
37
34
|
let endIdx = fileContent.indexOf(HOOK_MARKER_END);
|
|
38
35
|
if (startIdx === -1) return fileContent;
|
|
39
36
|
if (endIdx === -1) {
|
|
40
|
-
// Legacy hook without end marker — remove from start marker to EOF
|
|
41
37
|
endIdx = fileContent.length;
|
|
42
38
|
} else {
|
|
43
39
|
endIdx += HOOK_MARKER_END.length;
|
|
@@ -85,13 +81,9 @@ function getHooksDir(gitRoot) {
|
|
|
85
81
|
* Install a pre-push hook that runs secret scanning before push.
|
|
86
82
|
*
|
|
87
83
|
* @param {string} workspacePath - Path to the git repository
|
|
88
|
-
* @param {object} [options]
|
|
89
|
-
* @param {string} [options.failOn="HIGH"] - Severity threshold
|
|
90
84
|
* @returns {{ installed: boolean, hookPath: string|null, message: string }}
|
|
91
85
|
*/
|
|
92
|
-
export function installPushProtectionHook(workspacePath
|
|
93
|
-
const { failOn = 'HIGH' } = options;
|
|
94
|
-
|
|
86
|
+
export function installPushProtectionHook(workspacePath) {
|
|
95
87
|
const gitRoot = findGitRoot(workspacePath);
|
|
96
88
|
if (!gitRoot) {
|
|
97
89
|
return { installed: false, hookPath: null, message: 'Not a git repository' };
|
|
@@ -108,19 +100,24 @@ export function installPushProtectionHook(workspacePath, options = {}) {
|
|
|
108
100
|
const existing = readFileSync(hookPath, 'utf-8');
|
|
109
101
|
if (existing.includes(HOOK_MARKER)) {
|
|
110
102
|
// Replace only our block, preserve everything else
|
|
111
|
-
const updated = replaceCodeAntBlock(existing, buildHookBlock(
|
|
103
|
+
const updated = replaceCodeAntBlock(existing, buildHookBlock());
|
|
112
104
|
writeFileSync(hookPath, updated, 'utf-8');
|
|
113
105
|
chmodSync(hookPath, 0o755);
|
|
114
106
|
return { installed: true, hookPath, message: 'Hook updated' };
|
|
115
107
|
}
|
|
116
|
-
// There's a user-managed hook — append
|
|
117
|
-
const
|
|
108
|
+
// There's a user-managed hook — append only for shell hooks to avoid breaking non-shell scripts
|
|
109
|
+
const firstLine = existing.split('\n', 1)[0] || '';
|
|
110
|
+
const isShellHook = firstLine.startsWith('#!') ? /\/(ba|z|k)?sh(\s|$)/.test(firstLine) : true;
|
|
111
|
+
if (!isShellHook) {
|
|
112
|
+
return { installed: false, hookPath, message: 'Existing pre-push hook is non-shell; cannot append CodeAnt block safely' };
|
|
113
|
+
}
|
|
114
|
+
const appended = existing.trimEnd() + '\n\n' + buildHookBlock() + '\n';
|
|
118
115
|
writeFileSync(hookPath, appended, 'utf-8');
|
|
119
116
|
chmodSync(hookPath, 0o755);
|
|
120
117
|
return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
|
|
121
118
|
}
|
|
122
119
|
|
|
123
|
-
writeFileSync(hookPath, buildHookScript(
|
|
120
|
+
writeFileSync(hookPath, buildHookScript(), 'utf-8');
|
|
124
121
|
chmodSync(hookPath, 0o755);
|
|
125
122
|
return { installed: true, hookPath, message: 'Hook installed' };
|
|
126
123
|
}
|
|
@@ -152,7 +149,6 @@ export function removePushProtectionHook(workspacePath) {
|
|
|
152
149
|
// Remove our block (between start and end markers)
|
|
153
150
|
const remaining = replaceCodeAntBlock(content, '').trim();
|
|
154
151
|
if (!remaining || remaining === '#!/bin/sh') {
|
|
155
|
-
// Nothing left — delete the file
|
|
156
152
|
unlinkSync(hookPath);
|
|
157
153
|
return { removed: true, message: 'Hook removed' };
|
|
158
154
|
}
|