diff --git a/README.md b/README.md index 732bfb8dca3..342e2d0b3d8 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,16 @@ package: # same as -s ; SYFT_PACKAGE_CATALOGER_SCOPE env var scope: "squashed" +golang: + # search for go package licences in the GOPATH of the system running Syft, note that this is outside the + # container filesystem and potentially outside the root of a local directory scan + # SYFT_GOLANG_SEARCH_LOCAL_MOD_CACHE_LICENSES env var + search-local-mod-cache-licenses: false + + # specify an explicit go mod cache directory, if unset this defaults to $GOPATH/pkg/mod or $HOME/go/pkg/mod + # SYFT_GOLANG_LOCAL_MOD_CACHE_DIR env var + local-mod-cache-dir: "" + # cataloging file contents is exposed through the power-user subcommand file-contents: cataloger: diff --git a/go.mod b/go.mod index 75f3fe0f9f2..3db0c3d152f 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/anchore/stereoscope v0.0.0-20230317134707-7928713c391e github.com/docker/docker v23.0.1+incompatible github.com/google/go-containerregistry v0.14.0 + github.com/google/licensecheck v0.3.1 github.com/invopop/jsonschema v0.7.0 github.com/knqyf263/go-rpmdb v0.0.0-20221030135625-4082a22221ce github.com/opencontainers/go-digest v1.0.0 diff --git a/go.sum b/go.sum index dee7383f1b5..9cf42195726 100644 --- a/go.sum +++ b/go.sum @@ -264,6 +264,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-containerregistry v0.14.0 h1:z58vMqHxuwvAsVwvKEkmVBz2TlgBgH5k6koEXBtlYkw= github.com/google/go-containerregistry v0.14.0/go.mod h1:aiJ2fp/SXvkWgmYHioXnbMdlgB8eXiiYOY55gfN91Wk= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= +github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= diff --git a/internal/config/application.go b/internal/config/application.go index 0c9ae0a27e9..2e0b6a3905e 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -18,6 +18,7 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/pkg/cataloger" + golangCataloger "github.com/anchore/syft/syft/pkg/cataloger/golang" ) var ( @@ -48,6 +49,7 @@ type Application struct { Log logging `yaml:"log" json:"log" mapstructure:"log"` // all logging-related options Catalogers []string `yaml:"catalogers" json:"catalogers" mapstructure:"catalogers"` Package pkg `yaml:"package" json:"package" mapstructure:"package"` + Golang golang `yaml:"golang" json:"golang" mapstructure:"golang"` Attest attest `yaml:"attest" json:"attest" mapstructure:"attest"` FileMetadata FileMetadata `yaml:"file-metadata" json:"file-metadata" mapstructure:"file-metadata"` FileClassification fileClassification `yaml:"file-classification" json:"file-classification" mapstructure:"file-classification"` @@ -69,6 +71,10 @@ func (cfg Application) ToCatalogerConfig() cataloger.Config { }, Catalogers: cfg.Catalogers, Parallelism: cfg.Parallelism, + Golang: golangCataloger.GoCatalogerOpts{ + SearchLocalModCacheLicenses: cfg.Golang.SearchLocalModCacheLicenses, + LocalModCacheDir: cfg.Golang.LocalModCacheDir, + }, } } diff --git a/internal/config/golang.go b/internal/config/golang.go new file mode 100644 index 00000000000..29ab13db13d --- /dev/null +++ b/internal/config/golang.go @@ -0,0 +1,13 @@ +package config + +import "github.com/spf13/viper" + +type golang struct { + SearchLocalModCacheLicenses bool `json:"search-local-mod-cache-licenses" yaml:"search-local-mod-cache-licenses" mapstructure:"search-local-mod-cache-licenses"` + LocalModCacheDir string `json:"local-mod-cache-dir" yaml:"local-mod-cache-dir" mapstructure:"local-mod-cache-dir"` +} + +func (cfg golang) loadDefaultValues(v *viper.Viper) { + v.SetDefault("golang.search-local-mod-cache-licenses", false) + v.SetDefault("golang.local-mod-cache-dir", "") +} diff --git a/internal/licenses/list.go b/internal/licenses/list.go new file mode 100644 index 00000000000..dd53e881182 --- /dev/null +++ b/internal/licenses/list.go @@ -0,0 +1,53 @@ +package licenses + +import "github.com/anchore/syft/internal" + +// all of these taken from https://github.com/golang/pkgsite/blob/8996ff632abee854aef1b764ca0501f262f8f523/internal/licenses/licenses.go#L338 +// which unfortunately is not exported. But fortunately is under BSD-style license. + +var ( + FileNames = []string{ + "COPYING", + "COPYING.md", + "COPYING.markdown", + "COPYING.txt", + "LICENCE", + "LICENCE.md", + "LICENCE.markdown", + "LICENCE.txt", + "LICENSE", + "LICENSE.md", + "LICENSE.markdown", + "LICENSE.txt", + "LICENSE-2.0.txt", + "LICENCE-2.0.txt", + "LICENSE-APACHE", + "LICENCE-APACHE", + "LICENSE-APACHE-2.0.txt", + "LICENCE-APACHE-2.0.txt", + "LICENSE-MIT", + "LICENCE-MIT", + "LICENSE.MIT", + "LICENCE.MIT", + "LICENSE.code", + "LICENCE.code", + "LICENSE.docs", + "LICENCE.docs", + "LICENSE.rst", + "LICENCE.rst", + "MIT-LICENSE", + "MIT-LICENCE", + "MIT-LICENSE.md", + "MIT-LICENCE.md", + "MIT-LICENSE.markdown", + "MIT-LICENCE.markdown", + "MIT-LICENSE.txt", + "MIT-LICENCE.txt", + "MIT_LICENSE", + "MIT_LICENCE", + "UNLICENSE", + "UNLICENCE", + } + + FileNameSet = internal.NewStringSet(FileNames...) +) diff --git a/internal/licenses/parser.go b/internal/licenses/parser.go new file mode 100644 index 00000000000..cd20bf0b616 --- /dev/null +++ b/internal/licenses/parser.go @@ -0,0 +1,33 @@ +package licenses + +import ( + "io" + + "github.com/google/licensecheck" + "golang.org/x/exp/slices" +) + +const ( + coverageThreshold = 75 + unknownLicenseType = "UNKNOWN" +) + +// Parse scans the contents of a license file to attempt to determine the type of license it is +func Parse(reader io.Reader) (licenses []string, err error) { + contents, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + cov := licensecheck.Scan(contents) + + if cov.Percent < float64(coverageThreshold) { + licenses = append(licenses, unknownLicenseType) + } + for _, m := range cov.Match { + if slices.Contains(licenses, m.ID) { + continue + } + licenses = append(licenses, m.ID) + } + return +} diff --git a/syft/pkg/cataloger/cataloger.go b/syft/pkg/cataloger/cataloger.go index 8498da114ee..dfc91bb5539 100644 --- a/syft/pkg/cataloger/cataloger.go +++ b/syft/pkg/cataloger/cataloger.go @@ -48,7 +48,7 @@ func ImageCatalogers(cfg Config) []pkg.Cataloger { java.NewJavaCataloger(cfg.Java()), java.NewNativeImageCataloger(), apkdb.NewApkdbCataloger(), - golang.NewGoModuleBinaryCataloger(), + golang.NewGoModuleBinaryCataloger(cfg.Go()), dotnet.NewDotnetDepsCataloger(), portage.NewPortageCataloger(), sbom.NewSBOMCataloger(), @@ -72,8 +72,8 @@ func DirectoryCatalogers(cfg Config) []pkg.Cataloger { java.NewJavaPomCataloger(), java.NewNativeImageCataloger(), apkdb.NewApkdbCataloger(), - golang.NewGoModuleBinaryCataloger(), - golang.NewGoModFileCataloger(), + golang.NewGoModuleBinaryCataloger(cfg.Go()), + golang.NewGoModFileCataloger(cfg.Go()), rust.NewCargoLockCataloger(), dart.NewPubspecLockCataloger(), dotnet.NewDotnetDepsCataloger(), @@ -105,8 +105,8 @@ func AllCatalogers(cfg Config) []pkg.Cataloger { java.NewJavaPomCataloger(), java.NewNativeImageCataloger(), apkdb.NewApkdbCataloger(), - golang.NewGoModuleBinaryCataloger(), - golang.NewGoModFileCataloger(), + golang.NewGoModuleBinaryCataloger(cfg.Go()), + golang.NewGoModFileCataloger(cfg.Go()), rust.NewCargoLockCataloger(), rust.NewAuditBinaryCataloger(), dart.NewPubspecLockCataloger(), diff --git a/syft/pkg/cataloger/config.go b/syft/pkg/cataloger/config.go index c75e34681c2..2074f16e45e 100644 --- a/syft/pkg/cataloger/config.go +++ b/syft/pkg/cataloger/config.go @@ -1,11 +1,13 @@ package cataloger import ( + "github.com/anchore/syft/syft/pkg/cataloger/golang" "github.com/anchore/syft/syft/pkg/cataloger/java" ) type Config struct { Search SearchConfig + Golang golang.GoCatalogerOpts Catalogers []string Parallelism int } @@ -23,3 +25,7 @@ func (c Config) Java() java.Config { SearchIndexedArchives: c.Search.IncludeIndexedArchives, } } + +func (c Config) Go() golang.GoCatalogerOpts { + return c.Golang +} diff --git a/syft/pkg/cataloger/golang/cataloger.go b/syft/pkg/cataloger/golang/cataloger.go index f49b25e656a..2e64ad45148 100644 --- a/syft/pkg/cataloger/golang/cataloger.go +++ b/syft/pkg/cataloger/golang/cataloger.go @@ -8,14 +8,25 @@ import ( "github.com/anchore/syft/syft/pkg/cataloger/generic" ) +type GoCatalogerOpts struct { + SearchLocalModCacheLicenses bool + LocalModCacheDir string +} + // NewGoModFileCataloger returns a new Go module cataloger object. -func NewGoModFileCataloger() *generic.Cataloger { +func NewGoModFileCataloger(opts GoCatalogerOpts) *generic.Cataloger { + c := goModCataloger{ + licenses: newGoLicenses(opts), + } return generic.NewCataloger("go-mod-file-cataloger"). - WithParserByGlobs(parseGoModFile, "**/go.mod") + WithParserByGlobs(c.parseGoModFile, "**/go.mod") } // NewGoModuleBinaryCataloger returns a new Golang cataloger object. -func NewGoModuleBinaryCataloger() *generic.Cataloger { +func NewGoModuleBinaryCataloger(opts GoCatalogerOpts) *generic.Cataloger { + c := goBinaryCataloger{ + licenses: newGoLicenses(opts), + } return generic.NewCataloger("go-module-binary-cataloger"). - WithParserByMimeTypes(parseGoBinary, internal.ExecutableMIMETypeSet.List()...) + WithParserByMimeTypes(c.parseGoBinary, internal.ExecutableMIMETypeSet.List()...) } diff --git a/syft/pkg/cataloger/golang/cataloger_test.go b/syft/pkg/cataloger/golang/cataloger_test.go index 55a18e6af71..7323e9fa804 100644 --- a/syft/pkg/cataloger/golang/cataloger_test.go +++ b/syft/pkg/cataloger/golang/cataloger_test.go @@ -27,7 +27,7 @@ func Test_Mod_Cataloger_Globs(t *testing.T) { FromDirectory(t, test.fixture). ExpectsResolverContentQueries(test.expected). IgnoreUnfulfilledPathResponses("src/go.sum"). - TestCataloger(t, NewGoModFileCataloger()) + TestCataloger(t, NewGoModFileCataloger(GoCatalogerOpts{})) }) } } @@ -52,7 +52,7 @@ func Test_Binary_Cataloger_Globs(t *testing.T) { pkgtest.NewCatalogTester(). FromDirectory(t, test.fixture). ExpectsResolverContentQueries(test.expected). - TestCataloger(t, NewGoModuleBinaryCataloger()) + TestCataloger(t, NewGoModuleBinaryCataloger(GoCatalogerOpts{})) }) } } diff --git a/syft/pkg/cataloger/golang/licenses.go b/syft/pkg/cataloger/golang/licenses.go new file mode 100644 index 00000000000..9d74e8c0ef2 --- /dev/null +++ b/syft/pkg/cataloger/golang/licenses.go @@ -0,0 +1,140 @@ +package golang + +import ( + "fmt" + "os" + "path" + "regexp" + "strings" + + "github.com/mitchellh/go-homedir" + + "github.com/anchore/syft/internal/licenses" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/source" +) + +type goLicenses struct { + searchLocalModCacheLicenses bool + localModCacheResolver source.FileResolver +} + +func newGoLicenses(opts GoCatalogerOpts) goLicenses { + return goLicenses{ + searchLocalModCacheLicenses: opts.SearchLocalModCacheLicenses, + localModCacheResolver: modCacheResolver(opts.LocalModCacheDir), + } +} + +func defaultGoPath() string { + goPath := os.Getenv("GOPATH") + + if goPath == "" { + homeDir, err := homedir.Dir() + if err != nil { + log.Debug("unable to determine user home dir: %v", err) + } else { + goPath = path.Join(homeDir, "go") + } + } + + return goPath +} + +// resolver needs to be shared between mod file & binary scanners so it's only scanned once +var modCacheResolvers = map[string]source.FileResolver{} + +func modCacheResolver(modCacheDir string) source.FileResolver { + if modCacheDir == "" { + goPath := defaultGoPath() + if goPath != "" { + modCacheDir = path.Join(goPath, "pkg", "mod") + } + } + + if r, ok := modCacheResolvers[modCacheDir]; ok { + return r + } + + var r source.FileResolver + + if modCacheDir == "" { + log.Trace("unable to determine mod cache directory, skipping mod cache resolver") + r = source.NewMockResolverForPaths() + } else { + stat, err := os.Stat(modCacheDir) + + if os.IsNotExist(err) || stat == nil || !stat.IsDir() { + log.Tracef("unable to open mod cache directory: %s, skipping mod cache resolver", modCacheDir) + r = source.NewMockResolverForPaths() + } else { + r = source.NewDeferredResolverFromSource(func() (source.Source, error) { + return source.NewFromDirectory(modCacheDir) + }) + } + } + + modCacheResolvers[modCacheDir] = r + + return r +} + +func (c *goLicenses) getLicenses(resolver source.FileResolver, moduleName, moduleVersion string) (licenses []string, err error) { + moduleName = processCaps(moduleName) + + licenses, err = findLicenses(resolver, + fmt.Sprintf(`**/go/pkg/mod/%s@%s/*`, moduleName, moduleVersion), + ) + + if c.searchLocalModCacheLicenses && err == nil && len(licenses) == 0 { + // if we're running against a directory on the filesystem, it may not include the + // user's homedir / GOPATH, so we defer to using the localModCacheResolver + licenses, err = findLicenses(c.localModCacheResolver, + fmt.Sprintf(`**/%s@%s/*`, moduleName, moduleVersion), + ) + } + + // always return a non-nil slice + if licenses == nil { + licenses = []string{} + } + + return +} + +func findLicenses(resolver source.FileResolver, globMatch string) (out []string, err error) { + if resolver == nil { + return + } + + locations, err := resolver.FilesByGlob(globMatch) + if err != nil { + return nil, err + } + + for _, l := range locations { + fileName := path.Base(l.RealPath) + if licenses.FileNameSet.Contains(fileName) { + contents, err := resolver.FileContentsByLocation(l) + if err != nil { + return nil, err + } + parsed, err := licenses.Parse(contents) + if err != nil { + return nil, err + } + + out = append(out, parsed...) + } + } + + return +} + +var capReplacer = regexp.MustCompile("[A-Z]") + +func processCaps(s string) string { + return capReplacer.ReplaceAllStringFunc(s, func(s string) string { + return "!" + strings.ToLower(s) + }) +} diff --git a/syft/pkg/cataloger/golang/licenses_test.go b/syft/pkg/cataloger/golang/licenses_test.go new file mode 100644 index 00000000000..4003fe3101a --- /dev/null +++ b/syft/pkg/cataloger/golang/licenses_test.go @@ -0,0 +1,76 @@ +package golang + +import ( + "os" + "path" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/source" +) + +func Test_LicenseSearch(t *testing.T) { + tests := []struct { + name string + version string + expected string + }{ + { + name: "github.com/someorg/somename", + version: "v0.3.2", + expected: "Apache-2.0", + }, + { + name: "github.com/CapORG/CapProject", + version: "v4.111.5", + expected: "MIT", + }, + } + + wd, err := os.Getwd() + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := newGoLicenses(GoCatalogerOpts{ + SearchLocalModCacheLicenses: true, + LocalModCacheDir: path.Join(wd, "test-fixtures", "licenses"), + }) + licenses, err := l.getLicenses(source.MockResolver{}, test.name, test.version) + require.NoError(t, err) + + require.Len(t, licenses, 1) + + require.Equal(t, test.expected, licenses[0]) + }) + } +} + +func Test_processCaps(t *testing.T) { + tests := []struct { + name string + expected string + }{ + { + name: "CycloneDX", + expected: "!cyclone!d!x", + }, + { + name: "Azure", + expected: "!azure", + }, + { + name: "xkcd", + expected: "xkcd", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := processCaps(test.name) + + require.Equal(t, test.expected, got) + }) + } +} diff --git a/syft/pkg/cataloger/golang/package.go b/syft/pkg/cataloger/golang/package.go index 93f762a5d9a..de12e7880d7 100644 --- a/syft/pkg/cataloger/golang/package.go +++ b/syft/pkg/cataloger/golang/package.go @@ -6,18 +6,25 @@ import ( "strings" "github.com/anchore/packageurl-go" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/source" ) -func newGoBinaryPackage(dep *debug.Module, mainModule, goVersion, architecture string, buildSettings map[string]string, locations ...source.Location) pkg.Package { +func (c *goBinaryCataloger) newGoBinaryPackage(resolver source.FileResolver, dep *debug.Module, mainModule, goVersion, architecture string, buildSettings map[string]string, locations ...source.Location) pkg.Package { if dep.Replace != nil { dep = dep.Replace } + licenses, err := c.licenses.getLicenses(resolver, dep.Path, dep.Version) + if err != nil { + log.Tracef("error getting licenses for package: %s %v", dep.Path, err) + } + p := pkg.Package{ Name: dep.Path, Version: dep.Version, + Licenses: licenses, PURL: packageURL(dep.Path, dep.Version), Language: pkg.Go, Type: pkg.GoModulePkg, diff --git a/syft/pkg/cataloger/golang/parse_go_binary.go b/syft/pkg/cataloger/golang/parse_go_binary.go index bc2f033ae27..81e8c3d562c 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary.go +++ b/syft/pkg/cataloger/golang/parse_go_binary.go @@ -38,8 +38,12 @@ var ( const devel = "(devel)" +type goBinaryCataloger struct { + licenses goLicenses +} + // Catalog is given an object to resolve file references and content, this function returns any discovered Packages after analyzing rpm db installation. -func parseGoBinary(_ source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { +func (c *goBinaryCataloger) parseGoBinary(resolver source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { var pkgs []pkg.Package unionReader, err := unionreader.GetUnionReader(reader.ReadCloser) @@ -51,14 +55,14 @@ func parseGoBinary(_ source.FileResolver, _ *generic.Environment, reader source. internal.CloseAndLogError(reader.ReadCloser, reader.RealPath) for i, mod := range mods { - pkgs = append(pkgs, buildGoPkgInfo(reader.Location, mod, archs[i])...) + pkgs = append(pkgs, c.buildGoPkgInfo(resolver, reader.Location, mod, archs[i])...) } return pkgs, nil, nil } -func makeGoMainPackage(mod *debug.BuildInfo, arch string, location source.Location) pkg.Package { +func (c *goBinaryCataloger) makeGoMainPackage(resolver source.FileResolver, mod *debug.BuildInfo, arch string, location source.Location) pkg.Package { gbs := getBuildSettings(mod.Settings) - main := newGoBinaryPackage(&mod.Main, mod.Main.Path, mod.GoVersion, arch, gbs, location) + main := c.newGoBinaryPackage(resolver, &mod.Main, mod.Main.Path, mod.GoVersion, arch, gbs, location) if main.Version == devel { if version, ok := gbs["vcs.revision"]; ok { if timestamp, ok := gbs["vcs.time"]; ok { @@ -185,7 +189,7 @@ func createMainModuleFromPath(path string) (mod debug.Module) { return } -func buildGoPkgInfo(location source.Location, mod *debug.BuildInfo, arch string) []pkg.Package { +func (c *goBinaryCataloger) buildGoPkgInfo(resolver source.FileResolver, location source.Location, mod *debug.BuildInfo, arch string) []pkg.Package { var pkgs []pkg.Package if mod == nil { return pkgs @@ -200,7 +204,7 @@ func buildGoPkgInfo(location source.Location, mod *debug.BuildInfo, arch string) if dep == nil { continue } - p := newGoBinaryPackage(dep, mod.Main.Path, mod.GoVersion, arch, nil, location) + p := c.newGoBinaryPackage(resolver, dep, mod.Main.Path, mod.GoVersion, arch, nil, location) if pkg.IsValid(&p) { pkgs = append(pkgs, p) } @@ -210,7 +214,7 @@ func buildGoPkgInfo(location source.Location, mod *debug.BuildInfo, arch string) return pkgs } - main := makeGoMainPackage(mod, arch, location) + main := c.makeGoMainPackage(resolver, mod, arch, location) pkgs = append(pkgs, main) return pkgs diff --git a/syft/pkg/cataloger/golang/parse_go_binary_test.go b/syft/pkg/cataloger/golang/parse_go_binary_test.go index 4cf7fb0fc5d..93b91ff2220 100644 --- a/syft/pkg/cataloger/golang/parse_go_binary_test.go +++ b/syft/pkg/cataloger/golang/parse_go_binary_test.go @@ -497,6 +497,9 @@ func TestBuildGoPkgInfo(t *testing.T) { t.Run(test.name, func(t *testing.T) { for i := range test.expected { p := &test.expected[i] + if p.Licenses == nil { + p.Licenses = []string{} + } p.SetID() } location := source.Location{ @@ -505,7 +508,9 @@ func TestBuildGoPkgInfo(t *testing.T) { FileSystemID: "layer-id", }, } - pkgs := buildGoPkgInfo(location, test.mod, test.arch) + + c := goBinaryCataloger{} + pkgs := c.buildGoPkgInfo(source.NewMockResolverForPaths(), location, test.mod, test.arch) assert.Equal(t, test.expected, pkgs) }) } diff --git a/syft/pkg/cataloger/golang/parse_go_mod.go b/syft/pkg/cataloger/golang/parse_go_mod.go index 1ab03ac9574..8846cf90438 100644 --- a/syft/pkg/cataloger/golang/parse_go_mod.go +++ b/syft/pkg/cataloger/golang/parse_go_mod.go @@ -16,8 +16,14 @@ import ( "github.com/anchore/syft/syft/source" ) +type goModCataloger struct { + licenses goLicenses +} + // parseGoModFile takes a go.mod and lists all packages discovered. -func parseGoModFile(resolver source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { +// +//nolint:funlen +func (c *goModCataloger) parseGoModFile(resolver source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { packages := make(map[string]pkg.Package) contents, err := io.ReadAll(reader) @@ -36,9 +42,15 @@ func parseGoModFile(resolver source.FileResolver, _ *generic.Environment, reader } for _, m := range file.Require { + licenses, err := c.licenses.getLicenses(resolver, m.Mod.Path, m.Mod.Version) + if err != nil { + log.Tracef("error getting licenses for package: %s %v", m.Mod.Path, err) + } + packages[m.Mod.Path] = pkg.Package{ Name: m.Mod.Path, Version: m.Mod.Version, + Licenses: licenses, Locations: source.NewLocationSet(reader.Location), PURL: packageURL(m.Mod.Path, m.Mod.Version), Language: pkg.Go, @@ -52,9 +64,15 @@ func parseGoModFile(resolver source.FileResolver, _ *generic.Environment, reader // remove any old packages and replace with new ones... for _, m := range file.Replace { + licenses, err := c.licenses.getLicenses(resolver, m.New.Path, m.New.Version) + if err != nil { + log.Tracef("error getting licenses for package: %s %v", m.New.Path, err) + } + packages[m.New.Path] = pkg.Package{ Name: m.New.Path, Version: m.New.Version, + Licenses: licenses, Locations: source.NewLocationSet(reader.Location), PURL: packageURL(m.New.Path, m.New.Version), Language: pkg.Go, diff --git a/syft/pkg/cataloger/golang/parse_go_mod_test.go b/syft/pkg/cataloger/golang/parse_go_mod_test.go index cbabf97579d..531ee5ef736 100644 --- a/syft/pkg/cataloger/golang/parse_go_mod_test.go +++ b/syft/pkg/cataloger/golang/parse_go_mod_test.go @@ -88,10 +88,17 @@ func TestParseGoMod(t *testing.T) { for _, test := range tests { t.Run(test.fixture, func(t *testing.T) { + for i := range test.expected { + p := &test.expected[i] + if p.Licenses == nil { + p.Licenses = []string{} + } + } + c := goModCataloger{} pkgtest.NewCatalogTester(). FromFile(t, test.fixture). Expects(test.expected, nil). - TestParser(t, parseGoModFile) + TestParser(t, c.parseGoModFile) }) } } @@ -147,10 +154,16 @@ func Test_GoSumHashes(t *testing.T) { for _, test := range tests { t.Run(test.fixture, func(t *testing.T) { + for i := range test.expected { + p := &test.expected[i] + if p.Licenses == nil { + p.Licenses = []string{} + } + } pkgtest.NewCatalogTester(). FromDirectory(t, test.fixture). Expects(test.expected, nil). - TestCataloger(t, NewGoModFileCataloger()) + TestCataloger(t, NewGoModFileCataloger(GoCatalogerOpts{})) }) } } diff --git a/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/!cap!o!r!g/!cap!project@v4.111.5/LICENSE.txt b/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/!cap!o!r!g/!cap!project@v4.111.5/LICENSE.txt new file mode 100644 index 00000000000..1519c29debd --- /dev/null +++ b/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/!cap!o!r!g/!cap!project@v4.111.5/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Someone Cool + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/someorg/somename@v0.3.2/LICENSE b/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/someorg/somename@v0.3.2/LICENSE new file mode 100644 index 00000000000..0c44dcefe3d --- /dev/null +++ b/syft/pkg/cataloger/golang/test-fixtures/licenses/pkg/mod/github.com/someorg/somename@v0.3.2/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) 2009-present, Alibaba Cloud All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/syft/source/deferred_resolver.go b/syft/source/deferred_resolver.go new file mode 100644 index 00000000000..7ca9b90eab6 --- /dev/null +++ b/syft/source/deferred_resolver.go @@ -0,0 +1,108 @@ +package source + +import ( + "io" + + "github.com/anchore/syft/internal/log" +) + +func NewDeferredResolverFromSource(creator func() (Source, error)) *DeferredResolver { + return NewDeferredResolver(func() (FileResolver, error) { + s, err := creator() + if err != nil { + return nil, err + } + + return s.FileResolver(SquashedScope) + }) +} + +func NewDeferredResolver(creator func() (FileResolver, error)) *DeferredResolver { + return &DeferredResolver{ + creator: creator, + } +} + +type DeferredResolver struct { + creator func() (FileResolver, error) + resolver FileResolver +} + +func (d *DeferredResolver) getResolver() (FileResolver, error) { + if d.resolver == nil { + resolver, err := d.creator() + if err != nil { + return nil, err + } + d.resolver = resolver + } + return d.resolver, nil +} + +func (d *DeferredResolver) FileContentsByLocation(location Location) (io.ReadCloser, error) { + r, err := d.getResolver() + if err != nil { + return nil, err + } + return r.FileContentsByLocation(location) +} + +func (d *DeferredResolver) HasPath(s string) bool { + r, err := d.getResolver() + if err != nil { + log.Debug("unable to get resolver: %v", err) + return false + } + return r.HasPath(s) +} + +func (d *DeferredResolver) FilesByPath(paths ...string) ([]Location, error) { + r, err := d.getResolver() + if err != nil { + return nil, err + } + return r.FilesByPath(paths...) +} + +func (d *DeferredResolver) FilesByGlob(patterns ...string) ([]Location, error) { + r, err := d.getResolver() + if err != nil { + return nil, err + } + return r.FilesByGlob(patterns...) +} + +func (d *DeferredResolver) FilesByMIMEType(types ...string) ([]Location, error) { + r, err := d.getResolver() + if err != nil { + return nil, err + } + return r.FilesByMIMEType(types...) +} + +func (d *DeferredResolver) RelativeFileByPath(location Location, path string) *Location { + r, err := d.getResolver() + if err != nil { + return nil + } + return r.RelativeFileByPath(location, path) +} + +func (d *DeferredResolver) AllLocations() <-chan Location { + r, err := d.getResolver() + if err != nil { + log.Debug("unable to get resolver: %v", err) + return nil + } + return r.AllLocations() +} + +func (d *DeferredResolver) FileMetadataByLocation(location Location) (FileMetadata, error) { + r, err := d.getResolver() + if err != nil { + return FileMetadata{}, err + } + return r.FileMetadataByLocation(location) +} + +var _ FileResolver = (*DeferredResolver)(nil) diff --git a/syft/source/deferred_resolver_test.go b/syft/source/deferred_resolver_test.go new file mode 100644 index 00000000000..c7cd166c305 --- /dev/null +++ b/syft/source/deferred_resolver_test.go @@ -0,0 +1,24 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_NewDeferredResolver(t *testing.T) { + creatorCalled := false + + deferredResolver := NewDeferredResolver(func() (FileResolver, error) { + creatorCalled = true + return NewMockResolverForPaths(), nil + }) + + require.False(t, creatorCalled) + require.Nil(t, deferredResolver.resolver) + + _, _ = deferredResolver.FilesByGlob("**/*") + + require.True(t, creatorCalled) + require.NotNil(t, deferredResolver.resolver) +}