From bc1fc9428398813c57a33489ed56442c1f197b46 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Sun, 23 Aug 2026 23:08:35 -0500 Subject: [PATCH] fix(yarn): stop a dependency named "version" from clobbering the entry version parseYarnLock scans every indented line of an entry with no first-wins guard, so a line inside a dependencies:/peerDependencies: block whose package is literally named "version" (a real npm package) matches the version-field prefix and overwrites the already-captured entry version. A Classic entry for board2d@1.0.0 that depends on version@^0.1.0 was inventoried as version "^0.1.0"; the Berry form emitted "npm:^0.1.0". Since exposure matching is exact name+version, a compromised package at the pinned version was silently not flagged. Guard the capture with first-wins, the same way parsePnpmPackages in internal/ecosystem/pnpm/pnpm.go already guards the same field (strings.HasPrefix(trim, "version:") && cur.version == ""); the yarn twin never got the guard. Safe because yarn Classic and Berry always emit the entry's own version as the first field of an entry. --- internal/ecosystem/yarn/yarn.go | 6 ++- internal/ecosystem/yarn/yarn_test.go | 63 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/ecosystem/yarn/yarn.go b/internal/ecosystem/yarn/yarn.go index 8eb3f34..8000820 100644 --- a/internal/ecosystem/yarn/yarn.go +++ b/internal/ecosystem/yarn/yarn.go @@ -157,7 +157,11 @@ func parseYarnLock(data []byte) []yarnEntry { continue } line := strings.TrimSpace(raw) - if strings.HasPrefix(line, "version ") || strings.HasPrefix(line, "version:") { + // First-wins: yarn (Classic and Berry) always emits the entry's own + // version as the first field, so any later match is a dependency + // literally named "version" inside a dependencies:/peerDependencies: + // block, not the entry version. + if (strings.HasPrefix(line, "version ") || strings.HasPrefix(line, "version:")) && cur.version == "" { cur.version = unquote(trimField(line, "version")) } } diff --git a/internal/ecosystem/yarn/yarn_test.go b/internal/ecosystem/yarn/yarn_test.go index a826da6..ebb26a7 100644 --- a/internal/ecosystem/yarn/yarn_test.go +++ b/internal/ecosystem/yarn/yarn_test.go @@ -212,3 +212,66 @@ __metadata: t.Errorf("lodash: %+v", out[0]) } } + +// TestScanLockfile_DependencyNamedVersion guards against a dependency +// literally named "version" (a real npm package) inside a +// dependencies:/peerDependencies: block overwriting the entry's own +// version, which yarn always emits as the first field. +func TestScanLockfile_DependencyNamedVersion(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "classic", + body: `# yarn lockfile v1 + +board2d@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/board2d/-/board2d-1.0.0.tgz" + integrity sha512-xyz + dependencies: + version "^0.1.0" +`, + }, + { + name: "berry", + body: `# This file is generated by running "yarn install" inside your project. + +__metadata: + version: 6 + cacheKey: 8 + +"board2d@npm:^1.0.0": + version: 1.0.0 + resolution: "board2d@npm:1.0.0" + dependencies: + version: "npm:^0.1.0" + checksum: 10/abc + languageName: node + linkType: hard +`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "yarn.lock") + if err := os.WriteFile(path, []byte(c.body), 0o644); err != nil { + t.Fatal(err) + } + var out []model.Record + s := &Scanner{MaxFileSize: 1 << 20, Emit: func(r model.Record) { out = append(out, r) }} + if err := s.ScanLockfile(path, model.Record{}); err != nil { + t.Fatal(err) + } + if len(out) != 1 { + t.Fatalf("want 1 record, got %d", len(out)) + } + if out[0].PackageName != "board2d" || out[0].Version != "1.0.0" { + t.Errorf("board2d: got name=%q version=%q, want name=%q version=%q", + out[0].PackageName, out[0].Version, "board2d", "1.0.0") + } + }) + } +}