Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sbom

Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX and SPDX JSON into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON.
Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX JSON/XML and SPDX JSON/tag-value into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON.

Ported from [github.com/andrew/sbom](https://github.com/andrew/sbom).

Expand Down Expand Up @@ -30,7 +30,9 @@ for _, p := range doc.Packages {
}
```

`Parse` auto-detects the format. `Detect` returns just the type without a full parse and runs in roughly constant time regardless of document size, since it only token-scans top-level keys until it hits a discriminator.
`Parse` auto-detects the format. `Detect` returns just the type without a full parse, scanning JSON keys, the XML root, or the tag-value document header.

Packages with different document IDs or PURLs remain separate, even when their names and versions match. `AddPackage` replaces an entry only when its name, version, document ID, and set of PURLs match. `Package.PURL()` returns the first PURL; `Package.ExternalRefs` retains all declared references.

```go
switch sbom.Detect(data) {
Expand Down Expand Up @@ -94,10 +96,11 @@ for _, p := range doc.Packages {
| Format | Parse | Encode | Notes |
| --- | --- | --- | --- |
| CycloneDX JSON | yes | yes | nested components flattened, `license.id` / `license.name` / `expression` all read |
| CycloneDX XML | no | yes | |
| CycloneDX XML | yes | yes | nested components, dependency references, and package metadata |
| SPDX JSON | yes | yes | `{"sbom": ...}` and in-toto `{"predicate": ...}` envelopes unwrapped on parse |
| SPDX tag-value | yes | no | document and package fields, external references, relationships, and multiline text |

XML/YAML/tag-value parsing is not handled yet.
SPDX file, snippet, annotation, and extracted-license records are not included in the parsed model. YAML parsing is not supported.

## Why not protobom?

Expand Down
16 changes: 10 additions & 6 deletions cyclonedx.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ type cdxBOMXML struct {
}

type cdxMetadataXML struct {
Timestamp string `xml:"timestamp,omitempty"`
Tools *cdxToolsXML `xml:"tools,omitempty"`
Component *cdxComponentXML `xml:"component,omitempty"`
Supplier *cdxOrgEntity `xml:"supplier,omitempty"`
Timestamp string `xml:"timestamp,omitempty"`
Tools *cdxToolsXML `xml:"tools,omitempty"`
Component *cdxComponentXML `xml:"component,omitempty"`
Supplier *cdxOrgEntity `xml:"supplier,omitempty"`
Manufacture *cdxOrgEntity `xml:"manufacture,omitempty"`
}

type cdxToolsXML struct {
Expand Down Expand Up @@ -245,10 +246,13 @@ func parseCycloneDX(data []byte) (*SBOM, error) {
if err := json.Unmarshal(data, &bom); err != nil {
return nil, wrapErr("cyclonedx json", err)
}
if bom.BOMFormat != cdxBOMFormat {
if bom.BOMFormat != cdxBOMFormat && (bom.BOMFormat != "" || bom.Components == nil) {
return nil, ErrUnrecognized
}
return normalizeCycloneDX(&bom), nil
}

func normalizeCycloneDX(bom *cdxBOM) *SBOM {
packageCount := len(bom.Components)
nestedRelationshipCount := 0
for i := range bom.Components {
Expand Down Expand Up @@ -298,7 +302,7 @@ func parseCycloneDX(data []byte) (*SBOM, error) {
}
}

return s, nil
return s
}

func cdxComponentStats(components []cdxComponent) (packages, relationships int) {
Expand Down
140 changes: 140 additions & 0 deletions cyclonedx_xml.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package sbom

import (
"bytes"
"encoding/xml"
"fmt"
"io"
"strings"
)

const cdxXMLNamespacePrefix = "http://cyclonedx.org/schema/bom/"

type cdxDependencyXML struct {
Ref string `xml:"ref,attr"`
DependsOn []cdxDependencyXML `xml:"dependency"`
}

// The dependency targets are ref attributes, unlike the JSON string array.
type cdxInputXML struct {
XMLName xml.Name `xml:"bom"`
Version int `xml:"version,attr"`
SerialNumber string `xml:"serialNumber,attr"`
Metadata *cdxMetadataXML `xml:"metadata"`
Components *cdxComponentsXML `xml:"components"`
Dependencies []cdxDependencyXML `xml:"dependencies>dependency"`
}

func detectCycloneDXXML(data []byte) detectedFormat {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return detectedFormat{}
}
if start, ok := token.(xml.StartElement); ok {
if start.Name.Local == "bom" && (start.Name.Space == "" || cycloneDXXMLVersion(start.Name.Space) != "") {
return detectedFormat{typ: TypeCycloneDX, xml: true}
}
return detectedFormat{}
}
}
}

func cycloneDXXMLVersion(namespace string) string {
version, ok := strings.CutPrefix(namespace, cdxXMLNamespacePrefix)
if !ok {
return ""
}
return version
}

func parseCycloneDXXML(data []byte) (*SBOM, error) {
var input cdxInputXML
decoder := xml.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&input); err != nil {
return nil, wrapErr("cyclonedx xml", err)
}
if err := checkXMLEnd(decoder); err != nil {
return nil, wrapErr("cyclonedx xml", err)
}
bom := cdxBOM{
BOMFormat: cdxBOMFormat, SpecVersion: cycloneDXXMLVersion(input.XMLName.Space),
BOMVersion: input.Version, SerialNumber: input.SerialNumber,
Components: componentsFromXML(input.Components),
}
if input.Metadata != nil {
m := input.Metadata
bom.Metadata = &cdxMetadata{Timestamp: m.Timestamp, Supplier: m.Supplier, Manufacture: m.Manufacture}
if m.Component != nil {
component := componentFromXML(m.Component)
bom.Metadata.Component = &component
}
}
for _, dependency := range input.Dependencies {
d := cdxDependency{Ref: dependency.Ref}
for _, target := range dependency.DependsOn {
d.DependsOn = append(d.DependsOn, target.Ref)
}
bom.Dependencies = append(bom.Dependencies, d)
}
return normalizeCycloneDX(&bom), nil
}

func checkXMLEnd(decoder *xml.Decoder) error {
for {
token, err := decoder.Token()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch token := token.(type) {
case xml.Comment, xml.ProcInst:
continue
case xml.CharData:
if len(bytes.TrimSpace(token)) == 0 {
continue
}
}
return fmt.Errorf("unexpected content after bom element")
}
}

func componentsFromXML(input *cdxComponentsXML) []cdxComponent {
if input == nil || len(input.Components) == 0 {
return nil
}
components := make([]cdxComponent, len(input.Components))
for i := range input.Components {
components[i] = componentFromXML(&input.Components[i])
}
return components
}

func componentFromXML(input *cdxComponentXML) cdxComponent {
c := cdxComponent{
BOMRef: input.BOMRef, Type: input.Type, Name: input.Name, Version: input.Version,
Description: input.Description, Copyright: input.Copyright, Author: input.Author,
PURL: input.PURL, Supplier: input.Supplier, Components: componentsFromXML(input.Components),
}
if input.Hashes != nil {
c.Hashes = input.Hashes.Hashes
}
if input.ExternalReferences != nil {
c.ExternalReferences = input.ExternalReferences.References
}
if input.Properties != nil {
c.Properties = input.Properties.Properties
}
if input.Licenses != nil {
for _, license := range input.Licenses.Licenses {
c.Licenses = append(c.Licenses, cdxLicense{License: &cdxLicenseID{ID: license.ID, Name: license.Name}})
}
if input.Licenses.Expression != "" {
c.Licenses = append(c.Licenses, cdxLicense{Expression: input.Licenses.Expression})
}
}
return c
}
Loading
Loading