npq 3.20.0 → 3.21.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 +13 -3
- package/bin/npq-hero.js +28 -14
- package/bin/npq.js +25 -8
- package/lib/cli.js +20 -3
- package/lib/helpers/notEvaluated.js +11 -0
- package/lib/helpers/npmRegistry.js +3 -66
- package/lib/helpers/packageRepoUtils.js +11 -21
- package/lib/helpers/registryClient.js +206 -0
- package/lib/helpers/registryConfig.js +79 -0
- package/lib/helpers/registryErrors.js +32 -0
- package/lib/helpers/reportResults.js +46 -3
- package/lib/marshall.js +12 -3
- package/lib/marshalls/baseMarshall.js +27 -7
- package/lib/marshalls/index.js +9 -2
- package/lib/marshalls/provenance.marshall.js +72 -105
- package/lib/marshalls/signatures.marshall.js +36 -75
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -78,6 +78,16 @@ brew install npq
|
|
|
78
78
|
npq install express
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
### Custom registries
|
|
82
|
+
|
|
83
|
+
NPQ reads standard npm registry configuration, including default and scoped registries, authentication, proxies, and TLS settings. You can also select a registry on the command line; the option is shared with the package manager used for installation:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npq install @company/tool --registry=https://artifactory.example.com/artifactory/api/npm/npm-virtual/
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See [Custom registries](docs/feature/custom-registry.md) for `.npmrc` examples, configuration precedence, private CA setup, skipped capability behavior, and NPQ's no-fallback privacy guarantee.
|
|
90
|
+
|
|
81
91
|
### Embed in your day to day
|
|
82
92
|
|
|
83
93
|
Since `npq` is a pre-step to ensure that the npm package you're installing is safe, you can safely embed it in your day-to-day `npm` usage so there's no need to remember to run `npq` explicitly.
|
|
@@ -130,15 +140,15 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
130
140
|
| --- | --- | ---
|
|
131
141
|
| 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
|
|
132
142
|
| 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)
|
|
133
|
-
| downloads | Will show a warning for a package if its download count in the last month is less than 20
|
|
143
|
+
| downloads | Will show a warning for a package if its download count in the last month is less than 20 | Reports `not evaluated` for custom-registry packages instead of querying the public npm downloads service
|
|
134
144
|
| readme | Will show a warning if a package has no README or it has been detected as a security placeholder package by npm staff
|
|
135
145
|
| 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
|
|
136
146
|
| scripts | Will show a warning if a package has a pre/post install script which could potentially be malicious
|
|
137
147
|
| snyk | Will show a warning if a package has been found with vulnerabilities in Snyk's database | For Snyk to work you need to either have the `snyk` npm package installed with a valid API token, or make the token available in the `SNYK_TOKEN` environment variable, and npq will use it
|
|
138
148
|
| license | Will show a warning if a package has been found without a license field | Checks the latest version for a license
|
|
139
149
|
| 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
|
|
140
|
-
| signatures | Will compare the package's signature as it shows on the registry's
|
|
141
|
-
| 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)
|
|
150
|
+
| signatures | Will compare the package's signature as it shows on the registry's packument with signing keys published by the selected registry | Reports `not evaluated` if the selected registry does not expose signing keys
|
|
151
|
+
| 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) | Reports `not evaluated` if the selected registry does not expose signing keys or attestations
|
|
142
152
|
| 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
|
|
143
153
|
| 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.
|
|
144
154
|
| 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.
|
package/bin/npq-hero.js
CHANGED
|
@@ -12,6 +12,8 @@ const cliPrompt = require('../lib/helpers/cliPrompt.js')
|
|
|
12
12
|
const { reportResults } = require('../lib/helpers/reportResults')
|
|
13
13
|
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
14
14
|
const { promiseThrottleHelper } = require('../lib/helpers/promiseThrottler')
|
|
15
|
+
const RegistryConfig = require('../lib/helpers/registryConfig')
|
|
16
|
+
const RegistryClient = require('../lib/helpers/registryClient')
|
|
15
17
|
|
|
16
18
|
const PACKAGE_MANAGER_TOOL = process.env.NPQ_PKG_MGR
|
|
17
19
|
const DISABLE_AUTO_CONTINUE = process.env.NPQ_DISABLE_AUTO_CONTINUE === 'true'
|
|
@@ -27,14 +29,17 @@ if (spinner) {
|
|
|
27
29
|
spinner.start()
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] })
|
|
33
|
+
.then((registryConfig) => {
|
|
34
|
+
const registryClient = new RegistryClient(registryConfig)
|
|
35
|
+
const marshall = new Marshall({
|
|
36
|
+
pkgs: cliArgs.packages,
|
|
37
|
+
registryClient,
|
|
38
|
+
progressManager: spinner,
|
|
39
|
+
promiseThrottleHelper
|
|
40
|
+
})
|
|
41
|
+
return marshall.process()
|
|
42
|
+
})
|
|
38
43
|
.then((marshallResults) => {
|
|
39
44
|
if (spinner) {
|
|
40
45
|
spinner.stop()
|
|
@@ -46,12 +51,20 @@ marshall
|
|
|
46
51
|
Object.hasOwn(results, 'countErrors') &&
|
|
47
52
|
Object.hasOwn(results, 'countWarnings')
|
|
48
53
|
) {
|
|
49
|
-
const {
|
|
50
|
-
|
|
54
|
+
const {
|
|
55
|
+
countErrors,
|
|
56
|
+
countWarnings,
|
|
57
|
+
countNotEvaluated = 0,
|
|
58
|
+
useRichFormatting
|
|
59
|
+
} = results
|
|
60
|
+
const hasFindings = countErrors > 0 || countWarnings > 0
|
|
61
|
+
const hasReportableResults = hasFindings || countNotEvaluated > 0
|
|
51
62
|
|
|
52
|
-
if (
|
|
63
|
+
if (hasReportableResults) {
|
|
53
64
|
console.log()
|
|
54
|
-
console.log(
|
|
65
|
+
console.log(
|
|
66
|
+
hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:'
|
|
67
|
+
)
|
|
55
68
|
|
|
56
69
|
if (useRichFormatting) {
|
|
57
70
|
console.log(results.resultsForPrettyPrint)
|
|
@@ -63,9 +76,10 @@ marshall
|
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
return {
|
|
66
|
-
anyIssues:
|
|
79
|
+
anyIssues: hasFindings,
|
|
67
80
|
countErrors,
|
|
68
|
-
countWarnings
|
|
81
|
+
countWarnings,
|
|
82
|
+
countNotEvaluated
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
85
|
return undefined
|
package/bin/npq.js
CHANGED
|
@@ -15,6 +15,8 @@ const cliPrompt = require('../lib/helpers/cliPrompt.js')
|
|
|
15
15
|
const { reportResults } = require('../lib/helpers/reportResults')
|
|
16
16
|
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
17
17
|
const { promiseThrottleHelper } = require('../lib/helpers/promiseThrottler')
|
|
18
|
+
const RegistryConfig = require('../lib/helpers/registryConfig')
|
|
19
|
+
const RegistryClient = require('../lib/helpers/registryClient')
|
|
18
20
|
|
|
19
21
|
const debug = util.debuglog('npq')
|
|
20
22
|
|
|
@@ -27,7 +29,12 @@ if (spinner) {
|
|
|
27
29
|
spinner.start()
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
let registryClient
|
|
33
|
+
|
|
34
|
+
RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] })
|
|
35
|
+
.then((registryConfig) => {
|
|
36
|
+
registryClient = new RegistryClient(registryConfig)
|
|
37
|
+
})
|
|
31
38
|
.then(() => {
|
|
32
39
|
if (cliArgs.packages.length === 0) {
|
|
33
40
|
debug('\nNo packages specified, using project packages from package.json')
|
|
@@ -48,6 +55,7 @@ Promise.resolve()
|
|
|
48
55
|
|
|
49
56
|
const marshall = new Marshall({
|
|
50
57
|
pkgs: packages,
|
|
58
|
+
registryClient,
|
|
51
59
|
progressManager: spinner,
|
|
52
60
|
promiseThrottleHelper
|
|
53
61
|
})
|
|
@@ -65,12 +73,20 @@ Promise.resolve()
|
|
|
65
73
|
Object.hasOwn(results, 'countErrors') &&
|
|
66
74
|
Object.hasOwn(results, 'countWarnings')
|
|
67
75
|
) {
|
|
68
|
-
const {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
const {
|
|
77
|
+
countErrors,
|
|
78
|
+
countWarnings,
|
|
79
|
+
countNotEvaluated = 0,
|
|
80
|
+
useRichFormatting
|
|
81
|
+
} = results
|
|
82
|
+
const hasFindings = countErrors > 0 || countWarnings > 0
|
|
83
|
+
const hasReportableResults = hasFindings || countNotEvaluated > 0
|
|
84
|
+
|
|
85
|
+
if (hasReportableResults) {
|
|
72
86
|
console.log()
|
|
73
|
-
console.log(
|
|
87
|
+
console.log(
|
|
88
|
+
hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:'
|
|
89
|
+
)
|
|
74
90
|
|
|
75
91
|
if (useRichFormatting) {
|
|
76
92
|
console.log(results.resultsForPrettyPrint)
|
|
@@ -82,9 +98,10 @@ Promise.resolve()
|
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
return {
|
|
85
|
-
anyIssues:
|
|
101
|
+
anyIssues: hasFindings,
|
|
86
102
|
countErrors,
|
|
87
|
-
countWarnings
|
|
103
|
+
countWarnings,
|
|
104
|
+
countNotEvaluated
|
|
88
105
|
}
|
|
89
106
|
}
|
|
90
107
|
return undefined
|
package/lib/cli.js
CHANGED
|
@@ -20,6 +20,12 @@ const INSTALL_SUBCOMMANDS = new Set([
|
|
|
20
20
|
])
|
|
21
21
|
|
|
22
22
|
class CliParser {
|
|
23
|
+
static registryConfigArgs(values = {}) {
|
|
24
|
+
return ['registry', 'userconfig', 'globalconfig'].flatMap((key) =>
|
|
25
|
+
values[key] ? [`--${key}=${values[key]}`] : []
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
static isInstallSubcommand(token) {
|
|
24
30
|
return INSTALL_SUBCOMMANDS.has(token)
|
|
25
31
|
}
|
|
@@ -67,6 +73,9 @@ class CliParser {
|
|
|
67
73
|
packageManager: { type: 'string' },
|
|
68
74
|
pkgMgr: { type: 'string' },
|
|
69
75
|
'disable-auto-continue': { type: 'boolean' },
|
|
76
|
+
registry: { type: 'string' },
|
|
77
|
+
userconfig: { type: 'string' },
|
|
78
|
+
globalconfig: { type: 'string' },
|
|
70
79
|
help: { type: 'boolean', short: 'h' },
|
|
71
80
|
version: { type: 'boolean', short: 'v' }
|
|
72
81
|
}
|
|
@@ -95,6 +104,9 @@ Options:
|
|
|
95
104
|
--packageManager Package Manager to use (default: npm)
|
|
96
105
|
--pkgMgr Alias for packageManager
|
|
97
106
|
--disable-auto-continue Disable auto-continue countdown, always prompt
|
|
107
|
+
--registry npm registry used for audit and install
|
|
108
|
+
--userconfig Path to the user npm configuration file
|
|
109
|
+
--globalconfig Path to the global npm configuration file
|
|
98
110
|
-h, --help Show help
|
|
99
111
|
-v, --version Show version
|
|
100
112
|
|
|
@@ -127,6 +139,7 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
|
|
|
127
139
|
plain: values.plain || false,
|
|
128
140
|
disableAutoContinue:
|
|
129
141
|
values['disable-auto-continue'] || process.env.NPQ_DISABLE_AUTO_CONTINUE === 'true',
|
|
142
|
+
registryConfigArgs: this.registryConfigArgs(values),
|
|
130
143
|
installSubcommandExplicit
|
|
131
144
|
}
|
|
132
145
|
}
|
|
@@ -140,17 +153,21 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
|
|
|
140
153
|
type: 'string',
|
|
141
154
|
short: 'i',
|
|
142
155
|
default: 'install'
|
|
143
|
-
}
|
|
156
|
+
},
|
|
157
|
+
registry: { type: 'string' },
|
|
158
|
+
userconfig: { type: 'string' },
|
|
159
|
+
globalconfig: { type: 'string' }
|
|
144
160
|
}
|
|
145
161
|
}
|
|
146
162
|
|
|
147
|
-
const { positionals } = parseArgs(config)
|
|
163
|
+
const { values, positionals } = parseArgs(config)
|
|
148
164
|
|
|
149
165
|
const earlyExitNoInstall = true
|
|
150
166
|
const normalizedPackages = this._extractPackagesFromPositionals(positionals, earlyExitNoInstall)
|
|
151
167
|
|
|
152
168
|
return {
|
|
153
|
-
packages: normalizedPackages
|
|
169
|
+
packages: normalizedPackages,
|
|
170
|
+
registryConfigArgs: this.registryConfigArgs(values)
|
|
154
171
|
}
|
|
155
172
|
}
|
|
156
173
|
}
|
|
@@ -13,57 +13,6 @@ const MISSING_TIME_CUTOFF = '2015-01-01T00:00:00.000Z'
|
|
|
13
13
|
* This replaces pacote functionality for npq specific needs
|
|
14
14
|
*/
|
|
15
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
16
|
/**
|
|
68
17
|
* Verify package signatures using npm registry public keys
|
|
69
18
|
* @param {Object} manifest - Package manifest
|
|
@@ -140,27 +89,15 @@ class NpmRegistry {
|
|
|
140
89
|
* @param {Array} registryKeys - Public keys from npm registry
|
|
141
90
|
* @returns {Promise<Object>} Verified manifest with _attestations
|
|
142
91
|
*/
|
|
143
|
-
async verifyAttestations(manifest, registryKeys) {
|
|
92
|
+
async verifyAttestations(manifest, registryKeys, attestations) {
|
|
144
93
|
if (!manifest.dist || !manifest.dist.attestations) {
|
|
145
94
|
throw new Error('Package has no attestations to verify')
|
|
146
95
|
}
|
|
147
96
|
|
|
148
|
-
|
|
149
|
-
|
|
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}`)
|
|
97
|
+
if (!Array.isArray(attestations)) {
|
|
98
|
+
throw new Error('Package attestations response is invalid')
|
|
160
99
|
}
|
|
161
100
|
|
|
162
|
-
const { attestations } = await response.json()
|
|
163
|
-
|
|
164
101
|
const bundles = attestations.map(({ predicateType, bundle }) => {
|
|
165
102
|
const statement = JSON.parse(
|
|
166
103
|
Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8')
|
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const semver = require('semver')
|
|
4
|
-
const
|
|
5
|
-
const NPM_REGISTRY_API = 'https://api.npmjs.org'
|
|
4
|
+
const RegistryClient = require('./registryClient')
|
|
6
5
|
|
|
7
6
|
class PackageRepoUtils {
|
|
8
|
-
constructor(
|
|
9
|
-
this.
|
|
10
|
-
this.registryApiUrl = options.registryApiUrl ? options.registryApiUrl : NPM_REGISTRY_API
|
|
7
|
+
constructor({ registryClient = RegistryClient.public() } = {}) {
|
|
8
|
+
this.registryClient = registryClient
|
|
11
9
|
this.pkgInfoCache = {}
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
formatPackageForUrl(pkg) {
|
|
15
|
-
return pkg.replace(/\//g, '%2F')
|
|
16
|
-
}
|
|
17
|
-
|
|
18
12
|
getPackageInfo(pkg) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return fetch(`${this.registryUrl}/${this.formatPackageForUrl(pkg)}`)
|
|
23
|
-
.then((response) => response.json())
|
|
24
|
-
.then((data) => {
|
|
25
|
-
this.pkgInfoCache[pkg] = data
|
|
26
|
-
return data
|
|
27
|
-
})
|
|
13
|
+
const cacheKey = `${this.registryClient.registryFor(pkg)}|${pkg}`
|
|
14
|
+
if (this.pkgInfoCache[cacheKey]) {
|
|
15
|
+
return Promise.resolve(this.pkgInfoCache[cacheKey])
|
|
28
16
|
}
|
|
17
|
+
return this.registryClient.getPackageInfo(pkg).then((data) => {
|
|
18
|
+
this.pkgInfoCache[cacheKey] = data
|
|
19
|
+
return data
|
|
20
|
+
})
|
|
29
21
|
}
|
|
30
22
|
|
|
31
23
|
getLatestVersion(pkg) {
|
|
@@ -35,9 +27,7 @@ class PackageRepoUtils {
|
|
|
35
27
|
}
|
|
36
28
|
|
|
37
29
|
getDownloadInfo(pkg) {
|
|
38
|
-
return
|
|
39
|
-
.then((response) => response.json())
|
|
40
|
-
.then(({ downloads }) => downloads)
|
|
30
|
+
return this.registryClient.getDownloadInfo(pkg)
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
getReadmeInfo(pkg) {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const npa = require('npm-package-arg')
|
|
4
|
+
const npmFetch = require('npm-registry-fetch')
|
|
5
|
+
const NotEvaluated = require('./notEvaluated')
|
|
6
|
+
const RegistryConfig = require('./registryConfig')
|
|
7
|
+
const { RegistryError } = require('./registryErrors')
|
|
8
|
+
|
|
9
|
+
const PUBLIC_REGISTRY = 'https://registry.npmjs.org/'
|
|
10
|
+
const OPTIONAL_STATUS = new Set([404, 405, 501])
|
|
11
|
+
const CAPABILITY = Object.freeze({
|
|
12
|
+
SIGNING_KEYS: 'signing-keys',
|
|
13
|
+
ATTESTATIONS: 'attestations',
|
|
14
|
+
DOWNLOAD_COUNTS: 'download-counts'
|
|
15
|
+
})
|
|
16
|
+
const CAPABILITY_MESSAGE = Object.freeze({
|
|
17
|
+
[CAPABILITY.SIGNING_KEYS]: 'configured registry does not expose signing keys',
|
|
18
|
+
[CAPABILITY.ATTESTATIONS]: 'configured registry does not expose attestations',
|
|
19
|
+
[CAPABILITY.DOWNLOAD_COUNTS]:
|
|
20
|
+
'download counts are available only for the public npm registry'
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
class RegistryClient {
|
|
24
|
+
constructor(registryConfig, { fetcher = npmFetch } = {}) {
|
|
25
|
+
this.registryConfig = registryConfig
|
|
26
|
+
this.fetcher = fetcher
|
|
27
|
+
this.keyCache = new Map()
|
|
28
|
+
this.capabilityCache = new Map()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static public() {
|
|
32
|
+
return new RegistryClient(RegistryConfig.defaults())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
registryFor(packageSpec) {
|
|
36
|
+
return this.registryConfig.registryFor(packageSpec)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
requestOptions(packageSpec) {
|
|
40
|
+
return {
|
|
41
|
+
...this.registryConfig.requestOptions,
|
|
42
|
+
spec: packageSpec,
|
|
43
|
+
headers: {
|
|
44
|
+
accept: 'application/json',
|
|
45
|
+
'user-agent': 'npq-npm-registry-client'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async requestJson(
|
|
51
|
+
requestPath,
|
|
52
|
+
packageSpec,
|
|
53
|
+
{ capability = null, notFoundAsData = false } = {}
|
|
54
|
+
) {
|
|
55
|
+
const registry = this.registryFor(packageSpec)
|
|
56
|
+
try {
|
|
57
|
+
return await this.fetcher.json(requestPath, this.requestOptions(packageSpec))
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const statusCode = error.statusCode || error.status || null
|
|
60
|
+
if (notFoundAsData && statusCode === 404) {
|
|
61
|
+
return { error: 'Not found' }
|
|
62
|
+
}
|
|
63
|
+
if (capability && OPTIONAL_STATUS.has(statusCode)) {
|
|
64
|
+
this.capabilityCache.set(`${registry}|${capability}`, false)
|
|
65
|
+
throw new NotEvaluated(CAPABILITY_MESSAGE[capability], { capability })
|
|
66
|
+
}
|
|
67
|
+
const code =
|
|
68
|
+
statusCode === 401 || statusCode === 403
|
|
69
|
+
? 'EREGISTRYAUTH'
|
|
70
|
+
: statusCode
|
|
71
|
+
? 'EREGISTRYHTTP'
|
|
72
|
+
: 'EREGISTRYNETWORK'
|
|
73
|
+
throw new RegistryError(
|
|
74
|
+
statusCode === 401 || statusCode === 403
|
|
75
|
+
? 'Registry authentication or authorization failed'
|
|
76
|
+
: statusCode
|
|
77
|
+
? `Registry request failed with HTTP ${statusCode}`
|
|
78
|
+
: 'Registry network request failed',
|
|
79
|
+
{ registry, code, statusCode, cause: error }
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getPackageInfo(packageName) {
|
|
85
|
+
const spec = npa(packageName)
|
|
86
|
+
const data = await this.requestJson(spec.escapedName, packageName, {
|
|
87
|
+
notFoundAsData: true
|
|
88
|
+
})
|
|
89
|
+
if (data && data.error === 'Not found') {
|
|
90
|
+
return data
|
|
91
|
+
}
|
|
92
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
93
|
+
throw new RegistryError('Registry package metadata is malformed', {
|
|
94
|
+
registry: this.registryFor(packageName),
|
|
95
|
+
code: 'EREGISTRYPROTOCOL'
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return data
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getManifest(packageSpec, packument = null) {
|
|
102
|
+
const spec = npa(packageSpec)
|
|
103
|
+
const data = packument || (await this.getPackageInfo(spec.name))
|
|
104
|
+
let version = spec.fetchSpec
|
|
105
|
+
if (!version || version === '*' || version === 'latest') {
|
|
106
|
+
version = data['dist-tags'] && data['dist-tags'].latest
|
|
107
|
+
}
|
|
108
|
+
if (!data.versions || !data.versions[version]) {
|
|
109
|
+
throw new Error(`Version ${version} not found for package ${spec.name}`)
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...data.versions[version],
|
|
113
|
+
...(data.time && data.time[version] ? { _time: data.time[version] } : {})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
unavailable(capability) {
|
|
118
|
+
throw new NotEvaluated(CAPABILITY_MESSAGE[capability], { capability })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getRegistryKeys(packageSpec) {
|
|
122
|
+
const registry = this.registryFor(packageSpec)
|
|
123
|
+
const cacheKey = `${registry}|${CAPABILITY.SIGNING_KEYS}`
|
|
124
|
+
if (this.keyCache.has(cacheKey)) {
|
|
125
|
+
return this.keyCache.get(cacheKey)
|
|
126
|
+
}
|
|
127
|
+
if (this.capabilityCache.get(cacheKey) === false) {
|
|
128
|
+
return this.unavailable(CAPABILITY.SIGNING_KEYS)
|
|
129
|
+
}
|
|
130
|
+
const response = await this.requestJson('-/npm/v1/keys', packageSpec, {
|
|
131
|
+
capability: CAPABILITY.SIGNING_KEYS
|
|
132
|
+
})
|
|
133
|
+
if (!response || !Array.isArray(response.keys)) {
|
|
134
|
+
throw new RegistryError('Registry signing-key response is malformed', {
|
|
135
|
+
registry,
|
|
136
|
+
code: 'EREGISTRYPROTOCOL'
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
if (response.keys.length === 0) {
|
|
140
|
+
this.capabilityCache.set(cacheKey, false)
|
|
141
|
+
return this.unavailable(CAPABILITY.SIGNING_KEYS)
|
|
142
|
+
}
|
|
143
|
+
const keys = response.keys.map((key) => ({
|
|
144
|
+
...key,
|
|
145
|
+
pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`
|
|
146
|
+
}))
|
|
147
|
+
this.keyCache.set(cacheKey, keys)
|
|
148
|
+
return keys
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getAttestations(packageSpec, manifest) {
|
|
152
|
+
const registry = this.registryFor(packageSpec)
|
|
153
|
+
const cacheKey = `${registry}|${CAPABILITY.ATTESTATIONS}`
|
|
154
|
+
if (this.capabilityCache.get(cacheKey) === false) {
|
|
155
|
+
return this.unavailable(CAPABILITY.ATTESTATIONS)
|
|
156
|
+
}
|
|
157
|
+
let requestPath
|
|
158
|
+
try {
|
|
159
|
+
const advertisedPath = new URL(manifest.dist.attestations.url).pathname
|
|
160
|
+
const registryPath = new URL(registry).pathname
|
|
161
|
+
requestPath = advertisedPath.startsWith(registryPath)
|
|
162
|
+
? advertisedPath.slice(registryPath.length)
|
|
163
|
+
: advertisedPath.replace(/^\/+/, '')
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new RegistryError('Package attestation URL is malformed', {
|
|
166
|
+
registry,
|
|
167
|
+
code: 'EREGISTRYPROTOCOL',
|
|
168
|
+
cause: error
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
const response = await this.requestJson(requestPath, packageSpec, {
|
|
172
|
+
capability: CAPABILITY.ATTESTATIONS
|
|
173
|
+
})
|
|
174
|
+
if (!response || !Array.isArray(response.attestations)) {
|
|
175
|
+
throw new RegistryError('Registry attestation response is malformed', {
|
|
176
|
+
registry,
|
|
177
|
+
code: 'EREGISTRYPROTOCOL'
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
if (response.attestations.length === 0) {
|
|
181
|
+
this.capabilityCache.set(cacheKey, false)
|
|
182
|
+
return this.unavailable(CAPABILITY.ATTESTATIONS)
|
|
183
|
+
}
|
|
184
|
+
return response.attestations
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getDownloadInfo(packageName) {
|
|
188
|
+
if (this.registryFor(packageName) !== PUBLIC_REGISTRY) {
|
|
189
|
+
return this.unavailable(CAPABILITY.DOWNLOAD_COUNTS)
|
|
190
|
+
}
|
|
191
|
+
const escapedName = npa(packageName).escapedName
|
|
192
|
+
const response = await this.requestJson(
|
|
193
|
+
`https://api.npmjs.org/downloads/point/last-month/${escapedName}`,
|
|
194
|
+
packageName
|
|
195
|
+
)
|
|
196
|
+
if (!response || typeof response.downloads !== 'number') {
|
|
197
|
+
throw new RegistryError('Registry download response is malformed', {
|
|
198
|
+
registry: PUBLIC_REGISTRY,
|
|
199
|
+
code: 'EREGISTRYPROTOCOL'
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
return response.downloads
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = RegistryClient
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const Config = require('@npmcli/config')
|
|
5
|
+
const npmFetch = require('npm-registry-fetch')
|
|
6
|
+
const {
|
|
7
|
+
definitions,
|
|
8
|
+
flatten,
|
|
9
|
+
nerfDarts,
|
|
10
|
+
shorthands
|
|
11
|
+
} = require('@npmcli/config/lib/definitions')
|
|
12
|
+
const { RegistryError, sanitizeRegistryUrl } = require('./registryErrors')
|
|
13
|
+
|
|
14
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'
|
|
15
|
+
|
|
16
|
+
function normalizeRegistry(value) {
|
|
17
|
+
const url = new URL(value || DEFAULT_REGISTRY)
|
|
18
|
+
if (!url.pathname.endsWith('/')) {
|
|
19
|
+
url.pathname += '/'
|
|
20
|
+
}
|
|
21
|
+
return url.toString()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class RegistryConfig {
|
|
25
|
+
constructor(requestOptions) {
|
|
26
|
+
this.requestOptions = Object.freeze({
|
|
27
|
+
...requestOptions,
|
|
28
|
+
registry: normalizeRegistry(requestOptions.registry)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static defaults() {
|
|
33
|
+
return new RegistryConfig({ registry: DEFAULT_REGISTRY })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static async load({ argv = [], env = process.env, cwd = process.cwd() } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const registryArg = argv.find((arg) => arg.startsWith('--registry='))
|
|
39
|
+
if (registryArg) {
|
|
40
|
+
normalizeRegistry(registryArg.slice('--registry='.length))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const config = new Config({
|
|
44
|
+
npmPath: path.resolve(__dirname, '..'),
|
|
45
|
+
definitions,
|
|
46
|
+
shorthands,
|
|
47
|
+
flatten,
|
|
48
|
+
nerfDarts,
|
|
49
|
+
argv: ['node', 'npq', ...argv],
|
|
50
|
+
env,
|
|
51
|
+
cwd,
|
|
52
|
+
warn: false
|
|
53
|
+
})
|
|
54
|
+
await config.load()
|
|
55
|
+
if (!config.validate()) {
|
|
56
|
+
throw new Error('Invalid npm configuration')
|
|
57
|
+
}
|
|
58
|
+
return new RegistryConfig(config.flat)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const code =
|
|
61
|
+
error.code === 'ERR_INVALID_AUTH' ? 'EREGISTRYCONFIGAUTH' : 'EREGISTRYCONFIG'
|
|
62
|
+
const message =
|
|
63
|
+
error.code === 'ERR_INVALID_AUTH'
|
|
64
|
+
? 'Invalid npm registry authentication configuration'
|
|
65
|
+
: `Unable to load npm registry configuration: ${error.code || error.name}`
|
|
66
|
+
throw new RegistryError(message, { code, cause: error })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
registryFor(packageSpec) {
|
|
71
|
+
return normalizeRegistry(npmFetch.pickRegistry(packageSpec, this.requestOptions))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describeRegistry(packageSpec) {
|
|
75
|
+
return sanitizeRegistryUrl(this.registryFor(packageSpec))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = RegistryConfig
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function sanitizeRegistryUrl(value) {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(value)
|
|
6
|
+
url.username = ''
|
|
7
|
+
url.password = ''
|
|
8
|
+
url.search = ''
|
|
9
|
+
url.hash = ''
|
|
10
|
+
return url.toString()
|
|
11
|
+
} catch {
|
|
12
|
+
return 'configured registry'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class RegistryError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
message,
|
|
19
|
+
{ registry = null, code = 'EREGISTRY', statusCode = null, cause = null } = {}
|
|
20
|
+
) {
|
|
21
|
+
const sanitizedRegistry = registry ? sanitizeRegistryUrl(registry) : null
|
|
22
|
+
super(sanitizedRegistry ? `${message} (${sanitizedRegistry})` : message, {
|
|
23
|
+
cause: cause || undefined
|
|
24
|
+
})
|
|
25
|
+
this.name = 'RegistryError'
|
|
26
|
+
this.code = code
|
|
27
|
+
this.statusCode = statusCode
|
|
28
|
+
this.registry = sanitizedRegistry
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { RegistryError, sanitizeRegistryUrl }
|
|
@@ -108,6 +108,7 @@ function reportResults(marshallResults, { plain = false } = {}) {
|
|
|
108
108
|
|
|
109
109
|
let countErrors = 0
|
|
110
110
|
let countWarnings = 0
|
|
111
|
+
let countNotEvaluated = 0
|
|
111
112
|
let countPackages = results.length
|
|
112
113
|
|
|
113
114
|
results.forEach((result, index) => {
|
|
@@ -219,11 +220,33 @@ function reportResults(marshallResults, { plain = false } = {}) {
|
|
|
219
220
|
})
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
if (result.notEvaluated && result.notEvaluated.length > 0) {
|
|
224
|
+
result.notEvaluated.forEach((entryArray) => {
|
|
225
|
+
countNotEvaluated += entryArray.length
|
|
226
|
+
entryArray.forEach((entry) => {
|
|
227
|
+
const topicRich = styleText(
|
|
228
|
+
['cyan'],
|
|
229
|
+
entry.categoryFriendlyName.padEnd(maxTextLength, ' ')
|
|
230
|
+
)
|
|
231
|
+
const iconRich = styleText(['cyan'], '○')
|
|
232
|
+
const wrappedMessage = wrapTextWithIndent(
|
|
233
|
+
entry.message,
|
|
234
|
+
maxMessageWidth,
|
|
235
|
+
indentString
|
|
236
|
+
)
|
|
237
|
+
resultsForPrettyPrint +=
|
|
238
|
+
`\n ${prefixRich} ${iconRich} ${topicRich} ${separatorRich} ${wrappedMessage} `
|
|
239
|
+
resultsForPlainTextPrint +=
|
|
240
|
+
`\n NOT EVALUATED: ${entry.categoryFriendlyName} - ${entry.message}`
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
// Close rich formatting box
|
|
223
246
|
resultsForPrettyPrint += `\n ${styleText(['dim'], '└─')}`
|
|
224
247
|
})
|
|
225
248
|
|
|
226
|
-
const maxTextLength = 'Total
|
|
249
|
+
const maxTextLength = 'Total not evaluated:'.length
|
|
227
250
|
|
|
228
251
|
// Rich formatting summary
|
|
229
252
|
summaryForPrettyPrint += `\n\n${styleText(['bold'], 'Summary:')}\n`
|
|
@@ -239,6 +262,10 @@ function reportResults(marshallResults, { plain = false } = {}) {
|
|
|
239
262
|
['yellow', 'bold'],
|
|
240
263
|
String(countWarnings)
|
|
241
264
|
)}`
|
|
265
|
+
summaryForPrettyPrint += `\n - ${'Total not evaluated:'.padEnd(maxTextLength, ' ')} ${styleText(
|
|
266
|
+
['cyan'],
|
|
267
|
+
String(countNotEvaluated)
|
|
268
|
+
)}`
|
|
242
269
|
summaryForPrettyPrint += `\n`
|
|
243
270
|
|
|
244
271
|
// Plain text summary
|
|
@@ -246,11 +273,13 @@ function reportResults(marshallResults, { plain = false } = {}) {
|
|
|
246
273
|
summaryForPlainTextPrint += `\n - Total packages: ${countPackages}`
|
|
247
274
|
summaryForPlainTextPrint += `\n - Total errors: ${countErrors}`
|
|
248
275
|
summaryForPlainTextPrint += `\n - Total warnings: ${countWarnings}`
|
|
276
|
+
summaryForPlainTextPrint += `\n - Total not evaluated: ${countNotEvaluated}`
|
|
249
277
|
summaryForPlainTextPrint += `\n`
|
|
250
278
|
|
|
251
279
|
return {
|
|
252
280
|
countErrors,
|
|
253
281
|
countWarnings,
|
|
282
|
+
countNotEvaluated,
|
|
254
283
|
resultsForPrettyPrint,
|
|
255
284
|
resultsForJSON,
|
|
256
285
|
resultsForPlainTextPrint,
|
|
@@ -267,7 +296,8 @@ function marshallResultsToIssuesPerPackage(results) {
|
|
|
267
296
|
const issues = {
|
|
268
297
|
pkg,
|
|
269
298
|
errors: [],
|
|
270
|
-
warnings: []
|
|
299
|
+
warnings: [],
|
|
300
|
+
notEvaluated: []
|
|
271
301
|
}
|
|
272
302
|
for (const marshall of allMarshallResultsList) {
|
|
273
303
|
for (const value of Object.values(marshall)) {
|
|
@@ -294,9 +324,22 @@ function marshallResultsToIssuesPerPackage(results) {
|
|
|
294
324
|
|
|
295
325
|
issues.warnings.push(marshallWarnings)
|
|
296
326
|
}
|
|
327
|
+
if (value.notEvaluated && value.notEvaluated.length > 0) {
|
|
328
|
+
const skippedChecks = value.notEvaluated.map((entry) => ({
|
|
329
|
+
marshall: value.marshall,
|
|
330
|
+
categoryId: value.categoryId,
|
|
331
|
+
categoryFriendlyName: marshallCategories[value.categoryId].title,
|
|
332
|
+
...entry
|
|
333
|
+
}))
|
|
334
|
+
issues.notEvaluated.push(skippedChecks)
|
|
335
|
+
}
|
|
297
336
|
}
|
|
298
337
|
}
|
|
299
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
issues.errors.length > 0 ||
|
|
340
|
+
issues.warnings.length > 0 ||
|
|
341
|
+
issues.notEvaluated.length > 0
|
|
342
|
+
) {
|
|
300
343
|
issuesPerPackage.push(issues)
|
|
301
344
|
}
|
|
302
345
|
}
|
package/lib/marshall.js
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
// const util = require('node:util')
|
|
4
4
|
const Marshalls = require('./marshalls')
|
|
5
5
|
const PackageRepoUtils = require('./helpers/packageRepoUtils')
|
|
6
|
+
const RegistryClient = require('./helpers/registryClient')
|
|
6
7
|
// const { marshallCategories } = require('./marshalls/constants')
|
|
7
8
|
|
|
8
9
|
class Marshall {
|
|
9
10
|
constructor(options = {}) {
|
|
10
|
-
|
|
11
|
-
this.
|
|
11
|
+
options = options || {}
|
|
12
|
+
this.pkgs = options.pkgs || null
|
|
13
|
+
this.registryClient =
|
|
14
|
+
options.registryClient ||
|
|
15
|
+
options.packageRepoUtils?.registryClient ||
|
|
16
|
+
RegistryClient.public()
|
|
17
|
+
this.packageRepoUtils =
|
|
18
|
+
options.packageRepoUtils ||
|
|
19
|
+
new PackageRepoUtils({ registryClient: this.registryClient })
|
|
12
20
|
this.progressManager = options.progressManager || null
|
|
13
21
|
this.promiseThrottleHelper = options.promiseThrottleHelper || null
|
|
14
22
|
}
|
|
@@ -56,7 +64,8 @@ class Marshall {
|
|
|
56
64
|
const allPackages = Array.isArray(pkg) ? pkg : [pkg]
|
|
57
65
|
const config = {
|
|
58
66
|
pkgs: this.createPackageVersionMaps(allPackages),
|
|
59
|
-
packageRepoUtils: packageRepoUtils
|
|
67
|
+
packageRepoUtils: packageRepoUtils,
|
|
68
|
+
registryClient: this.registryClient
|
|
60
69
|
}
|
|
61
70
|
return Marshalls.tasks(config, this.progressManager)
|
|
62
71
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const semver = require('semver')
|
|
4
4
|
const util = require('node:util')
|
|
5
|
+
const NotEvaluated = require('../helpers/notEvaluated')
|
|
6
|
+
const { RegistryError } = require('../helpers/registryErrors')
|
|
5
7
|
const Warning = require('../helpers/warning')
|
|
6
8
|
const { marshallCategories } = require('./constants')
|
|
7
9
|
|
|
@@ -9,6 +11,7 @@ class BaseMarshall {
|
|
|
9
11
|
constructor(options) {
|
|
10
12
|
this.debug = util.debuglog('npq')
|
|
11
13
|
this.packageRepoUtils = options.packageRepoUtils
|
|
14
|
+
this.registryClient = options.registryClient
|
|
12
15
|
this.categoryId = marshallCategories.PackageHealth.id
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -19,6 +22,7 @@ class BaseMarshall {
|
|
|
19
22
|
status: null,
|
|
20
23
|
errors: [],
|
|
21
24
|
warnings: [],
|
|
25
|
+
notEvaluated: [],
|
|
22
26
|
data: {},
|
|
23
27
|
marshall: this.name,
|
|
24
28
|
categoryId: this.categoryId
|
|
@@ -42,13 +46,22 @@ class BaseMarshall {
|
|
|
42
46
|
return data
|
|
43
47
|
})
|
|
44
48
|
.catch((err) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
if (err instanceof RegistryError) {
|
|
50
|
+
throw err
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const message = {
|
|
54
|
+
pkg: pkg.packageString,
|
|
55
|
+
message: err.message
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (err instanceof NotEvaluated) {
|
|
59
|
+
this.setNotEvaluated(message)
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.setMessage(message, Boolean(err instanceof Warning))
|
|
64
|
+
return undefined
|
|
52
65
|
})
|
|
53
66
|
}
|
|
54
67
|
|
|
@@ -69,6 +82,13 @@ class BaseMarshall {
|
|
|
69
82
|
})
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
setNotEvaluated(msg) {
|
|
86
|
+
this.ctx.marshalls[this.name].notEvaluated.push({
|
|
87
|
+
pkg: msg.pkg,
|
|
88
|
+
message: msg.message
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
/**
|
|
73
93
|
* Resolves a package version string to an actual semver version
|
|
74
94
|
* Handles dist-tags (latest, beta, etc.) and direct semver versions
|
package/lib/marshalls/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs')
|
|
4
4
|
const path = require('node:path')
|
|
5
|
+
const { RegistryError } = require('../helpers/registryErrors')
|
|
5
6
|
|
|
6
7
|
class Marshalls {
|
|
7
8
|
static async collectMarshalls() {
|
|
@@ -20,7 +21,8 @@ class Marshalls {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const config = {
|
|
23
|
-
packageRepoUtils: options.packageRepoUtils
|
|
24
|
+
packageRepoUtils: options.packageRepoUtils,
|
|
25
|
+
registryClient: options.registryClient
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const marshallTasks = marshalls.reduce((prev, curr) => {
|
|
@@ -96,6 +98,7 @@ class Marshalls {
|
|
|
96
98
|
status: null,
|
|
97
99
|
errors: [{ pkg: options.pkgs[index].packageName, message: 'Package not found' }],
|
|
98
100
|
warnings: [],
|
|
101
|
+
notEvaluated: [],
|
|
99
102
|
data: {},
|
|
100
103
|
categoryId: 'PackageHealth'
|
|
101
104
|
}
|
|
@@ -119,7 +122,8 @@ class Marshalls {
|
|
|
119
122
|
const packagesToProcess = options.pkgs
|
|
120
123
|
if (packagesToProcess.length > 0) {
|
|
121
124
|
const marshallTasks = await Marshalls.buildMarshallTasks(marshalls, {
|
|
122
|
-
packageRepoUtils: options.packageRepoUtils
|
|
125
|
+
packageRepoUtils: options.packageRepoUtils,
|
|
126
|
+
registryClient: options.registryClient
|
|
123
127
|
})
|
|
124
128
|
|
|
125
129
|
for (const marshall of marshallTasks) {
|
|
@@ -130,6 +134,9 @@ class Marshalls {
|
|
|
130
134
|
})
|
|
131
135
|
marshallResults.push(res.marshalls)
|
|
132
136
|
} catch (error) {
|
|
137
|
+
if (error instanceof RegistryError) {
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
133
140
|
console.error(`Error running task ${marshall.title}:`, error)
|
|
134
141
|
}
|
|
135
142
|
}
|
|
@@ -4,6 +4,8 @@ const semver = require('semver')
|
|
|
4
4
|
const BaseMarshall = require('./baseMarshall')
|
|
5
5
|
const NpmRegistry = require('../helpers/npmRegistry')
|
|
6
6
|
const Warning = require('../helpers/warning')
|
|
7
|
+
const NotEvaluated = require('../helpers/notEvaluated')
|
|
8
|
+
const { RegistryError } = require('../helpers/registryErrors')
|
|
7
9
|
const { marshallCategories } = require('./constants')
|
|
8
10
|
|
|
9
11
|
const MARSHALL_NAME = 'provenance'
|
|
@@ -88,115 +90,80 @@ class Marshall extends BaseMarshall {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
validate(pkg) {
|
|
93
|
+
async validate(pkg) {
|
|
92
94
|
const validationMetadata = {}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
pkg.packageName,
|
|
105
|
-
pkg.packageVersion,
|
|
106
|
-
packageInfo
|
|
107
|
-
)
|
|
95
|
+
const verifier = new NpmRegistry()
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const packageInfo = await this.packageRepoUtils.getPackageInfo(pkg.packageName)
|
|
99
|
+
validationMetadata.name = packageInfo.name
|
|
100
|
+
validationMetadata.version = await this.resolvePackageVersion(
|
|
101
|
+
pkg.packageName,
|
|
102
|
+
pkg.packageVersion,
|
|
103
|
+
packageInfo
|
|
104
|
+
)
|
|
105
|
+
validationMetadata.packageInfo = packageInfo
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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`
|
|
164
|
-
)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
this.debug(
|
|
168
|
-
'\nUnable to verify provenance for package %s@%s: %s',
|
|
169
|
-
validationMetadata.name,
|
|
170
|
-
validationMetadata.version,
|
|
171
|
-
error.message
|
|
107
|
+
if (!validationMetadata.version) {
|
|
108
|
+
throw new Error('Unable to find version or dist-tag for package')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const manifest = await this.registryClient.getManifest(
|
|
112
|
+
`${pkg.packageName}@${validationMetadata.version}`,
|
|
113
|
+
packageInfo
|
|
114
|
+
)
|
|
115
|
+
let metadata
|
|
116
|
+
if (!manifest.dist || !manifest.dist.attestations) {
|
|
117
|
+
metadata = await verifier.verifyAttestations(manifest, [], null)
|
|
118
|
+
} else {
|
|
119
|
+
const [keys, attestations] = await Promise.all([
|
|
120
|
+
this.registryClient.getRegistryKeys(pkg.packageName),
|
|
121
|
+
this.registryClient.getAttestations(pkg.packageName, manifest)
|
|
122
|
+
])
|
|
123
|
+
metadata = await verifier.verifyAttestations(manifest, keys, attestations)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!metadata || !metadata._attestations) {
|
|
127
|
+
this.throwIfProvenanceRegression(validationMetadata)
|
|
128
|
+
throw new Warning('the package was published without any attestations')
|
|
129
|
+
}
|
|
130
|
+
return metadata._attestations
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof NotEvaluated || error instanceof RegistryError) {
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
if (error.code === 'EATTESTATIONVERIFY' && error.message.includes('malformed checkpoint')) {
|
|
136
|
+
return []
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
error instanceof Error &&
|
|
140
|
+
error.message === 'Unable to find version or dist-tag for package'
|
|
141
|
+
) {
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
error instanceof Warning &&
|
|
146
|
+
error.message === 'the package was published without any attestations'
|
|
147
|
+
) {
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
if (shouldConsiderProvenanceRegression(error)) {
|
|
151
|
+
this.throwIfProvenanceRegression(validationMetadata)
|
|
152
|
+
}
|
|
153
|
+
if (error.message.includes('Package has no attestations to verify')) {
|
|
154
|
+
throw new Warning(
|
|
155
|
+
'Unable to verify provenance: the package was published without any attestations'
|
|
172
156
|
)
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
}
|
|
157
|
+
}
|
|
176
158
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
})
|
|
186
|
-
.then((response) => {
|
|
187
|
-
const registryKeys = response.keys
|
|
188
|
-
|
|
189
|
-
return registryKeys.map((key) => ({
|
|
190
|
-
...key,
|
|
191
|
-
pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`
|
|
192
|
-
}))
|
|
193
|
-
})
|
|
194
|
-
.then((keys) => {
|
|
195
|
-
return keys
|
|
196
|
-
})
|
|
197
|
-
.catch((error) => {
|
|
198
|
-
throw new Warning(`Error fetching registry keys: ${error.message}`)
|
|
199
|
-
})
|
|
159
|
+
this.debug(
|
|
160
|
+
'\nUnable to verify provenance for package %s@%s: %s',
|
|
161
|
+
validationMetadata.name,
|
|
162
|
+
validationMetadata.version,
|
|
163
|
+
error.message
|
|
164
|
+
)
|
|
165
|
+
throw new Warning('Unable to verify provenance')
|
|
166
|
+
}
|
|
200
167
|
}
|
|
201
168
|
}
|
|
202
169
|
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
const BaseMarshall = require('./baseMarshall')
|
|
4
4
|
const Warning = require('../helpers/warning')
|
|
5
5
|
const NpmRegistry = require('../helpers/npmRegistry')
|
|
6
|
+
const NotEvaluated = require('../helpers/notEvaluated')
|
|
7
|
+
const { RegistryError } = require('../helpers/registryErrors')
|
|
6
8
|
const { marshallCategories } = require('./constants')
|
|
7
9
|
|
|
8
10
|
const MARSHALL_NAME = 'signatures'
|
|
9
11
|
|
|
10
|
-
let registryKeysCache = null
|
|
11
|
-
|
|
12
12
|
class Marshall extends BaseMarshall {
|
|
13
13
|
constructor(options) {
|
|
14
14
|
super(options)
|
|
@@ -20,83 +20,44 @@ class Marshall extends BaseMarshall {
|
|
|
20
20
|
return 'Verifying registry signatures for package'
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
validate(pkg) {
|
|
24
|
-
const
|
|
25
|
-
registry: 'https://registry.npmjs.org'
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
return this.packageRepoUtils
|
|
29
|
-
.getPackageInfo(pkg.packageName)
|
|
30
|
-
.then(async (packageInfo) => {
|
|
31
|
-
// Resolve version range to specific version
|
|
32
|
-
const resolvedVersion = await this.resolvePackageVersion(
|
|
33
|
-
pkg.packageName,
|
|
34
|
-
pkg.packageVersion,
|
|
35
|
-
packageInfo
|
|
36
|
-
)
|
|
23
|
+
async validate(pkg) {
|
|
24
|
+
const verifier = new NpmRegistry()
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
26
|
+
try {
|
|
27
|
+
const packageInfo = await this.packageRepoUtils.getPackageInfo(pkg.packageName)
|
|
28
|
+
const resolvedVersion = await this.resolvePackageVersion(
|
|
29
|
+
pkg.packageName,
|
|
30
|
+
pkg.packageVersion,
|
|
31
|
+
packageInfo
|
|
32
|
+
)
|
|
43
33
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
.getManifest(`${pkg.packageName}@${resolvedVersion}`)
|
|
50
|
-
.then((manifest) => npmRegistry.verifySignatures(manifest, keys))
|
|
51
|
-
})
|
|
52
|
-
})
|
|
53
|
-
.then((metadata) => {
|
|
54
|
-
return metadata
|
|
55
|
-
})
|
|
56
|
-
.catch((error) => {
|
|
57
|
-
// Handle specific error cases
|
|
58
|
-
if (
|
|
59
|
-
error.message &&
|
|
60
|
-
error.message.includes('but the corresponding public key has expired')
|
|
61
|
-
) {
|
|
62
|
-
// if the error is about an expired key, we throw a warning
|
|
63
|
-
// instead of an error, so that the process can continue
|
|
64
|
-
throw new Warning(`Package is signed with an expired key`)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
throw new Warning(`Unable to verify package signature on registry: ${error.message}`)
|
|
68
|
-
})
|
|
69
|
-
}
|
|
34
|
+
if (!resolvedVersion) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Unable to resolve version ${pkg.packageVersion} for package ${pkg.packageName}`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
70
39
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
40
|
+
const manifest = await this.registryClient.getManifest(
|
|
41
|
+
`${pkg.packageName}@${resolvedVersion}`,
|
|
42
|
+
packageInfo
|
|
43
|
+
)
|
|
44
|
+
if (!manifest.dist || !manifest.dist.signatures) {
|
|
45
|
+
return await verifier.verifySignatures(manifest, [])
|
|
46
|
+
}
|
|
47
|
+
const keys = await this.registryClient.getRegistryKeys(pkg.packageName)
|
|
48
|
+
return await verifier.verifySignatures(manifest, keys)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof NotEvaluated || error instanceof RegistryError) {
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
error.message &&
|
|
55
|
+
error.message.includes('but the corresponding public key has expired')
|
|
56
|
+
) {
|
|
57
|
+
throw new Warning('Package is signed with an expired key')
|
|
58
|
+
}
|
|
59
|
+
throw new Warning(`Unable to verify package signature on registry: ${error.message}`)
|
|
74
60
|
}
|
|
75
|
-
|
|
76
|
-
const registryHost = 'https://registry.npmjs.org'
|
|
77
|
-
const registryKeysEndpoint = '/-/npm/v1/keys'
|
|
78
|
-
|
|
79
|
-
const registryKeysUrl = `${registryHost}${registryKeysEndpoint}`
|
|
80
|
-
return fetch(registryKeysUrl)
|
|
81
|
-
.then((response) => {
|
|
82
|
-
return response.json()
|
|
83
|
-
})
|
|
84
|
-
.then((response) => {
|
|
85
|
-
const registryKeys = response.keys
|
|
86
|
-
|
|
87
|
-
return registryKeys.map((key) => ({
|
|
88
|
-
...key,
|
|
89
|
-
pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`
|
|
90
|
-
}))
|
|
91
|
-
})
|
|
92
|
-
.then((keys) => {
|
|
93
|
-
// save it in the cached singleton object
|
|
94
|
-
registryKeysCache = keys
|
|
95
|
-
return keys
|
|
96
|
-
})
|
|
97
|
-
.catch((error) => {
|
|
98
|
-
throw new Warning(`Error fetching registry keys: ${error.message}`)
|
|
99
|
-
})
|
|
100
61
|
}
|
|
101
62
|
}
|
|
102
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "npq",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.0",
|
|
4
4
|
"description": "marshall your npm/npm package installs with high quality and class 🎖",
|
|
5
5
|
"bin": {
|
|
6
6
|
"npq": "./bin/npq.js",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"@npmcli/config": "^10.10.0",
|
|
46
47
|
"npm-package-arg": "^13.0.2",
|
|
48
|
+
"npm-registry-fetch": "^19.1.1",
|
|
47
49
|
"semver": "^7.7.4",
|
|
48
50
|
"sigstore": "^4.1.0",
|
|
49
51
|
"ssri": "^13.0.1"
|