2023-12-05 03:05:38 +08:00
|
|
|
package sbom
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"os"
|
|
|
|
"sort"
|
|
|
|
|
|
|
|
"github.com/containers/buildah/define"
|
|
|
|
)
|
|
|
|
|
|
|
|
// getComponentNameVersionPurl extracts the "name", "version", and "purl"
|
|
|
|
// fields of a CycloneDX component record
|
|
|
|
func getComponentNameVersionPurl(anyComponent any) (string, string, error) {
|
|
|
|
if component, ok := anyComponent.(map[string]any); ok {
|
|
|
|
// read the "name" field
|
|
|
|
anyName, ok := component["name"]
|
|
|
|
if !ok {
|
|
|
|
return "", "", fmt.Errorf("no name in component %v", anyComponent)
|
|
|
|
}
|
|
|
|
name, ok := anyName.(string)
|
|
|
|
if !ok {
|
|
|
|
return "", "", fmt.Errorf("name %v is not a string", anyName)
|
|
|
|
}
|
|
|
|
// read the optional "version" field
|
|
|
|
var version string
|
|
|
|
anyVersion, ok := component["version"]
|
|
|
|
if ok {
|
|
|
|
if version, ok = anyVersion.(string); !ok {
|
|
|
|
return "", "", fmt.Errorf("version %v is not a string", anyVersion)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// combine them
|
|
|
|
nameWithVersion := name
|
|
|
|
if version != "" {
|
|
|
|
nameWithVersion += ("@" + version)
|
|
|
|
}
|
|
|
|
// read the optional "purl" field
|
|
|
|
var purl string
|
|
|
|
anyPurl, ok := component["purl"]
|
|
|
|
if ok {
|
|
|
|
if purl, ok = anyPurl.(string); !ok {
|
|
|
|
return "", "", fmt.Errorf("purl %v is not a string", anyPurl)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nameWithVersion, purl, nil
|
|
|
|
}
|
|
|
|
return "", "", fmt.Errorf("component %v is not an object", anyComponent)
|
|
|
|
}
|
|
|
|
|
|
|
|
// getPackageNameVersionInfoPurl extracts the "name", "versionInfo", and "purl"
|
|
|
|
// fields of an SPDX package record
|
|
|
|
func getPackageNameVersionInfoPurl(anyPackage any) (string, string, error) {
|
|
|
|
if pkg, ok := anyPackage.(map[string]any); ok {
|
|
|
|
// read the "name" field
|
|
|
|
anyName, ok := pkg["name"]
|
|
|
|
if !ok {
|
|
|
|
return "", "", fmt.Errorf("no name in package %v", anyPackage)
|
|
|
|
}
|
|
|
|
name, ok := anyName.(string)
|
|
|
|
if !ok {
|
|
|
|
return "", "", fmt.Errorf("name %v is not a string", anyName)
|
|
|
|
}
|
|
|
|
// read the optional "versionInfo" field
|
|
|
|
var versionInfo string
|
|
|
|
if anyVersionInfo, ok := pkg["versionInfo"]; ok {
|
|
|
|
if versionInfo, ok = anyVersionInfo.(string); !ok {
|
|
|
|
return "", "", fmt.Errorf("versionInfo %v is not a string", anyVersionInfo)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// combine them
|
|
|
|
nameWithVersionInfo := name
|
|
|
|
if versionInfo != "" {
|
|
|
|
nameWithVersionInfo += ("@" + versionInfo)
|
|
|
|
}
|
|
|
|
// now look for optional externalRefs[].purl if "referenceCategory"
|
|
|
|
// is "PACKAGE-MANAGER" and "referenceType" is "purl"
|
|
|
|
var purl string
|
|
|
|
if anyExternalRefs, ok := pkg["externalRefs"]; ok {
|
|
|
|
if externalRefs, ok := anyExternalRefs.([]any); ok {
|
|
|
|
for _, anyExternalRef := range externalRefs {
|
|
|
|
if externalRef, ok := anyExternalRef.(map[string]any); ok {
|
|
|
|
anyReferenceCategory, ok := externalRef["referenceCategory"]
|
|
|
|
if !ok {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if referenceCategory, ok := anyReferenceCategory.(string); !ok || referenceCategory != "PACKAGE-MANAGER" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
anyReferenceType, ok := externalRef["referenceType"]
|
|
|
|
if !ok {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if referenceType, ok := anyReferenceType.(string); !ok || referenceType != "purl" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if anyReferenceLocator, ok := externalRef["referenceLocator"]; ok {
|
|
|
|
if purl, ok = anyReferenceLocator.(string); !ok {
|
|
|
|
return "", "", fmt.Errorf("purl %v is not a string", anyReferenceLocator)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nameWithVersionInfo, purl, nil
|
|
|
|
}
|
|
|
|
return "", "", fmt.Errorf("package %v is not an object", anyPackage)
|
|
|
|
}
|
|
|
|
|
|
|
|
// getLicenseID extracts the "licenseId" field of an SPDX license record
|
|
|
|
func getLicenseID(anyLicense any) (string, error) {
|
|
|
|
var licenseID string
|
|
|
|
if lic, ok := anyLicense.(map[string]any); ok {
|
|
|
|
anyID, ok := lic["licenseId"]
|
|
|
|
if !ok {
|
|
|
|
return "", fmt.Errorf("no licenseId in license %v", anyID)
|
|
|
|
}
|
|
|
|
id, ok := anyID.(string)
|
|
|
|
if !ok {
|
|
|
|
return "", fmt.Errorf("licenseId %v is not a string", anyID)
|
|
|
|
}
|
|
|
|
licenseID = id
|
|
|
|
}
|
|
|
|
return licenseID, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// mergeSlicesWithoutDuplicates merges a named slice in "base" with items from
|
|
|
|
// the same slice in "merge", so long as getKey() returns values for them that
|
|
|
|
// it didn't for items from the "base" slice
|
|
|
|
func mergeSlicesWithoutDuplicates(base, merge map[string]any, sliceField string, getKey func(record any) (string, error)) error {
|
|
|
|
uniqueKeys := make(map[string]struct{})
|
|
|
|
// go through all of the values in the base slice, grab their
|
|
|
|
// keys, and note them
|
|
|
|
baseRecords := base[sliceField]
|
|
|
|
baseRecordsSlice, ok := baseRecords.([]any)
|
|
|
|
if !ok {
|
|
|
|
baseRecordsSlice = []any{}
|
|
|
|
}
|
|
|
|
for _, anyRecord := range baseRecordsSlice {
|
|
|
|
key, err := getKey(anyRecord)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
uniqueKeys[key] = struct{}{}
|
|
|
|
}
|
|
|
|
// go through all of the record values in the merge doc, grab their
|
|
|
|
// associated keys, and append them to the base records slice if we
|
|
|
|
// haven't seen the key yet
|
|
|
|
mergeRecords := merge[sliceField]
|
|
|
|
mergeRecordsSlice, ok := mergeRecords.([]any)
|
|
|
|
if !ok {
|
|
|
|
mergeRecordsSlice = []any{}
|
|
|
|
}
|
|
|
|
for _, anyRecord := range mergeRecordsSlice {
|
|
|
|
key, err := getKey(anyRecord)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if _, present := uniqueKeys[key]; !present {
|
|
|
|
baseRecordsSlice = append(baseRecordsSlice, anyRecord)
|
|
|
|
uniqueKeys[key] = struct{}{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(baseRecordsSlice) > 0 {
|
|
|
|
base[sliceField] = baseRecordsSlice
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// decodeJSON decodes a file into a map
|
|
|
|
func decodeJSON(inputFile string, document *map[string]any) error {
|
|
|
|
src, err := os.Open(inputFile)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer src.Close()
|
|
|
|
if err = json.NewDecoder(src).Decode(document); err != nil {
|
|
|
|
return fmt.Errorf("decoding JSON document from %q: %w", inputFile, err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// encodeJSON encodes a map and saves it to a file
|
|
|
|
func encodeJSON(outputFile string, document any) error {
|
|
|
|
dst, err := os.Create(outputFile)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer dst.Close()
|
|
|
|
if err = json.NewEncoder(dst).Encode(document); err != nil {
|
|
|
|
return fmt.Errorf("writing JSON document to %q: %w", outputFile, err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Merge adds the contents of inputSBOM to inputOutputSBOM using one of a
|
|
|
|
// handful of named strategies.
|
|
|
|
func Merge(mergeStrategy define.SBOMMergeStrategy, inputOutputSBOM, inputSBOM, outputPURL string) (err error) {
|
|
|
|
type purlImageContents struct {
|
|
|
|
Dependencies []string `json:"dependencies,omitempty"`
|
|
|
|
}
|
|
|
|
type purlDocument struct {
|
2025-09-11 01:18:15 +08:00
|
|
|
ImageContents purlImageContents `json:"image_contents"`
|
2023-12-05 03:05:38 +08:00
|
|
|
}
|
|
|
|
purls := []string{}
|
|
|
|
seenPurls := make(map[string]struct{})
|
|
|
|
|
|
|
|
switch mergeStrategy {
|
|
|
|
case define.SBOMMergeStrategyCycloneDXByComponentNameAndVersion:
|
|
|
|
var base, merge map[string]any
|
|
|
|
if err = decodeJSON(inputOutputSBOM, &base); err != nil {
|
|
|
|
return fmt.Errorf("reading first SBOM to be merged from %q: %w", inputOutputSBOM, err)
|
|
|
|
}
|
|
|
|
if err = decodeJSON(inputSBOM, &merge); err != nil {
|
|
|
|
return fmt.Errorf("reading second SBOM to be merged from %q: %w", inputSBOM, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// merge the "components" lists based on unique combinations of
|
|
|
|
// "name" and "version" fields, and save unique package URL
|
|
|
|
// values
|
|
|
|
err = mergeSlicesWithoutDuplicates(base, merge, "components", func(anyPackage any) (string, error) {
|
|
|
|
nameWithVersion, purl, err := getComponentNameVersionPurl(anyPackage)
|
|
|
|
if purl != "" {
|
|
|
|
if _, seen := seenPurls[purl]; !seen {
|
|
|
|
purls = append(purls, purl)
|
|
|
|
seenPurls[purl] = struct{}{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nameWithVersion, err
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("merging the %q field of CycloneDX SBOMs: %w", "components", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// save the updated doc
|
|
|
|
err = encodeJSON(inputOutputSBOM, base)
|
|
|
|
|
|
|
|
case define.SBOMMergeStrategySPDXByPackageNameAndVersionInfo:
|
|
|
|
var base, merge map[string]any
|
|
|
|
if err = decodeJSON(inputOutputSBOM, &base); err != nil {
|
|
|
|
return fmt.Errorf("reading first SBOM to be merged from %q: %w", inputOutputSBOM, err)
|
|
|
|
}
|
|
|
|
if err = decodeJSON(inputSBOM, &merge); err != nil {
|
|
|
|
return fmt.Errorf("reading second SBOM to be merged from %q: %w", inputSBOM, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// merge the "packages" lists based on unique combinations of
|
|
|
|
// "name" and "versionInfo" fields, and save unique package URL
|
|
|
|
// values
|
|
|
|
err = mergeSlicesWithoutDuplicates(base, merge, "packages", func(anyPackage any) (string, error) {
|
|
|
|
nameWithVersionInfo, purl, err := getPackageNameVersionInfoPurl(anyPackage)
|
|
|
|
if purl != "" {
|
|
|
|
if _, seen := seenPurls[purl]; !seen {
|
|
|
|
purls = append(purls, purl)
|
|
|
|
seenPurls[purl] = struct{}{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nameWithVersionInfo, err
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("merging the %q field of SPDX SBOMs: %w", "packages", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// merge the "hasExtractedLicensingInfos" lists based on unique
|
|
|
|
// "licenseId" values
|
|
|
|
err = mergeSlicesWithoutDuplicates(base, merge, "hasExtractedLicensingInfos", getLicenseID)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("merging the %q field of SPDX SBOMs: %w", "hasExtractedLicensingInfos", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// save the updated doc
|
|
|
|
err = encodeJSON(inputOutputSBOM, base)
|
|
|
|
|
|
|
|
case define.SBOMMergeStrategyCat:
|
|
|
|
dst, err := os.OpenFile(inputOutputSBOM, os.O_RDWR|os.O_APPEND, 0o644)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer dst.Close()
|
|
|
|
src, err := os.Open(inputSBOM)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer src.Close()
|
|
|
|
if _, err = io.Copy(dst, src); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err == nil {
|
|
|
|
sort.Strings(purls)
|
|
|
|
err = encodeJSON(outputPURL, &purlDocument{purlImageContents{Dependencies: purls}})
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|