Skip to content

Commit 037a33a

Browse files
committed
feat(java): detect JAR licenses from packaged LICENSE files
1 parent 6e37acf commit 037a33a

5 files changed

Lines changed: 126 additions & 14 deletions

File tree

docs/guide/coverage/language/java.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,15 @@ To find information about these JARs[^2], the same logic is used as for the base
3838
`table` format only contains the name of root JAR[^2] . To get the full path to inner JARs[^2] use the `json` format.
3939

4040
### Licenses
41-
Trivy detects licenses declared in the `<licenses>` block of the embedded `META-INF/maven/<groupId>/<artifactId>/pom.xml` and attaches them to the matching package.
41+
Trivy detects licenses for a JAR[^2] from two sources, in this order:
4242

43-
Coverage is limited: many JARs declare a license only in a parent POM (which is not expanded in the embedded `pom.xml`) or ship no Maven descriptor at all.
43+
1. **Embedded POM** — the `<licenses>` block of the embedded `META-INF/maven/<groupId>/<artifactId>/pom.xml`, matched to the package by `groupId:artifactId`.
44+
2. **License files**`LICENSE`, `LICENCE` or `COPYRIGHT` files (including variants like `LICENSE.txt`) located at the JAR[^2] root or directly under `META-INF/`. Their content is classified with the [license classifier](../../scanner/license.md). A license file carries no `groupId:artifactId`, so it is attached only when the JAR[^2] contains a single artifact; in uber/shaded JARs[^2] (multiple artifacts) the owner is ambiguous and such files are skipped.
45+
46+
Notes and limitations:
47+
48+
- Coverage is limited: many JARs[^2] declare a license only in a parent POM (which is not expanded in the embedded `pom.xml`) or ship no Maven descriptor at all.
49+
- A single license file may bundle the texts of third-party components (e.g. Spring or Tomcat artifacts), so a package can be reported with several licenses found in that file, not only its own.
4450

4551
## pom.xml
4652
Trivy parses your `pom.xml` file and tries to find files with dependencies from these local locations.

pkg/dependency/parser/java/jar/parse.go

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ import (
2121
"golang.org/x/xerrors"
2222

2323
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
24+
"github.com/aquasecurity/trivy/pkg/licensing"
2425
"github.com/aquasecurity/trivy/pkg/log"
26+
"github.com/aquasecurity/trivy/pkg/set"
2527
xio "github.com/aquasecurity/trivy/pkg/x/io"
2628
xos "github.com/aquasecurity/trivy/pkg/x/os"
2729
)
2830

31+
// licenseFileStems are the base names (without extension) of files treated as
32+
// license files inside a jar, matched case-insensitively. It mirrors the set used
33+
// by the standalone license file analyzer (its acceptedFileNames), but matching is
34+
// done on the stem so common variants like LICENSE.txt are also recognized.
35+
var licenseFileStems = set.NewCaseInsensitive("license", "licence", "copyright")
36+
2937
var (
3038
jarFileRegEx = regexp.MustCompile(`^([a-zA-Z0-9\._-]*[^-*])-(\d\S*(?:-SNAPSHOT)?).jar$`)
3139
)
@@ -37,10 +45,11 @@ type Client interface {
3745
}
3846

3947
type Parser struct {
40-
logger *log.Logger
41-
rootFilePath string
42-
offline bool
43-
size int64
48+
logger *log.Logger
49+
rootFilePath string
50+
offline bool
51+
size int64
52+
licenseConfidenceLevel float64
4453

4554
client Client
4655
}
@@ -65,6 +74,12 @@ func WithSize(size int64) Option {
6574
}
6675
}
6776

77+
func WithLicenseClassifierConfidenceLevel(level float64) Option {
78+
return func(p *Parser) {
79+
p.licenseConfidenceLevel = level
80+
}
81+
}
82+
6883
func NewParser(c Client, opts ...Option) *Parser {
6984
p := &Parser{
7085
logger: log.WithPrefix("jar"),
@@ -173,11 +188,12 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
173188
var pkgs []ftypes.Package
174189
var m manifest
175190
var foundPomProps bool
191+
var licenseFiles []*zip.File
176192

177193
// Licenses declared in embedded META-INF/maven/<g>/<a>/pom.xml, keyed by "groupID:artifactID".
178194
// The path carries no version, so packages are matched by G:A after the loop
179195
// (file order in the zip is not guaranteed).
180-
licenses := make(map[string][]string)
196+
pomLicenses := make(map[string][]string)
181197

182198
zr, err := zip.NewReader(r, size)
183199
if err != nil {
@@ -193,11 +209,16 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
193209
continue
194210
}
195211
if len(names) > 0 {
196-
licenses[packageName(groupID, artifactID)] = names
212+
pomLicenses[packageName(groupID, artifactID)] = names
197213
}
198214
continue
199215
}
200216

217+
if isJarLicenseFile(fileInJar.Name) {
218+
licenseFiles = append(licenseFiles, fileInJar)
219+
continue
220+
}
221+
201222
switch {
202223
case filepath.Base(fileInJar.Name) == "pom.properties":
203224
props, err := parsePomProperties(fileInJar, fileProps.FilePath)
@@ -229,18 +250,60 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
229250
}
230251

231252
// Attach licenses from embedded pom.xml, matched by "groupID:artifactID".
253+
attachPomLicenses(pkgs, pomLicenses)
254+
255+
// Classify and attach the license from the LICENSE file packed in the jar.
256+
p.attachFileLicenses(pkgs, fileProps.FilePath, licenseFiles)
257+
258+
return pkgs, m, foundPomProps, nil
259+
}
260+
261+
// attachPomLicenses attaches licenses declared in embedded pom.xml files to packages,
262+
// matched by "groupID:artifactID". Packages that already have a license (e.g. set by a
263+
// nested jar from its own pom.xml) are left untouched.
264+
func attachPomLicenses(pkgs []ftypes.Package, pomLicenses map[string][]string) {
232265
for i := range pkgs {
233266
pkg := &pkgs[i]
234-
// Keep licenses already set by a nested jar from its own pom.xml.
235267
if len(pkg.Licenses) > 0 {
236268
continue
237269
}
238-
if names, ok := licenses[pkg.Name]; ok {
270+
if names, ok := pomLicenses[pkg.Name]; ok {
239271
pkg.Licenses = names
240272
}
241273
}
274+
}
242275

243-
return pkgs, m, foundPomProps, nil
276+
// attachFileLicenses classifies the LICENSE file packed in a jar and attaches it to the
277+
// jar's own package, but only when the owner is unambiguous: a single LICENSE file, a
278+
// single package belonging to this jar, and no license from its pom.xml yet.
279+
func (p *Parser) attachFileLicenses(pkgs []ftypes.Package, filePath string, licenseFiles []*zip.File) {
280+
if len(licenseFiles) != 1 {
281+
return
282+
}
283+
284+
var own []int
285+
for i := range pkgs {
286+
if pkgs[i].FilePath == filePath {
287+
own = append(own, i)
288+
}
289+
}
290+
if len(own) != 1 {
291+
return
292+
}
293+
294+
pkg := &pkgs[own[0]]
295+
if len(pkg.Licenses) > 0 {
296+
return
297+
}
298+
299+
names, err := p.classifyPackedLicense(licenseFiles[0])
300+
if err != nil {
301+
p.logger.Debug("Failed to classify license file", log.String("file", licenseFiles[0].Name), log.Err(err))
302+
return
303+
}
304+
if len(names) > 0 {
305+
pkg.Licenses = names
306+
}
244307
}
245308

246309
func (p *Parser) parseInnerJar(zf *zip.File, rootPath string) ([]ftypes.Package, []ftypes.Dependency, error) {
@@ -407,6 +470,40 @@ func decodePomLicenses(r io.Reader) ([]string, error) {
407470
return names, nil
408471
}
409472

473+
// isJarLicenseFile reports whether a zip entry is a license file eligible for
474+
// classification: located at the jar root or directly under META-INF/ (not in a subdirectory),
475+
// with a base name whose stem is license/licence/copyright (e.g. LICENSE, LICENSE.txt).
476+
// Vendored licenses use prefixed names (e.g. FastDoubleParser-LICENSE) or nested
477+
// paths, so they are intentionally excluded.
478+
func isJarLicenseFile(name string) bool {
479+
dir := path.Dir(name)
480+
if dir != "." && dir != "META-INF" {
481+
return false
482+
}
483+
base := path.Base(name)
484+
stem := strings.TrimSuffix(base, path.Ext(base))
485+
return licenseFileStems.Contains(stem)
486+
}
487+
488+
// classifyPackedLicense classifies a LICENSE file packed in a jar and returns the
489+
// detected license names.
490+
func (p *Parser) classifyPackedLicense(f *zip.File) ([]string, error) {
491+
file, err := f.Open()
492+
if err != nil {
493+
return nil, xerrors.Errorf("unable to open %s: %w", f.Name, err)
494+
}
495+
defer file.Close()
496+
497+
lf, err := licensing.Classify(f.Name, file, p.licenseConfidenceLevel)
498+
if err != nil {
499+
return nil, xerrors.Errorf("license classification error: %w", err)
500+
}
501+
if lf == nil {
502+
return nil, nil
503+
}
504+
return lf.Findings.Names(), nil
505+
}
506+
410507
type manifest struct {
411508
implementationVersion string
412509
implementationTitle string

pkg/dependency/parser/java/jar/parse_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ var (
6565
Name: "org.apache.commons:commons-lang3",
6666
Version: "3.11",
6767
FilePath: "testdata/maven.war/WEB-INF/lib/commons-lang3-3.11.jar",
68+
Licenses: []string{"Apache-2.0"},
6869
},
6970
}
7071

@@ -77,11 +78,13 @@ var (
7778
Name: "commons-dbcp:commons-dbcp",
7879
Version: "1.4",
7980
FilePath: "testdata/gradle.war/WEB-INF/lib/commons-dbcp-1.4.jar",
81+
Licenses: []string{"Apache-2.0"},
8082
},
8183
{
8284
Name: "commons-pool:commons-pool",
8385
Version: "1.6",
8486
FilePath: "testdata/gradle.war/WEB-INF/lib/commons-pool-1.6.jar",
87+
Licenses: []string{"Apache-2.0"},
8588
},
8689
{
8790
Name: "log4j:log4j",
@@ -93,6 +96,7 @@ var (
9396
Name: "org.apache.commons:commons-compress",
9497
Version: "1.19",
9598
FilePath: "testdata/gradle.war/WEB-INF/lib/commons-compress-1.19.jar",
99+
Licenses: []string{"Apache-2.0"},
96100
},
97101
}
98102

pkg/fanal/analyzer/language/java/jar/jar.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ var requiredExtensions = []string{
3333

3434
// javaLibraryAnalyzer analyzes jar/war/ear/par files
3535
type javaLibraryAnalyzer struct {
36-
parallel int
36+
parallel int
37+
licenseClassifierConfidenceLevel float64
3738
}
3839

3940
func newJavaLibraryAnalyzer(options analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
4041
return &javaLibraryAnalyzer{
41-
parallel: options.Parallel,
42+
parallel: options.Parallel,
43+
licenseClassifierConfidenceLevel: options.LicenseScannerOption.ClassifierConfidenceLevel,
4244
}, nil
4345
}
4446

@@ -57,7 +59,8 @@ func (a *javaLibraryAnalyzer) PostAnalyze(ctx context.Context, input analyzer.Po
5759

5860
// It will be called on each JAR file
5961
onFile := func(path string, info fs.FileInfo, r xio.ReadSeekerAt) (*types.Application, error) {
60-
p := jar.NewParser(client, jar.WithSize(info.Size()), jar.WithFilePath(path))
62+
p := jar.NewParser(client, jar.WithSize(info.Size()), jar.WithFilePath(path),
63+
jar.WithLicenseClassifierConfidenceLevel(a.licenseClassifierConfidenceLevel))
6164
return language.ParsePackage(ctx, types.Jar, path, r, p, input.Options.FileChecksum)
6265
}
6366

pkg/fanal/analyzer/language/java/jar/jar_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func Test_javaLibraryAnalyzer_Analyze(t *testing.T) {
6969
Name: "org.apache.commons:commons-lang3",
7070
FilePath: "testdata/test.war/WEB-INF/lib/commons-lang3-3.11.jar",
7171
Version: "3.11",
72+
Licenses: []string{"Apache-2.0"},
7273
},
7374
{
7475
Name: "com.example:web-app",
@@ -114,6 +115,7 @@ func Test_javaLibraryAnalyzer_Analyze(t *testing.T) {
114115
Name: "org.apache.tomcat.embed:tomcat-embed-websocket",
115116
FilePath: "testdata/test.jar",
116117
Version: "9.0.65",
118+
Licenses: []string{"Apache-2.0"},
117119
},
118120
},
119121
},

0 commit comments

Comments
 (0)