diff --git a/internal/gitrepo/gitrepo.go b/internal/gitrepo/gitrepo.go index ecaa0a84..80e3c5b4 100644 --- a/internal/gitrepo/gitrepo.go +++ b/internal/gitrepo/gitrepo.go @@ -60,6 +60,16 @@ func PlainClone(ctx context.Context, dir, repoURL string) (repo *git.Repository, }) } +// 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 53c86791..22241619 100644 --- a/internal/report/fix.go +++ b/internal/report/fix.go @@ -18,6 +18,7 @@ import ( ) 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 00000000..f5e1e155 --- /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 @ 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 +}