Skip to content

Commit ff29a07

Browse files
committed
feat(java): detect JAR licenses from the embedded pom.xml
1 parent 47b7ba4 commit ff29a07

8 files changed

Lines changed: 245 additions & 20 deletions

File tree

docs/guide/coverage/language/java.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Each artifact supports the following scanners:
55

66
| Artifact | SBOM | Vulnerability | License |
77
|------------------|:----:|:-------------:|:-------:|
8-
| JAR/WAR/PAR/EAR ||| - |
8+
| JAR/WAR/PAR/EAR ||| |
99
| pom.xml ||||
1010
| *gradle.lockfile ||||
1111
| *.sbt.lock ||| - |
@@ -37,6 +37,11 @@ To find information about these JARs[^2], the same logic is used as for the base
3737

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

40+
### 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.
42+
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.
44+
4045
## pom.xml
4146
Trivy parses your `pom.xml` file and tries to find files with dependencies from these local locations.
4247

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package jar
2+
3+
// Bridge to expose jar parser internals to tests in the jar_test package.
4+
5+
var (
6+
EmbeddedPomGAV = embeddedPomGAV
7+
DecodePomLicenses = decodePomLicenses
8+
)

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

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"crypto/sha1" // nolint:gosec
88
"encoding/hex"
9+
"encoding/xml"
910
"errors"
1011
"io"
1112
"os"
@@ -16,6 +17,7 @@ import (
1617
"strings"
1718

1819
mavenversion "github.com/masahiro331/go-mvn-version"
20+
"golang.org/x/net/html/charset"
1921
"golang.org/x/xerrors"
2022

2123
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
@@ -89,51 +91,77 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
8991

9092
// Try to extract artifactId and version from the file name
9193
// e.g. spring-core-5.3.4-SNAPSHOT.jar => sprint-core, 5.3.4-SNAPSHOT
92-
fileName := filepath.Base(filePath)
9394
fileProps := parseFileName(filePath)
9495

95-
pkgs, m, foundPomProps, err := p.traverseZip(filePath, size, r, fileProps)
96+
pkgs, m, foundPomProps, licenses, err := p.traverseZip(filePath, size, r, fileProps)
9697
if err != nil {
9798
return nil, nil, xerrors.Errorf("zip error: %w", err)
9899
}
99100

100101
// If pom.properties is found, it should be preferred than MANIFEST.MF.
101-
if foundPomProps {
102-
return pkgs, nil, nil
102+
// 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)
105+
if err != nil {
106+
return nil, nil, err
107+
}
108+
if found {
109+
pkgs = append(pkgs, props.Package())
110+
}
103111
}
104112

113+
// Attach licenses from embedded pom.xml, matched by "groupID:artifactID".
114+
for i := range pkgs {
115+
pkg := &pkgs[i]
116+
// Keep licenses already set by a nested jar from its own pom.xml.
117+
if len(pkg.Licenses) > 0 {
118+
continue
119+
}
120+
if names, ok := licenses[pkg.Name]; ok {
121+
pkg.Licenses = names
122+
}
123+
}
124+
125+
return pkgs, nil, nil
126+
}
127+
128+
// resolveArtifact determines the artifact of the jar itself when pom.properties is absent,
129+
// trying MANIFEST.MF, then Maven Central by SHA-1, then a heuristic search by file name.
130+
func (p *Parser) resolveArtifact(filePath string, r xio.ReadSeekerAt, m manifest, fileProps Properties) (Properties, bool, error) {
131+
fileName := filepath.Base(filePath)
132+
105133
manifestProps := m.properties(filePath)
106134
if p.offline {
107135
// In offline mode, we will not check if the artifact information is correct.
108136
if !manifestProps.Valid() {
109137
p.logger.Debug("Unable to identify POM in offline mode", log.String("file", fileName))
110-
return pkgs, nil, nil
138+
return Properties{}, false, nil
111139
}
112-
return append(pkgs, manifestProps.Package()), nil, nil
140+
return manifestProps, true, nil
113141
}
114142

115143
if manifestProps.Valid() {
116144
// Even if MANIFEST.MF is found, the groupId and artifactId might not be valid.
117145
// We have to make sure that the artifact exists actually.
118146
if ok, _ := p.client.Exists(manifestProps.GroupID, manifestProps.ArtifactID); ok {
119147
// If groupId and artifactId are valid, they will be returned.
120-
return append(pkgs, manifestProps.Package()), nil, nil
148+
return manifestProps, true, nil
121149
}
122150
}
123151

124152
// If groupId and artifactId are not found, call Maven Central's search API with SHA-1 digest.
125153
props, err := p.searchBySHA1(r, filePath)
126154
if err == nil {
127-
return append(pkgs, props.Package()), nil, nil
155+
return props, true, nil
128156
} else if !errors.Is(err, ArtifactNotFoundErr) {
129-
return nil, nil, xerrors.Errorf("failed to search by SHA1: %w", err)
157+
return Properties{}, false, xerrors.Errorf("failed to search by SHA1: %w", err)
130158
}
131159

132160
p.logger.Debug("No such POM in the central repositories", log.String("file", fileName))
133161

134162
// Return when artifactId or version from the file name are empty
135163
if fileProps.ArtifactID == "" || fileProps.Version == "" {
136-
return pkgs, nil, nil
164+
return Properties{}, false, nil
137165
}
138166

139167
// Try to search groupId by artifactId via sonatype API
@@ -142,31 +170,49 @@ func (p *Parser) parseArtifact(filePath string, size int64, r xio.ReadSeekerAt)
142170
if err == nil {
143171
p.logger.Debug("POM was determined in a heuristic way", log.String("file", fileName),
144172
log.String("artifact", fileProps.String()))
145-
pkgs = append(pkgs, fileProps.Package())
173+
return fileProps, true, nil
146174
} else if !errors.Is(err, ArtifactNotFoundErr) {
147-
return nil, nil, xerrors.Errorf("failed to search by artifact id: %w", err)
175+
return Properties{}, false, xerrors.Errorf("failed to search by artifact id: %w", err)
148176
}
149177

150-
return pkgs, nil, nil
178+
return Properties{}, false, nil
151179
}
152180

153181
func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fileProps Properties) (
154-
[]ftypes.Package, manifest, bool, error) {
182+
[]ftypes.Package, manifest, bool, map[string][]string, error) {
155183
var pkgs []ftypes.Package
156184
var m manifest
157185
var foundPomProps bool
158186

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)
191+
159192
zr, err := zip.NewReader(r, size)
160193
if err != nil {
161-
return nil, manifest{}, false, xerrors.Errorf("zip error: %w", err)
194+
return nil, manifest{}, false, nil, xerrors.Errorf("zip error: %w", err)
162195
}
163196

164197
for _, fileInJar := range zr.File {
198+
// Collect licenses declared in the embedded META-INF/maven/<g>/<a>/pom.xml.
199+
if groupID, artifactID, ok := embeddedPomGAV(fileInJar.Name); ok {
200+
names, err := parsePomLicenses(fileInJar)
201+
if err != nil {
202+
p.logger.Debug("Failed to parse licenses", log.String("file", fileInJar.Name), log.Err(err))
203+
continue
204+
}
205+
if len(names) > 0 {
206+
licenses[packageName(groupID, artifactID)] = names
207+
}
208+
continue
209+
}
210+
165211
switch {
166212
case filepath.Base(fileInJar.Name) == "pom.properties":
167213
props, err := parsePomProperties(fileInJar, filePath)
168214
if err != nil {
169-
return nil, manifest{}, false, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
215+
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err)
170216
}
171217
// Validation of props to avoid getting packages with empty Name/Version
172218
if props.Valid() {
@@ -180,7 +226,7 @@ func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fi
180226
case filepath.Base(fileInJar.Name) == "MANIFEST.MF":
181227
m, err = parseManifest(fileInJar)
182228
if err != nil {
183-
return nil, manifest{}, false, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
229+
return nil, manifest{}, false, nil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err)
184230
}
185231
case isArtifact(fileInJar.Name):
186232
innerPkgs, _, err := p.parseInnerJar(fileInJar, filePath) // TODO process inner deps
@@ -191,7 +237,7 @@ func (p *Parser) traverseZip(filePath string, size int64, r xio.ReadSeekerAt, fi
191237
pkgs = append(pkgs, innerPkgs...)
192238
}
193239
}
194-
return pkgs, m, foundPomProps, nil
240+
return pkgs, m, foundPomProps, licenses, nil
195241
}
196242

197243
func (p *Parser) parseInnerJar(zf *zip.File, rootPath string) ([]ftypes.Package, []ftypes.Dependency, error) {
@@ -300,6 +346,64 @@ func parsePomProperties(f *zip.File, filePath string) (Properties, error) {
300346
return p, nil
301347
}
302348

349+
// embeddedPom is a minimal view of an embedded pom.xml: only the license names are needed.
350+
type embeddedPom struct {
351+
Licenses struct {
352+
License []struct {
353+
Name string `xml:"name"`
354+
} `xml:"license"`
355+
} `xml:"licenses"`
356+
}
357+
358+
// embeddedPomGAV extracts groupId and artifactId from a path of the form
359+
// META-INF/maven/<groupId>/<artifactId>/pom.xml. The version is not part of the path.
360+
// ok is false when the path is not a Maven descriptor pom.xml.
361+
func embeddedPomGAV(name string) (groupID, artifactID string, ok bool) {
362+
rel, found := strings.CutPrefix(name, "META-INF/maven/")
363+
if !found {
364+
return "", "", false
365+
}
366+
rel, found = strings.CutSuffix(rel, "/pom.xml")
367+
if !found {
368+
return "", "", false
369+
}
370+
groupID, artifactID, found = strings.Cut(rel, "/")
371+
if !found || groupID == "" || artifactID == "" {
372+
return "", "", false
373+
}
374+
return groupID, artifactID, true
375+
}
376+
377+
// parsePomLicenses returns the raw <license><name> values from an embedded pom.xml.
378+
func parsePomLicenses(f *zip.File) ([]string, error) {
379+
file, err := f.Open()
380+
if err != nil {
381+
return nil, xerrors.Errorf("unable to open %s: %w", f.Name, err)
382+
}
383+
defer file.Close()
384+
385+
return decodePomLicenses(file)
386+
}
387+
388+
// decodePomLicenses decodes a pom.xml and returns the raw <license><name> values.
389+
// Names are kept as-is; normalization happens downstream.
390+
func decodePomLicenses(r io.Reader) ([]string, error) {
391+
var pom embeddedPom
392+
decoder := xml.NewDecoder(r)
393+
decoder.CharsetReader = charset.NewReaderLabel
394+
if err := decoder.Decode(&pom); err != nil {
395+
return nil, xerrors.Errorf("xml decode error: %w", err)
396+
}
397+
398+
var names []string
399+
for _, lic := range pom.Licenses.License {
400+
if name := strings.TrimSpace(lic.Name); name != "" {
401+
names = append(names, name)
402+
}
403+
}
404+
return names, nil
405+
}
406+
303407
type manifest struct {
304408
implementationVersion string
305409
implementationTitle string
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package jar_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/aquasecurity/trivy/pkg/dependency/parser/java/jar"
11+
)
12+
13+
func TestEmbeddedPomGAV(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
path string
17+
wantGroup string
18+
wantArt string
19+
wantOK bool
20+
}{
21+
{
22+
name: "valid path",
23+
path: "META-INF/maven/com.example/demo/pom.xml",
24+
wantGroup: "com.example",
25+
wantArt: "demo",
26+
wantOK: true,
27+
},
28+
{
29+
name: "wrong prefix",
30+
path: "BOOT-INF/classes/pom.xml",
31+
wantOK: false,
32+
},
33+
{
34+
name: "not pom.xml",
35+
path: "META-INF/maven/com.example/demo/pom.properties",
36+
wantOK: false,
37+
},
38+
{
39+
name: "missing artifactId",
40+
path: "META-INF/maven/com.example/pom.xml",
41+
wantOK: false,
42+
},
43+
}
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
groupID, artifactID, ok := jar.EmbeddedPomGAV(tt.path)
47+
assert.Equal(t, tt.wantOK, ok)
48+
assert.Equal(t, tt.wantGroup, groupID)
49+
assert.Equal(t, tt.wantArt, artifactID)
50+
})
51+
}
52+
}
53+
54+
func TestDecodePomLicenses(t *testing.T) {
55+
tests := []struct {
56+
name string
57+
xml string
58+
want []string
59+
}{
60+
{
61+
name: "single license",
62+
xml: `<project><licenses><license><name>Apache-2.0</name></license></licenses></project>`,
63+
want: []string{"Apache-2.0"},
64+
},
65+
{
66+
name: "multiple licenses",
67+
xml: `<project><licenses><license><name>MIT</name></license><license><name>Apache-2.0</name></license></licenses></project>`,
68+
want: []string{"MIT", "Apache-2.0"},
69+
},
70+
{
71+
name: "name with surrounding whitespace",
72+
xml: "<project><licenses><license><name> Apache-2.0\n </name></license></licenses></project>",
73+
want: []string{"Apache-2.0"},
74+
},
75+
{
76+
name: "empty name is skipped",
77+
xml: `<project><licenses><license><name></name></license></licenses></project>`,
78+
want: nil,
79+
},
80+
{
81+
name: "no licenses block (parent only)",
82+
xml: `<project><parent><groupId>com.example</groupId></parent></project>`,
83+
want: nil,
84+
},
85+
}
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
got, err := jar.DecodePomLicenses(strings.NewReader(tt.xml))
89+
require.NoError(t, err)
90+
assert.Equal(t, tt.want, got)
91+
})
92+
}
93+
}

0 commit comments

Comments
 (0)