@socketsecurity/cli 0.7.2 → 0.8.2

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,139 @@ 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 apiKeySettingsInit = sdkPromise.then(async ({ setupSdk }) => {
44
+ try {
45
+ const sdk = await setupSdk(await pubTokenPromise)
46
+ const orgResult = await sdk.getOrganizations()
47
+ if (!orgResult.success) {
48
+ throw new Error('Failed to fetch Socket organization info: ' + orgResult.error.message)
49
+ }
50
+ /**
51
+ * @type {(Exclude<typeof orgResult.data.organizations[string], undefined>)[]}
52
+ */
53
+ const orgs = []
54
+ for (const org of Object.values(orgResult.data.organizations)) {
55
+ if (org) {
56
+ orgs.push(org)
57
+ }
58
+ }
59
+ const result = await sdk.postSettings(orgs.map(org => {
60
+ return {
61
+ organization: org.id
62
+ }
63
+ }))
64
+ if (!result.success) {
65
+ throw new Error('Failed to fetch API key settings: ' + result.error.message)
66
+ }
67
+ return {
68
+ orgs,
69
+ settings: result.data
70
+ }
71
+ } catch (e) {
72
+ if (e && typeof e === 'object' && 'cause' in e) {
73
+ const cause = e.cause
74
+ if (isErrnoException(cause)) {
75
+ if (cause.code === 'ENOTFOUND' || cause.code === 'ECONNREFUSED') {
76
+ throw new Error('Unable to connect to socket.dev, ensure internet connectivity before retrying', {
77
+ cause: e
78
+ })
79
+ }
80
+ }
81
+ }
82
+ throw e
83
+ }
84
+ })
85
+ // mark apiKeySettingsInit as handled
86
+ apiKeySettingsInit.catch(() => {})
87
+
88
+ /**
89
+ *
90
+ */
91
+ async function findSocketYML () {
92
+ let prevDir = null
93
+ let dir = process.cwd()
94
+ const fs = require('fs/promises')
95
+ while (dir !== prevDir) {
96
+ const ymlPath = path.join(dir, 'socket.yml')
97
+ const yml = fs.readFile(ymlPath, 'utf-8')
98
+ // mark as handled
99
+ yml.catch(() => {})
100
+ const yamlPath = path.join(dir, 'socket.yaml')
101
+ const yaml = fs.readFile(yamlPath, 'utf-8')
102
+ // mark as handled
103
+ yaml.catch(() => {})
104
+ /**
105
+ * @param {unknown} e
106
+ * @returns {boolean}
107
+ */
108
+ function checkFileFoundError (e) {
109
+ if (isErrnoException(e)) {
110
+ if (e.code !== 'ENOENT' && e.code !== 'EISDIR') {
111
+ throw e
112
+ }
113
+ return false
114
+ }
115
+ return true
116
+ }
117
+ try {
118
+ return {
119
+ path: ymlPath,
120
+ parsed: config.parseSocketConfig(await yml)
121
+ }
122
+ } catch (e) {
123
+ if (checkFileFoundError(e)) {
124
+ throw new Error('Found file but was unable to parse ' + ymlPath)
125
+ }
126
+ }
127
+ try {
128
+ return {
129
+ path: ymlPath,
130
+ parsed: config.parseSocketConfig(await yaml)
131
+ }
132
+ } catch (e) {
133
+ if (checkFileFoundError(e)) {
134
+ throw new Error('Found file but was unable to parse ' + yamlPath)
135
+ }
136
+ }
137
+ prevDir = dir
138
+ dir = path.join(dir, '..')
139
+ }
140
+ return null
141
+ }
142
+
143
+ /**
144
+ * @type {Promise<ReturnType<import('../utils/issue-rules.cjs')['createIssueUXLookup']> | undefined>}
145
+ */
146
+ const uxLookupInit = settingsPromise.then(async ({ getSetting }) => {
147
+ const enforcedOrgs = getSetting('enforcedOrgs') ?? []
148
+ const remoteSettings = await apiKeySettingsInit
149
+ const { orgs, settings } = remoteSettings
150
+
151
+ // remove any organizations not being enforced
152
+ for (const [i, org] of orgs.entries()) {
153
+ if (!enforcedOrgs.includes(org.id)) {
154
+ settings.entries.splice(i, 1)
155
+ }
156
+ }
157
+
158
+ const socketYml = await findSocketYML()
159
+ if (socketYml) {
160
+ settings.entries.push({
161
+ start: socketYml.path,
162
+ // @ts-ignore
163
+ settings: {
164
+ [socketYml.path]: {
165
+ deferTo: null,
166
+ issueRules: socketYml.parsed.issueRules
167
+ }
168
+ }
169
+ })
170
+ }
171
+ return createIssueUXLookup(settings)
172
+ })
173
+ // mark uxLookupInit as handled
174
+ uxLookupInit.catch(() => {})
39
175
 
40
176
  // shadow `npm` and `npx` to mitigate subshells
41
177
  require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
@@ -76,6 +212,7 @@ async function * batchScan (
76
212
  }
77
213
  })
78
214
  }
215
+ // TODO: migrate to SDK
79
216
  const pkgDataReq = https.request(
80
217
  'https://api.socket.dev/v0/scan/batch',
81
218
  {
@@ -108,8 +245,10 @@ let translations = null
108
245
  */
109
246
  let formatter = null
110
247
 
111
- const ttyServerPromise = chalkPromise.then(chalk => {
112
- return createTTYServer(chalk.default.level)
248
+ const ttyServerPromise = chalkPromise.then(async (chalk) => {
249
+ return createTTYServer(chalk.default.level, (await isInteractivePromise).default({
250
+ stream: process.stdin
251
+ }), npmlog)
113
252
  })
114
253
 
115
254
  const npmEntrypoint = fs.realpathSync(`${process.argv[1]}`)
@@ -130,6 +269,10 @@ function findRoot (filepath) {
130
269
  const npmDir = findRoot(path.dirname(npmEntrypoint))
131
270
  const arboristLibClassPath = path.join(npmDir, 'node_modules', '@npmcli', 'arborist', 'lib', 'arborist', 'index.js')
132
271
  const npmlog = require(path.join(npmDir, 'node_modules', 'npmlog', 'lib', 'log.js'))
272
+ /**
273
+ * @type {import('pacote')}
274
+ */
275
+ const pacote = require(path.join(npmDir, 'node_modules', 'pacote'))
133
276
 
134
277
  /**
135
278
  * @type {typeof import('@npmcli/arborist')}
@@ -178,7 +321,12 @@ class SafeArborist extends Arborist {
178
321
  return this[kRiskyReify](...args)
179
322
  }
180
323
  args[0] ??= {}
181
- const old = { ...args[0] }
324
+ const old = {
325
+ dryRun: false,
326
+ save: Boolean(args[0].save ?? true),
327
+ saveBundle: Boolean(args[0].saveBundle ?? false),
328
+ ...args[0]
329
+ }
182
330
  // @ts-expect-error types are wrong
183
331
  args[0].dryRun = true
184
332
  args[0].save = false
@@ -197,7 +345,7 @@ class SafeArborist extends Arborist {
197
345
  }
198
346
  const ttyServer = await ttyServerPromise
199
347
  const proceed = await ttyServer.captureTTY(async (input, output, colorLevel) => {
200
- if (input) {
348
+ if (input && output) {
201
349
  const chalkNS = await chalkPromise
202
350
  chalkNS.default.level = colorLevel
203
351
  const oraNS = await oraPromise
@@ -212,7 +360,7 @@ class SafeArborist extends Arborist {
212
360
  spinner: oraNS.spinners.dots,
213
361
  })
214
362
  }
215
- const risky = await packagesHaveRiskyIssues(this.registry, diff, ora, input, output)
363
+ const risky = await packagesHaveRiskyIssues(this, this.registry, diff, ora, input, output)
216
364
  if (!risky) {
217
365
  return true
218
366
  }
@@ -244,11 +392,13 @@ class SafeArborist extends Arborist {
244
392
  rli.close()
245
393
  }
246
394
  } else {
247
- if (await packagesHaveRiskyIssues(this.registry, diff, null, null, output)) {
395
+ if (await packagesHaveRiskyIssues(this, this.registry, diff, null, null, output)) {
248
396
  throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
249
397
  }
250
398
  return true
251
399
  }
400
+ // @ts-ignore paranoia
401
+ // eslint-disable-next-line
252
402
  return false
253
403
  })
254
404
  if (proceed) {
@@ -353,14 +503,15 @@ function walk (diff, needInfoOn = []) {
353
503
  }
354
504
 
355
505
  /**
356
- * @param {string} registry
506
+ * @param {SafeArborist} safeArb
507
+ * @param {string} _registry
357
508
  * @param {InstallEffect[]} pkgs
358
509
  * @param {import('ora')['default'] | null} ora
359
- * @param {Readable | null} input
360
- * @param {Writable} ora
510
+ * @param {Readable | null} [_input]
511
+ * @param {Writable | null} [output]
361
512
  * @returns {Promise<boolean>}
362
513
  */
363
- async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, output) {
514
+ async function packagesHaveRiskyIssues (safeArb, _registry, pkgs, ora = null, _input, output) {
364
515
  let failed = false
365
516
  if (pkgs.length) {
366
517
  let remaining = pkgs.length
@@ -374,69 +525,81 @@ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, outpu
374
525
  const spinner = ora ? ora().start(getText()) : null
375
526
  const pkgDatas = []
376
527
  try {
528
+ // TODO: determine org based on cwd, pass in
529
+ const uxLookup = await uxLookupInit
530
+
377
531
  for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
532
+ /**
533
+ * @type {Array<any>}
534
+ */
378
535
  let failures = []
536
+ let displayWarning = false
537
+ const name = pkgData.pkg
538
+ const version = pkgData.ver
539
+ let blocked = false
379
540
  if (pkgData.type === 'missing') {
541
+ failed = true
380
542
  failures.push({
381
543
  type: 'missingDependency'
382
544
  })
383
545
  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
- )
546
+ } else {
547
+ for (const failure of pkgData.value.issues) {
548
+ const ux = await uxLookup({ package: { name, version }, issue: { type: failure.type } })
549
+ if (ux.display || ux.block) {
550
+ failures.push({ raw: failure, block: ux.block })
551
+ // before we ask about problematic issues, check to see if they already existed in the old version
552
+ // if they did, be quiet
553
+ const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}` && pkg.existing?.startsWith(pkgData.pkg + '@'))
554
+ if (pkg?.existing) {
555
+ for await (const oldPkgData of batchScan([pkg.existing])) {
556
+ if (oldPkgData.type === 'success') {
557
+ failures = failures.filter(
558
+ issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.raw.type) == null
559
+ )
560
+ }
561
+ }
412
562
  }
413
563
  }
564
+ if (ux.block) {
565
+ failed = true
566
+ blocked = true
567
+ }
568
+ if (ux.display) {
569
+ displayWarning = true
570
+ }
414
571
  }
415
572
  }
416
- if (failures.length) {
417
- failed = true
418
- spinner?.stop()
573
+ if (!blocked) {
574
+ const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}`)
575
+ if (pkg) {
576
+ pacote.tarball.stream(pkg.pkgid, (stream) => {
577
+ stream.resume()
578
+ // @ts-ignore pacote does a naughty
579
+ return stream.promise()
580
+ }, { ...safeArb[kCtorArgs][0] })
581
+ }
582
+ }
583
+ if (displayWarning) {
419
584
  translations ??= JSON.parse(fs.readFileSync(path.join(__dirname, '/translations.json'), 'utf-8'))
420
585
  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
- }
586
+ spinner?.stop()
587
+ output?.write(`(socket) ${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:\n`)
588
+ const lines = new Set()
589
+ for (const failure of failures.sort((a, b) => a.raw.type < b.raw.type ? -1 : 1)) {
590
+ const type = failure.raw.type
591
+ if (type) {
592
+ // @ts-ignore
593
+ const issueTypeTranslation = translations.issues[type]
594
+ // TODO: emoji seems to misalign terminals sometimes
595
+ // @ts-ignore
596
+ lines.add(` ${issueTypeTranslation?.title ?? type}${failure.block ? '' : ' (non-blocking)'} - ${issueTypeTranslation?.description ?? ''}\n`)
435
597
  }
436
598
  }
599
+ for (const line of lines) {
600
+ output?.write(line)
601
+ }
437
602
  spinner?.start()
438
- } else {
439
- // TODO: have pacote/cacache download non-problematic files while waiting
440
603
  }
441
604
  remaining--
442
605
  if (remaining !== 0) {
@@ -459,195 +622,3 @@ async function packagesHaveRiskyIssues (registry, pkgs, ora = null, input, outpu
459
622
  return false
460
623
  }
461
624
  }
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
- }