Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for bom links #33

Merged
merged 2 commits into from May 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions cyclonedx.go
Expand Up @@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io"
"regexp"
)

const (
Expand Down Expand Up @@ -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"`
Expand Down
148 changes: 148 additions & 0 deletions 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<serial>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/(?P<version>[1-9]\d*)(?:#(?P<ref>[\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
}
49 changes: 49 additions & 0 deletions 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
}
168 changes: 168 additions & 0 deletions 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)
})
}
})
}