npxconfuse 1.0.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 +462 -0
- package/bin/cli.js +280 -0
- package/package.json +47 -0
- package/src/analyzer.js +167 -0
- package/src/extractors/js-bundle.js +147 -0
- package/src/extractors/package-json.js +162 -0
- package/src/formatters/csv.js +39 -0
- package/src/formatters/json.js +11 -0
- package/src/formatters/table.js +144 -0
- package/src/registries/npm.js +185 -0
- package/src/sources/github.js +142 -0
- package/src/sources/local.js +117 -0
- package/src/sources/web.js +182 -0
- package/src/utils/constants.js +181 -0
- package/src/utils/http.js +179 -0
- package/src/utils/logger.js +83 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* npxconfuse — npx confusion vulnerability scanner
|
|
5
|
+
*
|
|
6
|
+
* Scans local codebases, GitHub organizations, and web domains for
|
|
7
|
+
* package names that are unclaimed on the npm registry, enabling
|
|
8
|
+
* npx confusion and dependency confusion attacks.
|
|
9
|
+
*
|
|
10
|
+
* Based on: https://www.landh.tech/blog/20260521-npx-used-confusion-and-its-super-effective/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Command } from "commander";
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { writeFile } from "node:fs/promises";
|
|
16
|
+
import ora from "ora";
|
|
17
|
+
|
|
18
|
+
import logger, { setVerbose } from "../src/utils/logger.js";
|
|
19
|
+
|
|
20
|
+
// Sources
|
|
21
|
+
import { scanLocal } from "../src/sources/local.js";
|
|
22
|
+
import { scanGitHub } from "../src/sources/github.js";
|
|
23
|
+
import { scanWeb } from "../src/sources/web.js";
|
|
24
|
+
|
|
25
|
+
// Extractors
|
|
26
|
+
import { extractFromPackageJson } from "../src/extractors/package-json.js";
|
|
27
|
+
import { extractFromJsBundle } from "../src/extractors/js-bundle.js";
|
|
28
|
+
|
|
29
|
+
// Analysis
|
|
30
|
+
import { analyze } from "../src/analyzer.js";
|
|
31
|
+
|
|
32
|
+
// Formatters
|
|
33
|
+
import { formatTable } from "../src/formatters/table.js";
|
|
34
|
+
import { formatJson } from "../src/formatters/json.js";
|
|
35
|
+
import { formatCsv } from "../src/formatters/csv.js";
|
|
36
|
+
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
38
|
+
// CLI Setup
|
|
39
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
const program = new Command();
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.name("npxconfuse")
|
|
45
|
+
.description(
|
|
46
|
+
"npx confusion vulnerability scanner — find unclaimed npm package names",
|
|
47
|
+
)
|
|
48
|
+
.version("1.0.0")
|
|
49
|
+
.option("-v, --verbose", "Enable verbose debug output")
|
|
50
|
+
.option("-o, --output <format>", "Output format: table, json, csv", "table")
|
|
51
|
+
.option("-c, --concurrency <number>", "Max parallel registry requests", "20")
|
|
52
|
+
.option("--timeout <ms>", "HTTP timeout in milliseconds", "10000")
|
|
53
|
+
.option("--save <filepath>", "Save results to a file")
|
|
54
|
+
.hook("preAction", (thisCommand) => {
|
|
55
|
+
const opts = thisCommand.opts();
|
|
56
|
+
if (opts.verbose) setVerbose(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── scan: Local filesystem scanning ─────────────────────────────────
|
|
60
|
+
program
|
|
61
|
+
.command("scan <path>")
|
|
62
|
+
.description(
|
|
63
|
+
"Scan a local directory or file for npx confusion vulnerabilities",
|
|
64
|
+
)
|
|
65
|
+
.option("--deep", "Also scan JS bundles (slower, more thorough)")
|
|
66
|
+
.action(async (targetPath, cmdOpts) => {
|
|
67
|
+
const opts = { ...program.opts(), ...cmdOpts };
|
|
68
|
+
logger.banner();
|
|
69
|
+
logger.section("Phase 1: Discovery");
|
|
70
|
+
|
|
71
|
+
const spinner = ora("Scanning local files...").start();
|
|
72
|
+
try {
|
|
73
|
+
const files = await scanLocal(targetPath, { deep: opts.deep });
|
|
74
|
+
spinner.succeed(`Found ${files.length} files to analyze`);
|
|
75
|
+
|
|
76
|
+
const extracted = extractAll(files);
|
|
77
|
+
await runAnalysis(extracted, opts);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
spinner.fail(err.message);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── github: GitHub organization scanning ────────────────────────────
|
|
85
|
+
program
|
|
86
|
+
.command("github <org>")
|
|
87
|
+
.description("Scan a GitHub organization for npx confusion vulnerabilities")
|
|
88
|
+
.option("--max-repos <number>", "Maximum repos to scan", "1000")
|
|
89
|
+
.option("--github-enterprise <url>", "GitHub Enterprise base URL")
|
|
90
|
+
.action(async (org, cmdOpts) => {
|
|
91
|
+
const opts = { ...program.opts(), ...cmdOpts };
|
|
92
|
+
logger.banner();
|
|
93
|
+
logger.section("Phase 1: GitHub Discovery");
|
|
94
|
+
|
|
95
|
+
const spinner = ora(`Scanning GitHub org: ${org}...`).start();
|
|
96
|
+
try {
|
|
97
|
+
const files = await scanGitHub(org, {
|
|
98
|
+
maxRepos: parseInt(opts.maxRepos, 10),
|
|
99
|
+
githubEnterprise: opts.githubEnterprise,
|
|
100
|
+
});
|
|
101
|
+
spinner.succeed(`Found ${files.length} files from GitHub`);
|
|
102
|
+
|
|
103
|
+
const extracted = extractAll(files);
|
|
104
|
+
await runAnalysis(extracted, opts);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
spinner.fail(err.message);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ─── web: Web domain scanning ────────────────────────────────────────
|
|
112
|
+
program
|
|
113
|
+
.command("web <domains-file>")
|
|
114
|
+
.description("Scan web domains for exposed package.json and JS bundles")
|
|
115
|
+
.action(async (domainsFile, cmdOpts) => {
|
|
116
|
+
const opts = { ...program.opts(), ...cmdOpts };
|
|
117
|
+
logger.banner();
|
|
118
|
+
logger.section("Phase 1: Web Discovery");
|
|
119
|
+
|
|
120
|
+
const spinner = ora("Scanning web domains...").start();
|
|
121
|
+
try {
|
|
122
|
+
const files = await scanWeb(domainsFile, {
|
|
123
|
+
concurrency: parseInt(opts.concurrency, 10),
|
|
124
|
+
timeout: parseInt(opts.timeout, 10),
|
|
125
|
+
});
|
|
126
|
+
spinner.succeed(`Found ${files.length} files from web`);
|
|
127
|
+
|
|
128
|
+
const extracted = extractAll(files);
|
|
129
|
+
await runAnalysis(extracted, opts);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
spinner.fail(err.message);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── check: Direct package name checking ─────────────────────────────
|
|
137
|
+
program
|
|
138
|
+
.command("check <names-file>")
|
|
139
|
+
.description("Check a list of package names against the npm registry")
|
|
140
|
+
.action(async (namesFile, cmdOpts) => {
|
|
141
|
+
const opts = { ...program.opts(), ...cmdOpts };
|
|
142
|
+
logger.banner();
|
|
143
|
+
logger.section("Phase 1: Loading Package Names");
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const content = await readFile(namesFile, "utf-8");
|
|
147
|
+
const names = content
|
|
148
|
+
.split("\n")
|
|
149
|
+
.map((line) => line.trim())
|
|
150
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
151
|
+
|
|
152
|
+
logger.info(`Loaded ${names.length} package names from ${namesFile}`);
|
|
153
|
+
|
|
154
|
+
// Convert to extraction format
|
|
155
|
+
const extracted = names.map((name) => ({
|
|
156
|
+
name,
|
|
157
|
+
type: name.startsWith("@") ? "dependency-confusion" : "npx-confusion",
|
|
158
|
+
source: namesFile,
|
|
159
|
+
context: "direct check",
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
await runAnalysis(extracted, opts);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
logger.error(err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
170
|
+
// Shared Pipeline
|
|
171
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run all extractors on discovered files.
|
|
175
|
+
*/
|
|
176
|
+
function extractAll(files) {
|
|
177
|
+
logger.section("Phase 2: Extraction");
|
|
178
|
+
const all = [];
|
|
179
|
+
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
try {
|
|
182
|
+
let items = [];
|
|
183
|
+
|
|
184
|
+
switch (file.type) {
|
|
185
|
+
case "package-json":
|
|
186
|
+
items = extractFromPackageJson(file.content, file.filepath);
|
|
187
|
+
break;
|
|
188
|
+
case "js-bundle":
|
|
189
|
+
items = extractFromJsBundle(file.content, file.filepath);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (items.length > 0) {
|
|
194
|
+
logger.debug(
|
|
195
|
+
`Extracted ${items.length} candidates from ${file.filepath}`,
|
|
196
|
+
);
|
|
197
|
+
all.push(...items);
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
logger.warn(`Extraction error for ${file.filepath}: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.info(`Extracted ${all.length} total candidates`);
|
|
205
|
+
return all;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run analysis and output results.
|
|
210
|
+
*/
|
|
211
|
+
async function runAnalysis(extracted, opts) {
|
|
212
|
+
if (extracted.length === 0) {
|
|
213
|
+
logger.info("No package name candidates found. Nothing to check.");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
logger.section("Phase 3: Registry Analysis");
|
|
218
|
+
|
|
219
|
+
const concurrency = parseInt(opts.concurrency, 10);
|
|
220
|
+
|
|
221
|
+
const spinner = ora("Checking npm registry...").start();
|
|
222
|
+
const results = await analyze(extracted, { concurrency });
|
|
223
|
+
spinner.stop();
|
|
224
|
+
|
|
225
|
+
// ─── Output ──────────────────────────────────────────────────────────
|
|
226
|
+
logger.section("Results");
|
|
227
|
+
|
|
228
|
+
let output;
|
|
229
|
+
switch (opts.output) {
|
|
230
|
+
case "json":
|
|
231
|
+
output = formatJson(results);
|
|
232
|
+
console.log(output);
|
|
233
|
+
break;
|
|
234
|
+
case "csv":
|
|
235
|
+
output = formatCsv(results);
|
|
236
|
+
console.log(output);
|
|
237
|
+
break;
|
|
238
|
+
case "table":
|
|
239
|
+
default:
|
|
240
|
+
output = formatTable(results);
|
|
241
|
+
console.log(output);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Save to file if requested ───────────────────────────────────────
|
|
246
|
+
if (opts.save) {
|
|
247
|
+
const saveFormat = opts.save.endsWith(".csv")
|
|
248
|
+
? "csv"
|
|
249
|
+
: opts.save.endsWith(".json")
|
|
250
|
+
? "json"
|
|
251
|
+
: opts.output;
|
|
252
|
+
|
|
253
|
+
let saveContent;
|
|
254
|
+
switch (saveFormat) {
|
|
255
|
+
case "csv":
|
|
256
|
+
saveContent = formatCsv(results);
|
|
257
|
+
break;
|
|
258
|
+
case "json":
|
|
259
|
+
saveContent = formatJson(results);
|
|
260
|
+
break;
|
|
261
|
+
default:
|
|
262
|
+
saveContent = formatJson(results);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await writeFile(opts.save, saveContent, "utf-8");
|
|
267
|
+
logger.success(`Results saved to ${opts.save}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Exit with error code if critical findings
|
|
271
|
+
if (results.summary.critical > 0) {
|
|
272
|
+
process.exit(2);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
277
|
+
// Run
|
|
278
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
279
|
+
|
|
280
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "npxconfuse",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Detect npx confusion vulnerabilities — find unclaimed npm package names in your codebase, GitHub orgs, and web domains before attackers do.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"npxconfuse": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/analyzer.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node bin/cli.js",
|
|
16
|
+
"test": "echo \"No tests yet — contributions welcome!\" && exit 0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"npx-confusion",
|
|
20
|
+
"dependency-confusion",
|
|
21
|
+
"supply-chain",
|
|
22
|
+
"security",
|
|
23
|
+
"vulnerability-scanner",
|
|
24
|
+
"npm",
|
|
25
|
+
"bug-bounty",
|
|
26
|
+
"defcon",
|
|
27
|
+
"sast"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/cybershaykh/npxconfuse"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@octokit/rest": "^21.1.1",
|
|
40
|
+
"chalk": "^5.4.1",
|
|
41
|
+
"cli-table3": "^0.6.5",
|
|
42
|
+
"commander": "^13.1.0",
|
|
43
|
+
"glob": "^11.0.2",
|
|
44
|
+
"ora": "^8.2.0",
|
|
45
|
+
"p-limit": "^6.2.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/analyzer.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { checkNpmBatch } from "./registries/npm.js";
|
|
2
|
+
import {
|
|
3
|
+
SEVERITY,
|
|
4
|
+
FINDING_TYPE,
|
|
5
|
+
DEFAULT_CONCURRENCY,
|
|
6
|
+
} from "./utils/constants.js";
|
|
7
|
+
import logger from "./utils/logger.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Severity sort order (most severe first).
|
|
11
|
+
*/
|
|
12
|
+
const SEVERITY_ORDER = {
|
|
13
|
+
[SEVERITY.CRITICAL]: 0,
|
|
14
|
+
[SEVERITY.HIGH]: 1,
|
|
15
|
+
[SEVERITY.MEDIUM]: 2,
|
|
16
|
+
[SEVERITY.LOW]: 3,
|
|
17
|
+
[SEVERITY.INFO]: 4,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Analyze extracted items by checking them against the npm registry.
|
|
22
|
+
*
|
|
23
|
+
* @param {Array<{name: string, type: string, source: string, context: string}>} extractedItems
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {number} options.concurrency - Max parallel requests
|
|
26
|
+
* @returns {Promise<{findings: object[], summary: object}>}
|
|
27
|
+
*/
|
|
28
|
+
export async function analyze(extractedItems, options = {}) {
|
|
29
|
+
const concurrency = options.concurrency || DEFAULT_CONCURRENCY;
|
|
30
|
+
|
|
31
|
+
if (extractedItems.length === 0) {
|
|
32
|
+
return {
|
|
33
|
+
findings: [],
|
|
34
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, info: 0 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── 1. Deduplicate: group by name, merge sources/contexts ──
|
|
39
|
+
const grouped = new Map();
|
|
40
|
+
|
|
41
|
+
for (const item of extractedItems) {
|
|
42
|
+
const key = item.name;
|
|
43
|
+
if (grouped.has(key)) {
|
|
44
|
+
const existing = grouped.get(key);
|
|
45
|
+
existing.sources.add(item.source);
|
|
46
|
+
existing.contexts.add(item.context);
|
|
47
|
+
// Keep the most severe type
|
|
48
|
+
if (typeSeverity(item.type) < typeSeverity(existing.type)) {
|
|
49
|
+
existing.type = item.type;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
grouped.set(key, {
|
|
53
|
+
name: item.name,
|
|
54
|
+
type: item.type,
|
|
55
|
+
sources: new Set([item.source]),
|
|
56
|
+
contexts: new Set([item.context]),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.info(
|
|
62
|
+
`Deduplicated to ${grouped.size} unique names from ${extractedItems.length} extractions`,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// ── 2. Collect all names for npm check ──
|
|
66
|
+
const names = [...grouped.keys()];
|
|
67
|
+
|
|
68
|
+
// ── 3. Batch check npm registry ──
|
|
69
|
+
logger.section("Registry Check");
|
|
70
|
+
|
|
71
|
+
const npmResults =
|
|
72
|
+
names.length > 0 ? await checkNpmBatch(names, concurrency) : [];
|
|
73
|
+
|
|
74
|
+
// Index results by name
|
|
75
|
+
const registryResults = new Map();
|
|
76
|
+
for (const r of npmResults) {
|
|
77
|
+
registryResults.set(r.name, r);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── 4. Merge results and assign final severity ──
|
|
81
|
+
const findings = [];
|
|
82
|
+
|
|
83
|
+
for (const [name, item] of grouped) {
|
|
84
|
+
const regResult = registryResults.get(name);
|
|
85
|
+
if (!regResult) continue;
|
|
86
|
+
|
|
87
|
+
const finding = {
|
|
88
|
+
name,
|
|
89
|
+
type: item.type,
|
|
90
|
+
registry: "npm",
|
|
91
|
+
status: regResult.status,
|
|
92
|
+
severity: computeSeverity(item.type, regResult.status),
|
|
93
|
+
owner: regResult.owner || null,
|
|
94
|
+
description: regResult.description || null,
|
|
95
|
+
details: regResult.details || null,
|
|
96
|
+
weeklyDownloads: regResult.weeklyDownloads ?? null,
|
|
97
|
+
lastPublish: regResult.lastPublish || null,
|
|
98
|
+
createdAt: regResult.createdAt || null,
|
|
99
|
+
sources: [...item.sources],
|
|
100
|
+
contexts: [...item.contexts],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
findings.push(finding);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 5. Sort by severity ──
|
|
107
|
+
findings.sort((a, b) => {
|
|
108
|
+
const severityDiff =
|
|
109
|
+
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99);
|
|
110
|
+
if (severityDiff !== 0) return severityDiff;
|
|
111
|
+
return a.name.localeCompare(b.name);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── 6. Build summary ──
|
|
115
|
+
const summary = {
|
|
116
|
+
total: findings.length,
|
|
117
|
+
critical: findings.filter((f) => f.severity === SEVERITY.CRITICAL).length,
|
|
118
|
+
high: findings.filter((f) => f.severity === SEVERITY.HIGH).length,
|
|
119
|
+
medium: findings.filter((f) => f.severity === SEVERITY.MEDIUM).length,
|
|
120
|
+
low: findings.filter((f) => f.severity === SEVERITY.LOW).length,
|
|
121
|
+
info: findings.filter((f) => f.severity === SEVERITY.INFO).length,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return { findings, summary };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute the final severity based on finding type and registry status.
|
|
129
|
+
*/
|
|
130
|
+
function computeSeverity(type, status) {
|
|
131
|
+
if (status === "unclaimed") {
|
|
132
|
+
if (type === FINDING_TYPE.NPX_CONFUSION) return SEVERITY.CRITICAL;
|
|
133
|
+
if (type === FINDING_TYPE.BIN_MISMATCH) return SEVERITY.CRITICAL;
|
|
134
|
+
if (type === FINDING_TYPE.DEPENDENCY_CONFUSION) return SEVERITY.HIGH;
|
|
135
|
+
return SEVERITY.HIGH;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (status === "claimed") {
|
|
139
|
+
return SEVERITY.MEDIUM; // potential name clash
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (status === "private") {
|
|
143
|
+
return SEVERITY.INFO;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return SEVERITY.INFO; // error or unknown
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get a numeric severity for finding type (lower = more severe).
|
|
151
|
+
*/
|
|
152
|
+
function typeSeverity(type) {
|
|
153
|
+
switch (type) {
|
|
154
|
+
case FINDING_TYPE.NPX_CONFUSION:
|
|
155
|
+
return 0;
|
|
156
|
+
case FINDING_TYPE.BIN_MISMATCH:
|
|
157
|
+
return 1;
|
|
158
|
+
case FINDING_TYPE.DEPENDENCY_CONFUSION:
|
|
159
|
+
return 2;
|
|
160
|
+
case FINDING_TYPE.NAME_CLASH:
|
|
161
|
+
return 3;
|
|
162
|
+
default:
|
|
163
|
+
return 4;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default analyze;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { NODE_BUILTINS, FINDING_TYPE } from '../utils/constants.js';
|
|
2
|
+
import { extractFromPackageJson } from './package-json.js';
|
|
3
|
+
import logger from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract package name candidates from compiled JS bundles
|
|
7
|
+
* (webpack, Vite, Rollup, esbuild output).
|
|
8
|
+
*
|
|
9
|
+
* @param {string} content - Raw JS bundle content
|
|
10
|
+
* @param {string} filepath - Path/URL to the bundle
|
|
11
|
+
* @returns {Array<{name: string, type: string, source: string, context: string}>}
|
|
12
|
+
*/
|
|
13
|
+
export function extractFromJsBundle(content, filepath) {
|
|
14
|
+
const results = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
|
|
17
|
+
// ── 1. Try to find embedded package.json ──
|
|
18
|
+
const pkgJsonPattern = /\{[^{}]*"name"\s*:\s*"(@?[a-zA-Z0-9][\w./-]*)"/g;
|
|
19
|
+
let pkgMatch;
|
|
20
|
+
while ((pkgMatch = pkgJsonPattern.exec(content)) !== null) {
|
|
21
|
+
// Try to extract a valid JSON object starting from this position
|
|
22
|
+
const start = content.lastIndexOf('{', pkgMatch.index);
|
|
23
|
+
if (start === -1) continue;
|
|
24
|
+
|
|
25
|
+
// Find the matching closing brace (simple depth tracking)
|
|
26
|
+
let depth = 0;
|
|
27
|
+
let end = -1;
|
|
28
|
+
for (let i = start; i < Math.min(content.length, start + 5000); i++) {
|
|
29
|
+
if (content[i] === '{') depth++;
|
|
30
|
+
else if (content[i] === '}') {
|
|
31
|
+
depth--;
|
|
32
|
+
if (depth === 0) { end = i + 1; break; }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (end > start) {
|
|
37
|
+
const candidate = content.slice(start, end);
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(candidate);
|
|
40
|
+
if (parsed.name && (parsed.scripts || parsed.bin || parsed.dependencies)) {
|
|
41
|
+
logger.debug(`Found embedded package.json in bundle: ${parsed.name}`);
|
|
42
|
+
const embedded = extractFromPackageJson(candidate, `${filepath} (embedded: ${parsed.name})`);
|
|
43
|
+
for (const item of embedded) {
|
|
44
|
+
if (!seen.has(item.name)) {
|
|
45
|
+
seen.add(item.name);
|
|
46
|
+
results.push(item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Not valid JSON, skip
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 2. require() calls ──
|
|
57
|
+
const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = requirePattern.exec(content)) !== null) {
|
|
60
|
+
addCandidate(match[1], seen, results, filepath, 'require()');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── 3. import ... from "pkg" ──
|
|
64
|
+
const importFromPattern = /from\s+['"]([^'"]+)['"]/g;
|
|
65
|
+
while ((match = importFromPattern.exec(content)) !== null) {
|
|
66
|
+
addCandidate(match[1], seen, results, filepath, 'import from');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 4. dynamic import() ──
|
|
70
|
+
const dynamicImportPattern = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
71
|
+
while ((match = dynamicImportPattern.exec(content)) !== null) {
|
|
72
|
+
addCandidate(match[1], seen, results, filepath, 'dynamic import()');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 5. __webpack_require__() ──
|
|
76
|
+
const webpackPattern = /__webpack_require__\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
77
|
+
while ((match = webpackPattern.exec(content)) !== null) {
|
|
78
|
+
addCandidate(match[1], seen, results, filepath, '__webpack_require__');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate and add a package name candidate.
|
|
86
|
+
*/
|
|
87
|
+
function addCandidate(raw, seen, results, filepath, context) {
|
|
88
|
+
const name = raw.trim();
|
|
89
|
+
|
|
90
|
+
// Skip if already seen
|
|
91
|
+
if (seen.has(name)) return;
|
|
92
|
+
|
|
93
|
+
// Skip relative paths
|
|
94
|
+
if (name.startsWith('./') || name.startsWith('../') || name.startsWith('/')) return;
|
|
95
|
+
|
|
96
|
+
// Skip URLs
|
|
97
|
+
if (name.startsWith('http://') || name.startsWith('https://') || name.startsWith('data:')) return;
|
|
98
|
+
|
|
99
|
+
// Skip Node.js built-ins
|
|
100
|
+
if (NODE_BUILTINS.has(name)) return;
|
|
101
|
+
// Also check without node: prefix
|
|
102
|
+
if (name.startsWith('node:') && NODE_BUILTINS.has(name.slice(5))) return;
|
|
103
|
+
|
|
104
|
+
// Skip things that look like file paths (contain multiple /)
|
|
105
|
+
// but allow scoped packages like @scope/name
|
|
106
|
+
if (!name.startsWith('@') && (name.split('/').length > 2)) return;
|
|
107
|
+
if (name.startsWith('@') && (name.split('/').length > 2)) return;
|
|
108
|
+
|
|
109
|
+
// Skip obviously invalid names
|
|
110
|
+
if (name.length === 0 || name.length > 214) return;
|
|
111
|
+
if (/\s/.test(name)) return;
|
|
112
|
+
|
|
113
|
+
// Validate npm package name format:
|
|
114
|
+
// - Must start with a letter, digit, or @
|
|
115
|
+
// - Can only contain lowercase alphanum, hyphens, dots, underscores, tildes
|
|
116
|
+
// - Must be at least 2 chars for unscoped, 4 chars for scoped (@x/y)
|
|
117
|
+
// - No uppercase (npm lowercases everything)
|
|
118
|
+
// - No special chars like +, =, !, etc.
|
|
119
|
+
const validNpmName = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
120
|
+
if (!validNpmName.test(name)) return;
|
|
121
|
+
|
|
122
|
+
// Skip single-character names (almost always minified variable refs)
|
|
123
|
+
if (!name.startsWith('@') && name.length < 2) return;
|
|
124
|
+
|
|
125
|
+
// For scoped packages, extract just the scope/name part
|
|
126
|
+
let packageName = name;
|
|
127
|
+
if (name.startsWith('@')) {
|
|
128
|
+
// @scope/name or @scope/name/subpath
|
|
129
|
+
const parts = name.split('/');
|
|
130
|
+
packageName = `${parts[0]}/${parts[1]}`;
|
|
131
|
+
} else {
|
|
132
|
+
// name or name/subpath → just the package name
|
|
133
|
+
packageName = name.split('/')[0];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
seen.add(name);
|
|
137
|
+
seen.add(packageName);
|
|
138
|
+
|
|
139
|
+
results.push({
|
|
140
|
+
name: packageName,
|
|
141
|
+
type: FINDING_TYPE.DEPENDENCY_CONFUSION,
|
|
142
|
+
source: filepath,
|
|
143
|
+
context: `JS bundle ${context}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default extractFromJsBundle;
|