@socketsecurity/cli 0.5.3 → 0.6.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.
@@ -4,6 +4,11 @@ const path = require('path')
4
4
 
5
5
  const which = require('which')
6
6
 
7
+ if (process.platform === 'win32') {
8
+ console.error('Socket npm and socket npx wrapper Windows suppport is limited to WSL at this time.')
9
+ process.exit(1)
10
+ }
11
+
7
12
  /**
8
13
  * @param {string} realDirname path to shadow/bin
9
14
  * @param {'npm' | 'npx'} binname
@@ -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,56 @@ 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 ipc_version = require('../../package.json').version
16
+
17
+ try {
18
+ // due to update-notifier pkg being ESM only we actually spawn a subprocess sadly
19
+ require('child_process').spawnSync(process.execPath, [
20
+ path.join(__dirname, 'update-notifier.mjs')
21
+ ], {
22
+ stdio: 'inherit'
23
+ })
24
+ } catch (e) {
25
+ // ignore if update notification fails
26
+ }
27
+
28
+ /**
29
+ * @typedef {import('stream').Readable} Readable
30
+ */
31
+ /**
32
+ * @typedef {import('stream').Writable} Writable
33
+ */
13
34
 
14
35
  const pubToken = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
15
36
 
16
37
  // shadow `npm` and `npx` to mitigate subshells
17
38
  require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
18
39
 
40
+ /**
41
+ *
42
+ * @param {string} pkgid
43
+ * @returns {{name: string, version: string}}
44
+ */
45
+ const pkgidParts = (pkgid) => {
46
+ const delimiter = pkgid.lastIndexOf('@')
47
+ const name = pkgid.slice(0, delimiter)
48
+ const version = pkgid.slice(delimiter + 1)
49
+ return { name, version }
50
+ }
51
+
52
+ /**
53
+ * @typedef PURLParts
54
+ * @property {'npm'} type
55
+ * @property {string} namespace_and_name
56
+ * @property {string} version
57
+ * @property {URL['href']} repository_url
58
+ */
59
+
19
60
  /**
20
61
  * @param {string[]} pkgids
21
62
  * @returns {AsyncGenerator<{eco: string, pkg: string, ver: string } & ({type: 'missing'} | {type: 'success', value: { issues: any[] }})>}
@@ -25,9 +66,7 @@ async function * batchScan (
25
66
  ) {
26
67
  const query = {
27
68
  packages: pkgids.map(pkgid => {
28
- const delimiter = pkgid.lastIndexOf('@')
29
- const name = pkgid.slice(0, delimiter)
30
- const version = pkgid.slice(delimiter + 1)
69
+ const { name, version } = pkgidParts(pkgid)
31
70
  return {
32
71
  eco: 'npm', pkg: name, ver: version, top: true
33
72
  }
@@ -65,6 +104,10 @@ let translations = null
65
104
  */
66
105
  let formatter = null
67
106
 
107
+ const ttyServerPromise = chalkPromise.then(chalk => {
108
+ return createTTYServer(chalk.default.level)
109
+ })
110
+
68
111
  const npmEntrypoint = fs.realpathSync(`${process.argv[1]}`)
69
112
  /**
70
113
  * @param {string} filepath
@@ -82,6 +125,8 @@ function findRoot (filepath) {
82
125
  }
83
126
  const npmDir = findRoot(path.dirname(npmEntrypoint))
84
127
  const arboristLibClassPath = path.join(npmDir, 'node_modules', '@npmcli', 'arborist', 'lib', 'arborist', 'index.js')
128
+ const npmlog = require(path.join(npmDir, 'node_modules', 'npmlog', 'lib', 'log.js'))
129
+
85
130
  /**
86
131
  * @type {typeof import('@npmcli/arborist')}
87
132
  */
@@ -96,11 +141,11 @@ class SafeArborist extends Arborist {
96
141
  constructor (...ctorArgs) {
97
142
  const mutedArguments = [{
98
143
  ...(ctorArgs[0] ?? {}),
144
+ audit: true,
99
145
  dryRun: true,
100
146
  ignoreScripts: true,
101
147
  save: false,
102
148
  saveBundle: false,
103
- audit: false,
104
149
  // progress: false,
105
150
  fund: false
106
151
  }, ctorArgs.slice(1)]
@@ -140,39 +185,72 @@ class SafeArborist extends Arborist {
140
185
  const diff = gatherDiff(this)
141
186
  // @ts-expect-error types are wrong
142
187
  args[0].dryRun = old.dryRun
143
- // @ts-expect-error types are wrong
144
188
  args[0].save = old.save
145
- // @ts-expect-error types are wrong
146
189
  args[0].saveBundle = old.saveBundle
147
- // nothing to check, mmm already installed?
148
- if (diff.check.length === 0 && diff.unknowns.length === 0) {
190
+ // nothing to check, mmm already installed or all private?
191
+ if (diff.findIndex(c => c.newPackage.repository_url === 'https://registry.npmjs.org') === -1) {
149
192
  return this[kRiskyReify](...args)
150
193
  }
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))
194
+ const ttyServer = await ttyServerPromise
195
+ const proceed = await ttyServer.captureTTY(async (input, output, colorLevel) => {
196
+ if (input) {
197
+ const chalkNS = await chalkPromise
198
+ chalkNS.default.level = colorLevel
199
+ const oraNS = await oraPromise
200
+ const ora = () => {
201
+ return oraNS.default({
202
+ stream: output,
203
+ color: 'cyan',
204
+ isEnabled: true,
205
+ isSilent: false,
206
+ hideCursor: true,
207
+ discardStdin: true,
208
+ spinner: oraNS.spinners.dots,
164
209
  })
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')
210
+ }
211
+ const risky = await packagesHaveRiskyIssues(this.registry, diff, ora, input, output)
212
+ if (!risky) {
213
+ return true
214
+ }
215
+ const rl = require('readline')
216
+ const rlin = new PassThrough()
217
+ input.pipe(rlin, {
218
+ end: true
219
+ })
220
+ const rlout = new PassThrough()
221
+ rlout.pipe(output, {
222
+ end: false
223
+ })
224
+ const rli = rl.createInterface(rlin, rlout)
225
+ try {
226
+ while (true) {
227
+ /**
228
+ * @type {string}
229
+ */
230
+ const answer = await new Promise((resolve) => {
231
+ rli.question('Accept risks of installing these packages (y/N)? ', (str) => resolve(str))
232
+ })
233
+ if (/^\s*y(es)?\s*$/i.test(answer)) {
234
+ return true
235
+ } else if (/^(\s*no?\s*|)$/i.test(answer)) {
236
+ return false
237
+ }
169
238
  }
239
+ } finally {
240
+ rli.close()
241
+ }
242
+ } else {
243
+ if (await packagesHaveRiskyIssues(this.registry, diff, null, null, output)) {
244
+ throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
170
245
  }
246
+ return true
171
247
  }
248
+ return false
249
+ })
250
+ if (proceed) {
172
251
  return this[kRiskyReify](...args)
173
252
  } else {
174
- await packagesHaveRiskyIssues(diff.check)
175
- throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
253
+ throw new Error('Socket npm exiting due to risks')
176
254
  }
177
255
  }
178
256
  }
@@ -180,34 +258,18 @@ class SafeArborist extends Arborist {
180
258
  require.cache[arboristLibClassPath].exports = SafeArborist
181
259
 
182
260
  /**
183
- * @param {InstanceType<typeof Arborist>} arb
184
- * @returns {{
261
+ * @typedef {{
185
262
  * check: InstallEffect[],
186
263
  * unknowns: InstallEffect[]
187
- * }}
264
+ * }} InstallDiff
265
+ */
266
+
267
+ /**
268
+ * @param {InstanceType<typeof Arborist>} arb
269
+ * @returns {InstallEffect[]}
188
270
  */
189
271
  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
- }
272
+ return walk(arb.diff)
211
273
  }
212
274
  /**
213
275
  * @typedef InstallEffect
@@ -216,6 +278,8 @@ function gatherDiff (arb) {
216
278
  * @property {import('@npmcli/arborist').Node['pkgid']} pkgid
217
279
  * @property {import('@npmcli/arborist').Node['resolved']} resolved
218
280
  * @property {import('@npmcli/arborist').Node['location']} location
281
+ * @property {PURLParts | null} oldPackage
282
+ * @property {PURLParts} newPackage
219
283
  */
220
284
  /**
221
285
  * @param {import('@npmcli/arborist').Diff | null} diff
@@ -243,13 +307,36 @@ function walk (diff, needInfoOn = []) {
243
307
  }
244
308
  if (keep) {
245
309
  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
- })
310
+ /**
311
+ *
312
+ * @param {string} pkgid - `pkg@ver`
313
+ * @param {string} resolved - tarball link, should match `/name/-/name-ver.tgz` as tail, used to obtain repository_url
314
+ * @returns {PURLParts}
315
+ */
316
+ function toPURL (pkgid, resolved) {
317
+ const repo = resolved
318
+ .replace(/#[\s\S]*$/u, '')
319
+ .replace(/\?[\s\S]*$/u, '')
320
+ .replace(/\/[^/]*\/-\/[\s\S]*$/u, '')
321
+ const { name, version } = pkgidParts(pkgid)
322
+ return {
323
+ type: 'npm',
324
+ namespace_and_name: name,
325
+ version,
326
+ repository_url: repo
327
+ }
328
+ }
329
+ if (diff.ideal.resolved && (!diff.actual || diff.actual.resolved)) {
330
+ needInfoOn.push({
331
+ existing,
332
+ action: diff.action,
333
+ location: diff.ideal.location,
334
+ pkgid: diff.ideal.pkgid,
335
+ newPackage: toPURL(diff.ideal.pkgid, diff.ideal.resolved),
336
+ oldPackage: diff.actual && diff.actual.resolved ? toPURL(diff.actual.pkgid, diff.actual.resolved) : null,
337
+ resolved: diff.ideal.resolved,
338
+ })
339
+ }
253
340
  }
254
341
  }
255
342
  }
@@ -262,11 +349,14 @@ function walk (diff, needInfoOn = []) {
262
349
  }
263
350
 
264
351
  /**
352
+ * @param {string} registry
265
353
  * @param {InstallEffect[]} pkgs
266
354
  * @param {import('ora')['default'] | null} ora
355
+ * @param {Readable | null} input
356
+ * @param {Writable} ora
267
357
  * @returns {Promise<boolean>}
268
358
  */
269
- async function packagesHaveRiskyIssues (pkgs, ora = null) {
359
+ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, output) {
270
360
  let failed = false
271
361
  if (pkgs.length) {
272
362
  let remaining = pkgs.length
@@ -277,7 +367,7 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
277
367
  function getText () {
278
368
  return `Looking up data for ${remaining} packages`
279
369
  }
280
- const spinner = ora ? ora(getText()).start() : null
370
+ const spinner = ora ? ora().start(getText()) : null
281
371
  const pkgDatas = []
282
372
  try {
283
373
  for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
@@ -326,7 +416,7 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
326
416
  formatter ??= new ((await chalkMarkdownPromise).ChalkOrMarkdown)(false)
327
417
  const name = pkgData.pkg
328
418
  const version = pkgData.ver
329
- console.error(`${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:`)
419
+ output.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
330
420
  if (translations) {
331
421
  for (const failure of failures) {
332
422
  const type = failure.type
@@ -335,8 +425,8 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
335
425
  const issueTypeTranslation = translations.issues[type]
336
426
  // TODO: emoji seems to misalign terminals sometimes
337
427
  // @ts-ignore
338
- const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}`
339
- console.error(msg)
428
+ const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}\n`
429
+ output.write(msg)
340
430
  }
341
431
  }
342
432
  }
@@ -349,8 +439,6 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
349
439
  if (spinner) {
350
440
  spinner.text = getText()
351
441
  }
352
- } else {
353
- spinner?.stop()
354
442
  }
355
443
  pkgDatas.push(pkgData)
356
444
  }
@@ -367,3 +455,195 @@ async function packagesHaveRiskyIssues (pkgs, ora = null) {
367
455
  return false
368
456
  }
369
457
  }
458
+
459
+ /**
460
+ * @param {import('chalk')['default']['level']} colorLevel
461
+ * @returns {Promise<{ captureTTY<RET extends any>(mutexFn: (input: Readable | null, output: Writable, colorLevel: import('chalk')['default']['level']) => Promise<RET>): Promise<RET> }>}
462
+ */
463
+ async function createTTYServer (colorLevel) {
464
+ const TTY_IPC = process.env.SOCKET_SECURITY_TTY_IPC
465
+ const net = require('net')
466
+ /**
467
+ * @type {import('readline')}
468
+ */
469
+ let readline
470
+ const isSTDINInteractive = (await isInteractivePromise).default({
471
+ stream: process.stdin
472
+ })
473
+ if (!isSTDINInteractive && TTY_IPC) {
474
+ return {
475
+ async captureTTY (mutexFn) {
476
+ return new Promise((resolve, reject) => {
477
+ const conn = net.createConnection({
478
+ path: TTY_IPC
479
+ }).on('error', reject)
480
+ let captured = false
481
+ const bufs = []
482
+ conn.on('data', function awaitCapture (chunk) {
483
+ bufs.push(chunk)
484
+ const lineBuff = Buffer.concat(bufs)
485
+ try {
486
+ if (!captured) {
487
+ const EOL = lineBuff.indexOf('\n'.charCodeAt(0))
488
+ if (EOL !== -1) {
489
+ conn.removeListener('data', awaitCapture)
490
+ conn.push(lineBuff.slice(EOL + 1))
491
+ lineBuff = null
492
+ captured = true
493
+ const {
494
+ ipc_version: remote_ipc_version,
495
+ capabilities: { input: hasInput, output: hasOutput, colorLevel: ipcColorLevel }
496
+ } = JSON.parse(lineBuff.slice(0, EOL).toString('utf-8'))
497
+ if (remote_ipc_version !== ipc_version) {
498
+ throw new Error('Mismatched STDIO tunnel IPC version, ensure you only have 1 version of socket CLI being called.')
499
+ }
500
+ const input = hasInput ? new PassThrough() : null
501
+ input.pause()
502
+ conn.pipe(input)
503
+ const output = hasOutput ? new PassThrough() : null
504
+ output.pipe(conn)
505
+ // make ora happy
506
+ // @ts-ignore
507
+ output.isTTY = true
508
+ // @ts-ignore
509
+ output.cursorTo = function cursorTo (x, y, callback) {
510
+ readline = readline || require('readline')
511
+ readline.cursorTo(this, x, y, callback)
512
+ }
513
+ // @ts-ignore
514
+ output.clearLine = function clearLine (dir, callback) {
515
+ readline = readline || require('readline')
516
+ readline.clearLine(this, dir, callback)
517
+ }
518
+ mutexFn(hasInput ? input : null, hasOutput ? output : null, ipcColorLevel)
519
+ .then(resolve, reject)
520
+ .finally(() => {
521
+ conn.unref()
522
+ conn.end()
523
+ input.end()
524
+ output.end()
525
+ // process.exit(13)
526
+ })
527
+ }
528
+ }
529
+ } catch (e) {
530
+ reject(e)
531
+ }
532
+ })
533
+ })
534
+ }
535
+ }
536
+ }
537
+ const pendingCaptures = []
538
+ let captured = false
539
+ const sock = path.join(require('os').tmpdir(), `socket-security-tty-${process.pid}.sock`)
540
+ process.env.SOCKET_SECURITY_TTY_IPC = sock
541
+ try {
542
+ await require('fs/promises').unlink(sock)
543
+ } catch (e) {
544
+ if (e.code !== 'ENOENT') {
545
+ throw e
546
+ }
547
+ }
548
+ process.on('exit', () => {
549
+ ttyServer.close()
550
+ try {
551
+ require('fs').unlinkSync(sock)
552
+ } catch (e) {
553
+ if (e.code !== 'ENOENT') {
554
+ throw e
555
+ }
556
+ }
557
+ })
558
+ const input = isSTDINInteractive ? process.stdin : null
559
+ const output = process.stderr
560
+ const ttyServer = await new Promise((resolve, reject) => {
561
+ const server = net.createServer(async (conn) => {
562
+ if (captured) {
563
+ const captured = new Promise((resolve) => {
564
+ pendingCaptures.push({
565
+ resolve
566
+ })
567
+ })
568
+ await captured
569
+ } else {
570
+ captured = true
571
+ }
572
+ const wasProgressEnabled = npmlog.progressEnabled
573
+ npmlog.pause()
574
+ if (wasProgressEnabled) {
575
+ npmlog.disableProgress()
576
+ }
577
+ conn.write(`${JSON.stringify({
578
+ ipc_version,
579
+ capabilities: {
580
+ input: Boolean(input),
581
+ output: true,
582
+ colorLevel
583
+ }
584
+ })}\n`)
585
+ conn.on('data', (data) => {
586
+ output.write(data)
587
+ })
588
+ conn.on('error', (e) => {
589
+ output.write(`there was an error prompting from a subshell (${e.message}), socket npm closing`)
590
+ process.exit(1)
591
+ })
592
+ input.on('data', (data) => {
593
+ conn.write(data)
594
+ })
595
+ input.on('end', () => {
596
+ conn.unref()
597
+ conn.end()
598
+ if (wasProgressEnabled) {
599
+ npmlog.enableProgress()
600
+ }
601
+ npmlog.resume()
602
+ nextCapture()
603
+ })
604
+ }).listen(sock, (err) => {
605
+ if (err) reject(err)
606
+ else resolve(server)
607
+ }).unref()
608
+ })
609
+ /**
610
+ *
611
+ */
612
+ function nextCapture () {
613
+ if (pendingCaptures.length > 0) {
614
+ const nextCapture = pendingCaptures.shift()
615
+ nextCapture.resolve()
616
+ } else {
617
+ captured = false
618
+ }
619
+ }
620
+ return {
621
+ async captureTTY (mutexFn) {
622
+ if (captured) {
623
+ const captured = new Promise((resolve) => {
624
+ pendingCaptures.push({
625
+ resolve
626
+ })
627
+ })
628
+ await captured
629
+ } else {
630
+ captured = true
631
+ }
632
+ const wasProgressEnabled = npmlog.progressEnabled
633
+ try {
634
+ npmlog.pause()
635
+ if (wasProgressEnabled) {
636
+ npmlog.disableProgress()
637
+ }
638
+ // need await here for proper finally timing
639
+ return await mutexFn(input, output, colorLevel)
640
+ } finally {
641
+ if (wasProgressEnabled) {
642
+ npmlog.enableProgress()
643
+ }
644
+ npmlog.resume()
645
+ nextCapture()
646
+ }
647
+ }
648
+ }
649
+ }
@@ -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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/cli",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "CLI tool for Socket.dev",
5
5
  "homepage": "http://github.com/SocketDev/socket-cli-js",
6
6
  "repository": {
@@ -47,7 +47,7 @@
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/mocha": "^10.0.1",
51
51
  "@types/mock-fs": "^4.13.1",
52
52
  "@types/node": "^14.18.31",
53
53
  "@types/npm": "^7.19.0",
@@ -1,3 +0,0 @@
1
- declare module 'hyperlinker' {
2
- export = (msg: string, href: URL['href']) => string
3
- }