npq 3.21.4 → 3.22.1

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
@@ -130,6 +130,7 @@ NPQ_PKG_MGR=pnpm npx npq install fastify
130
130
 
131
131
  ```bash
132
132
  alias pnpm="NPQ_PKG_MGR=pnpm npq-hero"
133
+ pnpm --filter workspace... add express
133
134
  ```
134
135
 
135
136
  Note: `npq` by default will offload all commands and their arguments to the `npm` (or other package manager as specified) after it finished its due-diligence checks for the respective packages.
@@ -146,7 +147,7 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
146
147
  | scripts | Will show a warning if a package has a pre/post install script which could potentially be malicious
147
148
  | 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
148
149
  | license | Will show a warning if a package has been found without a license field | Checks the latest version for a license
149
- | expired domains | Normalizes and checks complete maintainer email hosts, warning only when a simple two-label domain does not resolve but its public top-level domain does | Multipart and otherwise inconclusive DNS failures are reported as not evaluated; non-resolution is not proof that a domain is expired or available
150
+ | expired domains | Normalizes and checks complete maintainer email hosts, then corroborates a simple two-label DNS failure through authoritative RDAP before warning | Multipart and otherwise inconclusive results are reported as not evaluated; see [expired-domain marshall documentation](docs/feature/expired-domains.md)
150
151
  | 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
152
  | 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
152
153
  | 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
@@ -265,7 +266,7 @@ When auto-continue is disabled, npq will always prompt for explicit confirmation
265
266
 
266
267
  5. **Why is NPQ connecting to external domains like gmail.com or personal websites during installation?**
267
268
 
268
- * This is not telemetry. NPQ does not collect any usage data. When auditing a package, NPQ fetches the maintainers/authors of the dependency and checks their email addresses to verify they are valid and not associated with expired domains. Expired domains can be abused by attackers for account takeover (ATO) attacks to compromise packages with malicious versions. Hence, NPQ may make DNS requests to domains like `gmail.com` or personal domains found in maintainer emails. Additionally, NPQ makes HTTP requests to `osv.dev` to fetch security vulnerability data (or uses Snyk if configured, as a prioritized option).
269
+ * This is not telemetry. NPQ does not collect any usage data. When auditing a package, NPQ fetches the maintainers/authors of the dependency and checks their email addresses to verify they are valid and not associated with expired domains. Expired domains can be abused by attackers for account takeover (ATO) attacks to compromise packages with malicious versions. Hence, NPQ may make DNS requests to domains like `gmail.com` or personal domains found in maintainer emails. After a simple two-label domain returns NXDOMAIN and its top-level domain resolves, NPQ may fetch the official IANA RDAP bootstrap and query the selected authoritative HTTPS RDAP service, sending only the normalized two-label domain. Additionally, NPQ makes HTTP requests to `osv.dev` to fetch security vulnerability data (or uses Snyk if configured, as a prioritized option).
269
270
 
270
271
  ## Documentation
271
272
 
package/lib/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { parseArgs } = require('node:util')
4
4
  const npa = require('npm-package-arg')
5
5
  const pkg = require('../package.json')
6
+ const { parsePackageManagerArguments } = require('./helpers/packageManagerArgumentParser')
6
7
 
7
8
  const INSTALL_SUBCOMMANDS = new Set([
8
9
  'install',
@@ -145,22 +146,11 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
145
146
  }
146
147
 
147
148
  static parseArgsMinimal() {
148
- const config = {
149
- allowPositionals: true,
150
- strict: false,
151
- options: {
152
- install: {
153
- type: 'string',
154
- short: 'i',
155
- default: 'install'
156
- },
157
- registry: { type: 'string' },
158
- userconfig: { type: 'string' },
159
- globalconfig: { type: 'string' }
160
- }
161
- }
162
-
163
- const { values, positionals } = parseArgs(config)
149
+ const packageManager = process.env.NPQ_PKG_MGR || 'npm'
150
+ const { values, positionals } = parsePackageManagerArguments({
151
+ packageManager,
152
+ args: process.argv.slice(2)
153
+ })
164
154
 
165
155
  const earlyExitNoInstall = true
166
156
  const normalizedPackages = this._extractPackagesFromPositionals(positionals, earlyExitNoInstall)
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ const { parseArgs } = require('node:util')
4
+
5
+ const BASE_OPTIONS = {
6
+ install: {
7
+ type: 'string',
8
+ short: 'i',
9
+ default: 'install'
10
+ },
11
+ registry: { type: 'string' },
12
+ userconfig: { type: 'string' },
13
+ globalconfig: { type: 'string' }
14
+ }
15
+
16
+ function parsePackageManagerArguments({ packageManager, args }) {
17
+ const options =
18
+ packageManager === 'pnpm'
19
+ ? {
20
+ ...BASE_OPTIONS,
21
+ filter: { type: 'string', short: 'F', multiple: true },
22
+ 'filter-prod': { type: 'string', multiple: true }
23
+ }
24
+ : BASE_OPTIONS
25
+
26
+ const config = {
27
+ allowPositionals: true,
28
+ strict: false,
29
+ options
30
+ }
31
+
32
+ if (args !== undefined) {
33
+ config.args = args
34
+ }
35
+
36
+ return parseArgs(config)
37
+ }
38
+
39
+ module.exports.parsePackageManagerArguments = parsePackageManagerArguments
@@ -0,0 +1,146 @@
1
+ 'use strict'
2
+
3
+ const IANA_DNS_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'
4
+ const DEFAULT_TIMEOUT_MS = 3000
5
+
6
+ const rdapStatuses = Object.freeze({
7
+ Registered: 'registered',
8
+ NotFound: 'not-found',
9
+ Inconclusive: 'inconclusive'
10
+ })
11
+
12
+ function isHttpsUrl(value) {
13
+ try {
14
+ return new URL(value).protocol === 'https:'
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ class RdapClient {
21
+ constructor({
22
+ fetcher = globalThis.fetch,
23
+ bootstrapUrl = IANA_DNS_BOOTSTRAP_URL,
24
+ timeoutMs = DEFAULT_TIMEOUT_MS
25
+ } = {}) {
26
+ this.fetcher = fetcher
27
+ this.bootstrapUrl = bootstrapUrl
28
+ this.timeoutMs = timeoutMs
29
+ this.bootstrapPromise = null
30
+ this.domainPromises = new Map()
31
+ }
32
+
33
+ lookup(domain) {
34
+ const normalizedDomain = domain.toLowerCase()
35
+ if (!this.domainPromises.has(normalizedDomain)) {
36
+ this.domainPromises.set(
37
+ normalizedDomain,
38
+ this.lookupUncached(normalizedDomain).catch((error) => ({
39
+ status: rdapStatuses.Inconclusive,
40
+ reason: error && (error.code || error.name || error.message)
41
+ }))
42
+ )
43
+ }
44
+
45
+ return this.domainPromises.get(normalizedDomain)
46
+ }
47
+
48
+ async lookupUncached(domain) {
49
+ const bootstrap = await this.getBootstrap()
50
+ const endpoint = this.findEndpoint(bootstrap, domain)
51
+
52
+ if (!endpoint) {
53
+ return { status: rdapStatuses.Inconclusive, reason: 'bootstrap-unavailable' }
54
+ }
55
+
56
+ const baseUrl = endpoint.endsWith('/') ? endpoint : `${endpoint}/`
57
+ const lookupUrl = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString()
58
+ const response = await this.request(lookupUrl)
59
+
60
+ if (response.status === 404) {
61
+ return { status: rdapStatuses.NotFound }
62
+ }
63
+
64
+ if (!response.ok) {
65
+ return { status: rdapStatuses.Inconclusive, reason: `http-${response.status}` }
66
+ }
67
+
68
+ const body = await response.json()
69
+ if (!body || body.objectClassName !== 'domain') {
70
+ return { status: rdapStatuses.Inconclusive, reason: 'invalid-response' }
71
+ }
72
+
73
+ return { status: rdapStatuses.Registered }
74
+ }
75
+
76
+ getBootstrap() {
77
+ if (!this.bootstrapPromise) {
78
+ this.bootstrapPromise = this.loadBootstrap()
79
+ }
80
+
81
+ return this.bootstrapPromise
82
+ }
83
+
84
+ async loadBootstrap() {
85
+ if (!isHttpsUrl(this.bootstrapUrl)) {
86
+ throw new Error('RDAP bootstrap URL must use HTTPS')
87
+ }
88
+
89
+ const response = await this.request(this.bootstrapUrl)
90
+ if (!response.ok) {
91
+ throw new Error(`RDAP bootstrap request failed with HTTP ${response.status}`)
92
+ }
93
+
94
+ const bootstrap = await response.json()
95
+ if (!bootstrap || !Array.isArray(bootstrap.services)) {
96
+ throw new Error('RDAP bootstrap response is invalid')
97
+ }
98
+
99
+ return bootstrap
100
+ }
101
+
102
+ findEndpoint(bootstrap, domain) {
103
+ const tld = domain.split('.').pop().toLowerCase()
104
+
105
+ for (const service of bootstrap.services) {
106
+ if (!Array.isArray(service) || !Array.isArray(service[0]) || !Array.isArray(service[1])) {
107
+ continue
108
+ }
109
+
110
+ const handlesTld = service[0].some(
111
+ (candidate) => typeof candidate === 'string' && candidate.toLowerCase() === tld
112
+ )
113
+ if (!handlesTld) {
114
+ continue
115
+ }
116
+
117
+ return service[1].find((candidate) => typeof candidate === 'string' && isHttpsUrl(candidate))
118
+ }
119
+
120
+ return undefined
121
+ }
122
+
123
+ request(url) {
124
+ if (!isHttpsUrl(url)) {
125
+ throw new Error('RDAP requests must use HTTPS')
126
+ }
127
+
128
+ return this.fetcher(url, {
129
+ headers: {
130
+ accept: 'application/rdap+json, application/json'
131
+ },
132
+ redirect: 'error',
133
+ signal: AbortSignal.timeout(this.timeoutMs)
134
+ })
135
+ }
136
+ }
137
+
138
+ const defaultRdapClient = new RdapClient()
139
+
140
+ module.exports = {
141
+ DEFAULT_TIMEOUT_MS,
142
+ IANA_DNS_BOOTSTRAP_URL,
143
+ RdapClient,
144
+ defaultRdapClient,
145
+ rdapStatuses
146
+ }
@@ -8,6 +8,7 @@ const {
8
8
  isSimpleMaintainerDomain,
9
9
  normalizeMaintainerDomain
10
10
  } = require('../helpers/maintainerDomain')
11
+ const { defaultRdapClient, rdapStatuses } = require('../helpers/rdapClient')
11
12
  const { Resolver } = require('dns/promises')
12
13
 
13
14
  const dns = new Resolver()
@@ -20,6 +21,7 @@ class Marshall extends BaseMarshall {
20
21
  this.name = MARSHALL_NAME
21
22
  this.categoryId = marshallCategories.PackageHealth.id
22
23
  this.dnsResolver = options.dnsResolver || dns
24
+ this.rdapClient = options.rdapClient || defaultRdapClient
23
25
  }
24
26
 
25
27
  title() {
@@ -103,21 +105,39 @@ class Marshall extends BaseMarshall {
103
105
  )
104
106
  })
105
107
 
106
- const suspectedDomains = simpleMissingDomains.filter((domain) => {
108
+ const rdapCandidateDomains = simpleMissingDomains.filter((domain) => {
107
109
  const isPublic = publicTopLevelDomains.has(domain.split('.')[1])
108
110
  if (!isPublic) {
109
111
  incompleteDomains.push(domain)
110
112
  }
111
113
  return isPublic
112
114
  })
115
+ const rdapResults = await Promise.allSettled(
116
+ rdapCandidateDomains.map((domain) => this.rdapClient.lookup(domain))
117
+ )
118
+ const corroboratedDomains = []
119
+
120
+ rdapResults.forEach((result, index) => {
121
+ const domain = rdapCandidateDomains[index]
122
+ if (result.status === 'rejected' || !result.value) {
123
+ incompleteDomains.push(domain)
124
+ return
125
+ }
126
+
127
+ if (result.value.status === rdapStatuses.NotFound) {
128
+ corroboratedDomains.push(domain)
129
+ } else if (result.value.status !== rdapStatuses.Registered) {
130
+ incompleteDomains.push(domain)
131
+ }
132
+ })
113
133
 
114
134
  const incompleteCount =
115
135
  invalidMaintainers +
116
136
  incompleteDomains.reduce((count, domain) => count + emailDomains.get(domain), 0)
117
- if (suspectedDomains.length > 0) {
118
- const domainNoun = suspectedDomains.length === 1 ? 'domain' : 'domains'
119
- const resolveVerb = suspectedDomains.length === 1 ? 'does' : 'do'
120
- let message = `Maintainer ${domainNoun} ${suspectedDomains.join(', ')} ${resolveVerb} not resolve in public DNS and may warrant investigation.`
137
+ if (corroboratedDomains.length > 0) {
138
+ const domainNoun = corroboratedDomains.length === 1 ? 'domain' : 'domains'
139
+ const resolveVerb = corroboratedDomains.length === 1 ? 'does' : 'do'
140
+ let message = `Maintainer ${domainNoun} ${corroboratedDomains.join(', ')} ${resolveVerb} not resolve in public DNS, and RDAP found no active registration; account takeover may be possible.`
121
141
 
122
142
  if (incompleteCount > 0) {
123
143
  const recordNoun = incompleteCount === 1 ? 'record' : 'records'
@@ -130,7 +150,7 @@ class Marshall extends BaseMarshall {
130
150
  if (incompleteCount > 0) {
131
151
  const recordNoun = incompleteCount === 1 ? 'record' : 'records'
132
152
  throw new NotEvaluated(
133
- `${incompleteCount} maintainer ${recordNoun} could not be evaluated because DNS or email data was incomplete`
153
+ `${incompleteCount} maintainer ${recordNoun} could not be evaluated because DNS, RDAP, or email data was incomplete`
134
154
  )
135
155
  }
136
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npq",
3
- "version": "3.21.4",
3
+ "version": "3.22.1",
4
4
  "description": "marshall your npm/npm package installs with high quality and class 🎖",
5
5
  "bin": {
6
6
  "npq": "./bin/npq.js",