@socketsecurity/cli 0.5.4 → 0.7.1

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.
@@ -2,3 +2,5 @@ export * from './info/index.js'
2
2
  export * from './report/index.js'
3
3
  export * from './npm/index.js'
4
4
  export * from './npx/index.js'
5
+ export * from './login/index.js'
6
+ export * from './logout/index.js'
@@ -0,0 +1,66 @@
1
+ import isInteractive from 'is-interactive'
2
+ import meow from 'meow'
3
+ import ora from 'ora'
4
+ import prompts from 'prompts'
5
+
6
+ import { ChalkOrMarkdown } from '../../utils/chalk-markdown.js'
7
+ import { AuthError, InputError } from '../../utils/errors.js'
8
+ import { setupSdk } from '../../utils/sdk.js'
9
+ import { getSetting, updateSetting } from '../../utils/settings.js'
10
+
11
+ const description = 'Socket API login'
12
+
13
+ /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
14
+ export const login = {
15
+ description,
16
+ run: async (argv, importMeta, { parentName }) => {
17
+ const name = parentName + ' login'
18
+ const cli = meow(`
19
+ Usage
20
+ $ ${name}
21
+
22
+ Logs into the Socket API by prompting for an API key
23
+
24
+ Examples
25
+ $ ${name}
26
+ `, {
27
+ argv,
28
+ description,
29
+ importMeta,
30
+ })
31
+
32
+ if (cli.input.length) cli.showHelp()
33
+
34
+ if (!isInteractive()) {
35
+ throw new InputError('cannot prompt for credentials in a non-interactive shell')
36
+ }
37
+ const format = new ChalkOrMarkdown(false)
38
+ const { apiKey } = await prompts({
39
+ type: 'password',
40
+ name: 'apiKey',
41
+ message: `Enter your ${format.hyperlink(
42
+ 'Socket.dev API key',
43
+ 'https://docs.socket.dev/docs/api-keys'
44
+ )}`,
45
+ })
46
+
47
+ if (!apiKey) {
48
+ ora('API key not updated').warn()
49
+ return
50
+ }
51
+
52
+ const spinner = ora('Verifying API key...').start()
53
+
54
+ const oldKey = getSetting('apiKey')
55
+ updateSetting('apiKey', apiKey)
56
+ try {
57
+ const sdk = await setupSdk()
58
+ const quota = await sdk.getQuota()
59
+ if (!quota.success) throw new AuthError()
60
+ spinner.succeed(`API key ${oldKey ? 'updated' : 'set'}`)
61
+ } catch (e) {
62
+ updateSetting('apiKey', oldKey)
63
+ spinner.fail('Invalid API key')
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,32 @@
1
+ import meow from 'meow'
2
+ import ora from 'ora'
3
+
4
+ import { updateSetting } from '../../utils/settings.js'
5
+
6
+ const description = 'Socket API logout'
7
+
8
+ /** @type {import('../../utils/meow-with-subcommands').CliSubcommand} */
9
+ export const logout = {
10
+ description,
11
+ run: async (argv, importMeta, { parentName }) => {
12
+ const name = parentName + ' logout'
13
+ const cli = meow(`
14
+ Usage
15
+ $ ${name}
16
+
17
+ Logs out of the Socket API and clears all Socket credentials from disk
18
+
19
+ Examples
20
+ $ ${name}
21
+ `, {
22
+ argv,
23
+ description,
24
+ importMeta,
25
+ })
26
+
27
+ if (cli.input.length) cli.showHelp()
28
+
29
+ updateSetting('apiKey', null)
30
+ ora('Successfully logged out').succeed()
31
+ }
32
+ }
@@ -181,7 +181,17 @@ async function setupCommand (name, description, argv, importMeta) {
181
181
  }
182
182
  })
183
183
 
184
- const packagePaths = await getPackageFiles(cwd, cli.input, config, debugLog)
184
+ // TODO: setupSdk(getDefaultKey() || FREE_API_KEY)
185
+ const socketSdk = await setupSdk()
186
+ const supportedFiles = await socketSdk.getReportSupportedFiles()
187
+ .then(res => {
188
+ if (!res.success) handleUnsuccessfulApiResponse('getReportSupportedFiles', res, ora())
189
+ return res.data
190
+ }).catch(cause => {
191
+ throw new ErrorWithCause('Failed getting supported files for report', { cause })
192
+ })
193
+
194
+ const packagePaths = await getPackageFiles(cwd, cli.input, config, supportedFiles, debugLog)
185
195
 
186
196
  return {
187
197
  config,
@@ -1,5 +1,5 @@
1
- // THIS MUST BE CJS TO WORK WITH --require
2
1
  /* eslint-disable no-console */
2
+ // THIS MUST BE CJS TO WORK WITH --require
3
3
  'use strict'
4
4
 
5
5
  const fs = require('fs')
@@ -7,15 +7,59 @@ const path = require('path')
7
7
  const https = require('https')
8
8
  const events = require('events')
9
9
  const rl = require('readline')
10
+ const { PassThrough } = require('stream')
10
11
  const oraPromise = import('ora')
11
12
  const isInteractivePromise = import('is-interactive')
13
+ const chalkPromise = import('chalk')
12
14
  const chalkMarkdownPromise = import('../utils/chalk-markdown.js')
15
+ const settingsPromise = import('../utils/settings.js')
16
+ const ipc_version = require('../../package.json').version
17
+
18
+ try {
19
+ // due to update-notifier pkg being ESM only we actually spawn a subprocess sadly
20
+ require('child_process').spawnSync(process.execPath, [
21
+ path.join(__dirname, 'update-notifier.mjs')
22
+ ], {
23
+ stdio: 'inherit'
24
+ })
25
+ } catch (e) {
26
+ // ignore if update notification fails
27
+ }
28
+
29
+ /**
30
+ * @typedef {import('stream').Readable} Readable
31
+ */
32
+ /**
33
+ * @typedef {import('stream').Writable} Writable
34
+ */
13
35
 
14
- const pubToken = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
36
+ const pubTokenPromise = settingsPromise.then(({ getSetting }) =>
37
+ getSetting('apiKey') || 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
38
+ );
15
39
 
16
40
  // shadow `npm` and `npx` to mitigate subshells
17
41
  require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
18
42
 
43
+ /**
44
+ *
45
+ * @param {string} pkgid
46
+ * @returns {{name: string, version: string}}
47
+ */
48
+ const pkgidParts = (pkgid) => {
49
+ const delimiter = pkgid.lastIndexOf('@')
50
+ const name = pkgid.slice(0, delimiter)
51
+ const version = pkgid.slice(delimiter + 1)
52
+ return { name, version }
53
+ }
54
+
55
+ /**
56
+ * @typedef PURLParts
57
+ * @property {'npm'} type
58
+ * @property {string} namespace_and_name
59
+ * @property {string} version
60
+ * @property {URL['href']} repository_url
61
+ */
62
+
19
63
  /**
20
64
  * @param {string[]} pkgids
21
65
  * @returns {AsyncGenerator<{eco: string, pkg: string, ver: string } & ({type: 'missing'} | {type: 'success', value: { issues: any[] }})>}
@@ -23,11 +67,10 @@ require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
23
67
  async function * batchScan (
24
68
  pkgids
25
69
  ) {
70
+ const pubToken = await pubTokenPromise
26
71
  const query = {
27
72
  packages: pkgids.map(pkgid => {
28
- const delimiter = pkgid.lastIndexOf('@')
29
- const name = pkgid.slice(0, delimiter)
30
- const version = pkgid.slice(delimiter + 1)
73
+ const { name, version } = pkgidParts(pkgid)
31
74
  return {
32
75
  eco: 'npm', pkg: name, ver: version, top: true
33
76
  }
@@ -65,6 +108,10 @@ let translations = null
65
108
  */
66
109
  let formatter = null
67
110
 
111
+ const ttyServerPromise = chalkPromise.then(chalk => {
112
+ return createTTYServer(chalk.default.level)
113
+ })
114
+
68
115
  const npmEntrypoint = fs.realpathSync(`${process.argv[1]}`)
69
116
  /**
70
117
  * @param {string} filepath
@@ -82,6 +129,8 @@ function findRoot (filepath) {
82
129
  }
83
130
  const npmDir = findRoot(path.dirname(npmEntrypoint))
84
131
  const arboristLibClassPath = path.join(npmDir, 'node_modules', '@npmcli', 'arborist', 'lib', 'arborist', 'index.js')
132
+ const npmlog = require(path.join(npmDir, 'node_modules', 'npmlog', 'lib', 'log.js'))
133
+
85
134
  /**
86
135
  * @type {typeof import('@npmcli/arborist')}
87
136
  */
@@ -96,11 +145,11 @@ class SafeArborist extends Arborist {
96
145
  constructor (...ctorArgs) {
97
146
  const mutedArguments = [{
98
147
  ...(ctorArgs[0] ?? {}),
148
+ audit: true,
99
149
  dryRun: true,
100
150
  ignoreScripts: true,
101
151
  save: false,
102
152
  saveBundle: false,
103
- audit: false,
104
153
  // progress: false,
105
154
  fund: false
106
155
  }, ctorArgs.slice(1)]
@@ -140,39 +189,72 @@ class SafeArborist extends Arborist {
140
189
  const diff = gatherDiff(this)
141
190
  // @ts-expect-error types are wrong
142
191
  args[0].dryRun = old.dryRun
143
- // @ts-expect-error types are wrong
144
192
  args[0].save = old.save
145
- // @ts-expect-error types are wrong
146
193
  args[0].saveBundle = old.saveBundle
147
- // nothing to check, mmm already installed?
148
- if (diff.check.length === 0 && diff.unknowns.length === 0) {
194
+ // nothing to check, mmm already installed or all private?
195
+ if (diff.findIndex(c => c.newPackage.repository_url === 'https://registry.npmjs.org') === -1) {
149
196
  return this[kRiskyReify](...args)
150
197
  }
151
- const isInteractive = (await isInteractivePromise).default()
152
- if (isInteractive) {
153
- const ora = (await oraPromise).default
154
- const risky = await packagesHaveRiskyIssues(diff.check, ora)
155
- if (risky) {
156
- const rl = require('readline')
157
- const rli = rl.createInterface(process.stdin, process.stderr)
158
- while (true) {
159
- /**
160
- * @type {string}
161
- */
162
- const answer = await new Promise((resolve) => {
163
- rli.question('Accept risks of installing these packages (y/N)? ', (str) => resolve(str))
198
+ const ttyServer = await ttyServerPromise
199
+ const proceed = await ttyServer.captureTTY(async (input, output, colorLevel) => {
200
+ if (input) {
201
+ const chalkNS = await chalkPromise
202
+ chalkNS.default.level = colorLevel
203
+ const oraNS = await oraPromise
204
+ const ora = () => {
205
+ return oraNS.default({
206
+ stream: output,
207
+ color: 'cyan',
208
+ isEnabled: true,
209
+ isSilent: false,
210
+ hideCursor: true,
211
+ discardStdin: true,
212
+ spinner: oraNS.spinners.dots,
164
213
  })
165
- if (/^\s*y(es)?\s*$/i.test(answer)) {
166
- break
167
- } else if (/^(\s*no?\s*|)$/i.test(answer)) {
168
- throw new Error('Socket npm exiting due to risks')
214
+ }
215
+ const risky = await packagesHaveRiskyIssues(this.registry, diff, ora, input, output)
216
+ if (!risky) {
217
+ return true
218
+ }
219
+ const rl = require('readline')
220
+ const rlin = new PassThrough()
221
+ input.pipe(rlin, {
222
+ end: true
223
+ })
224
+ const rlout = new PassThrough()
225
+ rlout.pipe(output, {
226
+ end: false
227
+ })
228
+ const rli = rl.createInterface(rlin, rlout)
229
+ try {
230
+ while (true) {
231
+ /**
232
+ * @type {string}
233
+ */
234
+ const answer = await new Promise((resolve) => {
235
+ rli.question('Accept risks of installing these packages (y/N)? ', (str) => resolve(str))
236
+ })
237
+ if (/^\s*y(es)?\s*$/i.test(answer)) {
238
+ return true
239
+ } else if (/^(\s*no?\s*|)$/i.test(answer)) {
240
+ return false
241
+ }
169
242
  }
243
+ } finally {
244
+ rli.close()
245
+ }
246
+ } else {
247
+ if (await packagesHaveRiskyIssues(this.registry, diff, null, null, output)) {
248
+ throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
170
249
  }
250
+ return true
171
251
  }
252
+ return false
253
+ })
254
+ if (proceed) {
172
255
  return this[kRiskyReify](...args)
173
256
  } else {
174
- await packagesHaveRiskyIssues(diff.check)
175
- throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
257
+ throw new Error('Socket npm exiting due to risks')
176
258
  }
177
259
  }
178
260
  }
@@ -180,34 +262,18 @@ class SafeArborist extends Arborist {
180
262
  require.cache[arboristLibClassPath].exports = SafeArborist
181
263
 
182
264
  /**
183
- * @param {InstanceType<typeof Arborist>} arb
184
- * @returns {{
265
+ * @typedef {{
185
266
  * check: InstallEffect[],
186
267
  * unknowns: InstallEffect[]
187
- * }}
268
+ * }} InstallDiff
269
+ */
270
+
271
+ /**
272
+ * @param {InstanceType<typeof Arborist>} arb
273
+ * @returns {InstallEffect[]}
188
274
  */
189
275
  function gatherDiff (arb) {
190
- // TODO: make this support private registry complexities
191
- const registry = arb.registry
192
- /**
193
- * @type {InstallEffect[]}
194
- */
195
- const unknowns = []
196
- /**
197
- * @type {InstallEffect[]}
198
- */
199
- const check = []
200
- for (const node of walk(arb.diff)) {
201
- if (node.resolved?.startsWith(registry)) {
202
- check.push(node)
203
- } else {
204
- unknowns.push(node)
205
- }
206
- }
207
- return {
208
- check,
209
- unknowns
210
- }
276
+ return walk(arb.diff)
211
277
  }
212
278
  /**
213
279
  * @typedef InstallEffect
@@ -216,6 +282,8 @@ function gatherDiff (arb) {
216
282
  * @property {import('@npmcli/arborist').Node['pkgid']} pkgid
217
283
  * @property {import('@npmcli/arborist').Node['resolved']} resolved
218
284
  * @property {import('@npmcli/arborist').Node['location']} location
285
+ * @property {PURLParts | null} oldPackage
286
+ * @property {PURLParts} newPackage
219
287
  */
220
288
  /**
221
289
  * @param {import('@npmcli/arborist').Diff | null} diff
@@ -243,13 +311,36 @@ function walk (diff, needInfoOn = []) {
243
311
  }
244
312
  if (keep) {
245
313
  if (diff.ideal?.pkgid) {
246
- needInfoOn.push({
247
- existing,
248
- action: diff.action,
249
- location: diff.ideal.location,
250
- pkgid: diff.ideal.pkgid,
251
- resolved: diff.ideal.resolved
252
- })
314
+ /**
315
+ *
316
+ * @param {string} pkgid - `pkg@ver`
317
+ * @param {string} resolved - tarball link, should match `/name/-/name-ver.tgz` as tail, used to obtain repository_url
318
+ * @returns {PURLParts}
319
+ */
320
+ function toPURL (pkgid, resolved) {
321
+ const repo = resolved
322
+ .replace(/#[\s\S]*$/u, '')
323
+ .replace(/\?[\s\S]*$/u, '')
324
+ .replace(/\/[^/]*\/-\/[\s\S]*$/u, '')
325
+ const { name, version } = pkgidParts(pkgid)
326
+ return {
327
+ type: 'npm',
328
+ namespace_and_name: name,
329
+ version,
330
+ repository_url: repo
331
+ }
332
+ }
333
+ if (diff.ideal.resolved && (!diff.actual || diff.actual.resolved)) {
334
+ needInfoOn.push({
335
+ existing,
336
+ action: diff.action,
337
+ location: diff.ideal.location,
338
+ pkgid: diff.ideal.pkgid,
339
+ newPackage: toPURL(diff.ideal.pkgid, diff.ideal.resolved),
340
+ oldPackage: diff.actual && diff.actual.resolved ? toPURL(diff.actual.pkgid, diff.actual.resolved) : null,
341
+ resolved: diff.ideal.resolved,
342
+ })
343
+ }
253
344
  }
254
345
  }
255
346
  }
@@ -262,11 +353,14 @@ function walk (diff, needInfoOn = []) {
262
353
  }
263
354
 
264
355
  /**
356
+ * @param {string} registry
265
357
  * @param {InstallEffect[]} pkgs
266
358
  * @param {import('ora')['default'] | null} ora
359
+ * @param {Readable | null} input
360
+ * @param {Writable} ora
267
361
  * @returns {Promise<boolean>}
268
362
  */
269
- async function packagesHaveRiskyIssues (pkgs, ora = null) {
363
+ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, output) {
270
364
  let failed = false
271
365
  if (pkgs.length) {
272
366
  let remaining = pkgs.length
@@ -277,7 +371,7 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
277
371
  function getText () {
278
372
  return `Looking up data for ${remaining} packages`
279
373
  }
280
- const spinner = ora ? ora(getText()).start() : null
374
+ const spinner = ora ? ora().start(getText()) : null
281
375
  const pkgDatas = []
282
376
  try {
283
377
  for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
@@ -326,7 +420,7 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
326
420
  formatter ??= new ((await chalkMarkdownPromise).ChalkOrMarkdown)(false)
327
421
  const name = pkgData.pkg
328
422
  const version = pkgData.ver
329
- console.error(`${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:`)
423
+ output.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
330
424
  if (translations) {
331
425
  for (const failure of failures) {
332
426
  const type = failure.type
@@ -335,8 +429,8 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
335
429
  const issueTypeTranslation = translations.issues[type]
336
430
  // TODO: emoji seems to misalign terminals sometimes
337
431
  // @ts-ignore
338
- const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}`
339
- console.error(msg)
432
+ const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}\n`
433
+ output.write(msg)
340
434
  }
341
435
  }
342
436
  }
@@ -349,8 +443,6 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
349
443
  if (spinner) {
350
444
  spinner.text = getText()
351
445
  }
352
- } else {
353
- spinner?.stop()
354
446
  }
355
447
  pkgDatas.push(pkgData)
356
448
  }
@@ -367,3 +459,195 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
367
459
  return false
368
460
  }
369
461
  }
462
+
463
+ /**
464
+ * @param {import('chalk')['default']['level']} colorLevel
465
+ * @returns {Promise<{ captureTTY<RET extends any>(mutexFn: (input: Readable | null, output: Writable, colorLevel: import('chalk')['default']['level']) => Promise<RET>): Promise<RET> }>}
466
+ */
467
+ async function createTTYServer (colorLevel) {
468
+ const TTY_IPC = process.env.SOCKET_SECURITY_TTY_IPC
469
+ const net = require('net')
470
+ /**
471
+ * @type {import('readline')}
472
+ */
473
+ let readline
474
+ const isSTDINInteractive = (await isInteractivePromise).default({
475
+ stream: process.stdin
476
+ })
477
+ if (!isSTDINInteractive && TTY_IPC) {
478
+ return {
479
+ async captureTTY (mutexFn) {
480
+ return new Promise((resolve, reject) => {
481
+ const conn = net.createConnection({
482
+ path: TTY_IPC
483
+ }).on('error', reject)
484
+ let captured = false
485
+ const bufs = []
486
+ conn.on('data', function awaitCapture (chunk) {
487
+ bufs.push(chunk)
488
+ const lineBuff = Buffer.concat(bufs)
489
+ try {
490
+ if (!captured) {
491
+ const EOL = lineBuff.indexOf('\n'.charCodeAt(0))
492
+ if (EOL !== -1) {
493
+ conn.removeListener('data', awaitCapture)
494
+ conn.push(lineBuff.slice(EOL + 1))
495
+ lineBuff = null
496
+ captured = true
497
+ const {
498
+ ipc_version: remote_ipc_version,
499
+ capabilities: { input: hasInput, output: hasOutput, colorLevel: ipcColorLevel }
500
+ } = JSON.parse(lineBuff.slice(0, EOL).toString('utf-8'))
501
+ if (remote_ipc_version !== ipc_version) {
502
+ throw new Error('Mismatched STDIO tunnel IPC version, ensure you only have 1 version of socket CLI being called.')
503
+ }
504
+ const input = hasInput ? new PassThrough() : null
505
+ input.pause()
506
+ conn.pipe(input)
507
+ const output = hasOutput ? new PassThrough() : null
508
+ output.pipe(conn)
509
+ // make ora happy
510
+ // @ts-ignore
511
+ output.isTTY = true
512
+ // @ts-ignore
513
+ output.cursorTo = function cursorTo (x, y, callback) {
514
+ readline = readline || require('readline')
515
+ readline.cursorTo(this, x, y, callback)
516
+ }
517
+ // @ts-ignore
518
+ output.clearLine = function clearLine (dir, callback) {
519
+ readline = readline || require('readline')
520
+ readline.clearLine(this, dir, callback)
521
+ }
522
+ mutexFn(hasInput ? input : null, hasOutput ? output : null, ipcColorLevel)
523
+ .then(resolve, reject)
524
+ .finally(() => {
525
+ conn.unref()
526
+ conn.end()
527
+ input.end()
528
+ output.end()
529
+ // process.exit(13)
530
+ })
531
+ }
532
+ }
533
+ } catch (e) {
534
+ reject(e)
535
+ }
536
+ })
537
+ })
538
+ }
539
+ }
540
+ }
541
+ const pendingCaptures = []
542
+ let captured = false
543
+ const sock = path.join(require('os').tmpdir(), `socket-security-tty-${process.pid}.sock`)
544
+ process.env.SOCKET_SECURITY_TTY_IPC = sock
545
+ try {
546
+ await require('fs/promises').unlink(sock)
547
+ } catch (e) {
548
+ if (e.code !== 'ENOENT') {
549
+ throw e
550
+ }
551
+ }
552
+ process.on('exit', () => {
553
+ ttyServer.close()
554
+ try {
555
+ require('fs').unlinkSync(sock)
556
+ } catch (e) {
557
+ if (e.code !== 'ENOENT') {
558
+ throw e
559
+ }
560
+ }
561
+ })
562
+ const input = isSTDINInteractive ? process.stdin : null
563
+ const output = process.stderr
564
+ const ttyServer = await new Promise((resolve, reject) => {
565
+ const server = net.createServer(async (conn) => {
566
+ if (captured) {
567
+ const captured = new Promise((resolve) => {
568
+ pendingCaptures.push({
569
+ resolve
570
+ })
571
+ })
572
+ await captured
573
+ } else {
574
+ captured = true
575
+ }
576
+ const wasProgressEnabled = npmlog.progressEnabled
577
+ npmlog.pause()
578
+ if (wasProgressEnabled) {
579
+ npmlog.disableProgress()
580
+ }
581
+ conn.write(`${JSON.stringify({
582
+ ipc_version,
583
+ capabilities: {
584
+ input: Boolean(input),
585
+ output: true,
586
+ colorLevel
587
+ }
588
+ })}\n`)
589
+ conn.on('data', (data) => {
590
+ output.write(data)
591
+ })
592
+ conn.on('error', (e) => {
593
+ output.write(`there was an error prompting from a subshell (${e.message}), socket npm closing`)
594
+ process.exit(1)
595
+ })
596
+ input.on('data', (data) => {
597
+ conn.write(data)
598
+ })
599
+ input.on('end', () => {
600
+ conn.unref()
601
+ conn.end()
602
+ if (wasProgressEnabled) {
603
+ npmlog.enableProgress()
604
+ }
605
+ npmlog.resume()
606
+ nextCapture()
607
+ })
608
+ }).listen(sock, (err) => {
609
+ if (err) reject(err)
610
+ else resolve(server)
611
+ }).unref()
612
+ })
613
+ /**
614
+ *
615
+ */
616
+ function nextCapture () {
617
+ if (pendingCaptures.length > 0) {
618
+ const nextCapture = pendingCaptures.shift()
619
+ nextCapture.resolve()
620
+ } else {
621
+ captured = false
622
+ }
623
+ }
624
+ return {
625
+ async captureTTY (mutexFn) {
626
+ if (captured) {
627
+ const captured = new Promise((resolve) => {
628
+ pendingCaptures.push({
629
+ resolve
630
+ })
631
+ })
632
+ await captured
633
+ } else {
634
+ captured = true
635
+ }
636
+ const wasProgressEnabled = npmlog.progressEnabled
637
+ try {
638
+ npmlog.pause()
639
+ if (wasProgressEnabled) {
640
+ npmlog.disableProgress()
641
+ }
642
+ // need await here for proper finally timing
643
+ return await mutexFn(input, output, colorLevel)
644
+ } finally {
645
+ if (wasProgressEnabled) {
646
+ npmlog.enableProgress()
647
+ }
648
+ npmlog.resume()
649
+ nextCapture()
650
+ }
651
+ }
652
+ }
653
+ }
@@ -0,0 +1,3 @@
1
+ // ESM entrypoint doesn't work w/ --require, this needs to be done w/ a spawnSync sadly
2
+ import { initUpdateNotifier } from '../utils/update-notifier.js'
3
+ initUpdateNotifier()
@@ -8,7 +8,7 @@ import { AuthError } from './errors.js'
8
8
  * @param {T} _name
9
9
  * @param {import('@socketsecurity/sdk').SocketSdkErrorType<T>} result
10
10
  * @param {import('ora').Ora} spinner
11
- * @returns {void}
11
+ * @returns {never}
12
12
  */
13
13
  export function handleUnsuccessfulApiResponse (_name, result, spinner) {
14
14
  const resultError = 'error' in result && result.error && typeof result.error === 'object' ? result.error : {}
@@ -5,17 +5,12 @@ import { globby } from 'globby'
5
5
  import ignore from 'ignore'
6
6
  // @ts-ignore This package provides no types
7
7
  import { directories } from 'ignore-by-default'
8
+ import micromatch from 'micromatch'
8
9
  import { ErrorWithCause } from 'pony-cause'
9
10
 
10
11
  import { InputError } from './errors.js'
11
12
  import { isErrnoException } from './type-helpers.js'
12
13
 
13
- /** @type {readonly string[]} */
14
- const SUPPORTED_LOCKFILES = [
15
- 'package-lock.json',
16
- 'yarn.lock',
17
- ]
18
-
19
14
  /**
20
15
  * There are a lot of possible folders that we should not be looking in and "ignore-by-default" helps us with defining those
21
16
  *
@@ -23,10 +18,17 @@ const SUPPORTED_LOCKFILES = [
23
18
  */
24
19
  const ignoreByDefault = directories()
25
20
 
26
- /** @type {readonly string[]} */
27
- const GLOB_IGNORE = [
28
- ...ignoreByDefault.map(item => '**/' + item)
29
- ]
21
+ /** @type {import('globby').Options} */
22
+ const BASE_GLOBBY_OPTS = {
23
+ absolute: true,
24
+ expandDirectories: false,
25
+ gitignore: true,
26
+ ignore: [
27
+ ...ignoreByDefault.map(item => '**/' + item)
28
+ ],
29
+ markDirectories: true,
30
+ unique: true,
31
+ }
30
32
 
31
33
  /**
32
34
  * Resolves package.json and lockfiles from (globbed) input paths, applying relevant ignores
@@ -34,28 +36,24 @@ const GLOB_IGNORE = [
34
36
  * @param {string} cwd The working directory to use when resolving paths
35
37
  * @param {string[]} inputPaths A list of paths to folders, package.json files and/or recognized lockfiles. Supports globs.
36
38
  * @param {import('@socketsecurity/config').SocketYml|undefined} config
39
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<"getReportSupportedFiles">['data']} supportedFiles
37
40
  * @param {typeof console.error} debugLog
38
41
  * @returns {Promise<string[]>}
39
42
  * @throws {InputError}
40
43
  */
41
- export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
44
+ export async function getPackageFiles (cwd, inputPaths, config, supportedFiles, debugLog) {
42
45
  debugLog(`Globbed resolving ${inputPaths.length} paths:`, inputPaths)
43
46
 
44
47
  // TODO: Does not support `~/` paths
45
48
  const entries = await globby(inputPaths, {
46
- absolute: true,
49
+ ...BASE_GLOBBY_OPTS,
47
50
  cwd,
48
- expandDirectories: false,
49
- gitignore: true,
50
- ignore: [...GLOB_IGNORE],
51
- markDirectories: true,
52
- onlyFiles: false,
53
- unique: true,
51
+ onlyFiles: false
54
52
  })
55
53
 
56
54
  debugLog(`Globbed resolved ${inputPaths.length} paths to ${entries.length} paths:`, entries)
57
55
 
58
- const packageFiles = await mapGlobResultToFiles(entries)
56
+ const packageFiles = await mapGlobResultToFiles(entries, supportedFiles)
59
57
 
60
58
  debugLog(`Mapped ${entries.length} entries to ${packageFiles.length} files:`, packageFiles)
61
59
 
@@ -73,11 +71,14 @@ export async function getPackageFiles (cwd, inputPaths, config, debugLog) {
73
71
  * Takes paths to folders, package.json and/or recognized lock files and resolves them to package.json + lockfile pairs (where possible)
74
72
  *
75
73
  * @param {string[]} entries
74
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<"getReportSupportedFiles">['data']} supportedFiles
76
75
  * @returns {Promise<string[]>}
77
76
  * @throws {InputError}
78
77
  */
79
- export async function mapGlobResultToFiles (entries) {
80
- const packageFiles = await Promise.all(entries.map(mapGlobEntryToFiles))
78
+ export async function mapGlobResultToFiles (entries, supportedFiles) {
79
+ const packageFiles = await Promise.all(
80
+ entries.map(entry => mapGlobEntryToFiles(entry, supportedFiles))
81
+ )
81
82
 
82
83
  const uniquePackageFiles = [...new Set(packageFiles.flat())]
83
84
 
@@ -88,46 +89,58 @@ export async function mapGlobResultToFiles (entries) {
88
89
  * Takes a single path to a folder, package.json or a recognized lock file and resolves to a package.json + lockfile pair (where possible)
89
90
  *
90
91
  * @param {string} entry
92
+ * @param {import('@socketsecurity/sdk').SocketSdkReturnType<'getReportSupportedFiles'>['data']} supportedFiles
91
93
  * @returns {Promise<string[]>}
92
94
  * @throws {InputError}
93
95
  */
94
- export async function mapGlobEntryToFiles (entry) {
96
+ export async function mapGlobEntryToFiles (entry, supportedFiles) {
95
97
  /** @type {string|undefined} */
96
- let pkgFile
97
- /** @type {string|undefined} */
98
- let lockFile
99
-
98
+ let pkgJSFile
99
+ /** @type {string[]} */
100
+ let jsLockFiles = []
101
+ /** @type {string[]} */
102
+ let pyFiles = []
103
+
104
+ const jsSupported = supportedFiles['npm'] || {}
105
+ const jsLockFilePatterns = Object.keys(jsSupported)
106
+ .filter(key => key !== 'packagejson')
107
+ .map(key => /** @type {{ pattern: string }} */ (jsSupported[key]).pattern)
108
+
109
+ const pyFilePatterns = Object.values(supportedFiles['pypi'] || {}).map(p => p.pattern)
100
110
  if (entry.endsWith('/')) {
101
111
  // If the match is a folder and that folder contains a package.json file, then include it
102
112
  const filePath = path.resolve(entry, 'package.json')
103
- pkgFile = await fileExists(filePath) ? filePath : undefined
104
- } else if (path.basename(entry) === 'package.json') {
105
- // If the match is a package.json file, then include it
106
- pkgFile = entry
107
- } else if (SUPPORTED_LOCKFILES.includes(path.basename(entry))) {
108
- // If the match is a lock file, include both it and the corresponding package.json file
109
- lockFile = entry
110
- pkgFile = path.resolve(path.dirname(entry), 'package.json')
113
+ if (await fileExists(filePath)) pkgJSFile = filePath
114
+ pyFiles = await globby(pyFilePatterns, {
115
+ ...BASE_GLOBBY_OPTS,
116
+ cwd: entry
117
+ })
118
+ } else {
119
+ const entryFile = path.basename(entry)
120
+
121
+ if (entryFile === 'package.json') {
122
+ // If the match is a package.json file, then include it
123
+ pkgJSFile = entry
124
+ } else if (micromatch.isMatch(entryFile, jsLockFilePatterns)) {
125
+ jsLockFiles = [entry]
126
+ pkgJSFile = path.resolve(path.dirname(entry), 'package.json')
127
+ if (!(await fileExists(pkgJSFile))) return []
128
+ } else if (micromatch.isMatch(entryFile, pyFilePatterns)) {
129
+ pyFiles = [entry]
130
+ }
111
131
  }
112
132
 
113
133
  // If we will include a package.json file but don't already have a corresponding lockfile, then look for one
114
- if (!lockFile && pkgFile) {
115
- const pkgDir = path.dirname(pkgFile)
116
-
117
- for (const name of SUPPORTED_LOCKFILES) {
118
- const lockFileAlternative = path.resolve(pkgDir, name)
119
- if (await fileExists(lockFileAlternative)) {
120
- lockFile = lockFileAlternative
121
- break
122
- }
123
- }
124
- }
134
+ if (!jsLockFiles.length && pkgJSFile) {
135
+ const pkgDir = path.dirname(pkgJSFile)
125
136
 
126
- if (pkgFile && lockFile) {
127
- return [pkgFile, lockFile]
137
+ jsLockFiles = await globby(jsLockFilePatterns, {
138
+ ...BASE_GLOBBY_OPTS,
139
+ cwd: pkgDir
140
+ })
128
141
  }
129
142
 
130
- return pkgFile ? [pkgFile] : []
143
+ return [...jsLockFiles, ...pyFiles].concat(pkgJSFile ? [pkgJSFile] : [])
131
144
  }
132
145
 
133
146
  /**
package/lib/utils/sdk.js CHANGED
@@ -7,28 +7,27 @@ import isInteractive from 'is-interactive'
7
7
  import prompts from 'prompts'
8
8
 
9
9
  import { AuthError } from './errors.js'
10
+ import { getSetting } from './settings.js'
10
11
 
11
12
  /**
12
- * The API key should be stored globally for the duration of the CLI execution
13
+ * This API key should be stored globally for the duration of the CLI execution
13
14
  *
14
15
  * @type {string | undefined}
15
16
  */
16
- let apiKey
17
+ let sessionAPIKey
17
18
 
18
19
  /** @returns {Promise<import('@socketsecurity/sdk').SocketSdk>} */
19
20
  export async function setupSdk () {
20
- if (!apiKey) {
21
- apiKey = process.env['SOCKET_SECURITY_API_KEY']
22
- }
21
+ let apiKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || sessionAPIKey
23
22
 
24
23
  if (!apiKey && isInteractive()) {
25
24
  const input = await prompts({
26
25
  type: 'password',
27
26
  name: 'apiKey',
28
- message: 'Enter your Socket.dev API key',
27
+ message: 'Enter your Socket.dev API key (not saved)',
29
28
  })
30
29
 
31
- apiKey = input.apiKey
30
+ apiKey = sessionAPIKey = input.apiKey
32
31
  }
33
32
 
34
33
  if (!apiKey) {
@@ -0,0 +1,57 @@
1
+ import * as fs from 'fs'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+
5
+ import ora from 'ora'
6
+
7
+ let dataHome = process.platform === 'win32'
8
+ ? process.env['LOCALAPPDATA']
9
+ : process.env['XDG_DATA_HOME']
10
+
11
+ if (!dataHome) {
12
+ if (process.platform === 'win32') throw new Error('missing %LOCALAPPDATA%')
13
+ const home = os.homedir()
14
+ dataHome = path.join(home, ...(process.platform === 'darwin'
15
+ ? ['Library', 'Application Support']
16
+ : ['.local', 'share']
17
+ ))
18
+ }
19
+
20
+ const settingsPath = path.join(dataHome, 'socket', 'settings')
21
+
22
+ /** @type {{apiKey?: string | null}} */
23
+ let settings = {}
24
+
25
+ if (fs.existsSync(settingsPath)) {
26
+ const raw = fs.readFileSync(settingsPath, 'utf-8')
27
+ try {
28
+ settings = JSON.parse(Buffer.from(raw, 'base64').toString())
29
+ } catch (e) {
30
+ ora(`Failed to parse settings at ${settingsPath}`).warn()
31
+ }
32
+ } else {
33
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
34
+ }
35
+
36
+ /**
37
+ * @template {keyof typeof settings} Key
38
+ * @param {Key} key
39
+ * @returns {typeof settings[Key]}
40
+ */
41
+ export function getSetting (key) {
42
+ return settings[key]
43
+ }
44
+
45
+ /**
46
+ * @template {keyof typeof settings} Key
47
+ * @param {Key} key
48
+ * @param {typeof settings[Key]} value
49
+ * @returns {void}
50
+ */
51
+ export function updateSetting (key, value) {
52
+ settings[key] = value
53
+ fs.writeFileSync(
54
+ settingsPath,
55
+ Buffer.from(JSON.stringify(settings)).toString('base64')
56
+ )
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/cli",
3
- "version": "0.5.4",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tool for Socket.dev",
5
5
  "homepage": "http://github.com/SocketDev/socket-cli-js",
6
6
  "repository": {
@@ -43,11 +43,12 @@
43
43
  "test": "run-s check test:*"
44
44
  },
45
45
  "devDependencies": {
46
- "@socketsecurity/eslint-config": "^2.0.0",
46
+ "@socketsecurity/eslint-config": "^3.0.1",
47
47
  "@tsconfig/node14": "^1.0.3",
48
48
  "@types/chai": "^4.3.3",
49
49
  "@types/chai-as-promised": "^7.1.5",
50
- "@types/mocha": "^10.0.0",
50
+ "@types/micromatch": "^4.0.2",
51
+ "@types/mocha": "^10.0.1",
51
52
  "@types/mock-fs": "^4.13.1",
52
53
  "@types/node": "^14.18.31",
53
54
  "@types/npm": "^7.19.0",
@@ -84,7 +85,7 @@
84
85
  "dependencies": {
85
86
  "@apideck/better-ajv-errors": "^0.3.6",
86
87
  "@socketsecurity/config": "^2.0.0",
87
- "@socketsecurity/sdk": "^0.5.4",
88
+ "@socketsecurity/sdk": "^0.6.0",
88
89
  "chalk": "^5.1.2",
89
90
  "globby": "^13.1.3",
90
91
  "hpagent": "^1.2.0",
@@ -93,6 +94,7 @@
93
94
  "is-interactive": "^2.0.0",
94
95
  "is-unicode-supported": "^1.3.0",
95
96
  "meow": "^11.0.0",
97
+ "micromatch": "^4.0.5",
96
98
  "ora": "^6.1.2",
97
99
  "pony-cause": "^2.1.8",
98
100
  "prompts": "^2.4.2",
@@ -1,3 +0,0 @@
1
- declare module 'hyperlinker' {
2
- export = (msg: string, href: URL['href']) => string
3
- }