npq 3.15.4 → 3.16.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 +11 -1
- package/lib/helpers/levenshteinDistance.js +78 -0
- package/lib/helpers/packageRepoUtils.js +79 -0
- package/lib/helpers/sourcePackages.js +14 -3
- package/lib/marshalls/deprecation.marshall.js +32 -0
- package/lib/marshalls/typosquatting.marshall.js +4 -4
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -7,6 +7,16 @@ npq allows you to audit npm packages _before_ you install them
|
|
|
7
7
|
[](https://snyk.io/test/github/lirantal/npq)
|
|
8
8
|
[](SECURITY.md)
|
|
9
9
|
|
|
10
|
+
TL;DR how to use npq:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
$ npx npq install express --dry-run
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
_What it does: the `npx` tool downloads and execute `npq` package, runs an install check for the `express` package and `--dry-run` means npq exists regardless of success/errors_.
|
|
17
|
+
|
|
18
|
+
Here's a screenshot of npq in action:
|
|
19
|
+
|
|
10
20
|

|
|
11
21
|
|
|
12
22
|
Media coverage about npq:
|
|
@@ -133,7 +143,7 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
133
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
|
|
134
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.
|
|
135
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.
|
|
136
|
-
| deprecation | Will show a warning if the package version
|
|
146
|
+
| deprecation | Will show a warning if the package version is deprecated on npm or if its GitHub repository has been archived. | Helps identify packages that are no longer maintained or recommended for use. Set `GITHUB_TOKEN` environment variable for higher GitHub API rate limits.
|
|
137
147
|
|
|
138
148
|
### Disabling Marshalls
|
|
139
149
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates the Levenshtein distance between two strings.
|
|
5
|
+
* Uses the Wagner-Fischer algorithm with single-row space optimization
|
|
6
|
+
* and optional early termination when distance exceeds maxDistance.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} a - First string
|
|
9
|
+
* @param {string} b - Second string
|
|
10
|
+
* @param {number} [maxDistance] - Optional maximum distance threshold for early termination
|
|
11
|
+
* @returns {number} The Levenshtein edit distance between the two strings
|
|
12
|
+
*/
|
|
13
|
+
function levenshteinDistance(a, b, maxDistance) {
|
|
14
|
+
// Handle edge cases
|
|
15
|
+
if (a === b) return 0
|
|
16
|
+
if (a.length === 0) return b.length
|
|
17
|
+
if (b.length === 0) return a.length
|
|
18
|
+
|
|
19
|
+
// Ensure a is the shorter string for space optimization
|
|
20
|
+
if (a.length > b.length) {
|
|
21
|
+
const temp = a
|
|
22
|
+
a = b
|
|
23
|
+
b = temp
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const aLen = a.length
|
|
27
|
+
const bLen = b.length
|
|
28
|
+
|
|
29
|
+
// Early termination: if length difference exceeds maxDistance, no need to compute
|
|
30
|
+
if (maxDistance !== undefined && bLen - aLen >= maxDistance) {
|
|
31
|
+
return bLen - aLen
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Initialize the previous row (represents distances for empty string a prefix)
|
|
35
|
+
let prevRow = new Array(aLen + 1)
|
|
36
|
+
for (let i = 0; i <= aLen; i++) {
|
|
37
|
+
prevRow[i] = i
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Current row for computation
|
|
41
|
+
let currRow = new Array(aLen + 1)
|
|
42
|
+
|
|
43
|
+
// Fill in the matrix row by row
|
|
44
|
+
for (let j = 1; j <= bLen; j++) {
|
|
45
|
+
currRow[0] = j
|
|
46
|
+
|
|
47
|
+
let rowMin = currRow[0]
|
|
48
|
+
|
|
49
|
+
for (let i = 1; i <= aLen; i++) {
|
|
50
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
51
|
+
|
|
52
|
+
currRow[i] = Math.min(
|
|
53
|
+
prevRow[i] + 1, // deletion
|
|
54
|
+
currRow[i - 1] + 1, // insertion
|
|
55
|
+
prevRow[i - 1] + cost // substitution
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (currRow[i] < rowMin) {
|
|
59
|
+
rowMin = currRow[i]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Early termination: if minimum value in current row exceeds maxDistance,
|
|
64
|
+
// the final distance will also exceed maxDistance
|
|
65
|
+
if (maxDistance !== undefined && rowMin >= maxDistance) {
|
|
66
|
+
return rowMin
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Swap rows
|
|
70
|
+
const temp = prevRow
|
|
71
|
+
prevRow = currRow
|
|
72
|
+
currRow = temp
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return prevRow[aLen]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { levenshteinDistance }
|
|
@@ -93,6 +93,85 @@ class PackageRepoUtils {
|
|
|
93
93
|
throw new Error(`Could not find dist-tag ${packageVersion} for package ${packageName}`)
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract GitHub owner and repo from a repository URL
|
|
99
|
+
* @param {string} repoUrl - Repository URL (e.g., "git+https://github.com/owner/repo.git")
|
|
100
|
+
* @returns {{owner: string, repo: string} | null} - Owner and repo, or null if not a GitHub URL
|
|
101
|
+
*/
|
|
102
|
+
extractGitHubRepoFromUrl(repoUrl) {
|
|
103
|
+
if (!repoUrl || typeof repoUrl !== 'string') {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Match GitHub URLs in various formats:
|
|
108
|
+
// - git+https://github.com/owner/repo.git
|
|
109
|
+
// - https://github.com/owner/repo.git
|
|
110
|
+
// - https://github.com/owner/repo
|
|
111
|
+
// - git://github.com/owner/repo.git
|
|
112
|
+
// - git@github.com:owner/repo.git
|
|
113
|
+
const httpsPattern = /github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i
|
|
114
|
+
const match = repoUrl.match(httpsPattern)
|
|
115
|
+
|
|
116
|
+
if (match) {
|
|
117
|
+
return {
|
|
118
|
+
owner: match[1],
|
|
119
|
+
repo: match[2]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if a GitHub repository is archived
|
|
128
|
+
* @param {string} owner - Repository owner
|
|
129
|
+
* @param {string} repo - Repository name
|
|
130
|
+
* @returns {Promise<boolean>} - True if archived, false otherwise
|
|
131
|
+
* @throws {Error} - On rate limit or API errors
|
|
132
|
+
*/
|
|
133
|
+
async isGitHubRepoArchived(owner, repo) {
|
|
134
|
+
const headers = {
|
|
135
|
+
Accept: 'application/vnd.github.v3+json',
|
|
136
|
+
'User-Agent': 'npq-package-checker'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Use GITHUB_TOKEN if available for higher rate limits
|
|
140
|
+
if (process.env.GITHUB_TOKEN) {
|
|
141
|
+
headers.Authorization = `token ${process.env.GITHUB_TOKEN}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
145
|
+
headers
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (response.status === 403) {
|
|
149
|
+
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining')
|
|
150
|
+
if (rateLimitRemaining === '0') {
|
|
151
|
+
throw new Error(
|
|
152
|
+
'GitHub API rate limit exceeded - deprecation marshall could not evaluate repository archive status. ' +
|
|
153
|
+
'Set GITHUB_TOKEN environment variable for higher rate limits.'
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
throw new Error(
|
|
157
|
+
`GitHub API access forbidden for ${owner}/${repo} - deprecation marshall could not evaluate repository archive status`
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (response.status === 404) {
|
|
162
|
+
// Repository doesn't exist or is private - can't determine archive status
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`GitHub API error (${response.status}) - deprecation marshall could not evaluate repository archive status`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await response.json()
|
|
173
|
+
return data.archived === true
|
|
174
|
+
}
|
|
96
175
|
}
|
|
97
176
|
|
|
98
177
|
module.exports = PackageRepoUtils
|
|
@@ -12,9 +12,20 @@ async function getProjectPackages() {
|
|
|
12
12
|
const packageJSON = await fs.readFile(packageJSONPath, 'utf-8')
|
|
13
13
|
const packageData = JSON.parse(packageJSON)
|
|
14
14
|
if (packageData && packageData.dependencies) {
|
|
15
|
-
packages =
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
packages = [
|
|
16
|
+
...packages,
|
|
17
|
+
...Object.keys(packageData.dependencies).map((dep) => {
|
|
18
|
+
return `${dep}@${packageData.dependencies[dep]}`
|
|
19
|
+
})
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
if (packageData && packageData.devDependencies) {
|
|
23
|
+
packages = [
|
|
24
|
+
...packages,
|
|
25
|
+
...Object.keys(packageData.devDependencies).map((dep) => {
|
|
26
|
+
return `${dep}@${packageData.devDependencies[dep]}`
|
|
27
|
+
})
|
|
28
|
+
]
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
return packages
|
|
@@ -42,6 +42,38 @@ class Marshall extends BaseMarshall {
|
|
|
42
42
|
if (packageDeprecated) {
|
|
43
43
|
throw new Error(`Package deprecated: ${packageDeprecated}`)
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// Check if the GitHub repository is archived
|
|
47
|
+
await this.checkGitHubRepoArchived(data)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if the package's GitHub repository is archived
|
|
52
|
+
* @param {object} data - Package data from npm registry
|
|
53
|
+
* @throws {Error} - If repository is archived or on API rate limit
|
|
54
|
+
*/
|
|
55
|
+
async checkGitHubRepoArchived(data) {
|
|
56
|
+
const repoUrl = data.repository?.url
|
|
57
|
+
|
|
58
|
+
if (!repoUrl) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const githubRepo = this.packageRepoUtils.extractGitHubRepoFromUrl(repoUrl)
|
|
63
|
+
|
|
64
|
+
if (!githubRepo) {
|
|
65
|
+
// @TODO: Add support for GitLab and Bitbucket repository archive detection
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isArchived = await this.packageRepoUtils.isGitHubRepoArchived(
|
|
70
|
+
githubRepo.owner,
|
|
71
|
+
githubRepo.repo
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if (isArchived) {
|
|
75
|
+
throw new Error('Package repository has been archived on GitHub')
|
|
76
|
+
}
|
|
45
77
|
}
|
|
46
78
|
}
|
|
47
79
|
|
|
@@ -4,7 +4,7 @@ const BaseMarshall = require('./baseMarshall')
|
|
|
4
4
|
const { marshallCategories } = require('./constants')
|
|
5
5
|
|
|
6
6
|
const path = require('path')
|
|
7
|
-
const {
|
|
7
|
+
const { levenshteinDistance } = require('../helpers/levenshteinDistance')
|
|
8
8
|
const topPackagesRawJSON = require(path.join(__dirname, '../../data/top-packages.json'))
|
|
9
9
|
|
|
10
10
|
const MARSHALL_NAME = 'typosquatting'
|
|
@@ -21,7 +21,7 @@ class Marshall extends BaseMarshall {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
validate(pkg) {
|
|
24
|
-
let
|
|
24
|
+
let editDistance = null
|
|
25
25
|
let similarPackages = []
|
|
26
26
|
return new Promise((resolve, reject) => {
|
|
27
27
|
// If package is within an allow-list
|
|
@@ -36,9 +36,9 @@ class Marshall extends BaseMarshall {
|
|
|
36
36
|
return resolve([])
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
editDistance = levenshteinDistance(pkg.packageName, popularPackageNameInRepository)
|
|
40
40
|
|
|
41
|
-
if (
|
|
41
|
+
if (editDistance > 0 && editDistance < 3) {
|
|
42
42
|
similarPackages.push(popularPackageNameInRepository)
|
|
43
43
|
}
|
|
44
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "npq",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.1",
|
|
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,6 @@
|
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"fastest-levenshtein": "^1.0.16",
|
|
47
46
|
"npm-package-arg": "^13.0.2",
|
|
48
47
|
"semver": "^7.7.3",
|
|
49
48
|
"sigstore": "^3.1.0",
|