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/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
+ }
@@ -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;