Skip to content
Open
8 changes: 5 additions & 3 deletions docs/guide/coverage/language/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,16 @@ To find information about these JARs[^2], the same logic is used as for the base
`table` format only contains the name of root JAR[^2] . To get the full path to inner JARs[^2] use the `json` format.

### Licenses
Trivy detects licenses for a JAR[^2] from three sources, in this order:
Trivy detects licenses for a JAR[^2] from four sources, in this order:

1. **Embedded POM** — the `<licenses>` block of the embedded `META-INF/maven/<groupId>/<artifactId>/pom.xml`, matched to the package by `groupId:artifactId`.
1. **Embedded POM** — the `<licenses>` block of the embedded `META-INF/maven/<groupId>/<artifactId>/pom.xml`, matched to the package by `groupId:artifactId`. A `<license>` is taken from its `<name>`; when the `<name>` is empty, the `<url>` is used instead if it maps to a known SPDX license.
2. **Jenkins plugin manifest** — `Plugin-License-Name` attributes in `META-INF/MANIFEST.MF`, including suffixed variants such as `Plugin-License-Name-2`.
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.
3. **OSGi `Bundle-License` manifest header** — the `Bundle-License` header of `META-INF/MANIFEST.MF`. Each entry is used when it is an SPDX license ID, or a URL (including the entry's `link` attribute) that maps to a known SPDX license.
4. **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.

Notes and limitations:

- License URLs are mapped to SPDX IDs using the `seeAlso` references of the [SPDX license list](https://spdx.org/licenses/licenses.json); a URL that does not map to a known SPDX license is skipped, so the next source is tried.
- 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.
- 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.

Expand Down
149 changes: 117 additions & 32 deletions magefiles/spdx.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import (
"os"
"path/filepath"
"sort"
"strings"

"golang.org/x/xerrors"

"github.com/aquasecurity/trivy/pkg/downloader"
"github.com/aquasecurity/trivy/pkg/licensing"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/set"
xslices "github.com/aquasecurity/trivy/pkg/x/slices"
)

Expand All @@ -27,6 +30,7 @@ const (
)

func main() {
log.InitLogger(false, false)
if err := run(); err != nil {
log.Fatal("Fatal error", log.Err(err))
}
Expand All @@ -41,7 +45,8 @@ type Exception struct {
ID string `json:"licenseExceptionId"`
}

// run downloads SPDX licenses and exceptions, extracts only IDs and writes flat arrays into the `expression` package.
// run downloads SPDX licenses and exceptions and writes them into the `expression` package:
// exceptions as a flat array of IDs, and licenses as a map of ID to its normalized seeAlso URLs.
func run() error {
if err := updateLicenses(); err != nil {
return err
Expand All @@ -50,14 +55,19 @@ func run() error {
}

func updateExceptions() error {
return fetchAndWrite(exceptionURL, exceptionFileName, filepath.Join(expressionDir, exceptionFileName), func(b []byte) ([]string, error) {
var exceptions Exceptions
if err := json.Unmarshal(b, &exceptions); err != nil {
return nil, xerrors.Errorf("unable to unmarshal exceptions.json file: %w", err)
}
exs := xslices.Map(exceptions.Exceptions, func(ex Exception) string { return ex.ID })
return exs, nil
})
b, err := fetch(exceptionURL, exceptionFileName)
if err != nil {
return err
}

var exceptions Exceptions
if err := json.Unmarshal(b, &exceptions); err != nil {
return xerrors.Errorf("unable to unmarshal exceptions.json file: %w", err)
}

exs := xslices.Map(exceptions.Exceptions, func(ex Exception) string { return ex.ID })
sort.Strings(exs)
return writeJSON(filepath.Join(expressionDir, exceptionFileName), exs)
}

type Licenses struct {
Expand All @@ -66,51 +76,126 @@ type Licenses struct {

type License struct {
ID string `json:"licenseId"`
// SeeAlso lists the upstream license URLs (e.g. https://www.apache.org/licenses/LICENSE-2.0).
// Inverting these gives a URL -> SPDX-ID index for values that appear as URLs
// (OSGi Bundle-License headers, pom <url> fallbacks).
SeeAlso []string `json:"seeAlso"`
}

func updateLicenses() error {
return fetchAndWrite(licenseURL, licenseFileName, filepath.Join(expressionDir, licenseFileName), func(b []byte) ([]string, error) {
var licenses Licenses
if err := json.Unmarshal(b, &licenses); err != nil {
return nil, xerrors.Errorf("unable to unmarshal licenses.json file: %w", err)
b, err := fetch(licenseURL, licenseFileName)
if err != nil {
return err
}

var licenses Licenses
if err := json.Unmarshal(b, &licenses); err != nil {
return xerrors.Errorf("unable to unmarshal licenses.json file: %w", err)
}

// result maps each SPDX license ID to its normalized seeAlso URLs. Every ID is
// added up front (even with no URL) so the map doubles as the SPDX license ID
// list used for validation.
result := make(map[string][]string, len(licenses.Licenses))
for _, l := range licenses.Licenses {
if l.ID != "" {
result[l.ID] = []string{}
}
}

// Resolve each normalized URL to a single license ID:
// - the first license to reference it -> its ID
// - more IDs of the same only/or-later/+ family -> the smallest of them (see below)
// - genuinely different licenses -> "" (ambiguous; the URL is dropped)
urlToID := make(map[string]string)
for _, l := range licenses.Licenses {
if l.ID == "" {
continue
}
ids := xslices.Map(licenses.Licenses, func(l License) string { return l.ID })
return ids, nil
})
seen := set.New[string]() // dedup URLs within a single license
for _, raw := range l.SeeAlso {
u := licensing.NormalizeLicenseURL(raw)
if u == "" {
continue
}
if seen.Contains(u) {
continue
}
seen.Append(u)

switch existing, ok := urlToID[u]; {
case !ok:
urlToID[u] = l.ID
case existing == "":
// Already marked ambiguous; keep it dropped.
case licenseStem(existing) == licenseStem(l.ID):
// Same only/or-later/+ family: keep the lexicographically smallest
// referencing ID — deterministic, and the bare base when it is in the
// group ("GPL-3.0" < "GPL-3.0+" < "GPL-3.0-only" < ...).
if l.ID < existing {
urlToID[u] = l.ID
}
default:
log.Warn("Dropping ambiguous license URL shared by different licenses",
log.String("url", u), log.String("licenses", existing+", "+l.ID))
urlToID[u] = ""
}
}
}

// Attach each resolved URL to its license. Ambiguous URLs were dropped above
// (empty ID); every remaining ID is a real SPDX license, so it is a key in result.
for u, id := range urlToID {
if id == "" {
continue
}
result[id] = append(result[id], u)
}
for id := range result {
sort.Strings(result[id])
}

return writeJSON(filepath.Join(expressionDir, licenseFileName), result)
}

// fetchAndWrite downloads a SPDX index file, extracts IDs using extractor, sorts and writes them to destPath
func fetchAndWrite(url, tmpFileName, destPath string, extractor func([]byte) ([]string, error)) error {
tmpDir, err := downloader.DownloadToTempDir(context.Background(), url, downloader.Options{})
if err != nil {
return xerrors.Errorf("unable to download %s: %w", tmpFileName, err)
// licenseStem strips a trailing +, -only or -or-later suffix from an SPDX license
// ID, mapping only/or-later/+ variants to their shared base (e.g. "GPL-3.0-only"
// and "GPL-3.0+" both -> "GPL-3.0").
func licenseStem(id string) string {
for _, suffix := range []string{"-or-later", "-only", "+"} {
if s, ok := strings.CutSuffix(id, suffix); ok {
return s
}
}
tmpFile, err := os.ReadFile(filepath.Join(tmpDir, tmpFileName))
return id
}

// fetch downloads a SPDX index file and returns its contents.
func fetch(url, tmpFileName string) ([]byte, error) {
tmpDir, err := downloader.DownloadToTempDir(context.Background(), url, downloader.Options{})
if err != nil {
return xerrors.Errorf("unable to read %s: %w", tmpFileName, err)
return nil, xerrors.Errorf("unable to download %s: %w", tmpFileName, err)
}

ids, err := extractor(tmpFile)
b, err := os.ReadFile(filepath.Join(tmpDir, tmpFileName))
if err != nil {
return err
return nil, xerrors.Errorf("unable to read %s: %w", tmpFileName, err)
}
sort.Strings(ids)
return writeIDs(destPath, ids)
return b, nil
}

func writeIDs(path string, ids []string) error {
func writeJSON(path string, v any) error {
f, err := os.Create(path)
if err != nil {
return xerrors.Errorf("unable to create file %s: %w", path, err)
}
defer f.Close()

b, err := json.Marshal(ids)
b, err := json.Marshal(v)
if err != nil {
return xerrors.Errorf("unable to marshal id list: %w", err)
return xerrors.Errorf("unable to marshal %s: %w", path, err)
}
if _, err = f.Write(b); err != nil {
return xerrors.Errorf("unable to write id list: %w", err)
return xerrors.Errorf("unable to write %s: %w", path, err)
}
return nil
}
5 changes: 5 additions & 0 deletions pkg/dependency/parser/java/jar/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ var (
DecodePomLicenses = decodePomLicenses
IsJarLicenseFile = isJarLicenseFile
ParsePluginLicenseName = parsePluginLicenseName
ParseBundleLicense = parseBundleLicense
ParseManifest = parseManifest
)

// BundleLicense exposes the unexported bundleLicense field to tests.
func (m manifest) BundleLicense() string { return m.bundleLicense }
Loading
Loading