mobbdev 0.0.15 → 0.0.20

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,23 @@
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 } = args;
10
+ await scan({ repoUrl: repo, branch, scanner }, { skipPrompts: yes });
11
+ }
12
+ if (command === 'analyze') {
13
+ const { repo, scanFile, branch, yes } = args;
14
+ await analyze(
15
+ { repoUrl: repo, scanFilePath: scanFile, branch },
16
+ { skipPrompts: yes }
12
17
  );
13
- console.error(err);
14
- })
15
- .then(() => {
16
- tmpObj.removeCallback();
17
- });
18
+ }
19
+ }
20
+
21
+ (async () => {
22
+ await run();
23
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobbdev",
3
- "version": "0.0.15",
3
+ "version": "0.0.20",
4
4
  "description": "Automated secure code remediation tool",
5
5
  "main": "index.mjs",
6
6
  "scripts": {
@@ -15,15 +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",
22
+ "debug": "4.3.4",
20
23
  "dotenv": "16.0.3",
21
24
  "extract-zip": "2.0.1",
25
+ "inquirer": "9.2.7",
26
+ "nanospinner": "1.1.0",
22
27
  "node-fetch": "3.3.1",
23
28
  "open": "8.4.2",
24
29
  "semver": "7.5.0",
25
30
  "snyk": "1.1118.0",
26
31
  "tmp": "0.2.1",
32
+ "yargs": "17.7.2",
27
33
  "zod": "3.21.4"
28
34
  },
29
35
  "devDependencies": {
@@ -0,0 +1,78 @@
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 },
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({ repoUrl, scanFilePath, branch }, { skipPrompts });
53
+ }
54
+
55
+ export async function scan(
56
+ { repoUrl, scanner, branch },
57
+ { skipPrompts = false } = {}
58
+ ) {
59
+ const { success, error } = UrlZ.safeParse(repoUrl);
60
+ if (!success) {
61
+ return handleScanErrorMessage({ error, repoUrl, command: 'scan' });
62
+ }
63
+ await showWelcomeMessage(skipPrompts);
64
+ scanner ??= scanner || (await choseScanner());
65
+ if (scanner !== SCANNERS.Snyk) {
66
+ console.log(
67
+ 'Vulnerability scanning via Bugsy is available only with Snyk at the moment. Additional scanners will follow soon.'
68
+ );
69
+ return;
70
+ }
71
+ await runAnalysis({ repoUrl, scanner, branch }, { skipPrompts });
72
+ }
73
+ async function showWelcomeMessage(skipPrompts = false) {
74
+ console.log(mobbAscii);
75
+ const welcome = chalkAnimation.rainbow('\n\t\t\tWelcome to Bugsy\n');
76
+ skipPrompts ? await sleep(100) : await sleep(2000);
77
+ welcome.stop();
78
+ }
package/src/constants.mjs CHANGED
@@ -2,20 +2,61 @@ import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import * as dotenv from 'dotenv';
4
4
  import { z } from 'zod';
5
+ import Debug from 'debug';
5
6
 
7
+ const debug = Debug('mobbdev:constants');
6
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
9
  dotenv.config({ path: path.join(__dirname, '../.env') });
8
10
 
11
+ export const SCANNERS = {
12
+ Checkmarx: 'checkmarx',
13
+ Codeql: 'codeql',
14
+ Fortify: 'fortify',
15
+ Snyk: 'snyk',
16
+ };
17
+
9
18
  const envVariablesSchema = z
10
19
  .object({
11
20
  WEB_LOGIN_URL: z.string(),
12
21
  WEB_APP_URL: z.string(),
13
22
  API_URL: z.string(),
23
+ GITHUB_CLIENT_ID: z.string(),
14
24
  })
15
25
  .required();
16
26
 
17
27
  const envVariables = envVariablesSchema.parse(process.env);
28
+ debug('config %o', envVariables);
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
+ `;
18
58
 
19
59
  export const WEB_LOGIN_URL = envVariables.WEB_LOGIN_URL;
20
60
  export const WEB_APP_URL = envVariables.WEB_APP_URL;
21
61
  export const API_URL = envVariables.API_URL;
62
+ export const GITHUB_CLIENT_ID = envVariables.GITHUB_CLIENT_ID;
@@ -1,10 +1,14 @@
1
+ import Debug from 'debug';
1
2
  import { setTimeout, clearTimeout } from 'node:timers';
2
3
  import http from 'node:http';
3
- import { WEB_LOGIN_URL } from './constants.mjs';
4
4
  import querystring from 'node:querystring';
5
5
  import open from 'open';
6
6
 
7
- export async function webLogin() {
7
+ const debug = Debug('mobbdev:web-login');
8
+
9
+ export async function callbackServer(url, redirectUrl) {
10
+ debug('web login start');
11
+
8
12
  let responseResolver;
9
13
  let responseRejecter;
10
14
  const responseAwaiter = new Promise((resolve, reject) => {
@@ -12,35 +16,45 @@ export async function webLogin() {
12
16
  responseRejecter = reject;
13
17
  });
14
18
  const timerHandler = setTimeout(() => {
15
- responseRejecter(new Error('No login happened in one minute.'));
16
- }, 60000);
19
+ debug('timeout happened');
20
+ responseRejecter(new Error('No login happened in three minutes.'));
21
+ }, 180000);
17
22
 
18
23
  const server = http.createServer((req, res) => {
24
+ debug('incoming request');
19
25
  let body = '';
20
26
 
21
27
  req.on('data', (chunk) => {
28
+ debug('http server get chunk %s', chunk);
22
29
  body += chunk;
23
30
  });
24
31
 
25
32
  req.on('end', () => {
33
+ debug('http server end %s', body);
26
34
  res.writeHead(301, {
27
- Location: `${WEB_LOGIN_URL}?done=true`,
35
+ Location: redirectUrl,
28
36
  }).end();
29
- responseResolver(querystring.parse(body).token);
37
+
38
+ responseResolver(querystring.parse(body));
30
39
  });
31
40
  });
32
41
 
42
+ debug('http server starting');
33
43
  const port = await new Promise((resolve) => {
34
44
  server.listen(0, '127.0.0.1', () => {
35
45
  resolve(server.address().port);
36
46
  });
37
47
  });
48
+ debug('http server started on port %d', port);
38
49
 
39
- await open(`${WEB_LOGIN_URL}?port=${port}`);
50
+ debug('opening the browser on %s', `${url}?port=${port}`);
51
+ await open(`${url}?port=${port}`);
40
52
 
41
53
  try {
54
+ debug('waiting for http request');
42
55
  return await responseAwaiter;
43
56
  } finally {
57
+ debug('http server close');
44
58
  clearTimeout(timerHandler);
45
59
  server.close();
46
60
  }
@@ -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,22 +1,32 @@
1
1
  import fetch from 'node-fetch';
2
- import { API_URL } from './constants.mjs';
2
+ import Debug from 'debug';
3
+ import { API_URL } from '../../constants.mjs';
4
+
5
+ const debug = Debug('mobbdev:gql');
3
6
 
4
7
  const ME = `
5
8
  query Me {
6
- user {
9
+ me {
7
10
  id
8
11
  email
9
- userOrganizations {
12
+ githubToken
13
+ }
14
+ }
15
+ `;
16
+
17
+ const GET_ORG_AND_PROJECT_ID = `
18
+ query getOrgAndProjectId {
19
+ user {
20
+ userOrganizationsAndUserOrganizationRoles {
10
21
  organization {
11
22
  id
12
- projects {
23
+ projects(order_by: {updatedAt: desc}) {
13
24
  id
14
25
  }
15
26
  }
16
27
  }
17
28
  }
18
- }
19
- `;
29
+ }`;
20
30
 
21
31
  const CREATE_COMMUNITY_USER = `
22
32
  mutation CreateCommunityUser {
@@ -59,12 +69,16 @@ mutation SubmitVulnerabilityReport($vulnerabilityReportFileName: String!, $fixRe
59
69
 
60
70
  export class GQLClient {
61
71
  constructor(token) {
72
+ debug('init with token %s', token);
62
73
  this._token = token;
63
- this._projectId = undefined;
64
- this._organizationId = undefined;
74
+ }
75
+ async getUserInfo() {
76
+ const { me } = await this._apiCall(ME);
77
+ return me;
65
78
  }
66
79
 
67
80
  async _apiCall(query, variables = {}) {
81
+ debug('api call %o %s', variables, query);
68
82
  const response = await fetch(API_URL, {
69
83
  method: 'POST',
70
84
  headers: {
@@ -77,13 +91,15 @@ export class GQLClient {
77
91
  });
78
92
 
79
93
  if (!response.ok) {
94
+ debug('API request failed %s %s', response.body, response.status);
80
95
  throw new Error(`API call failed: ${response.status}`);
81
96
  }
82
97
 
83
98
  const data = await response.json();
99
+ debug('API request ok %j', data);
84
100
 
85
101
  if (data.errors) {
86
- throw new Error(`API error: ${response.errors[0].message}`);
102
+ throw new Error(`API error: ${data.errors[0].message}`);
87
103
  }
88
104
 
89
105
  if (!data.data) {
@@ -93,40 +109,35 @@ export class GQLClient {
93
109
  return data.data;
94
110
  }
95
111
 
96
- getOrganizationId() {
97
- return this._organizationId;
98
- }
99
-
100
- getProjectId() {
101
- return this._projectId;
102
- }
103
-
104
112
  async verifyToken() {
105
113
  await this.createCommunityUser();
106
114
 
107
115
  try {
108
- const userInfo = await this._apiCall(ME);
109
- const {
110
- user: [{ userOrganizations }],
111
- } = userInfo;
112
- const [
113
- {
114
- organization: { id: organizationId, projects },
115
- },
116
- ] = userOrganizations;
117
- const [{ id: projectId }] = projects;
118
- this._projectId = projectId;
119
- this._organizationId = organizationId;
116
+ await this._apiCall(ME);
120
117
  } catch (e) {
118
+ debug('verify token failed %o', e);
121
119
  return false;
122
120
  }
123
121
  return true;
124
122
  }
125
123
 
124
+ async getOrgAndProjectId() {
125
+ const data = await this._apiCall(GET_ORG_AND_PROJECT_ID);
126
+ const org =
127
+ data.user[0].userOrganizationsAndUserOrganizationRoles[0]
128
+ .organization;
129
+
130
+ return {
131
+ organizationId: org.id,
132
+ projectId: org.projects[0].id,
133
+ };
134
+ }
135
+
126
136
  async createCommunityUser() {
127
137
  try {
128
138
  await this._apiCall(CREATE_COMMUNITY_USER);
129
139
  } catch (e) {
140
+ debug('create community user failed %o', e);
130
141
  // Ignore errors
131
142
  }
132
143
  }
@@ -146,13 +157,18 @@ export class GQLClient {
146
157
  };
147
158
  }
148
159
 
149
- async submitVulnerabilityReport(fixReportId, repoUrl, reference) {
160
+ async submitVulnerabilityReport(
161
+ fixReportId,
162
+ repoUrl,
163
+ reference,
164
+ projectId
165
+ ) {
150
166
  await this._apiCall(SUBMIT_VULNERABILITY_REPORT, {
151
167
  fixReportId,
152
168
  repoUrl,
153
169
  reference,
154
170
  vulnerabilityReportFileName: 'report.json',
155
- projectId: this._projectId,
171
+ projectId,
156
172
  });
157
173
  }
158
174
  }
@@ -0,0 +1,223 @@
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 },
55
+ { skipPrompts }
56
+ ) {
57
+ try {
58
+ await _scan(
59
+ { dirname: tmpObj.name, repoUrl, scanner, scanFilePath, branch },
60
+ { skipPrompts }
61
+ );
62
+ } catch (err) {
63
+ console.error(
64
+ 'Something went wrong, please try again or contact support if issue persists.'
65
+ );
66
+ console.error(err);
67
+ } finally {
68
+ tmpObj.removeCallback();
69
+ }
70
+ }
71
+
72
+ export async function _scan(
73
+ { dirname, repoUrl, scanFilePath, branch },
74
+ { skipPrompts = false } = {}
75
+ ) {
76
+ debug('start %s %s', dirname, repoUrl);
77
+
78
+ let token = config.get('token');
79
+ debug('token %s', token);
80
+ let gqlClient = new GQLClient(token);
81
+ await handleMobbLogin();
82
+ const userInfo = await gqlClient.getUserInfo();
83
+ let { githubToken } = userInfo;
84
+ const isRepoAvailable = await canReachRepo(repoUrl, {
85
+ token: userInfo.githubToken,
86
+ });
87
+ if (!isRepoAvailable) {
88
+ const { token } = await handleGithubIntegration();
89
+ githubToken = token;
90
+ }
91
+
92
+ const reference =
93
+ branch ?? (await getDefaultBranch(repoUrl, { token: githubToken }));
94
+ const { projectId, organizationId } = await gqlClient.getOrgAndProjectId();
95
+ debug('org id %s', organizationId);
96
+ debug('project id %s', projectId);
97
+ debug('default branch %s', reference);
98
+ const uploadData = await gqlClient.uploadS3BucketInfo();
99
+
100
+ const repositoryRoot = await downloadRepo(
101
+ {
102
+ repoUrl,
103
+ reference,
104
+ dirname,
105
+ },
106
+ { token: githubToken }
107
+ );
108
+
109
+ const reportPath = scanFilePath || (await getReportFromSnyk());
110
+
111
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
112
+ await uploadFile(
113
+ reportPath,
114
+ uploadData.url,
115
+ uploadData.uploadKey,
116
+ uploadData.uploadFields
117
+ );
118
+ const mobbSpinner = createSpinner('🕵️‍♂️ Initiating Mobb analysis').start();
119
+ try {
120
+ await gqlClient.submitVulnerabilityReport(
121
+ uploadData.fixReportId,
122
+ repoUrl,
123
+ reference,
124
+ projectId
125
+ );
126
+ } catch (e) {
127
+ mobbSpinner.error({ text: '🕵️‍♂️ Mobb analysis failed' });
128
+ throw e;
129
+ }
130
+
131
+ debug('report %o', report);
132
+
133
+ const results = ((report.runs || [])[0] || {}).results || [];
134
+ if (results.length === 0 && !scanFilePath) {
135
+ mobbSpinner.success({
136
+ text: '🕵️‍♂️ Report did not detect any vulnerabilities — nothing to fix.',
137
+ });
138
+ } else {
139
+ mobbSpinner.success({
140
+ text: '🕵️‍♂️ Generating fixes...',
141
+ });
142
+
143
+ await askToOpenAnalysis();
144
+ }
145
+ process.exit(0);
146
+ async function getReportFromSnyk() {
147
+ const reportPath = path.join(dirname, 'report.json');
148
+
149
+ if (
150
+ !(await getSnykReport(reportPath, repositoryRoot, { skipPrompts }))
151
+ ) {
152
+ debug('snyk code is not enabled');
153
+ return;
154
+ }
155
+ return reportPath;
156
+ }
157
+ async function askToOpenAnalysis() {
158
+ !skipPrompts && (await mobbAnalysisPrompt());
159
+ open(
160
+ `${WEB_APP_URL}/organization/${organizationId}/project/${projectId}/report/${uploadData.fixReportId}`
161
+ );
162
+ console.log(
163
+ chalk.bgBlue(
164
+ '\n\n My work here is done for now, see you soon! 🕵️‍♂️ '
165
+ )
166
+ );
167
+ }
168
+
169
+ async function handleMobbLogin() {
170
+ if (token && (await gqlClient.verifyToken())) {
171
+ createSpinner().start().success({
172
+ text: '🔓 Logged in to Mobb successfully',
173
+ });
174
+ return;
175
+ }
176
+ const mobbLoginSpinner = createSpinner().start();
177
+ if (!skipPrompts) {
178
+ mobbLoginSpinner.update({ text: MOBB_LOGIN_REQUIRED_MSG });
179
+ await keypress();
180
+ }
181
+
182
+ mobbLoginSpinner.update({
183
+ text: '🔓 Waiting for Mobb login...',
184
+ });
185
+
186
+ const loginResponse = await callbackServer(
187
+ webLoginUrl,
188
+ `${webLoginUrl}?done=true`
189
+ );
190
+ token = loginResponse.token;
191
+
192
+ gqlClient = new GQLClient(token);
193
+
194
+ if (!(await gqlClient.verifyToken())) {
195
+ mobbLoginSpinner.error({
196
+ text: 'Something went wrong, API token is invalid.',
197
+ });
198
+ process.exit(0);
199
+ }
200
+ debug('set token %s', token);
201
+ config.set('token', token);
202
+ mobbLoginSpinner.success({ text: '🔓 Login to Mobb successful!' });
203
+ }
204
+ async function handleGithubIntegration() {
205
+ const addGithubIntegration = skipPrompts
206
+ ? true
207
+ : await githubIntegrationPrompt();
208
+
209
+ const githubSpinner = createSpinner(
210
+ '🔗 Waiting for github integration...'
211
+ ).start();
212
+ if (!addGithubIntegration) {
213
+ githubSpinner.error();
214
+ throw Error('Could not reach github repo');
215
+ }
216
+ const result = await callbackServer(
217
+ GITHUB_OAUTH_URL,
218
+ `${githubSubmitUrl}?done=true`
219
+ );
220
+ githubSpinner.success({ text: '🔗 Github integration successful!' });
221
+ return result;
222
+ }
223
+ }
@@ -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
+ }
@@ -0,0 +1,110 @@
1
+ import cp from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { stdout } from 'colors/lib/system/supports-colors.js';
4
+ import open from 'open';
5
+ import * as process from 'process';
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';
11
+
12
+ const debug = Debug('mobbdev:snyk');
13
+ const require = createRequire(import.meta.url);
14
+ const SNYK_PATH = require.resolve('snyk/bin/snyk');
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
+
19
+ debug('snyk executable path %s', SNYK_PATH);
20
+
21
+ async function forkSnyk(args, display) {
22
+ debug('fork snyk with args %o %s', args, display);
23
+
24
+ return new Promise((resolve, reject) => {
25
+ const child = cp.fork(SNYK_PATH, args, {
26
+ stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
27
+ env: { FORCE_COLOR: stdout.level },
28
+ });
29
+ let out = '';
30
+ const onData = (chunk) => {
31
+ debug('chunk received from snyk std %s', chunk);
32
+ out += chunk;
33
+ };
34
+
35
+ child.stdout.on('data', onData);
36
+ child.stderr.on('data', onData);
37
+
38
+ if (display) {
39
+ child.stdout.pipe(process.stdout);
40
+ child.stderr.pipe(process.stderr);
41
+ }
42
+
43
+ child.on('exit', () => {
44
+ debug('snyk exit');
45
+ resolve(out);
46
+ });
47
+ child.on('error', (err) => {
48
+ debug('snyk error %o', err);
49
+ reject(err);
50
+ });
51
+ });
52
+ }
53
+
54
+ export async function getSnykReport(
55
+ reportPath,
56
+ repoRoot,
57
+ { skipPrompts = false }
58
+ ) {
59
+ debug('get snyk report start %s %s', reportPath, repoRoot);
60
+
61
+ const config = await forkSnyk(['config'], false);
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
+
74
+ debug('no token in the config %s', config);
75
+ await forkSnyk(['auth'], true);
76
+ snykLoginSpinner.success({ text: '🔓 Login to Snyk Successful' });
77
+ }
78
+ const snykSpinner = createSpinner(
79
+ '🔍 Scanning your repo with Snyk '
80
+ ).start();
81
+ const out = await forkSnyk(
82
+ ['code', 'test', `--sarif-file-output=${reportPath}`, repoRoot],
83
+ true
84
+ );
85
+
86
+ if (
87
+ out.includes(
88
+ 'Snyk Code is not supported for org: enable in Settings > Snyk Code'
89
+ )
90
+ ) {
91
+ debug('snyk code is not enabled %s', out);
92
+ snykSpinner.error({ text: '🔍 Snyk configuration needed' });
93
+ const answer = await snykArticlePrompt();
94
+ debug('answer %s', answer);
95
+
96
+ if (answer) {
97
+ debug('opening the browser');
98
+ await open(SNYK_ARTICLE_URL);
99
+ }
100
+ console.log(
101
+ chalk.bgBlue(
102
+ '\nPlease enable Snyk Code in your Snyk account and try again.'
103
+ )
104
+ );
105
+ return false;
106
+ }
107
+
108
+ snykSpinner.success({ text: '🔍 Snyk code scan completed' });
109
+ return true;
110
+ }
@@ -1,6 +1,13 @@
1
1
  import fetch, { FormData, fileFrom } from 'node-fetch';
2
+ import Debug from 'debug';
3
+
4
+ const debug = Debug('mobbdev:upload-file');
2
5
 
3
6
  export async function uploadFile(reportPath, url, uploadKey, uploadFields) {
7
+ debug('upload report file start %s %s', reportPath, url);
8
+ debug('upload fields %o', uploadFields);
9
+ debug('upload key %s', uploadKey);
10
+
4
11
  const form = new FormData();
5
12
 
6
13
  for (const key in uploadFields) {
@@ -16,6 +23,8 @@ export async function uploadFile(reportPath, url, uploadKey, uploadFields) {
16
23
  });
17
24
 
18
25
  if (!response.ok) {
26
+ debug('error from S3 %s %s', response.body, response.status);
19
27
  throw new Error(`Failed to upload the report: ${response.status}`);
20
28
  }
29
+ debug('upload report file done');
21
30
  }
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
+ alias: 'api-key',
25
+ describe: chalk.bold('Mobb authentication api-key'),
26
+ type: 'string',
27
+ };
28
+
29
+ export const parseArgs = (args) => {
30
+ const yargsInstance = yargs(args);
31
+ return yargsInstance
32
+ .updateStrings({
33
+ 'Commands:': chalk.yellow.underline.bold('Commands:'),
34
+ 'Options:': chalk.yellow.underline.bold('Options:'),
35
+ 'Examples:': chalk.yellow.underline.bold('Examples:'),
36
+ 'Show help': chalk.bold('Show help'),
37
+ })
38
+ .usage(
39
+ `${chalk.bold(
40
+ '\n Bugsy - Trusted, Automatic Vulnerability Fixer 🕵️‍♂️\n\n'
41
+ )} ${chalk.yellow.underline.bold('Usage:')} \n $0 ${chalk.green(
42
+ '<command>'
43
+ )} ${chalk.dim('[options]')}
44
+ `
45
+ )
46
+ .version(false)
47
+ .command({
48
+ //
49
+ command: 'scan',
50
+ describe: chalk.bold(
51
+ 'Scan your code for vulnerabilities, get automated fixes right away.'
52
+ ),
53
+ builder: (yargs) => {
54
+ return yargs.options({
55
+ r: repoOption,
56
+ b: branchOption,
57
+ s: {
58
+ alias: 'scanner',
59
+ choices: Object.values(SCANNERS),
60
+ describe: chalk.bold('Select the scanner to use'),
61
+ },
62
+ y: yesOption,
63
+ k: apiKeyOption,
64
+ });
65
+ },
66
+ })
67
+ .command({
68
+ command: 'analyze',
69
+ describe: chalk.bold(
70
+ 'Provide a vulnerability report and relevant code repository, get automated fixes right away.'
71
+ ),
72
+ builder: (yargs) => {
73
+ return yargs.options({
74
+ f: {
75
+ alias: 'scan-file',
76
+ demandOption: true,
77
+ describe: chalk.bold(
78
+ 'Select the vulnerability report to analyze'
79
+ ),
80
+ },
81
+ r: repoOption,
82
+ y: yesOption,
83
+ k: 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,54 +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
-
8
- const pipeline = promisify(stream.pipeline);
9
-
10
- export async function getDefaultBranch(repoUrl) {
11
- let slug = repoUrl.replace(/https?:\/\/github\.com\//i, '');
12
-
13
- if (slug.endsWith('/')) {
14
- slug = slug.substring(0, slug.length - 1);
15
- }
16
-
17
- if (slug.endsWith('.git')) {
18
- slug = slug.substring(0, slug.length - '.git'.length);
19
- }
20
-
21
- const response = await fetch(`https://api.github.com/repos/${slug}`, {
22
- method: 'GET',
23
- headers: {
24
- Accept: 'application/vnd.github+json',
25
- 'X-GitHub-Api-Version': '2022-11-28',
26
- },
27
- });
28
-
29
- if (!response.ok) {
30
- throw new Error(
31
- `Can't get default branch, make sure the repository is public: ${repoUrl}.`
32
- );
33
- }
34
-
35
- const repoInfo = await response.json();
36
-
37
- return repoInfo.default_branch;
38
- }
39
-
40
- export async function downloadRepo(repoUrl, reference, dirname) {
41
- const zipFilePath = path.join(dirname, 'repo.zip');
42
- const response = await fetch(`${repoUrl}/zipball/${reference}`);
43
- const fileWriterStream = fs.createWriteStream(zipFilePath);
44
-
45
- await pipeline(response.body, fileWriterStream);
46
- await extract(zipFilePath, { dir: dirname });
47
-
48
- const repoRoot = fs
49
- .readdirSync(dirname, { withFileTypes: true })
50
- .filter((dirent) => dirent.isDirectory())
51
- .map((dirent) => dirent.name)[0];
52
-
53
- return path.join(dirname, repoRoot);
54
- }
package/src/index.mjs DELETED
@@ -1,91 +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 { GQLClient } from './gql.mjs';
8
- import { webLogin } from './web-login.mjs';
9
- import { downloadRepo, getDefaultBranch } from './github.mjs';
10
- import { getSnykReport } from './snyk.mjs';
11
- import { uploadFile } from './upload-file.mjs';
12
- import { WEB_APP_URL } from './constants.mjs';
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
- const packageJson = JSON.parse(
16
- fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')
17
- );
18
-
19
- if (!semver.satisfies(process.version, packageJson.engines.node)) {
20
- console.error(
21
- `${packageJson.name} requires node version ${packageJson.engines.node}, but running ${process.version}.`
22
- );
23
- process.exit(1);
24
- }
25
-
26
- const config = new Configstore(packageJson.name, { token: '' });
27
-
28
- export async function main(dirname, repoUrl) {
29
- if (!repoUrl) {
30
- console.warn(
31
- 'Mobb CLI usage: npx mobbdev <public GitHub repository URL>'
32
- );
33
- return;
34
- }
35
-
36
- let token = config.get('token');
37
- let gqlClient = new GQLClient(token);
38
-
39
- if (!token || !(await gqlClient.verifyToken())) {
40
- console.log(
41
- 'You will be redirected to our login page, once the authorization is complete, return to this prompt.'
42
- );
43
- token = await webLogin();
44
- gqlClient = new GQLClient(token);
45
-
46
- if (!(await gqlClient.verifyToken())) {
47
- console.error('Something went wrong, API token is invalid.');
48
- return;
49
- }
50
- config.set('token', token);
51
- }
52
-
53
- const reference = await getDefaultBranch(repoUrl);
54
- const uploadData = await gqlClient.uploadS3BucketInfo();
55
-
56
- const repositoryRoot = await downloadRepo(repoUrl, reference, dirname);
57
- const reportPath = path.join(dirname, 'report.json');
58
-
59
- console.log(
60
- 'We will run Snyk CLI to scan the repository for vulnerabilities. You may be redirected to the login page in the process.'
61
- );
62
- await getSnykReport(reportPath, repositoryRoot);
63
-
64
- const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
65
-
66
- await uploadFile(
67
- reportPath,
68
- uploadData.url,
69
- uploadData.uploadKey,
70
- uploadData.uploadFields
71
- );
72
- await gqlClient.submitVulnerabilityReport(
73
- uploadData.fixReportId,
74
- repoUrl,
75
- reference
76
- );
77
-
78
- const results = ((report.runs || [])[0] || {}).results || [];
79
- if (results.length === 0) {
80
- console.log('Snyk has not found any vulnerabilities — nothing to fix.');
81
- } else {
82
- console.log(
83
- 'You will be redirected to our report page, please wait until the analysis is finished and enjoy your fixes.'
84
- );
85
- const projectId = gqlClient.getProjectId();
86
- const organizationId = gqlClient.getOrganizationId();
87
- await open(
88
- `${WEB_APP_URL}/organization/${organizationId}/project/${projectId}/report/${uploadData.fixReportId}`
89
- );
90
- }
91
- }
package/src/snyk.mjs DELETED
@@ -1,82 +0,0 @@
1
- import cp from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import readline from 'node:readline';
4
- import { stdout } from 'colors/lib/system/supports-colors.js';
5
- import open from 'open';
6
- import * as process from 'process';
7
-
8
- const require = createRequire(import.meta.url);
9
- const SNYK_PATH = require.resolve('snyk/bin/snyk');
10
-
11
- async function forkSnyk(args, display) {
12
- return new Promise((resolve, reject) => {
13
- const child = cp.fork(SNYK_PATH, args, {
14
- stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
15
- env: { FORCE_COLOR: stdout.level },
16
- });
17
- let out = '';
18
- const onData = (chunk) => {
19
- out += chunk;
20
- };
21
-
22
- child.stdout.on('data', onData);
23
- child.stderr.on('data', onData);
24
-
25
- if (display) {
26
- child.stdout.pipe(process.stdout);
27
- child.stderr.pipe(process.stderr);
28
- }
29
-
30
- child.on('exit', () => {
31
- resolve(out);
32
- });
33
- child.on('error', (err) => {
34
- reject(err);
35
- });
36
- });
37
- }
38
-
39
- async function question(questionString) {
40
- const rl = readline.createInterface({
41
- input: process.stdin,
42
- output: process.stdout,
43
- });
44
-
45
- return new Promise((resolve) => {
46
- rl.question(`${questionString} `, (answer) => {
47
- rl.close();
48
- resolve(answer);
49
- });
50
- });
51
- }
52
-
53
- export async function getSnykReport(reportPath, repoRoot) {
54
- const config = await forkSnyk(['config'], false);
55
-
56
- if (!config.includes('api: ')) {
57
- await forkSnyk(['auth'], true);
58
- }
59
-
60
- const out = await forkSnyk(
61
- ['code', 'test', `--sarif-file-output=${reportPath}`, repoRoot],
62
- true
63
- );
64
-
65
- if (
66
- out.includes(
67
- 'Snyk Code is not supported for org: enable in Settings > Snyk Code'
68
- )
69
- ) {
70
- const answer = await question(
71
- "Do you want to be taken to the relevant Snyk's online article? (Y/N)"
72
- );
73
-
74
- if (['y', 'yes', ''].includes(answer.toLowerCase())) {
75
- await open(
76
- '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'
77
- );
78
- }
79
-
80
- process.exit(0);
81
- }
82
- }