feat: license file search (#4327)

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow
2025-11-03 14:16:05 -05:00
committed by GitHub
parent 7c154e7c37
commit a400c675fc
32 changed files with 616 additions and 210 deletions
@@ -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)
}
})
}
}
+7
View File
@@ -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