Repository: G-Rath/osv-detector (go)

Issue:
Support parsing yarn.lock
We should support parsing `yarn.lock` for the `npm` ecosystem.

`yarn.lock` is _almost_ YAML but iirc isn't entirely (at least in v1); luckily the format isn't super complex (I wrote a parser for it a while ago for something else, which I can try and dig up)

this is sort of doing it: https://github.com/G-Rath/scripts/blob/master/src/yarn_remove_package_from_lock.js though I thought I wrote a proper one at some point 🤔 

Pull request:
feat: support parsing `yarn.lock`
Resolves #14 

Patch:
diff --git a/README.md b/README.md
index 5365f81..576a31a 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ The detector supports parsing the following lockfiles:
 | Lockfile             | Ecosystem   | Tool       |
 | -------------------- | ----------- | ---------- |
 | `package-lock.json`  | `npm`       | `npm`      |
+| `yarn.lock`          | `npm`       | `yarn      |
 | `composer.lock`      | `Packagist` | `composer` |
 | `Gemfile.lock`       | `RubyGems`  | `bundler`  |
 | `requirements.txt`\* | `PyPI`      | `pip`      |
diff --git a/detector/parsers/fixtures/yarn/empty.v1.lock b/detector/parsers/fixtures/yarn/empty.v1.lock
new file mode 100644
index 0000000..c37fc61
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/empty.v1.lock
@@ -0,0 +1,3 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
diff --git a/detector/parsers/fixtures/yarn/empty.v2.lock b/detector/parsers/fixtures/yarn/empty.v2.lock
new file mode 100644
index 0000000..c81073d
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/empty.v2.lock
@@ -0,0 +1,2 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
diff --git a/detector/parsers/fixtures/yarn/metadata-only.v2.lock b/detector/parsers/fixtures/yarn/metadata-only.v2.lock
new file mode 100644
index 0000000..ae05d58
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/metadata-only.v2.lock
@@ -0,0 +1,6 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 5
+  cacheKey: 8
diff --git a/detector/parsers/fixtures/yarn/multiple-constraints.v1.lock b/detector/parsers/fixtures/yarn/multiple-constraints.v1.lock
new file mode 100644
index 0000000..1b26f89
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/multiple-constraints.v1.lock
@@ -0,0 +1,15 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+domelementtype@1, domelementtype@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
+  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13":
+  version "7.12.13"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658"
+  integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==
+  dependencies:
+    "@babel/highlight" "^7.12.13"
diff --git a/detector/parsers/fixtures/yarn/multiple-versions.v1.lock b/detector/parsers/fixtures/yarn/multiple-versions.v1.lock
new file mode 100644
index 0000000..8d38ac3
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/multiple-versions.v1.lock
@@ -0,0 +1,32 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+define-properties@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
+  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+  dependencies:
+    object-keys "^1.0.12"
+
+define-property@^0.2.5:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
+  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
+  dependencies:
+    is-descriptor "^0.1.0"
+
+define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
+  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
+  dependencies:
+    is-descriptor "^1.0.0"
+
+define-property@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
+  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
+  dependencies:
+    is-descriptor "^1.0.2"
+    isobject "^3.0.1"
diff --git a/detector/parsers/fixtures/yarn/multiple-versions.v2.lock b/detector/parsers/fixtures/yarn/multiple-versions.v2.lock
new file mode 100644
index 0000000..e3f5a86
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/multiple-versions.v2.lock
@@ -0,0 +1,36 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 5
+  cacheKey: 8
+
+"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3":
+  version: 4.3.3
+  resolution: "debug@npm:4.3.3"
+  dependencies:
+    ms: 2.1.2
+  peerDependenciesMeta:
+    supports-color:
+      optional: true
+  checksum: 14472d56fe4a94dbcfaa6dbed2dd3849f1d72ba78104a1a328047bb564643ca49df0224c3a17fa63533fd11dd3d4c8636cd861191232a2c6735af00cc2d4de16
+  languageName: node
+  linkType: hard
+
+"debug@npm:^2.6.9":
+  version: 2.6.9
+  resolution: "debug@npm:2.6.9"
+  dependencies:
+    ms: 2.0.0
+  checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6
+  languageName: node
+  linkType: hard
+
+"debug@npm:^3.2.7":
+  version: 3.2.7
+  resolution: "debug@npm:3.2.7"
+  dependencies:
+    ms: ^2.1.1
+  checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c
+  languageName: node
+  linkType: hard
diff --git a/detector/parsers/fixtures/yarn/one-package.v1.lock b/detector/parsers/fixtures/yarn/one-package.v1.lock
new file mode 100644
index 0000000..6fef048
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/one-package.v1.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
diff --git a/detector/parsers/fixtures/yarn/one-package.v2.lock b/detector/parsers/fixtures/yarn/one-package.v2.lock
new file mode 100644
index 0000000..0296db5
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/one-package.v2.lock
@@ -0,0 +1,13 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 5
+  cacheKey: 8
+
+"balanced-match@npm:^1.0.0":
+  version: 1.0.2
+  resolution: "balanced-match@npm:1.0.2"
+  checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65
+  languageName: node
+  linkType: hard
diff --git a/detector/parsers/fixtures/yarn/scoped-packages.v1.lock b/detector/parsers/fixtures/yarn/scoped-packages.v1.lock
new file mode 100644
index 0000000..a5c9b41
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/scoped-packages.v1.lock
@@ -0,0 +1,15 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@7.12.11":
+  version "7.12.11"
+  resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz"
+  integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
+  dependencies:
+    "@babel/highlight" "^7.10.4"
+
+"@babel/compat-data@^7.13.11":
+  version "7.14.0"
+  resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.0.tgz"
+  integrity sha512-vu9V3uMM/1o5Hl5OekMUowo3FqXLJSw+s+66nt0fSWVWTtmosdzn45JHOB3cPtZoe6CTBDzvSw0RdOY85Q37+Q==
diff --git a/detector/parsers/fixtures/yarn/scoped-packages.v2.lock b/detector/parsers/fixtures/yarn/scoped-packages.v2.lock
new file mode 100644
index 0000000..3d545e4
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/scoped-packages.v2.lock
@@ -0,0 +1,49 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 5
+  cacheKey: 8
+
+"@babel/cli@npm:^7.4.4":
+  version: 7.16.8
+  resolution: "@babel/cli@npm:7.16.8"
+  dependencies:
+    "@nicolo-ribaudo/chokidar-2": 2.1.8-no-fsevents.3
+    chokidar: ^3.4.0
+    commander: ^4.0.1
+    convert-source-map: ^1.1.0
+    fs-readdir-recursive: ^1.1.0
+    glob: ^7.0.0
+    make-dir: ^2.1.0
+    slash: ^2.0.0
+    source-map: ^0.5.0
+  peerDependencies:
+    "@babel/core": ^7.0.0-0
+  dependenciesMeta:
+    "@nicolo-ribaudo/chokidar-2":
+      optional: true
+    chokidar:
+      optional: true
+  bin:
+    babel: ./bin/babel.js
+    babel-external-helpers: ./bin/babel-external-helpers.js
+  checksum: bb0cf50ff502a30e92918cf644192351023a9ef615acc3e7774abbd0327948d93e58ab3a8d31f52c18e2f04df8af10186b89421c28f4f0a2eee6b1ddce17a8ef
+  languageName: node
+  linkType: hard
+
+"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7":
+  version: 7.16.7
+  resolution: "@babel/code-frame@npm:7.16.7"
+  dependencies:
+    "@babel/highlight": ^7.16.7
+  checksum: db2f7faa31bc2c9cf63197b481b30ea57147a5fc1a6fab60e5d6c02cdfbf6de8e17b5121f99917b3dabb5eeb572da078312e70697415940383efc140d4e0808b
+  languageName: node
+  linkType: hard
+
+"@babel/compat-data@npm:^7.13.11, @babel/compat-data@npm:^7.16.4, @babel/compat-data@npm:^7.16.8":
+  version: 7.16.8
+  resolution: "@babel/compat-data@npm:7.16.8"
+  checksum: 10da2dac5ea9589c251412b00920889910e476c1ab24cd7095577635bc3a27c785151c89db4e26285fd39f509510ec29ab9d7e721f4fc16e4aec221cacde784b
+  languageName: node
+  linkType: hard
diff --git a/detector/parsers/fixtures/yarn/two-packages.v1.lock b/detector/parsers/fixtures/yarn/two-packages.v1.lock
new file mode 100644
index 0000000..5bbb31d
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/two-packages.v1.lock
@@ -0,0 +1,18 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+concat-stream@^1.5.0:
+  version "1.6.2"
+  resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz"
+  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^2.2.2"
+    typedarray "^0.0.6"
diff --git a/detector/parsers/fixtures/yarn/two-packages.v2.lock b/detector/parsers/fixtures/yarn/two-packages.v2.lock
new file mode 100644
index 0000000..3521d43
--- /dev/null
+++ b/detector/parsers/fixtures/yarn/two-packages.v2.lock
@@ -0,0 +1,23 @@
+# This file is generated by running "yarn install" inside your project.
+# Manual changes might be lost - proceed with caution!
+
+__metadata:
+  version: 5
+  cacheKey: 8
+
+"compare-func@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "compare-func@npm:2.0.0"
+  dependencies:
+    array-ify: ^1.0.0
+    dot-prop: ^5.1.0
+  checksum: fb71d70632baa1e93283cf9d80f30ac97f003aabee026e0b4426c9716678079ef5fea7519b84d012cbed938c476493866a38a79760564a9e21ae9433e40e6f0d
+  languageName: node
+  linkType: hard
+
+"concat-map@npm:0.0.1":
+  version: 0.0.1
+  resolution: "concat-map@npm:0.0.1"
+  checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af
+  languageName: node
+  linkType: hard
diff --git a/detector/parsers/parse-npm-lock.go b/detector/parsers/parse-npm-lock.go
index 536c31c..9ac0d5a 100644
--- a/detector/parsers/parse-npm-lock.go
+++ b/detector/parsers/parse-npm-lock.go
@@ -70,7 +70,7 @@ func parseNpmLockDependencies(dependencies map[string]NpmLockDependency) map[str
 	return details
 }
 
-func extractPackageName(name string) string {
+func extractNpmPackageName(name string) string {
 	maybeScope := path.Base(path.Dir(name))
 	pkgName := path.Base(name)
 
@@ -88,7 +88,7 @@ func parseNpmLockPackages(packages map[string]NpmLockPackage) map[string]Package
 		if namePath == "" {
 			continue
 		}
-		finalName := extractPackageName(namePath)
+		finalName := extractNpmPackageName(namePath)
 
 		details[finalName+"@"+detail.Version] = PackageDetails{
 			Name:      finalName,
diff --git a/detector/parsers/parse-yarn-lock.go b/detector/parsers/parse-yarn-lock.go
new file mode 100644
index 0000000..ef12318
--- /dev/null
+++ b/detector/parsers/parse-yarn-lock.go
@@ -0,0 +1,113 @@
+package parsers
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+)
+
+const YarnEcosystem = NpmEcosystem
+
+func shouldSkipYarnLine(line string) bool {
+	return line == "" || strings.HasPrefix(line, "#")
+}
+
+func groupPackageLines(scanner *bufio.Scanner) [][]string {
+	var groups [][]string
+	var group []string
+
+	for scanner.Scan() {
+		line := scanner.Text()
+
+		if shouldSkipYarnLine(line) {
+			continue
+		}
+
+		// represents the start of a new dependency
+		if !strings.HasPrefix(line, " ") {
+			if len(group) > 0 {
+				groups = append(groups, group)
+			}
+			group = make([]string, 0)
+		}
+
+		group = append(group, line)
+	}
+
+	if len(group) > 0 {
+		groups = append(groups, group)
+	}
+
+	return groups
+}
+
+func extractYarnPackageName(str string) string {
+	str = strings.TrimPrefix(str, "\"")
+
+	isScoped := strings.HasPrefix(str, "@")
+
+	if isScoped {
+		str = strings.TrimPrefix(str, "@")
+	}
+
+	name := strings.SplitN(str, "@", 2)[0]
+
+	if isScoped {
+		name = "@" + name
+	}
+
+	return name
+}
+
+func determineYarnPackageVersion(group []string) string {
+	re := regexp.MustCompile(`^ {2}version:? "?([\d.]+)"?$`)
+
+	for _, s := range group {
+		matched := re.FindStringSubmatch(s)
+
+		if matched != nil {
+			return matched[1]
+		}
+	}
+
+	// todo: decide what to do here - maybe panic...?
+	return ""
+}
+
+func parsePackageGroup(group []string) PackageDetails {
+	return PackageDetails{
+		Name:      extractYarnPackageName(group[0]),
+		Version:   determineYarnPackageVersion(group),
+		Ecosystem: YarnEcosystem,
+	}
+}
+
+func ParseYarnLock(pathToLockfile string) ([]PackageDetails, error) {
+	var packages []PackageDetails
+
+	file, err := os.Open(pathToLockfile)
+	if err != nil {
+		return packages, fmt.Errorf("could not open %s: %w", pathToLockfile, err)
+	}
+	defer file.Close()
+
+	scanner := bufio.NewScanner(file)
+
+	packageGroups := groupPackageLines(scanner)
+
+	if err := scanner.Err(); err != nil {
+		return packages, fmt.Errorf("error while scanning %s: %w", pathToLockfile, err)
+	}
+
+	for _, group := range packageGroups {
+		if group[0] == "__metadata:" {
+			continue
+		}
+
+		packages = append(packages, parsePackageGroup(group))
+	}
+
+	return packages, nil
+}
diff --git a/detector/parsers/parsers.go b/detector/parsers/parsers.go
index 5d30ba0..f1a553a 100644
--- a/detector/parsers/parsers.go
+++ b/detector/parsers/parsers.go
@@ -13,6 +13,8 @@ func findParser(pathToLockfile string) PackageDetailsParser {
 		return ParseGemfileLock
 	case "package-lock.json":
 		return ParseNpmLock
+	case "yarn.lock":
+		return ParseYarnLock
 	case "requirements.txt":
 		return ParseRequirementsTxt
 	default:


Test patch:
diff --git a/detector/parsers/parse-yarn-lock-v1_test.go b/detector/parsers/parse-yarn-lock-v1_test.go
new file mode 100644
index 0000000..1d25698
--- /dev/null
+++ b/detector/parsers/parse-yarn-lock-v1_test.go
@@ -0,0 +1,147 @@
+package parsers_test
+
+import (
+	"osv-detector/detector/parsers"
+	"testing"
+)
+
+func TestParseYarnLock_v1_FileDoesNotExist(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/does-not-exist")
+
+	expectErrContaining(t, err, "could not open")
+	expectPackages(t, packages, []parsers.PackageDetails{})
+}
+
+func TestYarnLock_v1_NoPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/empty.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{})
+}
+
+func TestYarnLock_v1_OnePackage(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/one-package.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "balanced-match",
+			Version:   "1.0.2",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v1_TwoPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/two-packages.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "concat-stream",
+			Version:   "1.6.2",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "concat-map",
+			Version:   "0.0.1",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v1_MultipleVersions(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/multiple-versions.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "define-properties",
+			Version:   "1.1.3",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "define-property",
+			Version:   "0.2.5",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "define-property",
+			Version:   "1.0.0",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "define-property",
+			Version:   "2.0.2",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v1_MultipleConstraints(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/multiple-constraints.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "@babel/code-frame",
+			Version:   "7.12.13",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "domelementtype",
+			Version:   "1.3.1",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v1_ScopedPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/scoped-packages.v1.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "@babel/code-frame",
+			Version:   "7.12.11",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "@babel/compat-data",
+			Version:   "7.14.0",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
diff --git a/detector/parsers/parse-yarn-lock-v2_test.go b/detector/parsers/parse-yarn-lock-v2_test.go
new file mode 100644
index 0000000..561a6fb
--- /dev/null
+++ b/detector/parsers/parse-yarn-lock-v2_test.go
@@ -0,0 +1,124 @@
+package parsers_test
+
+import (
+	"osv-detector/detector/parsers"
+	"testing"
+)
+
+func TestParseYarnLock_v2_FileDoesNotExist(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/does-not-exist")
+
+	expectErrContaining(t, err, "could not open")
+	expectPackages(t, packages, []parsers.PackageDetails{})
+}
+
+func TestYarnLock_v2_NoPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/empty.v2.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{})
+}
+
+func TestYarnLock_v2_OnePackage(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/one-package.v2.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "balanced-match",
+			Version:   "1.0.2",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v2_TwoPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/two-packages.v2.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "compare-func",
+			Version:   "2.0.0",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "concat-map",
+			Version:   "0.0.1",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v2_MultipleVersions(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/multiple-versions.v2.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "debug",
+			Version:   "4.3.3",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "debug",
+			Version:   "2.6.9",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "debug",
+			Version:   "3.2.7",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}
+
+func TestYarnLock_v2_ScopedPackages(t *testing.T) {
+	t.Parallel()
+
+	packages, err := parsers.ParseYarnLock("fixtures/yarn/scoped-packages.v2.lock")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []parsers.PackageDetails{
+		{
+			Name:      "@babel/cli",
+			Version:   "7.16.8",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "@babel/code-frame",
+			Version:   "7.16.7",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+		{
+			Name:      "@babel/compat-data",
+			Version:   "7.16.8",
+			Ecosystem: parsers.YarnEcosystem,
+		},
+	})
+}