Files
syft/syft/pkg/cataloger/golang/parse_go_binary.go
T
Weston Steimel e962c10da7 fix: improve go binary semver extraction for traefik (#3325)
Improves the go cataloger semver extraction logic to include getting the
release version of traefik.  This is based off of the regex pattern that
already existed in the traefik binary classifier.

Signed-off-by: Weston Steimel <commits@weston.slmail.me>
2024-10-14 09:41:34 -04:00

412 lines
12 KiB
Go

package golang
import (
"bytes"
"context"
"debug/elf"
"debug/macho"
"debug/pe"
"errors"
"fmt"
"io"
"regexp"
"runtime/debug"
"slices"
"strings"
"time"
"golang.org/x/mod/module"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/internal/unionreader"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/pkg/cataloger/golang/internal/xcoff"
)
const goArch = "GOARCH"
var (
// errUnrecognizedFormat is returned when a given executable file doesn't
// appear to be in a known format, or it breaks the rules of that format,
// or when there are I/O errors reading the file.
errUnrecognizedFormat = errors.New("unrecognized file format")
// devel is used to recognize the current default version when a golang main distribution is built
// https://github.com/golang/go/issues/29228 this issue has more details on the progress of being able to
// inject the correct version into the main module of the build process
knownBuildFlagPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?m)\.[a-zA-Z0-9]*([rR]elease)?([gG]it)?([bB]uild)?[vV]er(sion)?=(\S+/)*(?P<version>v?\d+.\d+.\d+[-\w]*)`),
regexp.MustCompile(`(?m)\.[a-zA-Z0-9]*([tT]ag)=(\S+/)*(?P<version>v?\d+.\d+.\d+[-\w]*)`),
}
)
const devel = "(devel)"
type goBinaryCataloger struct {
licenseResolver goLicenseResolver
mainModuleVersion MainModuleVersionConfig
}
func newGoBinaryCataloger(opts CatalogerConfig) *goBinaryCataloger {
return &goBinaryCataloger{
licenseResolver: newGoLicenseResolver(binaryCatalogerName, opts),
mainModuleVersion: opts.MainModuleVersion,
}
}
// parseGoBinary catalogs packages found in the "buildinfo" section of a binary built by the go compiler.
func (c *goBinaryCataloger) parseGoBinary(_ context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var pkgs []pkg.Package
unionReader, err := unionreader.GetUnionReader(reader.ReadCloser)
if err != nil {
return nil, nil, err
}
defer internal.CloseAndLogError(reader.ReadCloser, reader.RealPath)
mods, errs := scanFile(reader.Location, unionReader)
var rels []artifact.Relationship
for _, mod := range mods {
var depPkgs []pkg.Package
mainPkg, depPkgs := c.buildGoPkgInfo(resolver, reader.Location, mod, mod.arch, unionReader)
if mainPkg != nil {
rels = createModuleRelationships(*mainPkg, depPkgs)
pkgs = append(pkgs, *mainPkg)
}
pkgs = append(pkgs, depPkgs...)
}
return pkgs, rels, errs
}
func createModuleRelationships(main pkg.Package, deps []pkg.Package) []artifact.Relationship {
var relationships []artifact.Relationship
for _, dep := range deps {
relationships = append(relationships, artifact.Relationship{
From: dep,
To: main,
Type: artifact.DependencyOfRelationship,
})
}
return relationships
}
var emptyModule debug.Module
var moduleFromPartialPackageBuild = debug.Module{Path: "command-line-arguments"}
func (c *goBinaryCataloger) buildGoPkgInfo(resolver file.Resolver, location file.Location, mod *extendedBuildInfo, arch string, reader io.ReadSeekCloser) (*pkg.Package, []pkg.Package) {
if mod == nil {
return nil, nil
}
if missingMainModule(mod) {
mod.Main = createMainModuleFromPath(mod)
}
var pkgs []pkg.Package
for _, dep := range mod.Deps {
if dep == nil {
continue
}
gover, experiments := getExperimentsFromVersion(mod.GoVersion)
p := c.newGoBinaryPackage(
resolver,
dep,
mod.Main.Path,
gover,
arch,
nil,
mod.cryptoSettings,
experiments,
location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
)
if pkg.IsValid(&p) {
pkgs = append(pkgs, p)
}
}
if mod.Main == emptyModule {
return nil, pkgs
}
main := c.makeGoMainPackage(resolver, mod, arch, location, reader)
return &main, pkgs
}
func missingMainModule(mod *extendedBuildInfo) bool {
if mod.Main == emptyModule && mod.Path != "" {
return true
}
// special case: when invoking go build with a source file and not a package (directory) then you will
// see "command-line-arguments" as the main module path... even though that's not the main module. In this
// circumstance, we should treat the main module as missing and search for it within the dependencies.
return mod.Main == moduleFromPartialPackageBuild
}
func (c *goBinaryCataloger) makeGoMainPackage(resolver file.Resolver, mod *extendedBuildInfo, arch string, location file.Location, reader io.ReadSeekCloser) pkg.Package {
gbs := getBuildSettings(mod.Settings)
gover, experiments := getExperimentsFromVersion(mod.GoVersion)
main := c.newGoBinaryPackage(
resolver,
&mod.Main,
mod.Main.Path,
gover,
arch,
gbs,
mod.cryptoSettings,
experiments,
location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
)
if main.Version != devel {
// found a full package with a non-development version... return it as is...
return main
}
// we have a package, but the version is "devel"... let's try and find a better answer
var metadata *pkg.GolangBinaryBuildinfoEntry
if v, ok := main.Metadata.(pkg.GolangBinaryBuildinfoEntry); ok {
metadata = &v
}
version := c.findMainModuleVersion(metadata, gbs, reader)
if version != "" {
// make sure version is prefixed with v as some build systems parsed
// during `findMainModuleVersion` can include incomplete semver
// vx.x.x is correct
version = ensurePrefix(version, "v")
main.Version = version
main.PURL = packageURL(main.Name, main.Version)
main.SetID()
}
return main
}
// this is checking for (.L)? because at least one binary seems to have \xA0L preceding the version string, but for some reason
// this is unable to be matched by the regex here as \x00\xA0L;
// the only thing that seems to work is to just look for version strings following both \x00 and \x00.L for now
var semverPattern = regexp.MustCompile(`(\x00|\x{FFFD})(.L)?(?P<version>v?(\d+\.\d+\.\d+[-\w]*[+\w]*))\x00`)
func (c *goBinaryCataloger) findMainModuleVersion(metadata *pkg.GolangBinaryBuildinfoEntry, gbs pkg.KeyValues, reader io.ReadSeekCloser) string {
vcsVersion, hasVersion := gbs.Get("vcs.revision")
timestamp, hasTimestamp := gbs.Get("vcs.time")
var ldflags, majorVersion, fullVersion string
if c.mainModuleVersion.FromLDFlags && metadata != nil {
// we've found a specific version from the ldflags! use it as the version.
// why not combine that with the pseudo version (e.g. v1.2.3-0.20210101000000-abcdef123456)?
// short answer: we're assuming that if a specific semver was provided in the ldflags that
// there is a matching vcs tag to match that could be referenced. This assumption could
// be incorrect in terms of the go.mod contents, but is not incorrect in terms of the logical
// version of the package.
ldflags, _ = metadata.BuildSettings.Get("-ldflags")
majorVersion, fullVersion = extractVersionFromLDFlags(ldflags, metadata.MainModule)
if fullVersion != "" {
return fullVersion
}
}
// guess the version from pattern matching in the binary (can result in false positives)
if c.mainModuleVersion.FromContents {
_, err := reader.Seek(0, io.SeekStart)
if err != nil {
log.WithFields("error", err).Trace("unable to seek to start of go binary reader")
} else {
if v := extractVersionFromContents(reader); v != "" {
return v
}
}
}
// fallback to using the go standard pseudo v0.0.0 version
if c.mainModuleVersion.FromBuildSettings && hasVersion && hasTimestamp {
version := vcsVersion
//NOTE: err is ignored, because if parsing fails
// we still use the empty Time{} struct to generate an empty date, like 00010101000000
// for consistency with the pseudo-version format: https://go.dev/ref/mod#pseudo-versions
ts, _ := time.Parse(time.RFC3339, timestamp)
if len(vcsVersion) >= 12 {
version = vcsVersion[:12]
}
return module.PseudoVersion(majorVersion, fullVersion, ts, version)
}
return ""
}
func extractVersionFromContents(reader io.Reader) string {
contents, err := io.ReadAll(reader)
if err != nil {
log.WithFields("error", err).Trace("unable to read from go binary reader")
return ""
}
matchMetadata := internal.MatchNamedCaptureGroups(semverPattern, string(contents))
version, ok := matchMetadata["version"]
if ok {
return version
}
return ""
}
func extractVersionFromLDFlags(ldflags string, maimModule string) (majorVersion string, fullVersion string) {
if ldflags == "" {
return "", ""
}
for _, pattern := range knownBuildFlagPatterns {
newPattern := regexp.MustCompile(fmt.Sprintf(`(main|%s\/[^\s]*)%s`, strings.ReplaceAll(maimModule, "/", "\\/"), pattern.String()))
groups := internal.MatchNamedCaptureGroups(newPattern, ldflags)
v, ok := groups["version"]
if !ok {
continue
}
fullVersion = v
if !strings.HasPrefix(v, "v") {
fullVersion = fmt.Sprintf("v%s", v)
}
components := strings.Split(v, ".")
if len(components) == 0 {
continue
}
majorVersion = strings.TrimPrefix(components[0], "v")
return majorVersion, fullVersion
}
return "", ""
}
func getGOARCH(settings []debug.BuildSetting) string {
for _, s := range settings {
if s.Key == goArch {
return s.Value
}
}
return ""
}
func getGOARCHFromBin(r io.ReaderAt) (string, error) {
// Read the first bytes of the file to identify the format, then delegate to
// a format-specific function to load segment and section headers.
ident := make([]byte, 16)
if n, err := r.ReadAt(ident, 0); n < len(ident) || err != nil {
return "", fmt.Errorf("unrecognized file format: %w", err)
}
var arch string
switch {
case bytes.HasPrefix(ident, []byte("\x7FELF")):
f, err := elf.NewFile(r)
if err != nil {
return "", fmt.Errorf("unrecognized file format: %w", err)
}
arch = f.Machine.String()
case bytes.HasPrefix(ident, []byte("MZ")):
f, err := pe.NewFile(r)
if err != nil {
return "", fmt.Errorf("unrecognized file format: %w", err)
}
arch = fmt.Sprintf("%d", f.Machine)
case bytes.HasPrefix(ident, []byte("\xFE\xED\xFA")) || bytes.HasPrefix(ident[1:], []byte("\xFA\xED\xFE")):
f, err := macho.NewFile(r)
if err != nil {
return "", fmt.Errorf("unrecognized file format: %w", err)
}
arch = f.Cpu.String()
case bytes.HasPrefix(ident, []byte{0x01, 0xDF}) || bytes.HasPrefix(ident, []byte{0x01, 0xF7}):
f, err := xcoff.NewFile(r)
if err != nil {
return "", fmt.Errorf("unrecognized file format: %w", err)
}
arch = fmt.Sprintf("%d", f.FileHeader.TargetMachine)
default:
return "", errUnrecognizedFormat
}
arch = strings.Replace(arch, "EM_", "", 1)
arch = strings.Replace(arch, "Cpu", "", 1)
arch = strings.ToLower(arch)
return arch, nil
}
func getBuildSettings(settings []debug.BuildSetting) pkg.KeyValues {
m := make(pkg.KeyValues, 0)
for _, s := range settings {
m = append(m, pkg.KeyValue{
Key: s.Key,
Value: s.Value,
})
}
return m
}
func getExperimentsFromVersion(version string) (string, []string) {
// See: https://github.com/anchore/grype/issues/1851
var experiments []string
version, rest, ok := strings.Cut(version, " ")
if ok {
// Assume they may add more non-version chunks in the future, so only look for "X:".
for _, chunk := range strings.Split(rest, " ") {
if strings.HasPrefix(rest, "X:") {
csv := strings.TrimPrefix(chunk, "X:")
experiments = append(experiments, strings.Split(csv, ",")...)
}
}
}
return version, experiments
}
func createMainModuleFromPath(existing *extendedBuildInfo) debug.Module {
// search for a main module candidate within the dependencies
var mainModuleCandidates []debug.Module
var usedIndex int
for i, dep := range existing.Deps {
if dep == nil {
continue
}
if dep.Version == devel {
usedIndex = i
mainModuleCandidates = append(mainModuleCandidates, *dep)
}
}
if len(mainModuleCandidates) == 1 {
// we need to prune the dependency from module list
existing.Deps = slices.Delete(existing.Deps, usedIndex, usedIndex+1)
return mainModuleCandidates[0]
}
// otherwise craft a main module from the path (a bit of a cop out, but allows us to have a main module)
return debug.Module{
Path: existing.Path,
Version: devel,
}
}
func ensurePrefix(s, prefix string) string {
if !strings.HasPrefix(s, prefix) {
return prefix + s
}
return s
}