npq 3.21.3 → 3.22.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
CHANGED
|
@@ -146,7 +146,7 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
146
146
|
| scripts | Will show a warning if a package has a pre/post install script which could potentially be malicious
|
|
147
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
|
|
148
148
|
| 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 |
|
|
149
|
+
| 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
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
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
|
|
152
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
|
|
@@ -265,7 +265,7 @@ When auto-continue is disabled, npq will always prompt for explicit confirmation
|
|
|
265
265
|
|
|
266
266
|
5. **Why is NPQ connecting to external domains like gmail.com or personal websites during installation?**
|
|
267
267
|
|
|
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).
|
|
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. 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
269
|
|
|
270
270
|
## Documentation
|
|
271
271
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isIP } = require('node:net')
|
|
4
|
+
const { domainToASCII } = require('node:url')
|
|
5
|
+
|
|
6
|
+
const SPECIAL_USE_SUFFIXES = new Set([
|
|
7
|
+
'alt',
|
|
8
|
+
'corp',
|
|
9
|
+
'example',
|
|
10
|
+
'home',
|
|
11
|
+
'internal',
|
|
12
|
+
'invalid',
|
|
13
|
+
'local',
|
|
14
|
+
'localhost',
|
|
15
|
+
'mail',
|
|
16
|
+
'onion',
|
|
17
|
+
'test'
|
|
18
|
+
])
|
|
19
|
+
const RESERVED_HOSTS = new Set([
|
|
20
|
+
'example.com',
|
|
21
|
+
'example.net',
|
|
22
|
+
'example.org',
|
|
23
|
+
'home.arpa',
|
|
24
|
+
'in-addr.arpa',
|
|
25
|
+
'ip6.arpa'
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
function hasReservedSuffix(hostname, suffixes) {
|
|
29
|
+
return [...suffixes].some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeMaintainerDomain(maintainerEmail) {
|
|
33
|
+
const atIndex = typeof maintainerEmail === 'string' ? maintainerEmail.lastIndexOf('@') : -1
|
|
34
|
+
const hasOneAtSign = atIndex > 0 && maintainerEmail.indexOf('@') === atIndex
|
|
35
|
+
const emailDomain = atIndex > 0 ? maintainerEmail.slice(atIndex + 1).trim() : ''
|
|
36
|
+
|
|
37
|
+
if (!hasOneAtSign || !emailDomain || /\s/.test(emailDomain)) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hostname = emailDomain.replace(/\.$/, '').toLowerCase()
|
|
42
|
+
const bracketedIp = hostname.match(/^\[(.*)\]$/)
|
|
43
|
+
if (isIP(bracketedIp ? bracketedIp[1] : hostname)) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const asciiHostname = domainToASCII(hostname).toLowerCase()
|
|
48
|
+
const labels = asciiHostname.split('.')
|
|
49
|
+
const labelsAreValid = labels.every(
|
|
50
|
+
(label) =>
|
|
51
|
+
label.length > 0 &&
|
|
52
|
+
label.length <= 63 &&
|
|
53
|
+
/^[a-z0-9-]+$/.test(label) &&
|
|
54
|
+
!label.startsWith('-') &&
|
|
55
|
+
!label.endsWith('-')
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
!asciiHostname ||
|
|
60
|
+
isIP(asciiHostname) ||
|
|
61
|
+
asciiHostname.length > 253 ||
|
|
62
|
+
labels.length < 2 ||
|
|
63
|
+
!labelsAreValid ||
|
|
64
|
+
hasReservedSuffix(asciiHostname, SPECIAL_USE_SUFFIXES) ||
|
|
65
|
+
hasReservedSuffix(asciiHostname, RESERVED_HOSTS)
|
|
66
|
+
) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return asciiHostname
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isSimpleMaintainerDomain(domain) {
|
|
74
|
+
return domain.split('.').length === 2
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { isSimpleMaintainerDomain, normalizeMaintainerDomain }
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,11 @@ const BaseMarshall = require('./baseMarshall')
|
|
|
4
4
|
const { marshallCategories } = require('./constants')
|
|
5
5
|
const NotEvaluated = require('../helpers/notEvaluated')
|
|
6
6
|
const Warning = require('../helpers/warning')
|
|
7
|
+
const {
|
|
8
|
+
isSimpleMaintainerDomain,
|
|
9
|
+
normalizeMaintainerDomain
|
|
10
|
+
} = require('../helpers/maintainerDomain')
|
|
11
|
+
const { defaultRdapClient, rdapStatuses } = require('../helpers/rdapClient')
|
|
7
12
|
const { Resolver } = require('dns/promises')
|
|
8
13
|
|
|
9
14
|
const dns = new Resolver()
|
|
@@ -16,6 +21,7 @@ class Marshall extends BaseMarshall {
|
|
|
16
21
|
this.name = MARSHALL_NAME
|
|
17
22
|
this.categoryId = marshallCategories.PackageHealth.id
|
|
18
23
|
this.dnsResolver = options.dnsResolver || dns
|
|
24
|
+
this.rdapClient = options.rdapClient || defaultRdapClient
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
title() {
|
|
@@ -36,17 +42,9 @@ class Marshall extends BaseMarshall {
|
|
|
36
42
|
const emailDomains = new Map()
|
|
37
43
|
let invalidMaintainers = 0
|
|
38
44
|
for (const maintainerInfo of maintainersAccounts) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
atIndex > 0
|
|
43
|
-
? maintainerEmail
|
|
44
|
-
.slice(atIndex + 1)
|
|
45
|
-
.trim()
|
|
46
|
-
.toLowerCase()
|
|
47
|
-
: ''
|
|
48
|
-
|
|
49
|
-
if (!emailDomain || emailDomain.includes('@') || /\s/.test(emailDomain)) {
|
|
45
|
+
const emailDomain = normalizeMaintainerDomain(maintainerInfo && maintainerInfo.email)
|
|
46
|
+
|
|
47
|
+
if (!emailDomain) {
|
|
50
48
|
invalidMaintainers += 1
|
|
51
49
|
continue
|
|
52
50
|
}
|
|
@@ -62,7 +60,7 @@ class Marshall extends BaseMarshall {
|
|
|
62
60
|
const resolutionResults = await Promise.allSettled(
|
|
63
61
|
domains.map((emailDomain) => this.dnsResolver.resolve(emailDomain, 'NS'))
|
|
64
62
|
)
|
|
65
|
-
const
|
|
63
|
+
const simpleMissingDomains = []
|
|
66
64
|
const incompleteDomains = []
|
|
67
65
|
|
|
68
66
|
resolutionResults.forEach((result, index) => {
|
|
@@ -78,20 +76,68 @@ class Marshall extends BaseMarshall {
|
|
|
78
76
|
errorCode || '<unknown>'
|
|
79
77
|
)
|
|
80
78
|
|
|
81
|
-
if (errorCode === 'ENOTFOUND') {
|
|
82
|
-
|
|
79
|
+
if (errorCode === 'ENOTFOUND' && isSimpleMaintainerDomain(domain)) {
|
|
80
|
+
simpleMissingDomains.push(domain)
|
|
83
81
|
} else {
|
|
84
82
|
incompleteDomains.push(domain)
|
|
85
83
|
}
|
|
86
84
|
})
|
|
87
85
|
|
|
86
|
+
const topLevelDomains = [
|
|
87
|
+
...new Set(simpleMissingDomains.map((domain) => domain.split('.')[1]))
|
|
88
|
+
].sort()
|
|
89
|
+
const topLevelResults = await Promise.allSettled(
|
|
90
|
+
topLevelDomains.map((domain) => this.dnsResolver.resolve(domain, 'NS'))
|
|
91
|
+
)
|
|
92
|
+
const publicTopLevelDomains = new Set()
|
|
93
|
+
|
|
94
|
+
topLevelResults.forEach((result, index) => {
|
|
95
|
+
const domain = topLevelDomains[index]
|
|
96
|
+
if (result.status === 'fulfilled') {
|
|
97
|
+
publicTopLevelDomains.add(domain)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.debug(
|
|
102
|
+
'\nUnable to verify public top-level domain %s with code %s',
|
|
103
|
+
domain,
|
|
104
|
+
(result.reason && result.reason.code) || '<unknown>'
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const rdapCandidateDomains = simpleMissingDomains.filter((domain) => {
|
|
109
|
+
const isPublic = publicTopLevelDomains.has(domain.split('.')[1])
|
|
110
|
+
if (!isPublic) {
|
|
111
|
+
incompleteDomains.push(domain)
|
|
112
|
+
}
|
|
113
|
+
return isPublic
|
|
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
|
+
})
|
|
133
|
+
|
|
88
134
|
const incompleteCount =
|
|
89
135
|
invalidMaintainers +
|
|
90
136
|
incompleteDomains.reduce((count, domain) => count + emailDomains.get(domain), 0)
|
|
91
|
-
if (
|
|
92
|
-
const domainNoun =
|
|
93
|
-
const resolveVerb =
|
|
94
|
-
let message = `Maintainer ${domainNoun} ${
|
|
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.`
|
|
95
141
|
|
|
96
142
|
if (incompleteCount > 0) {
|
|
97
143
|
const recordNoun = incompleteCount === 1 ? 'record' : 'records'
|
|
@@ -104,7 +150,7 @@ class Marshall extends BaseMarshall {
|
|
|
104
150
|
if (incompleteCount > 0) {
|
|
105
151
|
const recordNoun = incompleteCount === 1 ? 'record' : 'records'
|
|
106
152
|
throw new NotEvaluated(
|
|
107
|
-
`${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`
|
|
108
154
|
)
|
|
109
155
|
}
|
|
110
156
|
|