Skip to content

Commit f065203

Browse files
0xTaoZDmitriyLewen
andauthored
feat(java): read Jenkins plugin manifest licenses (#10939)
Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
1 parent beb33f0 commit f065203

5 files changed

Lines changed: 133 additions & 16 deletions

File tree

docs/guide/coverage/language/java.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ 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 for a JAR[^2] from two sources, in this order:
41+
Trivy detects licenses for a JAR[^2] from three sources, in this order:
4242

4343
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.
44+
2. **Jenkins plugin manifest**`Plugin-License-Name` attributes in `META-INF/MANIFEST.MF`, including suffixed variants such as `Plugin-License-Name-2`.
45+
3. **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.
4546

4647
Notes and limitations:
4748

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ package jar
33
// Bridge to expose jar parser internals to tests in the jar_test package.
44

55
var (
6-
EmbeddedPomGAV = embeddedPomGAV
7-
DecodePomLicenses = decodePomLicenses
8-
IsJarLicenseFile = isJarLicenseFile
6+
EmbeddedPomGAV = embeddedPomGAV
7+
DecodePomLicenses = decodePomLicenses
8+
IsJarLicenseFile = isJarLicenseFile
9+
ParsePluginLicenseName = parsePluginLicenseName
910
)

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

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,11 @@ func (p *Parser) parsePackages(filePath string, size int64, r xio.ReadSeekerAt)
147147
}
148148
}
149149

150-
// Classify and attach the LICENSE file now that the jar's own artifact is resolved
151-
// (it may have been added above from MANIFEST.MF / SHA-1 / file name).
150+
// Attach license sources that depend on the jar's own artifact after it has
151+
// been resolved (it may have been added above from MANIFEST.MF / SHA-1 / file
152+
// name). Manifest attributes are cheap to parse, so try them before falling
153+
// back to classifying packed LICENSE files.
154+
attachManifestLicenses(pkgs, fileProps.FilePath, m.licenseNames())
152155
p.attachFileLicenses(pkgs, fileProps.FilePath, licenseFile)
153156

154157
return pkgs, nil, nil
@@ -327,31 +330,53 @@ func attachPomLicenses(pkgs []ftypes.Package, pomLicenses map[string][]string) {
327330
}
328331
}
329332

330-
// attachFileLicenses classifies the LICENSE file packed in a jar and attaches it to the
331-
// jar's own package, but only when the owner is unambiguous: a single LICENSE file, a
332-
// single package belonging to this jar, and no license from its pom.xml yet.
333-
func (p *Parser) attachFileLicenses(pkgs []ftypes.Package, filePath string, licenseFile *zip.File) {
334-
if licenseFile == nil {
333+
// attachManifestLicenses attaches Jenkins plugin licenses declared in MANIFEST.MF
334+
// to the jar's own package when a single unambiguous package belongs to this jar.
335+
func attachManifestLicenses(pkgs []ftypes.Package, filePath string, licenses []string) {
336+
if len(licenses) == 0 {
337+
return
338+
}
339+
340+
pkg := ownJarPackage(pkgs, filePath)
341+
if pkg == nil {
335342
return
336343
}
337344

345+
pkg.Licenses = licenses
346+
}
347+
348+
// ownJarPackage returns the single package that belongs to filePath and still
349+
// needs a license, or nil when there is none, the owner already has a license,
350+
// or the owner is ambiguous.
351+
func ownJarPackage(pkgs []ftypes.Package, filePath string) *ftypes.Package {
338352
var pkg *ftypes.Package
339353

340354
for i := range pkgs {
341355
if pkgs[i].FilePath != filePath {
342356
continue
343357
}
344358
if pkg != nil {
345-
return // more than one package belongs to this jar
359+
return nil // more than one package belongs to this jar
346360
}
347361
pkg = &pkgs[i]
348362
}
349363

350-
if pkg == nil {
351-
return // no package belongs to this jar
364+
if pkg == nil || len(pkg.Licenses) > 0 {
365+
return nil
352366
}
367+
return pkg
368+
}
353369

354-
if len(pkg.Licenses) > 0 {
370+
// attachFileLicenses classifies the LICENSE file packed in a jar and attaches it to the
371+
// jar's own package, but only when the owner is unambiguous: a single LICENSE file, a
372+
// single package belonging to this jar, and no license from its pom.xml yet.
373+
func (p *Parser) attachFileLicenses(pkgs []ftypes.Package, filePath string, licenseFile *zip.File) {
374+
if licenseFile == nil {
375+
return
376+
}
377+
378+
pkg := ownJarPackage(pkgs, filePath)
379+
if pkg == nil {
355380
return
356381
}
357382

@@ -583,6 +608,7 @@ type manifest struct {
583608
bundleName string
584609
bundleVersion string
585610
bundleSymbolicName string
611+
pluginLicenseNames []string
586612
}
587613

588614
func parseManifest(f *zip.File) (manifest, error) {
@@ -626,6 +652,8 @@ func parseManifest(f *zip.File) (manifest, error) {
626652
m.bundleName = strings.TrimPrefix(line, "Bundle-Name:")
627653
case strings.HasPrefix(line, "Bundle-SymbolicName:"):
628654
m.bundleSymbolicName = strings.TrimPrefix(line, "Bundle-SymbolicName:")
655+
case strings.HasPrefix(line, "Plugin-License-Name"):
656+
m.pluginLicenseNames = append(m.pluginLicenseNames, line)
629657
}
630658
}
631659

@@ -635,6 +663,33 @@ func parseManifest(f *zip.File) (manifest, error) {
635663
return m, nil
636664
}
637665

666+
// parsePluginLicenseName extracts the license name from a single Jenkins
667+
// Plugin-License-Name[-N] manifest line, or an empty string when the line is not
668+
// such an attribute or carries no value.
669+
func parsePluginLicenseName(line string) string {
670+
key, value, ok := strings.Cut(line, ":")
671+
if !ok {
672+
return ""
673+
}
674+
if key != "Plugin-License-Name" {
675+
if _, ok = strings.CutPrefix(key, "Plugin-License-Name-"); !ok {
676+
return ""
677+
}
678+
}
679+
return strings.TrimSpace(value)
680+
}
681+
682+
// licenseNames returns the license names declared in the manifest.
683+
func (m manifest) licenseNames() []string {
684+
var names []string
685+
for _, line := range m.pluginLicenseNames {
686+
if name := parsePluginLicenseName(line); name != "" {
687+
names = append(names, name)
688+
}
689+
}
690+
return names
691+
}
692+
638693
func (m manifest) properties(filePath string) Properties {
639694
groupID, err := m.determineGroupID()
640695
if err != nil {

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,19 @@ var (
240240
FilePath: "testdata/multi-license-1.0.0.jar",
241241
},
242242
}
243+
244+
// Manually created: a Jenkins plugin with license information in MANIFEST.MF.
245+
wantJenkinsPlugin = []ftypes.Package{
246+
{
247+
Name: "com.example:jenkins-plugin",
248+
Version: "1.0.0",
249+
FilePath: "testdata/license-from-jenkins-plugin.jar",
250+
Licenses: []string{
251+
"Apache License, Version 2.0",
252+
"MIT License",
253+
},
254+
},
255+
}
243256
)
244257

245258
type apiResponse struct {
@@ -313,6 +326,12 @@ func TestParse(t *testing.T) {
313326
file: "testdata/multi-license-1.0.0.jar",
314327
want: wantMultiLicense,
315328
},
329+
{
330+
name: "jenkins plugin manifest license",
331+
file: "testdata/license-from-jenkins-plugin.jar",
332+
offline: true,
333+
want: wantJenkinsPlugin,
334+
},
316335
}
317336

318337
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -386,6 +405,47 @@ func TestParse(t *testing.T) {
386405
}
387406
}
388407

408+
func TestParsePluginLicenseName(t *testing.T) {
409+
tests := []struct {
410+
name string
411+
line string
412+
want string
413+
}{
414+
{
415+
name: "plugin license name",
416+
line: "Plugin-License-Name: Apache License, Version 2.0",
417+
want: "Apache License, Version 2.0",
418+
},
419+
{
420+
name: "suffixed plugin license name",
421+
line: "Plugin-License-Name-2: MIT License",
422+
want: "MIT License",
423+
},
424+
{
425+
name: "trims license name",
426+
line: "Plugin-License-Name: MIT License ",
427+
want: "MIT License",
428+
},
429+
{
430+
name: "empty license name",
431+
line: "Plugin-License-Name: ",
432+
want: "",
433+
},
434+
{
435+
name: "non license line",
436+
line: "Plugin-License-Url: https://opensource.org/licenses/MIT",
437+
want: "",
438+
},
439+
}
440+
441+
for _, tt := range tests {
442+
t.Run(tt.name, func(t *testing.T) {
443+
got := jar.ParsePluginLicenseName(tt.line)
444+
assert.Equal(t, tt.want, got)
445+
})
446+
}
447+
}
448+
389449
func TestEmbeddedPomGAV(t *testing.T) {
390450
tests := []struct {
391451
name string
Binary file not shown.

0 commit comments

Comments
 (0)