mobbdev 0.0.18 → 0.0.22

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/.env CHANGED
@@ -1,4 +1,5 @@
1
- # production@v6
1
+ # production@v8
2
2
  WEB_LOGIN_URL="https://app.mobb.dev/cli-login"
3
3
  API_URL="https://api.mobb.dev/v1/graphql"
4
- WEB_APP_URL="https://app.mobb.dev"
4
+ WEB_APP_URL="https://app.mobb.dev"
5
+ GITHUB_CLIENT_ID="49d729663b401f91afe5"
package/README.md CHANGED
@@ -27,11 +27,11 @@ Snyk CLI is used to produce a SAST vulnerability report.
27
27
  You can use Bugsy from the command line. To evaluate and remediate a new open-source repository, you can run the following command:
28
28
 
29
29
  ```shell
30
- npx mobbdev https://github.com/mobb-dev/simple-vulnerable-java-project
30
+ npx mobbdev scan -r https://github.com/mobb-dev/simple-vulnerable-java-project
31
31
  ```
32
32
 
33
33
  Bugsy will automatically generate a fix for each supported vulnerability identified in the SAST results, present it to developers for review and commit to their code.
34
34
 
35
35
  ## Getting support
36
36
 
37
- If you need support using Bugsy or just want to share your thoughts and learn more, you are more than welcome to join our [discord server](https://discord.gg/ks6Nz3H828)
37
+ If you need support using Bugsy or just want to share your thoughts and learn more, you are more than welcome to join our [discord server](https://discord.gg/Jmpb5QUa)
package/index.mjs CHANGED
@@ -1,17 +1,26 @@
1
- import { main } from './src/index.mjs';
2
- import tmp from 'tmp';
1
+ import { analyze, scan } from './src/commands/index.mjs';
2
+ import { parseArgs } from './src/yargs.mjs';
3
+ import { hideBin } from 'yargs/helpers';
3
4
 
4
- const tmpObj = tmp.dirSync({
5
- unsafeCleanup: true,
6
- });
7
-
8
- main(tmpObj.name, process.argv[2])
9
- .catch((err) => {
10
- console.error(
11
- 'Something went wrong, please try again or contact support if issue persists.'
5
+ async function run() {
6
+ const args = await parseArgs(hideBin(process.argv));
7
+ const [command] = args._;
8
+ if (command === 'scan') {
9
+ const { repo, branch, yes, scanner, apiKey } = args;
10
+ await scan(
11
+ { repoUrl: repo, branch, scanner, apiKey },
12
+ { skipPrompts: yes }
13
+ );
14
+ }
15
+ if (command === 'analyze') {
16
+ const { repo, scanFile, branch, yes, apiKey } = args;
17
+ await analyze(
18
+ { repoUrl: repo, scanFilePath: scanFile, branch, apiKey },
19
+ { skipPrompts: yes }
12
20
  );
13
- console.error(err);
14
- })
15
- .then(() => {
16
- tmpObj.removeCallback();
17
- });
21
+ }
22
+ }
23
+
24
+ (async () => {
25
+ await run();
26
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobbdev",
3
- "version": "0.0.18",
3
+ "version": "0.0.22",
4
4
  "description": "Automated secure code remediation tool",
5
5
  "main": "index.mjs",
6
6
  "scripts": {
@@ -15,16 +15,21 @@
15
15
  "author": "",
16
16
  "license": "MIT",
17
17
  "dependencies": {
18
+ "chalk": "5.3.0",
19
+ "chalk-animation": "2.0.3",
18
20
  "colors": "1.4.0",
19
21
  "configstore": "6.0.0",
20
22
  "debug": "4.3.4",
21
23
  "dotenv": "16.0.3",
22
24
  "extract-zip": "2.0.1",
25
+ "inquirer": "9.2.7",
26
+ "nanospinner": "1.1.0",
23
27
  "node-fetch": "3.3.1",
24
28
  "open": "8.4.2",
25
29
  "semver": "7.5.0",
26
30
  "snyk": "1.1118.0",
27
31
  "tmp": "0.2.1",
32
+ "yargs": "17.7.2",
28
33
  "zod": "3.21.4"
29
34
  },
30
35
  "devDependencies": {
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import fs from 'node:fs';
3
+ import chalk from 'chalk';
4
+ import chalkAnimation from 'chalk-animation';
5
+ import { choseScanner } from '../feature/analysis/prompts.mjs';
6
+ import { SCANNERS, mobbAscii } from '../constants.mjs';
7
+ import { runAnalysis } from '../feature/analysis/index.mjs';
8
+ import { sleep } from '../utils.mjs';
9
+ import path from 'path';
10
+
11
+ const GITHUB_REPO_URL_PATTERN = new RegExp(
12
+ 'https://github.com/[\\w-]+/[\\w-]+'
13
+ );
14
+
15
+ const UrlZ = z
16
+ .string({ required_error: 'Please Enter A Github Url' })
17
+ .regex(GITHUB_REPO_URL_PATTERN, {
18
+ message: 'Invalid Github repository URL',
19
+ });
20
+
21
+ function handleScanErrorMessage({ error, repoUrl, command }) {
22
+ console.log('\n');
23
+ const errorMessage = error.issues[error.issues.length - 1].message;
24
+ if (repoUrl) {
25
+ console.log(`Error: ${chalk.bold(repoUrl)} is ${errorMessage}`);
26
+ }
27
+ console.log(
28
+ `Example: \n\tmobbdev ${command} ${chalk.bold(
29
+ '-r https://github.com/WebGoat/WebGoat'
30
+ )}`
31
+ );
32
+ }
33
+
34
+ export async function analyze(
35
+ { repoUrl, scanFilePath, branch, apiKey },
36
+ { skipPrompts = false } = {}
37
+ ) {
38
+ const { success, error } = UrlZ.safeParse(repoUrl);
39
+ if (!success) {
40
+ return handleScanErrorMessage({ error, repoUrl, command: 'analyze' });
41
+ }
42
+ console.log('scanFilePath', scanFilePath);
43
+ if (!fs.existsSync(scanFilePath)) {
44
+ console.log(`\nCan't access ${chalk.bold(scanFilePath)}`);
45
+ return;
46
+ }
47
+ if (path.extname(scanFilePath) !== '.json') {
48
+ console.log(`\n${chalk.bold(scanFilePath)} is not a json file`);
49
+ return;
50
+ }
51
+ await showWelcomeMessage(skipPrompts);
52
+ await runAnalysis(
53
+ { repoUrl, scanFilePath, branch, apiKey },
54
+ { skipPrompts }
55
+ );
56
+ }
57
+
58
+ export async function scan(
59
+ { repoUrl, scanner, branch, apiKey },
60
+ { skipPrompts = false } = {}
61
+ ) {
62
+ const { success, error } = UrlZ.safeParse(repoUrl);
63
+ if (!success) {
64
+ return handleScanErrorMessage({ error, repoUrl, command: 'scan' });
65
+ }
66
+ await showWelcomeMessage(skipPrompts);
67
+ scanner ??= scanner || (await choseScanner());
68
+ if (scanner !== SCANNERS.Snyk) {
69
+ console.log(
70
+ 'Vulnerability scanning via Bugsy is available only with Snyk at the moment. Additional scanners will follow soon.'
71
+ );
72
+ return;
73
+ }
74
+ await runAnalysis({ repoUrl, scanner, branch, apiKey }, { skipPrompts });
75
+ }
76
+ async function showWelcomeMessage(skipPrompts = false) {
77
+ console.log(mobbAscii);
78
+ const welcome = chalkAnimation.rainbow('\n\t\t\tWelcome to Bugsy\n');
79
+ skipPrompts ? await sleep(100) : await sleep(2000);
80
+ welcome.stop();
81
+ }
package/src/constants.mjs CHANGED
@@ -8,17 +8,55 @@ const debug = Debug('mobbdev:constants');
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  dotenv.config({ path: path.join(__dirname, '../.env') });
10
10
 
11
+ export const SCANNERS = {
12
+ Checkmarx: 'checkmarx',
13
+ Codeql: 'codeql',
14
+ Fortify: 'fortify',
15
+ Snyk: 'snyk',
16
+ };
17
+
11
18
  const envVariablesSchema = z
12
19
  .object({
13
20
  WEB_LOGIN_URL: z.string(),
14
21
  WEB_APP_URL: z.string(),
15
22
  API_URL: z.string(),
23
+ GITHUB_CLIENT_ID: z.string(),
16
24
  })
17
25
  .required();
18
26
 
19
27
  const envVariables = envVariablesSchema.parse(process.env);
20
28
  debug('config %o', envVariables);
21
29
 
30
+ export const mobbAscii = `
31
+ ..
32
+ ..........
33
+ .................
34
+ ...........................
35
+ ..............................
36
+ ................................
37
+ ..................................
38
+ ....................................
39
+ .....................................
40
+ .............................................
41
+ .................................................
42
+ ............................... .................
43
+ .................................. ............
44
+ .................. ............. ..........
45
+ ......... ........ ......... ......
46
+ ............... ....
47
+ .... ..
48
+
49
+ . ...
50
+ ..............
51
+ ......................
52
+ ...........................
53
+ ................................
54
+ ......................................
55
+ ...............................
56
+ .................
57
+ `;
58
+
22
59
  export const WEB_LOGIN_URL = envVariables.WEB_LOGIN_URL;
23
60
  export const WEB_APP_URL = envVariables.WEB_APP_URL;
24
61
  export const API_URL = envVariables.API_URL;
62
+ export const GITHUB_CLIENT_ID = envVariables.GITHUB_CLIENT_ID;
@@ -3,11 +3,10 @@ import { setTimeout, clearTimeout } from 'node:timers';
3
3
  import http from 'node:http';
4
4
  import querystring from 'node:querystring';
5
5
  import open from 'open';
6
- import { WEB_LOGIN_URL } from './constants.mjs';
7
6
 
8
7
  const debug = Debug('mobbdev:web-login');
9
8
 
10
- export async function webLogin() {
9
+ export async function callbackServer(url, redirectUrl) {
11
10
  debug('web login start');
12
11
 
13
12
  let responseResolver;
@@ -33,9 +32,10 @@ export async function webLogin() {
33
32
  req.on('end', () => {
34
33
  debug('http server end %s', body);
35
34
  res.writeHead(301, {
36
- Location: `${WEB_LOGIN_URL}?done=true`,
35
+ Location: redirectUrl,
37
36
  }).end();
38
- responseResolver(querystring.parse(body).token);
37
+
38
+ responseResolver(querystring.parse(body));
39
39
  });
40
40
  });
41
41
 
@@ -47,8 +47,8 @@ export async function webLogin() {
47
47
  });
48
48
  debug('http server started on port %d', port);
49
49
 
50
- debug('opening the browser on %s', `${WEB_LOGIN_URL}?port=${port}`);
51
- await open(`${WEB_LOGIN_URL}?port=${port}`);
50
+ debug('opening the browser on %s', `${url}?port=${port}`);
51
+ await open(`${url}?port=${port}`);
52
52
 
53
53
  try {
54
54
  debug('waiting for http request');
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import chalk from 'chalk';
3
+ import stream from 'node:stream';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import fetch from 'node-fetch';
7
+ import extract from 'extract-zip';
8
+ import Debug from 'debug';
9
+ import { createSpinner } from 'nanospinner';
10
+ import { GITHUB_CLIENT_ID, WEB_APP_URL } from '../../constants.mjs';
11
+ const pipeline = promisify(stream.pipeline);
12
+ const debug = Debug('mobbdev:github');
13
+
14
+ const githubTokenUrl = `${WEB_APP_URL}/gh-callback`;
15
+
16
+ export const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=repo&redirect_uri=${githubTokenUrl}`;
17
+
18
+ async function getRepo(slug, { token } = {}) {
19
+ try {
20
+ return fetch(`https://api.github.com/repos/${slug}`, {
21
+ method: 'GET',
22
+ headers: {
23
+ Accept: 'application/vnd.github+json',
24
+ 'X-GitHub-Api-Version': '2022-11-28',
25
+ ...(token && { Authorization: `bearer ${token}` }),
26
+ },
27
+ });
28
+ } catch (e) {
29
+ debug(`error fetching the repo ${slug}`, e);
30
+ throw e;
31
+ }
32
+ }
33
+
34
+ function extractSlug(repoUrl) {
35
+ debug('get default branch %s', repoUrl);
36
+ let slug = repoUrl.replace(/https?:\/\/github\.com\//i, '');
37
+
38
+ if (slug.endsWith('/')) {
39
+ slug = slug.substring(0, slug.length - 1);
40
+ }
41
+
42
+ if (slug.endsWith('.git')) {
43
+ slug = slug.substring(0, slug.length - '.git'.length);
44
+ }
45
+ debug('slug %s', slug);
46
+ return slug;
47
+ }
48
+
49
+ export async function canReachRepo(repoUrl, { token } = {}) {
50
+ const slug = extractSlug(repoUrl);
51
+ const response = await getRepo(slug, { token });
52
+ return response.ok;
53
+ }
54
+
55
+ export async function getDefaultBranch(repoUrl, { token } = {}) {
56
+ const slug = extractSlug(repoUrl);
57
+ const response = await getRepo(slug, { token });
58
+ if (!response.ok) {
59
+ debug('GH request failed %s %s', response.body, response.status);
60
+ throw new Error(
61
+ `Can't get default branch, make sure you have access to : ${repoUrl}.`
62
+ );
63
+ }
64
+
65
+ const repoInfo = await response.json();
66
+ debug('GH request ok %o', repoInfo);
67
+
68
+ return repoInfo.default_branch;
69
+ }
70
+
71
+ export async function downloadRepo(
72
+ { repoUrl, reference, dirname },
73
+ { token } = {}
74
+ ) {
75
+ const repoSpinner = createSpinner('💾 Downloading Repo').start();
76
+ debug('download repo %s %s %s', repoUrl, reference, dirname);
77
+ const zipFilePath = path.join(dirname, 'repo.zip');
78
+ const response = await fetch(`${repoUrl}/zipball/${reference}`, {
79
+ method: 'GET',
80
+ headers: {
81
+ ...(token && { Authorization: `bearer ${token}` }),
82
+ },
83
+ });
84
+ if (!response.ok) {
85
+ debug(
86
+ 'GH zipball request failed %s %s',
87
+ response.body,
88
+ response.status
89
+ );
90
+ repoSpinner.error({ text: '💾 Repo download failed' });
91
+ throw new Error(
92
+ `Can't access the the branch ${chalk.bold(
93
+ reference
94
+ )} on ${chalk.bold(repoUrl)} make sure it exits.`
95
+ );
96
+ }
97
+
98
+ const fileWriterStream = fs.createWriteStream(zipFilePath);
99
+
100
+ await pipeline(response.body, fileWriterStream);
101
+ await extract(zipFilePath, { dir: dirname });
102
+
103
+ const repoRoot = fs
104
+ .readdirSync(dirname, { withFileTypes: true })
105
+ .filter((dirent) => dirent.isDirectory())
106
+ .map((dirent) => dirent.name)[0];
107
+ debug('repo root %s', repoRoot);
108
+ repoSpinner.success({ text: '💾 Repo downloaded successfully' });
109
+ return path.join(dirname, repoRoot);
110
+ }
@@ -1,14 +1,15 @@
1
1
  import fetch from 'node-fetch';
2
2
  import Debug from 'debug';
3
- import { API_URL } from './constants.mjs';
3
+ import { API_URL } from '../../constants.mjs';
4
4
 
5
5
  const debug = Debug('mobbdev:gql');
6
6
 
7
7
  const ME = `
8
8
  query Me {
9
- user {
9
+ me {
10
10
  id
11
11
  email
12
+ githubToken
12
13
  }
13
14
  }
14
15
  `;
@@ -67,18 +68,30 @@ mutation SubmitVulnerabilityReport($vulnerabilityReportFileName: String!, $fixRe
67
68
  `;
68
69
 
69
70
  export class GQLClient {
70
- constructor(token) {
71
- debug('init with token %s', token);
71
+ constructor(args) {
72
+ const { token, apiKey } = args;
73
+ apiKey
74
+ ? debug('init with apiKey %s', apiKey)
75
+ : debug('init with token %s', token);
72
76
  this._token = token;
77
+ this._apiKey = apiKey;
78
+ }
79
+ async getUserInfo() {
80
+ const { me } = await this._apiCall(ME);
81
+ return me;
73
82
  }
74
83
 
75
84
  async _apiCall(query, variables = {}) {
76
85
  debug('api call %o %s', variables, query);
86
+ const headers = this._apiKey
87
+ ? { 'x-mobb-key': this._apiKey }
88
+ : {
89
+ authorization: `Bearer ${this._token}`,
90
+ };
91
+ debug('headers %o', headers);
77
92
  const response = await fetch(API_URL, {
78
93
  method: 'POST',
79
- headers: {
80
- authorization: `Bearer ${this._token}`,
81
- },
94
+ headers,
82
95
  body: JSON.stringify({
83
96
  query,
84
97
  variables,
@@ -0,0 +1,242 @@
1
+ import chalk from 'chalk';
2
+ import Configstore from 'configstore';
3
+ import Debug from 'debug';
4
+ import { createSpinner } from 'nanospinner';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import open from 'open';
9
+ import semver from 'semver';
10
+ import { callbackServer } from './callback-server.mjs';
11
+ import tmp from 'tmp';
12
+
13
+ import { WEB_APP_URL } from '../../constants.mjs';
14
+ import {
15
+ canReachRepo,
16
+ downloadRepo,
17
+ getDefaultBranch,
18
+ GITHUB_OAUTH_URL,
19
+ } from './github.mjs';
20
+ import { GQLClient } from './gql.mjs';
21
+ import { githubIntegrationPrompt, mobbAnalysisPrompt } from './prompts.mjs';
22
+ import { getSnykReport } from './snyk.mjs';
23
+ import { uploadFile } from './upload-file.mjs';
24
+ import { keypress } from '../../utils.mjs';
25
+
26
+ const webLoginUrl = `${WEB_APP_URL}/cli-login`;
27
+ const githubSubmitUrl = `${WEB_APP_URL}/gh-callback`;
28
+
29
+ const MOBB_LOGIN_REQUIRED_MSG = `🔓 Login to Mobb is Required, you will be redirected to our login page, once the authorization is complete return to this prompt, ${chalk.bgBlue(
30
+ 'press any key to continue'
31
+ )};`;
32
+ const tmpObj = tmp.dirSync({
33
+ unsafeCleanup: true,
34
+ });
35
+
36
+ const debug = Debug('mobbdev:index');
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+
39
+ const packageJson = JSON.parse(
40
+ fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8')
41
+ );
42
+
43
+ if (!semver.satisfies(process.version, packageJson.engines.node)) {
44
+ console.error(
45
+ `${packageJson.name} requires node version ${packageJson.engines.node}, but running ${process.version}.`
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ const config = new Configstore(packageJson.name, { token: '' });
51
+ debug('config %o', config);
52
+
53
+ export async function runAnalysis(
54
+ { repoUrl, scanner, scanFilePath, branch, apiKey },
55
+ { skipPrompts }
56
+ ) {
57
+ try {
58
+ await _scan(
59
+ {
60
+ dirname: tmpObj.name,
61
+ repoUrl,
62
+ scanner,
63
+ scanFilePath,
64
+ branch,
65
+ apiKey,
66
+ },
67
+ { skipPrompts }
68
+ );
69
+ } catch (err) {
70
+ console.error(
71
+ 'Something went wrong, please try again or contact support if issue persists.'
72
+ );
73
+ console.error(err);
74
+ } finally {
75
+ tmpObj.removeCallback();
76
+ }
77
+ }
78
+
79
+ export async function _scan(
80
+ { dirname, repoUrl, scanFilePath, branch, apiKey },
81
+ { skipPrompts = false } = {}
82
+ ) {
83
+ debug('start %s %s', dirname, repoUrl);
84
+
85
+ let token = config.get('token');
86
+ debug('token %s', token);
87
+ apiKey ?? debug('token %s', apiKey);
88
+ let gqlClient = new GQLClient(apiKey ? { apiKey } : { token });
89
+ await handleMobbLogin();
90
+ const userInfo = await gqlClient.getUserInfo();
91
+ let { githubToken } = userInfo;
92
+ const isRepoAvailable = await canReachRepo(repoUrl, {
93
+ token: userInfo.githubToken,
94
+ });
95
+ if (!isRepoAvailable) {
96
+ const { token } = await handleGithubIntegration();
97
+ githubToken = token;
98
+ }
99
+
100
+ const reference =
101
+ branch ?? (await getDefaultBranch(repoUrl, { token: githubToken }));
102
+ const { projectId, organizationId } = await gqlClient.getOrgAndProjectId();
103
+ debug('org id %s', organizationId);
104
+ debug('project id %s', projectId);
105
+ debug('default branch %s', reference);
106
+ const uploadData = await gqlClient.uploadS3BucketInfo();
107
+
108
+ const repositoryRoot = await downloadRepo(
109
+ {
110
+ repoUrl,
111
+ reference,
112
+ dirname,
113
+ },
114
+ { token: githubToken }
115
+ );
116
+
117
+ const reportPath = scanFilePath || (await getReportFromSnyk());
118
+
119
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
120
+ await uploadFile(
121
+ reportPath,
122
+ uploadData.url,
123
+ uploadData.uploadKey,
124
+ uploadData.uploadFields
125
+ );
126
+ const mobbSpinner = createSpinner('🕵️‍♂️ Initiating Mobb analysis').start();
127
+ try {
128
+ await gqlClient.submitVulnerabilityReport(
129
+ uploadData.fixReportId,
130
+ repoUrl,
131
+ reference,
132
+ projectId
133
+ );
134
+ } catch (e) {
135
+ mobbSpinner.error({ text: '🕵️‍♂️ Mobb analysis failed' });
136
+ throw e;
137
+ }
138
+
139
+ debug('report %o', report);
140
+
141
+ const results = ((report.runs || [])[0] || {}).results || [];
142
+ if (results.length === 0 && !scanFilePath) {
143
+ mobbSpinner.success({
144
+ text: '🕵️‍♂️ Report did not detect any vulnerabilities — nothing to fix.',
145
+ });
146
+ } else {
147
+ mobbSpinner.success({
148
+ text: '🕵️‍♂️ Generating fixes...',
149
+ });
150
+
151
+ await askToOpenAnalysis();
152
+ }
153
+ process.exit(0);
154
+ async function getReportFromSnyk() {
155
+ const reportPath = path.join(dirname, 'report.json');
156
+
157
+ if (
158
+ !(await getSnykReport(reportPath, repositoryRoot, { skipPrompts }))
159
+ ) {
160
+ debug('snyk code is not enabled');
161
+ return;
162
+ }
163
+ return reportPath;
164
+ }
165
+ async function askToOpenAnalysis() {
166
+ !skipPrompts && (await mobbAnalysisPrompt());
167
+ open(
168
+ `${WEB_APP_URL}/organization/${organizationId}/project/${projectId}/report/${uploadData.fixReportId}`
169
+ );
170
+ console.log(
171
+ chalk.bgBlue(
172
+ '\n\n My work here is done for now, see you soon! 🕵️‍♂️ '
173
+ )
174
+ );
175
+ }
176
+
177
+ async function handleMobbLogin() {
178
+ if (
179
+ (token && (await gqlClient.verifyToken())) ||
180
+ (apiKey && (await gqlClient.verifyToken()))
181
+ ) {
182
+ createSpinner().start().success({
183
+ text: '🔓 Logged in to Mobb successfully',
184
+ });
185
+
186
+ return;
187
+ }
188
+ if (apiKey && !(await gqlClient.verifyToken())) {
189
+ createSpinner().start().error({
190
+ text: '🔓 Logged in to Mobb failed - check your api-key',
191
+ });
192
+ process.exit(1);
193
+ return;
194
+ }
195
+ const mobbLoginSpinner = createSpinner().start();
196
+ if (!skipPrompts) {
197
+ mobbLoginSpinner.update({ text: MOBB_LOGIN_REQUIRED_MSG });
198
+ await keypress();
199
+ }
200
+
201
+ mobbLoginSpinner.update({
202
+ text: '🔓 Waiting for Mobb login...',
203
+ });
204
+
205
+ const loginResponse = await callbackServer(
206
+ webLoginUrl,
207
+ `${webLoginUrl}?done=true`
208
+ );
209
+ token = loginResponse.token;
210
+
211
+ gqlClient = new GQLClient({ token });
212
+
213
+ if (!(await gqlClient.verifyToken())) {
214
+ mobbLoginSpinner.error({
215
+ text: 'Something went wrong, API token is invalid.',
216
+ });
217
+ process.exit(0);
218
+ }
219
+ debug('set token %s', token);
220
+ config.set('token', token);
221
+ mobbLoginSpinner.success({ text: '🔓 Login to Mobb successful!' });
222
+ }
223
+ async function handleGithubIntegration() {
224
+ const addGithubIntegration = skipPrompts
225
+ ? true
226
+ : await githubIntegrationPrompt();
227
+
228
+ const githubSpinner = createSpinner(
229
+ '🔗 Waiting for github integration...'
230
+ ).start();
231
+ if (!addGithubIntegration) {
232
+ githubSpinner.error();
233
+ throw Error('Could not reach github repo');
234
+ }
235
+ const result = await callbackServer(
236
+ GITHUB_OAUTH_URL,
237
+ `${githubSubmitUrl}?done=true`
238
+ );
239
+ githubSpinner.success({ text: '🔗 Github integration successful!' });
240
+ return result;
241
+ }
242
+ }
@@ -0,0 +1,55 @@
1
+ import inquirer from 'inquirer';
2
+ import { keypress } from '../../utils.mjs';
3
+ import { SCANNERS } from '../../constants.mjs';
4
+ import { createSpinner } from 'nanospinner';
5
+
6
+ export async function choseScanner() {
7
+ const { scanner } = await inquirer.prompt({
8
+ name: 'scanner',
9
+ message: 'Choose a scanner you wish to use to scan your code',
10
+ type: 'list',
11
+ choices: [
12
+ { name: 'Snyk', value: SCANNERS.Snyk },
13
+ { name: 'Checkmarx', value: SCANNERS.Checkmarx },
14
+ { name: 'Codeql', value: SCANNERS.Codeql },
15
+ { name: 'Fortify', value: SCANNERS.Fortify },
16
+ ],
17
+ });
18
+ return scanner;
19
+ }
20
+
21
+ export async function snykLoginPrompt() {
22
+ const spinner = createSpinner(
23
+ '🔓 Login to Snyk is required, press any key to continue'
24
+ ).start();
25
+ await keypress();
26
+ return spinner.success();
27
+ }
28
+
29
+ export async function githubIntegrationPrompt() {
30
+ const answers = await inquirer.prompt({
31
+ name: 'githubConfirm',
32
+ type: 'confirm',
33
+ message:
34
+ "It seems we don't have access to the repo, do you want to grant access to your github account",
35
+ default: true,
36
+ });
37
+ return answers.githubConfirm;
38
+ }
39
+ export async function mobbAnalysisPrompt() {
40
+ const spinner = createSpinner().start();
41
+ spinner.update({ text: 'Hit any key to view available fixes' });
42
+ await keypress();
43
+ return spinner.success();
44
+ }
45
+
46
+ export async function snykArticlePrompt() {
47
+ const { snykArticleConfirm } = await inquirer.prompt({
48
+ name: 'snykArticleConfirm',
49
+ type: 'confirm',
50
+ message:
51
+ "Do you want to be taken to the relevant Snyk's online article?",
52
+ default: true,
53
+ });
54
+ return snykArticleConfirm;
55
+ }
@@ -1,15 +1,21 @@
1
1
  import cp from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
- import readline from 'node:readline';
4
3
  import { stdout } from 'colors/lib/system/supports-colors.js';
5
4
  import open from 'open';
6
5
  import * as process from 'process';
7
6
  import Debug from 'debug';
7
+ import chalk from 'chalk';
8
+ import { createSpinner } from 'nanospinner';
9
+ import { snykArticlePrompt } from './prompts.mjs';
10
+ import { keypress } from '../../utils.mjs';
8
11
 
9
12
  const debug = Debug('mobbdev:snyk');
10
13
  const require = createRequire(import.meta.url);
11
14
  const SNYK_PATH = require.resolve('snyk/bin/snyk');
12
15
 
16
+ const SNYK_ARTICLE_URL =
17
+ 'https://docs.snyk.io/scan-application-code/snyk-code/getting-started-with-snyk-code/activating-snyk-code-using-the-web-ui/step-1-enabling-the-snyk-code-option';
18
+
13
19
  debug('snyk executable path %s', SNYK_PATH);
14
20
 
15
21
  async function forkSnyk(args, display) {
@@ -45,30 +51,33 @@ async function forkSnyk(args, display) {
45
51
  });
46
52
  }
47
53
 
48
- async function question(questionString) {
49
- const rl = readline.createInterface({
50
- input: process.stdin,
51
- output: process.stdout,
52
- });
53
-
54
- return new Promise((resolve) => {
55
- rl.question(`${questionString} `, (answer) => {
56
- rl.close();
57
- resolve(answer);
58
- });
59
- });
60
- }
61
-
62
- export async function getSnykReport(reportPath, repoRoot) {
54
+ export async function getSnykReport(
55
+ reportPath,
56
+ repoRoot,
57
+ { skipPrompts = false }
58
+ ) {
63
59
  debug('get snyk report start %s %s', reportPath, repoRoot);
64
60
 
65
61
  const config = await forkSnyk(['config'], false);
66
-
67
62
  if (!config.includes('api: ')) {
63
+ const snykLoginSpinner = createSpinner().start();
64
+ if (!skipPrompts) {
65
+ snykLoginSpinner.update({
66
+ text: '🔓 Login to Snyk is required, press any key to continue',
67
+ });
68
+ await keypress();
69
+ }
70
+ snykLoginSpinner.update({
71
+ text: '🔓 Waiting for Snyk login to complete',
72
+ });
73
+
68
74
  debug('no token in the config %s', config);
69
75
  await forkSnyk(['auth'], true);
76
+ snykLoginSpinner.success({ text: '🔓 Login to Snyk Successful' });
70
77
  }
71
-
78
+ const snykSpinner = createSpinner(
79
+ '🔍 Scanning your repo with Snyk '
80
+ ).start();
72
81
  const out = await forkSnyk(
73
82
  ['code', 'test', `--sarif-file-output=${reportPath}`, repoRoot],
74
83
  true
@@ -80,18 +89,22 @@ export async function getSnykReport(reportPath, repoRoot) {
80
89
  )
81
90
  ) {
82
91
  debug('snyk code is not enabled %s', out);
83
- const answer = await question(
84
- "Do you want to be taken to the relevant Snyk's online article? (Y/N)"
85
- );
92
+ snykSpinner.error({ text: '🔍 Snyk configuration needed' });
93
+ const answer = await snykArticlePrompt();
86
94
  debug('answer %s', answer);
87
95
 
88
- if (['y', 'yes', ''].includes(answer.toLowerCase())) {
96
+ if (answer) {
89
97
  debug('opening the browser');
90
- await open(
91
- 'https://docs.snyk.io/scan-application-code/snyk-code/getting-started-with-snyk-code/activating-snyk-code-using-the-web-ui/step-1-enabling-the-snyk-code-option'
92
- );
98
+ await open(SNYK_ARTICLE_URL);
93
99
  }
100
+ console.log(
101
+ chalk.bgBlue(
102
+ '\nPlease enable Snyk Code in your Snyk account and try again.'
103
+ )
104
+ );
94
105
  return false;
95
106
  }
107
+
108
+ snykSpinner.success({ text: '🔍 Snyk code scan completed' });
96
109
  return true;
97
110
  }
package/src/utils.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import readline from 'node:readline';
2
+ export const sleep = (ms = 2000) => new Promise((r) => setTimeout(r, ms));
3
+
4
+ export async function keypress() {
5
+ const rl = readline.createInterface({
6
+ input: process.stdin,
7
+ output: process.stdout,
8
+ });
9
+
10
+ return new Promise((resolve) => {
11
+ rl.question('', (answer) => {
12
+ rl.close();
13
+ process.stderr.moveCursor(0, -1);
14
+ process.stderr.clearLine(1);
15
+ resolve(answer);
16
+ });
17
+ });
18
+ }
package/src/yargs.mjs ADDED
@@ -0,0 +1,100 @@
1
+ import yargs from 'yargs/yargs';
2
+ import chalk from 'chalk';
3
+
4
+ import { SCANNERS } from './constants.mjs';
5
+
6
+ const branchOption = {
7
+ alias: 'branch',
8
+ describe: chalk.bold('Branch of the repository'),
9
+ type: 'string',
10
+ };
11
+
12
+ const repoOption = {
13
+ alias: 'repo',
14
+ demandOption: true,
15
+ describe: chalk.bold('Github repository URL'),
16
+ };
17
+
18
+ const yesOption = {
19
+ alias: 'yes',
20
+ describe: chalk.bold('Skip prompts and use default values'),
21
+ };
22
+
23
+ const apiKeyOption = {
24
+ describe: chalk.bold('Mobb authentication api-key'),
25
+ type: 'string',
26
+ };
27
+
28
+ export const parseArgs = (args) => {
29
+ const yargsInstance = yargs(args);
30
+ return yargsInstance
31
+ .updateStrings({
32
+ 'Commands:': chalk.yellow.underline.bold('Commands:'),
33
+ 'Options:': chalk.yellow.underline.bold('Options:'),
34
+ 'Examples:': chalk.yellow.underline.bold('Examples:'),
35
+ 'Show help': chalk.bold('Show help'),
36
+ })
37
+ .usage(
38
+ `${chalk.bold(
39
+ '\n Bugsy - Trusted, Automatic Vulnerability Fixer 🕵️‍♂️\n\n'
40
+ )} ${chalk.yellow.underline.bold('Usage:')} \n $0 ${chalk.green(
41
+ '<command>'
42
+ )} ${chalk.dim('[options]')}
43
+ `
44
+ )
45
+ .version(false)
46
+ .command({
47
+ //
48
+ command: 'scan',
49
+ describe: chalk.bold(
50
+ 'Scan your code for vulnerabilities, get automated fixes right away.'
51
+ ),
52
+ builder: (yargs) => {
53
+ return yargs.options({
54
+ r: repoOption,
55
+ b: branchOption,
56
+ s: {
57
+ alias: 'scanner',
58
+ choices: Object.values(SCANNERS),
59
+ describe: chalk.bold('Select the scanner to use'),
60
+ },
61
+ y: yesOption,
62
+ ['api-key']: apiKeyOption,
63
+ });
64
+ },
65
+ })
66
+ .command({
67
+ command: 'analyze',
68
+ describe: chalk.bold(
69
+ 'Provide a vulnerability report and relevant code repository, get automated fixes right away.'
70
+ ),
71
+ builder: (yargs) => {
72
+ return yargs.options({
73
+ f: {
74
+ alias: 'scan-file',
75
+ demandOption: true,
76
+ describe: chalk.bold(
77
+ 'Select the vulnerability report to analyze'
78
+ ),
79
+ },
80
+ r: repoOption,
81
+ b: branchOption,
82
+ y: yesOption,
83
+ ['api-key']: apiKeyOption,
84
+ });
85
+ },
86
+ })
87
+ .example('$0 scan -r https://github.com/WebGoat/WebGoat')
88
+ .command({
89
+ command: '*',
90
+ handler() {
91
+ yargsInstance.showHelp();
92
+ },
93
+ })
94
+ .help('h')
95
+ .alias('h', 'help')
96
+ .epilog(chalk.bgBlue('Made with ❤️ by Mobb'))
97
+ .showHelpOnFail(true)
98
+ .wrap(Math.min(120, yargsInstance.terminalWidth()))
99
+ .parse();
100
+ };
package/src/github.mjs DELETED
@@ -1,74 +0,0 @@
1
- import fs from 'node:fs';
2
- import stream from 'node:stream';
3
- import path from 'node:path';
4
- import { promisify } from 'node:util';
5
- import fetch from 'node-fetch';
6
- import extract from 'extract-zip';
7
- import Debug from 'debug';
8
-
9
- const pipeline = promisify(stream.pipeline);
10
- const debug = Debug('mobbdev:github');
11
-
12
- export async function getDefaultBranch(repoUrl) {
13
- debug('get default branch %s', repoUrl);
14
- let slug = repoUrl.replace(/https?:\/\/github\.com\//i, '');
15
-
16
- if (slug.endsWith('/')) {
17
- slug = slug.substring(0, slug.length - 1);
18
- }
19
-
20
- if (slug.endsWith('.git')) {
21
- slug = slug.substring(0, slug.length - '.git'.length);
22
- }
23
- debug('slug %s', slug);
24
-
25
- const response = await fetch(`https://api.github.com/repos/${slug}`, {
26
- method: 'GET',
27
- headers: {
28
- Accept: 'application/vnd.github+json',
29
- 'X-GitHub-Api-Version': '2022-11-28',
30
- },
31
- });
32
-
33
- if (!response.ok) {
34
- debug('GH request failed %s %s', response.body, response.status);
35
- throw new Error(
36
- `Can't get default branch, make sure the repository is public: ${repoUrl}.`
37
- );
38
- }
39
-
40
- const repoInfo = await response.json();
41
- debug('GH request ok %o', repoInfo);
42
-
43
- return repoInfo.default_branch;
44
- }
45
-
46
- export async function downloadRepo(repoUrl, reference, dirname) {
47
- debug('download repo %s %s %s', repoUrl, reference, dirname);
48
- const zipFilePath = path.join(dirname, 'repo.zip');
49
- const response = await fetch(`${repoUrl}/zipball/${reference}`);
50
-
51
- if (!response.ok) {
52
- debug(
53
- 'GH zipball request failed %s %s',
54
- response.body,
55
- response.status
56
- );
57
- throw new Error(
58
- `Can't access the repository, make it is public: ${repoUrl}.`
59
- );
60
- }
61
-
62
- const fileWriterStream = fs.createWriteStream(zipFilePath);
63
-
64
- await pipeline(response.body, fileWriterStream);
65
- await extract(zipFilePath, { dir: dirname });
66
-
67
- const repoRoot = fs
68
- .readdirSync(dirname, { withFileTypes: true })
69
- .filter((dirent) => dirent.isDirectory())
70
- .map((dirent) => dirent.name)[0];
71
- debug('repo root %s', repoRoot);
72
-
73
- return path.join(dirname, repoRoot);
74
- }
package/src/index.mjs DELETED
@@ -1,107 +0,0 @@
1
- import { fileURLToPath } from 'node:url';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import semver from 'semver';
5
- import open from 'open';
6
- import Configstore from 'configstore';
7
- import Debug from 'debug';
8
- import { GQLClient } from './gql.mjs';
9
- import { webLogin } from './web-login.mjs';
10
- import { downloadRepo, getDefaultBranch } from './github.mjs';
11
- import { getSnykReport } from './snyk.mjs';
12
- import { uploadFile } from './upload-file.mjs';
13
- import { WEB_APP_URL } from './constants.mjs';
14
-
15
- const debug = Debug('mobbdev:index');
16
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
- const packageJson = JSON.parse(
18
- fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')
19
- );
20
-
21
- if (!semver.satisfies(process.version, packageJson.engines.node)) {
22
- console.error(
23
- `${packageJson.name} requires node version ${packageJson.engines.node}, but running ${process.version}.`
24
- );
25
- process.exit(1);
26
- }
27
-
28
- const config = new Configstore(packageJson.name, { token: '' });
29
- debug('config %o', config);
30
-
31
- export async function main(dirname, repoUrl) {
32
- debug('start %s %s', dirname, repoUrl);
33
-
34
- if (!repoUrl) {
35
- console.warn(
36
- 'Mobb CLI usage: npx mobbdev <public GitHub repository URL>'
37
- );
38
- return;
39
- }
40
-
41
- let token = config.get('token');
42
- debug('token %s', token);
43
- let gqlClient = new GQLClient(token);
44
-
45
- if (!token || !(await gqlClient.verifyToken())) {
46
- console.log(
47
- 'You will be redirected to our login page, once the authorization is complete, return to this prompt.'
48
- );
49
- token = await webLogin();
50
- gqlClient = new GQLClient(token);
51
-
52
- if (!(await gqlClient.verifyToken())) {
53
- console.error('Something went wrong, API token is invalid.');
54
- return;
55
- }
56
- debug('set token %s', token);
57
- config.set('token', token);
58
- }
59
-
60
- const { projectId, organizationId } = await gqlClient.getOrgAndProjectId();
61
- debug('org id %s', organizationId);
62
- debug('project id %s', projectId);
63
- const reference = await getDefaultBranch(repoUrl);
64
- debug('default branch %s', reference);
65
- const uploadData = await gqlClient.uploadS3BucketInfo();
66
-
67
- const repositoryRoot = await downloadRepo(repoUrl, reference, dirname);
68
- const reportPath = path.join(dirname, 'report.json');
69
-
70
- console.log(
71
- 'We will run Snyk CLI to scan the repository for vulnerabilities. You may be redirected to the login page in the process.'
72
- );
73
-
74
- if (!(await getSnykReport(reportPath, repositoryRoot))) {
75
- debug('snyk code is not enabled');
76
- return;
77
- }
78
-
79
- const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
80
-
81
- await uploadFile(
82
- reportPath,
83
- uploadData.url,
84
- uploadData.uploadKey,
85
- uploadData.uploadFields
86
- );
87
- await gqlClient.submitVulnerabilityReport(
88
- uploadData.fixReportId,
89
- repoUrl,
90
- reference,
91
- projectId
92
- );
93
-
94
- debug('report %o', report);
95
-
96
- const results = ((report.runs || [])[0] || {}).results || [];
97
- if (results.length === 0) {
98
- console.log('Snyk has not found any vulnerabilities — nothing to fix.');
99
- } else {
100
- console.log(
101
- 'You will be redirected to our report page, please wait until the analysis is finished and enjoy your fixes.'
102
- );
103
- await open(
104
- `${WEB_APP_URL}/organization/${organizationId}/project/${projectId}/report/${uploadData.fixReportId}`
105
- );
106
- }
107
- }