Skip to contents

A software bill of materials, or SBOM, is the ingredients label on a piece of software: every component, its version, who supplies it, its license and what depends on what. Security teams want one so that when the next advisory lands they can tell in minutes, not days, whether it concerns them. Compliance teams want one so that “what exactly did we run?” has a written answer.

sbom writes that label for R. It reads the DESCRIPTION files already on disk, along with lockfiles, deployment manifests and Nix files, and runs no package code. It touches the network only when you ask it to. This page builds one SBOM, looks at it from a few angles and writes it out in every format.

Build one

The nearest package to hand is sbom itself. sbom_package() takes a package folder, a DESCRIPTION file or a source tarball.

x <- sbom_package(
  system.file("DESCRIPTION", package = "sbom"),
  system = FALSE,
  timestamp = as.POSIXct("2026-01-01", tz = "UTC")
)
x
#> <sbom> sbom 0.1.0
#> Components: 19
#> Relationships: 28
#> Issues: 3 (see sbom_issues())
#> Advisories: not requested.

Two arguments are there for this page’s sake. system = FALSE skips the search for system libraries, which runs otool or ldd and would make the output depend on the machine that built the page; the projects guide explains it. timestamp pins the creation time, so the document comes out the same on every build. Leave it out and sbom uses the current time, or SOURCE_DATE_EPOCH when that is set.

sbom follows the Depends, Imports and LinkingTo fields down through every package they reach, and lists the root’s own Suggests without following them further. Each dependency is looked up in your library, so the SBOM records the versions you actually have installed. A dependency that is not installed still gets a line, with an unknown version and an issue saying so.

Inspect it

summary() counts what is there. Evidence says how sbom knows each thing: declared means a package says so, observed means sbom read it from what is installed, and inferred means a rule matched.

A crate stands for a package. Inside: its dependencies and the code compiled in through LinkingTo, both declared; C or C++ copied into src/ and JavaScript or CSS bundled in inst/, both inferred because a rule matched. Under it, the system libraries it needs: declared through SystemRequirements, and observed in what the binary links.

summary(x)
#> sbom 0.1.0
#> Advisories: not requested.
#> 
#> ── Components ──
#> 
#> ecosystem: repository (15), base-r (2), local (1), r (1)
#> type: library (18), platform (1)
#> evidence: observed (18), declared (1)
#> Unknown version or license: 2
#> 
#> ── Licenses ──
#> 
#> MIT (12), GPL-2.0-only OR GPL-3.0-only (3), NOASSERTION (2), BSD-3-Clause (1),
#> GPL-3.0-only (1)
#> 
#> ── Drift ──
#> 
#> No drift.

sbom_tree() draws the dependency tree. max_depth = 1 keeps it to the packages sbom names directly; the full tree repeats itself the way family trees do.

sbom_tree(x, max_depth = 1)
#> sbom 0.1.0
#> ├─R 4.6.2
#> ├─cli 3.6.6
#> ├─curl 8.1.0 (suggested)
#> ├─htmltools 0.5.9 (suggested)
#> ├─jsonlite 2.0.0
#> ├─jsonvalidate 1.5.0 (suggested)
#> ├─knitr 1.52 (suggested)
#> ├─pak 0.11.2 (suggested)
#> ├─quarto 1.5.1 (suggested)
#> ├─renv 1.3.1
#> ├─rmarkdown 2.32 (suggested)
#> ├─testthat 3.3.2 (suggested)
#> ├─utils 4.6.2
#> ├─withr 3.0.3 (suggested)
#> ├─xml2 1.6.0
#> └─yaml 2.3.12 (suggested)

The components themselves are a data frame, one row each, with more columns than fit on a page: package URL (purl), supplier, maintainer, the license as written, homepage and so on.

One component, cli 3.6.5: its package URL pkg:cran/cli@3.6.5, its license MIT, translated from MIT + file LICENSE as written, its supplier CRAN, its maintainer, its scope and its evidence, declared.

Here is a slice of the table.

comps <- sbom_components(x)
knitr::kable(comps[, c("name", "version", "ecosystem", "scope", "license_spdx", "evidence")])
name version ecosystem scope license_spdx evidence
R 4.6.2 r required GPL-2.0-only OR GPL-3.0-only observed
methods 4.6.2 base-r required NA observed
utils 4.6.2 base-r required NA observed
sbom 0.1.0 local required MIT declared
cli 3.6.6 repository required MIT observed
curl 8.1.0 repository optional MIT observed
htmltools 0.5.9 repository optional GPL-2.0-only OR GPL-3.0-only observed
jsonlite 2.0.0 repository required MIT observed
jsonvalidate 1.5.0 repository optional MIT observed
knitr 1.52 repository optional GPL-2.0-only OR GPL-3.0-only observed
pak 0.11.2 repository optional GPL-3.0-only observed
quarto 1.5.1 repository optional MIT observed
renv 1.3.1 repository required MIT observed
rlang 1.3.0 repository required MIT observed
rmarkdown 2.32 repository optional MIT observed
testthat 3.3.2 repository optional MIT observed
withr 3.0.3 repository optional MIT observed
xml2 1.6.0 repository required MIT observed
yaml 2.3.12 repository optional BSD-3-Clause observed

Licenses are translated from R’s License field into SPDX expressions with license_spdx(), which uses R’s own license database rather than guesswork. Base packages show no SPDX license because they are part of R.

The relationships between them are in sbom_dependencies(), one row per edge:

head(sbom_dependencies(x))
#>         from
#> 1          R
#> 2          R
#> 3 local/sbom
#> 4 local/sbom
#> 5 local/sbom
#> 6 local/sbom
#>                                                                                                  to
#> 1                                                                                         R/methods
#> 2                                                                                           R/utils
#> 3                                                                                                 R
#> 4                                                                                           R/utils
#> 5  pkg:cran/cli@3.6.6?repository_url=https://packagemanager.posit.co/cran/__linux__/resolute/latest
#> 6 pkg:cran/curl@8.1.0?repository_url=https://packagemanager.posit.co/cran/__linux__/resolute/latest
#>      field           spec evidence
#> 1 contains           <NA> inferred
#> 2 contains           <NA> inferred
#> 3  Depends   R (>= 4.1.0) declared
#> 4  Imports          utils declared
#> 5  Imports cli (>= 3.6.0) declared
#> 6 Suggests           curl declared

Last, and most useful when someone else will read the file, the issues:

knitr::kable(sbom_issues(x)[, c("severity", "code", "message")])
severity code message
warning supplier-unknown sbom 0.1.0 was installed from a local source, so it has no supplier on record.
info compiled-code-limited 10 packages contain compiled code, but sbom recognized no bundled library in their COPYRIGHTS file or cph entries, so it could not look inside them: cli 3.6.6, curl 8.1.0, htmltools 0.5.9, jsonlite 2.0.0, pak 0.11.2, renv 1.3.1, rlang 1.3.0, testthat 3.3.2, xml2 1.6.0, yaml 2.3.12.
info no-sbom-author No author or organization was given for this SBOM, so only the tool that made it is recorded.

Each issue is one sentence, with the ref of the component it concerns. write_sbom() copies the sentences into the file: sbom:note properties in CycloneDX, the document comment in SPDX 2. To close a gap, assert the missing value with an override, as the compliance guide shows.

Write it

write_sbom() picks the format from the file extension.

path <- file.path(tempdir(), "sbom.cdx.json")
write_sbom(x, path)
cat(head(readLines(path), 12), sep = "\n")
#> {
#>   "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
#>   "bomFormat": "CycloneDX",
#>   "specVersion": "1.6",
#>   "serialNumber": "urn:uuid:f5858314-b068-5ebe-9618-61e2c6cd7a29",
#>   "version": 1,
#>   "metadata": {
#>     "timestamp": "2026-01-01T00:00:00Z",
#>     "tools": {
#>       "components": [
#>         {
#>           "type": "application",

Pass format and version when the extension is not enough, for instance when a reviewer’s tool only reads SPDX 2.2:

write_sbom(x, file.path(tempdir(), "sbom-old.json"), format = "spdx-json", version = "2.2")

format = "all" writes every standard format into a folder, named after the root component:

out <- file.path(tempdir(), "sbom-all")
write_sbom(x, out, format = "all")
list.files(out)
#> [1] "sbom-0.1.0.cdx.json"     "sbom-0.1.0.cdx.xml"     
#> [3] "sbom-0.1.0.spdx"         "sbom-0.1.0.spdx.json"   
#> [5] "sbom-0.1.0.spdx.xml"     "sbom-0.1.0.spdx.yaml"   
#> [7] "sbom-0.1.0.spdx3.jsonld" "sbom-0.1.0.swidtag"

sbom_formats() lists what is on offer, with each format’s versions and the default:

knitr::kable(sbom_formats()[, c("format", "name", "extension", "versions", "default_version")])
format name extension versions default_version
cyclonedx-json CycloneDX JSON .cdx.json 1.4, 1.5, 1.6, 1.7 1.6
cyclonedx-xml CycloneDX XML .cdx.xml 1.4, 1.5, 1.6, 1.7 1.6
spdx-json SPDX JSON .spdx.json 2.2, 2.3 2.3
spdx-yaml SPDX YAML .spdx.yaml 2.2, 2.3 2.3
spdx-xml SPDX XML .spdx.xml 2.2, 2.3 2.3
spdx-tv SPDX tag-value .spdx 2.2, 2.3 2.3
spdx3-jsonld SPDX 3 JSON-LD .spdx3.jsonld 3.0.1 3.0.1
swid SWID tag .swidtag 2015 2015
csv CSV inventory .csv NA

If you were asked for “an SBOM” and nothing more, ask which format. Failing an answer, CycloneDX JSON and SPDX JSON are the two that most tools read. The CSV inventory is not a standard and is not part of "all": it is a table for people with spreadsheets, described in the compliance guide.

Keep going

The projects guide builds SBOMs for analyses and apps rather than packages: from a folder, an renv lockfile, a Posit Connect manifest, a Shiny app, a Nix file written by rix or a whole library. It also covers system libraries and when the network is used.

The reading and validating guide reads SBOMs that sbom or any other tool wrote, checks them against their schema and the NTIA minimum elements, and compares two of them.

The compliance guide goes from the issues sbom reports to a document you can sign off: overrides for what sbom cannot find, reproducible output, the CSV inventory and security advisories.