sbom

package module
v0.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 15 Imported by: 2

README

sbom

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.

Installation

go get github.com/git-pkgs/sbom

Parsing

import "github.com/git-pkgs/sbom"

data, _ := os.ReadFile("bom.cdx.json")
doc, err := sbom.Parse(data)
if err != nil {
    log.Fatal(err)
}

fmt.Println(doc.Type)             // cyclonedx
fmt.Println(doc.SpecVersion)      // 1.6
fmt.Println(doc.Document.Name)    // my-app

for _, p := range doc.Packages {
    fmt.Println(p.Name, p.Version, p.PURL())
}

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.

switch sbom.Detect(data) {
case sbom.TypeCycloneDX:
case sbom.TypeSPDX:
}

Generating

Build an *sbom.SBOM, populate Document and Packages, then Encode:

s := sbom.New(sbom.TypeCycloneDX)
s.Document = sbom.Document{
    Name:      "my-app",
    Namespace: "https://example.com/my-app",
    Component: sbom.Component{
        Type:              "application",
        Name:              "my-app",
        Version:           "1.2.3",
        LicenseExpression: "MIT",
        LicenseNames:      []string{"Additional Project Terms"},
        ExtractedLicenses: []sbom.ExtractedLicense{{
            Name: "LICENSE.custom",
            Text: "Custom license text",
        }},
    },
    Creators:  []sbom.Creator{{Type: "Tool", Name: "my-tool-1.0"}},
}
s.AddPackage(sbom.Package{
    Name: "lodash", Version: "4.17.21", LicenseDeclared: "MIT",
    ExternalRefs: []sbom.ExternalRef{{Type: "purl", Locator: "pkg:npm/lodash@4.17.21"}},
})

sbom.Encode(os.Stdout, s, sbom.FormatCycloneDXJSON)
sbom.Encode(os.Stdout, s, sbom.FormatSPDXJSON)

Relationships

Both parsers normalise dependency edges to sbom.RelDependsOn, SPDX DEPENDENCY_OF edges included with their direction reversed, and SPDX's document-to-root link to sbom.RelDescribes. ClassifyScope walks that graph to tag each package as a direct or transitive dependency of the document's root:

doc, _ := sbom.Parse(data)
scope := doc.ClassifyScope()
for _, p := range doc.Packages {
    switch scope[p.ID] {
    case sbom.ScopeDirect:
    case sbom.ScopeTransitive:
    default:
        // root, or a flat-list SBOM with no dependency graph
    }
}

ClassifyScope returns nil when the document has no dependency graph at all, so callers can hide a scope filter rather than showing every package as unclassified.

When the document declares its root, through an SPDX DESCRIBES edge or the CycloneDX metadata.component bom-ref (kept as Document.Component.ID), and that root has dependency edges, it is the only root. Otherwise roots are inferred as the nodes that have dependencies but no dependents. That is the project for lock files that list it, such as package-lock.json, Cargo.lock and uv.lock. Lock files such as poetry.lock list only dependencies, so the inferred roots would be the top-level dependencies. For packages syft records as coming from poetry.lock, pdm.lock, pnpm-lock.yaml or a yarn v1 yarn.lock (the syft:location:0:path property in CycloneDX, PackageSourceInfo in SPDX), those are classified as direct instead, as is any package from such a lock file that no edge names, such as a dependency without dependencies of its own, as long as the lock file has edges at all. Other generators' lock-file SBOMs that don't link the project may still have top-level dependencies inferred as roots.

Roots returns the root IDs and whether the document declared all of them, so a caller can tell the project apart from an inferred root:

ids, declared := doc.Roots()

An inferred root isn't always the project: in an image, packages nothing depends on, such as alpine-baselayout, are roots too. ProjectRoots returns only the roots that are the project: the declared ones, and inferred roots syft records as coming from a lock file that lists the project (package-lock.json, npm-shrinkwrap.json, Cargo.lock, uv.lock or a yarn berry yarn.lock):

project := doc.ProjectRoots()

Ancestors

Package.Ancestors records the components a package was derived from, such as the upstream project of a fork. CycloneDX JSON and XML read and write these as pedigree.ancestors:

s.AddPackage(sbom.Package{
    Name: "@plotly/d3", Version: "3.8.2",
    ExternalRefs: []sbom.ExternalRef{{Type: "purl", Locator: "pkg:npm/%40plotly/d3@3.8.2"}},
    Ancestors: []sbom.Ancestor{{Name: "d3/d3", PURL: "pkg:github/d3/d3"}},
})

Leave Version empty when only the upstream repository is known. Ancestors are provenance, not installed packages: they never appear in Packages or create relationships. They are written without a bom-ref. SPDX output does not include them.

Supported formats

Format Parse Encode Notes
CycloneDX JSON yes yes nested components flattened, license.id / license.name / expression all read
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

SPDX file, snippet, annotation, and extracted-license records are not included in the parsed model. YAML parsing is not supported.

Why not protobom?

protobom is the OpenSSF normaliser and covers every serialisation. It's pure Go (no cgo) but importing pkg/reader adds about 21 indirect modules including google.golang.org/protobuf, sigs.k8s.io/release-utils, logrus, tablewriter, and a handful of terminal-colour libraries that have no business in a parser. This module has zero dependencies and the field mappings were already worked out in the Ruby gem, so for tools that just need the package list it's a smaller surface to audit. If you need full spec coverage, use protobom.

Benchmarks

go test -bench . -benchmem

On an M1 Pro, Parse runs at roughly 100-165 MB/s; the 3 MB syft-generated nginx.spdx.json (151 packages) parses in about 18 ms. Detect is sub-microsecond on the same file.

License

MIT.

Documentation

Overview

Package sbom parses Software Bill of Materials documents into a unified data model. It reads CycloneDX JSON/XML and SPDX JSON/tag-value and normalises both into the same Document/Package/Relationship shape so callers don't need to care which format they were handed.

The model and field mappings are a port of github.com/andrew/sbom (Ruby).

doc, err := sbom.Parse(data)
for _, p := range doc.Packages {
    fmt.Println(p.Name, p.Version, p.PURL())
}

Index

Constants

View Source
const (
	SupplierOrganization = "Organization"
	SupplierPerson       = "Person"
)

Supplier/originator type values, shared across both formats.

View Source
const (
	RelDependsOn = "DEPENDS_ON"
	RelDescribes = "DESCRIBES"
)

Relationship type values. SPDX has ~40 of these; we name the two the parsers emit and ClassifyScope reads. CycloneDX dependency edges and SPDX DEPENDENCY_OF edges are normalised to RelDependsOn.

View Source
const (
	ScopeDirect     = "direct"
	ScopeTransitive = "transitive"
)

Scope values returned by ClassifyScope.

Variables

View Source
var ErrUnrecognized = errors.New("sbom: unrecognized format")

ErrUnrecognized is returned when the input does not look like any supported SBOM format.

Functions

func Encode

func Encode(w io.Writer, s *SBOM, f Format) error

Encode writes s to w in the requested Format. Document fields left empty are filled with spec-mandated defaults (timestamps, NOASSERTION, etc.).

Types

type Ancestor added in v0.1.8

type Ancestor struct {
	Type    string
	Name    string
	Version string
	PURL    string
}

Ancestor is a component a package was derived from, such as the upstream project of a fork (CycloneDX pedigree.ancestors). Ancestors describe provenance only: they are not installed packages and never produce relationships. Version is optional because an ancestor may be known only as a repository. CycloneDX requires Name; Type defaults to library.

type Checksum

type Checksum struct {
	Algorithm string
	Value     string
}

Checksum is a single hash over a package artefact.

type Component

type Component struct {
	ID                string
	Type              string
	Name              string
	Version           string
	LicenseExpression string
	LicenseNames      []string
	ExtractedLicenses []ExtractedLicense
}

Component is the root subject the SBOM describes (CycloneDX metadata.component / SPDX root package). ID is its CycloneDX bom-ref, the identifier dependency edges from the root use; it is empty when the document gives none.

type Creator

type Creator struct {
	Type string
	Name string
}

Creator is a tool, person, or organization that produced the document.

type Document

type Document struct {
	Name        string
	ID          string
	Type        Type
	SpecVersion string
	DataLicense string
	Namespace   string
	Created     string
	Supplier    string
	Creators    []Creator
	Component   Component
}

Document holds metadata about the SBOM document itself, distinct from the packages it describes.

type ExternalRef

type ExternalRef struct {
	Category string
	Type     string
	Locator  string
}

ExternalRef is a typed pointer to an external resource. PURLs and CPEs are stored here in both formats.

type ExtractedLicense added in v0.1.4

type ExtractedLicense struct {
	ID   string
	Name string
	Text string
}

ExtractedLicense is non-SPDX license text associated with a component. ID is optional. Encode prefixes a supplied ID with LicenseRef- when needed, or generates a stable LicenseRef when ID is empty.

type Format

type Format int

Format selects an output serialisation for Encode.

const (
	FormatCycloneDXJSON Format = iota
	FormatCycloneDXXML
	FormatSPDXJSON
)

type Package

type Package struct {
	ID               string
	Name             string
	Version          string
	Type             string
	Description      string
	Supplier         string
	SupplierType     string
	Originator       string
	OriginatorType   string
	Homepage         string
	DownloadLocation string
	Filename         string
	// SourceInfo is SPDX's free-text note on where the package information
	// came from (PackageSourceInfo).
	SourceInfo       string
	LicenseConcluded string
	LicenseDeclared  string
	Copyright        string
	Checksums        []Checksum
	ExternalRefs     []ExternalRef
	Properties       []Property
	Ancestors        []Ancestor
}

Package is a single component/package entry from the SBOM, regardless of source format.

func (*Package) CPE

func (p *Package) CPE() string

CPE returns the first CPE identifier for this package, if any.

func (*Package) PURL

func (p *Package) PURL() string

PURL returns the Package URL for this package, if one was declared. In CycloneDX this is the top-level purl field; in SPDX it lives in externalRefs with referenceType "purl". Both are normalised into ExternalRefs so this is a simple lookup.

type Property

type Property struct {
	Name  string
	Value string
}

Property is an arbitrary name/value annotation on a package.

type Relationship

type Relationship struct {
	SourceID string
	Source   string
	TargetID string
	Target   string
	Type     string
}

Relationship links two elements in the SBOM. SourceID/TargetID are the raw identifiers from the document; Source/Target are resolved names where the parser could determine them.

type SBOM

type SBOM struct {
	Type          Type
	SpecVersion   string
	Document      Document
	Packages      []Package
	Relationships []Relationship
	// contains filtered or unexported fields
}

SBOM is the unified parse result.

func New

func New(t Type) *SBOM

New returns an empty SBOM ready for AddPackage calls. Use this when building a document for Encode rather than parsing one.

func Parse

func Parse(data []byte) (*SBOM, error)

Parse reads CycloneDX JSON/XML or SPDX JSON/tag-value, detecting the format.

func (*SBOM) AddPackage

func (s *SBOM) AddPackage(p Package)

AddPackage appends p, replacing any existing package with the same name, version, document ID, and set of PURLs.

func (*SBOM) ClassifyScope added in v0.1.3

func (s *SBOM) ClassifyScope() map[string]string

ClassifyScope derives direct-vs-transitive scope for each package from the relationship graph. A package is ScopeDirect if a root depends on it, ScopeTransitive if only a non-root does, and absent from the map if the graph doesn't mention it at all.

When the document declares its root, as the target of a DESCRIBES edge (SPDX's document-to-root-package link) or as Document.Component.ID (the CycloneDX metadata.component bom-ref), and that root has DEPENDS_ON edges, the declared roots are the only roots. Otherwise roots are inferred: nodes that originate DEPENDS_ON edges but are never a DEPENDS_ON target, plus any declared root. An inferred root is not necessarily the project: for a lock file that doesn't list the project itself the top-level dependencies are inferred as roots, except for packages syft records as coming from such a lock file (poetry.lock, pdm.lock, pnpm-lock.yaml and yarn v1's yarn.lock): those are ScopeDirect themselves, and what they depend on is transitive. A package from such a lock file that no edge names is ScopeDirect too, when other packages of the lock file have edges.

Returns nil for a flat-list SBOM with no dependency graph so callers can tell "no graph" apart from "graph present but this package is a root".

func (*SBOM) FilterProperties added in v0.1.2

func (s *SBOM) FilterProperties(keep func(name string) bool)

FilterProperties walks every package and removes properties whose names keep returns false for. Useful when handing a document to a downstream consumer that doesn't recognise a particular property namespace — for example, strip a tool-specific "mytool:" prefix before sharing outside the team that produced it. The filter mutates the SBOM in place; callers wanting a copy should Encode + Parse around the call.

Document- and Component-level metadata is untouched; only the per-package Properties slice is filtered.

func (*SBOM) ProjectRoots added in v0.1.9

func (s *SBOM) ProjectRoots() []string

ProjectRoots returns the IDs of the roots that are the project rather than a top of the graph: the declared roots, and inferred roots read from a lock file that lists the project (see projectLockFiles). Packages of an image that nothing depends on are inferred roots, but not the project. It returns nil for a flat-list SBOM.

func (*SBOM) Roots added in v0.1.8

func (s *SBOM) Roots() (ids []string, declared bool)

Roots returns the IDs ClassifyScope treats as roots, and whether all of them are declared by the document rather than inferred from the graph. Declared roots are the project; inferred ones may be top-level dependencies of a lock file that leaves the project out. It returns nil for a flat-list SBOM.

type Type

type Type string

Type identifies the source SBOM specification.

const (
	TypeUnknown   Type = ""
	TypeCycloneDX Type = "cyclonedx"
	TypeSPDX      Type = "spdx"
)

func Detect

func Detect(data []byte) Type

Detect inspects content and returns the SBOM Type without fully parsing it. Returns TypeUnknown if the format can't be determined.

It scans JSON keys, the XML root, or the tag-value document header.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL