@@ -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+
2938var (
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+
6886func 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
243318func (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+
407516type manifest struct {
408517 implementationVersion string
409518 implementationTitle string
0 commit comments