Files
aquasecurity-trivy/pkg/dependency/parser/golang/mod/parse.go
2024-11-22 12:06:33 +00:00

261 lines
7.9 KiB
Go

package mod
import (
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"github.com/samber/lo"
"golang.org/x/mod/modfile"
"golang.org/x/xerrors"
"github.com/aquasecurity/trivy/pkg/dependency"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
xio "github.com/aquasecurity/trivy/pkg/x/io"
)
var (
// By convention, modules with a major version equal to or above v2
// have it as suffix in their module path.
VCSUrlMajorVersionSuffixRegex = regexp.MustCompile(`(/v[\d]+)$`)
// gopkg.in/user/pkg.v -> github.com/user/pkg
VCSUrlGoPkgInRegexWithUser = regexp.MustCompile(`^gopkg\.in/([^/]+)/([^.]+)\..*$`)
// gopkg.in without user segment
// Example: gopkg.in/pkg.v3 -> github.com/go-pkg/pkg
VCSUrlGoPkgInRegexWithoutUser = regexp.MustCompile(`^gopkg\.in/([^.]+)\..*$`)
)
type Parser struct {
replace bool // 'replace' represents if the 'replace' directive should be taken into account.
useMinVersion bool
}
func NewParser(replace, useMinVersion bool) *Parser {
return &Parser{
replace: replace,
useMinVersion: useMinVersion,
}
}
func (p *Parser) GetExternalRefs(path string) []ftypes.ExternalRef {
if url := resolveVCSUrl(path); url != "" {
return []ftypes.ExternalRef{
{
Type: ftypes.RefVCS,
URL: url,
},
}
}
return nil
}
func resolveVCSUrl(modulePath string) string {
switch {
case strings.HasPrefix(modulePath, "github.com/"):
return "https://" + VCSUrlMajorVersionSuffixRegex.ReplaceAllString(modulePath, "")
case VCSUrlGoPkgInRegexWithUser.MatchString(modulePath):
return "https://" + VCSUrlGoPkgInRegexWithUser.ReplaceAllString(modulePath, "github.com/$1/$2")
case VCSUrlGoPkgInRegexWithoutUser.MatchString(modulePath):
return "https://" + VCSUrlGoPkgInRegexWithoutUser.ReplaceAllString(modulePath, "github.com/go-$1/$1")
}
return ""
}
// Parse parses a go.mod file
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
pkgs := make(map[string]ftypes.Package)
goModData, err := io.ReadAll(r)
if err != nil {
return nil, nil, xerrors.Errorf("file read error: %w", err)
}
modFileParsed, err := modfile.Parse("go.mod", goModData, nil)
if err != nil {
return nil, nil, xerrors.Errorf("go.mod parse error: %w", err)
}
skipIndirect := true
if modFileParsed.Go != nil { // Old go.mod file may not include the go version. Go version for these files is less than 1.17
skipIndirect = lessThan(modFileParsed.Go.Version, 1, 17)
}
// Use minimal required go version from `toolchain` line (or from `go` line if `toolchain` is omitted) as `stdlib`.
// Show `stdlib` only with `useMinVersion` flag.
if p.useMinVersion {
if toolchainVer := toolchainVersion(modFileParsed.Toolchain, modFileParsed.Go); toolchainVer != "" {
pkgs["stdlib"] = ftypes.Package{
ID: packageID("stdlib", toolchainVer),
Name: "stdlib",
// Our versioning library doesn't support canonical (goX.Y.Z) format,
// So we need to add `v` prefix for consistency (with module and dependency versions).
Version: fmt.Sprintf("v%s", toolchainVer),
Relationship: ftypes.RelationshipDirect, // Considered a direct dependency as the main module depends on the standard packages.
}
}
}
// Required modules
for _, require := range modFileParsed.Require {
// Skip indirect dependencies less than Go 1.17
if skipIndirect && require.Indirect {
continue
}
pkgs[require.Mod.Path] = ftypes.Package{
ID: packageID(require.Mod.Path, require.Mod.Version),
Name: require.Mod.Path,
Version: require.Mod.Version,
Relationship: lo.Ternary(require.Indirect, ftypes.RelationshipIndirect, ftypes.RelationshipDirect),
ExternalReferences: p.GetExternalRefs(require.Mod.Path),
}
}
// No need to evaluate the 'replace' directive for indirect dependencies
if p.replace {
for _, rep := range modFileParsed.Replace {
// Check if replaced path is actually in our pkgs.
old, ok := pkgs[rep.Old.Path]
if !ok {
continue
}
// If the replace directive has a version on the left side, make sure it matches the version that was imported.
if rep.Old.Version != "" && old.Version != rep.Old.Version {
continue
}
// Only support replace directive with version on the right side.
// Directive without version is a local path.
if rep.New.Version == "" {
// Delete old pkg, since it's a local path now.
delete(pkgs, rep.Old.Path)
continue
}
// Delete old pkg, in case the path has changed.
delete(pkgs, rep.Old.Path)
// Add replaced package to package register.
pkgs[rep.New.Path] = ftypes.Package{
ID: packageID(rep.New.Path, rep.New.Version),
Name: rep.New.Path,
Version: rep.New.Version,
Relationship: old.Relationship,
ExternalReferences: p.GetExternalRefs(rep.New.Path),
}
}
}
var deps ftypes.Dependencies
// Main module
if m := modFileParsed.Module; m != nil {
root := ftypes.Package{
ID: packageID(m.Mod.Path, m.Mod.Version),
Name: m.Mod.Path,
Version: m.Mod.Version,
ExternalReferences: p.GetExternalRefs(m.Mod.Path),
Relationship: ftypes.RelationshipRoot,
}
// Store child dependencies for the root package (main module).
// We will build a dependency graph for Direct/Indirect in `fanal` using additional files.
dependsOn := lo.FilterMap(lo.Values(pkgs), func(pkg ftypes.Package, _ int) (string, bool) {
return pkg.ID, pkg.Relationship == ftypes.RelationshipDirect
})
sort.Strings(dependsOn)
deps = append(deps, ftypes.Dependency{
ID: root.ID,
DependsOn: dependsOn,
})
pkgs[root.Name] = root
}
pkgSlice := lo.Values(pkgs)
sort.Sort(ftypes.Packages(pkgSlice))
return pkgSlice, deps, nil
}
// lessThan checks if the Go version is less than `<majorVer>.<minorVer>`
func lessThan(ver string, majorVer, minorVer int) bool {
if ver == "" {
return false
}
ss := strings.Split(ver, ".")
if len(ss) != 2 {
return false
}
major, err := strconv.Atoi(ss[0])
if err != nil {
return false
}
minor, err := strconv.Atoi(ss[1])
if err != nil {
return false
}
return major <= majorVer && minor < minorVer
}
// toolchainVersion returns version from `toolchain`.
// If `toolchain` is omitted - return version from `go` line (if it is version in toolchain format)
// cf. https://go.dev/doc/toolchain
func toolchainVersion(toolchain *modfile.Toolchain, goVer *modfile.Go) string {
if toolchain != nil && toolchain.Name != "" {
// cf. https://go.dev/doc/toolchain#name
// `dropping the initial go and discarding off any suffix beginning with -`
// e.g. `go1.22.5-custom` => `1.22.5`
name, _, _ := strings.Cut(toolchain.Name, "-")
return strings.TrimPrefix(name, "go")
}
if goVer != nil {
return toolchainVersionFromGoLine(goVer.Version)
}
return ""
}
// toolchainVersionFromGoLine detects Go version from `go` line if `toolchain` line is omitted.
// `go` line supports the following formats:
// cf. https://go.dev/doc/toolchain#version
// - `1.N.P`. e.g. `1.22.0`
// - `1.N`. e.g. `1.22`
// - `1.NrcR`. e.g. `1.22rc1`
// - `1.NbetaR`. e.g. `1.18beta1` - only for Go 1.20 or earlier
func toolchainVersionFromGoLine(ver string) string {
var majorMinorVer string
if ss := strings.Split(ver, "."); len(ss) > 2 { // `1.N.P`
majorMinorVer = strings.Join(ss[:2], ".")
} else if v, _, rcFound := strings.Cut(ver, "rc"); rcFound { // `1.NrcR`
majorMinorVer = v
} else { // `1.N`
majorMinorVer = ver
// Add `.0` suffix to avoid user confusing.
// See https://github.com/aquasecurity/trivy/pull/7163#discussion_r1682424315
ver = v + ".0"
}
// `toolchain` has been added in go 1.21.
// So we need to check that Go version is 1.21 or higher.
// cf. https://github.com/aquasecurity/trivy/pull/7163#discussion_r1682424315
if lessThan(majorMinorVer, 1, 21) {
return ""
}
return ver
}
func packageID(name, version string) string {
return dependency.ID(ftypes.GoModule, name, version)
}