Skip to contents

No SBOM tool knows everything. An in-house package has no public supplier, a license of file LICENSE has no SPDX name, and a folder outside git has no version. The useful question is whether the document admits it. sbom does: anything it could not determine becomes an issue, and every issue is written into the file. This guide takes a small study from that first honest draft to a document a reviewer can sign, then adds security advisories.

library(sbom)
ts <- as.POSIXct("2026-01-01", tz = "UTC")
study <- sbom_example("study", copy = TRUE)
list.files(study)
#> [1] "analysis.R" "renv.lock"
writeLines(readLines(file.path(study, "renv.lock")))
#> {
#>   "R": {"Version": "4.5.1",
#>         "Repositories": [{"Name": "CRAN", "URL": "https://cloud.r-project.org"}]},
#>   "Packages": {
#>     "holeypkg": {"Package": "holeypkg", "Version": "1.0.0", "Source": "Repository",
#>       "Repository": "CRAN", "License": "MIT + file LICENSE",
#>       "Maintainer": "Ann Example <ann@example.org>"},
#>     "inhouse": {"Package": "inhouse", "Version": "0.3.0", "Source": "Local",
#>       "License": "file LICENSE", "Maintainer": "Stats Team <stats@example.com>",
#>       "Imports": ["holeypkg"]}
#>   }
#> }

The study is one of the examples that come with sbom, copied into a temporary folder so that this guide can add files to it. Its script calls library(inhouse). Both packages are made up: {holeypkg} plays a CRAN package, {inhouse} a package your team built.

Review the gaps

Build the SBOM and list its issues:

x <- sbom(study, system = FALSE, timestamp = ts)
knitr::kable(sbom_issues(x)[, c("ref", "severity", "code", "message")])
ref severity code message
project/study warning version-missing The project folder study is not a git repository, so it has no version.
local/inhouse warning license-no-spdx The license of inhouse 0.3.0, file LICENSE, has no SPDX expression, so it is recorded as NOASSERTION.
local/inhouse warning supplier-unknown inhouse 0.3.0 was installed from a local source, so it has no supplier on record.
project/study info no-sbom-author No author or organization was given for this SBOM, so only the tool that made it is recorded.
project/study info drift-not-checked No project library was found, so the installed packages were not compared with renv.lock.

Warnings are gaps in the record; info is context, such as what was not compared. The same sentences go into the file: sbom:note properties in CycloneDX, the document comment in SPDX 2.

cdx <- file.path(tempdir(), "study.cdx.json")
write_sbom(x, cdx)
jsonlite::fromJSON(cdx)$metadata$properties$value
#> [1] "Checksums are only those of files sbom read itself, and a component without one has none on record."  
#> [2] "Download locations are not recorded, since sbom did not look up any package repository."              
#> [3] "No advisory data was on hand for this file, so it lists no advisories."                               
#> [4] "The project folder study is not a git repository, so it has no version."                              
#> [5] "The license of inhouse 0.3.0, file LICENSE, has no SPDX expression, so it is recorded as NOASSERTION."
#> [6] "inhouse 0.3.0 was installed from a local source, so it has no supplier on record."                    
#> [7] "No author or organization was given for this SBOM, so only the tool that made it is recorded."        
#> [8] "No project library was found, so the installed packages were not compared with renv.lock."

The NTIA minimum elements make the gaps concrete:

validate_sbom(x)
#> <sbom> object: FAIL
#> Profile: ntia-2021.
#> 
#> ── Errors ──
#> 
#> ✖ ntia-identifier: 3 findings
#>   • R 4.5.1 has no purl, CPE or SWID identifier. (component R)
#>   • inhouse 0.3.0 has no purl, CPE or SWID identifier. (component
#>     local/inhouse)
#>   • study has no purl, CPE or SWID identifier. (component project/study)
#>   ℹ Give each component a purl, CPE or SWID tag id; if sbom made this SBOM,
#>     assert a purl it cannot find with an override of purl.
#> ✖ ntia-supplier: 2 findings
#>   • R 4.5.1 has no supplier. (component R)
#>   • study has no supplier. (component project/study)
#>   ℹ State each component's supplier; if sbom made this SBOM, assert the ones it
#>     cannot find with an override of supplier_name.
#> ✖ study has no version. (component project/study)
#>   ℹ State each component's version; if sbom made this SBOM, assert the ones it
#>     cannot find with an override of version.
#> 
#> ── Warnings ──
#> 
#> ! The document names only a tool (sbom) as its author; NTIA asks for the person
#>   or organization that made the SBOM data. (metadata$tool_version)
#>   ℹ Name the person or organization that made the SBOM data as its author; if
#>     sbom made this SBOM, give its author or organization argument.

Answer with overrides

Some of these, only a person can answer. Legal knows the license of {inhouse}, your team knows who supplies it, and the study’s release tag is its version. Overrides record those answers with who gave each and why. They are a data frame, or a CSV file, with the columns component, field, value, asserted_by and reason. field is one of version, license_spdx, supplier_name, supplier_url, maintainer, purl and download_location, and component is a name or ref from sbom_components().

One row of sbom-overrides.csv: inhouse, license_spdx, LicenseRef-Proprietary, Jo Reviewer, Agreed with legal (LEG-42). The columns are the component, a name or ref; the field, one of seven; the value, the answer; asserted_by, who said so; and reason, why.

sbom() applies a file called sbom-overrides.csv in the project folder by itself, so the answers live next to the code they describe. The examples include the study’s:

overrides <- file.path(study, "sbom-overrides.csv")
invisible(file.copy(sbom_example("study-overrides.csv"), overrides))
knitr::kable(read.csv(overrides))
component field value asserted_by reason
R supplier_name The R Foundation Jo Reviewer Publisher of R.
R purl pkg:generic/r@4.5.1 Jo Reviewer No purl type covers R itself.
inhouse license_spdx LicenseRef-Proprietary Jo Reviewer Agreed with legal (LEG-42).
inhouse supplier_name Example Pharma Ltd Jo Reviewer Built in house.
inhouse purl pkg:generic/inhouse@0.3.0 Jo Reviewer Internal package.
study version 2026.1 Jo Reviewer Release tag of the study code.
study supplier_name Example Pharma Ltd Jo Reviewer Our study.
study purl pkg:generic/study@2026.1 Jo Reviewer Our study.

y <- sbom(study, system = FALSE, timestamp = ts, author = "Jo Reviewer")
knitr::kable(sbom_issues(y)[, c("code", "message")])
code message
drift-not-checked No project library was found, so the installed packages were not compared with renv.lock.
validate_sbom(y)
#> <sbom> object: PASS
#> Profile: ntia-2021.
#> No findings.

Each asserted field removed the issues it answered, and the content checks pass. To check the schema as well, write the file and validate that.

The seven NTIA minimum elements of the study's SBOM. In the first draft, supplier, version, unique identifier and author fail, and component name, dependencies and timestamp pass. With the overrides and an author, all seven pass.

An override is a claim, not a correction that vanishes. Every assertion stays in the SBOM with its author and reason, and one that contradicts a value sbom found still wins but adds an override-conflict issue, so the disagreement stays visible. An asserted version is exact, and the package URL follows it.

Make documents reproducible

Run sbom twice on the same inputs and you get the same bytes. The serial number or namespace of a document derives from its content, so the only thing that would differ is the creation time, and timestamp pins that:

a <- write_sbom(y, file.path(tempdir(), "a.cdx.json"))
b <- write_sbom(sbom(study, system = FALSE, timestamp = ts, author = "Jo Reviewer"),
  file.path(tempdir(), "b.cdx.json"))
unname(tools::md5sum(c(a, b)))
#> [1] "4b54e8161625ca5651c805a10dec3a77" "4b54e8161625ca5651c805a10dec3a77"

Without timestamp, sbom reads the SOURCE_DATE_EPOCH environment variable, the reproducible-builds convention, and notes that it did:

old <- Sys.getenv("SOURCE_DATE_EPOCH", unset = NA)
Sys.setenv(SOURCE_DATE_EPOCH = "1767225600")
z <- sbom(study, system = FALSE, author = "Jo Reviewer")
z$metadata$timestamp
#> [1] "2026-01-01 UTC"
grep("SOURCE_DATE_EPOCH", z$metadata$notes, value = TRUE)
#> [1] "The timestamp comes from the SOURCE_DATE_EPOCH environment variable."
if (is.na(old)) Sys.unsetenv("SOURCE_DATE_EPOCH") else Sys.setenv(SOURCE_DATE_EPOCH = old)

Nix sets SOURCE_DATE_EPOCH to 1980-01-01 in every nix-shell, which is reproducible but not true. sbom notes when the timestamp is that placeholder.

Export a CSV for reviewers

Reviewers often want a table they can filter, sort and annotate. format = "csv" writes one row per component, with the issues and the assertions in plain columns. It is not an SBOM standard, and format = "all" leaves it out.

csv <- write_sbom(y, file.path(tempdir(), "study.csv"))
inventory <- read.csv(csv, fileEncoding = "UTF-8-BOM")
knitr::kable(inventory[, c("name", "version", "license", "supplier", "asserted")])
name version license supplier asserted
study 2026.1 Example Pharma Ltd version=2026.1 (Jo Reviewer: Release tag of the study code.); supplier_name=Example Pharma Ltd (Jo Reviewer: Our study.); purl=pkg:generic/study@2026.1 (Jo Reviewer: Our study.)
R 4.5.1 GPL-2.0-only OR GPL-3.0-only The R Foundation supplier_name=The R Foundation (Jo Reviewer: Publisher of R.); purl=pkg:generic/r@4.5.1 (Jo Reviewer: No purl type covers R itself.)
inhouse 0.3.0 LicenseRef-Proprietary Example Pharma Ltd license_spdx=LicenseRef-Proprietary (Jo Reviewer: Agreed with legal (LEG-42).); supplier_name=Example Pharma Ltd (Jo Reviewer: Built in house.); purl=pkg:generic/inhouse@0.3.0 (Jo Reviewer: Internal package.)
holeypkg 1.0.0 MIT CRAN

The file starts with a byte-order mark so that spreadsheets read it as UTF-8, hence the fileEncoding. Any value that starts with =, +, - or @ gets a leading apostrophe, so a spreadsheet will not run a crafted value as a formula. Real names, versions and dates never start that way.

Add security advisories

Advisories are off by default and never fetched behind your back. With advisories = TRUE, sbom downloads OSV’s export of the R Consortium advisory database and matches it on your machine, so no package name is sent anywhere. It then looks up the CVSS rating of each upstream record the advisories name at api.osv.dev, sending only those public ids.

sbom(study, advisories = TRUE)

On a server without network access, download https://osv-vulnerabilities.storage.googleapis.com/CRAN/all.zip elsewhere and pass its path. It is read where it is, offline and without ratings. The examples include such a file, holding one made-up advisory against {holeypkg} 1.0.0:

all_zip <- sbom_example("osv-all.zip")
adv <- sbom(study, system = FALSE, timestamp = ts, author = "Jo Reviewer",
  advisories = all_zip)
adv
#> <sbom> study 2026.1
#> Components: 4
#> Relationships: 2
#> Issues: 2 (see sbom_issues())
#> Advisories: 1 listed by OSV for these exact versions.
knitr::kable(sbom_advisories(adv)[, c("id", "name", "version", "fixed", "summary")])
id name version fixed summary
EXAMPLE-2026-1 holeypkg 1.0.0 1.0.1 A made-up advisory for sbom’s examples

Only exact versions of packages with a CRAN package URL are compared, which includes packages from CRAN-like repositories. A package without an exact version, or one not known to come from CRAN (not installed, or installed from a local source, GitHub, GitLab, Bitbucket or git), is named in an issue instead. OSV does not cover Bioconductor packages yet, and the SBOM says so when it holds one. Here {inhouse} now has a generic package URL, so:

sbom_issues(adv)$message[startsWith(sbom_issues(adv)$code, "advisories-")]
#> [1] "1 R package is not known to come from CRAN, so OSV's advisories were not looked up for it (inhouse)."

Read the result as what OSV says about these exact versions, nothing more. A package without an advisory has not been shown to be free of flaws, and an upstream CVSS rating describes the upstream flaw, not how exposed the R package is. The advisories are written into CycloneDX, SPDX 2 and SPDX 3 output, and read_sbom() reads them back. A SWID tag has no place for them, and says so.

An SBOM built without advisories says that too, in a note, so nobody mistakes silence for a clean bill of health:

grep("advisory", y$metadata$notes, value = TRUE)
#> [1] "No advisory data was on hand for this file, so it lists no advisories."

When the advisory data cannot be downloaded or read, the call fails with an error that names the problem. advisories_on_error = "continue" builds the SBOM anyway, with a warning, an issue and that same note:

broken <- file.path(tempdir(), "broken.zip")
writeLines("not a zip file", broken)
w <- sbom(study, system = FALSE, timestamp = ts, author = "Jo Reviewer",
  advisories = broken, advisories_on_error = "continue")
#> Warning in sbom(study, system = FALSE, timestamp = ts, author = "Jo Reviewer", : OSV's advisory data could not be read (broken.zip is damaged: it is not a zip
#> file), so this SBOM lists no advisories.
sbom_issues(w)$message[sbom_issues(w)$code == "advisories-unavailable"]
#> [1] "sbom could not read OSV's advisory data (broken.zip is damaged: it is not a zip file), so this file lists no advisories."