@herodevs/cli 2.0.0-beta.14 → 2.0.0-beta.16
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 +148 -16
- package/dist/api/apollo.client.d.ts +3 -0
- package/dist/api/apollo.client.js +53 -0
- package/dist/api/ci-token.client.d.ts +27 -0
- package/dist/api/ci-token.client.js +95 -0
- package/dist/api/errors.d.ts +8 -0
- package/dist/api/errors.js +13 -0
- package/dist/api/gql-operations.d.ts +3 -0
- package/dist/api/gql-operations.js +34 -0
- package/dist/api/graphql-errors.d.ts +6 -0
- package/dist/api/graphql-errors.js +22 -0
- package/dist/api/nes.client.d.ts +1 -2
- package/dist/api/nes.client.js +25 -17
- package/dist/api/user-setup.client.d.ts +18 -0
- package/dist/api/user-setup.client.js +92 -0
- package/dist/commands/auth/login.d.ts +14 -0
- package/dist/commands/auth/login.js +225 -0
- package/dist/commands/auth/logout.d.ts +5 -0
- package/dist/commands/auth/logout.js +27 -0
- package/dist/commands/auth/provision-ci-token.d.ts +5 -0
- package/dist/commands/auth/provision-ci-token.js +72 -0
- package/dist/commands/scan/eol.d.ts +2 -0
- package/dist/commands/scan/eol.js +34 -4
- package/dist/commands/tracker/run.d.ts +15 -0
- package/dist/commands/tracker/run.js +183 -0
- package/dist/config/constants.d.ts +10 -0
- package/dist/config/constants.js +10 -0
- package/dist/config/tracker.config.js +1 -3
- package/dist/hooks/finally/finally.js +10 -4
- package/dist/hooks/init/01_initialize_amplitude.js +20 -9
- package/dist/service/analytics.svc.d.ts +10 -3
- package/dist/service/analytics.svc.js +180 -18
- package/dist/service/auth-config.svc.d.ts +5 -0
- package/dist/service/auth-config.svc.js +20 -0
- package/dist/service/auth-refresh.svc.d.ts +8 -0
- package/dist/service/auth-refresh.svc.js +45 -0
- package/dist/service/auth-token.svc.d.ts +11 -0
- package/dist/service/auth-token.svc.js +48 -0
- package/dist/service/auth.svc.d.ts +27 -0
- package/dist/service/auth.svc.js +91 -0
- package/dist/service/ci-auth.svc.d.ts +6 -0
- package/dist/service/ci-auth.svc.js +32 -0
- package/dist/service/ci-token.svc.d.ts +6 -0
- package/dist/service/ci-token.svc.js +75 -0
- package/dist/service/jwt.svc.d.ts +1 -0
- package/dist/service/jwt.svc.js +19 -0
- package/dist/service/tracker.svc.d.ts +56 -1
- package/dist/service/tracker.svc.js +78 -3
- package/dist/types/auth.d.ts +9 -0
- package/dist/types/auth.js +1 -0
- package/dist/utils/open-in-browser.d.ts +1 -0
- package/dist/utils/open-in-browser.js +21 -0
- package/dist/utils/retry.d.ts +11 -0
- package/dist/utils/retry.js +29 -0
- package/dist/utils/strip-typename.js +2 -1
- package/package.json +31 -17
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { config } from "../config/constants.js";
|
|
2
|
+
import { getTokenProvider } from "../service/auth.svc.js";
|
|
3
|
+
import { debugLogger } from "../service/log.svc.js";
|
|
4
|
+
import { withRetries } from "../utils/retry.js";
|
|
5
|
+
import { createApollo } from "./apollo.client.js";
|
|
6
|
+
import { ApiError, isApiErrorCode } from "./errors.js";
|
|
7
|
+
import { completeUserSetupMutation, userSetupStatusQuery } from "./gql-operations.js";
|
|
8
|
+
import { getGraphQLErrors } from "./graphql-errors.js";
|
|
9
|
+
const USER_SETUP_MAX_ATTEMPTS = 3;
|
|
10
|
+
const USER_SETUP_RETRY_DELAY_MS = 500;
|
|
11
|
+
const USER_FACING_SERVER_ERROR = 'Please contact support@herodevs.com.';
|
|
12
|
+
const SERVER_ERROR_CODES = ['INTERNAL_SERVER_ERROR', 'SERVER_ERROR', 'SERVICE_UNAVAILABLE'];
|
|
13
|
+
const getGraphqlUrl = () => `${config.graphqlHost}${config.graphqlPath}`;
|
|
14
|
+
function extractErrorCode(errors) {
|
|
15
|
+
const code = errors[0]?.extensions?.code;
|
|
16
|
+
if (!code || !isApiErrorCode(code))
|
|
17
|
+
return;
|
|
18
|
+
return code;
|
|
19
|
+
}
|
|
20
|
+
export async function getUserSetupStatus(options) {
|
|
21
|
+
const tokenProvider = getTokenProvider(options?.preferOAuth, options?.orgAccessToken);
|
|
22
|
+
const client = createApollo(getGraphqlUrl(), tokenProvider);
|
|
23
|
+
const res = await client.query({ query: userSetupStatusQuery });
|
|
24
|
+
const errors = getGraphQLErrors(res);
|
|
25
|
+
if (res?.error || errors?.length) {
|
|
26
|
+
debugLogger('Error returned from userSetupStatus query: %o', res.error || errors);
|
|
27
|
+
if (errors?.length) {
|
|
28
|
+
const rawCode = errors[0]?.extensions?.code;
|
|
29
|
+
if (rawCode && SERVER_ERROR_CODES.includes(rawCode)) {
|
|
30
|
+
throw new Error(USER_FACING_SERVER_ERROR);
|
|
31
|
+
}
|
|
32
|
+
const code = extractErrorCode(errors);
|
|
33
|
+
const message = errors[0].message ?? 'Failed to check user setup status';
|
|
34
|
+
if (code) {
|
|
35
|
+
throw new ApiError(message, code);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(message);
|
|
38
|
+
}
|
|
39
|
+
throw new Error('Failed to check user setup status');
|
|
40
|
+
}
|
|
41
|
+
const status = res.data?.eol?.userSetupStatus;
|
|
42
|
+
if (!status || typeof status.isComplete !== 'boolean') {
|
|
43
|
+
debugLogger('Unexpected userSetupStatus query response: %o', res.data);
|
|
44
|
+
throw new Error('Failed to check user setup status');
|
|
45
|
+
}
|
|
46
|
+
return { isComplete: status.isComplete, orgId: status.orgId ?? undefined };
|
|
47
|
+
}
|
|
48
|
+
export async function completeUserSetup(options) {
|
|
49
|
+
const tokenProvider = getTokenProvider(options?.preferOAuth, options?.orgAccessToken);
|
|
50
|
+
const client = createApollo(getGraphqlUrl(), tokenProvider);
|
|
51
|
+
const res = await client.mutate({ mutation: completeUserSetupMutation });
|
|
52
|
+
const errors = getGraphQLErrors(res);
|
|
53
|
+
if (res?.error || errors?.length) {
|
|
54
|
+
debugLogger('Error returned from completeUserSetup mutation: %o', res.error || errors);
|
|
55
|
+
if (errors?.length) {
|
|
56
|
+
const rawCode = errors[0]?.extensions?.code;
|
|
57
|
+
if (rawCode && SERVER_ERROR_CODES.includes(rawCode)) {
|
|
58
|
+
throw new Error(USER_FACING_SERVER_ERROR);
|
|
59
|
+
}
|
|
60
|
+
const code = extractErrorCode(errors);
|
|
61
|
+
const message = errors[0].message ?? 'Failed to complete user setup';
|
|
62
|
+
if (code) {
|
|
63
|
+
throw new ApiError(message, code);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(message);
|
|
66
|
+
}
|
|
67
|
+
throw new Error('Failed to complete user setup');
|
|
68
|
+
}
|
|
69
|
+
const result = res.data?.eol?.completeUserSetup;
|
|
70
|
+
if (!result || result.isComplete !== true) {
|
|
71
|
+
debugLogger('completeUserSetup mutation returned unsuccessful response: %o', res.data);
|
|
72
|
+
throw new Error('Failed to complete user setup');
|
|
73
|
+
}
|
|
74
|
+
return { isComplete: true, orgId: result.orgId ?? undefined };
|
|
75
|
+
}
|
|
76
|
+
export async function ensureUserSetup(options) {
|
|
77
|
+
const status = await withRetries('user-setup-status', () => getUserSetupStatus(options), {
|
|
78
|
+
attempts: USER_SETUP_MAX_ATTEMPTS,
|
|
79
|
+
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
|
|
80
|
+
});
|
|
81
|
+
if (status.isComplete && status.orgId != null) {
|
|
82
|
+
return status.orgId;
|
|
83
|
+
}
|
|
84
|
+
const result = await withRetries('user-setup-complete', () => completeUserSetup(options), {
|
|
85
|
+
attempts: USER_SETUP_MAX_ATTEMPTS,
|
|
86
|
+
baseDelayMs: USER_SETUP_RETRY_DELAY_MS,
|
|
87
|
+
});
|
|
88
|
+
if (result.orgId != null) {
|
|
89
|
+
return result.orgId;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`User setup did not return an organization ID. ${USER_FACING_SERVER_ERROR}`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class AuthLogin extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
private server?;
|
|
5
|
+
private stopServerPromise?;
|
|
6
|
+
private readonly port;
|
|
7
|
+
private readonly redirectUri;
|
|
8
|
+
private readonly realmUrl;
|
|
9
|
+
private readonly clientId;
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
private startServerAndAwaitCode;
|
|
12
|
+
private stopServer;
|
|
13
|
+
private exchangeCodeForToken;
|
|
14
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
import { Command } from '@oclif/core';
|
|
6
|
+
import { ensureUserSetup } from "../../api/user-setup.client.js";
|
|
7
|
+
import { OAUTH_CALLBACK_ERROR_CODES } from "../../config/constants.js";
|
|
8
|
+
import { refreshIdentityFromStoredToken } from "../../service/analytics.svc.js";
|
|
9
|
+
import { persistTokenResponse } from "../../service/auth.svc.js";
|
|
10
|
+
import { getClientId, getRealmUrl } from "../../service/auth-config.svc.js";
|
|
11
|
+
import { debugLogger, getErrorMessage } from "../../service/log.svc.js";
|
|
12
|
+
import { openInBrowser } from "../../utils/open-in-browser.js";
|
|
13
|
+
export default class AuthLogin extends Command {
|
|
14
|
+
static description = 'OAuth CLI login';
|
|
15
|
+
server;
|
|
16
|
+
stopServerPromise;
|
|
17
|
+
port = parseInt(process.env.OAUTH_CALLBACK_PORT || '4000', 10);
|
|
18
|
+
redirectUri = process.env.OAUTH_CALLBACK_REDIRECT || `http://localhost:${this.port}/oauth2/callback`;
|
|
19
|
+
realmUrl = getRealmUrl();
|
|
20
|
+
clientId = getClientId();
|
|
21
|
+
async run() {
|
|
22
|
+
if (typeof this.config.runHook === 'function') {
|
|
23
|
+
await this.parse(AuthLogin);
|
|
24
|
+
}
|
|
25
|
+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
26
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
27
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
28
|
+
const authUrl = `${this.realmUrl}/auth?` +
|
|
29
|
+
`client_id=${this.clientId}` +
|
|
30
|
+
`&response_type=code` +
|
|
31
|
+
`&redirect_uri=${encodeURIComponent(this.redirectUri)}` +
|
|
32
|
+
`&code_challenge=${codeChallenge}` +
|
|
33
|
+
`&code_challenge_method=S256` +
|
|
34
|
+
`&state=${state}`;
|
|
35
|
+
const code = await this.startServerAndAwaitCode(authUrl, state);
|
|
36
|
+
const token = await this.exchangeCodeForToken(code, codeVerifier);
|
|
37
|
+
try {
|
|
38
|
+
await persistTokenResponse(token);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
this.warn(`Failed to store tokens securely: ${error instanceof Error ? error.message : error}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await ensureUserSetup({ preferOAuth: true });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
this.error(`User setup failed. ${getErrorMessage(error)}`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await refreshIdentityFromStoredToken();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.warn(`Failed to refresh analytics identity: ${getErrorMessage(error)}`);
|
|
55
|
+
}
|
|
56
|
+
this.log('\nLogin completed successfully.');
|
|
57
|
+
}
|
|
58
|
+
startServerAndAwaitCode(authUrl, expectedState) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
this.server = http.createServer((req, res) => {
|
|
61
|
+
if (!req.url) {
|
|
62
|
+
res.writeHead(400);
|
|
63
|
+
res.end('Invalid request');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let parsedUrl;
|
|
67
|
+
try {
|
|
68
|
+
parsedUrl = new URL(req.url, `http://localhost:${this.port}`);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
72
|
+
res.end('Invalid callback URL');
|
|
73
|
+
this.stopServer();
|
|
74
|
+
reject(new Error('Invalid callback URL'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (parsedUrl.pathname === '/oauth2/callback') {
|
|
78
|
+
const code = parsedUrl.searchParams.get('code');
|
|
79
|
+
const state = parsedUrl.searchParams.get('state');
|
|
80
|
+
const oauthError = parsedUrl.searchParams.get('error');
|
|
81
|
+
const oauthErrorDescription = parsedUrl.searchParams.get('error_description');
|
|
82
|
+
if (!state) {
|
|
83
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
84
|
+
res.end('Missing state parameter.');
|
|
85
|
+
this.stopServer();
|
|
86
|
+
return reject(new Error('Missing state parameter in callback'));
|
|
87
|
+
}
|
|
88
|
+
if (state !== expectedState) {
|
|
89
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
90
|
+
res.end('State verification failed. Please restart the login flow.');
|
|
91
|
+
this.stopServer();
|
|
92
|
+
return reject(new Error('State verification failed'));
|
|
93
|
+
}
|
|
94
|
+
if (oauthError) {
|
|
95
|
+
const isAlreadyLoggedIn = oauthError === OAUTH_CALLBACK_ERROR_CODES.ALREADY_LOGGED_IN;
|
|
96
|
+
const isDifferentUserAuthenticated = oauthError === OAUTH_CALLBACK_ERROR_CODES.DIFFERENT_USER_AUTHENTICATED;
|
|
97
|
+
debugLogger('OAuth callback returned error: %s (%s)', oauthError, oauthErrorDescription ?? 'no description');
|
|
98
|
+
let browserMessage;
|
|
99
|
+
let cliErrorMessage;
|
|
100
|
+
if (isAlreadyLoggedIn) {
|
|
101
|
+
browserMessage = "You're already signed in. We'll continue for you. Return to the terminal.";
|
|
102
|
+
cliErrorMessage = `You're already signed in. Run "hd auth login" again to continue.`;
|
|
103
|
+
}
|
|
104
|
+
else if (isDifferentUserAuthenticated) {
|
|
105
|
+
browserMessage =
|
|
106
|
+
"You're signed in with a different account than this sign-in attempt. Return to the terminal.";
|
|
107
|
+
cliErrorMessage =
|
|
108
|
+
`You're signed in with a different account than this sign-in attempt. ` +
|
|
109
|
+
`Choose another account, or reset this sign-in session and try again. ` +
|
|
110
|
+
`If needed, run "hd auth logout" and then "hd auth login".`;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
browserMessage = "We couldn't complete sign-in. Return to the terminal and try again.";
|
|
114
|
+
cliErrorMessage = `We couldn't complete sign-in. Please run "hd auth login" again.`;
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
117
|
+
res.end(browserMessage);
|
|
118
|
+
this.stopServer();
|
|
119
|
+
return reject(new Error(cliErrorMessage));
|
|
120
|
+
}
|
|
121
|
+
if (code) {
|
|
122
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
123
|
+
res.end('Login successful. You can close this window.');
|
|
124
|
+
this.stopServer();
|
|
125
|
+
resolve(code);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
129
|
+
res.end('No authorization code returned. Please try again.');
|
|
130
|
+
this.stopServer();
|
|
131
|
+
reject(new Error('No code returned from Keycloak'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
res.writeHead(404);
|
|
136
|
+
res.end();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
this.server.listen(this.port, async () => {
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
142
|
+
rl.question(`Press Enter to navigate to: ${authUrl}\n`, () => {
|
|
143
|
+
rl.close();
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
await openInBrowser(authUrl);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
this.warn(`Failed to open browser automatically. Please open this URL manually:\n${authUrl}\n${err instanceof Error ? err.message : err}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
this.server.on('error', (err) => {
|
|
155
|
+
this.stopServer();
|
|
156
|
+
reject(err);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
stopServer() {
|
|
161
|
+
if (this.stopServerPromise) {
|
|
162
|
+
return this.stopServerPromise;
|
|
163
|
+
}
|
|
164
|
+
const server = this.server;
|
|
165
|
+
this.server = undefined;
|
|
166
|
+
if (!server) {
|
|
167
|
+
return Promise.resolve();
|
|
168
|
+
}
|
|
169
|
+
const stopPromise = new Promise((resolve) => {
|
|
170
|
+
const timeoutMs = 1000;
|
|
171
|
+
let settled = false;
|
|
172
|
+
let timeout;
|
|
173
|
+
const complete = (err) => {
|
|
174
|
+
if (settled) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
settled = true;
|
|
178
|
+
if (timeout) {
|
|
179
|
+
clearTimeout(timeout);
|
|
180
|
+
timeout = undefined;
|
|
181
|
+
}
|
|
182
|
+
const code = err?.code;
|
|
183
|
+
if (err && code !== 'ERR_SERVER_NOT_RUNNING') {
|
|
184
|
+
this.warn('Failed to stop local OAuth callback server.');
|
|
185
|
+
debugLogger('Failed to stop local OAuth callback server: %s', getErrorMessage(err));
|
|
186
|
+
}
|
|
187
|
+
resolve();
|
|
188
|
+
};
|
|
189
|
+
timeout = setTimeout(() => {
|
|
190
|
+
debugLogger('Timed out while stopping local OAuth callback server after %dms', timeoutMs);
|
|
191
|
+
complete();
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
try {
|
|
194
|
+
server.close((err) => complete(err));
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
complete(err);
|
|
198
|
+
}
|
|
199
|
+
}).finally(() => {
|
|
200
|
+
this.stopServerPromise = undefined;
|
|
201
|
+
});
|
|
202
|
+
this.stopServerPromise = stopPromise;
|
|
203
|
+
return stopPromise;
|
|
204
|
+
}
|
|
205
|
+
async exchangeCodeForToken(code, codeVerifier) {
|
|
206
|
+
const tokenUrl = `${this.realmUrl}/token`;
|
|
207
|
+
const params = new URLSearchParams({
|
|
208
|
+
grant_type: 'authorization_code',
|
|
209
|
+
client_id: this.clientId,
|
|
210
|
+
redirect_uri: this.redirectUri,
|
|
211
|
+
code_verifier: codeVerifier,
|
|
212
|
+
code,
|
|
213
|
+
});
|
|
214
|
+
const response = await fetch(tokenUrl, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
217
|
+
body: params.toString(),
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const text = await response.text();
|
|
221
|
+
throw new Error(`Token exchange failed: ${response.status} ${response.statusText}\n${text}`);
|
|
222
|
+
}
|
|
223
|
+
return response.json();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { clearTrackedIdentity } from "../../service/analytics.svc.js";
|
|
3
|
+
import { logoutFromProvider } from "../../service/auth-refresh.svc.js";
|
|
4
|
+
import { clearStoredTokens, getStoredTokens } from "../../service/auth-token.svc.js";
|
|
5
|
+
export default class AuthLogout extends Command {
|
|
6
|
+
static description = 'Logs out of HeroDevs OAuth and clears stored tokens';
|
|
7
|
+
async run() {
|
|
8
|
+
if (typeof this.config.runHook === 'function') {
|
|
9
|
+
await this.parse(AuthLogout);
|
|
10
|
+
}
|
|
11
|
+
const tokens = await getStoredTokens();
|
|
12
|
+
if (!tokens?.accessToken && !tokens?.refreshToken) {
|
|
13
|
+
this.log('No stored authentication tokens found.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
await logoutFromProvider(tokens?.refreshToken);
|
|
18
|
+
this.log('Logged out of HeroDevs OAuth provider.');
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
this.warn(`Failed to revoke tokens remotely: ${error instanceof Error ? error.message : error}`);
|
|
22
|
+
}
|
|
23
|
+
clearTrackedIdentity();
|
|
24
|
+
await clearStoredTokens();
|
|
25
|
+
this.log('Local authentication tokens removed from your system.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
import { provisionCIToken } from "../../api/ci-token.client.js";
|
|
3
|
+
import { ensureUserSetup } from "../../api/user-setup.client.js";
|
|
4
|
+
import { refreshIdentityFromStoredToken, track } from "../../service/analytics.svc.js";
|
|
5
|
+
import { requireAccessToken } from "../../service/auth.svc.js";
|
|
6
|
+
import { saveCIToken } from "../../service/ci-token.svc.js";
|
|
7
|
+
import { getErrorMessage } from "../../service/log.svc.js";
|
|
8
|
+
export default class AuthProvisionCiToken extends Command {
|
|
9
|
+
static description = 'Provision a CI/CD long-lived refresh token for headless auth';
|
|
10
|
+
async run() {
|
|
11
|
+
await this.parse(AuthProvisionCiToken);
|
|
12
|
+
try {
|
|
13
|
+
await requireAccessToken();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
this.error(`Must be logged in to provision CI token. Run 'hd auth login' first. ${getErrorMessage(error)}`);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await refreshIdentityFromStoredToken();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
this.warn(`Failed to refresh analytics identity: ${getErrorMessage(error)}`);
|
|
23
|
+
}
|
|
24
|
+
track('CLI CI Token Provision Started', (context) => ({
|
|
25
|
+
command: 'auth provision-ci-token',
|
|
26
|
+
app_used: context.app_used,
|
|
27
|
+
ci_provider: context.ci_provider,
|
|
28
|
+
cli_version: context.cli_version,
|
|
29
|
+
started_at: context.started_at,
|
|
30
|
+
}));
|
|
31
|
+
let orgId;
|
|
32
|
+
try {
|
|
33
|
+
orgId = await ensureUserSetup();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
track('CLI CI Token Provision Failed', () => ({
|
|
37
|
+
command: 'auth provision-ci-token',
|
|
38
|
+
error: `user_setup_failed:${getErrorMessage(error)}`,
|
|
39
|
+
}));
|
|
40
|
+
this.error(`User setup failed. ${getErrorMessage(error)}`);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const result = await provisionCIToken({ orgId });
|
|
44
|
+
try {
|
|
45
|
+
await ensureUserSetup({ orgAccessToken: result.access_token });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
track('CLI CI Token Provision Failed', () => ({
|
|
49
|
+
command: 'auth provision-ci-token',
|
|
50
|
+
error: `user_setup_failed:${getErrorMessage(error)}`,
|
|
51
|
+
}));
|
|
52
|
+
this.error(`User Org setup failed. ${getErrorMessage(error)}`);
|
|
53
|
+
}
|
|
54
|
+
const refreshToken = result.refresh_token;
|
|
55
|
+
saveCIToken(refreshToken);
|
|
56
|
+
this.log('CI token provisioned and saved locally.');
|
|
57
|
+
this.log('');
|
|
58
|
+
this.log('For CI/CD, set this environment variable:');
|
|
59
|
+
this.log(` HD_CI_CREDENTIAL=${refreshToken}`);
|
|
60
|
+
track('CLI CI Token Provision Succeeded', () => ({
|
|
61
|
+
command: 'auth provision-ci-token',
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
track('CLI CI Token Provision Failed', () => ({
|
|
66
|
+
command: 'auth provision-ci-token',
|
|
67
|
+
error: `provision_failed:${getErrorMessage(error)}`,
|
|
68
|
+
}));
|
|
69
|
+
this.error(`CI token provisioning failed. ${getErrorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -16,11 +16,13 @@ export default class ScanEol extends Command {
|
|
|
16
16
|
sbomOutput: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
17
|
saveTrimmedSbom: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
18
|
hideReportUrl: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
automated: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
20
|
version: import("@oclif/core/interfaces").BooleanFlag<void>;
|
|
20
21
|
};
|
|
21
22
|
run(): Promise<EolReport | undefined>;
|
|
22
23
|
private loadSbom;
|
|
23
24
|
private scanSbom;
|
|
25
|
+
private getScanLoadTime;
|
|
24
26
|
private saveReport;
|
|
25
27
|
private saveSbom;
|
|
26
28
|
private saveTrimmedSbom;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { trimCdxBom } from '@herodevs/eol-shared';
|
|
2
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
+
import { ApiError } from "../../api/errors.js";
|
|
4
5
|
import { submitScan } from "../../api/nes.client.js";
|
|
5
|
-
import { config, filenamePrefix } from "../../config/constants.js";
|
|
6
|
+
import { config, filenamePrefix, SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from "../../config/constants.js";
|
|
6
7
|
import { track } from "../../service/analytics.svc.js";
|
|
8
|
+
import { AUTH_ERROR_MESSAGES, getTokenForScanWithSource } from "../../service/auth.svc.js";
|
|
7
9
|
import { createSbom } from "../../service/cdx.svc.js";
|
|
8
10
|
import { countComponentsByStatus, formatDataPrivacyLink, formatReportSaveHint, formatScanResults, formatWebReportUrl, } from "../../service/display.svc.js";
|
|
9
11
|
import { readSbomFromFile, saveArtifactToFile, validateDirectory } from "../../service/file.svc.js";
|
|
@@ -68,10 +70,19 @@ export default class ScanEol extends Command {
|
|
|
68
70
|
default: false,
|
|
69
71
|
description: 'Hide the generated web report URL for this scan',
|
|
70
72
|
}),
|
|
73
|
+
automated: Flags.boolean({
|
|
74
|
+
default: false,
|
|
75
|
+
description: 'Mark scan as automated (for CI/CD pipelines)',
|
|
76
|
+
}),
|
|
71
77
|
version: Flags.version(),
|
|
72
78
|
};
|
|
73
79
|
async run() {
|
|
74
80
|
const { flags } = await this.parse(ScanEol);
|
|
81
|
+
const { source } = await getTokenForScanWithSource();
|
|
82
|
+
if (source === 'ci') {
|
|
83
|
+
this.log('CI credentials found');
|
|
84
|
+
this.log('Using CI credentials');
|
|
85
|
+
}
|
|
75
86
|
track('CLI EOL Scan Started', (context) => ({
|
|
76
87
|
command: context.command,
|
|
77
88
|
command_flags: context.command_flags,
|
|
@@ -116,7 +127,6 @@ export default class ScanEol extends Command {
|
|
|
116
127
|
}
|
|
117
128
|
const scanStartTime = performance.now();
|
|
118
129
|
const scan = await this.scanSbom(sbom);
|
|
119
|
-
const scanEndTime = performance.now();
|
|
120
130
|
const componentCounts = countComponentsByStatus(scan);
|
|
121
131
|
track('CLI EOL Scan Completed', (context) => ({
|
|
122
132
|
command: context.command,
|
|
@@ -126,7 +136,7 @@ export default class ScanEol extends Command {
|
|
|
126
136
|
nes_available_count: componentCounts.NES_AVAILABLE,
|
|
127
137
|
number_of_packages: componentCounts.TOTAL,
|
|
128
138
|
sbom_created: !flags.file,
|
|
129
|
-
scan_load_time: (
|
|
139
|
+
scan_load_time: this.getScanLoadTime(scanStartTime),
|
|
130
140
|
scanned_ecosystems: componentCounts.ECOSYSTEMS,
|
|
131
141
|
web_report_link: !flags.hideReportUrl && scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined,
|
|
132
142
|
web_report_hidden: flags.hideReportUrl,
|
|
@@ -159,6 +169,8 @@ export default class ScanEol extends Command {
|
|
|
159
169
|
return sbom;
|
|
160
170
|
}
|
|
161
171
|
async scanSbom(sbom) {
|
|
172
|
+
const scanStartTime = performance.now();
|
|
173
|
+
const numberOfPackages = sbom.components?.length ?? 0;
|
|
162
174
|
const { flags } = await this.parse(ScanEol);
|
|
163
175
|
const spinner = ora().start('Trimming SBOM');
|
|
164
176
|
const trimmedSbom = trimCdxBom(sbom);
|
|
@@ -173,21 +185,39 @@ export default class ScanEol extends Command {
|
|
|
173
185
|
}
|
|
174
186
|
spinner.start('Scanning for EOL packages');
|
|
175
187
|
try {
|
|
176
|
-
const
|
|
188
|
+
const scanOrigin = flags.automated ? SCAN_ORIGIN_AUTOMATED : SCAN_ORIGIN_CLI;
|
|
189
|
+
const scan = await submitScan({ sbom: trimmedSbom, scanOrigin });
|
|
177
190
|
spinner.succeed('Scan completed');
|
|
178
191
|
return scan;
|
|
179
192
|
}
|
|
180
193
|
catch (error) {
|
|
181
194
|
spinner.fail('Scanning failed');
|
|
195
|
+
const scanLoadTime = this.getScanLoadTime(scanStartTime);
|
|
196
|
+
if (error instanceof ApiError) {
|
|
197
|
+
track('CLI EOL Scan Failed', (context) => ({
|
|
198
|
+
command: context.command,
|
|
199
|
+
command_flags: context.command_flags,
|
|
200
|
+
scan_failure_reason: error.code,
|
|
201
|
+
scan_load_time: scanLoadTime,
|
|
202
|
+
number_of_packages: numberOfPackages,
|
|
203
|
+
}));
|
|
204
|
+
const message = AUTH_ERROR_MESSAGES[error.code] ?? error.message?.trim();
|
|
205
|
+
this.error(message);
|
|
206
|
+
}
|
|
182
207
|
const errorMessage = getErrorMessage(error);
|
|
183
208
|
track('CLI EOL Scan Failed', (context) => ({
|
|
184
209
|
command: context.command,
|
|
185
210
|
command_flags: context.command_flags,
|
|
186
211
|
scan_failure_reason: errorMessage,
|
|
212
|
+
scan_load_time: scanLoadTime,
|
|
213
|
+
number_of_packages: numberOfPackages,
|
|
187
214
|
}));
|
|
188
215
|
this.error(`Failed to submit scan to NES. ${errorMessage}`);
|
|
189
216
|
}
|
|
190
217
|
}
|
|
218
|
+
getScanLoadTime(scanStartTime) {
|
|
219
|
+
return (performance.now() - scanStartTime) / 1000;
|
|
220
|
+
}
|
|
191
221
|
saveReport(report, dir, outputPath) {
|
|
192
222
|
try {
|
|
193
223
|
return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Run extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static enableJsonFlag: boolean;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
configDir: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
configFile: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Fetches Git last commit
|
|
13
|
+
*/
|
|
14
|
+
private fetchGitLastCommit;
|
|
15
|
+
}
|