fix: golang PURL should include full module (#4395)

* fixed #4316 go mod with ver purl

Signed-off-by: Rez Moss <hi@rezmoss.com>

* go mod purl fixed, added func to handle go.mod

Signed-off-by: Rez Moss <hi@rezmoss.com>

* fix: use module name in PURL string everywhere

Signed-off-by: Keith Zantow <kzantow@gmail.com>

---------

Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Keith Zantow <kzantow@gmail.com>
Co-authored-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Rez Moss 2025-12-12 14:19:26 -05:00 committed by GitHub
parent 4c38ee1932
commit e0b61a3ae3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 61 additions and 40 deletions

View File

@ -188,7 +188,8 @@ func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
WithFromContents(cfg.Golang.MainModuleVersion.FromContents). WithFromContents(cfg.Golang.MainModuleVersion.FromContents).
WithFromBuildSettings(cfg.Golang.MainModuleVersion.FromBuildSettings). WithFromBuildSettings(cfg.Golang.MainModuleVersion.FromBuildSettings).
WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags), WithFromLDFlags(cfg.Golang.MainModuleVersion.FromLDFlags),
), ).
WithUsePackagesLib(*multiLevelOption(true, enrichmentEnabled(cfg.Enrich, task.Go, task.Golang), cfg.Golang.UsePackagesLib)),
JavaScript: javascript.DefaultCatalogerConfig(). JavaScript: javascript.DefaultCatalogerConfig().
WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)). WithIncludeDevDependencies(*multiLevelOption(false, cfg.JavaScript.IncludeDevDependencies)).
WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)). WithSearchRemoteLicenses(*multiLevelOption(false, enrichmentEnabled(cfg.Enrich, task.JavaScript, task.Node, task.NPM), cfg.JavaScript.SearchRemoteLicenses)).

View File

@ -16,6 +16,7 @@ type golangConfig struct {
Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"` Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"`
NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"` NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"`
MainModuleVersion golangMainModuleVersionConfig `json:"main-module-version" yaml:"main-module-version" mapstructure:"main-module-version"` MainModuleVersion golangMainModuleVersionConfig `json:"main-module-version" yaml:"main-module-version" mapstructure:"main-module-version"`
UsePackagesLib *bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"`
} }
var _ interface { var _ interface {
@ -37,6 +38,7 @@ if unset this defaults to $GONOPROXY`)
descriptions.Add(&o.MainModuleVersion, `the go main module version discovered from binaries built with the go compiler will descriptions.Add(&o.MainModuleVersion, `the go main module version discovered from binaries built with the go compiler will
always show (devel) as the version. Use these options to control heuristics to guess always show (devel) as the version. Use these options to control heuristics to guess
a more accurate version from the binary.`) a more accurate version from the binary.`)
descriptions.Add(&o.UsePackagesLib, `use the golang.org/x/tools/go/packages library, which executes golang tooling found on the path in addition to potential network access to get the most accurate results`)
descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`) descriptions.Add(&o.MainModuleVersion.FromLDFlags, `look for LD flags that appear to be setting a version (e.g. -X main.version=1.0.0)`)
descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version descriptions.Add(&o.MainModuleVersion.FromBuildSettings, `use the build settings (e.g. vcs.version & vcs.time) to craft a v0 pseudo version
(e.g. v0.0.0-20220308212642-53e6d0aaf6fb) when a more accurate version cannot be found otherwise`) (e.g. v0.0.0-20220308212642-53e6d0aaf6fb) when a more accurate version cannot be found otherwise`)
@ -64,5 +66,6 @@ func defaultGolangConfig() golangConfig {
FromContents: def.MainModuleVersion.FromContents, FromContents: def.MainModuleVersion.FromContents,
FromBuildSettings: def.MainModuleVersion.FromBuildSettings, FromBuildSettings: def.MainModuleVersion.FromBuildSettings,
}, },
UsePackagesLib: nil, // this defaults to true, which is the API default
} }
} }

View File

@ -48,6 +48,9 @@ type CatalogerConfig struct {
NoProxy []string `yaml:"no-proxy,omitempty" json:"no-proxy,omitempty" mapstructure:"no-proxy"` NoProxy []string `yaml:"no-proxy,omitempty" json:"no-proxy,omitempty" mapstructure:"no-proxy"`
MainModuleVersion MainModuleVersionConfig `yaml:"main-module-version" json:"main-module-version" mapstructure:"main-module-version"` MainModuleVersion MainModuleVersionConfig `yaml:"main-module-version" json:"main-module-version" mapstructure:"main-module-version"`
// Whether to use the golang.org/x/tools/go/packages, which executes golang tooling found on the path in addition to potential network access
UsePackagesLib bool `json:"use-packages-lib" yaml:"use-packages-lib" mapstructure:"use-packages-lib"`
} }
type MainModuleVersionConfig struct { type MainModuleVersionConfig struct {
@ -70,6 +73,7 @@ type MainModuleVersionConfig struct {
// - setting the default local module cache dir if none is provided // - setting the default local module cache dir if none is provided
func DefaultCatalogerConfig() CatalogerConfig { func DefaultCatalogerConfig() CatalogerConfig {
g := CatalogerConfig{ g := CatalogerConfig{
UsePackagesLib: true,
MainModuleVersion: DefaultMainModuleVersionConfig(), MainModuleVersion: DefaultMainModuleVersionConfig(),
LocalModCacheDir: defaultGoModDir(), LocalModCacheDir: defaultGoModDir(),
} }
@ -180,6 +184,11 @@ func (g CatalogerConfig) WithMainModuleVersion(input MainModuleVersionConfig) Ca
return g return g
} }
func (g CatalogerConfig) WithUsePackagesLib(useLib bool) CatalogerConfig {
g.UsePackagesLib = useLib
return g
}
func (g MainModuleVersionConfig) WithFromLDFlags(input bool) MainModuleVersionConfig { func (g MainModuleVersionConfig) WithFromLDFlags(input bool) MainModuleVersionConfig {
g.FromLDFlags = input g.FromLDFlags = input
return g return g

View File

@ -57,6 +57,7 @@ func Test_Config(t *testing.T) {
Proxies: []string{"https://my.proxy"}, Proxies: []string{"https://my.proxy"},
NoProxy: []string{"my.private", "no.proxy"}, NoProxy: []string{"my.private", "no.proxy"},
MainModuleVersion: DefaultMainModuleVersionConfig(), MainModuleVersion: DefaultMainModuleVersionConfig(),
UsePackagesLib: true,
}, },
}, },
{ {
@ -84,6 +85,7 @@ func Test_Config(t *testing.T) {
Proxies: []string{"https://alt.proxy", "direct"}, Proxies: []string{"https://alt.proxy", "direct"},
NoProxy: []string{"alt.no.proxy"}, NoProxy: []string{"alt.no.proxy"},
MainModuleVersion: DefaultMainModuleVersionConfig(), MainModuleVersion: DefaultMainModuleVersionConfig(),
UsePackagesLib: true,
}, },
}, },
} }

View File

@ -64,27 +64,23 @@ func newBinaryMetadata(dep *debug.Module, mainModule, goVersion, architecture st
func packageURL(moduleName, moduleVersion string) string { func packageURL(moduleName, moduleVersion string) string {
// source: https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang // source: https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#golang
// note: "The version is often empty when a commit is not specified and should be the commit in most cases when available." // note: "The version is often empty when a commit is not specified and should be the commit in most cases when available."
if moduleName == "" {
fields := strings.Split(moduleName, "/")
if len(fields) == 0 {
return "" return ""
} }
namespace := "" namespace := ""
name := "" name := moduleName
// The subpath is used to point to a subpath inside a package (e.g. pkg:golang/google.golang.org/genproto#googleapis/api/annotations)
subpath := ""
switch len(fields) { // golang PURLs from _modules_ are constructed as pkg:golang/<module>@version, where
case 1: // the full module name often includes multiple segments including `/v#`-type versions, for example:
name = fields[0] // pkg:golang/github.com/cli/cli/v2@2.63.0
case 2: // see: https://github.com/package-url/purl-spec/issues/63
name = fields[1] // and: https://github.com/package-url/purl-spec/blob/main/types-doc/golang-definition.md#subpath-definition
namespace = fields[0] // by setting the namespace this way, it does not escape the slashes:
default: lastSlash := strings.LastIndex(moduleName, "/")
name = fields[2] if lastSlash > 0 && lastSlash < len(moduleName)-1 {
namespace = strings.Join(fields[0:2], "/") name = moduleName[lastSlash+1:]
subpath = strings.Join(fields[3:], "/") namespace = moduleName[0:lastSlash]
} }
return packageurl.NewPackageURL( return packageurl.NewPackageURL(
@ -93,6 +89,6 @@ func packageURL(moduleName, moduleVersion string) string {
name, name,
moduleVersion, moduleVersion,
nil, nil,
subpath, "", // subpath is used to reference a specific _package_ within the module
).ToString() ).ToString()
} }

View File

@ -38,14 +38,14 @@ func Test_packageURL(t *testing.T) {
Name: "github.com/coreos/go-systemd/v22", Name: "github.com/coreos/go-systemd/v22",
Version: "v22.1.0", Version: "v22.1.0",
}, },
expected: "pkg:golang/github.com/coreos/go-systemd@v22.1.0#v22", expected: "pkg:golang/github.com/coreos/go-systemd/v22@v22.1.0",
}, },
{ {
name: "golang with subpath deep", name: "golang with subpath deep",
pkg: pkg.Package{ pkg: pkg.Package{
Name: "google.golang.org/genproto/googleapis/api/annotations", Name: "google.golang.org/genproto/googleapis/api/annotations",
}, },
expected: "pkg:golang/google.golang.org/genproto/googleapis#api/annotations", expected: "pkg:golang/google.golang.org/genproto/googleapis/api/annotations",
}, },
} }

View File

@ -278,7 +278,7 @@ func TestBuildGoPkgInfo(t *testing.T) {
{ {
Name: "github.com/a/b/c", Name: "github.com/a/b/c",
Version: "", // this was (devel) but we cleared it explicitly Version: "", // this was (devel) but we cleared it explicitly
PURL: "pkg:golang/github.com/a/b#c", PURL: "pkg:golang/github.com/a/b/c",
Language: pkg.Go, Language: pkg.Go,
Type: pkg.GoModulePkg, Type: pkg.GoModulePkg,
Locations: file.NewLocationSet( Locations: file.NewLocationSet(

View File

@ -8,7 +8,6 @@ import (
"io" "io"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
"github.com/spf13/afero" "github.com/spf13/afero"
@ -26,17 +25,19 @@ import (
) )
type goModCataloger struct { type goModCataloger struct {
usePackagesLib bool
licenseResolver goLicenseResolver licenseResolver goLicenseResolver
} }
func newGoModCataloger(opts CatalogerConfig) *goModCataloger { func newGoModCataloger(opts CatalogerConfig) *goModCataloger {
return &goModCataloger{ return &goModCataloger{
usePackagesLib: opts.UsePackagesLib,
licenseResolver: newGoLicenseResolver(modFileCatalogerName, opts), licenseResolver: newGoLicenseResolver(modFileCatalogerName, opts),
} }
} }
// parseGoModFile takes a go.mod and tries to resolve and lists all packages discovered. // parseGoModFile takes a go.mod and tries to resolve and lists all packages discovered.
func (c *goModCataloger) parseGoModFile(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { func (c *goModCataloger) parseGoModFile(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) (pkgs []pkg.Package, relationships []artifact.Relationship, err error) {
modDir := filepath.Dir(string(reader.Location.Reference().RealPath)) modDir := filepath.Dir(string(reader.Location.Reference().RealPath))
digests, err := parseGoSumFile(resolver, reader) digests, err := parseGoSumFile(resolver, reader)
if err != nil { if err != nil {
@ -48,24 +49,34 @@ func (c *goModCataloger) parseGoModFile(ctx context.Context, resolver file.Resol
scanRoot = dir.Chroot.Base() scanRoot = dir.Chroot.Base()
} }
// source analysis using go toolchain if available
syftSourcePackages, sourceModules, sourceDependencies, unknownErr := c.loadPackages(modDir, reader.Location)
catalogedModules, sourceModuleToPkg := c.catalogModules(ctx, scanRoot, syftSourcePackages, sourceModules, reader, digests)
relationships := buildModuleRelationships(catalogedModules, sourceDependencies, sourceModuleToPkg)
// base case go.mod file parsing // base case go.mod file parsing
modFile, err := c.parseModFileContents(reader) modFile, err := c.parseModFileContents(reader)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// only use mod packages NOT found in source analysis // source analysis using go toolchain if available
var sourceModules map[string]*packages.Module
var catalogedModules []pkg.Package
if c.usePackagesLib {
var sourcePackages map[string][]pkgInfo
var sourceDependencies map[string][]string
var sourceModuleToPkg map[string]artifact.Identifiable
sourcePackages, sourceModules, sourceDependencies, err = c.loadPackages(modDir, reader.Location)
catalogedModules, sourceModuleToPkg = c.catalogModules(ctx, scanRoot, sourcePackages, sourceModules, reader, digests)
relationships = buildModuleRelationships(catalogedModules, sourceDependencies, sourceModuleToPkg)
}
// only use go.mod packages NOT found in source analysis
goModPackages := c.createGoModPackages(ctx, resolver, modFile, sourceModules, reader, digests) goModPackages := c.createGoModPackages(ctx, resolver, modFile, sourceModules, reader, digests)
c.applyReplaceDirectives(ctx, resolver, modFile, goModPackages, reader, digests) c.applyReplaceDirectives(ctx, resolver, modFile, goModPackages, reader, digests)
c.applyExcludeDirectives(modFile, goModPackages) c.applyExcludeDirectives(modFile, goModPackages)
finalPkgs := c.assembleResults(catalogedModules, goModPackages) pkgs = c.assembleResults(catalogedModules, goModPackages)
return finalPkgs, relationships, unknownErr
return pkgs, relationships, err
} }
// loadPackages uses golang.org/x/tools/go/packages to get dependency information. // loadPackages uses golang.org/x/tools/go/packages to get dependency information.
@ -327,7 +338,7 @@ func (c *goModCataloger) createGoModPackages(ctx context.Context, resolver file.
goModPackages := make(map[string]pkg.Package) goModPackages := make(map[string]pkg.Package)
for _, m := range modFile.Require { for _, m := range modFile.Require {
if _, exists := sourceModules[m.Mod.Path]; !exists { if sourceModules == nil || sourceModules[m.Mod.Path] == nil {
lics := c.licenseResolver.getLicenses(ctx, resolver, m.Mod.Path, m.Mod.Version) lics := c.licenseResolver.getLicenses(ctx, resolver, m.Mod.Path, m.Mod.Version)
goModPkg := pkg.Package{ goModPkg := pkg.Package{
Name: m.Mod.Path, Name: m.Mod.Path,
@ -392,9 +403,7 @@ func (c *goModCataloger) assembleResults(catalogedPkgs []pkg.Package, goModPacka
pkgsSlice = append(pkgsSlice, p) pkgsSlice = append(pkgsSlice, p)
} }
sort.SliceStable(pkgsSlice, func(i, j int) bool { pkg.Sort(pkgsSlice)
return pkgsSlice[i].Name < pkgsSlice[j].Name
})
return pkgsSlice return pkgsSlice
} }

View File

@ -54,7 +54,7 @@ func TestParseGoMod(t *testing.T) {
{ {
Name: "github.com/anchore/archiver/v3", Name: "github.com/anchore/archiver/v3",
Version: "v3.5.2", Version: "v3.5.2",
PURL: "pkg:golang/github.com/anchore/archiver@v3.5.2#v3", PURL: "pkg:golang/github.com/anchore/archiver/v3@v3.5.2",
Locations: file.NewLocationSet(file.NewLocation("test-fixtures/go-mod-fixtures/many-packages/go.mod")), Locations: file.NewLocationSet(file.NewLocation("test-fixtures/go-mod-fixtures/many-packages/go.mod")),
Language: pkg.Go, Language: pkg.Go,
Type: pkg.GoModulePkg, Type: pkg.GoModulePkg,
@ -111,7 +111,7 @@ func TestParseGoMod(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) { t.Run(test.fixture, func(t *testing.T) {
c := newGoModCataloger(DefaultCatalogerConfig()) c := newGoModCataloger(DefaultCatalogerConfig().WithUsePackagesLib(false))
pkgtest.NewCatalogTester(). pkgtest.NewCatalogTester().
FromFile(t, test.fixture). FromFile(t, test.fixture).
Expects(test.expected, nil). Expects(test.expected, nil).
@ -172,7 +172,9 @@ func Test_GoSumHashes(t *testing.T) {
pkgtest.NewCatalogTester(). pkgtest.NewCatalogTester().
FromDirectory(t, test.fixture). FromDirectory(t, test.fixture).
Expects(test.expected, nil). Expects(test.expected, nil).
TestCataloger(t, NewGoModuleFileCataloger(CatalogerConfig{})) TestCataloger(t, NewGoModuleFileCataloger(CatalogerConfig{
UsePackagesLib: false,
}))
}) })
} }
} }
@ -189,7 +191,6 @@ func Test_parseGoSource_packageResolution(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
fixturePath string fixturePath string
config CatalogerConfig
expectedPkgs []string expectedPkgs []string
expectedRels []string expectedRels []string
expectedLicenses map[string][]string expectedLicenses map[string][]string
@ -333,7 +334,7 @@ func Test_parseGoSource_packageResolution(t *testing.T) {
t.Errorf("mismatch in licenses (-want +got):\n%s", diff) t.Errorf("mismatch in licenses (-want +got):\n%s", diff)
} }
}). }).
TestCataloger(t, NewGoModuleFileCataloger(CatalogerConfig{})) TestCataloger(t, NewGoModuleFileCataloger(DefaultCatalogerConfig().WithUsePackagesLib(true)))
}) })
} }
} }