npq 3.13.2 → 3.13.3

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/README.md CHANGED
@@ -131,6 +131,7 @@ Example, to disable the Snyk vulnerability marshall:
131
131
  MARSHALL_DISABLE_SNYK=1 npq install express
132
132
  ```
133
133
 
134
+
134
135
  #### Available Marshall Environment Variables
135
136
 
136
137
  Here are all the available environment variable names for disabling specific marshalls:
@@ -0,0 +1,288 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const npa = require('npm-package-arg')
5
+ const sigstore = require('sigstore')
6
+ const ssri = require('ssri')
7
+
8
+ // Some really old packages have no time field so we need a cutoff date
9
+ const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
10
+
11
+ /**
12
+ * Helper class to interact with npm registry and verify package signatures/attestations
13
+ * This replaces pacote functionality for npq specific needs
14
+ */
15
+ class NpmRegistry {
16
+ constructor(opts = {}) {
17
+ this.opts = opts
18
+ this.registry = opts.registry || 'https://registry.npmjs.org'
19
+ }
20
+
21
+ /**
22
+ * Fetch package manifest from npm registry
23
+ * @param {string} packageSpec - Package name and version (e.g., 'express@4.18.2')
24
+ * @param {Object} options - Additional options
25
+ * @returns {Promise<Object>} Package manifest
26
+ */
27
+ async getManifest(packageSpec, options = {}) {
28
+ const spec = npa(packageSpec)
29
+ const escapedName = spec.escapedName
30
+ const packumentUrl = `${this.registry}/${escapedName}`
31
+
32
+ // Fetch packument (package metadata)
33
+ const response = await fetch(packumentUrl, {
34
+ headers: {
35
+ accept: 'application/json',
36
+ 'user-agent': 'npq-npm-registry-client',
37
+ ...(options.headers || {})
38
+ }
39
+ })
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`Failed to fetch package manifest: ${response.status} ${response.statusText}`)
43
+ }
44
+
45
+ const packument = await response.json()
46
+
47
+ // Pick the specific version
48
+ let version = spec.fetchSpec
49
+ if (version === 'latest' || !version || version === '*') {
50
+ version = packument['dist-tags']?.latest
51
+ }
52
+
53
+ if (!packument.versions || !packument.versions[version]) {
54
+ throw new Error(`Version ${version} not found for package ${spec.name}`)
55
+ }
56
+
57
+ const manifest = packument.versions[version]
58
+
59
+ // Add timing information if available
60
+ if (packument.time && packument.time[version]) {
61
+ manifest._time = packument.time[version]
62
+ }
63
+
64
+ return manifest
65
+ }
66
+
67
+ /**
68
+ * Verify package signatures using npm registry public keys
69
+ * @param {Object} manifest - Package manifest
70
+ * @param {Array} registryKeys - Public keys from npm registry
71
+ * @returns {Promise<Object>} Verified manifest with _signatures
72
+ */
73
+ async verifySignatures(manifest, registryKeys) {
74
+ if (!manifest.dist || !manifest.dist.signatures) {
75
+ throw new Error('Package has no signatures to verify')
76
+ }
77
+
78
+ const signatures = manifest.dist.signatures
79
+ const message = `${manifest._id}:${manifest.dist.integrity}`
80
+
81
+ for (const signature of signatures) {
82
+ const publicKey = registryKeys.find((key) => key.keyid === signature.keyid)
83
+
84
+ if (!publicKey) {
85
+ throw Object.assign(
86
+ new Error(
87
+ `${manifest._id} has a registry signature with keyid: ${signature.keyid} ` +
88
+ 'but no corresponding public key can be found'
89
+ ),
90
+ { code: 'EMISSINGSIGNATUREKEY' }
91
+ )
92
+ }
93
+
94
+ const publishedTime = Date.parse(manifest._time || MISSING_TIME_CUTOFF)
95
+ const validPublicKey = !publicKey.expires || publishedTime < Date.parse(publicKey.expires)
96
+
97
+ if (!validPublicKey) {
98
+ throw Object.assign(
99
+ new Error(
100
+ `${manifest._id} has a registry signature with keyid: ${signature.keyid} ` +
101
+ `but the corresponding public key has expired ${publicKey.expires}`
102
+ ),
103
+ { code: 'EEXPIREDSIGNATUREKEY' }
104
+ )
105
+ }
106
+
107
+ const verifier = crypto.createVerify('SHA256')
108
+ verifier.write(message)
109
+ verifier.end()
110
+
111
+ const valid = verifier.verify(publicKey.pemkey, signature.sig, 'base64')
112
+
113
+ if (!valid) {
114
+ throw Object.assign(
115
+ new Error(
116
+ `${manifest._id} has an invalid registry signature with ` +
117
+ `keyid: ${publicKey.keyid} and signature: ${signature.sig}`
118
+ ),
119
+ {
120
+ code: 'EINTEGRITYSIGNATURE',
121
+ keyid: publicKey.keyid,
122
+ signature: signature.sig,
123
+ resolved: manifest.dist.tarball,
124
+ integrity: manifest.dist.integrity
125
+ }
126
+ )
127
+ }
128
+ }
129
+
130
+ // Add verified signatures to manifest
131
+ return {
132
+ ...manifest,
133
+ _signatures: signatures
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Verify package attestations/provenance using sigstore
139
+ * @param {Object} manifest - Package manifest
140
+ * @param {Array} registryKeys - Public keys from npm registry
141
+ * @returns {Promise<Object>} Verified manifest with _attestations
142
+ */
143
+ async verifyAttestations(manifest, registryKeys) {
144
+ if (!manifest.dist || !manifest.dist.attestations) {
145
+ throw new Error('Package has no attestations to verify')
146
+ }
147
+
148
+ const attestationsPath = new URL(manifest.dist.attestations.url).pathname
149
+ const attestationsUrl = this.registry + attestationsPath
150
+
151
+ const response = await fetch(attestationsUrl, {
152
+ headers: {
153
+ accept: 'application/json',
154
+ 'user-agent': 'npq-npm-registry-client'
155
+ }
156
+ })
157
+
158
+ if (!response.ok) {
159
+ throw new Error(`Failed to fetch attestations: ${response.status} ${response.statusText}`)
160
+ }
161
+
162
+ const { attestations } = await response.json()
163
+
164
+ const bundles = attestations.map(({ predicateType, bundle }) => {
165
+ const statement = JSON.parse(
166
+ Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8')
167
+ )
168
+ const keyid = bundle.dsseEnvelope.signatures[0].keyid
169
+ const signature = bundle.dsseEnvelope.signatures[0].sig
170
+
171
+ return {
172
+ predicateType,
173
+ bundle,
174
+ statement,
175
+ keyid,
176
+ signature
177
+ }
178
+ })
179
+
180
+ const attestationKeyIds = bundles.map((b) => b.keyid).filter((k) => !!k)
181
+ const attestationRegistryKeys = registryKeys.filter((key) =>
182
+ attestationKeyIds.includes(key.keyid)
183
+ )
184
+
185
+ if (!attestationRegistryKeys.length) {
186
+ throw Object.assign(
187
+ new Error(
188
+ `${manifest._id} has attestations but no corresponding public key(s) can be found`
189
+ ),
190
+ { code: 'EMISSINGSIGNATUREKEY' }
191
+ )
192
+ }
193
+
194
+ for (const { predicateType, bundle, keyid, signature, statement } of bundles) {
195
+ const publicKey = attestationRegistryKeys.find((key) => key.keyid === keyid)
196
+
197
+ // Publish attestations have a keyid set and a valid public key must be found
198
+ if (keyid) {
199
+ if (!publicKey) {
200
+ throw Object.assign(
201
+ new Error(
202
+ `${manifest._id} has attestations with keyid: ${keyid} ` +
203
+ 'but no corresponding public key can be found'
204
+ ),
205
+ { code: 'EMISSINGSIGNATUREKEY' }
206
+ )
207
+ }
208
+
209
+ const integratedTime = new Date(
210
+ Number(bundle.verificationMaterial.tlogEntries[0].integratedTime) * 1000
211
+ )
212
+ const validPublicKey = !publicKey.expires || integratedTime < Date.parse(publicKey.expires)
213
+
214
+ if (!validPublicKey) {
215
+ throw Object.assign(
216
+ new Error(
217
+ `${manifest._id} has attestations with keyid: ${keyid} ` +
218
+ `but the corresponding public key has expired ${publicKey.expires}`
219
+ ),
220
+ { code: 'EEXPIREDSIGNATUREKEY' }
221
+ )
222
+ }
223
+ }
224
+
225
+ const subject = {
226
+ name: statement.subject[0].name,
227
+ sha512: statement.subject[0].digest.sha512
228
+ }
229
+
230
+ // Parse package spec to create PURL for comparison
231
+ const spec = npa(`${manifest.name}@${manifest.version}`)
232
+ const purl = npa.toPurl(spec)
233
+
234
+ // Verify the statement subject matches the package, version
235
+ if (subject.name !== purl) {
236
+ throw Object.assign(
237
+ new Error(
238
+ `${manifest._id} package name and version (PURL): ${purl} ` +
239
+ `doesn't match what was signed: ${subject.name}`
240
+ ),
241
+ { code: 'EATTESTATIONSUBJECT' }
242
+ )
243
+ }
244
+
245
+ // Verify the statement subject matches the tarball integrity
246
+ const integrityHexDigest = ssri.parse(manifest.dist.integrity).hexDigest()
247
+ if (subject.sha512 !== integrityHexDigest) {
248
+ throw Object.assign(
249
+ new Error(
250
+ `${manifest._id} package integrity (hex digest): ` +
251
+ `${integrityHexDigest} ` +
252
+ `doesn't match what was signed: ${subject.sha512}`
253
+ ),
254
+ { code: 'EATTESTATIONSUBJECT' }
255
+ )
256
+ }
257
+
258
+ try {
259
+ // Provenance attestations are signed with a signing certificate
260
+ // Publish attestations are signed with a keyid so we need to specify a public key
261
+ const options = {
262
+ keySelector: publicKey ? () => publicKey.pemkey : undefined
263
+ }
264
+ await sigstore.verify(bundle, options)
265
+ } catch (e) {
266
+ throw Object.assign(
267
+ new Error(`${manifest._id} failed to verify attestation: ${e.message}`),
268
+ {
269
+ code: 'EATTESTATIONVERIFY',
270
+ predicateType,
271
+ keyid,
272
+ signature,
273
+ resolved: manifest.dist.tarball,
274
+ integrity: manifest.dist.integrity
275
+ }
276
+ )
277
+ }
278
+ }
279
+
280
+ // Add verified attestations to manifest
281
+ return {
282
+ ...manifest,
283
+ _attestations: manifest.dist.attestations
284
+ }
285
+ }
286
+ }
287
+
288
+ module.exports = NpmRegistry
@@ -2,7 +2,7 @@
2
2
 
3
3
  // const semver = require('semver')
4
4
  const BaseMarshall = require('./baseMarshall')
5
- const pacote = require('pacote')
5
+ const NpmRegistry = require('../helpers/npmRegistry')
6
6
  const Warning = require('../helpers/warning')
7
7
  const { marshallCategories } = require('./constants')
8
8
 
@@ -21,77 +21,64 @@ class Marshall extends BaseMarshall {
21
21
 
22
22
  validate(pkg) {
23
23
  const validationMetadata = {}
24
+ const npmRegistry = new NpmRegistry({
25
+ registry: 'https://registry.npmjs.org'
26
+ })
27
+
28
+ return this.packageRepoUtils
29
+ .getPackageInfo(pkg.packageName)
30
+ .then((packageInfo) => {
31
+ const packageName = packageInfo.name
32
+ const packageVersion =
33
+ pkg.packageVersion === 'latest'
34
+ ? packageInfo['dist-tags'] && packageInfo['dist-tags'].latest
35
+ : this.packageRepoUtils.parsePackageVersion(pkg.packageVersion).version
36
+
37
+ validationMetadata.name = packageName
38
+ validationMetadata.version = packageVersion
39
+ validationMetadata.packageInfo = packageInfo
40
+
41
+ if (!validationMetadata.version) {
42
+ throw new Error('Unable to find version or dist-tag for package')
43
+ }
44
+ })
45
+ .then(() => {
46
+ return this.fetchRegistryKeys()
47
+ })
48
+ .then((keys) => {
49
+ return npmRegistry
50
+ .getManifest(`${validationMetadata.name}@${validationMetadata.version}`)
51
+ .then((manifest) => npmRegistry.verifyAttestations(manifest, keys))
52
+ })
53
+ .then((metadata) => {
54
+ if (!metadata || !metadata._attestations) {
55
+ throw new Warning('the package was published without any attestations')
56
+ }
24
57
 
25
- // removed debug log
26
-
27
- return (
28
- this.packageRepoUtils
29
- .getPackageInfo(pkg.packageName)
30
- .then((packageInfo) => {
31
- const packageName = packageInfo.name
32
- const packageVersion =
33
- pkg.packageVersion === 'latest'
34
- ? packageInfo['dist-tags'] && packageInfo['dist-tags'].latest
35
- : this.packageRepoUtils.parsePackageVersion(pkg.packageVersion).version
36
-
37
- validationMetadata.name = packageName
38
- validationMetadata.version = packageVersion
39
- validationMetadata.packageInfo = packageInfo
40
-
41
- if (!validationMetadata.version) {
42
- throw new Error('Unable to find version or dist-tag for package')
43
- }
44
- })
45
- // Disabled for now, see notes in function due to slow performance for
46
- // running this check
47
- // .then(() => {
48
- // return this.checkProvenanceRegression(validationMetadata)
49
- // })
50
- .then(() => {
51
- return this.fetchRegistryKeys()
52
- })
53
- .then((keys) => {
54
- // @TODO currently we're hardcoding the official npm registry
55
- // this should however allow for local proxies and other registries
56
- return pacote.manifest(`${validationMetadata.name}@${validationMetadata.version}`, {
57
- verifyAttestations: true,
58
- registry: 'https://registry.npmjs.org',
59
-
60
- '//registry.npmjs.org/:_keys': keys
61
- })
62
- })
63
- .then((metadata) => {
64
- if (!metadata || !metadata._attestations) {
65
- throw new Warning('the package was published without any attestations')
66
- }
67
-
68
- const attestations = metadata._attestations
69
-
70
- return attestations
71
- })
72
- .catch((error) => {
73
- // We can ignore this type of error, false positive
74
- // See: https://github.com/lirantal/npq/issues/329
75
- if (
76
- error.code === 'EATTESTATIONVERIFY' &&
77
- error.message.includes('malformed checkpoint')
78
- ) {
79
- return []
58
+ const attestations = metadata._attestations
59
+ return attestations
60
+ })
61
+ .catch((error) => {
62
+ // We can ignore this type of error, false positive
63
+ // See: https://github.com/lirantal/npq/issues/329
64
+ if (error.code === 'EATTESTATIONVERIFY' && error.message.includes('malformed checkpoint')) {
65
+ return []
66
+ } else {
67
+ if (error.message.includes('Package has no attestations to verify')) {
68
+ throw new Warning(
69
+ `Unable to verify provenance: the package was published without any attestations`
70
+ )
80
71
  } else {
81
- if (error.message.includes('package was published without any attestations')) {
82
- throw new Warning(`Unable to verify provenance: ${error.message}`)
83
- } else {
84
- this.debug(
85
- '\nUnable to verify provenance for package %s@%s: %s',
86
- validationMetadata.name,
87
- validationMetadata.version,
88
- error.message
89
- )
90
- throw new Warning(`Unable to verify provenance`)
91
- }
72
+ this.debug(
73
+ '\nUnable to verify provenance for package %s@%s: %s',
74
+ validationMetadata.name,
75
+ validationMetadata.version,
76
+ error.message
77
+ )
78
+ throw new Warning(`Unable to verify provenance`)
92
79
  }
93
- })
94
- )
80
+ }
81
+ })
95
82
  }
96
83
 
97
84
  /*
@@ -2,7 +2,7 @@
2
2
 
3
3
  const BaseMarshall = require('./baseMarshall')
4
4
  const Warning = require('../helpers/warning')
5
- const pacote = require('pacote')
5
+ const NpmRegistry = require('../helpers/npmRegistry')
6
6
  const { marshallCategories } = require('./constants')
7
7
 
8
8
  const MARSHALL_NAME = 'signatures'
@@ -21,26 +21,21 @@ class Marshall extends BaseMarshall {
21
21
  }
22
22
 
23
23
  validate(pkg) {
24
- // @TODO currently we're hardcoding the official npm registry
25
- // this should however allow for local proxies and other registries
26
- // @TODO performance improvement: we should cache the registry keys
27
- // and not fetch them every time because they don't change between
28
- // requests
24
+ const npmRegistry = new NpmRegistry({
25
+ registry: 'https://registry.npmjs.org'
26
+ })
27
+
29
28
  return this.fetchRegistryKeys()
30
29
  .then((keys) => {
31
- return pacote.manifest(`${pkg.packageName}@${pkg.packageVersion}`, {
32
- verifySignatures: true,
33
- registry: 'https://registry.npmjs.org',
34
-
35
- '//registry.npmjs.org/:_keys': keys
36
- })
30
+ return npmRegistry
31
+ .getManifest(`${pkg.packageName}@${pkg.packageVersion}`)
32
+ .then((manifest) => npmRegistry.verifySignatures(manifest, keys))
37
33
  })
38
34
  .then((metadata) => {
39
35
  return metadata
40
36
  })
41
37
  .catch((error) => {
42
- // @TODO error.message that useful for rich debugging when enabled
43
-
38
+ // Handle specific error cases
44
39
  if (
45
40
  error.message &&
46
41
  error.message.includes('but the corresponding public key has expired')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npq",
3
- "version": "3.13.2",
3
+ "version": "3.13.3",
4
4
  "description": "marshall your npm/npm package installs with high quality and class 🎖",
5
5
  "bin": {
6
6
  "npq": "./bin/npq.js",
@@ -45,8 +45,9 @@
45
45
  "dependencies": {
46
46
  "fastest-levenshtein": "^1.0.16",
47
47
  "npm-package-arg": "^13.0.0",
48
- "pacote": "^21.0.0",
49
- "semver": "^7.7.2"
48
+ "semver": "^7.7.2",
49
+ "sigstore": "^3.1.0",
50
+ "ssri": "^12.0.0"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@commitlint/cli": "^19.8.1",