diff --git a/convert.go b/convert.go
new file mode 100644
index 0000000..f0da0c8
--- /dev/null
+++ b/convert.go
@@ -0,0 +1,304 @@
+// This file is part of CycloneDX Go
+//
+// Licensed under the Apache License, Version 2.0 (the “License”);
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an “AS IS” BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) OWASP Foundation. All Rights Reserved.
+
+package cyclonedx
+
+import "fmt"
+
+// copyAndConvert returns a converted copy of the BOM, adhering to a given SpecVersion.
+func (b BOM) copyAndConvert(specVersion SpecVersion) (*BOM, error) {
+ var bomCopy BOM
+ err := b.copy(&bomCopy)
+ if err != nil {
+ return nil, fmt.Errorf("failed to copy bom: %w", err)
+ }
+
+ bomCopy.convert(specVersion)
+ return &bomCopy, nil
+}
+
+// convert modifies the BOM such that it adheres to a given SpecVersion.
+func (b *BOM) convert(specVersion SpecVersion) {
+ if specVersion < SpecVersion1_1 {
+ b.SerialNumber = ""
+ b.ExternalReferences = nil
+ }
+ if specVersion < SpecVersion1_2 {
+ b.Dependencies = nil
+ b.Metadata = nil
+ b.Services = nil
+ }
+ if specVersion < SpecVersion1_3 {
+ b.Compositions = nil
+ }
+ if specVersion < SpecVersion1_4 {
+ b.Vulnerabilities = nil
+ }
+
+ if b.Metadata != nil {
+ if specVersion < SpecVersion1_3 {
+ b.Metadata.Licenses = nil
+ b.Metadata.Properties = nil
+ }
+
+ recurseComponent(b.Metadata.Component, componentConverter(specVersion))
+ convertLicenses(b.Metadata.Licenses, specVersion)
+ if b.Metadata.Tools != nil {
+ for i := range *b.Metadata.Tools {
+ convertTool(&(*b.Metadata.Tools)[i], specVersion)
+ }
+ }
+ }
+
+ if b.Components != nil {
+ for i := range *b.Components {
+ recurseComponent(&(*b.Components)[i], componentConverter(specVersion))
+ }
+ }
+
+ if b.Services != nil {
+ for i := range *b.Services {
+ recurseService(&(*b.Services)[i], serviceConverter(specVersion))
+ }
+ }
+
+ b.SpecVersion = specVersion
+ b.XMLNS = xmlNamespaces[specVersion]
+}
+
+// componentConverter modifies a Component such that it adheres to a given SpecVersion.
+func componentConverter(specVersion SpecVersion) func(*Component) {
+ return func(c *Component) {
+ if specVersion < SpecVersion1_1 {
+ c.BOMRef = ""
+ c.ExternalReferences = nil
+ if c.Modified == nil {
+ c.Modified = Bool(false)
+ }
+ c.Pedigree = nil
+ }
+
+ if specVersion < SpecVersion1_2 {
+ c.Author = ""
+ c.MIMEType = ""
+ if c.Pedigree != nil {
+ c.Pedigree.Patches = nil
+ }
+ c.Supplier = nil
+ c.SWID = nil
+ }
+
+ if specVersion < SpecVersion1_3 {
+ c.Evidence = nil
+ c.Properties = nil
+ }
+
+ if specVersion < SpecVersion1_4 {
+ c.ReleaseNotes = nil
+ if c.Version == "" {
+ c.Version = "0.0.0"
+ }
+ }
+
+ if !specVersion.supportsComponentType(c.Type) {
+ c.Type = ComponentTypeApplication
+ }
+ convertExternalReferences(c.ExternalReferences, specVersion)
+ convertHashes(c.Hashes, specVersion)
+ convertLicenses(c.Licenses, specVersion)
+ if !specVersion.supportsScope(c.Scope) {
+ c.Scope = ""
+ }
+ }
+}
+
+// convertExternalReferences modifies an ExternalReference slice such that it adheres to a given SpecVersion.
+func convertExternalReferences(extRefs *[]ExternalReference, specVersion SpecVersion) {
+ if extRefs == nil {
+ return
+ }
+
+ if specVersion < SpecVersion1_3 {
+ for i := range *extRefs {
+ (*extRefs)[i].Hashes = nil
+ }
+ }
+}
+
+// convertHashes modifies a Hash slice such that it adheres to a given SpecVersion.
+// If after the conversion no valid hashes are left in the slice, it will be nilled.
+func convertHashes(hashes *[]Hash, specVersion SpecVersion) {
+ if hashes == nil {
+ return
+ }
+
+ converted := make([]Hash, 0)
+ for i := range *hashes {
+ hash := (*hashes)[i]
+ if specVersion.supportsHashAlgorithm(hash.Algorithm) {
+ converted = append(converted, hash)
+ }
+ }
+
+ if len(converted) == 0 {
+ *hashes = nil
+ } else {
+ *hashes = converted
+ }
+}
+
+// convertLicenses modifies a Licenses slice such that it adheres to a given SpecVersion.
+// If after the conversion no valid licenses are left in the slice, it will be nilled.
+func convertLicenses(licenses *Licenses, specVersion SpecVersion) {
+ if licenses == nil {
+ return
+ }
+
+ if specVersion < SpecVersion1_1 {
+ converted := make(Licenses, 0)
+ for i := range *licenses {
+ choice := &(*licenses)[i]
+ if choice.License != nil {
+ if choice.License.ID == "" && choice.License.Name == "" {
+ choice.License = nil
+ } else {
+ choice.License.Text = nil
+ choice.License.URL = ""
+ }
+ }
+ choice.Expression = ""
+ if choice.License != nil {
+ converted = append(converted, *choice)
+ }
+ }
+
+ if len(converted) == 0 {
+ *licenses = nil
+ } else {
+ *licenses = converted
+ }
+ }
+}
+
+// serviceConverter modifies a Service such that it adheres to a given SpecVersion.
+func serviceConverter(specVersion SpecVersion) func(*Service) {
+ return func(s *Service) {
+ if specVersion < SpecVersion1_3 {
+ s.Properties = nil
+ }
+
+ if specVersion < SpecVersion1_4 {
+ s.ReleaseNotes = nil
+ }
+
+ convertExternalReferences(s.ExternalReferences, specVersion)
+ }
+}
+
+// convertTool modifies a Tool such that it adheres to a given SpecVersion.
+func convertTool(tool *Tool, specVersion SpecVersion) {
+ if tool == nil {
+ return
+ }
+
+ if specVersion < SpecVersion1_4 {
+ tool.ExternalReferences = nil
+ }
+
+ convertExternalReferences(tool.ExternalReferences, specVersion)
+ convertHashes(tool.Hashes, specVersion)
+}
+
+func recurseComponent(component *Component, f func(c *Component)) {
+ if component == nil {
+ return
+ }
+
+ f(component)
+
+ if component.Components != nil {
+ for i := range *component.Components {
+ recurseComponent(&(*component.Components)[i], f)
+ }
+ }
+ if component.Pedigree != nil {
+ if component.Pedigree.Ancestors != nil {
+ for i := range *component.Pedigree.Ancestors {
+ recurseComponent(&(*component.Pedigree.Ancestors)[i], f)
+ }
+ }
+ if component.Pedigree.Descendants != nil {
+ for i := range *component.Pedigree.Descendants {
+ recurseComponent(&(*component.Pedigree.Descendants)[i], f)
+ }
+ }
+ if component.Pedigree.Variants != nil {
+ for i := range *component.Pedigree.Variants {
+ recurseComponent(&(*component.Pedigree.Variants)[i], f)
+ }
+ }
+ }
+}
+
+func recurseService(service *Service, f func(s *Service)) {
+ if service == nil {
+ return
+ }
+
+ f(service)
+
+ if service.Services != nil {
+ for i := range *service.Services {
+ recurseService(&(*service.Services)[i], f)
+ }
+ }
+}
+
+func (sv SpecVersion) supportsComponentType(cType ComponentType) bool {
+ switch cType {
+ case ComponentTypeApplication, ComponentTypeDevice, ComponentTypeFramework, ComponentTypeLibrary, ComponentTypeOS:
+ return sv >= SpecVersion1_0
+ case ComponentTypeFile:
+ return sv >= SpecVersion1_1
+ case ComponentTypeContainer, ComponentTypeFirmware:
+ return sv >= SpecVersion1_2
+ }
+
+ return false
+}
+
+func (sv SpecVersion) supportsHashAlgorithm(algo HashAlgorithm) bool {
+ switch algo {
+ case HashAlgoMD5, HashAlgoSHA1, HashAlgoSHA256, HashAlgoSHA384, HashAlgoSHA512, HashAlgoSHA3_256, HashAlgoSHA3_512:
+ return sv >= SpecVersion1_0
+ case HashAlgoSHA3_384, HashAlgoBlake2b_256, HashAlgoBlake2b_384, HashAlgoBlake2b_512, HashAlgoBlake3:
+ return sv >= SpecVersion1_2
+ }
+
+ return false
+}
+
+func (sv SpecVersion) supportsScope(scope Scope) bool {
+ switch scope {
+ case ScopeRequired, ScopeOptional:
+ return sv >= SpecVersion1_0
+ case ScopeExcluded:
+ return sv >= SpecVersion1_2
+ }
+
+ return false
+}
diff --git a/cyclonedx.go b/cyclonedx.go
index 69c2b18..f995afe 100644
--- a/cyclonedx.go
+++ b/cyclonedx.go
@@ -19,10 +19,11 @@ package cyclonedx
import (
"encoding/xml"
+ "fmt"
"regexp"
)
-//go:generate stringer -linecomment -output cyclonedx_string.go -type SpecVersion
+//go:generate stringer -linecomment -output cyclonedx_string.go -type MediaType,SpecVersion
const (
BOMFormat = "CycloneDX"
@@ -239,6 +240,7 @@ const (
HashAlgoSHA384 HashAlgorithm = "SHA-384"
HashAlgoSHA512 HashAlgorithm = "SHA-512"
HashAlgoSHA3_256 HashAlgorithm = "SHA3-256"
+ HashAlgoSHA3_384 HashAlgorithm = "SHA3-384"
HashAlgoSHA3_512 HashAlgorithm = "SHA3-512"
HashAlgoBlake2b_256 HashAlgorithm = "BLAKE2b-256"
HashAlgoBlake2b_384 HashAlgorithm = "BLAKE2b-384"
@@ -318,6 +320,24 @@ type LicenseChoice struct {
Expression string `json:"expression,omitempty" xml:"-"`
}
+// MediaType defines the official media types for CycloneDX BOMs.
+// See https://cyclonedx.org/specification/overview/#registered-media-types
+type MediaType int
+
+const (
+ MediaTypeJSON MediaType = iota + 1 // application/vnd.cyclonedx+json
+ MediaTypeXML // application/vnd.cyclonedx+xml
+ MediaTypeProtobuf // application/x.vnd.cyclonedx+protobuf
+)
+
+func (mt MediaType) WithVersion(specVersion SpecVersion) (string, error) {
+ if mt == MediaTypeJSON && specVersion < SpecVersion1_2 {
+ return "", fmt.Errorf("json format is not supported for specification versions lower than %s", SpecVersion1_2)
+ }
+
+ return fmt.Sprintf("%s; version=%s", mt, specVersion), nil
+}
+
type Metadata struct {
Timestamp string `json:"timestamp,omitempty" xml:"timestamp,omitempty"`
Tools *[]Tool `json:"tools,omitempty" xml:"tools>tool,omitempty"`
diff --git a/cyclonedx_string.go b/cyclonedx_string.go
index 25ccfc9..32e6763 100644
--- a/cyclonedx_string.go
+++ b/cyclonedx_string.go
@@ -1,9 +1,29 @@
-// Code generated by "stringer -linecomment -output cyclonedx_string.go -type SpecVersion"; DO NOT EDIT.
+// Code generated by "stringer -linecomment -output cyclonedx_string.go -type MediaType,SpecVersion"; DO NOT EDIT.
package cyclonedx
import "strconv"
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[MediaTypeJSON-1]
+ _ = x[MediaTypeXML-2]
+ _ = x[MediaTypeProtobuf-3]
+}
+
+const _MediaType_name = "application/vnd.cyclonedx+jsonapplication/vnd.cyclonedx+xmlapplication/x.vnd.cyclonedx+protobuf"
+
+var _MediaType_index = [...]uint8{0, 30, 59, 95}
+
+func (i MediaType) String() string {
+ i -= 1
+ if i < 0 || i >= MediaType(len(_MediaType_index)-1) {
+ return "MediaType(" + strconv.FormatInt(int64(i+1), 10) + ")"
+ }
+ return _MediaType_name[_MediaType_index[i]:_MediaType_index[i+1]]
+}
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
diff --git a/cyclonedx_test.go b/cyclonedx_test.go
index bfcb8fd..54fd861 100644
--- a/cyclonedx_test.go
+++ b/cyclonedx_test.go
@@ -25,6 +25,7 @@ import (
"github.com/bradleyjkemp/cupaloy/v2"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
var snapShooter = cupaloy.NewDefaultConfig().
@@ -35,6 +36,19 @@ func TestBool(t *testing.T) {
assert.Equal(t, false, *Bool(false))
}
+func TestMediaType_WithVersion(t *testing.T) {
+ t.Run("ShouldReturnVersionedMediaType", func(t *testing.T) {
+ res, err := MediaTypeJSON.WithVersion(SpecVersion1_2)
+ require.NoError(t, err)
+ require.Equal(t, "application/vnd.cyclonedx+json; version=1.2", res)
+ })
+
+ t.Run("ShouldReturnErrorForSpecLowerThan1.2AndJSON", func(t *testing.T) {
+ _, err := MediaTypeJSON.WithVersion(SpecVersion1_1)
+ require.Error(t, err)
+ })
+}
+
func TestVulnerability_Properties(t *testing.T) {
// GIVEN
properties := []Property{}
diff --git a/decode.go b/decode.go
index 06dacd4..fdf0529 100644
--- a/decode.go
+++ b/decode.go
@@ -49,5 +49,17 @@ type xmlBOMDecoder struct {
// Decode implements the BOMDecoder interface.
func (x xmlBOMDecoder) Decode(bom *BOM) error {
- return xml.NewDecoder(x.reader).Decode(bom)
+ err := xml.NewDecoder(x.reader).Decode(bom)
+ if err != nil {
+ return err
+ }
+
+ for specVersion, xmlNs := range xmlNamespaces {
+ if xmlNs == bom.XMLNS {
+ bom.SpecVersion = specVersion
+ break
+ }
+ }
+
+ return nil
}
diff --git a/decode_test.go b/decode_test.go
index 0bd4ae4..9d10c03 100644
--- a/decode_test.go
+++ b/decode_test.go
@@ -18,12 +18,57 @@
package cyclonedx
import (
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestNewBOMDecoder(t *testing.T) {
assert.IsType(t, &jsonBOMDecoder{}, NewBOMDecoder(nil, BOMFileFormatJSON))
assert.IsType(t, &xmlBOMDecoder{}, NewBOMDecoder(nil, BOMFileFormatXML))
}
+
+func TestXmlBOMDecoder_Decode(t *testing.T) {
+ t.Run("ShouldSetSpecVersion", func(t *testing.T) {
+ testCases := []struct {
+ bomContent string
+ specVersion SpecVersion
+ }{
+ {
+ bomContent: ``,
+ specVersion: SpecVersion1_0,
+ },
+ {
+ bomContent: ``,
+ specVersion: SpecVersion1_1,
+ },
+ {
+ bomContent: ``,
+ specVersion: SpecVersion1_2,
+ },
+ {
+ bomContent: ``,
+ specVersion: SpecVersion1_3,
+ },
+ {
+ bomContent: ``,
+ specVersion: SpecVersion1_4,
+ },
+ {
+ bomContent: ``,
+ specVersion: SpecVersion(0),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.specVersion.String(), func(t *testing.T) {
+ var bom BOM
+ err := NewBOMDecoder(strings.NewReader(tc.bomContent), BOMFileFormatXML).Decode(&bom)
+ require.NoError(t, err)
+ require.Equal(t, tc.specVersion, bom.SpecVersion)
+ })
+ }
+ })
+}
diff --git a/downgrade.go b/downgrade.go
deleted file mode 100644
index 5523be2..0000000
--- a/downgrade.go
+++ /dev/null
@@ -1,166 +0,0 @@
-// This file is part of CycloneDX Go
-//
-// Licensed under the Apache License, Version 2.0 (the “License”);
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an “AS IS” BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-// SPDX-License-Identifier: Apache-2.0
-// Copyright (c) OWASP Foundation. All Rights Reserved.
-
-package cyclonedx
-
-import "fmt"
-
-// downgrade "downgrades" the BOM to a given version of the specification.
-// Downgrading works by successively removing (or changing) fields introduced in later specification versions.
-// This procedure has been adapted from the .NET implementation:
-// https://github.com/CycloneDX/cyclonedx-dotnet-library/blob/v5.2.2/src/CycloneDX.Core/BomUtils.cs#L60
-func (b *BOM) downgrade(version SpecVersion) error {
- if version < SpecVersion1_1 {
- b.SerialNumber = ""
- b.ExternalReferences = nil
- forEachComponent(b.Components, func(c *Component) {
- c.BOMRef = ""
- c.ExternalReferences = nil
- if c.Licenses != nil {
- // Keep track of licenses that are still valid
- // after removal of unsupported fields.
- validLicenses := make(Licenses, 0)
-
- for i := range *c.Licenses {
- license := &(*c.Licenses)[i]
- if license.License != nil {
- license.License.Text = nil
- license.License.URL = ""
- }
- license.Expression = ""
- if license.License != nil {
- validLicenses = append(validLicenses, *license)
- }
- }
-
- // Remove the licenses node entirely if no valid licenses
- // are left. This avoids empty (thus invalid) tags in XML.
- if len(validLicenses) == 0 {
- c.Licenses = nil
- } else {
- c.Licenses = &validLicenses
- }
- }
- if c.Modified == nil {
- c.Modified = Bool(false)
- }
- c.Pedigree = nil
- })
- }
-
- if version < SpecVersion1_2 {
- b.Metadata = nil
- b.Dependencies = nil
- b.Services = nil
- forEachComponent(b.Components, func(c *Component) {
- c.Author = ""
- c.MIMEType = ""
- c.Supplier = nil
- c.SWID = nil
- if c.Pedigree != nil {
- c.Pedigree.Patches = nil
- }
- })
- }
-
- if version < SpecVersion1_3 {
- b.Compositions = nil
- if b.Metadata != nil {
- b.Metadata.Licenses = nil
- b.Metadata.Properties = nil
- }
- forEachComponent(b.Components, func(c *Component) {
- c.Evidence = nil
- c.Properties = nil
- if c.ExternalReferences != nil {
- for i := range *c.ExternalReferences {
- (*c.ExternalReferences)[i].Hashes = nil
- }
- }
- })
- forEachService(b.Services, func(s *Service) {
- s.Properties = nil
- if s.ExternalReferences != nil {
- for i := range *s.ExternalReferences {
- (*s.ExternalReferences)[i].Hashes = nil
- }
- }
- })
- }
-
- if version < SpecVersion1_4 {
- if b.Metadata != nil && b.Metadata.Tools != nil {
- for i := range *b.Metadata.Tools {
- (*b.Metadata.Tools)[i].ExternalReferences = nil
- }
- }
- forEachComponent(b.Components, func(c *Component) {
- c.ReleaseNotes = nil
- if c.Version == "" {
- c.Version = "0.0.0"
- }
- })
- forEachService(b.Services, func(s *Service) {
- s.ReleaseNotes = nil
- })
- b.Vulnerabilities = nil
- }
-
- b.SpecVersion = version
- b.XMLNS = xmlNamespaces[version]
-
- return nil
-}
-
-func (b *BOM) copyAndDowngrade(version SpecVersion) (*BOM, error) {
- var bomCopy BOM
- err := b.copy(&bomCopy)
- if err != nil {
- return nil, fmt.Errorf("failed to copy bom: %w", err)
- }
-
- err = bomCopy.downgrade(version)
- return &bomCopy, err
-}
-
-func forEachComponent(components *[]Component, f func(c *Component)) {
- if components == nil || len(*components) == 0 {
- return
- }
-
- for i := range *components {
- component := &(*components)[i]
- f(component)
- forEachComponent(component.Components, f)
- if component.Pedigree != nil {
- forEachComponent(component.Pedigree.Ancestors, f)
- forEachComponent(component.Pedigree.Descendants, f)
- forEachComponent(component.Pedigree.Variants, f)
- }
- }
-}
-
-func forEachService(services *[]Service, f func(s *Service)) {
- if services == nil || len(*services) == 0 {
- return
- }
-
- for i := range *services {
- f(&(*services)[i])
- forEachService((*services)[i].Services, f)
- }
-}
diff --git a/encode.go b/encode.go
index 576dfcc..371c364 100644
--- a/encode.go
+++ b/encode.go
@@ -52,7 +52,7 @@ type jsonBOMEncoder struct {
// Encode implements the BOMEncoder interface.
func (j jsonBOMEncoder) Encode(bom *BOM) error {
if bom.SpecVersion < SpecVersion1_2 {
- return fmt.Errorf("json format is not supported for specification versions lower than 1.2")
+ return fmt.Errorf("json format is not supported for specification versions lower than %s", SpecVersion1_2)
}
encoder := json.NewEncoder(j.writer)
@@ -64,8 +64,8 @@ func (j jsonBOMEncoder) Encode(bom *BOM) error {
}
// EncodeVersion implements the BOMEncoder interface.
-func (j jsonBOMEncoder) EncodeVersion(bom *BOM, version SpecVersion) (err error) {
- bom, err = bom.copyAndDowngrade(version)
+func (j jsonBOMEncoder) EncodeVersion(bom *BOM, specVersion SpecVersion) (err error) {
+ bom, err = bom.copyAndConvert(specVersion)
if err != nil {
return
}
@@ -99,8 +99,8 @@ func (x xmlBOMEncoder) Encode(bom *BOM) error {
}
// EncodeVersion implements the BOMEncoder interface.
-func (x xmlBOMEncoder) EncodeVersion(bom *BOM, version SpecVersion) (err error) {
- bom, err = bom.copyAndDowngrade(version)
+func (x xmlBOMEncoder) EncodeVersion(bom *BOM, specVersion SpecVersion) (err error) {
+ bom, err = bom.copyAndConvert(specVersion)
if err != nil {
return
}