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.
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.
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 declaredLast, 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.