@socketsecurity/cli 0.7.2 → 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.
@@ -0,0 +1,221 @@
1
+ const path = require('path')
2
+ const { PassThrough } = require('stream')
3
+ const { isErrnoException } = require('../utils/type-helpers.cjs')
4
+ const ipc_version = require('../../package.json').version
5
+
6
+ /**
7
+ * @typedef {import('stream').Readable} Readable
8
+ */
9
+ /**
10
+ * @typedef {import('stream').Writable} Writable
11
+ */
12
+ /**
13
+ * @param {import('chalk')['default']['level']} colorLevel
14
+ * @param {boolean} isInteractive
15
+ * @param {any} npmlog
16
+ * @returns {Promise<{ captureTTY<RET extends any>(mutexFn: (input: Readable | null, output?: Writable, colorLevel: import('chalk')['default']['level']) => Promise<RET>): Promise<RET> }>}
17
+ */
18
+ module.exports = async function createTTYServer (colorLevel, isInteractive, npmlog) {
19
+ const TTY_IPC = process.env['SOCKET_SECURITY_TTY_IPC']
20
+ const net = require('net')
21
+ /**
22
+ * @type {import('readline')}
23
+ */
24
+ let readline
25
+ const isSTDINInteractive = true || isInteractive
26
+ if (!isSTDINInteractive && TTY_IPC) {
27
+ return {
28
+ async captureTTY (mutexFn) {
29
+ return new Promise((resolve, reject) => {
30
+ const conn = net.createConnection({
31
+ path: TTY_IPC
32
+ }).on('error', reject)
33
+ let captured = false
34
+ /**
35
+ * @type {Array<Buffer>}
36
+ */
37
+ const bufs = []
38
+ conn.on('data', function awaitCapture (chunk) {
39
+ bufs.push(chunk)
40
+ /**
41
+ * @type {Buffer | null}
42
+ */
43
+ let lineBuff = Buffer.concat(bufs)
44
+ try {
45
+ if (!captured) {
46
+ const EOL = lineBuff.indexOf('\n'.charCodeAt(0))
47
+ if (EOL !== -1) {
48
+ conn.removeListener('data', awaitCapture)
49
+ conn.push(lineBuff.slice(EOL + 1))
50
+ const {
51
+ ipc_version: remote_ipc_version,
52
+ capabilities: { input: hasInput, output: hasOutput, colorLevel: ipcColorLevel }
53
+ } = JSON.parse(lineBuff.slice(0, EOL).toString('utf-8'))
54
+ lineBuff = null
55
+ captured = true
56
+ if (remote_ipc_version !== ipc_version) {
57
+ throw new Error('Mismatched STDIO tunnel IPC version, ensure you only have 1 version of socket CLI being called.')
58
+ }
59
+ const input = hasInput ? new PassThrough() : null
60
+ input?.pause()
61
+ if (input) conn.pipe(input)
62
+ const output = hasOutput ? new PassThrough() : null
63
+ output?.pipe(conn)
64
+ // make ora happy
65
+ // @ts-ignore
66
+ output.isTTY = true
67
+ // @ts-ignore
68
+ output.cursorTo = function cursorTo (x, y, callback) {
69
+ readline = readline || require('readline')
70
+ // @ts-ignore
71
+ readline.cursorTo(this, x, y, callback)
72
+ }
73
+ // @ts-ignore
74
+ output.clearLine = function clearLine (dir, callback) {
75
+ readline = readline || require('readline')
76
+ // @ts-ignore
77
+ readline.clearLine(this, dir, callback)
78
+ }
79
+ mutexFn(hasInput ? input : null, hasOutput ? /** @type {Writable} */(output) : undefined, ipcColorLevel)
80
+ .then(resolve, reject)
81
+ .finally(() => {
82
+ conn.unref()
83
+ conn.end()
84
+ input?.end()
85
+ output?.end()
86
+ // process.exit(13)
87
+ })
88
+ }
89
+ }
90
+ } catch (e) {
91
+ reject(e)
92
+ }
93
+ })
94
+ })
95
+ }
96
+ }
97
+ }
98
+ /**
99
+ * @type {Array<{resolve(): void}>}}
100
+ */
101
+ const pendingCaptures = []
102
+ let captured = false
103
+ const sock = path.join(require('os').tmpdir(), `socket-security-tty-${process.pid}.sock`)
104
+ process.env['SOCKET_SECURITY_TTY_IPC'] = sock
105
+ try {
106
+ await require('fs/promises').unlink(sock)
107
+ } catch (e) {
108
+ if (isErrnoException(e) && e.code !== 'ENOENT') {
109
+ throw e
110
+ }
111
+ }
112
+ const input = isSTDINInteractive ? process.stdin : null
113
+ const output = process.stderr
114
+ if (input) {
115
+ await new Promise((resolve, reject) => {
116
+ const server = net.createServer(async (conn) => {
117
+ if (captured) {
118
+ const captured = new Promise((resolve) => {
119
+ pendingCaptures.push({
120
+ resolve () {
121
+ resolve(undefined)
122
+ }
123
+ })
124
+ })
125
+ await captured
126
+ } else {
127
+ captured = true
128
+ }
129
+ const wasProgressEnabled = npmlog.progressEnabled
130
+ npmlog.pause()
131
+ if (wasProgressEnabled) {
132
+ npmlog.disableProgress()
133
+ }
134
+ conn.write(`${JSON.stringify({
135
+ ipc_version,
136
+ capabilities: {
137
+ input: Boolean(input),
138
+ output: true,
139
+ colorLevel
140
+ }
141
+ })}\n`)
142
+ conn.on('data', (data) => {
143
+ output.write(data)
144
+ })
145
+ conn.on('error', (e) => {
146
+ output.write(`there was an error prompting from a subshell (${e.message}), socket npm closing`)
147
+ process.exit(1)
148
+ })
149
+ input.on('data', (data) => {
150
+ conn.write(data)
151
+ })
152
+ input.on('end', () => {
153
+ conn.unref()
154
+ conn.end()
155
+ if (wasProgressEnabled) {
156
+ npmlog.enableProgress()
157
+ }
158
+ npmlog.resume()
159
+ nextCapture()
160
+ })
161
+ }).listen(sock, () => resolve(server)).on('error', (err) => {
162
+ reject(err)
163
+ }).unref()
164
+ process.on('exit', () => {
165
+ server.close()
166
+ try {
167
+ require('fs').unlinkSync(sock)
168
+ } catch (e) {
169
+ if (isErrnoException(e) && e.code !== 'ENOENT') {
170
+ throw e
171
+ }
172
+ }
173
+ })
174
+ resolve(server)
175
+ })
176
+ }
177
+ /**
178
+ *
179
+ */
180
+ function nextCapture () {
181
+ if (pendingCaptures.length > 0) {
182
+ const nextCapture = pendingCaptures.shift()
183
+ if (nextCapture) {
184
+ nextCapture.resolve()
185
+ }
186
+ } else {
187
+ captured = false
188
+ }
189
+ }
190
+ return {
191
+ async captureTTY (mutexFn) {
192
+ if (captured) {
193
+ const captured = new Promise((resolve) => {
194
+ pendingCaptures.push({
195
+ resolve () {
196
+ resolve(undefined)
197
+ }
198
+ })
199
+ })
200
+ await captured
201
+ } else {
202
+ captured = true
203
+ }
204
+ const wasProgressEnabled = npmlog.progressEnabled
205
+ try {
206
+ npmlog.pause()
207
+ if (wasProgressEnabled) {
208
+ npmlog.disableProgress()
209
+ }
210
+ // need await here for proper finally timing
211
+ return await mutexFn(input, output, colorLevel)
212
+ } finally {
213
+ if (wasProgressEnabled) {
214
+ npmlog.enableProgress()
215
+ }
216
+ npmlog.resume()
217
+ nextCapture()
218
+ }
219
+ }
220
+ }
221
+ }
@@ -25,18 +25,16 @@ export function handleUnsuccessfulApiResponse (_name, result, spinner) {
25
25
  /**
26
26
  * @template T
27
27
  * @param {Promise<T>} value
28
- * @param {import('ora').Ora} spinner
29
28
  * @param {string} description
30
29
  * @returns {Promise<T>}
31
30
  */
32
- export async function handleApiCall (value, spinner, description) {
31
+ export async function handleApiCall (value, description) {
33
32
  /** @type {T} */
34
33
  let result
35
34
 
36
35
  try {
37
36
  result = await value
38
37
  } catch (cause) {
39
- spinner.fail()
40
38
  throw new ErrorWithCause(`Failed ${description}`, { cause })
41
39
  }
42
40
 
@@ -0,0 +1,180 @@
1
+ //#region UX Constants
2
+ /**
3
+ * @typedef {{block: boolean, display: boolean}} RuleActionUX
4
+ */
5
+ const IGNORE_UX = {
6
+ block: false,
7
+ display: false
8
+ }
9
+ const WARN_UX = {
10
+ block: false,
11
+ display: true
12
+ }
13
+ const ERROR_UX = {
14
+ block: true,
15
+ display: true
16
+ }
17
+ //#endregion
18
+ //#region utils
19
+ /**
20
+ * @typedef { NonNullable<NonNullable<NonNullable<(Awaited<ReturnType<import('@socketsecurity/sdk').SocketSdk['postSettings']>> & {success: true})['data']['entries'][number]['settings'][string]>['issueRules']>>[string] | boolean } NonNormalizedIssueRule
21
+ */
22
+ /**
23
+ * @typedef { (NonNullable<NonNullable<(Awaited<ReturnType<import('@socketsecurity/sdk').SocketSdk['postSettings']>> & {success: true})['data']['defaults']['issueRules']>[string]> & { action: string }) | boolean } NonNormalizedResolvedIssueRule
24
+ */
25
+ /**
26
+ * Iterates over all entries with ordered issue rule for deferal
27
+ * Iterates over all issue rules and finds the first defined value that does not defer otherwise uses the defaultValue
28
+ * Takes the value and converts into a UX workflow
29
+ *
30
+ * @param {Iterable<Iterable<NonNormalizedIssueRule>>} entriesOrderedIssueRules
31
+ * @param {NonNormalizedResolvedIssueRule} defaultValue
32
+ * @returns {RuleActionUX}
33
+ */
34
+ function resolveIssueRuleUX (entriesOrderedIssueRules, defaultValue) {
35
+ if (defaultValue === true || defaultValue == null) {
36
+ defaultValue = {
37
+ action: 'error'
38
+ }
39
+ } else if (defaultValue === false) {
40
+ defaultValue = {
41
+ action: 'ignore'
42
+ }
43
+ }
44
+ let block = false
45
+ let display = false
46
+ let needDefault = true
47
+ iterate_entries:
48
+ for (const issueRuleArr of entriesOrderedIssueRules) {
49
+ for (const rule of issueRuleArr) {
50
+ if (issueRuleValueDoesNotDefer(rule)) {
51
+ // there was a rule, even if a defer, don't narrow to the default
52
+ needDefault = false
53
+ const narrowingFilter = uxForDefinedNonDeferValue(rule)
54
+ block = block || narrowingFilter.block
55
+ display = display || narrowingFilter.display
56
+ continue iterate_entries
57
+ }
58
+ }
59
+ // all rules defer, narrow
60
+ const narrowingFilter = uxForDefinedNonDeferValue(defaultValue)
61
+ block = block || narrowingFilter.block
62
+ display = display || narrowingFilter.display
63
+ }
64
+ if (needDefault) {
65
+ // no config set a
66
+ const narrowingFilter = uxForDefinedNonDeferValue(defaultValue)
67
+ block = block || narrowingFilter.block
68
+ display = display || narrowingFilter.display
69
+ }
70
+ return {
71
+ block,
72
+ display
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Negative form because it is narrowing the type
78
+ *
79
+ * @type {(issueRuleValue: NonNormalizedIssueRule) => issueRuleValue is NonNormalizedResolvedIssueRule}
80
+ */
81
+ function issueRuleValueDoesNotDefer (issueRule) {
82
+ if (issueRule === undefined) {
83
+ return false
84
+ } else if (typeof issueRule === 'object' && issueRule) {
85
+ const { action } = issueRule
86
+ if (action === undefined || action === 'defer') {
87
+ return false
88
+ }
89
+ }
90
+ return true
91
+ }
92
+
93
+ /**
94
+ * Handles booleans for backwards compatibility
95
+ *
96
+ * @param {NonNormalizedResolvedIssueRule} issueRuleValue
97
+ * @returns {RuleActionUX}
98
+ */
99
+ function uxForDefinedNonDeferValue (issueRuleValue) {
100
+ if (typeof issueRuleValue === 'boolean') {
101
+ return issueRuleValue ? ERROR_UX : IGNORE_UX
102
+ }
103
+ const { action } = issueRuleValue
104
+ if (action === 'warn') {
105
+ return WARN_UX
106
+ } else if (action === 'ignore') {
107
+ return IGNORE_UX
108
+ }
109
+ return ERROR_UX
110
+ }
111
+ //#endregion
112
+ //#region exports
113
+ module.exports = {
114
+ /**
115
+ *
116
+ * @param {(Awaited<ReturnType<import('@socketsecurity/sdk').SocketSdk['postSettings']>> & {success: true})['data']} settings
117
+ * @returns {(context: {package: {name: string, version: string}, issue: {type: string}}) => RuleActionUX}
118
+ */
119
+ createIssueUXLookup (settings) {
120
+ /**
121
+ * @type {Map<keyof (typeof settings.defaults.issueRules), RuleActionUX>}
122
+ */
123
+ const cachedUX = new Map()
124
+ return (context) => {
125
+ const key = context.issue.type
126
+ /**
127
+ * @type {RuleActionUX | undefined}
128
+ */
129
+ let ux = cachedUX.get(key)
130
+ if (ux) {
131
+ return ux
132
+ }
133
+ /**
134
+ * @type {Array<Array<NonNormalizedIssueRule>>}
135
+ */
136
+ const entriesOrderedIssueRules = []
137
+ for (const settingsEntry of settings.entries) {
138
+ /**
139
+ * @type {Array<NonNormalizedIssueRule>}
140
+ */
141
+ const orderedIssueRules = []
142
+ let target = settingsEntry.start
143
+ while (target !== null) {
144
+ const resolvedTarget = settingsEntry.settings[target]
145
+ if (!resolvedTarget) {
146
+ break
147
+ }
148
+ const issueRuleValue = resolvedTarget.issueRules?.[key]
149
+ if (typeof issueRuleValue !== 'undefined') {
150
+ orderedIssueRules.push(issueRuleValue)
151
+ }
152
+ target = resolvedTarget.deferTo ?? null
153
+ }
154
+ entriesOrderedIssueRules.push(orderedIssueRules)
155
+ }
156
+ const defaultValue = settings.defaults.issueRules[key]
157
+ /**
158
+ * @type {NonNormalizedResolvedIssueRule}
159
+ */
160
+ let resolvedDefaultValue = {
161
+ action: 'error'
162
+ }
163
+ // @ts-ignore backcompat, cover with tests
164
+ if (defaultValue === false) {
165
+ resolvedDefaultValue = {
166
+ action: 'ignore'
167
+ }
168
+ // @ts-ignore backcompat, cover with tests
169
+ } else if (defaultValue && defaultValue !== true) {
170
+ resolvedDefaultValue = {
171
+ action: defaultValue.action ?? 'error'
172
+ }
173
+ }
174
+ ux = resolveIssueRuleUX(entriesOrderedIssueRules, resolvedDefaultValue)
175
+ cachedUX.set(key, ux)
176
+ return ux
177
+ }
178
+ }
179
+ }
180
+ //#endregion
package/lib/utils/misc.js CHANGED
@@ -7,7 +7,7 @@ import { logSymbols } from './chalk-markdown.js'
7
7
  export function createDebugLogger (printDebugLogs) {
8
8
  return printDebugLogs
9
9
  // eslint-disable-next-line no-console
10
- ? (...params) => console.error(logSymbols.info, ...params)
10
+ ? /** @type { (...params: unknown[]) => void } */(...params) => console.error(logSymbols.info, ...params)
11
11
  : () => {}
12
12
  }
13
13
 
@@ -9,7 +9,7 @@ import micromatch from 'micromatch'
9
9
  import { ErrorWithCause } from 'pony-cause'
10
10
 
11
11
  import { InputError } from './errors.js'
12
- import { isErrnoException } from './type-helpers.js'
12
+ import { isErrnoException } from './type-helpers.cjs'
13
13
 
14
14
  /**
15
15
  * There are a lot of possible folders that we should not be looking in and "ignore-by-default" helps us with defining those
@@ -100,17 +100,32 @@ export async function mapGlobEntryToFiles (entry, supportedFiles) {
100
100
  let jsLockFiles = []
101
101
  /** @type {string[]} */
102
102
  let pyFiles = []
103
+ /** @type {string|undefined} */
104
+ let pkgGoFile
105
+ /** @type {string[]} */
106
+ let goExtraFiles = []
103
107
 
104
108
  const jsSupported = supportedFiles['npm'] || {}
105
109
  const jsLockFilePatterns = Object.keys(jsSupported)
106
110
  .filter(key => key !== 'packagejson')
107
111
  .map(key => /** @type {{ pattern: string }} */ (jsSupported[key]).pattern)
108
112
 
109
- const pyFilePatterns = Object.values(supportedFiles['pypi'] || {}).map(p => p.pattern)
113
+ const pyFilePatterns = Object.values(supportedFiles['pypi'] || {})
114
+ .map(p => /** @type {{ pattern: string }} */ (p).pattern)
115
+
116
+ const goSupported = supportedFiles['go'] || {}
117
+ const goSupplementalPatterns = Object.keys(goSupported)
118
+ .filter(key => key !== 'gomod')
119
+ .map(key => /** @type {{ pattern: string }} */ (goSupported[key]).pattern)
120
+
110
121
  if (entry.endsWith('/')) {
111
122
  // If the match is a folder and that folder contains a package.json file, then include it
112
- const filePath = path.resolve(entry, 'package.json')
113
- if (await fileExists(filePath)) pkgJSFile = filePath
123
+ const jsPkg = path.resolve(entry, 'package.json')
124
+ if (await fileExists(jsPkg)) pkgJSFile = jsPkg
125
+
126
+ const goPkg = path.resolve(entry, 'go.mod')
127
+ if (await fileExists(goPkg)) pkgGoFile = goPkg
128
+
114
129
  pyFiles = await globby(pyFilePatterns, {
115
130
  ...BASE_GLOBBY_OPTS,
116
131
  cwd: entry
@@ -125,6 +140,11 @@ export async function mapGlobEntryToFiles (entry, supportedFiles) {
125
140
  jsLockFiles = [entry]
126
141
  pkgJSFile = path.resolve(path.dirname(entry), 'package.json')
127
142
  if (!(await fileExists(pkgJSFile))) return []
143
+ } else if (entryFile === 'go.mod') {
144
+ pkgGoFile = entry
145
+ } else if (micromatch.isMatch(entryFile, goSupplementalPatterns)) {
146
+ goExtraFiles = [entry]
147
+ pkgGoFile = path.resolve(path.dirname(entry), 'go.mod')
128
148
  } else if (micromatch.isMatch(entryFile, pyFilePatterns)) {
129
149
  pyFiles = [entry]
130
150
  }
@@ -140,7 +160,19 @@ export async function mapGlobEntryToFiles (entry, supportedFiles) {
140
160
  })
141
161
  }
142
162
 
143
- return [...jsLockFiles, ...pyFiles].concat(pkgJSFile ? [pkgJSFile] : [])
163
+ if (!goExtraFiles.length && pkgGoFile) {
164
+ // get go.sum whenever possible
165
+ const pkgDir = path.dirname(pkgGoFile)
166
+
167
+ goExtraFiles = await globby(goSupplementalPatterns, {
168
+ ...BASE_GLOBBY_OPTS,
169
+ cwd: pkgDir
170
+ })
171
+ }
172
+
173
+ return [...jsLockFiles, ...pyFiles, ...goExtraFiles]
174
+ .concat(pkgJSFile ? [pkgJSFile] : [])
175
+ .concat(pkgGoFile ? [pkgGoFile] : [])
144
176
  }
145
177
 
146
178
  /**
package/lib/utils/sdk.js CHANGED
@@ -9,25 +9,37 @@ import prompts from 'prompts'
9
9
  import { AuthError } from './errors.js'
10
10
  import { getSetting } from './settings.js'
11
11
 
12
+ export const FREE_API_KEY = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
13
+
12
14
  /**
13
15
  * This API key should be stored globally for the duration of the CLI execution
14
16
  *
15
17
  * @type {string | undefined}
16
18
  */
17
- let sessionAPIKey
19
+ let defaultKey
18
20
 
19
- /** @returns {Promise<import('@socketsecurity/sdk').SocketSdk>} */
20
- export async function setupSdk () {
21
- let apiKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || sessionAPIKey
21
+ /** @returns {string | undefined} */
22
+ export function getDefaultKey () {
23
+ defaultKey = getSetting('apiKey') || process.env['SOCKET_SECURITY_API_KEY'] || defaultKey
24
+ return defaultKey
25
+ }
22
26
 
23
- if (!apiKey && isInteractive()) {
27
+ /**
28
+ * @param {string} [apiKey]
29
+ * @returns {Promise<import('@socketsecurity/sdk').SocketSdk>}
30
+ */
31
+ export async function setupSdk (apiKey = getDefaultKey()) {
32
+ if (apiKey == null && isInteractive()) {
33
+ /**
34
+ * @type {{ apiKey: string }}
35
+ */
24
36
  const input = await prompts({
25
37
  type: 'password',
26
38
  name: 'apiKey',
27
- message: 'Enter your Socket.dev API key (not saved)',
39
+ message: 'Enter your Socket.dev API key (not saved, use socket login to persist)',
28
40
  })
29
41
 
30
- apiKey = sessionAPIKey = input.apiKey
42
+ apiKey = defaultKey = input.apiKey
31
43
  }
32
44
 
33
45
  if (!apiKey) {
@@ -19,7 +19,11 @@ if (!dataHome) {
19
19
 
20
20
  const settingsPath = path.join(dataHome, 'socket', 'settings')
21
21
 
22
- /** @type {{apiKey?: string | null}} */
22
+ /**
23
+ * @typedef {Record<string, boolean | {action: 'error' | 'warn' | 'ignore' | 'defer'}>} IssueRules
24
+ */
25
+
26
+ /** @type {{apiKey?: string | null, enforcedOrgs?: string[] | null}} */
23
27
  let settings = {}
24
28
 
25
29
  if (fs.existsSync(settingsPath)) {
@@ -42,6 +46,8 @@ export function getSetting (key) {
42
46
  return settings[key]
43
47
  }
44
48
 
49
+ let pendingSave = false
50
+
45
51
  /**
46
52
  * @template {keyof typeof settings} Key
47
53
  * @param {Key} key
@@ -50,8 +56,14 @@ export function getSetting (key) {
50
56
  */
51
57
  export function updateSetting (key, value) {
52
58
  settings[key] = value
53
- fs.writeFileSync(
54
- settingsPath,
55
- Buffer.from(JSON.stringify(settings)).toString('base64')
56
- )
59
+ if (!pendingSave) {
60
+ pendingSave = true
61
+ process.nextTick(() => {
62
+ pendingSave = false
63
+ fs.writeFileSync(
64
+ settingsPath,
65
+ Buffer.from(JSON.stringify(settings)).toString('base64')
66
+ )
67
+ })
68
+ }
57
69
  }
@@ -2,7 +2,7 @@
2
2
  * @param {unknown} value
3
3
  * @returns {value is NodeJS.ErrnoException}
4
4
  */
5
- export function isErrnoException (value) {
5
+ exports.isErrnoException = function isErrnoException (value) {
6
6
  if (!(value instanceof Error)) {
7
7
  return false
8
8
  }
@@ -6,6 +6,9 @@ import updateNotifier from 'update-notifier'
6
6
  export function initUpdateNotifier () {
7
7
  readFile(new URL('../../package.json', import.meta.url), 'utf8')
8
8
  .then(rawPkg => {
9
+ /**
10
+ * @type {Exclude<Parameters<typeof updateNotifier>[0], undefined>['pkg']}
11
+ */
9
12
  const pkg = JSON.parse(rawPkg)
10
13
  updateNotifier({ pkg }).notify()
11
14
  })