@socketsecurity/cli 0.4.2 → 0.5.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.
@@ -0,0 +1,369 @@
1
+ // THIS MUST BE CJS TO WORK WITH --require
2
+ /* eslint-disable no-console */
3
+ 'use strict'
4
+
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const https = require('https')
8
+ const events = require('events')
9
+ const rl = require('readline')
10
+ const oraPromise = import('ora')
11
+ const isInteractivePromise = import('is-interactive')
12
+ const chalkMarkdownPromise = import('../utils/chalk-markdown.js')
13
+
14
+ const pubToken = 'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
15
+
16
+ // shadow `npm` and `npx` to mitigate subshells
17
+ require('./link.cjs')(fs.realpathSync(path.join(__dirname, 'bin')), 'npm')
18
+
19
+ /**
20
+ * @param {string[]} pkgids
21
+ * @returns {AsyncGenerator<{eco: string, pkg: string, ver: string } & ({type: 'missing'} | {type: 'success', value: { issues: any[] }})>}
22
+ */
23
+ async function * batchScan (
24
+ pkgids
25
+ ) {
26
+ const query = {
27
+ packages: pkgids.map(pkgid => {
28
+ const delimiter = pkgid.lastIndexOf('@')
29
+ const name = pkgid.slice(0, delimiter)
30
+ const version = pkgid.slice(delimiter + 1)
31
+ return {
32
+ eco: 'npm', pkg: name, ver: version, top: true
33
+ }
34
+ })
35
+ }
36
+ const pkgDataReq = https.request(
37
+ 'https://api.socket.dev/v0/scan/batch',
38
+ {
39
+ method: 'POST',
40
+ headers: {
41
+ Authorization: `Basic ${Buffer.from(`${pubToken}:`).toString('base64url')}`
42
+ }
43
+ }
44
+ ).end(
45
+ JSON.stringify(query)
46
+ )
47
+ const [res] = await events.once(pkgDataReq, 'response')
48
+ const isSuccess = res.statusCode === 200
49
+ if (!isSuccess) {
50
+ throw new Error('Socket API Error: ' + res.statusCode)
51
+ }
52
+ const rli = rl.createInterface(res)
53
+ for await (const line of rli) {
54
+ const result = JSON.parse(line)
55
+ yield result
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @type {import('./translations.json') | null}
61
+ */
62
+ let translations = null
63
+ /**
64
+ * @type {import('../utils/chalk-markdown.js').ChalkOrMarkdown | null}
65
+ */
66
+ let formatter = null
67
+
68
+ const npmEntrypoint = fs.realpathSync(`${process.argv[1]}`)
69
+ /**
70
+ * @param {string} filepath
71
+ * @returns {string}
72
+ */
73
+ function findRoot (filepath) {
74
+ if (path.basename(filepath) === 'npm') {
75
+ return filepath
76
+ }
77
+ const parent = path.dirname(filepath)
78
+ if (parent === filepath) {
79
+ process.exit(127)
80
+ }
81
+ return findRoot(parent)
82
+ }
83
+ const npmDir = findRoot(path.dirname(npmEntrypoint))
84
+ const arboristLibClassPath = path.join(npmDir, 'node_modules', '@npmcli', 'arborist', 'lib', 'arborist', 'index.js')
85
+ /**
86
+ * @type {typeof import('@npmcli/arborist')}
87
+ */
88
+ const Arborist = require(arboristLibClassPath)
89
+
90
+ const kCtorArgs = Symbol('ctorArgs')
91
+ const kRiskyReify = Symbol('riskyReify')
92
+ class SafeArborist extends Arborist {
93
+ /**
94
+ * @param {ConstructorParameters<typeof Arborist>} ctorArgs
95
+ */
96
+ constructor (...ctorArgs) {
97
+ const mutedArguments = [{
98
+ ...(ctorArgs[0] ?? {}),
99
+ dryRun: true,
100
+ ignoreScripts: true,
101
+ save: false,
102
+ saveBundle: false,
103
+ audit: false,
104
+ // progress: false,
105
+ fund: false
106
+ }, ctorArgs.slice(1)]
107
+ super(...mutedArguments)
108
+ this[kCtorArgs] = ctorArgs
109
+ }
110
+
111
+ /**
112
+ * @param {Parameters<InstanceType<typeof Arborist>['reify']>} args
113
+ */
114
+ async [kRiskyReify] (...args) {
115
+ // safe arborist has suffered side effects and must be rebuilt from scratch
116
+ const arb = new Arborist(...this[kCtorArgs])
117
+ const ret = await arb.reify(...args)
118
+ Object.assign(this, arb)
119
+ return ret
120
+ }
121
+
122
+ /**
123
+ * @param {Parameters<InstanceType<typeof Arborist>['reify']>} args
124
+ * @override
125
+ */
126
+ async reify (...args) {
127
+ // @ts-expect-error types are wrong
128
+ if (args[0]?.dryRun) {
129
+ return this[kRiskyReify](...args)
130
+ }
131
+ args[0] ??= {}
132
+ const old = { ...args[0] }
133
+ // @ts-expect-error types are wrong
134
+ args[0].dryRun = true
135
+ args[0].save = false
136
+ args[0].saveBundle = false
137
+ // const originalDescriptors = Object.getOwnPropertyDescriptors(this)
138
+ // TODO: make this deal w/ any refactor to private fields by punching the class itself
139
+ await super.reify(...args)
140
+ const diff = gatherDiff(this)
141
+ // @ts-expect-error types are wrong
142
+ args[0].dryRun = old.dryRun
143
+ // @ts-expect-error types are wrong
144
+ args[0].save = old.save
145
+ // @ts-expect-error types are wrong
146
+ args[0].saveBundle = old.saveBundle
147
+ // nothing to check, mmm already installed?
148
+ if (diff.check.length === 0 && diff.unknowns.length === 0) {
149
+ return this[kRiskyReify](...args)
150
+ }
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))
164
+ })
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')
169
+ }
170
+ }
171
+ }
172
+ return this[kRiskyReify](...args)
173
+ } else {
174
+ await packagesHaveRiskyIssues(diff.check)
175
+ throw new Error('Socket npm Unable to prompt to accept risk, need TTY to do so')
176
+ }
177
+ }
178
+ }
179
+ // @ts-ignore
180
+ require.cache[arboristLibClassPath].exports = SafeArborist
181
+
182
+ /**
183
+ * @param {InstanceType<typeof Arborist>} arb
184
+ * @returns {{
185
+ * check: InstallEffect[],
186
+ * unknowns: InstallEffect[]
187
+ * }}
188
+ */
189
+ 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
+ }
211
+ }
212
+ /**
213
+ * @typedef InstallEffect
214
+ * @property {import('@npmcli/arborist').Diff['action']} action
215
+ * @property {import('@npmcli/arborist').Node['pkgid'] | null} existing
216
+ * @property {import('@npmcli/arborist').Node['pkgid']} pkgid
217
+ * @property {import('@npmcli/arborist').Node['resolved']} resolved
218
+ * @property {import('@npmcli/arborist').Node['location']} location
219
+ */
220
+ /**
221
+ * @param {import('@npmcli/arborist').Diff | null} diff
222
+ * @param {InstallEffect[]} needInfoOn
223
+ * @returns {InstallEffect[]}
224
+ */
225
+ function walk (diff, needInfoOn = []) {
226
+ if (!diff) {
227
+ return needInfoOn
228
+ }
229
+
230
+ if (diff.action) {
231
+ const sameVersion = diff.actual?.package.version === diff.ideal?.package.version
232
+ let keep = false
233
+ let existing = null
234
+ if (diff.action === 'CHANGE') {
235
+ if (!sameVersion) {
236
+ existing = diff.actual.pkgid
237
+ keep = true
238
+ } else {
239
+ // console.log('SKIPPING META CHANGE ON', diff)
240
+ }
241
+ } else {
242
+ keep = diff.action !== 'REMOVE'
243
+ }
244
+ if (keep) {
245
+ 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
+ })
253
+ }
254
+ }
255
+ }
256
+ if (diff.children) {
257
+ for (const child of diff.children) {
258
+ walk(child, needInfoOn)
259
+ }
260
+ }
261
+ return needInfoOn
262
+ }
263
+
264
+ /**
265
+ * @param {InstallEffect[]} pkgs
266
+ * @param {import('ora')['default'] | null} ora
267
+ * @returns {Promise<boolean>}
268
+ */
269
+ async function packagesHaveRiskyIssues (pkgs, ora = null) {
270
+ let failed = false
271
+ if (pkgs.length) {
272
+ let remaining = pkgs.length
273
+ /**
274
+ *
275
+ * @returns {string}
276
+ */
277
+ function getText () {
278
+ return `Looking up data for ${remaining} packages`
279
+ }
280
+ const spinner = ora ? ora(getText()).start() : null
281
+ const pkgDatas = []
282
+ try {
283
+ for await (const pkgData of batchScan(pkgs.map(pkg => pkg.pkgid))) {
284
+ let failures = []
285
+ if (pkgData.type === 'missing') {
286
+ failures.push({
287
+ type: 'missingDependency'
288
+ })
289
+ continue
290
+ }
291
+ for (const issue of (pkgData.value?.issues ?? [])) {
292
+ if ([
293
+ 'shellScriptOverride',
294
+ 'gitDependency',
295
+ 'httpDependency',
296
+ 'installScripts',
297
+ 'malware',
298
+ 'didYouMean',
299
+ 'hasNativeCode',
300
+ 'troll',
301
+ 'telemetry',
302
+ 'invalidPackageJSON',
303
+ 'unresolvedRequire',
304
+ ].includes(issue.type)) {
305
+ failures.push(issue)
306
+ }
307
+ }
308
+ // before we ask about problematic issues, check to see if they already existed in the old version
309
+ // if they did, be quiet
310
+ if (failures.length) {
311
+ const pkg = pkgs.find(pkg => pkg.pkgid === `${pkgData.pkg}@${pkgData.ver}` && pkg.existing?.startsWith(pkgData.pkg))
312
+ if (pkg?.existing) {
313
+ for await (const oldPkgData of batchScan([pkg.existing])) {
314
+ if (oldPkgData.type === 'success') {
315
+ failures = failures.filter(
316
+ issue => oldPkgData.value.issues.find(oldIssue => oldIssue.type === issue.type) == null
317
+ )
318
+ }
319
+ }
320
+ }
321
+ }
322
+ if (failures.length) {
323
+ failed = true
324
+ spinner?.stop()
325
+ translations ??= JSON.parse(fs.readFileSync(path.join(__dirname, '/translations.json'), 'utf-8'))
326
+ formatter ??= new ((await chalkMarkdownPromise).ChalkOrMarkdown)(false)
327
+ const name = pkgData.pkg
328
+ const version = pkgData.ver
329
+ console.error(`${formatter.hyperlink(`${name}@${version}`, `https://socket.dev/npm/package/${name}/overview/${version}`)} contains risks:`)
330
+ if (translations) {
331
+ for (const failure of failures) {
332
+ const type = failure.type
333
+ if (type) {
334
+ // @ts-ignore
335
+ const issueTypeTranslation = translations.issues[type]
336
+ // TODO: emoji seems to misalign terminals sometimes
337
+ // @ts-ignore
338
+ const msg = ` ${issueTypeTranslation.title} - ${issueTypeTranslation.description}`
339
+ console.error(msg)
340
+ }
341
+ }
342
+ }
343
+ spinner?.start()
344
+ } else {
345
+ // TODO: have pacote/cacache download non-problematic files while waiting
346
+ }
347
+ remaining--
348
+ if (remaining !== 0) {
349
+ if (spinner) {
350
+ spinner.text = getText()
351
+ }
352
+ } else {
353
+ spinner?.stop()
354
+ }
355
+ pkgDatas.push(pkgData)
356
+ }
357
+ return failed
358
+ } finally {
359
+ if (spinner?.isSpinning) {
360
+ spinner?.stop()
361
+ }
362
+ }
363
+ } else {
364
+ if (ora) {
365
+ ora('').succeed('No changes detected')
366
+ }
367
+ return false
368
+ }
369
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // THIS FILE USES .cjs to get around the extension-free entrypoint problem with ESM
3
+ 'use strict'
4
+ const { spawn } = require('child_process')
5
+ const { realpathSync } = require('fs')
6
+ const path = require('path')
7
+
8
+ const realFilename = realpathSync(__filename)
9
+ const realDirname = path.dirname(realFilename)
10
+
11
+ /**
12
+ */
13
+ async function main () {
14
+ const npxpath = await require('./link.cjs')(path.join(realDirname, 'bin'), 'npx')
15
+ process.exitCode = 1
16
+ const injectionpath = path.join(realDirname, 'npm-injection.cjs')
17
+ spawn(process.execPath, ['--require', injectionpath, npxpath, ...process.argv.slice(2)], {
18
+ stdio: 'inherit'
19
+ }).on('exit', (code, signal) => {
20
+ if (signal) {
21
+ process.kill(process.pid, signal)
22
+ } else if (code !== null) {
23
+ process.exit(code)
24
+ }
25
+ })
26
+ }
27
+ main()
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }