npq 3.17.0 → 3.19.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/README.md +2 -2
- package/lib/marshalls/author.marshall.js +81 -10
- package/lib/marshalls/provenance.marshall.js +97 -99
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
130
130
|
| Marshall Name | Description | Notes
|
|
131
131
|
| --- | --- | ---
|
|
132
132
|
| age | Will show a warning for a package if its age on npm is less than 22 days | Checks a package creation date, not a specific version
|
|
133
|
-
| author |
|
|
133
|
+
| author | Validates the resolved version’s publisher (`_npmUser`), flags a **new** maintainer on the package (first publish by that email within 21 days), **dormant maintainer** gaps (warning if over ~6 months since their last publish on that package, error if over ~9 months), and very **recent** publishes | See [docs/feature/author-marshall.md](docs/feature/author-marshall.md)
|
|
134
134
|
| downloads | Will show a warning for a package if its download count in the last month is less than 20
|
|
135
135
|
| readme | Will show a warning if a package has no README or it has been detected as a security placeholder package by npm staff
|
|
136
136
|
| repo | Will show a warning if a package has been found without a valid and working repository URL | Checks the latest version for a repository URL
|
|
@@ -139,7 +139,7 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
139
139
|
| license | Will show a warning if a package has been found without a license field | Checks the latest version for a license
|
|
140
140
|
| expired domains | Will show a warning if a package has been found with one of its maintainers having an email address that includes an expired domain | Checks a dependency version for a maintainer with an expired domain
|
|
141
141
|
| signatures | Will compare the package's signature as it shows on the registry's pakument with the keys published on the npmjs.com registry
|
|
142
|
-
| provenance | Will verify the package's attestations of provenance metadata for the published package
|
|
142
|
+
| provenance | Will verify the package's attestations of provenance metadata for the published package, and **error** on [provenance regression](docs/feature/provenance.md) (an older semver had registry provenance metadata but the version you install does not)
|
|
143
143
|
| version-maturity | Will show a warning if the specific version being installed was published less than 7 days ago | Helps identify recently published versions that may not have been reviewed by the community yet
|
|
144
144
|
| newBin | Will show a warning if the package version being installed introduces a new command-line binary (via the `bin` field in `package.json`) that was not present in its previous version. | Helps identify potentially unexpected new executables being added to your `node_modules/.bin/` directory.
|
|
145
145
|
| typosquatting | Will show a warning if the package name is similar to a popular package name, which could indicate a potential typosquatting attack. | Helps identify packages that may be trying to trick users into installing them by mimicking popular package names.
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Author Marshall - Package Publisher Security Checks
|
|
5
5
|
*
|
|
6
|
-
* This marshall performs
|
|
6
|
+
* This marshall performs three security checks related to package publishing:
|
|
7
7
|
*
|
|
8
|
-
* 1. NEW AUTHOR CHECK
|
|
8
|
+
* 1. NEW AUTHOR CHECK
|
|
9
9
|
* Detects if this is the first version ever published by this user for this package.
|
|
10
10
|
* - Condition: No prior versions exist from this user, OR the current version is their first.
|
|
11
11
|
* - Only flags if the version was published recently (≤21 days ago).
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
* compromise or malicious takeover. However, if the "first" version is old (e.g., 10 years),
|
|
14
14
|
* it's not a current risk.
|
|
15
15
|
*
|
|
16
|
-
* 2.
|
|
16
|
+
* 2. DORMANT MAINTAINER CHECK
|
|
17
|
+
* Detects if the same maintainer (_npmUser email) had a prior publish on this package,
|
|
18
|
+
* then a long gap before the current version.
|
|
19
|
+
* - Gap > ~9 months: Error
|
|
20
|
+
* - Gap > ~6 months (and ≤9 months): Warning
|
|
21
|
+
* - Strict boundaries: exactly 183 or 274 days does not cross the next tier.
|
|
22
|
+
* - Rationale: Long-inactive accounts publishing again can indicate compromise or neglected keys.
|
|
23
|
+
*
|
|
24
|
+
* 3. VERSION RECENCY CHECK
|
|
17
25
|
* Detects if the current version being installed was published very recently.
|
|
18
26
|
* - ≤7 days: Throws an Error (high risk)
|
|
19
27
|
* - ≤30 days: Throws a Warning (moderate risk)
|
|
@@ -21,9 +29,7 @@
|
|
|
21
29
|
* - Rationale: Very recently published versions haven't had time for community review
|
|
22
30
|
* and could contain undiscovered malicious code. This is independent of author history.
|
|
23
31
|
*
|
|
24
|
-
*
|
|
25
|
-
* - Check 1 focuses on WHO published (author trustworthiness)
|
|
26
|
-
* - Check 2 focuses on WHEN it was published (version maturity)
|
|
32
|
+
* Checks run in order (1 → 2 → 3); the first thrown Error or Warning ends validation.
|
|
27
33
|
*/
|
|
28
34
|
|
|
29
35
|
const BaseMarshall = require('./baseMarshall')
|
|
@@ -31,6 +37,44 @@ const Warning = require('../helpers/warning')
|
|
|
31
37
|
const { marshallCategories } = require('./constants')
|
|
32
38
|
|
|
33
39
|
const MARSHALL_NAME = 'author'
|
|
40
|
+
const DORMANT_MAINTAINER_WARNING_DAYS = Math.round(365.25 / 2) // ~6 months (183)
|
|
41
|
+
const DORMANT_MAINTAINER_ERROR_DAYS = Math.round(365.25 * 0.75) // ~9 months (274)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Latest publish time (ms) for `email` on this package strictly before `packageVersion`'s time.
|
|
45
|
+
* @returns {number|null}
|
|
46
|
+
*/
|
|
47
|
+
function findLastPriorPublishTimeMsForEmail(pakument, packageVersion, email) {
|
|
48
|
+
const currentTimeStr = pakument.time && pakument.time[packageVersion]
|
|
49
|
+
if (!currentTimeStr || typeof currentTimeStr !== 'string') {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
const currentMs = Date.parse(currentTimeStr)
|
|
53
|
+
if (Number.isNaN(currentMs)) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let bestMs = null
|
|
58
|
+
for (const v of Object.keys(pakument.versions || {})) {
|
|
59
|
+
const t = pakument.time && pakument.time[v]
|
|
60
|
+
if (!t || typeof t !== 'string') {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
const ver = pakument.versions[v]
|
|
64
|
+
if (!ver || !ver._npmUser || ver._npmUser.email !== email) {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const priorMs = Date.parse(t)
|
|
68
|
+
if (Number.isNaN(priorMs) || priorMs >= currentMs) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (bestMs === null || priorMs > bestMs) {
|
|
72
|
+
bestMs = priorMs
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return bestMs
|
|
77
|
+
}
|
|
34
78
|
|
|
35
79
|
class Marshall extends BaseMarshall {
|
|
36
80
|
constructor(options) {
|
|
@@ -46,17 +90,18 @@ class Marshall extends BaseMarshall {
|
|
|
46
90
|
/**
|
|
47
91
|
* Validates package author and version recency for security risks.
|
|
48
92
|
*
|
|
49
|
-
* Performs
|
|
93
|
+
* Performs three checks:
|
|
50
94
|
* 1. New Author Check: Flags if this is the user's first publish of this package
|
|
51
95
|
* AND the version was published within the last 21 days.
|
|
52
|
-
* 2.
|
|
96
|
+
* 2. Dormant Maintainer Check: Prior publish by same email with a long gap before this release.
|
|
97
|
+
* 3. Version Recency Check: Flags recently published versions regardless of author:
|
|
53
98
|
* - Error if ≤7 days old
|
|
54
99
|
* - Warning if ≤30 days old
|
|
55
100
|
*
|
|
56
101
|
* @param {Object} pkg - Package info with packageName and packageVersion
|
|
57
102
|
* @returns {string} The version's publish date string if all checks pass
|
|
58
|
-
* @throws {Error} If security risk is detected
|
|
59
|
-
* @throws {Warning} If moderate risk is detected
|
|
103
|
+
* @throws {Error} If security risk is detected
|
|
104
|
+
* @throws {Warning} If moderate risk is detected
|
|
60
105
|
*/
|
|
61
106
|
async validate(pkg) {
|
|
62
107
|
// @TODO move some of these utility functions about first package version
|
|
@@ -117,6 +162,32 @@ class Marshall extends BaseMarshall {
|
|
|
117
162
|
// )
|
|
118
163
|
}
|
|
119
164
|
|
|
165
|
+
const priorPublishMs = findLastPriorPublishTimeMsForEmail(
|
|
166
|
+
pakument,
|
|
167
|
+
packageVersion,
|
|
168
|
+
npmUser.email
|
|
169
|
+
)
|
|
170
|
+
if (priorPublishMs !== null && versionPublishedDateString) {
|
|
171
|
+
const currentMs = Date.parse(versionPublishedDateString)
|
|
172
|
+
if (!Number.isNaN(currentMs)) {
|
|
173
|
+
const gapMs = currentMs - priorPublishMs
|
|
174
|
+
let gapDays = 0
|
|
175
|
+
if (gapMs > 0) {
|
|
176
|
+
gapDays = Math.round(gapMs / (1000 * 60 * 60 * 24))
|
|
177
|
+
}
|
|
178
|
+
if (gapDays > DORMANT_MAINTAINER_ERROR_DAYS) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`The maintainer ${npmUser.name} <${npmUser.email}> had not published this package for ${gapDays} days before this release (more than 9 months dormant)`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
if (gapDays > DORMANT_MAINTAINER_WARNING_DAYS) {
|
|
184
|
+
throw new Warning(
|
|
185
|
+
`The maintainer ${npmUser.name} <${npmUser.email}> had not published this package for ${gapDays} days before this release (more than 6 months dormant)`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
120
191
|
// get date in ms
|
|
121
192
|
const dateDiffInMsVersionPublished = new Date() - new Date(versionPublishedDateString)
|
|
122
193
|
let dateDiffVersionPublished = 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const semver = require('semver')
|
|
4
4
|
const BaseMarshall = require('./baseMarshall')
|
|
5
5
|
const NpmRegistry = require('../helpers/npmRegistry')
|
|
6
6
|
const Warning = require('../helpers/warning')
|
|
@@ -8,6 +8,62 @@ const { marshallCategories } = require('./constants')
|
|
|
8
8
|
|
|
9
9
|
const MARSHALL_NAME = 'provenance'
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Newest semver older than installedVersion whose packument entry includes dist.attestations.
|
|
13
|
+
* Uses only packageInfo already returned by getPackageInfo (no extra HTTP).
|
|
14
|
+
*/
|
|
15
|
+
function findNewestPriorVersionWithDistAttestations(packageInfo, installedVersion) {
|
|
16
|
+
if (!packageInfo?.versions || !semver.valid(installedVersion)) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const older = Object.keys(packageInfo.versions)
|
|
21
|
+
.filter((v) => semver.valid(v) && semver.lt(v, installedVersion))
|
|
22
|
+
.sort(semver.rcompare)
|
|
23
|
+
|
|
24
|
+
for (const v of older) {
|
|
25
|
+
if (packageInfo.versions[v]?.dist?.attestations) {
|
|
26
|
+
return v
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether this error means the resolved version did not yield verifiable provenance,
|
|
35
|
+
* as opposed to infrastructure failures (manifest fetch, keys, attestations URL fetch).
|
|
36
|
+
*/
|
|
37
|
+
function shouldConsiderProvenanceRegression(error) {
|
|
38
|
+
const msg = error && typeof error.message === 'string' ? error.message : ''
|
|
39
|
+
if (!msg) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (msg.includes('Package has no attestations to verify')) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Malformed checkpoint (#329) is handled in validate() before this runs.
|
|
48
|
+
if (error.code === 'EATTESTATIONVERIFY') {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
error.code === 'EMISSINGSIGNATUREKEY' ||
|
|
54
|
+
error.code === 'EEXPIREDSIGNATUREKEY' ||
|
|
55
|
+
error.code === 'EATTESTATIONSUBJECT'
|
|
56
|
+
) {
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (msg.includes('failed to verify attestation')) {
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
11
67
|
class Marshall extends BaseMarshall {
|
|
12
68
|
constructor(options) {
|
|
13
69
|
super(options)
|
|
@@ -19,6 +75,19 @@ class Marshall extends BaseMarshall {
|
|
|
19
75
|
return 'Verifying package provenance'
|
|
20
76
|
}
|
|
21
77
|
|
|
78
|
+
throwIfProvenanceRegression(validationMetadata) {
|
|
79
|
+
const prior = findNewestPriorVersionWithDistAttestations(
|
|
80
|
+
validationMetadata.packageInfo,
|
|
81
|
+
validationMetadata.version
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (prior) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Provenance regression detected: published version ${prior} includes npm provenance metadata, but ${validationMetadata.name}@${validationMetadata.version} does not (or it could not be verified).`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
22
91
|
validate(pkg) {
|
|
23
92
|
const validationMetadata = {}
|
|
24
93
|
const npmRegistry = new NpmRegistry({
|
|
@@ -56,6 +125,7 @@ class Marshall extends BaseMarshall {
|
|
|
56
125
|
})
|
|
57
126
|
.then((metadata) => {
|
|
58
127
|
if (!metadata || !metadata._attestations) {
|
|
128
|
+
this.throwIfProvenanceRegression(validationMetadata)
|
|
59
129
|
throw new Warning('the package was published without any attestations')
|
|
60
130
|
}
|
|
61
131
|
|
|
@@ -67,114 +137,42 @@ class Marshall extends BaseMarshall {
|
|
|
67
137
|
// See: https://github.com/lirantal/npq/issues/329
|
|
68
138
|
if (error.code === 'EATTESTATIONVERIFY' && error.message.includes('malformed checkpoint')) {
|
|
69
139
|
return []
|
|
70
|
-
} else {
|
|
71
|
-
if (error.message.includes('Package has no attestations to verify')) {
|
|
72
|
-
throw new Warning(
|
|
73
|
-
`Unable to verify provenance: the package was published without any attestations`
|
|
74
|
-
)
|
|
75
|
-
} else {
|
|
76
|
-
this.debug(
|
|
77
|
-
'\nUnable to verify provenance for package %s@%s: %s',
|
|
78
|
-
validationMetadata.name,
|
|
79
|
-
validationMetadata.version,
|
|
80
|
-
error.message
|
|
81
|
-
)
|
|
82
|
-
throw new Warning(`Unable to verify provenance`)
|
|
83
|
-
}
|
|
84
140
|
}
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/*
|
|
89
|
-
async checkProvenanceRegression(validationMetadata) {
|
|
90
|
-
const { packageInfo, name, version } = validationMetadata
|
|
91
|
-
|
|
92
|
-
if (!packageInfo || !packageInfo.versions) {
|
|
93
|
-
return // Cannot check regression without version information
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const allVersions = Object.keys(packageInfo.versions)
|
|
97
|
-
const validVersions = allVersions.filter(v => semver.valid(v))
|
|
98
|
-
|
|
99
|
-
if (validVersions.length === 0) {
|
|
100
|
-
return // No valid versions to compare
|
|
101
|
-
}
|
|
102
141
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (olderVersions.length === 0) {
|
|
109
|
-
return // No older versions to check
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Check if any older versions had provenance
|
|
113
|
-
let hasProvenanceRegressionDetected = false
|
|
114
|
-
const versionWithProvenance = []
|
|
115
|
-
|
|
116
|
-
// NOTE: Going back all versions and fetching their manifest to check provenance
|
|
117
|
-
// is going to be very slow. We can limit this function to go back just 2-3 versions
|
|
118
|
-
// and then stop there
|
|
119
|
-
for (const olderVersion of olderVersions) {
|
|
120
|
-
try {
|
|
121
|
-
const keys = await this.fetchRegistryKeys()
|
|
122
|
-
const metadata = await pacote.manifest(`${name}@${olderVersion}`, {
|
|
123
|
-
verifyAttestations: true,
|
|
124
|
-
registry: 'https://registry.npmjs.org',
|
|
125
|
-
'//registry.npmjs.org/:_keys': keys
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
if (metadata && metadata._attestations) {
|
|
129
|
-
versionWithProvenance.push(olderVersion)
|
|
130
|
-
hasProvenanceRegressionDetected = true
|
|
142
|
+
if (
|
|
143
|
+
error instanceof Error &&
|
|
144
|
+
error.message === 'Unable to find version or dist-tag for package'
|
|
145
|
+
) {
|
|
146
|
+
throw error
|
|
131
147
|
}
|
|
132
|
-
} catch (error) {
|
|
133
|
-
// Ignore errors for older versions as they might not have provenance
|
|
134
|
-
// We only care if they successfully had provenance before
|
|
135
|
-
continue
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// If older versions had provenance, check if current version lacks it
|
|
140
|
-
if (hasProvenanceRegressionDetected) {
|
|
141
|
-
try {
|
|
142
|
-
const keys = await this.fetchRegistryKeys()
|
|
143
|
-
const currentMetadata = await pacote.manifest(`${name}@${version}`, {
|
|
144
|
-
verifyAttestations: true,
|
|
145
|
-
registry: 'https://registry.npmjs.org',
|
|
146
|
-
'//registry.npmjs.org/:_keys': keys
|
|
147
|
-
})
|
|
148
148
|
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
} catch (error) {
|
|
156
|
-
// If it's our custom error, re-throw it
|
|
157
|
-
if (error.message.includes('Provenance regression detected')) {
|
|
149
|
+
if (
|
|
150
|
+
error instanceof Warning &&
|
|
151
|
+
(error.message.includes('Error fetching registry keys') ||
|
|
152
|
+
error.message === 'the package was published without any attestations')
|
|
153
|
+
) {
|
|
158
154
|
throw error
|
|
159
155
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
156
|
+
|
|
157
|
+
if (shouldConsiderProvenanceRegression(error)) {
|
|
158
|
+
this.throwIfProvenanceRegression(validationMetadata)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (error.message.includes('Package has no attestations to verify')) {
|
|
162
|
+
throw new Warning(
|
|
163
|
+
`Unable to verify provenance: the package was published without any attestations`
|
|
166
164
|
)
|
|
167
165
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
|
|
167
|
+
this.debug(
|
|
168
|
+
'\nUnable to verify provenance for package %s@%s: %s',
|
|
169
|
+
validationMetadata.name,
|
|
170
|
+
validationMetadata.version,
|
|
171
|
+
error.message
|
|
173
172
|
)
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
throw new Warning(`Unable to verify provenance`)
|
|
174
|
+
})
|
|
176
175
|
}
|
|
177
|
-
*/
|
|
178
176
|
|
|
179
177
|
fetchRegistryKeys() {
|
|
180
178
|
const registryHost = 'https://registry.npmjs.org'
|