From 7ad4d9f9984adcdac7f3d59f8c816c4011ae3cca Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:26:29 -0700 Subject: [PATCH] fix(enrichment): scan npm alias dependency targets --- .../src/analyzers/dependency-scan.ts | 13 ++++++--- review-enrichment/test/typosquat.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/review-enrichment/src/analyzers/dependency-scan.ts b/review-enrichment/src/analyzers/dependency-scan.ts index 038f31de1c..7abe465d2e 100644 --- a/review-enrichment/src/analyzers/dependency-scan.ts +++ b/review-enrichment/src/analyzers/dependency-scan.ts @@ -27,7 +27,9 @@ interface ScanOptions { // Per-manifest line parsers. Each returns [name, version] for a `+`/`-` diff line, or null. Heuristic (line-based, // not a full manifest parse) — good enough to flag the deps a PR adds/bumps without resolving the whole tree. -const NPM_RE = /^"([^"]+)"\s*:\s*"([\^~>=<\s]*[0-9][^"]*)"/; +const NPM_RE = /^"([^"]+)"\s*:\s*"([^"]+)"/; +const NPM_ALIAS_RE = /^npm:(@[^/]+\/[^@]+|[^@]+)@(.+)$/; +const NPM_VERSION_PREFIX_RE = /^[\^~>=<\s]+/; const PYPI_RE = /^([A-Za-z0-9._-]+)\s*==\s*([0-9][^\s;]*)/; const GO_RE = /^([a-z0-9.\/-]+)\s+v([0-9][^\s]*)/; @@ -37,8 +39,13 @@ function parseLine( ): { name: string; version: string } | null { if (manifest === "package.json") { const m = NPM_RE.exec(body); - if (m) - return { name: m[1]!, version: m[2]!.replace(/^[\^~>=<\s]+/, "").trim() }; + if (m) { + const spec = m[2]!.trim(); + const alias = NPM_ALIAS_RE.exec(spec); + if (alias) return { name: alias[1]!, version: alias[2]!.replace(NPM_VERSION_PREFIX_RE, "").trim() }; + if (/^[\^~>=<\s]*[0-9]/.test(spec)) + return { name: m[1]!, version: spec.replace(NPM_VERSION_PREFIX_RE, "").trim() }; + } } else if (manifest === "requirements.txt") { const m = PYPI_RE.exec(body); if (m) return { name: m[1]!, version: m[2]! }; diff --git a/review-enrichment/test/typosquat.test.ts b/review-enrichment/test/typosquat.test.ts index 244764257a..6b8122d039 100644 --- a/review-enrichment/test/typosquat.test.ts +++ b/review-enrichment/test/typosquat.test.ts @@ -18,6 +18,12 @@ const npmAdd = (name, version = "1.0.0") => ({ files: [{ path: "package.json", patch: `@@ -1,0 +1,1 @@\n+ "${name}": "^${version}"` }], }); +const npmAliasAdd = (alias, target, version = "1.0.0") => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `@@ -1,0 +1,1 @@\n+ "${alias}": "npm:${target}@${version}"` }], +}); + // Fetch stubs returning a minimal Response-like shape (status + ok), matching the other analyzer tests. const status = (code) => async () => ({ status: code, ok: code >= 200 && code < 300 }); const throwingFetch = async () => { @@ -106,6 +112,27 @@ test("scanTyposquat flags dependency-confusion on a 404 unscoped name", async () assert.match(findings[0].reason, /publicly claimable/); }); +test("scanTyposquat scans npm alias targets for typosquats", async () => { + let called = false; + const findings = await scanTyposquat(npmAliasAdd("lodash", "l0dash", "^1.0.0"), async () => { + called = true; + return status(404)(); + }); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "typosquat"); + assert.equal(findings[0].package, "l0dash"); + assert.equal(findings[0].version, "1.0.0"); + assert.equal(called, false); +}); + +test("scanTyposquat scans npm alias targets for dependency-confusion", async () => { + const findings = await scanTyposquat(npmAliasAdd("react", "acme-internal-utils"), status(404)); + assert.equal(findings.length, 1); + assert.equal(findings[0].kind, "confusion"); + assert.equal(findings[0].package, "acme-internal-utils"); + assert.match(findings[0].reason, /publicly claimable/); +}); + test("scanTyposquat: a published unscoped name is not flagged", async () => { const findings = await scanTyposquat(npmAdd("acme-internal-utils"), status(200)); assert.deepEqual(findings, []);