@socketsecurity/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,15 @@ socket report view QXU8PmK7LfH608RAwfIKdbcHgwEd_ZeWJ9QEGv05FJUQ
22
22
  ## Commands
23
23
 
24
24
  * `socket info <package@version>` - looks up issues for a package
25
- * `socket report create <path(s)-to-folder-or-file>` - uploads the specified `package.json` and/or `package-lock.json` to create a report on [socket.dev](https://socket.dev/). If only one of a `package.json`/`package-lock.json` has been specified, the other will be automatically found and uploaded if it exists
25
+
26
+ * `socket report create <path(s)-to-folder-or-file>` - creates a report on [socket.dev](https://socket.dev/)
27
+
28
+ Uploads the specified `package.json` and lock files and, if any folder is specified, the ones found in there. Also includes the complementary `package.json` and lock file to any specified. Currently `package-lock.json` and `yarn.lock` are supported.
29
+
30
+ Supports globbing such as `**/package.json`.
31
+
32
+ Ignores any file specified in your project's `.gitignore`, the `projectIgnorePaths` in your project's [`socket.yml`](https://docs.socket.dev/docs/socket-yml) and on top of that has a sensible set of [default ignores](https://www.npmjs.com/package/ignore-by-default)
33
+
26
34
  * `socket report view <report-id>` - looks up issues and scores from a report
27
35
 
28
36
  ## Flags
@@ -36,6 +44,11 @@ socket report view QXU8PmK7LfH608RAwfIKdbcHgwEd_ZeWJ9QEGv05FJUQ
36
44
  * `--json` - outputs result as json which you can then pipe into [`jq`](https://stedolan.github.io/jq/) and other tools
37
45
  * `--markdown` - outputs result as markdown which you can then copy into an issue, PR or even chat
38
46
 
47
+ ## Strictness flags
48
+
49
+ * `--all` - by default only `high` and `critical` issues are included, by setting this flag all issues will be included
50
+ * `--strict` - when set, exits with an error code if any issues were found
51
+
39
52
  ### Other flags
40
53
 
41
54
  * `--dry-run` - like all CLI tools that perform an action should have, we have a dry run flag. Eg. `socket report create` supports running the command without actually uploading anything
@@ -43,6 +56,10 @@ socket report view QXU8PmK7LfH608RAwfIKdbcHgwEd_ZeWJ9QEGv05FJUQ
43
56
  * `--help` - prints the help for the current command. All CLI tools should have this flag
44
57
  * `--version` - prints the version of the tool. All CLI tools should have this flag
45
58
 
59
+ ## Configuration files
60
+
61
+ The CLI reads and uses data from a [`socket.yml` file](https://docs.socket.dev/docs/socket-yml) in the folder you run it in. It supports the version 2 of the `socket.yml` file format and makes use of the `projectIgnorePaths` to excludes files when creating a report.
62
+
46
63
  ## Environment variables
47
64
 
48
65
  * `SOCKET_SECURITY_API_KEY` - if set, this will be used as the API-key
package/cli.js CHANGED
@@ -37,6 +37,7 @@ try {
37
37
  } else if (err instanceof InputError) {
38
38
  errorTitle = 'Invalid input'
39
39
  errorMessage = err.message
40
+ errorBody = err.body
40
41
  } else if (err instanceof Error) {
41
42
  errorTitle = 'Unexpected error'
42
43
  errorMessage = messageWithCauses(err)
@@ -7,8 +7,9 @@ import ora from 'ora'
7
7
  import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api-helpers.js'
8
8
  import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js'
9
9
  import { InputError } from '../../utils/errors.js'
10
- import { getSeveritySummary } from '../../utils/format-issues.js'
10
+ import { getSeverityCount, formatSeverityCount } from '../../utils/format-issues.js'
11
11
  import { printFlagList } from '../../utils/formatting.js'
12
+ import { objectSome } from '../../utils/misc.js'
12
13
  import { setupSdk } from '../../utils/sdk.js'
13
14
 
14
15
  /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
@@ -18,32 +19,44 @@ export const info = {
18
19
  const name = parentName + ' info'
19
20
 
20
21
  const input = setupCommand(name, info.description, argv, importMeta)
21
- const result = input && await fetchPackageData(input.pkgName, input.pkgVersion)
22
+ const packageData = input && await fetchPackageData(input.pkgName, input.pkgVersion, input)
22
23
 
23
- if (result) {
24
- formatPackageDataOutput(result.data, { name, ...input })
24
+ if (packageData) {
25
+ formatPackageDataOutput(packageData, { name, ...input })
25
26
  }
26
27
  }
27
28
  }
28
29
 
29
30
  // Internal functions
30
31
 
32
+ /**
33
+ * @typedef CommandContext
34
+ * @property {boolean} includeAllIssues
35
+ * @property {boolean} outputJson
36
+ * @property {boolean} outputMarkdown
37
+ * @property {string} pkgName
38
+ * @property {string} pkgVersion
39
+ * @property {boolean} strict
40
+ */
41
+
31
42
  /**
32
43
  * @param {string} name
33
44
  * @param {string} description
34
45
  * @param {readonly string[]} argv
35
46
  * @param {ImportMeta} importMeta
36
- * @returns {void|{ outputJson: boolean, outputMarkdown: boolean, pkgName: string, pkgVersion: string }}
47
+ * @returns {void|CommandContext}
37
48
  */
38
- function setupCommand (name, description, argv, importMeta) {
49
+ function setupCommand (name, description, argv, importMeta) {
39
50
  const cli = meow(`
40
51
  Usage
41
52
  $ ${name} <name>
42
53
 
43
54
  Options
44
55
  ${printFlagList({
56
+ '--all': 'Include all issues',
45
57
  '--json': 'Output result as json',
46
58
  '--markdown': 'Output result as markdown',
59
+ '--strict': 'Exits with an error code if any matching issues are found',
47
60
  }, 6)}
48
61
 
49
62
  Examples
@@ -54,6 +67,10 @@ export const info = {
54
67
  description,
55
68
  importMeta,
56
69
  flags: {
70
+ all: {
71
+ type: 'boolean',
72
+ default: false,
73
+ },
57
74
  json: {
58
75
  type: 'boolean',
59
76
  alias: 'j',
@@ -64,12 +81,18 @@ export const info = {
64
81
  alias: 'm',
65
82
  default: false,
66
83
  },
84
+ strict: {
85
+ type: 'boolean',
86
+ default: false,
87
+ },
67
88
  }
68
89
  })
69
90
 
70
91
  const {
92
+ all: includeAllIssues,
71
93
  json: outputJson,
72
94
  markdown: outputMarkdown,
95
+ strict,
73
96
  } = cli.flags
74
97
 
75
98
  if (cli.input.length > 1) {
@@ -97,19 +120,28 @@ export const info = {
97
120
  }
98
121
 
99
122
  return {
123
+ includeAllIssues,
100
124
  outputJson,
101
125
  outputMarkdown,
102
126
  pkgName,
103
- pkgVersion
127
+ pkgVersion,
128
+ strict,
104
129
  }
105
130
  }
106
131
 
132
+ /**
133
+ * @typedef PackageData
134
+ * @property {import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>["data"]} data
135
+ * @property {Record<import('../../utils/format-issues').SocketIssue['severity'], number>} severityCount
136
+ */
137
+
107
138
  /**
108
139
  * @param {string} pkgName
109
140
  * @param {string} pkgVersion
110
- * @returns {Promise<void|import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>>}
141
+ * @param {Pick<CommandContext, 'includeAllIssues' | 'strict'>} context
142
+ * @returns {Promise<void|PackageData>}
111
143
  */
112
- async function fetchPackageData (pkgName, pkgVersion) {
144
+ async function fetchPackageData (pkgName, pkgVersion, { includeAllIssues, strict }) {
113
145
  const socketSdk = await setupSdk()
114
146
  const spinner = ora(`Looking up data for version ${pkgVersion} of ${pkgName}`).start()
115
147
  const result = await handleApiCall(socketSdk.getIssuesByNPMPackage(pkgName, pkgVersion), spinner, 'looking up package')
@@ -120,32 +152,40 @@ async function fetchPackageData (pkgName, pkgVersion) {
120
152
 
121
153
  // Conclude the status of the API call
122
154
 
123
- const issueSummary = getSeveritySummary(result.data)
124
- spinner.succeed(`Found ${issueSummary || 'no'} issues for version ${pkgVersion} of ${pkgName}`)
155
+ const severityCount = getSeverityCount(result.data, includeAllIssues ? undefined : 'high')
156
+
157
+ if (objectSome(severityCount)) {
158
+ const issueSummary = formatSeverityCount(severityCount)
159
+ spinner[strict ? 'fail' : 'succeed'](`Package has these issues: ${issueSummary}`)
160
+ } else {
161
+ spinner.succeed('Package has no issues')
162
+ }
125
163
 
126
- return result
164
+ return {
165
+ data: result.data,
166
+ severityCount,
167
+ }
127
168
  }
128
169
 
129
170
  /**
130
- * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>["data"]} data
131
- * @param {{ name: string, outputJson: boolean, outputMarkdown: boolean, pkgName: string, pkgVersion: string }} context
171
+ * @param {PackageData} packageData
172
+ * @param {{ name: string } & CommandContext} context
132
173
  * @returns {void}
133
174
  */
134
- function formatPackageDataOutput (data, { name, outputJson, outputMarkdown, pkgName, pkgVersion }) {
135
- // If JSON, output and return...
136
-
175
+ function formatPackageDataOutput ({ data, severityCount }, { name, outputJson, outputMarkdown, pkgName, pkgVersion, strict }) {
137
176
  if (outputJson) {
138
177
  console.log(JSON.stringify(data, undefined, 2))
139
- return
140
- }
141
-
142
- // ...else do the CLI / Markdown output dance
178
+ } else {
179
+ const format = new ChalkOrMarkdown(!!outputMarkdown)
180
+ const url = `https://socket.dev/npm/package/${pkgName}/overview/${pkgVersion}`
143
181
 
144
- const format = new ChalkOrMarkdown(!!outputMarkdown)
145
- const url = `https://socket.dev/npm/package/${pkgName}/overview/${pkgVersion}`
182
+ console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${pkgName} v${pkgVersion}`, url, { fallbackToUrl: true }))
183
+ if (!outputMarkdown) {
184
+ console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output'))
185
+ }
186
+ }
146
187
 
147
- console.log('\nDetailed info on socket.dev: ' + format.hyperlink(`${pkgName} v${pkgVersion}`, url, { fallbackToUrl: true }))
148
- if (!outputMarkdown) {
149
- console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output'))
188
+ if (strict && objectSome(severityCount)) {
189
+ process.exit(1)
150
190
  }
151
191
  }
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable no-console */
2
2
 
3
- import { stat } from 'node:fs/promises'
4
3
  import path from 'node:path'
5
4
 
5
+ import { betterAjvErrors } from '@apideck/better-ajv-errors'
6
+ import { readSocketConfig, SocketValidationError } from '@socketsecurity/config'
6
7
  import meow from 'meow'
7
8
  import ora from 'ora'
8
9
  import { ErrorWithCause } from 'pony-cause'
@@ -12,8 +13,8 @@ import { ChalkOrMarkdown, logSymbols } from '../../utils/chalk-markdown.js'
12
13
  import { InputError } from '../../utils/errors.js'
13
14
  import { printFlagList } from '../../utils/formatting.js'
14
15
  import { createDebugLogger } from '../../utils/misc.js'
16
+ import { getPackageFiles } from '../../utils/path-resolve.js'
15
17
  import { setupSdk } from '../../utils/sdk.js'
16
- import { isErrnoException } from '../../utils/type-helpers.js'
17
18
  import { fetchReportData, formatReportDataOutput } from './view.js'
18
19
 
19
20
  /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
@@ -29,9 +30,11 @@ export const create = {
29
30
  cwd,
30
31
  debugLog,
31
32
  dryRun,
33
+ includeAllIssues,
32
34
  outputJson,
33
35
  outputMarkdown,
34
36
  packagePaths,
37
+ strict,
35
38
  view,
36
39
  } = input
37
40
 
@@ -39,10 +42,10 @@ export const create = {
39
42
 
40
43
  if (result && view) {
41
44
  const reportId = result.data.id
42
- const reportResult = input && await fetchReportData(reportId)
45
+ const reportData = input && await fetchReportData(reportId, { includeAllIssues, strict })
43
46
 
44
- if (reportResult) {
45
- formatReportDataOutput(reportResult.data, { name, outputJson, outputMarkdown, reportId })
47
+ if (reportData) {
48
+ formatReportDataOutput(reportData, { includeAllIssues, name, outputJson, outputMarkdown, reportId, strict })
46
49
  }
47
50
  } else if (result) {
48
51
  formatReportCreationOutput(result.data, { outputJson, outputMarkdown })
@@ -53,30 +56,56 @@ export const create = {
53
56
 
54
57
  // Internal functions
55
58
 
59
+ /**
60
+ * @typedef CommandContext
61
+ * @property {string} cwd
62
+ * @property {typeof console.error} debugLog
63
+ * @property {boolean} dryRun
64
+ * @property {boolean} includeAllIssues
65
+ * @property {boolean} outputJson
66
+ * @property {boolean} outputMarkdown
67
+ * @property {string[]} packagePaths
68
+ * @property {boolean} strict
69
+ * @property {boolean} view
70
+ */
71
+
56
72
  /**
57
73
  * @param {string} name
58
74
  * @param {string} description
59
75
  * @param {readonly string[]} argv
60
76
  * @param {ImportMeta} importMeta
61
- * @returns {Promise<void|{ cwd: string, debugLog: typeof console.error, dryRun: boolean, outputJson: boolean, outputMarkdown: boolean, packagePaths: string[], view: boolean }>}
77
+ * @returns {Promise<void|CommandContext>}
62
78
  */
63
79
  async function setupCommand (name, description, argv, importMeta) {
64
80
  const cli = meow(`
65
81
  Usage
66
82
  $ ${name} <paths-to-package-folders-and-files>
67
83
 
84
+ Uploads the specified "package.json" and lock files and, if any folder is
85
+ specified, the ones found in there. Also includes the complementary
86
+ "package.json" and lock file to any specified. Currently "package-lock.json"
87
+ and "yarn.lock" are supported.
88
+
89
+ Supports globbing such as "**/package.json".
90
+
91
+ Ignores any file specified in your project's ".gitignore", your project's
92
+ "socket.yml" file's "projectIgnorePaths" and also has a sensible set of
93
+ default ignores from the "ignore-by-default" module.
94
+
68
95
  Options
69
96
  ${printFlagList({
97
+ '--all': 'Include all issues',
70
98
  '--debug': 'Output debug information',
71
99
  '--dry-run': 'Only output what will be done without actually doing it',
72
100
  '--json': 'Output result as json',
73
101
  '--markdown': 'Output result as markdown',
102
+ '--strict': 'Exits with an error code if any matching issues are found',
74
103
  '--view': 'Will wait for and return the created report'
75
104
  }, 6)}
76
105
 
77
106
  Examples
78
107
  $ ${name} .
79
- $ ${name} ../package-lock.json
108
+ $ ${name} '**/package.json'
80
109
  $ ${name} /path/to/a/package.json /path/to/another/package.json
81
110
  $ ${name} . --view --json
82
111
  `, {
@@ -84,6 +113,10 @@ async function setupCommand (name, description, argv, importMeta) {
84
113
  description,
85
114
  importMeta,
86
115
  flags: {
116
+ all: {
117
+ type: 'boolean',
118
+ default: false,
119
+ },
87
120
  debug: {
88
121
  type: 'boolean',
89
122
  alias: 'd',
@@ -103,6 +136,10 @@ async function setupCommand (name, description, argv, importMeta) {
103
136
  alias: 'm',
104
137
  default: false,
105
138
  },
139
+ strict: {
140
+ type: 'boolean',
141
+ default: false,
142
+ },
106
143
  view: {
107
144
  type: 'boolean',
108
145
  alias: 'v',
@@ -112,9 +149,11 @@ async function setupCommand (name, description, argv, importMeta) {
112
149
  })
113
150
 
114
151
  const {
152
+ all: includeAllIssues,
115
153
  dryRun,
116
154
  json: outputJson,
117
155
  markdown: outputMarkdown,
156
+ strict,
118
157
  view,
119
158
  } = cli.flags
120
159
 
@@ -125,27 +164,52 @@ async function setupCommand (name, description, argv, importMeta) {
125
164
 
126
165
  const debugLog = createDebugLogger(dryRun || cli.flags.debug)
127
166
 
167
+ // TODO: Allow setting a custom cwd and/or configFile path?
128
168
  const cwd = process.cwd()
129
- const packagePaths = await resolvePackagePaths(cwd, cli.input)
169
+ const absoluteConfigPath = path.join(cwd, 'socket.yml')
170
+
171
+ const config = await readSocketConfig(absoluteConfigPath)
172
+ .catch(/** @param {unknown} cause */ cause => {
173
+ if (cause && typeof cause === 'object' && cause instanceof SocketValidationError) {
174
+ // Inspired by workbox-build: https://github.com/GoogleChrome/workbox/blob/95f97a207fd51efb3f8a653f6e3e58224183a778/packages/workbox-build/src/lib/validate-options.ts#L68-L71
175
+ const betterErrors = betterAjvErrors({
176
+ basePath: 'config',
177
+ data: cause.data,
178
+ errors: cause.validationErrors,
179
+ // @ts-ignore
180
+ schema: cause.schema,
181
+ })
182
+ throw new InputError(
183
+ 'The socket.yml config is not valid',
184
+ betterErrors.map((err) => `[${err.path}] ${err.message}.${err.suggestion ? err.suggestion : ''}`).join('\n')
185
+ )
186
+ } else {
187
+ throw new ErrorWithCause('Failed to read socket.yml config', { cause })
188
+ }
189
+ })
190
+
191
+ const packagePaths = await getPackageFiles(cwd, cli.input, config, debugLog)
130
192
 
131
193
  return {
132
194
  cwd,
133
195
  debugLog,
134
196
  dryRun,
197
+ includeAllIssues,
135
198
  outputJson,
136
199
  outputMarkdown,
137
200
  packagePaths,
201
+ strict,
138
202
  view,
139
203
  }
140
204
  }
141
205
 
142
206
  /**
143
207
  * @param {string[]} packagePaths
144
- * @param {{ cwd: string, debugLog: typeof console.error, dryRun: boolean }} context
208
+ * @param {Pick<CommandContext, 'cwd' | 'debugLog' | 'dryRun'>} context
145
209
  * @returns {Promise<void|import('@socketsecurity/sdk').SocketSdkReturnType<'createReport'>>}
146
210
  */
147
211
  async function createReport (packagePaths, { cwd, debugLog, dryRun }) {
148
- debugLog(`${logSymbols.info} Uploading:`, packagePaths.join(`\n${logSymbols.info} Uploading:`))
212
+ debugLog('Uploading:', packagePaths.join(`\n${logSymbols.info} Uploading: `))
149
213
 
150
214
  if (dryRun) {
151
215
  return
@@ -168,7 +232,7 @@ async function createReport (packagePaths, { cwd, debugLog, dryRun }) {
168
232
 
169
233
  /**
170
234
  * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'createReport'>["data"]} data
171
- * @param {{ outputJson: boolean, outputMarkdown: boolean }} context
235
+ * @param {Pick<CommandContext, 'outputJson' | 'outputMarkdown'>} context
172
236
  * @returns {void}
173
237
  */
174
238
  function formatReportCreationOutput (data, { outputJson, outputMarkdown }) {
@@ -181,113 +245,3 @@ function formatReportCreationOutput (data, { outputJson, outputMarkdown }) {
181
245
 
182
246
  console.log('\nNew report: ' + format.hyperlink(data.id, data.url, { fallbackToUrl: true }))
183
247
  }
184
-
185
- // TODO: Add globbing support with support for ignoring, as a "./**/package.json" in a project also traverses eg. node_modules
186
- /**
187
- * Takes paths to folders and/or package.json / package-lock.json files and resolves to package.json + package-lock.json pairs (where feasible)
188
- *
189
- * @param {string} cwd
190
- * @param {string[]} inputPaths
191
- * @returns {Promise<string[]>}
192
- * @throws {InputError}
193
- */
194
- async function resolvePackagePaths (cwd, inputPaths) {
195
- const packagePathLookups = inputPaths.map(async (filePath) => {
196
- const packagePath = await resolvePackagePath(cwd, filePath)
197
- return findComplementaryPackageFile(packagePath)
198
- })
199
-
200
- const packagePaths = await Promise.all(packagePathLookups)
201
-
202
- const uniquePackagePaths = new Set(packagePaths.flat())
203
-
204
- return [...uniquePackagePaths]
205
- }
206
-
207
- /**
208
- * Resolves a package.json / package-lock.json path from a relative folder / file path
209
- *
210
- * @param {string} cwd
211
- * @param {string} inputPath
212
- * @returns {Promise<string>}
213
- * @throws {InputError}
214
- */
215
- async function resolvePackagePath (cwd, inputPath) {
216
- const filePath = path.resolve(cwd, inputPath)
217
- /** @type {string|undefined} */
218
- let filePathAppended
219
-
220
- try {
221
- const fileStat = await stat(filePath)
222
-
223
- if (fileStat.isDirectory()) {
224
- filePathAppended = path.resolve(filePath, 'package.json')
225
- }
226
- } catch (err) {
227
- if (isErrnoException(err) && err.code === 'ENOENT') {
228
- throw new InputError(`Expected '${inputPath}' to point to an existing file or directory`)
229
- }
230
- throw new ErrorWithCause('Failed to resolve path to package.json', { cause: err })
231
- }
232
-
233
- if (filePathAppended) {
234
- /** @type {import('node:fs').Stats} */
235
- let filePathAppendedStat
236
-
237
- try {
238
- filePathAppendedStat = await stat(filePathAppended)
239
- } catch (err) {
240
- if (isErrnoException(err) && err.code === 'ENOENT') {
241
- throw new InputError(`Expected directory '${inputPath}' to contain a package.json file`)
242
- }
243
- throw new ErrorWithCause('Failed to resolve package.json in directory', { cause: err })
244
- }
245
-
246
- if (!filePathAppendedStat.isFile()) {
247
- throw new InputError(`Expected '${filePathAppended}' to be a file`)
248
- }
249
-
250
- return filePathAppended
251
- }
252
-
253
- return filePath
254
- }
255
-
256
- /**
257
- * Finds any complementary file to a package.json or package-lock.json
258
- *
259
- * @param {string} packagePath
260
- * @returns {Promise<string[]>}
261
- * @throws {InputError}
262
- */
263
- async function findComplementaryPackageFile (packagePath) {
264
- const basename = path.basename(packagePath)
265
- const dirname = path.dirname(packagePath)
266
-
267
- if (basename === 'package-lock.json') {
268
- // We need the package file as well
269
- return [
270
- packagePath,
271
- path.resolve(dirname, 'package.json')
272
- ]
273
- }
274
-
275
- if (basename === 'package.json') {
276
- const lockfilePath = path.resolve(dirname, 'package-lock.json')
277
- try {
278
- const lockfileStat = await stat(lockfilePath)
279
- if (lockfileStat.isFile()) {
280
- return [packagePath, lockfilePath]
281
- }
282
- } catch (err) {
283
- if (isErrnoException(err) && err.code === 'ENOENT') {
284
- return [packagePath]
285
- }
286
- throw new ErrorWithCause(`Unexpected error when finding a lockfile for '${packagePath}'`, { cause: err })
287
- }
288
-
289
- throw new InputError(`Encountered a non-file at lockfile path '${lockfilePath}'`)
290
- }
291
-
292
- throw new InputError(`Expected '${packagePath}' to point to a package.json or package-lock.json or to a folder containing a package.json`)
293
- }
@@ -7,8 +7,9 @@ import ora from 'ora'
7
7
  import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api-helpers.js'
8
8
  import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js'
9
9
  import { InputError } from '../../utils/errors.js'
10
- import { getSeveritySummary } from '../../utils/format-issues.js'
10
+ import { getSeverityCount, formatSeverityCount } from '../../utils/format-issues.js'
11
11
  import { printFlagList } from '../../utils/formatting.js'
12
+ import { objectSome } from '../../utils/misc.js'
12
13
  import { setupSdk } from '../../utils/sdk.js'
13
14
 
14
15
  /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
@@ -18,22 +19,32 @@ export const view = {
18
19
  const name = parentName + ' view'
19
20
 
20
21
  const input = setupCommand(name, view.description, argv, importMeta)
21
- const result = input && await fetchReportData(input.reportId)
22
+ const result = input && await fetchReportData(input.reportId, input)
22
23
 
23
24
  if (result) {
24
- formatReportDataOutput(result.data, { name, ...input })
25
+ formatReportDataOutput(result, { name, ...input })
25
26
  }
26
27
  }
27
28
  }
28
29
 
29
30
  // Internal functions
30
31
 
32
+ // TODO: Share more of the flag setup inbetween the commands
33
+ /**
34
+ * @typedef CommandContext
35
+ * @property {boolean} includeAllIssues
36
+ * @property {boolean} outputJson
37
+ * @property {boolean} outputMarkdown
38
+ * @property {string} reportId
39
+ * @property {boolean} strict
40
+ */
41
+
31
42
  /**
32
43
  * @param {string} name
33
44
  * @param {string} description
34
45
  * @param {readonly string[]} argv
35
46
  * @param {ImportMeta} importMeta
36
- * @returns {void|{ outputJson: boolean, outputMarkdown: boolean, reportId: string }}
47
+ * @returns {void|CommandContext}
37
48
  */
38
49
  function setupCommand (name, description, argv, importMeta) {
39
50
  const cli = meow(`
@@ -42,8 +53,10 @@ function setupCommand (name, description, argv, importMeta) {
42
53
 
43
54
  Options
44
55
  ${printFlagList({
56
+ '--all': 'Include all issues',
45
57
  '--json': 'Output result as json',
46
58
  '--markdown': 'Output result as markdown',
59
+ '--strict': 'Exits with an error code if any matching issues are found',
47
60
  }, 6)}
48
61
 
49
62
  Examples
@@ -53,9 +66,8 @@ function setupCommand (name, description, argv, importMeta) {
53
66
  description,
54
67
  importMeta,
55
68
  flags: {
56
- debug: {
69
+ all: {
57
70
  type: 'boolean',
58
- alias: 'd',
59
71
  default: false,
60
72
  },
61
73
  json: {
@@ -68,14 +80,20 @@ function setupCommand (name, description, argv, importMeta) {
68
80
  alias: 'm',
69
81
  default: false,
70
82
  },
83
+ strict: {
84
+ type: 'boolean',
85
+ default: false,
86
+ },
71
87
  }
72
88
  })
73
89
 
74
90
  // Extract the input
75
91
 
76
92
  const {
93
+ all: includeAllIssues,
77
94
  json: outputJson,
78
95
  markdown: outputMarkdown,
96
+ strict,
79
97
  } = cli.flags
80
98
 
81
99
  const [reportId, ...extraInput] = cli.input
@@ -92,17 +110,26 @@ function setupCommand (name, description, argv, importMeta) {
92
110
  }
93
111
 
94
112
  return {
113
+ includeAllIssues,
95
114
  outputJson,
96
115
  outputMarkdown,
97
116
  reportId,
117
+ strict,
98
118
  }
99
119
  }
100
120
 
121
+ /**
122
+ * @typedef ReportData
123
+ * @property {import('@socketsecurity/sdk').SocketSdkReturnType<'getReport'>["data"]} data
124
+ * @property {Record<import('../../utils/format-issues').SocketIssue['severity'], number>} severityCount
125
+ */
126
+
101
127
  /**
102
128
  * @param {string} reportId
103
- * @returns {Promise<void|import('@socketsecurity/sdk').SocketSdkReturnType<'getReport'>>}
129
+ * @param {Pick<CommandContext, 'includeAllIssues' | 'strict'>} context
130
+ * @returns {Promise<void|ReportData>}
104
131
  */
105
- export async function fetchReportData (reportId) {
132
+ export async function fetchReportData (reportId, { includeAllIssues, strict }) {
106
133
  // Do the API call
107
134
 
108
135
  const socketSdk = await setupSdk()
@@ -115,32 +142,40 @@ export async function fetchReportData (reportId) {
115
142
 
116
143
  // Conclude the status of the API call
117
144
 
118
- const issueSummary = getSeveritySummary(result.data.issues)
119
- spinner.succeed(`Report contains ${issueSummary || 'no'} issues`)
145
+ const severityCount = getSeverityCount(result.data.issues, includeAllIssues ? undefined : 'high')
120
146
 
121
- return result
147
+ if (objectSome(severityCount)) {
148
+ const issueSummary = formatSeverityCount(severityCount)
149
+ spinner[strict ? 'fail' : 'succeed'](`Report has these issues: ${issueSummary}`)
150
+ } else {
151
+ spinner.succeed('Report has no issues')
152
+ }
153
+
154
+ return {
155
+ data: result.data,
156
+ severityCount,
157
+ }
122
158
  }
123
159
 
124
160
  /**
125
- * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'getReport'>["data"]} data
126
- * @param {{ name: string, outputJson: boolean, outputMarkdown: boolean, reportId: string }} context
161
+ * @param {ReportData} reportData
162
+ * @param {{ name: string } & CommandContext} context
127
163
  * @returns {void}
128
164
  */
129
- export function formatReportDataOutput (data, { name, outputJson, outputMarkdown, reportId }) {
130
- // If JSON, output and return...
131
-
165
+ export function formatReportDataOutput ({ data, severityCount }, { name, outputJson, outputMarkdown, reportId, strict }) {
132
166
  if (outputJson) {
133
167
  console.log(JSON.stringify(data, undefined, 2))
134
- return
135
- }
136
-
137
- // ...else do the CLI / Markdown output dance
168
+ } else {
169
+ const format = new ChalkOrMarkdown(!!outputMarkdown)
170
+ const url = `https://socket.dev/npm/reports/${encodeURIComponent(reportId)}`
138
171
 
139
- const format = new ChalkOrMarkdown(!!outputMarkdown)
140
- const url = `https://socket.dev/npm/reports/${encodeURIComponent(reportId)}`
172
+ console.log('\nDetailed info on socket.dev: ' + format.hyperlink(reportId, url, { fallbackToUrl: true }))
173
+ if (!outputMarkdown) {
174
+ console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output'))
175
+ }
176
+ }
141
177
 
142
- console.log('\nDetailed info on socket.dev: ' + format.hyperlink(reportId, url, { fallbackToUrl: true }))
143
- if (!outputMarkdown) {
144
- console.log(chalk.dim('\nOr rerun', chalk.italic(name), 'using the', chalk.italic('--json'), 'flag to get full JSON output'))
178
+ if (strict && objectSome(severityCount)) {
179
+ process.exit(1)
145
180
  }
146
181
  }
@@ -1,2 +1,14 @@
1
1
  export class AuthError extends Error {}
2
- export class InputError extends Error {}
2
+
3
+ export class InputError extends Error {
4
+ /**
5
+ * @param {string} message
6
+ * @param {string} [body]
7
+ */
8
+ constructor (message, body) {
9
+ super(message)
10
+
11
+ /** @type {string|undefined} */
12
+ this.body = body
13
+ }
14
+ }
@@ -1,15 +1,43 @@
1
1
  /** @typedef {import('@socketsecurity/sdk').SocketSdkReturnType<'getIssuesByNPMPackage'>['data']} SocketIssueList */
2
2
  /** @typedef {SocketIssueList[number]['value'] extends infer U | undefined ? U : never} SocketIssue */
3
3
 
4
- import { stringJoinWithSeparateFinalSeparator } from './misc.js'
4
+ import { pick, stringJoinWithSeparateFinalSeparator } from './misc.js'
5
+
6
+ const SEVERITIES_BY_ORDER = /** @type {const} */ ([
7
+ 'critical',
8
+ 'high',
9
+ 'middle',
10
+ 'low',
11
+ ])
12
+
13
+ /**
14
+ * @param {SocketIssue['severity']|undefined} lowestToInclude
15
+ * @returns {Array<SocketIssue['severity']>}
16
+ */
17
+ function getDesiredSeverities (lowestToInclude) {
18
+ /** @type {Array<SocketIssue['severity']>} */
19
+ const result = []
20
+
21
+ for (const severity of SEVERITIES_BY_ORDER) {
22
+ result.push(severity)
23
+ if (severity === lowestToInclude) {
24
+ break
25
+ }
26
+ }
27
+
28
+ return result
29
+ }
5
30
 
6
31
  /**
7
32
  * @param {SocketIssueList} issues
33
+ * @param {SocketIssue['severity']} [lowestToInclude]
8
34
  * @returns {Record<SocketIssue['severity'], number>}
9
35
  */
10
- function getSeverityCount (issues) {
11
- /** @type {Record<SocketIssue['severity'], number>} */
12
- const severityCount = { low: 0, middle: 0, high: 0, critical: 0 }
36
+ export function getSeverityCount (issues, lowestToInclude) {
37
+ const severityCount = pick(
38
+ { low: 0, middle: 0, high: 0, critical: 0 },
39
+ getDesiredSeverities(lowestToInclude)
40
+ )
13
41
 
14
42
  for (const issue of issues) {
15
43
  const value = issue.value
@@ -27,18 +55,18 @@ function getSeverityCount (issues) {
27
55
  }
28
56
 
29
57
  /**
30
- * @param {SocketIssueList} issues
58
+ * @param {Record<SocketIssue['severity'], number>} severityCount
31
59
  * @returns {string}
32
60
  */
33
- export function getSeveritySummary (issues) {
34
- const severityCount = getSeverityCount(issues)
61
+ export function formatSeverityCount (severityCount) {
62
+ /** @type {string[]} */
63
+ const summary = []
35
64
 
36
- const issueSummary = stringJoinWithSeparateFinalSeparator([
37
- severityCount.critical ? severityCount.critical + ' critical' : undefined,
38
- severityCount.high ? severityCount.high + ' high' : undefined,
39
- severityCount.middle ? severityCount.middle + ' middle' : undefined,
40
- severityCount.low ? severityCount.low + ' low' : undefined,
41
- ])
65
+ for (const severity of SEVERITIES_BY_ORDER) {
66
+ if (severityCount[severity]) {
67
+ summary.push(`${severityCount[severity]} ${severity}`)
68
+ }
69
+ }
42
70
 
43
- return issueSummary
71
+ return stringJoinWithSeparateFinalSeparator(summary)
44
72
  }
package/lib/utils/misc.js CHANGED
@@ -1,13 +1,14 @@
1
+ import { logSymbols } from './chalk-markdown.js'
2
+
1
3
  /**
2
4
  * @param {boolean|undefined} printDebugLogs
3
5
  * @returns {typeof console.error}
4
6
  */
5
7
  export function createDebugLogger (printDebugLogs) {
6
- if (printDebugLogs) {
8
+ return printDebugLogs
7
9
  // eslint-disable-next-line no-console
8
- return console.error.bind(console)
9
- }
10
- return () => {}
10
+ ? (...params) => console.error(logSymbols.info, ...params)
11
+ : () => {}
11
12
  }
12
13
 
13
14
  /**
@@ -26,3 +27,36 @@ export function stringJoinWithSeparateFinalSeparator (list, separator = ' and ')
26
27
 
27
28
  return values.join(', ') + separator + finalValue
28
29
  }
30
+
31
+ /**
32
+ * Returns a new object with only the specified keys from the input object
33
+ *
34
+ * @template {Record<string,any>} T
35
+ * @template {keyof T} K
36
+ * @param {T} input
37
+ * @param {K[]|ReadonlyArray<K>} keys
38
+ * @returns {Pick<T, K>}
39
+ */
40
+ export function pick (input, keys) {
41
+ /** @type {Partial<Pick<T, K>>} */
42
+ const result = {}
43
+
44
+ for (const key of keys) {
45
+ result[key] = input[key]
46
+ }
47
+
48
+ return /** @type {Pick<T, K>} */ (result)
49
+ }
50
+
51
+ /**
52
+ * @param {Record<string,any>} obj
53
+ * @returns {boolean}
54
+ */
55
+ export function objectSome (obj) {
56
+ for (const key in obj) {
57
+ if (obj[key]) {
58
+ return true
59
+ }
60
+ }
61
+ return false
62
+ }
@@ -0,0 +1,152 @@
1
+ import { stat } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { globby } from 'globby'
5
+ import ignore from 'ignore'
6
+ // @ts-ignore This package provides no types
7
+ import { directories } from 'ignore-by-default'
8
+ import { ErrorWithCause } from 'pony-cause'
9
+
10
+ import { InputError } from './errors.js'
11
+ import { isErrnoException } from './type-helpers.js'
12
+
13
+ /** @type {readonly string[]} */
14
+ const SUPPORTED_LOCKFILES = [
15
+ 'package-lock.json',
16
+ 'yarn.lock',
17
+ ]
18
+
19
+ /**
20
+ * There are a lot of possible folders that we should not be looking in and "ignore-by-default" helps us with defining those
21
+ *
22
+ * @type {readonly string[]}
23
+ */
24
+ const ignoreByDefault = directories()
25
+
26
+ /** @type {readonly string[]} */
27
+ const GLOB_IGNORE = [
28
+ ...ignoreByDefault.map(item => '**/' + item)
29
+ ]
30
+
31
+ /**
32
+ * Resolves package.json and lockfiles from (globbed) input paths, applying relevant ignores
33
+ *
34
+ * @param {string} cwd The working directory to use when resolving paths
35
+ * @param {string[]} inputPaths A list of paths to folders, package.json files and/or recognized lockfiles. Supports globs.
36
+ * @param {import('@socketsecurity/config').SocketYml|undefined} config
37
+ * @param {typeof console.error} debugLog
38
+ * @returns {Promise<string[]>}
39
+ * @throws {InputError}
40
+ */
41
+ export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
42
+ const entries = await globby(inputPaths, {
43
+ absolute: true,
44
+ cwd,
45
+ expandDirectories: false,
46
+ gitignore: true,
47
+ ignore: [...GLOB_IGNORE],
48
+ markDirectories: true,
49
+ onlyFiles: false,
50
+ unique: true,
51
+ })
52
+
53
+ debugLog(`Globbed resolved ${inputPaths.length} paths to ${entries.length} paths:`, entries)
54
+
55
+ const packageFiles = await mapGlobResultToFiles(entries)
56
+
57
+ debugLog(`Mapped ${entries.length} entries to ${packageFiles.length} files:`, packageFiles)
58
+
59
+ const includedPackageFiles = config?.projectIgnorePaths?.length
60
+ ? ignore()
61
+ .add(config.projectIgnorePaths)
62
+ .filter(packageFiles.map(item => path.relative(cwd, item)))
63
+ .map(item => path.resolve(cwd, item))
64
+ : packageFiles
65
+
66
+ return includedPackageFiles
67
+ }
68
+
69
+ /**
70
+ * Takes paths to folders, package.json and/or recognized lock files and resolves them to package.json + lockfile pairs (where possible)
71
+ *
72
+ * @param {string[]} entries
73
+ * @returns {Promise<string[]>}
74
+ * @throws {InputError}
75
+ */
76
+ export async function mapGlobResultToFiles (entries) {
77
+ const packageFiles = await Promise.all(entries.map(mapGlobEntryToFiles))
78
+
79
+ const uniquePackageFiles = [...new Set(packageFiles.flat())]
80
+
81
+ return uniquePackageFiles
82
+ }
83
+
84
+ /**
85
+ * Takes a single path to a folder, package.json or a recognized lock file and resolves to a package.json + lockfile pair (where possible)
86
+ *
87
+ * @param {string} entry
88
+ * @returns {Promise<string[]>}
89
+ * @throws {InputError}
90
+ */
91
+ export async function mapGlobEntryToFiles (entry) {
92
+ /** @type {string|undefined} */
93
+ let pkgFile
94
+ /** @type {string|undefined} */
95
+ let lockFile
96
+
97
+ if (entry.endsWith('/')) {
98
+ // If the match is a folder and that folder contains a package.json file, then include it
99
+ const filePath = path.resolve(entry, 'package.json')
100
+ pkgFile = await fileExists(filePath) ? filePath : undefined
101
+ } else if (path.basename(entry) === 'package.json') {
102
+ // If the match is a package.json file, then include it
103
+ pkgFile = entry
104
+ } else if (SUPPORTED_LOCKFILES.includes(path.basename(entry))) {
105
+ // If the match is a lock file, include both it and the corresponding package.json file
106
+ lockFile = entry
107
+ pkgFile = path.resolve(path.dirname(entry), 'package.json')
108
+ }
109
+
110
+ // If we will include a package.json file but don't already have a corresponding lockfile, then look for one
111
+ if (!lockFile && pkgFile) {
112
+ const pkgDir = path.dirname(pkgFile)
113
+
114
+ for (const name of SUPPORTED_LOCKFILES) {
115
+ const lockFileAlternative = path.resolve(pkgDir, name)
116
+ if (await fileExists(lockFileAlternative)) {
117
+ lockFile = lockFileAlternative
118
+ break
119
+ }
120
+ }
121
+ }
122
+
123
+ if (pkgFile && lockFile) {
124
+ return [pkgFile, lockFile]
125
+ }
126
+
127
+ return pkgFile ? [pkgFile] : []
128
+ }
129
+
130
+ /**
131
+ * @param {string} filePath
132
+ * @returns {Promise<boolean>}
133
+ */
134
+ export async function fileExists (filePath) {
135
+ /** @type {import('node:fs').Stats} */
136
+ let pathStat
137
+
138
+ try {
139
+ pathStat = await stat(filePath)
140
+ } catch (err) {
141
+ if (isErrnoException(err) && err.code === 'ENOENT') {
142
+ return false
143
+ }
144
+ throw new ErrorWithCause('Error while checking if file exists', { cause: err })
145
+ }
146
+
147
+ if (!pathStat.isFile()) {
148
+ throw new InputError(`Expected '${filePath}' to be a file`)
149
+ }
150
+
151
+ return true
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for Socket.dev",
5
5
  "homepage": "http://github.com/SocketDev/socket-cli-js",
6
6
  "repository": {
@@ -32,7 +32,6 @@
32
32
  "check:tsc": "tsc",
33
33
  "check:type-coverage": "type-coverage --detail --strict --at-least 95 --ignore-files 'test/*'",
34
34
  "check": "run-p -c --aggregate-output check:*",
35
- "generate-types": "node lib/utils/generate-types.js > lib/types/api.d.ts",
36
35
  "prepare": "husky install",
37
36
  "test:mocha": "c8 --reporter=lcov --reporter text mocha 'test/**/*.spec.js'",
38
37
  "test-ci": "run-s test:*",
@@ -42,7 +41,9 @@
42
41
  "@socketsecurity/eslint-config": "^1.0.0",
43
42
  "@tsconfig/node14": "^1.0.3",
44
43
  "@types/chai": "^4.3.3",
44
+ "@types/chai-as-promised": "^7.1.5",
45
45
  "@types/mocha": "^10.0.0",
46
+ "@types/mock-fs": "^4.13.1",
46
47
  "@types/node": "^14.18.31",
47
48
  "@types/prompts": "^2.4.1",
48
49
  "@types/update-notifier": "^6.0.1",
@@ -50,6 +51,7 @@
50
51
  "@typescript-eslint/parser": "^5.44.0",
51
52
  "c8": "^7.12.0",
52
53
  "chai": "^4.3.6",
54
+ "chai-as-promised": "^7.1.1",
53
55
  "dependency-check": "^5.0.0-7",
54
56
  "eslint": "^8.28.0",
55
57
  "eslint-config-standard": "^17.0.0",
@@ -61,17 +63,25 @@
61
63
  "eslint-plugin-promise": "^6.1.1",
62
64
  "eslint-plugin-react": "^7.31.11",
63
65
  "eslint-plugin-react-hooks": "^4.6.0",
66
+ "eslint-plugin-unicorn": "^45.0.2",
64
67
  "husky": "^8.0.1",
65
68
  "installed-check": "^6.0.5",
66
69
  "mocha": "^10.0.0",
70
+ "mock-fs": "^5.2.0",
71
+ "nock": "^13.2.9",
67
72
  "npm-run-all2": "^6.0.2",
68
73
  "type-coverage": "^2.24.1",
69
74
  "typescript": "~4.9.3"
70
75
  },
71
76
  "dependencies": {
77
+ "@apideck/better-ajv-errors": "^0.3.6",
78
+ "@socketsecurity/config": "^1.2.0",
72
79
  "@socketsecurity/sdk": "^0.4.0",
73
80
  "chalk": "^5.1.2",
81
+ "globby": "^13.1.3",
74
82
  "hpagent": "^1.2.0",
83
+ "ignore": "^5.2.1",
84
+ "ignore-by-default": "^2.1.0",
75
85
  "is-interactive": "^2.0.0",
76
86
  "is-unicode-supported": "^1.3.0",
77
87
  "meow": "^11.0.0",