@socketsecurity/cli 0.7.1 → 0.8.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.
@@ -2,18 +2,24 @@
2
2
  // THIS MUST BE CJS TO WORK WITH --require
3
3
  'use strict'
4
4
 
5
+ const events = require('events')
5
6
  const fs = require('fs')
6
- const path = require('path')
7
7
  const https = require('https')
8
- const events = require('events')
8
+ const path = require('path')
9
9
  const rl = require('readline')
10
10
  const { PassThrough } = require('stream')
11
+
12
+ const config = require('@socketsecurity/config')
13
+
11
14
  const oraPromise = import('ora')
12
15
  const isInteractivePromise = import('is-interactive')
13
16
  const chalkPromise = import('chalk')
14
17
  const chalkMarkdownPromise = import('../utils/chalk-markdown.js')
15
18
  const settingsPromise = import('../utils/settings.js')
16
- const ipc_version = require('../../package.json').version
19
+ const sdkPromise = import('../utils/sdk.js')
20
+ const createTTYServer = require('./tty-server.cjs')
21
+ const { createIssueUXLookup } = require('../utils/issue-rules.cjs')
22
+ const { isErrnoException } = require('../utils/type-helpers.cjs')
17
23
 
18
24
  try {
19
25
  // due to update-notifier pkg being ESM only we actually spawn a subprocess sadly
@@ -33,9 +39,119 @@ try {
33
39
  * @typedef {import('stream').Writable} Writable
34
40
  */
35
41
 
36
- const pubTokenPromise = settingsPromise.then(({ getSetting }) =>
37
- getSetting('apiKey') || 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
38
- );
42
+ const pubTokenPromise = sdkPromise.then(({ getDefaultKey, FREE_API_KEY }) => getDefaultKey() || FREE_API_KEY)
43
+ const apiKeySettingsPromise = sdkPromise.then(async ({ setupSdk }) => {
44
+ const sdk = await setupSdk(await pubTokenPromise)
45
+ const orgResult = await sdk.getOrganizations()
46
+ if (!orgResult.success) {
47
+ throw new Error('Failed to fetch Socket organization info: ' + orgResult.error.message)
48
+ }
49
+ /**
50
+ * @type {(Exclude<typeof orgResult.data.organizations[string], undefined>)[]}
51
+ */
52
+ const orgs = []
53
+ for (const org of Object.values(orgResult.data.organizations)) {
54
+ if (org) {
55
+ orgs.push(org)
56
+ }
57
+ }
58
+ const result = await sdk.postSettings(orgs.map(org => {
59
+ return {
60
+ organization: org.id
61
+ }
62
+ }))
63
+ if (!result.success) {
64
+ throw new Error('Failed to fetch API key settings: ' + result.error.message)
65
+ }
66
+ return {
67
+ orgs,
68
+ settings: result.data
69
+ }
70
+ })
71
+
72
+ /**
73
+ *
74
+ */
75
+ async function findSocketYML () {
76
+ let prevDir = null
77
+ let dir = process.cwd()
78
+ const fs = require('fs/promises')
79
+ while (dir !== prevDir) {
80
+ const ymlPath = path.join(dir, 'socket.yml')
81
+ // mark as handled
82
+ const yml = fs.readFile(ymlPath, 'utf-8').catch(() => {})
83
+ const yamlPath = path.join(dir, 'socket.yaml')
84
+ // mark as handled
85
+ const yaml = fs.readFile(yamlPath, 'utf-8').catch(() => {})
86
+ try {
87
+ const txt = await yml
88
+ if (txt != null) {
89
+ return {
90
+ path: ymlPath,
91
+ parsed: config.parseSocketConfig(txt)
92
+ }
93
+ }
94
+ } catch (e) {
95
+ if (isErrnoException(e)) {
96
+ if (e.code !== 'ENOENT' && e.code !== 'EISDIR') {
97
+ throw e
98
+ }
99
+ } else {
100
+ throw new Error('Found file but was unable to parse ' + ymlPath)
101
+ }
102
+ }
103
+ try {
104
+ const txt = await yaml
105
+ if (txt != null) {
106
+ return {
107
+ path: yamlPath,
108
+ parsed: config.parseSocketConfig(txt)
109
+ }
110
+ }
111
+ } catch (e) {
112
+ if (isErrnoException(e)) {
113
+ if (e.code !== 'ENOENT' && e.code !== 'EISDIR') {
114
+ throw e
115
+ }
116
+ } else {
117
+ throw new Error('Found file but was unable to parse ' + yamlPath)
118
+ }
119
+ }
120
+ prevDir = dir
121
+ dir = path.join(dir, '..')
122
+ }
123
+ return null
124
+ }
125
+
126
+ /**
127
+ * @type {Promise<ReturnType<import('../utils/issue-rules.cjs')['createIssueUXLookup']>>}
128
+ */
129
+ const uxLookupPromise = settingsPromise.then(async ({ getSetting }) => {
130
+ const enforcedOrgs = getSetting('enforcedOrgs') ?? []
131
+ const { orgs, settings } = await apiKeySettingsPromise
132
+
133
+ // remove any organizations not being enforced
134
+ for (const [i, org] of orgs.entries()) {
135
+ if (!enforcedOrgs.includes(org.id)) {
136
+ settings.entries.splice(i, 1)
137
+ }
138
+ }
139
+
140
+ const socketYml = await findSocketYML()
141
+ if (socketYml) {
142
+ settings.entries.push({
143
+ start: socketYml.path,
144
+ // @ts-ignore
145
+ settings: {
146
+ [socketYml.path]: {
147
+ deferTo: null,
148
+ issueRules: socketYml.parsed.issueRules
149
+ }
150
+ }
151
+ })
152
+ }
153
+ return createIssueUXLookup(settings)
154
+ })
39
155
 
40
156
  // shadow `npm` and `npx` to mitigate subshells
41
157
  require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
@@ -76,6 +192,7 @@ async function * batchScan (
76
192
  }
77
193
  })
78
194
  }
195
+ // TODO: migrate to SDK
79
196
  const pkgDataReq = https.request(
80
197
  'https://api.socket.dev/v0/scan/batch',
81
198
  {
@@ -108,8 +225,10 @@ let translations = null
108
225
  */
109
226
  let formatter = null
110
227
 
111
- const ttyServerPromise = chalkPromise.then(chalk => {
112
- return createTTYServer(chalk.default.level)
228
+ const ttyServerPromise = chalkPromise.then(async (chalk) => {
229
+ return createTTYServer(chalk.default.level, (await isInteractivePromise).default({
230
+ stream: process.stdin
231
+ }), npmlog)
113
232
  })
114
233
 
115
234
  const npmEntrypoint = fs.realpathSync(`${process.argv[1]}`)
@@ -130,6 +249,10 @@ function findRoot (filepath) {
130
249
  const npmDir = findRoot(path.dirname(npmEntrypoint))
131
250
  const arboristLibClassPath = path.join(npmDir, 'node_modules', '@npmcli', 'arborist', 'lib', 'arborist', 'index.js')
132
251
  const npmlog = require(path.join(npmDir, 'node_modules', 'npmlog', 'lib', 'log.js'))
252
+ /**
253
+ * @type {import('pacote')}
254
+ */
255
+ const pacote = require(path.join(npmDir, 'node_modules', 'pacote'))
133
256
 
134
257
  /**
135
258
  * @type {typeof import('@npmcli/arborist')}
@@ -178,7 +301,12 @@ class SafeArborist extends Arborist {
178
301
  return this[kRiskyReify](...args)
179
302
  }
180
303
  args[0] ??= {}
181
- const old = { ...args[0] }
304
+ const old = {
305
+ dryRun: false,
306
+ save: Boolean(args[0].save ?? true),
307
+ saveBundle: Boolean(args[0].saveBundle ?? false),
308
+ ...args[0]
309
+ }
182
310
  // @ts-expect-error types are wrong
183
311
  args[0].dryRun = true
184
312
  args[0].save = false
@@ -197,7 +325,7 @@ class SafeArborist extends Arborist {
197
325
  }
198
326
  const ttyServer = await ttyServerPromise
199
327
  const proceed = await ttyServer.captureTTY(async (input, output, colorLevel) => {
200
- if (input) {
328
+ if (input && output) {
201
329
  const chalkNS = await chalkPromise
202
330
  chalkNS.default.level = colorLevel
203
331
  const oraNS = await oraPromise
@@ -212,7 +340,7 @@ class SafeArborist extends Arborist {
212
340
  spinner: oraNS.spinners.dots,
213
341
  })
214
342
  }
215
- const risky = await packagesHaveRiskyIssues(this.registry, diff, ora, input, output)
343
+ const risky = await packagesHaveRiskyIssues(this, this.registry, diff, ora, input, output)
216
344
  if (!risky) {
217
345
  return true
218
346
  }
@@ -244,11 +372,13 @@ class SafeArborist extends Arborist {
244
372
  rli.close()
245
373
  }
246
374
  } else {
247
- if (await packagesHaveRiskyIssues(this.registry, diff, null, null, output)) {
375
+ if (await packagesHaveRiskyIssues(this, this.registry, diff, null, null, output)) {
248
376
  throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
249
377
  }
250
378
  return true
251
379
  }
380
+ // @ts-ignore paranoia
381
+ // eslint-disable-next-line
252
382
  return false
253
383
  })
254
384
  if (proceed) {
@@ -353,14 +483,15 @@ function walk (diff, needInfoOn = []) {
353
483
  }
354
484
 
355
485
  /**
356
- * @param {string} registry
486
+ * @param {SafeArborist} safeArb
487
+ * @param {string} _registry
357
488
  * @param {InstallEffect[]} pkgs
358
489
  * @param {import('ora')['default'] | null} ora
359
- * @param {Readable | null} input
360
- * @param {Writable} ora
490
+ * @param {Readable | null} [_input]
491
+ * @param {Writable | null} [output]
361
492
  * @returns {Promise<boolean>}
362
493
  */
363
- async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, output) {
494
+ async function packagesHaveRiskyIssues (safeArb, _registry, pkgs, ora = null, _input, output) {
364
495
  let failed = false
365
496
  if (pkgs.length) {
366
497
  let remaining = pkgs.length
@@ -374,69 +505,81 @@ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, outpu
374
505
  const spinner = ora ? ora().start(getText()) : null
375
506
  const pkgDatas = []
376
507
  try {
508
+ // TODO: determine org based on cwd, pass in
509
+ const uxLookup = await uxLookupPromise
510
+
377
511
  for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
512
+ /**
513
+ * @type {Array<any>}
514
+ */
378
515
  let failures = []
516
+ let displayWarning = false
517
+ const name = pkgData.pkg
518
+ const version = pkgData.ver
519
+ let blocked = false
379
520
  if (pkgData.type === 'missing') {
521
+ failed = true
380
522
  failures.push({
381
523
  type: 'missingDependency'
382
524
  })
383
525
  continue
384
- }
385
- for (const issue of (pkgData.value?.issues ?? [])) {
386
- if ([
387
- 'shellScriptOverride',
388
- 'gitDependency',
389
- 'httpDependency',
390
- 'installScripts',
391
- 'malware',
392
- 'didYouMean',
393
- 'hasNativeCode',
394
- 'troll',
395
- 'telemetry',
396
- 'invalidPackageJSON',
397
- 'unresolvedRequire',
398
- ].includes(issue.type)) {
399
- failures.push(issue)
400
- }
401
- }
402
- // before we ask about problematic issues, check to see if they already existed in the old version
403
- // if they did, be quiet
404
- if (failures.length) {
405
- const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}` && pkg.existing?.startsWith(pkgData.pkg))
406
- if (pkg?.existing) {
407
- for await (const oldPkgData of batchScan([pkg.existing])) {
408
- if (oldPkgData.type === 'success') {
409
- failures = failures.filter(
410
- issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.type) == null
411
- )
526
+ } else {
527
+ for (const failure of pkgData.value.issues) {
528
+ const ux = await uxLookup({ package: { name, version }, issue: { type: failure.type } })
529
+ if (ux.display || ux.block) {
530
+ failures.push({ raw: failure, block: ux.block })
531
+ // before we ask about problematic issues, check to see if they already existed in the old version
532
+ // if they did, be quiet
533
+ const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}` && pkg.existing?.startsWith(pkgData.pkg + '@'))
534
+ if (pkg?.existing) {
535
+ for await (const oldPkgData of batchScan([pkg.existing])) {
536
+ if (oldPkgData.type === 'success') {
537
+ failures = failures.filter(
538
+ issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.raw.type) == null
539
+ )
540
+ }
541
+ }
412
542
  }
413
543
  }
544
+ if (ux.block) {
545
+ failed = true
546
+ blocked = true
547
+ }
548
+ if (ux.display) {
549
+ displayWarning = true
550
+ }
414
551
  }
415
552
  }
416
- if (failures.length) {
417
- failed = true
418
- spinner?.stop()
553
+ if (!blocked) {
554
+ const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}`)
555
+ if (pkg) {
556
+ pacote.tarball.stream(pkg.pkgid, (stream) => {
557
+ stream.resume()
558
+ // @ts-ignore pacote does a naughty
559
+ return stream.promise()
560
+ }, { ...safeArb[kCtorArgs][0] })
561
+ }
562
+ }
563
+ if (displayWarning) {
419
564
  translations ??= JSON.parse(fs.readFileSync(path.join(__dirname, '/translations.json'), 'utf-8'))
420
565
  formatter ??= new ((await chalkMarkdownPromise).ChalkOrMarkdown)(false)
421
- const name = pkgData.pkg
422
- const version = pkgData.ver
423
- output.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
424
- if (translations) {
425
- for (const failure of failures) {
426
- const type = failure.type
427
- if (type) {
428
- // @ts-ignore
429
- const issueTypeTranslation = translations.issues[type]
430
- // TODO: emoji seems to misalign terminals sometimes
431
- // @ts-ignore
432
- const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}\n`
433
- output.write(msg)
434
- }
566
+ spinner?.stop()
567
+ output?.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
568
+ const lines = new Set()
569
+ for (const failure of failures.sort((a, b) => a.raw.type < b.raw.type ? -1 : 1)) {
570
+ const type = failure.raw.type
571
+ if (type) {
572
+ // @ts-ignore
573
+ const issueTypeTranslation = translations.issues[type]
574
+ // TODO: emoji seems to misalign terminals sometimes
575
+ // @ts-ignore
576
+ lines.add(` ${issueTypeTranslation?.title ?? type}${failure.block ? '' : ' (non-blocking)'} - ${issueTypeTranslation?.description ?? ''}\n`)
435
577
  }
436
578
  }
579
+ for (const line of lines) {
580
+ output?.write(line)
581
+ }
437
582
  spinner?.start()
438
- } else {
439
- // TODO: have pacote/cacache download non-problematic files while waiting
440
583
  }
441
584
  remaining--
442
585
  if (remaining !== 0) {
@@ -459,195 +602,3 @@ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, outpu
459
602
  return false
460
603
  }
461
604
  }
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
- }