Repository: sonatype-nexus-community/nancy
Pull request #138 (2020-04-18 19:33:04)

Some additional SBOM types to support upstream projects
For some additional projects (Ahab, Cheque, Hashbrowns), I figured we could extend Nancy to have the core cyclonedx functionality necessary to support additional use cases for generating different types of SBOMs

This pull request makes the following changes:
* Adds a SBOM processor for a slice of `types.Sha1SBOM` (supports Hashbrowns)
* Adds a SBOM processor for a slice of `packageurl.PackageURL` (Ahab, Cheque, and likely Nancy)
* Adds tests for verifying these
* Adds `group` to SBOM output as that was missing previously to packageurl and purl processors

cc @bhamail / @DarthHater


fitzoh: >https://github.com/sonatype-nexus-community/go-sona-types

nice
DarthHater: @zendern @fitzoh I am not against that at all. We've got enough tools consuming these things where that would make sense! I'll merge this for now but we can make that a thing for `1.0.0`, rip out the common stuff.

Patch:
diff --git a/cyclonedx/cyclonedx.go b/cyclonedx/cyclonedx.go
index ec41dbd..7491b94 100644
--- a/cyclonedx/cyclonedx.go
+++ b/cyclonedx/cyclonedx.go
@@ -37,11 +37,58 @@ func ProcessPurlsIntoSBOM(results []types.Coordinate) string {
 	return processPurlsIntoSBOMSchema1_1(results)
 }
 
+// SBOMFromPackageURLs will take a slice of packageurl.PackageURL and convert them
+// into a minimal 1.1 CycloneDX sbom
+func SBOMFromPackageURLs(results []packageurl.PackageURL) string {
+	return processPackageURLsIntoSBOMSchema1_1(results)
+}
+
+// SBOMFromSHA1 will take a slice of Sha1SBOM and convert them
+// into a minimal 1.1 CycloneDX sbom
+func SBOMFromSHA1(results []types.Sha1SBOM) string {
+	return createMinimalSha1Sbom(results)
+}
+
+func createMinimalSha1Sbom(results []types.Sha1SBOM) string {
+	sbom := createSbomDocument()
+	for _, v := range results {
+		hash := types.Hash{
+			Alg: "SHA-1", Attribute: v.Sha1,
+		}
+		component := types.Component{
+			Type:    "library",
+			BomRef:  v.Sha1,
+			Name:    v.Location,
+			Version: "0",
+		}
+		component.Hashes.Hash = append(component.Hashes.Hash, hash)
+
+		sbom.Components.Component = append(sbom.Components.Component, component)
+	}
+
+	return processAndReturnSbom(sbom)
+}
+
+func processPackageURLsIntoSBOMSchema1_1(results []packageurl.PackageURL) string {
+	sbom := createSbomDocument()
+	for _, v := range results {
+		component := types.Component{
+			Type:    "library",
+			BomRef:  v.ToString(),
+			Purl:    v.ToString(),
+			Group:   v.Namespace,
+			Name:    v.Name,
+			Version: v.Version,
+		}
+
+		sbom.Components.Component = append(sbom.Components.Component, component)
+	}
+
+	return processAndReturnSbom(sbom)
+}
+
 func processPurlsIntoSBOMSchema1_1(results []types.Coordinate) string {
-	sbom := types.Sbom{}
-	sbom.Xmlns = cycloneDXBomXmlns1_1
-	sbom.XMLNSV = cycloneDXBomXmlns1_0V
-	sbom.Version = version
+	sbom := createSbomDocument()
 	for _, v := range results {
 		purl, err := packageurl.FromString(v.Coordinates)
 		customerrors.Check(err, "Error parsing purl from given coordinate")
@@ -52,6 +99,7 @@ func processPurlsIntoSBOMSchema1_1(results []types.Coordinate) string {
 			Purl:    purl.String(),
 			Name:    purl.Name,
 			Version: purl.Version,
+			Group:   purl.Namespace,
 		}
 
 		if v.IsVulnerable() {
@@ -73,6 +121,19 @@ func processPurlsIntoSBOMSchema1_1(results []types.Coordinate) string {
 		sbom.Components.Component = append(sbom.Components.Component, component)
 	}
 
+	return processAndReturnSbom(sbom)
+}
+
+func createSbomDocument() *types.Sbom {
+	sbom := types.Sbom{}
+	sbom.Xmlns = cycloneDXBomXmlns1_1
+	sbom.XMLNSV = cycloneDXBomXmlns1_0V
+	sbom.Version = version
+
+	return &sbom
+}
+
+func processAndReturnSbom(sbom *types.Sbom) string {
 	output, err := xml.MarshalIndent(sbom, " ", "     ")
 	if err != nil {
 		LogLady.Error(err)
diff --git a/types/types.go b/types/types.go
index aaad9fa..70041dd 100644
--- a/types/types.go
+++ b/types/types.go
@@ -116,6 +116,12 @@ type StatusURLResult struct {
 
 // CycloneDX Types
 
+// Sha1SBOM is a struct to begin assembling a minimal SBOM based on sha1s
+type Sha1SBOM struct {
+	Location string
+	Sha1     string
+}
+
 // Sbom is a struct to begin assembling a minimal SBOM
 type Sbom struct {
 	XMLName    xml.Name   `xml:"bom"`
@@ -136,10 +142,21 @@ type Component struct {
 	BomRef          string          `xml:"bom-ref,attr"`
 	Name            string          `xml:"name"`
 	Version         string          `xml:"version"`
-	Purl            string          `xml:"purl"`
+	Group           string          `xml:"group,omitempty"`
+	Purl            string          `xml:"purl,omitempty"`
+	Hashes          Hashes          `xml:"hashes,omitempty"`
 	Vulnerabilities Vulnerabilities `xml:"v:vulnerabilities,omitempty"`
 }
 
+type Hashes struct {
+	Hash []Hash `xml:"hash,omitempty"`
+}
+
+type Hash struct {
+	Alg       string `xml:"alg,attr,omitempty"`
+	Attribute string `xml:",chardata"`
+}
+
 type Vulnerabilities struct {
 	Vulnerability []SbomVulnerability `xml:"v:vulnerability,omitempty"`
 }


Test patch:
diff --git a/cyclonedx/cyclonedx_test.go b/cyclonedx/cyclonedx_test.go
index aeff741..19053d6 100644
--- a/cyclonedx/cyclonedx_test.go
+++ b/cyclonedx/cyclonedx_test.go
@@ -27,6 +27,82 @@ import (
 	assert "gopkg.in/go-playground/assert.v1"
 )
 
+func TestCreateSBOMFromPackageURLs(t *testing.T) {
+	results := []packageurl.PackageURL{}
+	uno, _ := packageurl.FromString("pkg:golang/github.com/test/test@1.0.0")
+	results = append(results, uno)
+
+	result := SBOMFromPackageURLs(results)
+
+	doc := etree.NewDocument()
+
+	if err := doc.ReadFromString(result); err != nil {
+		t.Error("Uh Oh")
+	}
+
+	root := doc.SelectElement("bom")
+	assertBaseXMLValid(root, t)
+	components := root.SelectElement("components")
+	for i, component := range components.SelectElements("component") {
+		assert.Equal(t, component.Tag, "component")
+		assert.Equal(t, component.Attr[0].Key, "type")
+		assert.Equal(t, component.Attr[0].Value, "library")
+		assert.Equal(t, component.Attr[1].Key, "bom-ref")
+		assert.Equal(t, component.Attr[1].Value, results[i].ToString())
+		name := component.SelectElement("name")
+		assert.Equal(t, name.Tag, "name")
+		assert.Equal(t, name.Text(), results[i].Name)
+		version := component.SelectElement("version")
+		assert.Equal(t, version.Tag, "version")
+		assert.Equal(t, version.Text(), results[i].Version)
+		group := component.SelectElement("group")
+		assert.Equal(t, group.Tag, "group")
+		assert.Equal(t, group.Text(), results[i].Namespace)
+		purl := component.SelectElement("purl")
+		assert.Equal(t, purl.Tag, "purl")
+		assert.Equal(t, purl.Text(), results[i].ToString())
+	}
+}
+
+func TestCreateSBOMFromSHA1s(t *testing.T) {
+	results := []types.Sha1SBOM{}
+	uno := types.Sha1SBOM{Location: "/path/on/disk", Sha1: "c2843e01d9a2"}
+	results = append(results, uno)
+
+	result := SBOMFromSHA1(results)
+
+	doc := etree.NewDocument()
+
+	if err := doc.ReadFromString(result); err != nil {
+		t.Error("Uh Oh")
+	}
+
+	root := doc.SelectElement("bom")
+	assertBaseXMLValid(root, t)
+	components := root.SelectElement("components")
+	for i, component := range components.SelectElements("component") {
+		location := results[i].Location
+		sha1 := results[i].Sha1
+		assert.Equal(t, component.Tag, "component")
+		assert.Equal(t, component.Attr[0].Key, "type")
+		assert.Equal(t, component.Attr[0].Value, "library")
+		assert.Equal(t, component.Attr[1].Key, "bom-ref")
+		assert.Equal(t, component.Attr[1].Value, sha1)
+		name := component.SelectElement("name")
+		assert.Equal(t, name.Tag, "name")
+		assert.Equal(t, name.Text(), location)
+		version := component.SelectElement("version")
+		assert.Equal(t, version.Tag, "version")
+		assert.Equal(t, version.Text(), "0")
+		hashes := component.SelectElement("hashes")
+		hash := hashes.SelectElement("hash")
+		assert.Equal(t, hash.Tag, "hash")
+		assert.Equal(t, hash.Text(), sha1)
+		alg := hash.SelectAttr("alg")
+		assert.Equal(t, alg.Value, "SHA-1")
+	}
+}
+
 func TestProcessPurlsIntoSBOM(t *testing.T) {
 	results := []types.Coordinate{}
 	crypto := types.Coordinate{
@@ -61,14 +137,7 @@ func TestProcessPurlsIntoSBOM(t *testing.T) {
 	}
 
 	root := doc.SelectElement("bom")
-	assert.Equal(t, root.Tag, "bom")
-	assert.Equal(t, root.Attr[0].Key, "xmlns")
-	assert.Equal(t, root.Attr[0].Value, "http://cyclonedx.org/schema/bom/1.1")
-	assert.Equal(t, root.Attr[1].Space, "xmlns")
-	assert.Equal(t, root.Attr[1].Key, "v")
-	assert.Equal(t, root.Attr[1].Value, "http://cyclonedx.org/schema/ext/vulnerability/1.0")
-	assert.Equal(t, root.Attr[2].Key, "version")
-	assert.Equal(t, root.Attr[2].Value, "1")
+	assertBaseXMLValid(root, t)
 	components := root.SelectElement("components")
 	for i, component := range components.SelectElements("component") {
 		coordinate, _ := packageurl.FromString(results[i].Coordinates)
@@ -83,6 +152,9 @@ func TestProcessPurlsIntoSBOM(t *testing.T) {
 		version := component.SelectElement("version")
 		assert.Equal(t, version.Tag, "version")
 		assert.Equal(t, version.Text(), coordinate.Version)
+		group := component.SelectElement("group")
+		assert.Equal(t, group.Tag, "group")
+		assert.Equal(t, group.Text(), coordinate.Namespace)
 		purl := component.SelectElement("purl")
 		assert.Equal(t, purl.Tag, "purl")
 		assert.Equal(t, purl.Text(), coordinate.ToString())
@@ -129,3 +201,14 @@ func TestProcessPurlsIntoSBOM(t *testing.T) {
 		}
 	}
 }
+
+func assertBaseXMLValid(doc *etree.Element, t *testing.T) {
+	assert.Equal(t, doc.Tag, "bom")
+	assert.Equal(t, doc.Attr[0].Key, "xmlns")
+	assert.Equal(t, doc.Attr[0].Value, "http://cyclonedx.org/schema/bom/1.1")
+	assert.Equal(t, doc.Attr[1].Space, "xmlns")
+	assert.Equal(t, doc.Attr[1].Key, "v")
+	assert.Equal(t, doc.Attr[1].Value, "http://cyclonedx.org/schema/ext/vulnerability/1.0")
+	assert.Equal(t, doc.Attr[2].Key, "version")
+	assert.Equal(t, doc.Attr[2].Value, "1")
+}