From 5f10aea00cf46bbe3a4ce66ce2b85bd17576a35c Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 26 Sep 2022 19:37:51 +0200 Subject: [PATCH 1/3] refactor: refine spec version conversion to cover more cases improves on #51 Signed-off-by: nscuro --- convert.go | 304 +++++++++++++++++++++++++++++++++++++++++++++++++++ cyclonedx.go | 1 + downgrade.go | 166 ---------------------------- encode.go | 8 +- 4 files changed, 309 insertions(+), 170 deletions(-) create mode 100644 convert.go delete mode 100644 downgrade.go 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..1ac57f7 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -239,6 +239,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" 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..b03843d 100644 --- a/encode.go +++ b/encode.go @@ -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 } From 1655b7dad8bb4e1cc7c402fac75dddf998dc5621 Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 26 Sep 2022 19:51:26 +0200 Subject: [PATCH 2/3] feat: set `SpecVersion` when decoding from xml `SpecVersion` as a field is only used in the json format, but when working with `BOM` instances it is useful to know what spec version one is dealing with. Signed-off-by: nscuro --- decode.go | 14 +++++++++++++- decode_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) 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) + }) + } + }) +} From acb932270c1594cb44c052ebeacfe4400c25e30b Mon Sep 17 00:00:00 2001 From: nscuro Date: Mon, 26 Sep 2022 20:02:26 +0200 Subject: [PATCH 3/3] feat: add enum for official media types Signed-off-by: nscuro --- cyclonedx.go | 21 ++++++++++++++++++++- cyclonedx_string.go | 22 +++++++++++++++++++++- cyclonedx_test.go | 14 ++++++++++++++ encode.go | 2 +- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/cyclonedx.go b/cyclonedx.go index 1ac57f7..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" @@ -319,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/encode.go b/encode.go index b03843d..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)