Skip to content

Commit

Permalink
feat: add support for bom links (#33)
Browse files Browse the repository at this point in the history
Signed-off-by: nscuro <nscuro@protonmail.com>
  • Loading branch information
nscuro committed May 5, 2022
1 parent 3064f67 commit 3cc319e
Show file tree
Hide file tree
Showing 4 changed files with 368 additions and 0 deletions.
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)
})
}
})
}

0 comments on commit 3cc319e

Please sign in to comment.