codeant-cli 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -10
- package/cli-api.md +130 -0
- package/findings.md +163 -0
- package/mcp.md +207 -0
- package/package.json +18 -8
- package/scans.md +263 -0
- package/src/api/tenant.js +54 -0
- package/src/commands/api/index.js +33 -0
- package/src/commands/api/request.js +109 -0
- package/src/commands/findings/index.js +197 -0
- package/src/commands/getDashboardUrl.js +49 -0
- package/src/commands/hotlist/index.js +58 -0
- package/src/commands/login.js +9 -1
- package/src/commands/logout.js +14 -5
- package/src/commands/removeDashboardUrl.js +31 -0
- package/src/commands/setDashboardUrl.js +37 -0
- package/src/findings/antipatterns.js +31 -0
- package/src/findings/cloud.js +119 -0
- package/src/findings/pentest.js +42 -0
- package/src/hotlist/client.js +89 -0
- package/src/index.js +36 -0
- package/src/mcp/server.js +252 -5
- package/src/utils/baseUrl.js +27 -3
- package/src/utils/fetchApi.js +83 -9
- package/src/utils/logout.js +31 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useApp } from 'ink';
|
|
3
|
+
import { getConfigValue } from '../utils/config.js';
|
|
4
|
+
import { getBaseUrl, getDashboardUrl as resolveDashboardUrl } from '../utils/baseUrl.js';
|
|
5
|
+
|
|
6
|
+
export default function GetDashboardUrl() {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const [state, setState] = useState({ status: 'loading' });
|
|
9
|
+
|
|
10
|
+
const envValue = process.env.CODEANT_DASHBOARD_URL;
|
|
11
|
+
const configValue = getConfigValue('dashboardUrl');
|
|
12
|
+
const source = envValue ? 'env' : configValue ? 'config' : 'upstream (queried from API base URL)';
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const url = await resolveDashboardUrl();
|
|
18
|
+
setState({ status: 'resolved', url });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
setState({ status: 'error', message: err.message });
|
|
21
|
+
}
|
|
22
|
+
exit();
|
|
23
|
+
})();
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
if (state.status === 'loading') {
|
|
27
|
+
return React.createElement(
|
|
28
|
+
Box,
|
|
29
|
+
{ flexDirection: 'column', padding: 1 },
|
|
30
|
+
React.createElement(Text, null, 'Querying dashboard URL...')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (state.status === 'error') {
|
|
35
|
+
return React.createElement(
|
|
36
|
+
Box,
|
|
37
|
+
{ flexDirection: 'column', padding: 1 },
|
|
38
|
+
React.createElement(Text, { color: 'red' }, '✗ ', state.message),
|
|
39
|
+
React.createElement(Text, { color: 'gray' }, 'Base URL: ', getBaseUrl())
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return React.createElement(
|
|
44
|
+
Box,
|
|
45
|
+
{ flexDirection: 'column', padding: 1 },
|
|
46
|
+
React.createElement(Text, { bold: true }, 'Dashboard URL: ', state.url),
|
|
47
|
+
React.createElement(Text, { color: 'gray' }, 'Source: ', source)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { runHotlistGet, runHotlistList } from '../../hotlist/client.js';
|
|
2
|
+
|
|
3
|
+
function addTenantOptions(command) {
|
|
4
|
+
return command
|
|
5
|
+
.option('--org <org>', 'Organization name (auto-picked when unambiguous)')
|
|
6
|
+
.option('--service <service>', 'github, gitlab, bitbucket, or azuredevops')
|
|
7
|
+
.option('--provider-base-url <url>', 'Override the authenticated provider base URL')
|
|
8
|
+
.option('--max-wait <seconds>', 'Wait for an initial Hotlist build', Number, 60);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function registerHotlistCommands(program, { runCmd }) {
|
|
12
|
+
const hotlist = program
|
|
13
|
+
.command('hotlist')
|
|
14
|
+
.description('Query organization-wide prioritized Hotlist findings');
|
|
15
|
+
|
|
16
|
+
addTenantOptions(
|
|
17
|
+
hotlist
|
|
18
|
+
.command('list')
|
|
19
|
+
.description('List Hotlist findings using the same filters and ranking as the app')
|
|
20
|
+
.option('--search <text>', 'Search title, repository, path, package, CVE, or check ID')
|
|
21
|
+
.option('--type <values>', 'Comma-separated finding types')
|
|
22
|
+
.option('--location <values>', 'Comma-separated repositories or cloud accounts')
|
|
23
|
+
.option('--severity <values>', 'Comma-separated severities')
|
|
24
|
+
.option('--ticket-status <values>', 'created,not_created')
|
|
25
|
+
.option('--compliance <values>', 'Comma-separated compliance frameworks')
|
|
26
|
+
.option('--validation <values>', 'Comma-separated validation flags (for example exploit_confirmed)')
|
|
27
|
+
.option('--limit <n>', 'Page size from 1 to 100', Number, 30)
|
|
28
|
+
.option('--cursor <cursor>', 'Continue from a previous next_cursor')
|
|
29
|
+
.option('--all', 'Fetch every matching page', false),
|
|
30
|
+
).action((options) => runCmd(() => runHotlistList({
|
|
31
|
+
org: options.org,
|
|
32
|
+
service: options.service,
|
|
33
|
+
providerBaseUrl: options.providerBaseUrl,
|
|
34
|
+
maxWaitSeconds: options.maxWait,
|
|
35
|
+
search: options.search,
|
|
36
|
+
types: options.type,
|
|
37
|
+
locations: options.location,
|
|
38
|
+
severities: options.severity,
|
|
39
|
+
ticketStatuses: options.ticketStatus,
|
|
40
|
+
compliance: options.compliance,
|
|
41
|
+
validation: options.validation,
|
|
42
|
+
limit: options.limit,
|
|
43
|
+
cursor: options.cursor,
|
|
44
|
+
all: options.all,
|
|
45
|
+
})));
|
|
46
|
+
|
|
47
|
+
addTenantOptions(
|
|
48
|
+
hotlist
|
|
49
|
+
.command('get <finding-id>')
|
|
50
|
+
.description('Get one finding by the stable ID shown in the Hotlist detail panel'),
|
|
51
|
+
).action((findingId, options) => runCmd(() => runHotlistGet({
|
|
52
|
+
findingId,
|
|
53
|
+
org: options.org,
|
|
54
|
+
service: options.service,
|
|
55
|
+
providerBaseUrl: options.providerBaseUrl,
|
|
56
|
+
maxWaitSeconds: options.maxWait,
|
|
57
|
+
})));
|
|
58
|
+
}
|
package/src/commands/login.js
CHANGED
|
@@ -30,7 +30,15 @@ export default function Login() {
|
|
|
30
30
|
let timeoutId;
|
|
31
31
|
|
|
32
32
|
(async () => {
|
|
33
|
-
|
|
33
|
+
let dashboardUrl;
|
|
34
|
+
try {
|
|
35
|
+
dashboardUrl = await getDashboardUrl();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err.message);
|
|
38
|
+
setStatus('error');
|
|
39
|
+
setTimeout(() => exit(err), 100);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
34
42
|
const loginUrl = `${dashboardUrl}?ideLoginToken=${token}`;
|
|
35
43
|
setLoginUrl(loginUrl);
|
|
36
44
|
|
package/src/commands/logout.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import React, { useEffect } from 'react';
|
|
2
2
|
import { Text, Box, useApp } from 'ink';
|
|
3
|
-
import { getConfigValue
|
|
3
|
+
import { getConfigValue } from '../utils/config.js';
|
|
4
|
+
import { logoutCodeAnt } from '../utils/logout.js';
|
|
4
5
|
|
|
5
6
|
export default function Logout() {
|
|
6
7
|
const { exit } = useApp();
|
|
7
8
|
|
|
8
9
|
const wasLoggedIn = !!getConfigValue('apiKeyV2');
|
|
10
|
+
const [warning, setWarning] = React.useState(null);
|
|
11
|
+
const [done, setDone] = React.useState(!wasLoggedIn);
|
|
9
12
|
|
|
10
13
|
useEffect(() => {
|
|
11
|
-
if (wasLoggedIn) {
|
|
12
|
-
|
|
14
|
+
if (!wasLoggedIn) {
|
|
15
|
+
exit();
|
|
16
|
+
return;
|
|
13
17
|
}
|
|
14
|
-
|
|
18
|
+
logoutCodeAnt().then((result) => {
|
|
19
|
+
setWarning(result.warning || null);
|
|
20
|
+
setDone(true);
|
|
21
|
+
setTimeout(() => exit(), 100);
|
|
22
|
+
});
|
|
15
23
|
}, []);
|
|
16
24
|
|
|
17
25
|
if (!wasLoggedIn) {
|
|
@@ -25,6 +33,7 @@ export default function Logout() {
|
|
|
25
33
|
return React.createElement(
|
|
26
34
|
Box,
|
|
27
35
|
{ flexDirection: 'column', padding: 1 },
|
|
28
|
-
React.createElement(Text, { color: 'green' }, '✓ Logged out successfully.')
|
|
36
|
+
React.createElement(Text, { color: done ? 'green' : 'gray' }, done ? '✓ Logged out successfully.' : 'Revoking session...'),
|
|
37
|
+
warning ? React.createElement(Text, { color: 'yellow' }, warning) : null
|
|
29
38
|
);
|
|
30
39
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useApp } from 'ink';
|
|
3
|
+
import { getConfigValue, setConfigValue } from '../utils/config.js';
|
|
4
|
+
|
|
5
|
+
export default function RemoveDashboardUrl() {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
|
|
8
|
+
const hadValue = !!getConfigValue('dashboardUrl');
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (hadValue) {
|
|
12
|
+
setConfigValue('dashboardUrl', null);
|
|
13
|
+
}
|
|
14
|
+
exit();
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
if (!hadValue) {
|
|
18
|
+
return React.createElement(
|
|
19
|
+
Box,
|
|
20
|
+
{ flexDirection: 'column', padding: 1 },
|
|
21
|
+
React.createElement(Text, { color: 'yellow' }, 'No dashboard URL override was set.')
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return React.createElement(
|
|
26
|
+
Box,
|
|
27
|
+
{ flexDirection: 'column', padding: 1 },
|
|
28
|
+
React.createElement(Text, { color: 'green' }, '✓ Dashboard URL override removed.'),
|
|
29
|
+
React.createElement(Text, { color: 'gray' }, 'It will be auto-detected from the API base URL on next login.')
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useApp } from 'ink';
|
|
3
|
+
import { setConfigValue, CONFIG_FILE } from '../utils/config.js';
|
|
4
|
+
|
|
5
|
+
export default function SetDashboardUrl({ url }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!url) {
|
|
10
|
+
exit(new Error('URL is required'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
setConfigValue('dashboardUrl', url);
|
|
16
|
+
exit();
|
|
17
|
+
} catch (err) {
|
|
18
|
+
exit(err);
|
|
19
|
+
}
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
if (!url) {
|
|
23
|
+
return React.createElement(
|
|
24
|
+
Box,
|
|
25
|
+
{ flexDirection: 'column', padding: 1 },
|
|
26
|
+
React.createElement(Text, { color: 'red' }, '✗ Error: URL is required'),
|
|
27
|
+
React.createElement(Text, { color: 'gray' }, 'Usage: codeant set-dashboard-url <url>')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return React.createElement(
|
|
32
|
+
Box,
|
|
33
|
+
{ flexDirection: 'column', padding: 1 },
|
|
34
|
+
React.createElement(Text, { color: 'green' }, '✓ Dashboard URL set to: ', url),
|
|
35
|
+
React.createElement(Text, { color: 'gray' }, 'Saved to: ', CONFIG_FILE)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolveCliTenant } from '../api/tenant.js';
|
|
2
|
+
import { runRepos } from '../commands/scans/repos.js';
|
|
3
|
+
import { fetchAppApi } from '../utils/fetchApi.js';
|
|
4
|
+
|
|
5
|
+
function splitRepos(value) {
|
|
6
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
7
|
+
return values
|
|
8
|
+
.flatMap((entry) => String(entry).split(','))
|
|
9
|
+
.map((entry) => entry.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runOrganizationAntipatterns(options = {}) {
|
|
14
|
+
const tenant = await resolveCliTenant(options);
|
|
15
|
+
let repos = splitRepos(options.repos);
|
|
16
|
+
if (repos.length === 0) {
|
|
17
|
+
const result = await runRepos({ org: tenant.organization });
|
|
18
|
+
repos = (result.repos || []).map((repo) => repo.full_name || repo.name).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
if (repos.length === 0) throw new Error('No repositories are available for this organization.');
|
|
21
|
+
|
|
22
|
+
const result = await fetchAppApi(
|
|
23
|
+
'/explorer/quality/antipatterns',
|
|
24
|
+
'POST',
|
|
25
|
+
{ ...tenant.requestBody, org: tenant.organization, repos },
|
|
26
|
+
tenant,
|
|
27
|
+
);
|
|
28
|
+
return { tenant: tenant.requestBody, ...result };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { splitRepos };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { resolveCliTenant } from '../api/tenant.js';
|
|
2
|
+
import { fetchAppApi } from '../utils/fetchApi.js';
|
|
3
|
+
|
|
4
|
+
const PROVIDERS = new Set(['aws', 'azure', 'gcp']);
|
|
5
|
+
const KINDS = new Set(['cspm', 'vm', 'container']);
|
|
6
|
+
|
|
7
|
+
function normalizeProvider(value, { allowAll = false } = {}) {
|
|
8
|
+
const provider = String(value || '').trim().toLowerCase();
|
|
9
|
+
if (allowAll && (!provider || provider === 'all')) return 'all';
|
|
10
|
+
if (!PROVIDERS.has(provider)) {
|
|
11
|
+
throw new Error(`--provider must be one of: ${[...PROVIDERS].join(', ')}.`);
|
|
12
|
+
}
|
|
13
|
+
return provider;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requireValue(value, option) {
|
|
17
|
+
if (!String(value || '').trim()) throw new Error(`${option} is required.`);
|
|
18
|
+
return String(value).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeKind(value) {
|
|
22
|
+
const kind = String(value || 'cspm').trim().toLowerCase();
|
|
23
|
+
if (!KINDS.has(kind)) throw new Error(`--kind must be one of: ${[...KINDS].join(', ')}.`);
|
|
24
|
+
return kind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function endpoint(provider, kind, action) {
|
|
28
|
+
if (kind === 'vm') return `/cloud/${provider}/vm-scanning/${action}`;
|
|
29
|
+
if (kind === 'container') return `/cloud/${provider}/container-scanning/${action}`;
|
|
30
|
+
return `/cloud/${provider}/scan/${action}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function providerScope(provider, options = {}) {
|
|
34
|
+
if (provider === 'azure') {
|
|
35
|
+
return { tenant_id: requireValue(options.tenantId, '--tenant-id for Azure findings') };
|
|
36
|
+
}
|
|
37
|
+
if (provider === 'gcp') {
|
|
38
|
+
return { project_id: requireValue(options.projectId, '--project-id for GCP findings') };
|
|
39
|
+
}
|
|
40
|
+
return options.accountId ? { account_id: options.accountId } : {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function optionalFilters(provider, options = {}) {
|
|
44
|
+
const filters = {};
|
|
45
|
+
const service = options.cloudService;
|
|
46
|
+
if (service) filters[`${provider}_service`] = service;
|
|
47
|
+
if (options.severity) filters.severity = options.severity;
|
|
48
|
+
if (options.status) filters.status = options.status;
|
|
49
|
+
if (options.framework) filters.framework = options.framework;
|
|
50
|
+
if (options.minDaysUnused !== undefined) filters.min_days_unused = options.minDaysUnused;
|
|
51
|
+
if (provider === 'aws' && options.exploitAttemptedOnly) filters.exploit_attempted_only = true;
|
|
52
|
+
if (provider === 'azure' && options.subscriptionId) filters.subscription_id = options.subscriptionId;
|
|
53
|
+
return filters;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function request(provider, kind, action, options = {}, extra = {}, resolvedTenant = null) {
|
|
57
|
+
const tenant = resolvedTenant || await resolveCliTenant(options);
|
|
58
|
+
const data = await fetchAppApi(
|
|
59
|
+
endpoint(provider, kind, action),
|
|
60
|
+
'POST',
|
|
61
|
+
{
|
|
62
|
+
...tenant.requestBody,
|
|
63
|
+
username: tenant.organization,
|
|
64
|
+
...extra,
|
|
65
|
+
},
|
|
66
|
+
tenant,
|
|
67
|
+
);
|
|
68
|
+
return { tenant: tenant.requestBody, provider, kind, ...data };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runCloudHistory(options = {}) {
|
|
72
|
+
const provider = normalizeProvider(options.provider, { allowAll: true });
|
|
73
|
+
const kind = normalizeKind(options.kind);
|
|
74
|
+
if (options.latest && kind !== 'cspm') {
|
|
75
|
+
throw new Error('--latest is supported only for --kind cspm; VM and container history already returns scan records.');
|
|
76
|
+
}
|
|
77
|
+
const action = options.latest ? 'latest' : 'history';
|
|
78
|
+
const providers = provider === 'all' ? [...PROVIDERS] : [provider];
|
|
79
|
+
const tenant = await resolveCliTenant(options);
|
|
80
|
+
if (provider !== 'all') return request(provider, kind, action, options, {}, tenant);
|
|
81
|
+
const settled = await Promise.allSettled(
|
|
82
|
+
providers.map((item) => request(item, kind, action, options, {}, tenant)),
|
|
83
|
+
);
|
|
84
|
+
const results = settled.map((result, index) => (
|
|
85
|
+
result.status === 'fulfilled'
|
|
86
|
+
? result.value
|
|
87
|
+
: { provider: providers[index], kind, error: result.reason?.message || String(result.reason) }
|
|
88
|
+
));
|
|
89
|
+
return {
|
|
90
|
+
tenant: tenant.requestBody,
|
|
91
|
+
providers: Object.fromEntries(results.map((result) => [result.provider, result])),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runCloudFindings(options = {}) {
|
|
96
|
+
const provider = normalizeProvider(options.provider);
|
|
97
|
+
const kind = normalizeKind(options.kind);
|
|
98
|
+
const scanId = requireValue(options.scanId, '--scan-id');
|
|
99
|
+
return request(provider, kind, kind === 'cspm' ? 'findings' : 'results', options, {
|
|
100
|
+
scan_id: scanId,
|
|
101
|
+
...(kind === 'cspm' ? providerScope(provider, options) : {}),
|
|
102
|
+
...(kind === 'cspm' ? optionalFilters(provider, options) : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runCloudFindingGet(options = {}) {
|
|
107
|
+
const provider = normalizeProvider(options.provider);
|
|
108
|
+
const kind = normalizeKind(options.kind);
|
|
109
|
+
const scanId = requireValue(options.scanId, '--scan-id');
|
|
110
|
+
const uid = requireValue(options.uid, '--uid');
|
|
111
|
+
return request(provider, kind, 'finding_detail', options, {
|
|
112
|
+
scan_id: scanId,
|
|
113
|
+
uid,
|
|
114
|
+
...(kind === 'cspm' ? providerScope(provider, options) : {}),
|
|
115
|
+
...(kind === 'cspm' && options.cloudService ? { [`${provider}_service`]: options.cloudService } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { normalizeKind, normalizeProvider };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveCliTenant } from '../api/tenant.js';
|
|
2
|
+
import { fetchAppApi } from '../utils/fetchApi.js';
|
|
3
|
+
|
|
4
|
+
function variantBody(variant) {
|
|
5
|
+
if (!variant || variant === 'prod') return {};
|
|
6
|
+
if (variant !== 'test') throw new Error('--variant must be prod or test.');
|
|
7
|
+
return { variant: 'test' };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function request(endpoint, options = {}, body = {}) {
|
|
11
|
+
const tenant = await resolveCliTenant(options);
|
|
12
|
+
const data = await fetchAppApi(
|
|
13
|
+
endpoint,
|
|
14
|
+
'POST',
|
|
15
|
+
{ ...tenant.requestBody, ...body, org: tenant.organization },
|
|
16
|
+
tenant,
|
|
17
|
+
);
|
|
18
|
+
return { tenant: tenant.requestBody, ...data };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function reportId(options) {
|
|
22
|
+
if (!String(options.reportId || '').trim()) throw new Error('--report-id is required.');
|
|
23
|
+
return String(options.reportId).trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runPentestHistory(options = {}) {
|
|
27
|
+
return request('/pentesting/history', options);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runPentestIssues(options = {}) {
|
|
31
|
+
return request('/pentesting/issues', options, {
|
|
32
|
+
report_id: reportId(options),
|
|
33
|
+
...variantBody(options.variant),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runPentestReport(options = {}) {
|
|
38
|
+
return request('/pentesting/report', options, {
|
|
39
|
+
report_id: reportId(options),
|
|
40
|
+
...variantBody(options.variant),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { resolveCliTenant } from '../api/tenant.js';
|
|
2
|
+
import { fetchAppApi } from '../utils/fetchApi.js';
|
|
3
|
+
|
|
4
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
|
|
6
|
+
function splitValues(value) {
|
|
7
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
8
|
+
return values.flatMap((entry) => String(entry).split(',')).map((entry) => entry.trim()).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hotlistFilters(options = {}) {
|
|
12
|
+
return {
|
|
13
|
+
types: splitValues(options.types),
|
|
14
|
+
locations: splitValues(options.locations),
|
|
15
|
+
severities: splitValues(options.severities).map((value) => value.toLowerCase()),
|
|
16
|
+
ticket_statuses: splitValues(options.ticketStatuses),
|
|
17
|
+
compliance: splitValues(options.compliance).map((value) => value.toLowerCase()),
|
|
18
|
+
validation: splitValues(options.validation),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const resolveHotlistTenant = resolveCliTenant;
|
|
23
|
+
|
|
24
|
+
async function requestReady(endpoint, body, tenant, maxWaitSeconds = 60) {
|
|
25
|
+
const deadline = Date.now() + Math.max(0, Number(maxWaitSeconds) || 0) * 1000;
|
|
26
|
+
while (true) {
|
|
27
|
+
const response = await fetchAppApi(endpoint, 'POST', body, tenant);
|
|
28
|
+
if (response?.state !== 'building') return response;
|
|
29
|
+
if (Date.now() >= deadline) {
|
|
30
|
+
throw new Error('Hotlist is still building. Retry the command in a few seconds or increase --max-wait.');
|
|
31
|
+
}
|
|
32
|
+
const retrySeconds = Math.min(Math.max(Number(response.retry_after_seconds) || 3, 1), 10);
|
|
33
|
+
await sleep(retrySeconds * 1000);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pageLimit(value) {
|
|
38
|
+
const limit = value === undefined ? 30 : Number(value);
|
|
39
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
40
|
+
throw new Error('--limit must be an integer between 1 and 100.');
|
|
41
|
+
}
|
|
42
|
+
return limit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runHotlistList(options = {}) {
|
|
46
|
+
const tenant = await resolveHotlistTenant(options);
|
|
47
|
+
const request = {
|
|
48
|
+
...tenant.requestBody,
|
|
49
|
+
search: options.search || '',
|
|
50
|
+
filters: hotlistFilters(options),
|
|
51
|
+
limit: pageLimit(options.limit),
|
|
52
|
+
cursor: options.cursor || null,
|
|
53
|
+
};
|
|
54
|
+
let result = await requestReady('/explorer/security/hotlist/query', request, tenant, options.maxWaitSeconds);
|
|
55
|
+
if (!options.all) return { tenant: tenant.requestBody, ...result };
|
|
56
|
+
|
|
57
|
+
const items = [...(result.items || [])];
|
|
58
|
+
while (result.has_more && result.next_cursor) {
|
|
59
|
+
result = await requestReady(
|
|
60
|
+
'/explorer/security/hotlist/query',
|
|
61
|
+
{ ...request, cursor: result.next_cursor },
|
|
62
|
+
tenant,
|
|
63
|
+
options.maxWaitSeconds,
|
|
64
|
+
);
|
|
65
|
+
items.push(...(result.items || []));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
tenant: tenant.requestBody,
|
|
69
|
+
...result,
|
|
70
|
+
items,
|
|
71
|
+
returned_count: items.length,
|
|
72
|
+
next_cursor: null,
|
|
73
|
+
has_more: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runHotlistGet({ findingId, ...options } = {}) {
|
|
78
|
+
if (!/^[0-9a-f]{32}$/i.test(String(findingId || ''))) {
|
|
79
|
+
throw new Error('Finding ID must be the 32-character stable ID shown in Hotlist.');
|
|
80
|
+
}
|
|
81
|
+
const tenant = await resolveHotlistTenant(options);
|
|
82
|
+
const result = await requestReady(
|
|
83
|
+
'/explorer/security/hotlist/finding',
|
|
84
|
+
{ ...tenant.requestBody, finding_id: String(findingId).toLowerCase() },
|
|
85
|
+
tenant,
|
|
86
|
+
options.maxWaitSeconds,
|
|
87
|
+
);
|
|
88
|
+
return { tenant: tenant.requestBody, ...result };
|
|
89
|
+
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import { createRequire } from 'module';
|
|
|
7
7
|
import Secrets from './commands/secrets.js';
|
|
8
8
|
import SetBaseUrl from './commands/setBaseUrl.js';
|
|
9
9
|
import GetBaseUrl from './commands/getBaseUrl.js';
|
|
10
|
+
import SetDashboardUrl from './commands/setDashboardUrl.js';
|
|
11
|
+
import GetDashboardUrl from './commands/getDashboardUrl.js';
|
|
12
|
+
import RemoveDashboardUrl from './commands/removeDashboardUrl.js';
|
|
10
13
|
import SetApiKey from './commands/setApiKey.js';
|
|
11
14
|
import GetApiKey from './commands/getApiKey.js';
|
|
12
15
|
import Login from './commands/login.js';
|
|
@@ -20,6 +23,9 @@ import { setConfigValue } from './utils/config.js';
|
|
|
20
23
|
import { track, shutdown as analyticsShutdown, isTelemetryDisabled } from './utils/analytics.js';
|
|
21
24
|
import registerScansCommands from './commands/scans/index.js';
|
|
22
25
|
import registerSettingsCommands from './commands/settings/index.js';
|
|
26
|
+
import registerApiCommands from './commands/api/index.js';
|
|
27
|
+
import registerHotlistCommands from './commands/hotlist/index.js';
|
|
28
|
+
import registerFindingsCommands from './commands/findings/index.js';
|
|
23
29
|
|
|
24
30
|
// Read version from package.json
|
|
25
31
|
const require = createRequire(import.meta.url);
|
|
@@ -188,6 +194,27 @@ program
|
|
|
188
194
|
render(React.createElement(GetBaseUrl));
|
|
189
195
|
});
|
|
190
196
|
|
|
197
|
+
program
|
|
198
|
+
.command('set-dashboard-url <url>')
|
|
199
|
+
.description('Set the web app dashboard URL used for login')
|
|
200
|
+
.action((url) => {
|
|
201
|
+
render(React.createElement(SetDashboardUrl, { url }));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
program
|
|
205
|
+
.command('get-dashboard-url')
|
|
206
|
+
.description('Show the current dashboard URL override')
|
|
207
|
+
.action(() => {
|
|
208
|
+
render(React.createElement(GetDashboardUrl));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
program
|
|
212
|
+
.command('remove-dashboard-url')
|
|
213
|
+
.description('Remove the dashboard URL override')
|
|
214
|
+
.action(() => {
|
|
215
|
+
render(React.createElement(RemoveDashboardUrl));
|
|
216
|
+
});
|
|
217
|
+
|
|
191
218
|
program
|
|
192
219
|
.command('set-codeant-api-key <key>')
|
|
193
220
|
.description('Set the CodeAnt API key')
|
|
@@ -397,6 +424,15 @@ program
|
|
|
397
424
|
// ─── Settings commands ───
|
|
398
425
|
registerSettingsCommands(program, { runCmd });
|
|
399
426
|
|
|
427
|
+
// ─── Authenticated application API passthrough ───
|
|
428
|
+
registerApiCommands(program, { runCmd });
|
|
429
|
+
|
|
430
|
+
// ─── Organization Hotlist findings ───
|
|
431
|
+
registerHotlistCommands(program, { runCmd });
|
|
432
|
+
|
|
433
|
+
// ─── Unified findings commands ───
|
|
434
|
+
registerFindingsCommands(program, { runCmd });
|
|
435
|
+
|
|
400
436
|
// ─── MCP server (for Claude Code plugin and other MCP clients) ───
|
|
401
437
|
program
|
|
402
438
|
.command('mcp')
|