npq 3.8.0 → 3.9.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.
- package/.github/npq.mov +0 -0
- package/.github/npq.mp4 +0 -0
- package/.github/npq.png +0 -0
- package/.github/workflows/main.yml +2 -2
- package/README.md +18 -9
- package/__tests__/__fixtures__/test.marshall.js +5 -6
- package/__tests__/cli.test.js +110 -0
- package/__tests__/cliPrompt.test.js +409 -0
- package/__tests__/marshalls.base.test.js +1 -1
- package/__tests__/marshalls.classMethods.test.js +11 -14
- package/__tests__/marshalls.expiredDomains.test.js +2 -2
- package/__tests__/marshalls.provenance.test.js +181 -4
- package/__tests__/marshalls.repo.test.js +6 -6
- package/__tests__/marshalls.signatures.test.js +0 -4
- package/__tests__/marshalls.snyk.test.js +294 -0
- package/__tests__/marshalls.tasks.test.js +48 -24
- package/__tests__/marshalls.typosquatting.test.js +16 -9
- package/__tests__/marshalls.version-maturity.test.js +6 -6
- package/__tests__/packageRepoUtils.test.js +4 -4
- package/__tests__/reportResults.test.js +772 -0
- package/__tests__/scripts.test.js +2 -2
- package/bin/npq-hero.js +70 -20
- package/bin/npq.js +113 -21
- package/lib/cli.js +127 -24
- package/lib/helpers/cliPrompt.js +97 -0
- package/lib/helpers/cliSpinner.js +104 -0
- package/lib/helpers/cliSupportHandler.js +50 -7
- package/lib/helpers/packageRepoUtils.js +7 -2
- package/lib/helpers/promiseThrottler.js +96 -0
- package/lib/helpers/reportResults.js +304 -0
- package/lib/helpers/sourcePackages.js +36 -0
- package/lib/marshall.js +42 -65
- package/lib/marshalls/age.marshall.js +4 -3
- package/lib/marshalls/author.marshall.js +38 -9
- package/lib/marshalls/baseMarshall.js +9 -17
- package/lib/marshalls/deprecation.marshall.js +43 -0
- package/lib/marshalls/downloads.marshall.js +3 -3
- package/lib/marshalls/expiredDomains.marshall.js +7 -5
- package/lib/marshalls/index.js +110 -80
- package/lib/marshalls/license.marshall.js +3 -3
- package/lib/marshalls/newbin.marshall.js +1 -2
- package/lib/marshalls/provenance.marshall.js +152 -33
- package/lib/marshalls/repo.marshall.js +8 -7
- package/lib/marshalls/scripts.marshall.js +1 -1
- package/lib/marshalls/signatures.marshall.js +26 -2
- package/lib/marshalls/snyk.marshall.js +61 -15
- package/lib/marshalls/typosquatting.marshall.js +8 -5
- package/lib/marshalls/version-maturity.marshall.js +4 -4
- package/lib/marshallsDecomissioned/readme.marshall.js +2 -2
- package/package.json +16 -19
- package/scripts/postinstall.js +10 -11
- package/lib/cliCommons.js +0 -80
|
@@ -2,29 +2,26 @@ beforeEach(() => {
|
|
|
2
2
|
jest.resetModules()
|
|
3
3
|
})
|
|
4
4
|
|
|
5
|
-
jest.mock('glob', () => {
|
|
6
|
-
return {
|
|
7
|
-
glob: jest.fn().mockResolvedValue(['file1', 'file2'])
|
|
8
|
-
}
|
|
9
|
-
})
|
|
10
|
-
|
|
11
5
|
test('collecting marshalls resolves with files array', async () => {
|
|
12
6
|
const marshalls = require('../lib/marshalls')
|
|
13
7
|
|
|
14
8
|
const marshallFiles = await marshalls.collectMarshalls()
|
|
15
|
-
expect(marshallFiles).
|
|
9
|
+
expect(marshallFiles.length).toBeGreaterThan(0)
|
|
10
|
+
expect(marshallFiles.every((file) => file.endsWith('.marshall.js'))).toBe(true)
|
|
16
11
|
})
|
|
17
12
|
|
|
18
|
-
test('collecting marshalls
|
|
13
|
+
test('collecting marshalls handles directory read errors', async () => {
|
|
14
|
+
const fs = require('node:fs')
|
|
19
15
|
const marshalls = require('../lib/marshalls')
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
// Mock fs.promises.readdir to throw an error
|
|
18
|
+
const originalReaddir = fs.promises.readdir
|
|
19
|
+
fs.promises.readdir = jest.fn().mockRejectedValue(new Error('Directory read error'))
|
|
20
|
+
|
|
21
|
+
await expect(marshalls.collectMarshalls()).rejects.toThrow('Directory read error')
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
// Restore original function
|
|
24
|
+
fs.promises.readdir = originalReaddir
|
|
28
25
|
})
|
|
29
26
|
|
|
30
27
|
test('build marshalls without any should throw error', () => {
|
|
@@ -43,7 +43,7 @@ describe('Expired domains test suites', () => {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
await expect(testMarshall.validate(pkgData)).rejects.toThrow(
|
|
46
|
-
/
|
|
46
|
+
/Detected expired domain can be abused for account takeover/
|
|
47
47
|
)
|
|
48
48
|
})
|
|
49
49
|
|
|
@@ -69,7 +69,7 @@ describe('Expired domains test suites', () => {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
await expect(testMarshall.validate(pkgData)).rejects.toThrow(
|
|
72
|
-
/
|
|
72
|
+
/Detected expired domain can be abused for account takeover: <unknown>/
|
|
73
73
|
)
|
|
74
74
|
})
|
|
75
75
|
|
|
@@ -140,9 +140,10 @@ describe('Provenance test suites', () => {
|
|
|
140
140
|
}
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
// We assert that the validate method
|
|
144
|
-
//
|
|
145
|
-
|
|
143
|
+
// We assert that the validate method throws an error,
|
|
144
|
+
// but we can't assert the exact error message because we don't log
|
|
145
|
+
// it unless debugging is enabled
|
|
146
|
+
await expect(testMarshall.validate(pkg)).rejects.toThrow()
|
|
146
147
|
|
|
147
148
|
// Assert that the fetch method is called with the correct URL
|
|
148
149
|
// eslint-disable-next-line no-undef
|
|
@@ -196,11 +197,187 @@ describe('Provenance test suites', () => {
|
|
|
196
197
|
// We assert that the validate method didn't throw an error,
|
|
197
198
|
// because the keys match the signature
|
|
198
199
|
await expect(testMarshall.validate(pkg)).rejects.toThrow(
|
|
199
|
-
'the package was published without any attestations
|
|
200
|
+
'Unable to verify provenance: the package was published without any attestations'
|
|
200
201
|
)
|
|
201
202
|
|
|
202
203
|
// Assert that the fetch method is called with the correct URL
|
|
203
204
|
// eslint-disable-next-line no-undef
|
|
204
205
|
expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/-/npm/v1/keys')
|
|
205
206
|
})
|
|
207
|
+
|
|
208
|
+
test.skip('should throw error when provenance regression is detected', async () => {
|
|
209
|
+
// Mock fetch to return registry keys
|
|
210
|
+
const mockResponse = {
|
|
211
|
+
json: jest.fn().mockResolvedValue({
|
|
212
|
+
keys: [{ key: 'publicKey1' }]
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
global.fetch = jest.fn().mockResolvedValue(mockResponse)
|
|
216
|
+
|
|
217
|
+
const pkg = {
|
|
218
|
+
packageName: 'test-package',
|
|
219
|
+
packageVersion: '2.0.0'
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Mock pacote.manifest to simulate:
|
|
223
|
+
// - version 1.0.0 has attestations (older version with provenance)
|
|
224
|
+
// - version 2.0.0 has no attestations (newer version without provenance)
|
|
225
|
+
pacote.manifest = jest
|
|
226
|
+
.fn()
|
|
227
|
+
.mockImplementationOnce((manifest) => {
|
|
228
|
+
if (manifest.includes('1.0.0')) {
|
|
229
|
+
return Promise.resolve({
|
|
230
|
+
name: 'test-package',
|
|
231
|
+
version: '1.0.0',
|
|
232
|
+
_attestations: { provenance: 'test' }
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
return Promise.reject(new Error('No attestations'))
|
|
236
|
+
})
|
|
237
|
+
.mockImplementationOnce((manifest) => {
|
|
238
|
+
if (manifest.includes('2.0.0')) {
|
|
239
|
+
return Promise.resolve({
|
|
240
|
+
name: 'test-package',
|
|
241
|
+
version: '2.0.0'
|
|
242
|
+
// No _attestations property
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
return Promise.reject(new Error('No attestations'))
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const testMarshall = new ProvenanceMarshall({
|
|
249
|
+
packageRepoUtils: {
|
|
250
|
+
getPackageInfo: () => {
|
|
251
|
+
return Promise.resolve({
|
|
252
|
+
name: 'test-package',
|
|
253
|
+
versions: {
|
|
254
|
+
'1.0.0': { name: 'test-package', version: '1.0.0' },
|
|
255
|
+
'2.0.0': { name: 'test-package', version: '2.0.0' }
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
},
|
|
259
|
+
parsePackageVersion: (version) => ({ version })
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
await expect(testMarshall.validate(pkg)).rejects.toThrow(
|
|
264
|
+
'Provenance regression detected: Previous version 1.0.0 had provenance attestations, but version 2.0.0 does not. This represents a security downgrade.'
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('should pass when no older versions exist', async () => {
|
|
269
|
+
const mockResponse = {
|
|
270
|
+
json: jest.fn().mockResolvedValue({
|
|
271
|
+
keys: [{ key: 'publicKey1' }]
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
global.fetch = jest.fn().mockResolvedValue(mockResponse)
|
|
275
|
+
|
|
276
|
+
const pkg = {
|
|
277
|
+
packageName: 'new-package',
|
|
278
|
+
packageVersion: '1.0.0'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pacote.manifest = jest.fn().mockResolvedValue({
|
|
282
|
+
name: 'new-package',
|
|
283
|
+
version: '1.0.0',
|
|
284
|
+
_attestations: { provenance: 'test' }
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const testMarshall = new ProvenanceMarshall({
|
|
288
|
+
packageRepoUtils: {
|
|
289
|
+
getPackageInfo: () => {
|
|
290
|
+
return Promise.resolve({
|
|
291
|
+
name: 'new-package',
|
|
292
|
+
versions: {
|
|
293
|
+
'1.0.0': { name: 'new-package', version: '1.0.0' }
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
},
|
|
297
|
+
parsePackageVersion: (version) => ({ version })
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const result = await testMarshall.validate(pkg)
|
|
302
|
+
expect(result).toBeDefined()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('should pass when no older versions had provenance', async () => {
|
|
306
|
+
const mockResponse = {
|
|
307
|
+
json: jest.fn().mockResolvedValue({
|
|
308
|
+
keys: [{ key: 'publicKey1' }]
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
global.fetch = jest.fn().mockResolvedValue(mockResponse)
|
|
312
|
+
|
|
313
|
+
const pkg = {
|
|
314
|
+
packageName: 'test-package',
|
|
315
|
+
packageVersion: '2.0.0'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Both versions lack attestations
|
|
319
|
+
pacote.manifest = jest.fn().mockResolvedValue({
|
|
320
|
+
name: 'test-package',
|
|
321
|
+
version: '2.0.0'
|
|
322
|
+
// No _attestations
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const testMarshall = new ProvenanceMarshall({
|
|
326
|
+
packageRepoUtils: {
|
|
327
|
+
getPackageInfo: () => {
|
|
328
|
+
return Promise.resolve({
|
|
329
|
+
name: 'test-package',
|
|
330
|
+
versions: {
|
|
331
|
+
'1.0.0': { name: 'test-package', version: '1.0.0' },
|
|
332
|
+
'2.0.0': { name: 'test-package', version: '2.0.0' }
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
},
|
|
336
|
+
parsePackageVersion: (version) => ({ version })
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
await expect(testMarshall.validate(pkg)).rejects.toThrow(
|
|
341
|
+
'the package was published without any attestations'
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('should pass when current version maintains provenance', async () => {
|
|
346
|
+
const mockResponse = {
|
|
347
|
+
json: jest.fn().mockResolvedValue({
|
|
348
|
+
keys: [{ key: 'publicKey1' }]
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
global.fetch = jest.fn().mockResolvedValue(mockResponse)
|
|
352
|
+
|
|
353
|
+
const pkg = {
|
|
354
|
+
packageName: 'test-package',
|
|
355
|
+
packageVersion: '2.0.0'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Both versions have attestations
|
|
359
|
+
pacote.manifest = jest.fn().mockResolvedValue({
|
|
360
|
+
name: 'test-package',
|
|
361
|
+
version: '2.0.0',
|
|
362
|
+
_attestations: { provenance: 'test' }
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const testMarshall = new ProvenanceMarshall({
|
|
366
|
+
packageRepoUtils: {
|
|
367
|
+
getPackageInfo: () => {
|
|
368
|
+
return Promise.resolve({
|
|
369
|
+
name: 'test-package',
|
|
370
|
+
versions: {
|
|
371
|
+
'1.0.0': { name: 'test-package', version: '1.0.0' },
|
|
372
|
+
'2.0.0': { name: 'test-package', version: '2.0.0' }
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
},
|
|
376
|
+
parsePackageVersion: (version) => ({ version })
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
const result = await testMarshall.validate(pkg)
|
|
381
|
+
expect(result).toBeDefined()
|
|
382
|
+
})
|
|
206
383
|
})
|
|
@@ -37,7 +37,7 @@ describe('Repo test suites', () => {
|
|
|
37
37
|
|
|
38
38
|
test('throws the right error when there is no pkg data available', async () => {
|
|
39
39
|
await expect(testMarshall.validate({ packageName: {} })).rejects.toThrow(
|
|
40
|
-
'
|
|
40
|
+
'The package has no associated repository or homepage.'
|
|
41
41
|
)
|
|
42
42
|
})
|
|
43
43
|
|
|
@@ -54,7 +54,7 @@ describe('Repo test suites', () => {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
await expect(testMarshall.validate(pkgData)).rejects.toThrow(
|
|
57
|
-
'
|
|
57
|
+
'The package has no associated repository or homepage.'
|
|
58
58
|
)
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -73,7 +73,7 @@ describe('Repo test suites', () => {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
await expect(testMarshall.validate(pkgData)).rejects.toThrow(
|
|
76
|
-
'
|
|
76
|
+
'The package has no associated repository or homepage.'
|
|
77
77
|
)
|
|
78
78
|
})
|
|
79
79
|
|
|
@@ -81,7 +81,7 @@ describe('Repo test suites', () => {
|
|
|
81
81
|
global.fetch = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('error')))
|
|
82
82
|
|
|
83
83
|
await expect(testMarshall.validate(fullPkgData)).rejects.toThrow(
|
|
84
|
-
'
|
|
84
|
+
'No valid repository is associated with the package'
|
|
85
85
|
)
|
|
86
86
|
})
|
|
87
87
|
|
|
@@ -91,7 +91,7 @@ describe('Repo test suites', () => {
|
|
|
91
91
|
fullPkgData.packageName.versions['1.0.0'].repository.url =
|
|
92
92
|
'https://dsfsdfsdfs.abcdeugwecwekjasda.com/'
|
|
93
93
|
await expect(testMarshall.validate(fullPkgData)).rejects.toThrow(
|
|
94
|
-
'
|
|
94
|
+
'The repository associated with the package (https://dsfsdfsdfs.abcdeugwecwekjasda.com/) does not exist or is unreachable at the moment.'
|
|
95
95
|
)
|
|
96
96
|
})
|
|
97
97
|
|
|
@@ -113,7 +113,7 @@ describe('Repo test suites', () => {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
await expect(testMarshall.validate(pkgData)).rejects.toThrow(
|
|
116
|
-
'
|
|
116
|
+
'The homepage associated with the package (homepage-url) does not exist or is unreachable at the moment.'
|
|
117
117
|
)
|
|
118
118
|
})
|
|
119
119
|
|
|
@@ -128,9 +128,5 @@ describe('Signature test suites', () => {
|
|
|
128
128
|
// We assert that the validate method didn't throw an error,
|
|
129
129
|
// because the keys match the signature
|
|
130
130
|
await expect(testMarshall.validate(pkg)).rejects.toThrow('mocked manifest error')
|
|
131
|
-
|
|
132
|
-
// Assert that the fetch method is called with the correct URL
|
|
133
|
-
// eslint-disable-next-line no-undef
|
|
134
|
-
expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/-/npm/v1/keys')
|
|
135
131
|
})
|
|
136
132
|
})
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Marshall = require('../lib/marshalls/snyk.marshall.js')
|
|
4
|
+
const { marshallCategories } = require('../lib/marshalls/constants')
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
|
|
9
|
+
jest.mock('fs')
|
|
10
|
+
jest.mock('os')
|
|
11
|
+
|
|
12
|
+
os.homedir.mockReturnValue('/fake/home')
|
|
13
|
+
fs.statSync.mockImplementation(() => {
|
|
14
|
+
throw new Error('File not found')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Mock fetch globally
|
|
18
|
+
global.fetch = jest.fn()
|
|
19
|
+
|
|
20
|
+
const mockPackageRepoUtils = {
|
|
21
|
+
getLatestVersion: jest.fn().mockResolvedValue('1.0.0')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Snyk Marshall', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
os.homedir.mockReturnValue('/fake/home')
|
|
27
|
+
fetch.mockClear()
|
|
28
|
+
mockPackageRepoUtils.getLatestVersion.mockClear()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('with Snyk token', () => {
|
|
32
|
+
const marshall = new Marshall({
|
|
33
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
34
|
+
})
|
|
35
|
+
// Manually set the token for this test suite
|
|
36
|
+
marshall.snykApiToken = 'fake-snyk-token'
|
|
37
|
+
|
|
38
|
+
it('has the right title', () => {
|
|
39
|
+
expect(marshall.title()).toBe('Checking for known vulnerabilities')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should throw an error if vulnerabilities are found', async () => {
|
|
43
|
+
fetch.mockResolvedValueOnce({
|
|
44
|
+
ok: true,
|
|
45
|
+
json: () =>
|
|
46
|
+
Promise.resolve({
|
|
47
|
+
vulnerabilities: [{ title: 'XSS' }, { title: 'CSRF' }],
|
|
48
|
+
isMaliciousPackage: false
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const pkg = { packageName: 'test-pkg', packageVersion: '1.0.0' }
|
|
53
|
+
await expect(marshall.validate(pkg)).rejects.toThrow(
|
|
54
|
+
'2 vulnerable path(s) found: https://snyk.io/vuln/npm:test-pkg'
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should throw a specific error for malicious packages', async () => {
|
|
59
|
+
fetch.mockResolvedValueOnce({
|
|
60
|
+
ok: true,
|
|
61
|
+
json: () =>
|
|
62
|
+
Promise.resolve({
|
|
63
|
+
vulnerabilities: [{ title: 'Malicious Package' }],
|
|
64
|
+
isMaliciousPackage: true
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const pkg = { packageName: 'malicious-pkg', packageVersion: '1.0.0' }
|
|
69
|
+
await expect(marshall.validate(pkg)).rejects.toThrow(
|
|
70
|
+
'Malicious package found: https://snyk.io/vuln/npm:malicious-pkg'
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should pass if no vulnerabilities are found', async () => {
|
|
75
|
+
fetch.mockResolvedValueOnce({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () =>
|
|
78
|
+
Promise.resolve({
|
|
79
|
+
vulnerabilities: [],
|
|
80
|
+
isMaliciousPackage: false
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const pkg = { packageName: 'clean-pkg', packageVersion: '1.0.0' }
|
|
85
|
+
await expect(marshall.validate(pkg)).resolves.not.toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should throw an error if the Snyk API request fails', async () => {
|
|
89
|
+
fetch.mockResolvedValueOnce({
|
|
90
|
+
ok: false,
|
|
91
|
+
status: 500
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const pkg = { packageName: 'test-pkg', packageVersion: '1.0.0' }
|
|
95
|
+
await expect(marshall.validate(pkg)).rejects.toThrow(
|
|
96
|
+
'Snyk API request failed with status 500'
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should throw an error if Snyk API provides no vulnerability info', async () => {
|
|
101
|
+
fetch.mockResolvedValueOnce({
|
|
102
|
+
ok: true,
|
|
103
|
+
json: () => Promise.resolve({ message: 'no vulns here' }) // No 'vulnerabilities' property
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const pkg = { packageName: 'test-pkg', packageVersion: '1.0.0' }
|
|
107
|
+
// This will cause getSnykVulnInfo to return `false`, which makes validate throw
|
|
108
|
+
await expect(marshall.validate(pkg)).rejects.toThrow(
|
|
109
|
+
'Unable to query vulnerabilities for packages'
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should use latest version if packageVersion is not defined', async () => {
|
|
114
|
+
fetch.mockResolvedValueOnce({
|
|
115
|
+
ok: true,
|
|
116
|
+
json: () =>
|
|
117
|
+
Promise.resolve({
|
|
118
|
+
vulnerabilities: [],
|
|
119
|
+
isMaliciousPackage: false
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const pkg = { packageName: 'test-pkg' }
|
|
124
|
+
await marshall.validate(pkg)
|
|
125
|
+
|
|
126
|
+
expect(mockPackageRepoUtils.getLatestVersion).toHaveBeenCalledWith('test-pkg')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('without Snyk token (fallback to OSV)', () => {
|
|
131
|
+
const marshall = new Marshall({
|
|
132
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
133
|
+
})
|
|
134
|
+
// Ensure no token is set
|
|
135
|
+
marshall.snykApiToken = null
|
|
136
|
+
|
|
137
|
+
it('should throw an error if vulnerabilities are found by OSV', async () => {
|
|
138
|
+
fetch.mockResolvedValueOnce({
|
|
139
|
+
ok: true,
|
|
140
|
+
json: () => Promise.resolve({ vulns: [{ id: 'OSV-1' }, { id: 'OSV-2' }] })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const pkg = { packageName: 'test-pkg-osv', packageVersion: '1.0.0' }
|
|
144
|
+
await expect(marshall.validate(pkg)).rejects.toThrow(
|
|
145
|
+
'2 vulnerabilities found by OSV for test-pkg-osv'
|
|
146
|
+
)
|
|
147
|
+
expect(fetch).toHaveBeenCalledWith('https://api.osv.dev/v1/query', expect.any(Object))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should pass if no vulnerabilities are found by OSV', async () => {
|
|
151
|
+
fetch.mockResolvedValueOnce({
|
|
152
|
+
ok: true,
|
|
153
|
+
json: () => Promise.resolve({ vulns: [] })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const pkg = { packageName: 'clean-pkg-osv', packageVersion: '1.0.0' }
|
|
157
|
+
await expect(marshall.validate(pkg)).resolves.not.toThrow()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('should handle an empty response from OSV', async () => {
|
|
161
|
+
fetch.mockResolvedValueOnce({
|
|
162
|
+
ok: true,
|
|
163
|
+
json: () => Promise.resolve({})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const pkg = { packageName: 'empty-res-pkg', packageVersion: '1.0.0' }
|
|
167
|
+
await expect(marshall.validate(pkg)).resolves.toEqual({
|
|
168
|
+
issuesCount: 0,
|
|
169
|
+
isMaliciousPackage: false
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should handle OSV API fetch error', async () => {
|
|
174
|
+
fetch.mockRejectedValueOnce(new Error('Network error'))
|
|
175
|
+
|
|
176
|
+
const pkg = { packageName: 'fetch-err-pkg', packageVersion: '1.0.0' }
|
|
177
|
+
const result = await marshall.validate(pkg)
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({ issuesCount: 0, isMaliciousPackage: false })
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('run method', () => {
|
|
184
|
+
const marshall = new Marshall({
|
|
185
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('should call checkPackage for each package', async () => {
|
|
189
|
+
const ctx = {
|
|
190
|
+
pkgs: [
|
|
191
|
+
{ packageName: 'pkg1', packageVersion: '1.0.0' },
|
|
192
|
+
{ packageName: 'pkg2', packageVersion: '2.0.0' }
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
const task = {}
|
|
196
|
+
marshall.checkPackage = jest.fn()
|
|
197
|
+
|
|
198
|
+
await marshall.run(ctx, task)
|
|
199
|
+
|
|
200
|
+
expect(marshall.checkPackage).toHaveBeenCalledTimes(2)
|
|
201
|
+
expect(marshall.checkPackage).toHaveBeenCalledWith(ctx.pkgs[0], ctx, task)
|
|
202
|
+
expect(marshall.checkPackage).toHaveBeenCalledWith(ctx.pkgs[1], ctx, task)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('getSnykToken', () => {
|
|
207
|
+
it('should return null if config file does not exist', () => {
|
|
208
|
+
fs.statSync.mockImplementation(() => {
|
|
209
|
+
throw new Error('ENOENT')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const marshall = new Marshall({
|
|
213
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
214
|
+
})
|
|
215
|
+
marshall.snykApiToken = null // ensure no token
|
|
216
|
+
const token = marshall.getSnykToken()
|
|
217
|
+
expect(token).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should return null if config file exists but has no token', () => {
|
|
221
|
+
fs.statSync.mockReturnValueOnce({ isFile: () => true })
|
|
222
|
+
jest.doMock('/fake/home/.config/configstore/snyk.json', () => ({}), { virtual: true })
|
|
223
|
+
|
|
224
|
+
const marshall = new Marshall({
|
|
225
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
226
|
+
})
|
|
227
|
+
marshall.snykApiToken = null // ensure no token
|
|
228
|
+
const token = marshall.getSnykToken()
|
|
229
|
+
expect(token).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should return the token if the config file exists and has a token', () => {
|
|
233
|
+
fs.statSync.mockImplementation(() => ({ isFile: () => true }))
|
|
234
|
+
fs.readFileSync.mockReturnValue(JSON.stringify({ api: 'a-real-token' }))
|
|
235
|
+
|
|
236
|
+
const marshall = new Marshall({
|
|
237
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
238
|
+
})
|
|
239
|
+
marshall.snykApiToken = null // ensure no token
|
|
240
|
+
const token = marshall.getSnykToken()
|
|
241
|
+
expect(token).toBe('a-real-token')
|
|
242
|
+
|
|
243
|
+
// Reset mock to avoid affecting other tests
|
|
244
|
+
fs.statSync.mockImplementation(() => {
|
|
245
|
+
throw new Error('File not found')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should return null if config file is malformed', () => {
|
|
250
|
+
fs.statSync.mockImplementation(() => ({ isFile: () => true }))
|
|
251
|
+
fs.readFileSync.mockReturnValue('not a valid json')
|
|
252
|
+
|
|
253
|
+
const marshall = new Marshall({
|
|
254
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
255
|
+
})
|
|
256
|
+
marshall.snykApiToken = null // ensure no token
|
|
257
|
+
const token = marshall.getSnykToken()
|
|
258
|
+
expect(token).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should return null if statSync returns falsy', () => {
|
|
262
|
+
fs.statSync.mockReturnValue(false)
|
|
263
|
+
|
|
264
|
+
const marshall = new Marshall({
|
|
265
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
266
|
+
})
|
|
267
|
+
marshall.snykApiToken = null // ensure no token
|
|
268
|
+
const token = marshall.getSnykToken()
|
|
269
|
+
expect(token).toBeNull()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('getSnykToken with env var', () => {
|
|
274
|
+
const OLD_ENV = process.env
|
|
275
|
+
|
|
276
|
+
beforeEach(() => {
|
|
277
|
+
jest.resetModules() // Most important - it clears the cache
|
|
278
|
+
process.env = { ...OLD_ENV } // Make a copy
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
afterAll(() => {
|
|
282
|
+
process.env = OLD_ENV // Restore old environment
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('should return token from SNYK_TOKEN environment variable', () => {
|
|
286
|
+
process.env.SNYK_TOKEN = 'token-from-env'
|
|
287
|
+
const MarshallWithEnv = require('../lib/marshalls/snyk.marshall.js')
|
|
288
|
+
const marshall = new MarshallWithEnv({
|
|
289
|
+
packageRepoUtils: mockPackageRepoUtils
|
|
290
|
+
})
|
|
291
|
+
expect(marshall.getSnykToken()).toBe('token-from-env')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
})
|