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 { ImageContents purlImageContents `json:"image_contents,omitempty"` } 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 }