mirror of
https://github.com/anchore/syft.git
synced 2026-11-03 07:56:36 +01:00
feat: license file search (#4327)
Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// RelativeToPackage searches for licenses in the same directory as primary evidence locations
|
||||
// on the package and returns the package with licenses set and ID reset if the package has no licenses already
|
||||
func RelativeToPackage(ctx context.Context, resolver file.Resolver, p pkg.Package) pkg.Package {
|
||||
// if licenses were already found, don't search for more
|
||||
if !p.Licenses.Empty() {
|
||||
return p
|
||||
}
|
||||
var out []pkg.License
|
||||
for _, l := range p.Locations.ToUnorderedSlice() {
|
||||
if evidenceType, ok := l.Annotations[pkg.EvidenceAnnotationKey]; ok && evidenceType != pkg.PrimaryEvidenceAnnotation {
|
||||
continue
|
||||
}
|
||||
// search for license files relative to any primary evidence on the package
|
||||
out = append(out, FindRelativeToLocations(ctx, resolver, l)...)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
p.Licenses = pkg.NewLicenseSet(out...)
|
||||
p.SetID()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// FindAtLocations creates License objects by reading license files directly the provided locations
|
||||
func FindAtLocations(ctx context.Context, resolver file.Resolver, locations ...file.Location) []pkg.License {
|
||||
var out []pkg.License
|
||||
for _, loc := range locations {
|
||||
out = append(out, readFromResolver(ctx, resolver, loc)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindAtPaths creates License objects by reading license files directly at the provided paths
|
||||
func FindAtPaths(ctx context.Context, resolver file.Resolver, paths ...string) []pkg.License {
|
||||
var out []pkg.License
|
||||
for _, p := range paths {
|
||||
locs, err := resolver.FilesByPath(p)
|
||||
if err != nil {
|
||||
log.WithFields("error", err, "path", p).Trace("unable to resolve license path")
|
||||
continue
|
||||
}
|
||||
for _, loc := range locs {
|
||||
out = append(out, readFromResolver(ctx, resolver, loc)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindInDirs creates License objects by searching for known license files in the provided directories
|
||||
func FindInDirs(ctx context.Context, resolver file.Resolver, dirs ...string) []pkg.License {
|
||||
var out []pkg.License
|
||||
for _, dir := range dirs {
|
||||
glob := path.Join(dir, "*") // only search in the directory
|
||||
out = append(out, FindByGlob(ctx, resolver, glob)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindRelativeToLocations creates License objects by searching for known license files relative to the provided locations, in the same directory path
|
||||
func FindRelativeToLocations(ctx context.Context, resolver file.Resolver, locations ...file.Location) []pkg.License {
|
||||
var out []pkg.License
|
||||
for _, location := range locations {
|
||||
dir := path.Dir(location.AccessPath)
|
||||
out = append(out, FindInDirs(ctx, resolver, dir)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindByGlob creates License objects by searching for license files with the provided glob.
|
||||
// only file names which match licenses.LowerFileNames() case-insensitive will be included,
|
||||
// so a recursive glob search such as: `<path>/**/*` will only attempt to read LICENSE files it finds, for example
|
||||
func FindByGlob(ctx context.Context, resolver file.Resolver, glob string) []pkg.License {
|
||||
locs, err := resolver.FilesByGlob(glob)
|
||||
if err != nil {
|
||||
log.WithFields("glob", glob, "error", err).Debug("error searching for license files")
|
||||
return nil
|
||||
}
|
||||
var out []pkg.License
|
||||
for _, l := range locs {
|
||||
fileName := path.Base(l.Path())
|
||||
if IsLicenseFile(fileName) {
|
||||
out = append(out, readFromResolver(ctx, resolver, l)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewFromValues(ctx context.Context, locations []file.Location, values ...string) []pkg.License {
|
||||
if len(locations) == 0 {
|
||||
return pkg.NewLicensesFromValuesWithContext(ctx, values...)
|
||||
}
|
||||
|
||||
var out []pkg.License
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, pkg.NewLicenseFromLocationsWithContext(ctx, value, locations...))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func readFromResolver(ctx context.Context, resolver file.Resolver, location file.Location) []pkg.License {
|
||||
metadataContents, err := resolver.FileContentsByLocation(location)
|
||||
if err != nil || metadataContents == nil {
|
||||
log.WithFields("error", err, "path", location.Path()).Trace("unable to license file contents")
|
||||
return nil
|
||||
}
|
||||
defer internal.CloseAndLogError(metadataContents, location.Path())
|
||||
return pkg.NewLicensesFromReadCloserWithContext(ctx, file.NewLocationReadCloser(location, metadataContents))
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/internal/licenses"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/fileresolver"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
)
|
||||
|
||||
// scanner is used by all tests
|
||||
var scanner = getScanner()
|
||||
|
||||
func Test_FindRelativeLicenses(t *testing.T) {
|
||||
resolver := fileresolver.NewFromUnindexedDirectory("testdata")
|
||||
sourceTxtResolved, err := resolver.FilesByPath("source.txt")
|
||||
require.NoError(t, err)
|
||||
|
||||
sourceTxt := file.NewLocationSet(sourceTxtResolved[0].WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resolver file.Resolver
|
||||
p pkg.Package
|
||||
expected pkg.LicenseSet
|
||||
}{
|
||||
{
|
||||
name: "existing license",
|
||||
resolver: resolver,
|
||||
p: pkg.Package{
|
||||
Locations: sourceTxt,
|
||||
Licenses: pkg.NewLicenseSet(pkg.NewLicense("GPL-2.0")),
|
||||
},
|
||||
expected: pkg.NewLicenseSet(pkg.NewLicense("GPL-2.0")),
|
||||
},
|
||||
{
|
||||
name: "no licenses",
|
||||
resolver: fileresolver.Empty{},
|
||||
p: pkg.Package{
|
||||
Locations: sourceTxt,
|
||||
},
|
||||
expected: pkg.NewLicenseSet(),
|
||||
},
|
||||
{
|
||||
name: "found relative license",
|
||||
resolver: resolver,
|
||||
p: pkg.Package{
|
||||
Locations: sourceTxt,
|
||||
},
|
||||
expected: pkg.NewLicenseSet(pkg.NewLicense("MIT")),
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
ctx = licenses.SetContextLicenseScanner(ctx, scanner)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := RelativeToPackage(ctx, tt.resolver, tt.p)
|
||||
require.Equal(t, licenseNames(tt.expected.ToSlice()), licenseNames(got.Licenses.ToSlice()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Finders(t *testing.T) {
|
||||
resolver := fileresolver.NewFromUnindexedDirectory("testdata")
|
||||
|
||||
// prepare context with license scanner
|
||||
ctx := context.TODO()
|
||||
ctx = licenses.SetContextLicenseScanner(ctx, scanner)
|
||||
|
||||
// resolve known files
|
||||
licenseLocs, err := resolver.FilesByPath("LICENSE")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, licenseLocs)
|
||||
licenseLoc := licenseLocs[0]
|
||||
|
||||
sourceLocs, err := resolver.FilesByPath("source.txt")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, sourceLocs)
|
||||
sourceLoc := sourceLocs[0]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
finder func(t *testing.T) []pkg.License
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "FindAtLocations finds LICENSE content",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindAtLocations(ctx, resolver, licenseLoc)
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "FindAtLocations with empty resolver returns none",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindAtLocations(ctx, fileresolver.Empty{}, licenseLoc)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FindAtPaths finds LICENSE by path",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindAtPaths(ctx, resolver, "LICENSE")
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "FindInDirs finds LICENSE in directory",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindInDirs(ctx, resolver, ".")
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "FindRelativeToLocations finds LICENSE relative to source.txt",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindRelativeToLocations(ctx, resolver, sourceLoc)
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "FindByGlob finds LICENSE with glob",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindByGlob(ctx, resolver, "*")
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "FindByGlob finds LICENSE with recursive glob",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return FindByGlob(ctx, resolver, "**/*")
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
{
|
||||
name: "NewFromValues with locations returns license values",
|
||||
finder: func(t *testing.T) []pkg.License {
|
||||
return NewFromValues(ctx, []file.Location{licenseLoc}, "MIT")
|
||||
},
|
||||
expected: []string{"MIT"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.finder(t)
|
||||
require.Equal(t, tt.expected, licenseNames(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func licenseNames(slice []pkg.License) []string {
|
||||
var out []string
|
||||
for _, l := range slice {
|
||||
out = append(out, l.SPDXExpression)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getScanner() licenses.Scanner {
|
||||
s, err := licenses.NewDefaultScanner()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/syft/internal/licenses"
|
||||
)
|
||||
|
||||
var licenseRegexp = regexp.MustCompile(`^(?i)(?:(?:UN|MIT-)?LICEN[S|C]E|COPYING|NOTICE).*$`)
|
||||
|
||||
// lowerFileNames is a strset.Set of lowercased filenames
|
||||
var lowerFileNames = func() *strset.Set {
|
||||
lowerNames := strset.New()
|
||||
for _, fileName := range licenses.FileNames() {
|
||||
lowerNames.Add(strings.ToLower(fileName))
|
||||
}
|
||||
return lowerNames
|
||||
}()
|
||||
|
||||
// lowerFileNamesSorted is a sorted slice of lowercased filenames
|
||||
var lowerFileNamesSorted = func() []string {
|
||||
out := lowerFileNames.List()
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}()
|
||||
|
||||
// remove duplicate names that match the regex, keep any extras to test after regex check
|
||||
var minLength, extraFileNames = func() (int, []string) {
|
||||
minSize := math.MaxInt
|
||||
var extras []string
|
||||
for _, name := range lowerFileNamesSorted {
|
||||
if len(name) < minSize {
|
||||
minSize = len(name)
|
||||
}
|
||||
if licenseRegexp.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
extras = append(extras, name)
|
||||
}
|
||||
return minSize, extras
|
||||
}()
|
||||
|
||||
// IsLicenseFile returns true if the name matches known license file name patterns
|
||||
func IsLicenseFile(name string) bool {
|
||||
if len(name) < minLength {
|
||||
return false
|
||||
}
|
||||
if licenseRegexp.MatchString(name) {
|
||||
return true
|
||||
}
|
||||
for _, licenseFile := range extraFileNames {
|
||||
if strings.EqualFold(licenseFile, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_IsLicenseFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// positive cases (should be detected as license files)
|
||||
{"plain LICENSE", "LICENSE", true},
|
||||
{"lowercase license", "license", true},
|
||||
{"license with extension", "LICENSE.txt", true},
|
||||
{"mixed case", "LiCeNsE", true},
|
||||
{"copying", "COPYING", true},
|
||||
{"AL2.0", "AL2.0", true},
|
||||
{"notice", "NOTICE", true},
|
||||
{"mit-license", "MIT-License", true},
|
||||
{"unlicense", "UNLICENSE", true},
|
||||
{"licence variant", "LICENCE", true},
|
||||
{"license markdown", "license.md", true},
|
||||
|
||||
// negative cases (should NOT be detected)
|
||||
{"AL1.0", "AL1.0", false},
|
||||
{"readme", "README", false},
|
||||
{"readme with ext", "README.md", false},
|
||||
{"not a license", "not_a_license", false},
|
||||
{"licensor (prefix-like but not)", "LICENSOR", false},
|
||||
{"too short (below minLength)", "a", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := IsLicenseFile(tt.input)
|
||||
if got != tt.want {
|
||||
t.Fatalf("IsLicenseFile(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Some Place, Inc.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1 @@
|
||||
a source file
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/anchore/syft/internal/relationship"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
"github.com/anchore/syft/syft/internal/fileresolver"
|
||||
"github.com/anchore/syft/syft/linux"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
@@ -81,6 +82,7 @@ func NewCatalogTester() *CatalogTester {
|
||||
locationComparer: cmptest.DefaultLocationComparer,
|
||||
licenseComparer: cmptest.DefaultLicenseComparer,
|
||||
packageStringer: stringPackage,
|
||||
resolver: fileresolver.Empty{},
|
||||
ignoreUnfulfilledPathResponses: map[string][]string{
|
||||
"FilesByPath": {
|
||||
// most catalogers search for a linux release, which will not be fulfilled in testing
|
||||
|
||||
Reference in New Issue
Block a user