Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions review-enrichment/src/analyzers/dependency-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]*)/;

Expand All @@ -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]! };
Expand Down
27 changes: 27 additions & 0 deletions review-enrichment/test/typosquat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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, []);
Expand Down