Repository: google/osv-scanner (go)

Issue:
go.mod `replace` not being taken into account when reading
For example, Kubernetes go.mod has these lines:
```
require k8s.io/client-go v0.0.0
...
k8s.io/client-go => ./staging/src/k8s.io/client-go
```

and osv-scanner will incorrectly show all vulnerabilities for client-go that have introduced 0.


Pull request description:
fix: include `replace` directives in `go.mod`
Resolves #30

In theory this adds support for `replace`, though in practice it's only going to be useful when the replacement package is remote because there's no way to resolve the local package to the corresponding remote package name that'll be in OSVs - it's tempting to try and infer that from the target (e.g. if you're replacing `golang.org/x/net` with `./fork/net`, then it could be reasonable to assume its a fork of `golang.org/x/net`) but in practice there's no way to know when that shouldn't be applied either.

Likewise for the version, if there isn't one set as part of the replacement then there's not really anything that can be done because there's no requirement from Go that a version be specified for the replacement package anywhere on disk - the best that could be done would be to try and look for a git commit, but I think that is out of scope for this section of code + PR.

Patch:
diff --git a/pkg/lockfile/fixtures/go/replace-different.mod b/pkg/lockfile/fixtures/go/replace-different.mod
new file mode 100644
index 0000000..49cc76b
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-different.mod
@@ -0,0 +1,9 @@
+require (
+    golang.org/x/net v1.2.3
+    golang.org/x/net v0.5.6
+)
+
+replace (
+    golang.org/x/net v1.2.3 => example.com/fork/foe v1.4.5
+    golang.org/x/net v0.5.6 => example.com/fork/foe v1.4.2
+)
diff --git a/pkg/lockfile/fixtures/go/replace-local.mod b/pkg/lockfile/fixtures/go/replace-local.mod
new file mode 100644
index 0000000..e847de8
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-local.mod
@@ -0,0 +1,8 @@
+require (
+    golang.org/x/net v1.2.3
+    github.com/BurntSushi/toml v1.0.0
+)
+
+replace (
+    golang.org/x/net v1.2.3 => ./fork/net
+)
diff --git a/pkg/lockfile/fixtures/go/replace-mixed.mod b/pkg/lockfile/fixtures/go/replace-mixed.mod
new file mode 100644
index 0000000..2f1a587
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-mixed.mod
@@ -0,0 +1,8 @@
+require (
+    golang.org/x/net v1.2.3
+    golang.org/x/net v0.5.6
+)
+
+replace (
+    golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5
+)
diff --git a/pkg/lockfile/fixtures/go/replace-no-version.mod b/pkg/lockfile/fixtures/go/replace-no-version.mod
new file mode 100644
index 0000000..4802d6f
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-no-version.mod
@@ -0,0 +1,8 @@
+require (
+    golang.org/x/net v1.2.3
+    golang.org/x/net v0.5.6
+)
+
+replace (
+    golang.org/x/net => example.com/fork/net v1.4.5
+)
diff --git a/pkg/lockfile/fixtures/go/replace-not-required.mod b/pkg/lockfile/fixtures/go/replace-not-required.mod
new file mode 100644
index 0000000..c7c45ce
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-not-required.mod
@@ -0,0 +1,8 @@
+require (
+    golang.org/x/net v0.5.6
+    github.com/BurntSushi/toml v1.0.0
+)
+
+replace (
+    golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5
+)
diff --git a/pkg/lockfile/fixtures/go/replace-one.mod b/pkg/lockfile/fixtures/go/replace-one.mod
new file mode 100644
index 0000000..01c8169
--- /dev/null
+++ b/pkg/lockfile/fixtures/go/replace-one.mod
@@ -0,0 +1,5 @@
+require (
+  golang.org/x/net v1.2.3
+)
+
+replace golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5
diff --git a/pkg/lockfile/parse-go-lock.go b/pkg/lockfile/parse-go-lock.go
index 1204d8c..dec863a 100644
--- a/pkg/lockfile/parse-go-lock.go
+++ b/pkg/lockfile/parse-go-lock.go
@@ -9,6 +9,16 @@ import (
 
 const GoEcosystem Ecosystem = "Go"
 
+func deduplicatePackages(packages map[string]PackageDetails) map[string]PackageDetails {
+	details := map[string]PackageDetails{}
+
+	for _, detail := range packages {
+		details[detail.Name+"@"+detail.Version] = detail
+	}
+
+	return details
+}
+
 func ParseGoLock(pathToLockfile string) ([]PackageDetails, error) {
 	lockfileContents, err := os.ReadFile(pathToLockfile)
 
@@ -22,16 +32,47 @@ func ParseGoLock(pathToLockfile string) ([]PackageDetails, error) {
 		return []PackageDetails{}, fmt.Errorf("could not parse %s: %w", pathToLockfile, err)
 	}
 
-	packages := make([]PackageDetails, 0, len(parsedLockfile.Require))
+	packages := map[string]PackageDetails{}
 
 	for _, require := range parsedLockfile.Require {
-		packages = append(packages, PackageDetails{
+		packages[require.Mod.Path+"@"+require.Mod.Version] = PackageDetails{
 			Name:      require.Mod.Path,
 			Version:   strings.TrimPrefix(require.Mod.Version, "v"),
 			Ecosystem: GoEcosystem,
 			CompareAs: GoEcosystem,
-		})
+		}
+	}
+
+	for _, replace := range parsedLockfile.Replace {
+		var replacements []string
+
+		if replace.Old.Version == "" {
+			// If the left version is omitted, all versions of the module are replaced.
+			for k, pkg := range packages {
+				if pkg.Name == replace.Old.Path {
+					replacements = append(replacements, k)
+				}
+			}
+		} else {
+			// If a version is present on the left side of the arrow (=>),
+			// only that specific version of the module is replaced
+			s := replace.Old.Path + "@" + replace.Old.Version
+
+			// A `replace` directive has no effect if the module version on the left side is not required.
+			if _, ok := packages[s]; ok {
+				replacements = []string{s}
+			}
+		}
+
+		for _, replacement := range replacements {
+			packages[replacement] = PackageDetails{
+				Name:      replace.New.Path,
+				Version:   strings.TrimPrefix(replace.New.Version, "v"),
+				Ecosystem: GoEcosystem,
+				CompareAs: GoEcosystem,
+			}
+		}
 	}
 
-	return packages, nil
+	return pkgDetailsMapToSlice(deduplicatePackages(packages)), nil
 }


Test patch:
diff --git a/pkg/lockfile/parse-go-lock_test.go b/pkg/lockfile/parse-go-lock_test.go
index 9078412..d2dfe8a 100644
--- a/pkg/lockfile/parse-go-lock_test.go
+++ b/pkg/lockfile/parse-go-lock_test.go
@@ -121,3 +121,142 @@ func TestParseGoLock_IndirectPackages(t *testing.T) {
 		},
 	})
 }
+
+func TestParseGoLock_Replacements_One(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-one.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "example.com/fork/net",
+			Version:   "1.4.5",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}
+
+func TestParseGoLock_Replacements_Mixed(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-mixed.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "example.com/fork/net",
+			Version:   "1.4.5",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+		{
+			Name:      "golang.org/x/net",
+			Version:   "0.5.6",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}
+
+func TestParseGoLock_Replacements_Local(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-local.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "./fork/net",
+			Version:   "",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+		{
+			Name:      "github.com/BurntSushi/toml",
+			Version:   "1.0.0",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}
+
+func TestParseGoLock_Replacements_Different(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-different.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "example.com/fork/foe",
+			Version:   "1.4.5",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+		{
+			Name:      "example.com/fork/foe",
+			Version:   "1.4.2",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}
+
+func TestParseGoLock_Replacements_NotRequired(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-not-required.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "golang.org/x/net",
+			Version:   "0.5.6",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+		{
+			Name:      "github.com/BurntSushi/toml",
+			Version:   "1.0.0",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}
+
+
+func TestParseGoLock_Replacements_NoVersion(t *testing.T) {
+	t.Parallel()
+
+	packages, err := lockfile.ParseGoLock("fixtures/go/replace-no-version.mod")
+
+	if err != nil {
+		t.Errorf("Got unexpected error: %v", err)
+	}
+
+	expectPackages(t, packages, []lockfile.PackageDetails{
+		{
+			Name:      "example.com/fork/net",
+			Version:   "1.4.5",
+			Ecosystem: lockfile.GoEcosystem,
+			CompareAs: lockfile.GoEcosystem,
+		},
+	})
+}