codeant-cli 0.4.6 → 0.4.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 +8 -2
- package/src/commands/scans/dismissed.js +26 -0
- package/src/commands/scans/formatters/csv.js +24 -0
- package/src/commands/scans/formatters/index.js +12 -0
- package/src/commands/scans/formatters/json.js +8 -0
- package/src/commands/scans/formatters/md.js +63 -0
- package/src/commands/scans/formatters/sarif.js +105 -0
- package/src/commands/scans/formatters/table.js +83 -0
- package/src/commands/scans/get.js +64 -0
- package/src/commands/scans/history.js +43 -0
- package/src/commands/scans/index.js +146 -0
- package/src/commands/scans/lib/categories.js +51 -0
- package/src/commands/scans/lib/dismissMatch.js +32 -0
- package/src/commands/scans/lib/emit.js +25 -0
- package/src/commands/scans/lib/filters.js +72 -0
- package/src/commands/scans/lib/log.js +25 -0
- package/src/commands/scans/lib/normalize.js +155 -0
- package/src/commands/scans/lib/paginate.js +22 -0
- package/src/commands/scans/lib/resolveScan.js +50 -0
- package/src/commands/scans/lib/sort.js +19 -0
- package/src/commands/scans/orgs.js +15 -0
- package/src/commands/scans/repos.js +41 -0
- package/src/commands/scans/results.js +156 -0
- package/src/commands/scans/start-scan.js +90 -0
- package/src/components/ScanCenter.js +337 -0
- package/src/components/SelectList.js +80 -0
- package/src/index.js +12 -0
- package/src/scanCenter/handleSelectConnection.js +14 -0
- package/src/scanCenter/handleSelectRepo.js +19 -0
- package/src/scanCenter/handleSelectResultType.js +34 -0
- package/src/scanCenter/handleSelectScan.js +4 -0
- package/src/scanCenter/validateConnectionOnMount.js +15 -0
- package/src/scans/connectionHandler.js +52 -0
- package/src/scans/fetchAdvancedScanResults.js +325 -0
- package/src/scans/fetchDismissedAlerts.js +57 -0
- package/src/scans/fetchScanResults.js +115 -0
- package/src/scans/getScanHistory.js +38 -0
- package/src/scans/listRepos.js +38 -0
- package/src/scans/startScan.js +22 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { detectRepoName } from '../../scm/index.js';
|
|
4
|
+
import { startScan } from '../../scans/startScan.js';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
// Mirrors splitGlobs in src/index.js (not exported) — preserves commas inside {} brace expansions
|
|
10
|
+
function splitGlobs(input) {
|
|
11
|
+
const parts = [];
|
|
12
|
+
let current = '';
|
|
13
|
+
let depth = 0;
|
|
14
|
+
for (const ch of String(input)) {
|
|
15
|
+
if (ch === '{') depth++;
|
|
16
|
+
else if (ch === '}') depth--;
|
|
17
|
+
if (ch === ',' && depth === 0) {
|
|
18
|
+
parts.push(current.trim());
|
|
19
|
+
current = '';
|
|
20
|
+
} else {
|
|
21
|
+
current += ch;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (current.trim()) parts.push(current.trim());
|
|
25
|
+
return parts.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveCurrentBranch() {
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
|
|
31
|
+
return stdout.trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function resolveRemoteCommit(branch) {
|
|
38
|
+
try {
|
|
39
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', 'origin', `refs/heads/${branch}`]);
|
|
40
|
+
const sha = stdout.trim().split(/\s+/)[0];
|
|
41
|
+
if (!sha) {
|
|
42
|
+
const err = new Error(`Branch "${branch}" not found on remote origin. Pass --commit <sha> explicitly.`);
|
|
43
|
+
err.exitCode = 1;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return sha;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.exitCode) throw err;
|
|
49
|
+
const wrapped = new Error(`Could not resolve commit for branch "${branch}": ${err.message}. Pass --commit <sha> explicitly.`);
|
|
50
|
+
wrapped.exitCode = 1;
|
|
51
|
+
throw wrapped;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runStartScan({ repo, branch, commit, include, exclude } = {}) {
|
|
56
|
+
const resolvedRepo = repo || detectRepoName();
|
|
57
|
+
if (!resolvedRepo) {
|
|
58
|
+
const err = new Error('Could not detect repo name. Use --repo owner/repo');
|
|
59
|
+
err.exitCode = 1;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolvedBranch = branch || await resolveCurrentBranch();
|
|
64
|
+
if (!resolvedBranch) {
|
|
65
|
+
const err = new Error('Could not detect current branch. Use --branch <name>');
|
|
66
|
+
err.exitCode = 1;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const commitId = commit || await resolveRemoteCommit(resolvedBranch);
|
|
71
|
+
|
|
72
|
+
const includeFiles = include ? splitGlobs(include) : [];
|
|
73
|
+
const excludeFiles = exclude ? splitGlobs(exclude) : [];
|
|
74
|
+
|
|
75
|
+
const result = await startScan({
|
|
76
|
+
repo: resolvedRepo,
|
|
77
|
+
branch: resolvedBranch,
|
|
78
|
+
commitId,
|
|
79
|
+
includeFiles: includeFiles.length ? includeFiles : undefined,
|
|
80
|
+
excludeFiles: excludeFiles.length ? excludeFiles : undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
const err = new Error(result.error || 'Failed to start scan');
|
|
85
|
+
err.exitCode = 1;
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(result.message || 'Analysis started');
|
|
90
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import SelectList from './SelectList.js';
|
|
4
|
+
import { validateConnectionOnMount } from '../scanCenter/validateConnectionOnMount.js';
|
|
5
|
+
import { handleSelectConnection as _handleSelectConnection } from '../scanCenter/handleSelectConnection.js';
|
|
6
|
+
import { handleSelectRepo as _handleSelectRepo } from '../scanCenter/handleSelectRepo.js';
|
|
7
|
+
import { handleSelectScan as _handleSelectScan } from '../scanCenter/handleSelectScan.js';
|
|
8
|
+
import { handleSelectResultType as _handleSelectResultType } from '../scanCenter/handleSelectResultType.js';
|
|
9
|
+
|
|
10
|
+
const ce = React.createElement;
|
|
11
|
+
|
|
12
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const STEPS = {
|
|
15
|
+
LOADING: 'loading',
|
|
16
|
+
SELECT_CONNECTION: 'select-connection',
|
|
17
|
+
SELECT_REPO: 'select-repo',
|
|
18
|
+
SELECT_SCAN: 'select-scan',
|
|
19
|
+
SELECT_RESULT_TYPE: 'select-result-type',
|
|
20
|
+
SHOWING_RESULTS: 'showing-results',
|
|
21
|
+
ERROR: 'error',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const RESULT_TYPES = [
|
|
25
|
+
{ label: 'Security Issues (SAST)', value: 'security_issues', kind: 'basic' },
|
|
26
|
+
{ label: 'Anti-Patterns', value: 'anti_patterns', kind: 'basic' },
|
|
27
|
+
{ label: 'Docstring Issues', value: 'docstring', kind: 'basic' },
|
|
28
|
+
{ label: 'Complex Functions', value: 'complex_functions', kind: 'basic' },
|
|
29
|
+
{ label: 'SCA — Dependencies', value: 'sca', kind: 'advanced' },
|
|
30
|
+
{ label: 'SBOM', value: 'sbom', kind: 'advanced' },
|
|
31
|
+
{ label: 'Secrets', value: 'secrets', kind: 'advanced' },
|
|
32
|
+
{ label: 'IaC (Infrastructure)', value: 'iac', kind: 'advanced' },
|
|
33
|
+
{ label: 'Dead Code', value: 'dead_code', kind: 'advanced' },
|
|
34
|
+
{ label: 'Dismissed Alerts', value: 'dismissed_alerts', kind: 'dismissed' },
|
|
35
|
+
{ label: 'Dismissed Secrets', value: 'dismissed_secrets', kind: 'dismissed' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
39
|
+
const RESULTS_PAGE = 15;
|
|
40
|
+
|
|
41
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function severityColor(sev) {
|
|
44
|
+
if (!sev) return 'gray';
|
|
45
|
+
const s = sev.toLowerCase();
|
|
46
|
+
if (s === 'critical' || s === 'high') return 'red';
|
|
47
|
+
if (s === 'medium' || s === 'warning') return 'yellow';
|
|
48
|
+
if (s === 'low') return 'cyan';
|
|
49
|
+
return 'gray';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function severityLabel(sev) {
|
|
53
|
+
if (!sev) return 'INFO';
|
|
54
|
+
return sev.toUpperCase().slice(0, 4);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Spinner ─────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function Spinner({ label }) {
|
|
60
|
+
const [frame, setFrame] = useState(0);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 100);
|
|
63
|
+
return () => clearInterval(id);
|
|
64
|
+
}, []);
|
|
65
|
+
return ce(
|
|
66
|
+
Box,
|
|
67
|
+
null,
|
|
68
|
+
ce(Text, { color: 'cyan' }, SPINNER_FRAMES[frame] + ' '),
|
|
69
|
+
ce(Text, null, label)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Breadcrumb ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function Breadcrumb({ parts }) {
|
|
76
|
+
return ce(
|
|
77
|
+
Box,
|
|
78
|
+
{ marginBottom: 1 },
|
|
79
|
+
...parts.map((p, i) =>
|
|
80
|
+
ce(Text, { key: i, color: i === parts.length - 1 ? 'cyan' : 'gray' },
|
|
81
|
+
(i > 0 ? ' › ' : '') + p)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Results view ────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function ResultsView({ issues, resultTypeLabel, breadcrumbParts, onBack }) {
|
|
89
|
+
const [offset, setOffset] = useState(0);
|
|
90
|
+
|
|
91
|
+
useInput((input, key) => {
|
|
92
|
+
if (input === 'b' || key.escape) { onBack(); return; }
|
|
93
|
+
if (key.downArrow) setOffset((o) => Math.min(o + 1, Math.max(0, issues.length - RESULTS_PAGE)));
|
|
94
|
+
if (key.upArrow) setOffset((o) => Math.max(0, o - 1));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const visible = issues.slice(offset, offset + RESULTS_PAGE);
|
|
98
|
+
|
|
99
|
+
const rows = issues.length === 0
|
|
100
|
+
? [ce(Box, { key: 'empty', marginBottom: 1 }, ce(Text, { color: 'green' }, '✓ No issues found'))]
|
|
101
|
+
: [
|
|
102
|
+
ce(Box, { key: 'counter', marginBottom: 1 },
|
|
103
|
+
ce(Text, { color: 'gray' },
|
|
104
|
+
`Showing ${offset + 1}–${Math.min(offset + RESULTS_PAGE, issues.length)} of ${issues.length} issues`)
|
|
105
|
+
),
|
|
106
|
+
...visible.map((issue, i) =>
|
|
107
|
+
ce(
|
|
108
|
+
Box,
|
|
109
|
+
{ key: i, flexDirection: 'row', gap: 1 },
|
|
110
|
+
ce(Text, { color: severityColor(issue.severity), bold: true },
|
|
111
|
+
`[${severityLabel(issue.severity)}]`),
|
|
112
|
+
ce(Text, { color: 'gray' },
|
|
113
|
+
(issue.file_path || 'unknown') + (issue.line_number ? `:${issue.line_number}` : '')),
|
|
114
|
+
ce(Text, null,
|
|
115
|
+
issue.check_name || issue.issue_text || issue.message || issue.test_id || issue.type || issue.reason_for_dismiss || 'Dismissed finding')
|
|
116
|
+
)
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return ce(
|
|
121
|
+
Box,
|
|
122
|
+
{ flexDirection: 'column', paddingX: 1 },
|
|
123
|
+
ce(Breadcrumb, { parts: [...breadcrumbParts, resultTypeLabel] }),
|
|
124
|
+
...rows,
|
|
125
|
+
ce(
|
|
126
|
+
Box,
|
|
127
|
+
{ marginTop: 1, borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
128
|
+
ce(Text, { color: 'gray' }, '[↑↓] scroll [b] back')
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Error view ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function ErrorView({ message, onBack, canGoBack }) {
|
|
136
|
+
useInput((input, key) => {
|
|
137
|
+
if (input === 'b' || key.escape || key.return) onBack();
|
|
138
|
+
});
|
|
139
|
+
return ce(
|
|
140
|
+
Box,
|
|
141
|
+
{ flexDirection: 'column', paddingX: 1, paddingY: 1 },
|
|
142
|
+
ce(Text, { color: 'red', bold: true }, '✖ Error'),
|
|
143
|
+
ce(Box, { marginTop: 1 },
|
|
144
|
+
ce(Text, null, message)
|
|
145
|
+
),
|
|
146
|
+
ce(
|
|
147
|
+
Box,
|
|
148
|
+
{ marginTop: 1, borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
149
|
+
ce(Text, { color: 'gray' }, canGoBack ? '[b / Enter] go back' : '[b / Enter] exit')
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Main ScanCenter ─────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export default function ScanCenter() {
|
|
157
|
+
const { exit } = useApp();
|
|
158
|
+
|
|
159
|
+
const [step, setStep] = useState(STEPS.LOADING);
|
|
160
|
+
const [loadingMsg, setLoadingMsg] = useState('Validating connection…');
|
|
161
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
162
|
+
const [errorBackStep, setErrorBackStep] = useState(null);
|
|
163
|
+
|
|
164
|
+
const [connections, setConnections] = useState([]);
|
|
165
|
+
const [selectedConnection, setSelectedConnection] = useState(null);
|
|
166
|
+
|
|
167
|
+
const [repos, setRepos] = useState([]);
|
|
168
|
+
const [selectedRepo, setSelectedRepo] = useState(null);
|
|
169
|
+
|
|
170
|
+
const [scanHistory, setScanHistory] = useState([]);
|
|
171
|
+
const [selectedScan, setSelectedScan] = useState(null);
|
|
172
|
+
|
|
173
|
+
const [selectedResultType, setSelectedResultType] = useState(null);
|
|
174
|
+
const [results, setResults] = useState(null);
|
|
175
|
+
|
|
176
|
+
const setError = (msg, backStep) => {
|
|
177
|
+
setErrorMsg(msg);
|
|
178
|
+
setErrorBackStep(backStep);
|
|
179
|
+
setStep(STEPS.ERROR);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ── Step 1: validate connection on mount ──
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
validateConnectionOnMount({ STEPS, setError, setConnections, setStep });
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
// ── Step 2: connection selected → fetch repos ──
|
|
188
|
+
const handleSelectConnection = (item) =>
|
|
189
|
+
_handleSelectConnection({ STEPS, item, setSelectedConnection, setStep, setLoadingMsg, setError, setRepos });
|
|
190
|
+
|
|
191
|
+
// ── Step 3: repo selected → fetch scan history ──
|
|
192
|
+
const handleSelectRepo = (item) =>
|
|
193
|
+
_handleSelectRepo({ STEPS, item, setSelectedRepo, setStep, setLoadingMsg, setError, setScanHistory });
|
|
194
|
+
|
|
195
|
+
// ── Step 4: scan selected → show result type menu ──
|
|
196
|
+
const handleSelectScan = (item) =>
|
|
197
|
+
_handleSelectScan({ STEPS, item, setSelectedScan, setStep });
|
|
198
|
+
|
|
199
|
+
// ── Step 5: result type selected → fetch exactly one endpoint ──
|
|
200
|
+
const handleSelectResultType = (item) =>
|
|
201
|
+
_handleSelectResultType({ STEPS, item, selectedRepo, selectedScan, setSelectedResultType, setStep, setLoadingMsg, setError, setResults });
|
|
202
|
+
|
|
203
|
+
// ── Back navigation ──
|
|
204
|
+
const goBack = {
|
|
205
|
+
[STEPS.SELECT_CONNECTION]: () => exit(),
|
|
206
|
+
[STEPS.SELECT_REPO]: () => { setSelectedConnection(null); setStep(STEPS.SELECT_CONNECTION); },
|
|
207
|
+
[STEPS.SELECT_SCAN]: () => { setSelectedRepo(null); setStep(STEPS.SELECT_REPO); },
|
|
208
|
+
[STEPS.SELECT_RESULT_TYPE]:() => { setSelectedScan(null); setStep(STEPS.SELECT_SCAN); },
|
|
209
|
+
[STEPS.SHOWING_RESULTS]: () => { setResults(null); setSelectedResultType(null); setStep(STEPS.SELECT_RESULT_TYPE); },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const breadcrumbParts = [
|
|
213
|
+
selectedConnection?.organizationName,
|
|
214
|
+
selectedRepo?.name,
|
|
215
|
+
selectedScan ? (selectedScan.commitId || '').slice(0, 8) : null,
|
|
216
|
+
].filter(Boolean);
|
|
217
|
+
|
|
218
|
+
// ─── Render ───────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
if (step === STEPS.LOADING) {
|
|
221
|
+
return ce(Box, { paddingX: 1, paddingY: 1 }, ce(Spinner, { label: loadingMsg }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (step === STEPS.ERROR) {
|
|
225
|
+
const canGoBack = errorBackStep !== null;
|
|
226
|
+
const onErrorBack = canGoBack
|
|
227
|
+
? () => { setErrorMsg(''); setStep(errorBackStep); }
|
|
228
|
+
: () => exit();
|
|
229
|
+
return ce(ErrorView, { message: errorMsg, onBack: onErrorBack, canGoBack });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (step === STEPS.SELECT_CONNECTION) {
|
|
233
|
+
const items = connections
|
|
234
|
+
.filter((c) => c && c.organizationName)
|
|
235
|
+
.map((c) => ({
|
|
236
|
+
label: c.organizationName,
|
|
237
|
+
sublabel: `${c.service} ${c.baseUrl}`,
|
|
238
|
+
value: c,
|
|
239
|
+
}));
|
|
240
|
+
return ce(SelectList, {
|
|
241
|
+
title: 'Select a connection',
|
|
242
|
+
items,
|
|
243
|
+
onSelect: handleSelectConnection,
|
|
244
|
+
onBack: goBack[STEPS.SELECT_CONNECTION],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (step === STEPS.SELECT_REPO) {
|
|
249
|
+
const items = repos
|
|
250
|
+
.filter((r) => r && (r.name || r.full_name))
|
|
251
|
+
.map((r) => ({
|
|
252
|
+
label: r.name || r.full_name,
|
|
253
|
+
sublabel: r.pushed_at ? `Last push: ${new Date(r.pushed_at).toLocaleDateString()}` : undefined,
|
|
254
|
+
value: r,
|
|
255
|
+
}));
|
|
256
|
+
return ce(
|
|
257
|
+
Box,
|
|
258
|
+
{ flexDirection: 'column' },
|
|
259
|
+
ce(Box, { paddingX: 1 }, ce(Breadcrumb, { parts: [selectedConnection.organizationName] })),
|
|
260
|
+
ce(SelectList, {
|
|
261
|
+
title: 'Select a repository',
|
|
262
|
+
items,
|
|
263
|
+
onSelect: handleSelectRepo,
|
|
264
|
+
onBack: goBack[STEPS.SELECT_REPO],
|
|
265
|
+
emptyMessage: 'No repositories found for this organisation.',
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (step === STEPS.SELECT_SCAN) {
|
|
271
|
+
const items = scanHistory
|
|
272
|
+
.filter((s) => s && typeof s === 'object' && s.latest_commit_sha)
|
|
273
|
+
.slice()
|
|
274
|
+
.sort((a, b) => {
|
|
275
|
+
const da = new Date(a.timestamp || a.date || a.created_at || 0);
|
|
276
|
+
const db = new Date(b.timestamp || b.date || b.created_at || 0);
|
|
277
|
+
return db - da;
|
|
278
|
+
})
|
|
279
|
+
.map((s) => {
|
|
280
|
+
const date = s.timestamp || s.date || s.created_at;
|
|
281
|
+
const branch = s.branch || s.ref || '';
|
|
282
|
+
const commitFull = s.latest_commit_sha || '';
|
|
283
|
+
return {
|
|
284
|
+
label: branch || '(no branch)',
|
|
285
|
+
sublabel: [commitFull || null, date ? new Date(date).toLocaleString() : null].filter(Boolean).join(' '),
|
|
286
|
+
value: { ...s, commitId: commitFull },
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
return ce(
|
|
290
|
+
Box,
|
|
291
|
+
{ flexDirection: 'column' },
|
|
292
|
+
ce(Box, { paddingX: 1 },
|
|
293
|
+
ce(Breadcrumb, { parts: [selectedConnection.organizationName, selectedRepo.name] })),
|
|
294
|
+
ce(SelectList, {
|
|
295
|
+
title: 'Select a scan',
|
|
296
|
+
items,
|
|
297
|
+
onSelect: handleSelectScan,
|
|
298
|
+
onBack: goBack[STEPS.SELECT_SCAN],
|
|
299
|
+
emptyMessage: 'No scan history found for this repository.',
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (step === STEPS.SELECT_RESULT_TYPE) {
|
|
305
|
+
const items = RESULT_TYPES.map((rt) => ({
|
|
306
|
+
label: rt.label,
|
|
307
|
+
sublabel: rt.kind === 'basic' ? 'standard analysis'
|
|
308
|
+
: rt.kind === 'advanced' ? 'advanced analysis'
|
|
309
|
+
: 'dismissed',
|
|
310
|
+
value: rt,
|
|
311
|
+
}));
|
|
312
|
+
return ce(
|
|
313
|
+
Box,
|
|
314
|
+
{ flexDirection: 'column' },
|
|
315
|
+
ce(Box, { paddingX: 1 }, ce(Breadcrumb, { parts: breadcrumbParts })),
|
|
316
|
+
ce(SelectList, {
|
|
317
|
+
title: 'Select result type',
|
|
318
|
+
items,
|
|
319
|
+
onSelect: handleSelectResultType,
|
|
320
|
+
onBack: goBack[STEPS.SELECT_RESULT_TYPE],
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (step === STEPS.SHOWING_RESULTS) {
|
|
326
|
+
const issues = results?.issues || [];
|
|
327
|
+
const rtObj = RESULT_TYPES.find((r) => r.value === selectedResultType?.value);
|
|
328
|
+
return ce(ResultsView, {
|
|
329
|
+
issues,
|
|
330
|
+
resultTypeLabel: rtObj?.label || selectedResultType?.value || '',
|
|
331
|
+
breadcrumbParts,
|
|
332
|
+
onBack: goBack[STEPS.SHOWING_RESULTS],
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
|
|
4
|
+
const PAGE_SIZE = 15;
|
|
5
|
+
|
|
6
|
+
const ce = React.createElement;
|
|
7
|
+
|
|
8
|
+
export default function SelectList({ items = [], title, onSelect, onBack, emptyMessage = 'No items.' }) {
|
|
9
|
+
const [cursor, setCursor] = useState(0);
|
|
10
|
+
const [windowStart, setWindowStart] = useState(0);
|
|
11
|
+
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (items.length === 0) {
|
|
14
|
+
if (input === 'b' || key.escape) onBack?.();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (key.upArrow) {
|
|
18
|
+
const next = Math.max(0, cursor - 1);
|
|
19
|
+
setCursor(next);
|
|
20
|
+
if (next < windowStart) setWindowStart(next);
|
|
21
|
+
} else if (key.downArrow) {
|
|
22
|
+
const next = Math.min(items.length - 1, cursor + 1);
|
|
23
|
+
setCursor(next);
|
|
24
|
+
if (next >= windowStart + PAGE_SIZE) setWindowStart(next - PAGE_SIZE + 1);
|
|
25
|
+
} else if (key.return) {
|
|
26
|
+
onSelect(items[cursor]);
|
|
27
|
+
} else if (input === 'b' || key.escape) {
|
|
28
|
+
onBack?.();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const visible = items.slice(windowStart, windowStart + PAGE_SIZE);
|
|
33
|
+
|
|
34
|
+
const rows = items.length === 0
|
|
35
|
+
? [ce(Text, { key: 'empty', color: 'gray' }, emptyMessage)]
|
|
36
|
+
: visible.map((item, i) => {
|
|
37
|
+
const idx = windowStart + i;
|
|
38
|
+
const selected = idx === cursor;
|
|
39
|
+
return ce(
|
|
40
|
+
Box,
|
|
41
|
+
{ key: idx, flexDirection: 'column' },
|
|
42
|
+
ce(
|
|
43
|
+
Box,
|
|
44
|
+
{ key: 'row' },
|
|
45
|
+
ce(Text, { color: selected ? 'cyan' : undefined }, selected ? '▶ ' : ' '),
|
|
46
|
+
ce(Text, { color: selected ? 'cyan' : undefined, bold: selected }, item.label)
|
|
47
|
+
),
|
|
48
|
+
item.sublabel
|
|
49
|
+
? ce(Text, { key: 'sub', color: 'gray', dimColor: true }, ' ' + item.sublabel)
|
|
50
|
+
: null
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const counter = items.length > PAGE_SIZE
|
|
55
|
+
? ce(
|
|
56
|
+
Box,
|
|
57
|
+
{ key: 'counter', marginTop: 1 },
|
|
58
|
+
ce(Text, { color: 'gray', dimColor: true },
|
|
59
|
+
`${windowStart + 1}–${Math.min(windowStart + PAGE_SIZE, items.length)} of ${items.length}`)
|
|
60
|
+
)
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const footer = ce(
|
|
64
|
+
Box,
|
|
65
|
+
{ key: 'footer', marginTop: 1, borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
|
|
66
|
+
ce(Text, { color: 'gray' },
|
|
67
|
+
(items.length > 0 ? '[↑↓] navigate [Enter] select' : '') +
|
|
68
|
+
(onBack ? ' [b] back' : '')
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return ce(
|
|
73
|
+
Box,
|
|
74
|
+
{ flexDirection: 'column', paddingX: 1 },
|
|
75
|
+
ce(Box, { key: 'title', marginBottom: 1 }, ce(Text, { bold: true, color: 'cyan' }, title)),
|
|
76
|
+
...rows,
|
|
77
|
+
counter,
|
|
78
|
+
footer
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,10 +13,12 @@ import Login from './commands/login.js';
|
|
|
13
13
|
import Logout from './commands/logout.js';
|
|
14
14
|
import Review from './commands/review.js';
|
|
15
15
|
import { runReviewHeadless } from './reviewHeadless.js';
|
|
16
|
+
import ScanCenter from './components/ScanCenter.js';
|
|
16
17
|
import Welcome from './components/Welcome.js';
|
|
17
18
|
import * as scm from './scm/index.js';
|
|
18
19
|
import { setConfigValue } from './utils/config.js';
|
|
19
20
|
import { track, shutdown as analyticsShutdown, isTelemetryDisabled } from './utils/analytics.js';
|
|
21
|
+
import registerScansCommands from './commands/scans/index.js';
|
|
20
22
|
|
|
21
23
|
// Read version from package.json
|
|
22
24
|
const require = createRequire(import.meta.url);
|
|
@@ -206,6 +208,13 @@ program
|
|
|
206
208
|
render(React.createElement(Login));
|
|
207
209
|
});
|
|
208
210
|
|
|
211
|
+
program
|
|
212
|
+
.command('scan-center')
|
|
213
|
+
.description('Browse scan results interactively')
|
|
214
|
+
.action(() => {
|
|
215
|
+
render(React.createElement(ScanCenter));
|
|
216
|
+
});
|
|
217
|
+
|
|
209
218
|
program
|
|
210
219
|
.command('logout')
|
|
211
220
|
.description('Logout from CodeAnt')
|
|
@@ -376,6 +385,9 @@ program
|
|
|
376
385
|
}));
|
|
377
386
|
});
|
|
378
387
|
|
|
388
|
+
// ─── Scans commands ───
|
|
389
|
+
registerScansCommands(program, { runCmd });
|
|
390
|
+
|
|
379
391
|
// ─── Telemetry control ───
|
|
380
392
|
program
|
|
381
393
|
.command('set-telemetry <enabled>')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { listRepos } from '../scans/listRepos.js';
|
|
2
|
+
|
|
3
|
+
export async function handleSelectConnection({ STEPS, item, setSelectedConnection, setStep, setLoadingMsg, setError, setRepos }) {
|
|
4
|
+
setSelectedConnection(item.value);
|
|
5
|
+
setStep(STEPS.LOADING);
|
|
6
|
+
setLoadingMsg(`Fetching repos for ${item.value.organizationName}…`);
|
|
7
|
+
const res = await listRepos(item.value.organizationName);
|
|
8
|
+
if (!res.success) {
|
|
9
|
+
setError(res.error || 'Failed to fetch repos', STEPS.SELECT_CONNECTION);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
setRepos(res.repos || []);
|
|
13
|
+
setStep(STEPS.SELECT_REPO);
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getScanHistory } from '../scans/getScanHistory.js';
|
|
2
|
+
|
|
3
|
+
export async function handleSelectRepo({ STEPS, item, setSelectedRepo, setStep, setLoadingMsg, setError, setScanHistory }) {
|
|
4
|
+
setSelectedRepo(item.value);
|
|
5
|
+
setStep(STEPS.LOADING);
|
|
6
|
+
const repoFullName = item.value.full_name || item.value.name;
|
|
7
|
+
setLoadingMsg(`Loading scan history for ${repoFullName}…`);
|
|
8
|
+
const res = await getScanHistory(repoFullName);
|
|
9
|
+
if (!res.success) {
|
|
10
|
+
setError(res.error || 'Failed to fetch scan history', STEPS.SELECT_REPO);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const history = res.scanHistory || [];
|
|
14
|
+
if (process.env.CODEANT_DEBUG_SCAN_HISTORY === '1') {
|
|
15
|
+
process.stderr.write('SCAN_HISTORY_SAMPLE: ' + JSON.stringify(history.slice(0, 15), null, 2) + '\n');
|
|
16
|
+
}
|
|
17
|
+
setScanHistory(history);
|
|
18
|
+
setStep(STEPS.SELECT_SCAN);
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fetchScanResults } from '../scans/fetchScanResults.js';
|
|
2
|
+
import { fetchAdvancedScanResults } from '../scans/fetchAdvancedScanResults.js';
|
|
3
|
+
import { fetchDismissedAlerts } from '../scans/fetchDismissedAlerts.js';
|
|
4
|
+
|
|
5
|
+
export async function handleSelectResultType({ STEPS, item, selectedRepo, selectedScan, setSelectedResultType, setStep, setLoadingMsg, setError, setResults }) {
|
|
6
|
+
const rt = item.value;
|
|
7
|
+
setSelectedResultType(rt);
|
|
8
|
+
setStep(STEPS.LOADING);
|
|
9
|
+
setLoadingMsg(`Fetching ${item.label}…`);
|
|
10
|
+
|
|
11
|
+
const repo = selectedRepo.full_name;
|
|
12
|
+
const commitId = selectedScan.commitId;
|
|
13
|
+
let res;
|
|
14
|
+
|
|
15
|
+
if (rt.kind === 'basic') {
|
|
16
|
+
res = await fetchScanResults(repo, commitId, rt.value);
|
|
17
|
+
} else if (rt.kind === 'advanced') {
|
|
18
|
+
res = await fetchAdvancedScanResults(repo, commitId, rt.value);
|
|
19
|
+
} else if (rt.value === 'dismissed_alerts') {
|
|
20
|
+
const r = await fetchDismissedAlerts(repo, 'security');
|
|
21
|
+
res = r.success ? { success: true, issues: r.dismissedAlerts } : r;
|
|
22
|
+
} else if (rt.value === 'dismissed_secrets') {
|
|
23
|
+
const r = await fetchDismissedAlerts(repo, 'secrets');
|
|
24
|
+
res = r.success ? { success: true, issues: r.dismissedAlerts } : r;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!res || !res.success) {
|
|
28
|
+
setError((res && res.error) || 'Failed to fetch results', STEPS.SELECT_RESULT_TYPE);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setResults(res);
|
|
33
|
+
setStep(STEPS.SHOWING_RESULTS);
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { validateConnection } from '../scans/connectionHandler.js';
|
|
2
|
+
|
|
3
|
+
export async function validateConnectionOnMount({ STEPS, setError, setConnections, setStep }) {
|
|
4
|
+
const res = await validateConnection();
|
|
5
|
+
if (!res.success) {
|
|
6
|
+
setError(res.error || 'Failed to validate connection', null);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (!res.connections || res.connections.length === 0) {
|
|
10
|
+
setError('No connected organisations found. Please log in to CodeAnt first.', null);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
setConnections(res.connections);
|
|
14
|
+
setStep(STEPS.SELECT_CONNECTION);
|
|
15
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { fetchApi } from '../utils/fetchApi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate the stored API key with the CodeAnt backend.
|
|
5
|
+
*
|
|
6
|
+
* @returns {Promise<Object>}
|
|
7
|
+
* {
|
|
8
|
+
* success: true,
|
|
9
|
+
* connections: [
|
|
10
|
+
* {
|
|
11
|
+
* organizationName: "acme-corp",
|
|
12
|
+
* baseUrl: "https://github.com",
|
|
13
|
+
* service: "github" | "gitlab" | "azuredevops" | "bitbucket" | "unknown"
|
|
14
|
+
* }
|
|
15
|
+
* ],
|
|
16
|
+
* email: "dev@acme.com",
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
export async function validateConnection() {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetchApi('/extension/scans2/validate', 'POST', {
|
|
22
|
+
extension: 'cli',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response) {
|
|
26
|
+
return { success: false, error: 'Failed to connect to CodeAnt server' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (response.status === 'success' && response.data) {
|
|
30
|
+
const orgs = response.data.orgs || [];
|
|
31
|
+
return {
|
|
32
|
+
success: true,
|
|
33
|
+
connections: orgs.map((org) => ({
|
|
34
|
+
organizationName: org.organization_name,
|
|
35
|
+
baseUrl: org.base_url,
|
|
36
|
+
service: org.service || 'unknown',
|
|
37
|
+
})),
|
|
38
|
+
email: response.data.email,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: response.message || 'Invalid or expired connection string',
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
error: error.message || 'Failed to validate connection string',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|