Skip to content

Commit 2dcfdc3

Browse files
committed
feat(java): detect JAR licenses from packaged LICENSE files
1 parent ff29a07 commit 2dcfdc3

5 files changed

Lines changed: 154 additions & 30 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: 134 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,24 @@ import (
1717
"strings"
1818

1919
mavenversion "github.com/masahiro331/go-mvn-version"
20+
"github.com/samber/lo"
2021
"golang.org/x/net/html/charset"
2122
"golang.org/x/xerrors"
2223

2324
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
25+
"github.com/aquasecurity/trivy/pkg/licensing"
2426
"github.com/aquasecurity/trivy/pkg/log"
27+
"github.com/aquasecurity/trivy/pkg/set"
2528
xio "github.com/aquasecurity/trivy/pkg/x/io"
2629
xos "github.com/aquasecurity/trivy/pkg/x/os"
2730
)
2831

32+
// licenseFileStems are the base names (without extension) of files treated as
33+
// license files inside a jar, matched case-insensitively. It mirrors the set used
34+
// by the standalone license file analyzer (its acceptedFileNames), but matching is
35+
// done on the stem so common variants like LICENSE.txt are also recognized.
36+
var licenseFileStems = set.NewCaseInsensitive("license", "licence", "copyright")
37+
2938
var (
3039
jarFileRegEx = regexp.MustCompile(`^([a-zA-Z0-9\._-]*[^-*])-(\d\S*(?:-SNAPSHOT)?).jar$`)
3140
)
@@ -41,6 +50,9 @@ type Parser struct {
4150
rootFilePath string
4251
offline bool
4352
size int64
53+
// licenseConfidenceLevel is the confidence threshold for classifying
54+
// LICENSE files packed in a jar.
55+
licenseConfidenceLevel float64
4456

4557
client Client
4658
}
@@ -65,6 +77,12 @@ func WithSize(size int64) Option {
6577
}
6678
}
6779

80+
func WithLicenseClassifierConfidenceLevel(level float64) Option {
81+
return func(p *Parser) {
82+
p.licenseConfidenceLevel = level
83+
}
84+
}
85+
6886
func NewParser(c Client, opts ...Option) *Parser {
6987
p := &Parser{
7088
logger: log.WithPrefix("jar"),
@@ -93,15 +111,16 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
93111
// e.g. spring-core-5.3.4-SNAPSHOT.jar => sprint-core, 5.3.4-SNAPSHOT
94112
fileProps := parseFileName(filePath)
95113

96-
pkgs, m, foundPomProps, licenses, err := p.traverseZip(filePath, size, r, fileProps)
114+
ac, err := p.traverseZip(filePath, size, r, fileProps)
97115
if err != nil {
98116
return nil, nil, xerrors.Errorf("zip error: %w", err)
99117
}
118+
pkgs := ac.pkgs
100119

101120
// If pom.properties is found, it should be preferred than MANIFEST.MF.
102121
// Otherwise, resolve the artifact of the jar itself from MANIFEST.MF / SHA-1 / file name.
103-
if !foundPomProps {
104-
props, found, err := p.resolveArtifact(filePath, r, m, fileProps)
122+
if !ac.foundPomProps {
123+
props, found, err := p.resolveArtifact(filePath, r, ac.manifest, fileProps)
105124
if err != nil {
106125
return nil, nil, err
107126
}
@@ -110,19 +129,55 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
110129
}
111130
}
112131

113-
// Attach licenses from embedded pom.xml, matched by "groupID:artifactID".
132+
// Attach licenses declared in the embedded pom.xml, matched by "groupID:artifactID".
133+
attachPomLicenses(pkgs, ac.pomLicenses)
134+
135+
// Attach licenses classified from LICENSE files.
136+
attachFileLicenses(pkgs, filePath, ac.fileLicenses)
137+
138+
return pkgs, nil, nil
139+
}
140+
141+
// attachPomLicenses attaches licenses declared in embedded pom.xml files to packages,
142+
// matched by "groupID:artifactID". Packages that already have a license (e.g. set by a
143+
// nested jar from its own pom.xml) are left untouched.
144+
func attachPomLicenses(pkgs []ftypes.Package, pomLicenses map[string][]string) {
114145
for i := range pkgs {
115146
pkg := &pkgs[i]
116-
// Keep licenses already set by a nested jar from its own pom.xml.
117147
if len(pkg.Licenses) > 0 {
118148
continue
119149
}
120-
if names, ok := licenses[pkg.Name]; ok {
150+
if names, ok := pomLicenses[pkg.Name]; ok {
121151
pkg.Licenses = names
122152
}
123153
}
154+
}
155+
156+
// attachFileLicenses attaches licenses classified from LICENSE files to the jar's
157+
// own package, but only when the jar contains a single artifact and that package
158+
// has no license yet.
159+
func attachFileLicenses(pkgs []ftypes.Package, filePath string, fileLicenses []string) {
160+
if len(fileLicenses) == 0 {
161+
return
162+
}
163+
164+
var own []int
165+
for i := range pkgs {
166+
if pkgs[i].FilePath == filePath {
167+
own = append(own, i)
168+
}
169+
}
124170

125-
return pkgs, nil, nil
171+
if len(own) != 1 {
172+
// Zero (nothing to attribute) or multiple (uber/shaded: the LICENSE owner
173+
// is ambiguous) packages belong to this jar.
174+
return
175+
}
176+
177+
pkg := &pkgs[own[0]]
178+
if len(pkg.Licenses) == 0 {
179+
pkg.Licenses = lo.Uniq(fileLicenses)
180+
}
126181
}
127182

128183
// resolveArtifact determines the artifact of the jar itself when pom.properties is absent,
@@ -178,20 +233,29 @@ func (p *Parser) resolveArtifact(filePath string, r xio.ReadSeekerAt, m manifest
178233
return Properties{}, false, nil
179234
}
180235

181-
func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fileProps Properties) (
182-
[]ftypes.Package, manifest, bool, map[string][]string, error) {
183-
var pkgs []ftypes.Package
184-
var m manifest
185-
var foundPomProps bool
236+
// artifactContents holds everything extracted from a single jar in one zip traversal.
237+
type artifactContents struct {
238+
pkgs []ftypes.Package
239+
manifest manifest
240+
// foundPomProps is true when a pom.properties for the jar itself was found.
241+
foundPomProps bool
242+
// pomLicenses holds licenses declared in embedded META-INF/maven/<g>/<a>/pom.xml,
243+
// keyed by "groupID:artifactID". The path carries no version, so packages are
244+
// matched by G:A after the traversal (file order in the zip is not guaranteed).
245+
pomLicenses map[string][]string
246+
// fileLicenses holds licenses classified from LICENSE files at the jar root and
247+
// top-level META-INF/. They are attributed only when the jar has a single artifact.
248+
fileLicenses []string
249+
}
186250

187-
// Licenses declared in embedded META-INF/maven/<g>/<a>/pom.xml, keyed by "groupID:artifactID".
188-
// The path carries no version, so packages are matched by G:A after the loop
189-
// (file order in the zip is not guaranteed).
190-
licenses := make(map[string][]string)
251+
func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fileProps Properties) (artifactContents, error) {
252+
ac := artifactContents{
253+
pomLicenses: make(map[string][]string),
254+
}
191255

192256
zr, err := zip.NewReader(r, size)
193257
if err != nil {
194-
return nil, manifest{}, false, nil, xerrors.Errorf("zip error: %w", err)
258+
return artifactContents{}, xerrors.Errorf("zip error: %w", err)
195259
}
196260

197261
for _, fileInJar := range zr.File {
@@ -203,41 +267,52 @@ func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fi
203267
continue
204268
}
205269
if len(names) > 0 {
206-
licenses[packageName(groupID, artifactID)] = names
270+
ac.pomLicenses[packageName(groupID, artifactID)] = names
207271
}
208272
continue
209273
}
210274

275+
// Collect licenses classified from a LICENSE file.
276+
if isJarLicenseFile(fileInJar.Name) {
277+
names, err := p.classifyPackedLicense(fileInJar)
278+
if err != nil {
279+
p.logger.Debug("Failed to classify license file", log.String("file", fileInJar.Name), log.Err(err))
280+
continue
281+
}
282+
ac.fileLicenses = append(ac.fileLicenses, names...)
283+
continue
284+
}
285+
211286
switch {
212287
case filepath.Base(fileInJar.Name) == "pom.properties":
213288
props, err := parsePomProperties(fileInJar, filePath)
214289
if err != nil {
215-
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
290+
return artifactContents{}, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
216291
}
217292
// Validation of props to avoid getting packages with empty Name/Version
218293
if props.Valid() {
219-
pkgs = append(pkgs, props.Package())
294+
ac.pkgs = append(ac.pkgs, props.Package())
220295

221296
// Check if the pom.properties is for the original JAR/WAR/EAR
222297
if fileProps.ArtifactID == props.ArtifactID && fileProps.Version == props.Version {
223-
foundPomProps = true
298+
ac.foundPomProps = true
224299
}
225300
}
226301
case filepath.Base(fileInJar.Name) == "MANIFEST.MF":
227-
m, err = parseManifest(fileInJar)
302+
ac.manifest, err = parseManifest(fileInJar)
228303
if err != nil {
229-
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
304+
return artifactContents{}, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
230305
}
231306
case isArtifact(fileInJar.Name):
232307
innerPkgs, _, err := p.parseInnerJar(fileInJar, filePath) // TODO process inner deps
233308
if err != nil {
234309
p.logger.Debug("Failed to parse", log.String("file", fileInJar.Name), log.Err(err))
235310
continue
236311
}
237-
pkgs = append(pkgs, innerPkgs...)
312+
ac.pkgs = append(ac.pkgs, innerPkgs...)
238313
}
239314
}
240-
return pkgs, m, foundPomProps, licenses, nil
315+
return ac, nil
241316
}
242317

243318
func (p *Parser) parseInnerJar(zf *zip.File, rootPath string) ([]ftypes.Package, []ftypes.Dependency, error) {
@@ -404,6 +479,40 @@ func decodePomLicenses(r io.Reader) ([]string, error) {
404479
return names, nil
405480
}
406481

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