Skip to content

Commit 13d3dfc

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

7 files changed

Lines changed: 135 additions & 22 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.

integration/testdata/spring4shell-jre11.json.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@
257257
"PkgPath": "usr/local/tomcat/webapps/helloworld.war/WEB-INF/lib/spring-beans-5.3.15.jar",
258258
"PkgIdentifier": {
259259
"PURL": "pkg:maven/org.springframework/spring-beans@5.3.15",
260-
"UID": "382ba53322e3ac2a"
260+
"UID": "59399c43172e6917"
261261
},
262262
"InstalledVersion": "5.3.15",
263263
"FixedVersion": "5.3.18",

integration/testdata/spring4shell-jre8.json.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@
257257
"PkgPath": "usr/local/tomcat/webapps/helloworld.war/WEB-INF/lib/spring-beans-5.3.15.jar",
258258
"PkgIdentifier": {
259259
"PURL": "pkg:maven/org.springframework/spring-beans@5.3.15",
260-
"UID": "382ba53322e3ac2a"
260+
"UID": "59399c43172e6917"
261261
},
262262
"InstalledVersion": "5.3.15",
263263
"FixedVersion": "5.3.18",

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

Lines changed: 113 additions & 15 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"),
@@ -93,15 +108,15 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
93108
// e.g. spring-core-5.3.4-SNAPSHOT.jar => sprint-core, 5.3.4-SNAPSHOT
94109
fileProps := parseFileName(filePath)
95110

96-
pkgs, m, foundPomProps, err := p.traverseZip(size, r, fileProps)
111+
pkgs, m, foundPomProps, licenseFiles, err := p.traverseZip(size, r, fileProps)
97112
if err != nil {
98113
return nil, nil, xerrors.Errorf("zip error: %w", err)
99114
}
100115

101116
// If pom.properties is found, it should be preferred than MANIFEST.MF.
102117
// Otherwise, resolve the artifact of the jar itself from MANIFEST.MF / SHA-1 / file name.
103118
// Such an artifact has no embedded pom.xml (maven-archiver writes pom.xml and
104-
// pom.properties together), so it carries no license.
119+
// pom.properties together), so it carries no pom.xml license.
105120
if !foundPomProps {
106121
props, found, err := p.resolveArtifact(r, m, fileProps)
107122
if err != nil {
@@ -112,6 +127,10 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
112127
}
113128
}
114129

130+
// Classify and attach the LICENSE file now that the jar's own artifact is resolved
131+
// (it may have been added above from MANIFEST.MF / SHA-1 / file name).
132+
p.attachFileLicenses(pkgs, fileProps.FilePath, licenseFiles)
133+
115134
return pkgs, nil, nil
116135
}
117136

@@ -169,19 +188,20 @@ func (p *Parser) resolveArtifact(r xio.ReadSeekerAt, m manifest, fileProps Prope
169188
}
170189

171190
func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Properties) (
172-
[]ftypes.Package, manifest, bool, error) {
191+
[]ftypes.Package, manifest, bool, []*zip.File, error) {
173192
var pkgs []ftypes.Package
174193
var m manifest
175194
var foundPomProps bool
195+
var licenseFiles []*zip.File
176196

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

182202
zr, err := zip.NewReader(r, size)
183203
if err != nil {
184-
return nil, manifest{}, false, xerrors.Errorf("zip error: %w", err)
204+
return nil, manifest{}, false, nil, xerrors.Errorf("zip error: %w", err)
185205
}
186206

187207
for _, fileInJar := range zr.File {
@@ -193,16 +213,21 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
193213
continue
194214
}
195215
if len(names) > 0 {
196-
licenses[packageName(groupID, artifactID)] = names
216+
pomLicenses[packageName(groupID, artifactID)] = names
197217
}
198218
continue
199219
}
200220

221+
if isJarLicenseFile(fileInJar.Name) {
222+
licenseFiles = append(licenseFiles, fileInJar)
223+
continue
224+
}
225+
201226
switch {
202227
case filepath.Base(fileInJar.Name) == "pom.properties":
203228
props, err := parsePomProperties(fileInJar, fileProps.FilePath)
204229
if err != nil {
205-
return nil, manifest{}, false, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
230+
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
206231
}
207232
// Validation of props to avoid getting packages with empty Name/Version
208233
if props.Valid() {
@@ -216,7 +241,7 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
216241
case filepath.Base(fileInJar.Name) == "MANIFEST.MF":
217242
m, err = parseManifest(fileInJar)
218243
if err != nil {
219-
return nil, manifest{}, false, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
244+
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
220245
}
221246
case isArtifact(fileInJar.Name):
222247
innerPkgs, _, err := p.parseInnerJar(fileInJar, fileProps.FilePath) // TODO process inner deps
@@ -229,18 +254,57 @@ func (p *Parser) traverseZip(size int64, r xio.ReadSeekerAt, fileProps Propertie
229254
}
230255

231256
// Attach licenses from embedded pom.xml, matched by "groupID:artifactID".
257+
attachPomLicenses(pkgs, pomLicenses)
258+
259+
return pkgs, m, foundPomProps, licenseFiles, nil
260+
}
261+
262+
// attachPomLicenses attaches licenses declared in embedded pom.xml files to packages,
263+
// matched by "groupID:artifactID". Packages that already have a license (e.g. set by a
264+
// nested jar from its own pom.xml) are left untouched.
265+
func attachPomLicenses(pkgs []ftypes.Package, pomLicenses map[string][]string) {
232266
for i := range pkgs {
233267
pkg := &pkgs[i]
234-
// Keep licenses already set by a nested jar from its own pom.xml.
235268
if len(pkg.Licenses) > 0 {
236269
continue
237270
}
238-
if names, ok := licenses[pkg.Name]; ok {
271+
if names, ok := pomLicenses[pkg.Name]; ok {
239272
pkg.Licenses = names
240273
}
241274
}
275+
}
276+
277+
// attachFileLicenses classifies the LICENSE file packed in a jar and attaches it to the
278+
// jar's own package, but only when the owner is unambiguous: a single LICENSE file, a
279+
// single package belonging to this jar, and no license from its pom.xml yet.
280+
func (p *Parser) attachFileLicenses(pkgs []ftypes.Package, filePath string, licenseFiles []*zip.File) {
281+
if len(licenseFiles) != 1 {
282+
return
283+
}
284+
285+
var own []int
286+
for i := range pkgs {
287+
if pkgs[i].FilePath == filePath {
288+
own = append(own, i)
289+
}
290+
}
291+
if len(own) != 1 {
292+
return
293+
}
294+
295+
pkg := &pkgs[own[0]]
296+
if len(pkg.Licenses) > 0 {
297+
return
298+
}
242299

243-
return pkgs, m, foundPomProps, nil
300+
names, err := p.classifyPackedLicense(licenseFiles[0])
301+
if err != nil {
302+
p.logger.Debug("Failed to classify license file", log.String("file", licenseFiles[0].Name), log.Err(err))
303+
return
304+
}
305+
if len(names) > 0 {
306+
pkg.Licenses = names
307+
}
244308
}
245309

246310
func (p *Parser) parseInnerJar(zf *zip.File, rootPath string) ([]ftypes.Package, []ftypes.Dependency, error) {
@@ -407,6 +471,40 @@ func decodePomLicenses(r io.Reader) ([]string, error) {
407471
return names, nil
408472
}
409473

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