internal/report: support branch and tag expansion in fix

Adds support to expand git branches and tags in Report version fields
to git commit hashes during vulnreport fix. Commit hashes can then be
cleaned up by FixVersions(...).

Change-Id: I3d2d14983b669054c9a9e25d8ab51a6a70087929
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/573396
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gitrepo/gitrepo.go b/internal/gitrepo/gitrepo.go
index ecaa0a8..80e3c5b 100644
--- a/internal/gitrepo/gitrepo.go
+++ b/internal/gitrepo/gitrepo.go
@@ -60,6 +60,16 @@
 	})
 }
 
+// PlainCloneWith returns a (non-bare) repo with its history by cloning the repo with the given options opt.
+func PlainCloneWith(ctx context.Context, dir string, opts *git.CloneOptions) (repo *git.Repository, err error) {
+	defer derrors.Wrap(&err, "gitrepo.PlainCloneWith(%q)", opts.URL)
+	ctx, span := observe.Start(ctx, "gitrepo.PlainCloneWith")
+	defer span.End()
+
+	log.Infof(ctx, "Plain cloning repo %q at HEAD with options", opts.URL)
+	return git.PlainCloneContext(ctx, dir, false, opts)
+}
+
 // Open returns a repo by opening the repo at the local path dirpath.
 func Open(ctx context.Context, dirpath string) (repo *git.Repository, err error) {
 	defer derrors.Wrap(&err, "gitrepo.Open(%q)", dirpath)
diff --git a/internal/report/fix.go b/internal/report/fix.go
index 53c8679..2224161 100644
--- a/internal/report/fix.go
+++ b/internal/report/fix.go
@@ -18,6 +18,7 @@
 )
 
 func (r *Report) Fix(pc *proxy.Client) {
+	expandGitCommits(r)
 	for _, m := range r.Modules {
 		m.FixVersions(pc)
 	}
diff --git a/internal/report/git.go b/internal/report/git.go
new file mode 100644
index 0000000..f5e1e15
--- /dev/null
+++ b/internal/report/git.go
@@ -0,0 +1,155 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package report
+
+import (
+	"context"
+	"os"
+	"strings"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"golang.org/x/vulndb/cmd/vulnreport/log"
+	"golang.org/x/vulndb/internal/derrors"
+	"golang.org/x/vulndb/internal/gitrepo"
+)
+
+// expandGitCommits expands git repositories and names to commits.
+// Expands versions in r of the form <url>@<name> where url starts with
+// one of {'git://', 'https://', 'http://', 'ssh://', 'git+ssh://'}
+// and name is the name of a git branch or git tag to a git commit
+// hash. Any version that is successfully expanded is replaced.
+func expandGitCommits(r *Report) {
+	// Find repos in versions to expand
+	repos := make(map[string][]string) // url -> names
+	perVersion := func(v string) {
+		if b, a, f := cutRepoUrl(v); f {
+			repos[b] = append(repos[b], a)
+		}
+	}
+	for _, m := range r.Modules {
+		for _, vr := range m.Versions {
+			perVersion(vr.Introduced)
+			perVersion(vr.Fixed)
+		}
+		perVersion(m.VulnerableAt)
+	}
+
+	if len(repos) == 0 { // no repos to expand
+		return
+	}
+
+	log.Infof("Expanding git urls for %d repos", len(repos))
+
+	// Create scratch directory.
+	scratch, err := os.MkdirTemp("", "expand-git-references")
+	if err != nil {
+		log.Err("failed to create scratch directory for ExpandGitReferences")
+		return
+	}
+	defer func() {
+		_ = os.RemoveAll(scratch)
+	}()
+
+	// expand references and compute replacements
+	replacements := make(map[string]string)
+	for repo, names := range repos {
+		commits, err := gitNameToCommits(scratch, repo, names)
+		if err != nil {
+			log.Infof("expandGitCommits(%v, %v) failed with: %v", repo, names, err)
+			continue
+		}
+		for name, c := range commits {
+			replacements[repo+"@"+name] = c
+		}
+	}
+
+	if len(replacements) == 0 { // no replacements created
+		return
+	}
+
+	// Replace all.
+	replaceVersion := func(v string) string {
+		if r, ok := replacements[v]; ok {
+			return r
+		}
+		return v
+	}
+	for i, m := range r.Modules {
+		for j, vr := range m.Versions {
+			m.Versions[j].Introduced = replaceVersion(vr.Introduced)
+			m.Versions[j].Fixed = replaceVersion(vr.Fixed)
+		}
+		r.Modules[i].VulnerableAt = replaceVersion(m.VulnerableAt)
+	}
+}
+
+func cutRepoUrl(v string) (string, string, bool) {
+	prefixes := map[string]bool{
+		"https://":   true,
+		"http://":    true,
+		"git://":     true,
+		"git+ssh://": true,
+		"ssh://":     true,
+	}
+	for p := range prefixes {
+		if strings.HasPrefix(v, p) {
+			return strings.Cut(v, "@")
+		}
+	}
+	return v, "", false
+}
+
+// gitNameToCommits returns a mapping from the git repo at repoURL
+// and returns a mapping for the branches and tags in names to
+// a commit hash.
+func gitNameToCommits(dir string, repoURL string, names []string) (_ map[string]string, err error) {
+	defer derrors.Wrap(&err, "gitNameToCommits(%q, %q, %v)", dir, repoURL, names)
+
+	repoRoot, err := os.MkdirTemp(dir, "git*")
+	if err != nil {
+		return nil, err
+	}
+
+	ctx := context.Background()
+	repo, err := gitrepo.PlainCloneWith(ctx, repoRoot, &git.CloneOptions{
+		URL:           repoURL,
+		ReferenceName: plumbing.HEAD,
+		SingleBranch:  true, // allow branches other than master
+		Depth:         0,    // pull in history
+		// Leaves Tags the default value.
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	resolveName := func(name string) (string, bool) {
+		// branch name?
+		if b, err := repo.Branch(name); err == nil {
+			if ref, err := repo.Reference(b.Merge, true); err == nil {
+				if h := ref.Hash(); !h.IsZero() {
+					return h.String(), true
+				}
+			}
+		}
+
+		// tag name?
+		if ref, err := repo.Tag(name); err == nil {
+			if h := ref.Hash(); !h.IsZero() {
+				return h.String(), true
+			}
+		}
+
+		return "", false
+	}
+
+	results := make(map[string]string)
+	for _, name := range names {
+		if r, ok := resolveName(name); ok {
+			results[name] = r
+		}
+	}
+	return results, nil
+}