@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.
- package/lib/commands/index.js +2 -0
- package/lib/commands/info/index.js +9 -27
- package/lib/commands/npm/index.js +22 -0
- package/lib/commands/npx/index.js +22 -0
- package/lib/commands/report/create.js +26 -35
- package/lib/commands/report/view.js +9 -28
- package/lib/flags/index.js +2 -0
- package/lib/flags/output.js +16 -0
- package/lib/flags/validation.js +14 -0
- package/lib/shadow/bin/npm +2 -0
- package/lib/shadow/bin/npx +2 -0
- package/lib/shadow/global.d.ts +3 -0
- package/lib/shadow/link.cjs +43 -0
- package/lib/shadow/npm-cli.cjs +27 -0
- package/lib/shadow/npm-injection.cjs +369 -0
- package/lib/shadow/npx-cli.cjs +27 -0
- package/lib/shadow/package.json +3 -0
- package/lib/shadow/translations.json +689 -0
- package/lib/utils/api-helpers.js +3 -2
- package/lib/utils/flags.js +27 -0
- package/lib/utils/formatting.js +20 -9
- package/package.json +17 -7
|
@@ -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()
|