github.com/anchore/syft@v1.38.2/syft/format/internal/cyclonedxutil/helpers/licenses.go (about)

     1  package helpers
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"strings"
     7  
     8  	"github.com/CycloneDX/cyclonedx-go"
     9  
    10  	"github.com/anchore/syft/internal/licenses"
    11  	"github.com/anchore/syft/internal/spdxlicense"
    12  	"github.com/anchore/syft/syft/pkg"
    13  )
    14  
    15  // This should be a function that just surfaces licenses already validated in the package struct
    16  func encodeLicenses(p pkg.Package) *cyclonedx.Licenses {
    17  	spdx, other, ex := separateLicenses(p)
    18  	out := spdx
    19  	out = append(out, other...)
    20  
    21  	if len(other) > 0 || len(spdx) > 0 {
    22  		// found non spdx related licenses
    23  		// build individual license choices for each
    24  		// complex expressions are not combined and set as NAME fields
    25  		for _, e := range ex {
    26  			if e == "" {
    27  				continue
    28  			}
    29  			out = append(out, cyclonedx.LicenseChoice{
    30  				License: &cyclonedx.License{
    31  					Name: e,
    32  				},
    33  			})
    34  		}
    35  	} else if len(ex) > 0 {
    36  		// only expressions found
    37  		e := mergeSPDX(ex)
    38  		if e != "" {
    39  			out = append(out, cyclonedx.LicenseChoice{
    40  				Expression: e,
    41  			})
    42  		}
    43  	}
    44  
    45  	if len(out) > 0 {
    46  		return &out
    47  	}
    48  
    49  	return nil
    50  }
    51  
    52  func decodeLicenses(c *cyclonedx.Component) []pkg.License {
    53  	licenses := make([]pkg.License, 0)
    54  	if c == nil || c.Licenses == nil {
    55  		return licenses
    56  	}
    57  
    58  	for _, l := range *c.Licenses {
    59  		// these fields are mutually exclusive in the spec
    60  		switch {
    61  		case l.License != nil && l.License.ID != "":
    62  			licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), l.License.ID, l.License.URL))
    63  		case l.License != nil && l.License.Name != "":
    64  			licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), l.License.Name, l.License.URL))
    65  		case l.License != nil && l.License.URL != "":
    66  			// Try to enrich license from URL when ID and Name are empty
    67  			licenses = append(licenses, pkg.NewLicenseFromURLsWithContext(context.TODO(), "", l.License.URL))
    68  		case l.Expression != "":
    69  			licenses = append(licenses, pkg.NewLicenseWithContext(context.TODO(), l.Expression))
    70  		default:
    71  		}
    72  	}
    73  
    74  	return licenses
    75  }
    76  
    77  func separateLicenses(p pkg.Package) (spdx, other cyclonedx.Licenses, expressions []string) {
    78  	ex := make([]string, 0)
    79  	spdxc := cyclonedx.Licenses{}
    80  	otherc := cyclonedx.Licenses{}
    81  	/*
    82  			pkg.License can be a couple of things: see above declarations
    83  			- Complex SPDX expression
    84  			- Some other Valid license ID
    85  			- Some non-standard non spdx license
    86  
    87  			To determine if an expression is a singular ID we first run it against the SPDX license list.
    88  
    89  		The weird case we run into is if there is a package with a license that is not a valid SPDX expression
    90  			and a license that is a valid complex expression. In this case we will surface the valid complex expression
    91  			as a license choice and the invalid expression as a license string.
    92  
    93  	*/
    94  	seen := make(map[string]bool)
    95  	for _, l := range p.Licenses.ToSlice() {
    96  		// singular expression case
    97  		// only ID field here since we guarantee that the license is valid
    98  		if value, exists := spdxlicense.ID(l.SPDXExpression); exists {
    99  			if len(l.URLs) > 0 {
   100  				processLicenseURLs(l, value, &spdxc)
   101  				continue
   102  			}
   103  
   104  			if _, exists := seen[value]; exists {
   105  				continue
   106  			}
   107  			// try making set of license choices to avoid duplicates
   108  			// only update if the license has more information
   109  			spdxc = append(spdxc, cyclonedx.LicenseChoice{
   110  				License: &cyclonedx.License{
   111  					ID: value,
   112  				},
   113  			})
   114  			seen[value] = true
   115  			// we have added the license to the SPDX license list check next license
   116  			continue
   117  		}
   118  
   119  		if l.SPDXExpression != "" && !strings.HasPrefix(l.SPDXExpression, licenses.UnknownLicensePrefix) {
   120  			// COMPLEX EXPRESSION CASE
   121  			ex = append(ex, l.SPDXExpression)
   122  			continue
   123  		}
   124  
   125  		// license string that are not valid spdx expressions or ids
   126  		// we only use license Name here since we cannot guarantee that the license is a valid SPDX expression
   127  		if len(l.URLs) > 0 && !strings.HasPrefix(l.SPDXExpression, licenses.UnknownLicensePrefix) {
   128  			processLicenseURLs(l, "", &otherc)
   129  			continue
   130  		}
   131  
   132  		otherc = append(otherc, processCustomLicense(l)...)
   133  	}
   134  	return spdxc, otherc, ex
   135  }
   136  
   137  func processCustomLicense(l pkg.License) cyclonedx.Licenses {
   138  	result := cyclonedx.Licenses{}
   139  	if strings.HasPrefix(l.SPDXExpression, licenses.UnknownLicensePrefix) {
   140  		cyclonedxLicense := &cyclonedx.License{
   141  			Name: l.SPDXExpression,
   142  		}
   143  		if len(l.URLs) > 0 {
   144  			cyclonedxLicense.URL = l.URLs[0]
   145  		}
   146  		if len(l.Contents) > 0 {
   147  			cyclonedxLicense.Text = &cyclonedx.AttachedText{
   148  				Content: base64.StdEncoding.EncodeToString([]byte(l.Contents)),
   149  			}
   150  			cyclonedxLicense.Text.ContentType = "text/plain"
   151  			cyclonedxLicense.Text.Encoding = "base64"
   152  		}
   153  		result = append(result, cyclonedx.LicenseChoice{
   154  			License: cyclonedxLicense,
   155  		})
   156  	} else {
   157  		result = append(result, cyclonedx.LicenseChoice{
   158  			License: &cyclonedx.License{
   159  				Name: l.Value,
   160  			},
   161  		})
   162  	}
   163  	return result
   164  }
   165  
   166  func processLicenseURLs(l pkg.License, spdxID string, populate *cyclonedx.Licenses) {
   167  	for _, url := range l.URLs {
   168  		if spdxID == "" {
   169  			// CycloneDX requires either an id or name to be present for a license
   170  			// If l.Value is empty, use the URL as the name to ensure schema compliance
   171  			// at this point we've already tried to enrich the license we just don't want the format
   172  			// conversion to be lossy here
   173  			name := l.Value
   174  			if name == "" {
   175  				name = url
   176  			}
   177  			*populate = append(*populate, cyclonedx.LicenseChoice{
   178  				License: &cyclonedx.License{
   179  					URL:  url,
   180  					Name: name,
   181  				},
   182  			})
   183  		} else {
   184  			*populate = append(*populate, cyclonedx.LicenseChoice{
   185  				License: &cyclonedx.License{
   186  					ID:  spdxID,
   187  					URL: url,
   188  				},
   189  			})
   190  		}
   191  	}
   192  }
   193  
   194  func mergeSPDX(ex []string) string {
   195  	var candidate []string
   196  	for _, e := range ex {
   197  		// if the expression does not have balanced parens add them
   198  		if !strings.HasPrefix(e, "(") && !strings.HasSuffix(e, ")") {
   199  			e = "(" + e + ")"
   200  		}
   201  		candidate = append(candidate, e)
   202  	}
   203  
   204  	if len(candidate) == 1 {
   205  		return reduceOuter(candidate[0])
   206  	}
   207  
   208  	return reduceOuter(strings.Join(candidate, " AND "))
   209  }
   210  
   211  func reduceOuter(expression string) string {
   212  	expression = strings.TrimSpace(expression)
   213  
   214  	// If the entire expression is wrapped in parentheses, check if they are redundant.
   215  	if strings.HasPrefix(expression, "(") && strings.HasSuffix(expression, ")") {
   216  		trimmed := expression[1 : len(expression)-1]
   217  		if isBalanced(trimmed) {
   218  			return reduceOuter(trimmed) // Recursively reduce the trimmed expression.
   219  		}
   220  	}
   221  
   222  	return expression
   223  }
   224  
   225  func isBalanced(expression string) bool {
   226  	count := 0
   227  	for _, c := range expression {
   228  		switch c {
   229  		case '(':
   230  			count++
   231  		case ')':
   232  			count--
   233  			if count < 0 {
   234  				return false
   235  			}
   236  		default:
   237  		}
   238  	}
   239  	return count == 0
   240  }