sbom

package
v0.14.8 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package sbom is the SBOM codec: it projects a dependency graph into an SPDX 2.3 or CycloneDX document and reads such a document back into a graph.

It owns the document model (Document, Component, Dependency and the assertions they carry), the two format codecs, the strict preflight that refuses a document without one unambiguous reading (bomly-cli ADR-0039), and the projection in both directions: FromDepGraph and FromGraphEntries build a Document from a graph, ToGraph builds a graph from a Document, and MarshalJSON / UnmarshalJSON move a Document to and from bytes.

This package is the one home for that behaviour (bomly-cli ADR-0045). It was the CLI's internal/sbom, copied into two plugin repositories and drifted there; the plugins and then the CLI adopt this package from the release that carries it and delete their copies. What a document may name of a graph -- the questions every renderer and exporter asks of a node -- is the sibling package graphview.

Index

Constants

View Source
const MaxDocumentBytes = 256 << 20

MaxDocumentBytes bounds what any ingest entry point will read. The strict preflight and the codecs do work proportional to the input, and the CLI bounds a file at exactly this size before it reads it; a consumer of this package handing over bytes it obtained some other way gets the same bound here rather than none. A dumb byte count, on purpose: the bound is a resource limit, not a statement about what a document may contain.

Variables

View Source
var (
	ErrNilDocument       = errors.New("sbom document is nil")
	ErrUnsupportedTarget = errors.New("unsupported sbom target")
	ErrUnsupportedFormat = errors.New("unsupported sbom format")
	ErrMalformedJSON     = errors.New("malformed sbom json")
	// ErrDocumentTooLarge reports an input over MaxDocumentBytes, refused
	// before any of it is parsed.
	ErrDocumentTooLarge = errors.New("sbom document exceeds the size bound")

	// ErrSyftJSONUnsupported reports that the input is a syft-format JSON SBOM,
	// which Bomly does not ingest. Detection is kept so callers can point the
	// user at the conversion path instead of a generic format error.
	ErrSyftJSONUnsupported = errors.New("syft JSON SBOMs are not supported; convert with: syft convert <file> -o spdx-json")
)
View Source
var ErrAmbiguousJSON = errors.New("ambiguous sbom json")

ErrAmbiguousJSON reports a document whose JSON does not have one unambiguous reading: it repeats an object member name, it contains bytes that are not valid UTF-8, or it escapes an unpaired UTF-16 surrogate, which names no character. The wrapped error says which, and where.

View Source
var ErrNilGraph = errors.New("dependency graph is nil")
View Source
var ErrUnverifiableJSON = errors.New("unverifiable sbom json")

ErrUnverifiableJSON reports a document Bomly declines to validate because checking it would cost more memory than the check is worth.

Functions

func ComponentEcosystem

func ComponentEcosystem(component Component) model.Ecosystem

ComponentEcosystem resolves the ecosystem a document component belongs to: the component's own ecosystem field when the SDK recognizes it, and otherwise the ecosystem named by its package URL's type.

It lives here, beside the other ingest identity rules, because the question is about Component -- this package's own type -- and because it used to be answered in more than one place. ToGraph resolved it inline, and the CLI's benchmark kept a twelve-row purl-type switch of its own that had already drifted away from the SDK's table: it answered Elixir for pkg:hex, which purlkit refuses precisely because Hex serves Elixir and Erlang alike and nothing in the PURL says which; and it had never learned hackage, cran, opam, deb, or otp, so Haskell, R, OCaml, Debian, and OTP components came back unknown. That is what a second table does -- it is correct the day it is written and quietly wrong afterwards.

The purl-type half of the answer is model.EcosystemForPURLType, the one table every consumer reads; this function adds only the component's own ecosystem hint on top of it.

func DocumentAssertionsFor

func DocumentAssertionsFor(doc *Document) *model.DocumentAssertions

DocumentAssertionsFor returns the record of what a source document asserted about itself, for attaching to the graph entry that document became.

It is never nil for a document that was read, even one that asserted nothing. The presence of the record is itself a fact -- that this entry came from an SBOM -- and a merged export needs it: CycloneDX permits a document with neither a serial number nor metadata, and treating that as "no source" made a merge of two documents look like a conversion of one, publishing the merged inventory under the other document's identity.

Exported so the detector and this package agree by construction rather than by both remembering the same rule.

func IsProjectRootComponent

func IsProjectRootComponent(c Component) bool

IsProjectRootComponent reports whether a component is a synthesized pseudo root that stands in for the scanned project rather than a resolved package.

func MarshalDepGraphJSON

func MarshalDepGraphJSON(g *model.Graph, target Target, buildOpts BuildOptions, encodeOpts EncodeOptions) ([]byte, error)

MarshalDepGraphJSON converts a dependency graph directly into a target JSON SBOM.

For a graph built from ingested SBOMs, prefer MarshalGraphEntriesJSON: see FromDepGraph for what this entry point cannot see.

func MarshalGraphEntriesJSON

func MarshalGraphEntriesJSON(g *model.Graph, entries []model.GraphEntry, target Target, buildOpts BuildOptions, encodeOpts EncodeOptions) ([]byte, error)

MarshalGraphEntriesJSON converts the prepared graph entries and the graph they consolidated into directly into a target JSON SBOM.

Each call builds a new document with its own identity. To write one scan in several formats, build once with FromGraphEntries and call MarshalJSON per target, so every encoding carries the same UUID (see BuildOptions).

func MarshalJSON

func MarshalJSON(doc *Document, target Target, opts EncodeOptions) ([]byte, error)

MarshalJSON renders the intermediate SBOM document to a target JSON format.

func ToGraph

func ToGraph(doc *Document) (*model.Graph, error)

ToGraph converts a neutral SBOM document back into a dependency graph.

func ToGraphEntry added in v0.14.0

func ToGraphEntry(doc *Document, manifest model.ManifestMetadata) (model.GraphEntry, error)

ToGraphEntry converts a document into one manifest-scoped graph entry: the graph ToGraph builds, the document's own assertions, and -- carried in Packages, where consolidation folds them into the registry -- what the document asserted about its packages that a graph node cannot hold: the advisories it listed for a component, with their VEX analysis, and a component's end-of-life record. Before this, both were read from the document and dropped at this hop, so a document that said a package was vulnerable, or not affected, produced a graph that said neither.

Only components that carried one or the other get a package; the rest are what the graph already says. Two components that mint one identity fold into one package under the registry's merge classes. The slice is ordered by package URL.

func UnmarshalAutoJSON

func UnmarshalAutoJSON(data []byte) (*Document, Target, error)

UnmarshalAutoJSON parses a supported SBOM JSON payload without requiring the caller to preselect a target.

Types

type BuildOptions

type BuildOptions struct {
	DocumentName string
	// DocumentNS pins the SPDX documentNamespace. Empty mints one in Bomly's
	// form, "https://bomly.dev/spdx/<uuid>", sharing the serial's UUID.
	DocumentNS      string
	ToolName        string
	ToolNames       []string
	ToolVersion     string
	Created         time.Time
	RootComponentID string
	// SerialNumber pins the CycloneDX serialNumber, an urn:uuid. Empty mints
	// one sharing the namespace's UUID.
	SerialNumber string

	// ProjectRoot, when non-nil, lets the projection synthesize a primary
	// component for the scanned project when the graph has no single root.
	ProjectRoot *ProjectRoot

	Provenance Provenance

	// Lifecycle is the CycloneDX lifecycle phase the document describes
	// (for example "pre-build" for source scans, "post-build" for container
	// images). Empty omits lifecycle metadata.
	Lifecycle string

	// Aggregate is the CycloneDX composition completeness declaration
	// ("complete", "incomplete", ...). Empty omits the declaration; callers
	// must only claim "complete" when nothing filtered or degraded the graph.
	Aggregate string

	// RestatesSource is true only when the export is a faithful restatement
	// of its single source document: nothing filtered, enriched, or degraded
	// the graph between ingest and export. Only then does a conversion adopt
	// the source's identity, creation time, name and comment (ADR-0042). The
	// default, false, is the safe side: the document mints its own identity
	// and links the source. Ignored with zero or several sources, and
	// overridden when a natively resolved entry sits beside the document:
	// that graph is a merge whatever the caller says.
	RestatesSource bool

	// Registry, when non-nil, supplies matching-stage enrichment (licenses,
	// vulnerabilities, CPEs, digests, EOL) resolved by PURL and folded onto
	// each component during projection.
	Registry *model.PackageRegistry
}

BuildOptions controls how a depgraph is projected into the intermediate SBOM model.

One build is one document. The identity slots left empty here are filled from a single UUID, so the SPDX namespace and the CycloneDX serial of the resulting Document name the same document; pinning either slot derives the other from it when it carries a UUID. A scan exported in two formats is therefore built once and encoded twice -- FromGraphEntries, then MarshalJSON per target. Marshalling the graph once per target builds twice and mints two identities (issue #109).

type Component

type Component struct {
	ID   string
	Name string

	// Org is the package namespace -- an npm scope, a Go module host and
	// owner, a Maven group. It is the PURL's namespace segment, emitted as
	// CycloneDX `group`. SPDX 2.3 has no equivalent field, so there it is
	// carried only inside the PURL external reference.
	Org string

	Version string

	// Scopes is the full scope set, not a scalar. A package reachable from
	// both a runtime and a development root carries both, and exporting only
	// the merged precedence value lost that at the SBOM boundary -- which is
	// the whole of what PR #406's scope union bought.
	//
	// Both formats hold one scope per component, so each encoder projects
	// this onto its own scalar and writes the set beside it in a carrier the
	// decoder prefers: a `bomly:scopes` CycloneDX property, a `scope=` field
	// in the SPDX package comment. The projection, the carrier format, and
	// the reverse mapping are all the SDK's (ADR-0037): they are one rule,
	// and a second copy here is how the forward and reverse directions came
	// to disagree in the first place.
	//
	// A source document's own scalar rides beside the set in SourceScope, so
	// the derived set is never the only record of what the document said.
	Scopes []model.Scope

	// SourceScope is the scope word the source document used, in that
	// document's own vocabulary -- CycloneDX's "optional", say. It is a
	// preserved claim and never an input to filtering: Scopes is what the
	// pipeline reads, and the SDK derives it from this.
	//
	// ADR-0037 asks that the word be re-emitted verbatim unless Bomly's own
	// scope set changed, so "optional" and "excluded" do not collapse into
	// "required" across a round trip that asserted neither. Whether the word
	// still means what the set means is model.CycloneDXScopeForExport's
	// decision, not this package's -- the mapping that read the word in is
	// the only thing that can say whether it still describes the set, and a
	// second copy of it here is how the two directions came to disagree
	// before.
	//
	// Only CycloneDX has a scalar to hold it. SPDX 2.3 has no scope field at
	// all, so an SPDX export carries the set in its comment carrier and the
	// source word stops there.
	SourceScope string

	PURL           string
	Ecosystem      string
	PackageManager string
	Type           string
	Copyright      string
	Licenses       []License

	// Matching-stage enrichment (populated when BuildOptions.Registry is set).
	CPEs            []string
	Digests         []Digest
	Vulnerabilities []Vulnerability
	EOL             *EOL

	// Where the package came from. A detector asserts at most one of
	// ArtifactURL and VCSURL; registry enrichment may then fill VCSURL from a
	// resolved source repository when it is empty, so a component enriched
	// under --enrich can carry both. VCSRevision only accompanies VCSURL.
	// Both are plain absolute http(s) URLs; composing them into a format's
	// locator grammar is the encoder's job.
	ArtifactURL string
	VCSURL      string
	VCSRevision string

	// What the source document asserted about this component, beyond its
	// identity (ADR-0037, issue #396).
	//
	// These carry a foreign document's own claims through the graph so a
	// conversion does not silently drop them: `bomly scan --sbom --format
	// spdx` used to lose the supplier, description, checksums, CPEs and
	// references its input stated, because the only things surviving the
	// graph hop were coordinates, scope, copyright and licenses.
	//
	// They are SDK types rather than local structs on purpose. Each carries
	// its own publication gate -- Contact.Normalized, ExternalReference
	// .Normalized -- and every value crossing this boundary re-clears it,
	// because an ingested document is untrusted input that Bomly re-emits.
	// A local mirror of these shapes would be a second place for those rules
	// to be forgotten, which is the defect ADR-0037 replaced.
	Supplier    *model.Contact
	Originator  *model.Contact
	Description string
	Homepage    string

	// ExternalReferences are the document's own references, kept with the
	// category and type it stated so the SPDX triple round-trips without
	// being re-derived. Merge class: set, unioned by the reference's own
	// identity.
	ExternalReferences []model.ExternalReference

	// Every place this package was resolved from, primary first.
	//
	// ADR-0041 folds equal-identity records into one node and keeps their
	// disagreement as a list of origins -- a package resolved from two
	// different registries or mirrors is exactly the dependency-confusion
	// signal that fold exists to preserve. The single-valued fields above
	// carry the first, because a format has one download location; this
	// carries all of them, so the evidence survives the export boundary
	// rather than stopping at it.
	Origins []ComponentOrigin
}

Component describes one package surfaced in the intermediate SBOM model.

func (Component) NameOrID

func (c Component) NameOrID() string

NameOrID returns the component name when present, otherwise its stable ID.

type ComponentOrigin

type ComponentOrigin struct {
	ArtifactURL string
	Repository  string
	Revision    string
}

ComponentOrigin is one place a package was resolved from, already through the ADR-0033 publication gates.

type Dependency

type Dependency struct {
	Ref       string
	DependsOn []string
}

Dependency describes one package relationship list in the intermediate SBOM model.

type Digest

type Digest struct {
	Algorithm string
	Value     string
}

Digest is a content digest (algorithm + hex value) carried on a component.

type Document

type Document struct {
	Name         string
	Namespace    string
	Tool         string
	Tools        []string
	ToolVersion  string
	Created      time.Time
	SerialNumber string
	// SerialVersion is the CycloneDX document revision that goes with
	// SerialNumber. A serial names a BOM; the revision names which issue of
	// it, so adopting a source's serial without its revision produced a
	// document claiming to be revision 1 of a BOM whose revision 2 it had
	// actually converted. Zero means unset and encodes as 1.
	SerialVersion int
	Provenance    Provenance
	Lifecycle     string
	Aggregate     string

	Components   []Component
	Dependencies []Dependency
	Roots        []string

	// Described are the components the document names as its subject, as
	// decoded: CycloneDX metadata.component, the targets of SPDX DESCRIBES
	// and DESCRIBED_BY. Roots are the graph's entry points and can include a
	// component the document never called its subject -- a disconnected
	// package, say -- so the two are kept apart. Empty for a document
	// projected from a graph, whose subject is its single root.
	Described []string

	// Assertions are the claims this document makes about itself: its
	// identity, name, data license, creators, tools, and comment. A decoder
	// fills them; ingest carries them onto the graph entry the document
	// became, so a later export can say what the source said (ADR-0037).
	//
	// This is what a document says about *itself*, which is why it is not the
	// same field as Sources below.
	Assertions model.DocumentAssertions

	// UnknownScopeTokens are the scope-carrier tokens an ingested document
	// named that this build does not recognize, deduplicated and sorted.
	//
	// The carrier is Bomly's own, so an unreadable token is most likely one a
	// newer Bomly wrote. The scopes beside it are still kept -- that is the
	// point of the SDK's lenient read -- but the fact that something was not
	// read is worth telling a user, and this package has no logger of its
	// own. The SBOM detector is where it becomes a warning.
	//
	// Empty for a document Bomly wrote and for a document with no carrier at
	// all.
	UnknownScopeTokens []string

	// Sources are the documents this one was built from, one per ingested
	// SBOM, in entry order.
	//
	// Empty for a native scan: nothing was ingested, so Bomly's own document
	// asserts everything itself. One source is the conversion case, where the
	// document restates that source's assertions -- the fixed point issue
	// #396 requires. Two or more is the merge case, where the document
	// asserts its own aggregate identity and *links* each source rather than
	// inheriting one, because both formats give a document exactly one
	// identity and picking a source's would name a document that is not this
	// one.
	Sources []model.DocumentAssertions
}

Document is an intermediate, format-agnostic SBOM representation.

func FromDepGraph

func FromDepGraph(g *model.Graph, opts BuildOptions) (*Document, error)

FromDepGraph builds a neutral SBOM document from a dependency DAG.

For a graph that came from ingested SBOMs, prefer FromGraphEntries: this entry point has no way to see what those documents said about themselves, and so exports a document that credits only Bomly.

func FromGraphEntries

func FromGraphEntries(g *model.Graph, entries []model.GraphEntry, opts BuildOptions) (*Document, error)

FromGraphEntries builds a neutral SBOM document from the prepared graph entries and the consolidated graph they produced.

Both are passed, and neither is derived from the other. The graph is the one already selected for output -- consolidation renamed its identities and the scope filter decided what stays -- so re-merging the entries here would export a different graph than the rest of the command reports. The entries are here for the one thing only they carry: what each source document asserted about itself, which the merge into a single graph necessarily discards (ADR-0037).

func UnmarshalJSON

func UnmarshalJSON(data []byte, target Target) (*Document, error)

UnmarshalJSON parses a target JSON SBOM into the intermediate document model.

The document is checked for an unambiguous reading before any codec sees it (ADR-0039). This is the one gate every ingest path shares, so a format added later inherits it rather than having to remember it.

func (*Document) CreatedOrNow

func (d *Document) CreatedOrNow() time.Time

CreatedOrNow returns the document timestamp in UTC, defaulting to the current time.

func (*Document) NameOrDefault

func (d *Document) NameOrDefault() string

NameOrDefault returns the document name or Bomly's default document name.

func (*Document) NamespaceOrDefault

func (d *Document) NamespaceOrDefault() string

NamespaceOrDefault returns the document namespace or a generated Bomly namespace.

func (*Document) SerialVersionOrDefault

func (d *Document) SerialVersionOrDefault() int

SerialVersionOrDefault returns the CycloneDX document revision, defaulting to the first revision. CycloneDX numbers a document from 1, and a BOM-Link has to name a revision, so there is no "unset" to write.

func (*Document) ToolNamesOrDefault

func (d *Document) ToolNamesOrDefault() []string

ToolNamesOrDefault returns all producing tool labels, defaulting to Bomly's tool label.

func (*Document) ToolOrDefault

func (d *Document) ToolOrDefault() string

ToolOrDefault returns the producing tool name or Bomly's default tool label.

type EOL

type EOL struct {
	EOL           bool
	EOLDate       string
	Cycle         string
	LatestVersion string
}

EOL is a format-agnostic projection of end-of-life enrichment for a component.

type EncodeOptions

type EncodeOptions struct {
	Pretty bool
}

EncodeOptions controls JSON output formatting.

type License

type License struct {
	Value          string
	SPDXExpression string
	Type           string
}

License describes normalized license details captured from an SBOM component.

type ProjectRoot

type ProjectRoot struct {
	Name    string
	Version string
}

ProjectRoot describes the scanned project so the projection can synthesize a primary component when the graph itself has no single root (multiple manifests, multiple ecosystems). Name is required; Version is optional.

type Provenance

type Provenance struct {
	Manufacturer               string
	SecurityContact            string
	VulnerabilityDisclosureURL string
	SupportEnd                 string
}

Provenance carries optional producer metadata emitted into SBOM documents. The fields map onto EU CRA transparency expectations (manufacturer identification, security contact, coordinated disclosure, support period) but are format-agnostic and always optional.

func (Provenance) Empty

func (p Provenance) Empty() bool

Empty reports whether no provenance field is set.

type Target

type Target string

Target identifies an SBOM wire format target.

const (
	TargetSPDX23JSON      Target = "spdx-2.3+json"
	TargetCycloneDX14JSON Target = "cyclonedx-1.4+json"
	TargetCycloneDX15JSON Target = "cyclonedx-1.5+json"
	TargetCycloneDX16JSON Target = "cyclonedx-1.6+json"
	TargetCycloneDX17JSON Target = "cyclonedx-1.7+json"
	TargetSyftJSON        Target = "syft+json" // recognized on ingest so the refusal can name the conversion path; never decoded, no codec

)

func DetectJSONTarget

func DetectJSONTarget(data []byte) (Target, error)

DetectJSONTarget identifies the supported SBOM JSON format represented by data.

type Vulnerability

type Vulnerability struct {
	ID            string
	Source        string
	Severity      string
	Score         *float64
	Vector        string
	Method        string
	CWEs          []int
	FixedVersions []string
	Advisories    []string
	Description   string

	// Recommendation is remediation guidance derived from known fixed
	// versions (CycloneDX `recommendation`; SPDX 2.3 has no equivalent).
	Recommendation string

	// Analysis is the VEX impact analysis the source attached to this
	// advisory for this component (CycloneDX `analysis`; SPDX 2.3 has no
	// equivalent). The SDK type is used directly, as the other assertions
	// are (ADR-0037): a projection would have to be re-gated on both sides.
	Analysis *model.VulnerabilityAnalysis
}

Vulnerability is a format-agnostic projection of one matching-stage advisory affecting a component. Encoders map it to the format's native representation (CycloneDX vulnerabilities, SPDX SECURITY external references).

Jump to

Keyboard shortcuts

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