From 3cc319e20e6f9f6565c3365b62515575859ccf1f Mon Sep 17 00:00:00 2001 From: Niklas Date: Thu, 5 May 2022 23:20:22 +0200 Subject: [PATCH] feat: add support for bom links (#33) Signed-off-by: nscuro --- cyclonedx.go | 3 + link.go | 148 ++++++++++++++++++++++++++++++++++++++ link_example_test.go | 49 +++++++++++++ link_test.go | 168 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 link.go create mode 100644 link_example_test.go create mode 100644 link_test.go diff --git a/cyclonedx.go b/cyclonedx.go index fb0ff68..a265ef2 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "regexp" ) const ( @@ -582,6 +583,8 @@ const ( SeverityCritical Severity = "critical" ) +var serialNumberRegex = regexp.MustCompile(`^urn:uuid:[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$`) + type Source struct { Name string `json:"name,omitempty" xml:"name,omitempty"` URL string `json:"url,omitempty" xml:"url,omitempty"` diff --git a/link.go b/link.go new file mode 100644 index 0000000..22aa17a --- /dev/null +++ b/link.go @@ -0,0 +1,148 @@ +// 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" + "net/url" + "regexp" + "strconv" + "strings" +) + +// BOMLink provides the ability to create references to other +// BOMs and specific components, services or vulnerabilities within them. +// +// See also: +// - https://cyclonedx.org/capabilities/bomlink/ +// - https://www.iana.org/assignments/urn-formal/cdx +type BOMLink struct { + serialNumber string // Serial number of the linked BOM + version int // Version of the linked BOM + reference string // Reference of the linked element +} + +// NewBOMLink creates a new link to a BOM with a given serial number and version. +// The serial number MUST conform to RFC-4122. The version MUST NOT be zero or negative. +// +// By providing a non-nil element, a deep link to that element is created. +// Linkable elements include components, services and vulnerabilities. +// When an element is provided, it MUST have a bom reference. +func NewBOMLink(serial string, version int, elem interface{}) (link BOMLink, err error) { + if !serialNumberRegex.MatchString(serial) { + err = fmt.Errorf("invalid serial number") + return + } + if version < 1 { + err = fmt.Errorf("invalid version: must not be negative or zero") + return + } + + ref := "" + if elem != nil { + switch elem := elem.(type) { + case Component: + ref = elem.BOMRef + case *Component: + ref = elem.BOMRef + case Service: + ref = elem.BOMRef + case *Service: + ref = elem.BOMRef + case Vulnerability: + ref = elem.BOMRef + case *Vulnerability: + ref = elem.BOMRef + default: + err = fmt.Errorf("element of type %T is not linkable", elem) + return + } + if ref == "" { + err = fmt.Errorf("the provided element does not have a bom reference") + return + } + } + + return BOMLink{ + serialNumber: serial, + version: version, + reference: ref, + }, nil +} + +// SerialNumber returns the serial number of the linked BOM. +func (b BOMLink) SerialNumber() string { + return b.serialNumber +} + +// Version returns the version of the linked BOM. +func (b BOMLink) Version() int { + return b.version +} + +// Reference returns the reference of the element within the linked BOM. +func (b BOMLink) Reference() string { + return b.reference +} + +// String returns the string representation of the link. +func (b BOMLink) String() string { + if b.reference == "" { + return fmt.Sprintf("urn:cdx:%s/%d", strings.TrimPrefix(b.serialNumber, "urn:uuid:"), b.version) + } + + return fmt.Sprintf("urn:cdx:%s/%d#%s", strings.TrimPrefix(b.serialNumber, "urn:uuid:"), b.version, url.QueryEscape(b.reference)) +} + +var bomLinkRegex = regexp.MustCompile(`^urn:cdx:(?P[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/(?P[1-9]\d*)(?:#(?P[\da-zA-Z\-._~%!$&'()*+,;=:@/?]+))?$`) + +// IsBOMLink checks whether a given string is a valid BOM link. +func IsBOMLink(s string) bool { + return bomLinkRegex.MatchString(s) +} + +// ParseBOMLink parses a string into a BOMLink. +func ParseBOMLink(s string) (link BOMLink, err error) { + matches := bomLinkRegex.FindStringSubmatch(s) + if matches == nil { + err = fmt.Errorf("invalid bom link") + return + } + + serial := "urn:uuid:" + matches[1] + version, err := strconv.Atoi(matches[2]) + if err != nil { + err = fmt.Errorf("failed to parse version: %w", err) + return + } + + ref := "" + if len(matches) == 4 { + ref, err = url.QueryUnescape(matches[3]) + if err != nil { + err = fmt.Errorf("failed to unescape reference: %w", err) + return + } + } + + return BOMLink{ + serialNumber: serial, + version: version, + reference: ref, + }, nil +} diff --git a/link_example_test.go b/link_example_test.go new file mode 100644 index 0000000..a6c31c7 --- /dev/null +++ b/link_example_test.go @@ -0,0 +1,49 @@ +// 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_test + +import ( + "fmt" + + cdx "github.com/CycloneDX/cyclonedx-go" +) + +func ExampleNewBOMLink() { + bom := cdx.NewBOM() + bom.SerialNumber = "urn:uuid:bd064d10-4238-4a2e-9517-216f79ed77ad" + bom.Version = 2 + bom.Metadata = &cdx.Metadata{ + Component: &cdx.Component{ + BOMRef: "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.5.0?type=module", + Type: cdx.ComponentTypeLibrary, + Name: "github.com/CycloneDX/cyclonedx-go", + Version: "v0.5.0", + PackageURL: "pkg:golang/github.com/CycloneDX/cyclonedx-go@v0.5.0?type=module", + }, + } + + link, _ := cdx.NewBOMLink(bom.SerialNumber, bom.Version, nil) + deepLink, _ := cdx.NewBOMLink(bom.SerialNumber, bom.Version, bom.Metadata.Component) + + fmt.Println(link.String()) + fmt.Println(deepLink.String()) + + // Output: + // urn:cdx:bd064d10-4238-4a2e-9517-216f79ed77ad/2 + // urn:cdx:bd064d10-4238-4a2e-9517-216f79ed77ad/2#pkg%3Agolang%2Fgithub.com%2FCycloneDX%2Fcyclonedx-go%40v0.5.0%3Ftype%3Dmodule +} diff --git a/link_test.go b/link_test.go new file mode 100644 index 0000000..2f9bffe --- /dev/null +++ b/link_test.go @@ -0,0 +1,168 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBOMLink(t *testing.T) { + t.Run("InvalidSerial", func(t *testing.T) { + for _, input := range []string{ + "", + "50b69bf2", + "50b69bf2-fd4f", + "50b69bf2-fd4f-400e-9522", + "50b69bf2-fd4f-400e-9522-43badebb14ca", + "uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", + "urn:50b69bf2-fd4f-400e-9522-43badebb14ca", + } { + link, err := NewBOMLink(input, 1, nil) + require.Error(t, err) + require.Zero(t, link) + } + }) + + t.Run("InvalidVersion", func(t *testing.T) { + for _, input := range []int{0, -1} { + link, err := NewBOMLink("urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", input, nil) + require.Error(t, err) + require.Zero(t, link) + } + }) + + t.Run("ElementWithRef", func(t *testing.T) { + tests := map[string]interface{}{ + "Component": Component{BOMRef: "ref"}, + "ComponentPtr": &Component{BOMRef: "ref"}, + "Service": Service{BOMRef: "ref"}, + "ServicePtr": &Service{BOMRef: "ref"}, + "Vulnerability": Vulnerability{BOMRef: "ref"}, + "VulnerabilityPtr": &Vulnerability{BOMRef: "ref"}, + } + + for name, input := range tests { + t.Run(name, func(t *testing.T) { + link, err := NewBOMLink("urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", 6, input) + require.NoError(t, err) + require.Equal(t, "urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", link.SerialNumber()) + require.Equal(t, 6, link.Version()) + require.Equal(t, "ref", link.Reference()) + }) + } + }) + + t.Run("ElementWithoutRef", func(t *testing.T) { + link, err := NewBOMLink("urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", 6, Component{}) + require.Error(t, err) + require.Zero(t, link) + }) + + t.Run("NonLinkableElement", func(t *testing.T) { + link, err := NewBOMLink("urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", 1, OrganizationalEntity{}) + require.Error(t, err) + require.Zero(t, link) + }) +} + +func TestBOMLink_String(t *testing.T) { + tests := map[string]struct { + input string + want string + }{ + "WithRef": {input: "r/e/f@1.2.3", want: "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6#r%2Fe%2Ff%401.2.3"}, + "WithoutRef": {input: "", want: "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + link := BOMLink{ + serialNumber: "urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", + version: 6, + reference: tc.input, + } + require.Equal(t, tc.want, link.String()) + }) + } +} + +func TestIsBOMLink(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + for _, input := range []string{ + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/111", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#r%2Fe%2Ff", + } { + assert.True(t, IsBOMLink(input)) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, input := range []string{ + "urn", + "urn:cdx", + "urn:cdx:foo-bar", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/#ref", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/1#", + "urn:cdx:ca0265ad-5bb3-46f2-8523-af52a7efc40b/0", + } { + assert.False(t, IsBOMLink(input), input) + } + }) +} + +func TestParseBOMLink(t *testing.T) { + t.Run("WithReference", func(t *testing.T) { + link, err := ParseBOMLink("urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6#r%2Fe%2Ff%401.2.3") + require.NoError(t, err) + require.Equal(t, "urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", link.serialNumber) + require.Equal(t, 6, link.version) + require.Equal(t, "r/e/f@1.2.3", link.reference) + }) + + t.Run("WithoutReference", func(t *testing.T) { + link, err := ParseBOMLink("urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/6") + require.NoError(t, err) + require.Equal(t, "urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", link.serialNumber) + require.Equal(t, 6, link.version) + require.Equal(t, "", link.reference) + }) + + t.Run("Invalid", func(t *testing.T) { + tests := map[string]string{ + "UUIDURN": "urn:uuid:50b69bf2-fd4f-400e-9522-43badebb14ca", + "InvalidUUID": "urn:cdx:foobar", + "NoVersion": "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca", + "ZeroVersion": "urn:cdx:50b69bf2-fd4f-400e-9522-43badebb14ca/0", + } + + for name, input := range tests { + t.Run(name, func(t *testing.T) { + link, err := ParseBOMLink(input) + require.Error(t, err) + require.Zero(t, link) + }) + } + }) +}