@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.
Files changed (56) hide show
  1. package/README.md +148 -16
  2. package/dist/api/apollo.client.d.ts +3 -0
  3. package/dist/api/apollo.client.js +53 -0
  4. package/dist/api/ci-token.client.d.ts +27 -0
  5. package/dist/api/ci-token.client.js +95 -0
  6. package/dist/api/errors.d.ts +8 -0
  7. package/dist/api/errors.js +13 -0
  8. package/dist/api/gql-operations.d.ts +3 -0
  9. package/dist/api/gql-operations.js +34 -0
  10. package/dist/api/graphql-errors.d.ts +6 -0
  11. package/dist/api/graphql-errors.js +22 -0
  12. package/dist/api/nes.client.d.ts +1 -2
  13. package/dist/api/nes.client.js +25 -17
  14. package/dist/api/user-setup.client.d.ts +18 -0
  15. package/dist/api/user-setup.client.js +92 -0
  16. package/dist/commands/auth/login.d.ts +14 -0
  17. package/dist/commands/auth/login.js +225 -0
  18. package/dist/commands/auth/logout.d.ts +5 -0
  19. package/dist/commands/auth/logout.js +27 -0
  20. package/dist/commands/auth/provision-ci-token.d.ts +5 -0
  21. package/dist/commands/auth/provision-ci-token.js +72 -0
  22. package/dist/commands/scan/eol.d.ts +2 -0
  23. package/dist/commands/scan/eol.js +34 -4
  24. package/dist/commands/tracker/run.d.ts +15 -0
  25. package/dist/commands/tracker/run.js +183 -0
  26. package/dist/config/constants.d.ts +10 -0
  27. package/dist/config/constants.js +10 -0
  28. package/dist/config/tracker.config.js +1 -3
  29. package/dist/hooks/finally/finally.js +10 -4
  30. package/dist/hooks/init/01_initialize_amplitude.js +20 -9
  31. package/dist/service/analytics.svc.d.ts +10 -3
  32. package/dist/service/analytics.svc.js +180 -18
  33. package/dist/service/auth-config.svc.d.ts +5 -0
  34. package/dist/service/auth-config.svc.js +20 -0
  35. package/dist/service/auth-refresh.svc.d.ts +8 -0
  36. package/dist/service/auth-refresh.svc.js +45 -0
  37. package/dist/service/auth-token.svc.d.ts +11 -0
  38. package/dist/service/auth-token.svc.js +48 -0
  39. package/dist/service/auth.svc.d.ts +27 -0
  40. package/dist/service/auth.svc.js +91 -0
  41. package/dist/service/ci-auth.svc.d.ts +6 -0
  42. package/dist/service/ci-auth.svc.js +32 -0
  43. package/dist/service/ci-token.svc.d.ts +6 -0
  44. package/dist/service/ci-token.svc.js +75 -0
  45. package/dist/service/jwt.svc.d.ts +1 -0
  46. package/dist/service/jwt.svc.js +19 -0
  47. package/dist/service/tracker.svc.d.ts +56 -1
  48. package/dist/service/tracker.svc.js +78 -3
  49. package/dist/types/auth.d.ts +9 -0
  50. package/dist/types/auth.js +1 -0
  51. package/dist/utils/open-in-browser.d.ts +1 -0
  52. package/dist/utils/open-in-browser.js +21 -0
  53. package/dist/utils/retry.d.ts +11 -0
  54. package/dist/utils/retry.js +29 -0
  55. package/dist/utils/strip-typename.js +2 -1
  56. 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,5 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AuthLogout extends Command {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -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,5 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AuthProvisionCiToken extends Command {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -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: (scanEndTime - scanStartTime) / 1000,
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 scan = await submitScan({ sbom: trimmedSbom });
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
+ }