--- title: "pkgaudit Rule Coverage" output: rmarkdown::html_vignette: # Rule messages quote shell flags and make constructs verbatim, so the # "smart" typography that would turn --clean into an en dash, or # $(shell ...) into an ellipsis, is disabled. md_extensions: "-smart" vignette: > %\VignetteIndexEntry{pkgaudit Rule Coverage} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( echo = FALSE, results = "asis" ) library(pkgaudit) # Read the installed database so the tables below cannot drift from the rules # the package actually ships. rules <- load_rules() phase_names <- setdiff(names(rules$phases), c("context", "version")) ``` ```{r helpers} # The rule sources are read from the installed package, but linked in the # repository, where a reader can browse them. repo <- "https://github.com/tylerjssmith/pkgaudit/blob/main/" # Map each rule name to the YAML file that defines it, as a path relative to # inst/rules/. The database records a rule's name but not where it came from, # so the rule sources are read for it. # # Keyed by category as well as name: a rule name is unique only within its # category, and `curl` names both a pattern rule and a match rule. rule_files <- local({ root <- system.file("rules", package = "pkgaudit") paths <- list.files(root, pattern = "\\.yaml$", recursive = TRUE) named <- vapply(paths, function(path) { line <- grep("^name: ", readLines(file.path(root, path)), value = TRUE) if (length(line) == 0L) NA_character_ else sub("^name: ", "", line[[1L]]) }, character(1L)) # The computed contexts have no rule file to link to, and # carry no name field. keep <- !is.na(named) split(stats::setNames(paths[keep], named[keep]), dirname(paths[keep])) }) # A rule name, linked to the YAML that defines it in the given category. rule_link <- function(name, category) { sprintf("[%s](%sinst/rules/%s)", name, repo, rule_files[[category]][name]) } # The phases a context runs in, named as the columns a caller would filter on. # "none" is what `summary()` calls a context that runs at no phase at all. phase_cell <- function(context) { row <- rules$phases[match(context, rules$phases$context), phase_names] on <- phase_names[as.logical(row)] if (length(on) == 0L) "none" else paste0("`", on, "`", collapse = ", ") } # A file context's file name, recovered from the rule's search path and # pattern so the table cannot disagree with what the rule matches. # # A rule anchored at both ends names one file. A rule matching a class of files # -- every R source in R/, say -- has no single name to show, so its pattern is # shown after a wildcard instead. anchored <- function(pattern) grepl("^\\^", pattern) & grepl("\\$$", pattern) file_label <- function(path, pattern) { literal <- gsub("\\\\", "", sub("\\$$", "", sub("^\\^", "", pattern))) file <- ifelse(anchored(pattern), literal, paste0("*", literal)) ifelse(path == ".", file, paste0(path, "/", file)) } # Render a markdown table, one row per line, escaping any cell content that # would otherwise be read as a column separator. emit_table <- function(headers, ...) { cells <- lapply(list(...), function(x) gsub("|", "\\|", x, fixed = TRUE)) cat( c( paste0("| ", paste(headers, collapse = " | "), " |"), paste0("|", strrep("---|", length(headers))), paste0("| ", do.call(paste, c(cells, sep = " | ")), " |") ), sep = "\n" ) cat("\n\n") } ``` ```{r labels} # What each code-context and pattern rule covers, in the terms a reader would # recognize. These cannot be derived from the rule: an XPath expression is a # definition, not a summary of one. Every rule in the database must appear # here, so a new rule fails this document until it is described. code_labels <- c( LastLib_base = "`.Last.lib()`", onAttach_base = "`.onAttach()`", onDetach_base = "`.onDetach()`", onLoad_base = "`.onLoad()`", on_load_rlang = "`rlang::on_load()`", onUnload_base = "`.onUnload()`", Rd_examples = "`\\examples{}`", Rd_Sexpr_build = "`\\Sexpr[stage=build]`", Rd_Sexpr_install = "`\\Sexpr[stage=install]`", Rd_Sexpr_render = "`\\Sexpr[stage=render]`" ) pattern_labels <- c( chmod = "`Sys.chmod()`, `Sys.umask()`", credentials_environment = paste( "`Sys.getenv()` or `Sys.setenv()` of a name containing `TOKEN`, `SECRET`,", "`PASSWORD`, `API_KEY`, `PRIVATE_KEY`, `ACCESS_KEY` or `CREDENTIAL`, and", "`Sys.getenv()` with no arguments" ), credentials_files = paste( "a path that only holds a secret: `~/.ssh/`, `~/.aws/`, `.netrc`,", "`.pgpass`, `id_rsa`, `~/.gnupg/`, `~/.kube/config`, `~/.config/gcloud`" ), curl = paste( "`curl()`, `curl_fetch_memory()`, `curl_fetch_disk()`,", "`curl_fetch_stream()`, `curl_fetch_multi()`, `curl_download()`,", "`curl_upload()`, `multi_download()`, `multi_run()`, `send_mail()`" ), decoding = paste( "`base64decode()`, `base64_dec()`, `base64_decode()`, `memDecompress()`,", "`rawToChar(as.raw())`" ), deserialization = "`readRDS()`, `load()`, `unserialize()`, `dget()`", download_file = "`download.file()`, `url()`", dynload = "`dyn.load()`, `library.dynam()`", eval_parse = paste( "`eval()`/`evalq()` over `parse()`, `str2lang()` or `str2expression()`,", "when the text came from a fetch, a decode, a deserialization, a", "subprocess or a file read -- most functions the rules for those already", "cover, plus `readLines()`, `scan()`, `readBin()` and `rawToChar()`;", "callr's and processx's generically named entry points are not matched" ), fs = "`file_chmod()`, `file_chown()`", httr = paste( "`GET()`, `POST()`, `PUT()`, `PATCH()`, `DELETE()`, `HEAD()`, `VERB()`" ), httr2 = paste( "`req_perform()`, `req_perform_iterative()`, `req_perform_parallel()`,", "`req_perform_sequential()`, `req_perform_stream()`,", "`req_perform_connection()`, `req_perform_promise()`" ), install = paste( "`install.packages()`, `install_github()`, `install_gitlab()`,", "`install_bitbucket()`, `install_git()`, `install_url()`,", "`install_version()`, `install_local()`, `install_bioc()`,", "`install_dev()`, `install_cran()`, `pak()`, `pkg_install()`,", "`local_install()`, `lockfile_install()`, `BiocManager::install()`,", "`renv::install()`, `devtools::install()`, `py_install()`,", "`virtualenv_install()`, `conda_install()`, `virtualenv_create()`,", "`conda_create()`" ), jsonlite = "`fromJSON()` of a literal URL with the `http` or `https` scheme", lazyload = paste( "`makeLazyLoadDB()`, `makeLazyLoading()`, `code2LazyLoadDB()`,", "`data2LazyLoadDB()`, `sysdata2LazyLoadDB()`" ), namespace = paste( "`getFromNamespace()`, `getExportedValue()`, `getAnywhere()`,", "`assignInNamespace()`, `assignInMyNamespace()`, `unlockBinding()`" ), options_repos = "`options(repos = )`", persistence = paste( "a startup file or scheduler: `~/.bashrc`, `~/.zshrc`, `~/.profile`,", "`~/.Rprofile`, `~/.Renviron`, `crontab`, `LaunchAgents`, `/etc/systemd`" ), python = paste( "`py_run_string()`, `py_run_file()`, `py_eval()`, `source_python()`,", "`py_call()`, `import_builtins()`" ), rcurl = paste( "`getURL()`, `getURI()`, `getForm()`, `postForm()`, `curlPerform()`" ), socket = paste( "`socketConnection()`, `make.socket()`, `serverSocket()`,", "`socketAccept()`" ), source = "`source()`", system = "`system()`, `system2()`, `shell()`, `pipe()`", system_callr = paste( "`callr::r()`, `r_bg()`, `r_safe()`, `r_copycat()`, `r_vanilla()`,", "`callr::rcmd()`, `rcmd_bg()`, `rcmd_safe()`, `rcmd_copycat()`,", "`rscript()`, `r_session$new()`, `r_process$new()`,", "`rcmd_process$new()`, `rscript_process$new()`" ), system_processx = paste( "`processx::run()`, `processx::pipeline()`, `process$new()`" ), system_sys = paste( "`exec_wait()`, `exec_background()`, `exec_internal()`, `eval_safe()`,", "`eval_fork()`, `r_background()`, `r_internal()`, `r_wait()`" ) ) # The two computed code contexts, which no rule defines and which carry no # phases of their own. computed_contexts <- data.frame( context = c("top_level", "in_function"), description = c( paste( "Code outside any function definition. It carries the phases of the file", "context it sits in, so the same call reads at_check under tests/ and", "at_build under data/." ), paste( "Code inside a function definition. It inherits the phases of the code", "around it, except under R/, where the rules report it as running at no", "phase." ) ), stringsAsFactors = FALSE ) # Every rule and computed context must be described above, and every file # context's derived file name must be matched by the pattern it came from. stopifnot( all(rules$file_contexts$name %in% names(rule_files$file_contexts)), all(rules$code_contexts$name %in% names(rule_files$code_contexts)), all(rules$patterns$name %in% names(rule_files$patterns)), all(rules$matches$name %in% names(rule_files$matches)), setequal(names(code_labels), rules$code_contexts$name), setequal(names(pattern_labels), rules$patterns$name), # The computed contexts are exactly those with no phases row: they inherit. !any(computed_contexts$context %in% rules$phases$context), length(setdiff(rules$phases$context, c(rules$file_contexts$name, rules$code_contexts$name))) == 0L, # Every code context a file-context rule names must be a rule that exists. all(setdiff(unlist(strsplit( stats::na.omit(rules$file_contexts$code_context), "[[:space:]]+")), "computed") %in% rules$code_contexts$name), # Only a rule naming one file has a name to check against its own pattern. local({ fc <- rules$file_contexts[anchored(rules$file_contexts$filename), ] all(mapply(grepl, fc$filename, basename(file_label(fc$path, fc$filename)))) }) ) ``` In pkgaudit v`r utils::packageVersion("pkgaudit")`, pattern and match rules describe what code does; phases resolved from file-context and code-context rules describe when it runs. All rules are defined in YAML files under [inst/rules/](https://github.com/tylerjssmith/pkgaudit/tree/main/inst/rules) and compiled into the SQLite database at `inst/db/rules.db`. This vignette is generated from that database, at rules v`r rules_version()`; the Rule columns below link to the defining YAML. ## Patterns Patterns are security-relevant function calls such as `system()` and strings such as "`~/.ssh`". Qualified (`pkg::fn()`, `pkg:::fn()`) and unqualified (`fn()`) call forms are both detected. A rule that declares its function names also flags calls made through a name -- `do.call("system", ...)`, `match.fun("system")`, and `getFunction("system")`. Pattern rules carry [MITRE ATT&CK](https://attack.mitre.org/) technique labels. ```{r patterns} pat <- rules$patterns emit_table( c("Rule", "Pattern", "Description"), rule_link(pat$name, "patterns"), pattern_labels[pat$name], pat$message ) ``` ## Matches Matches are regular-expression matches in shell code: the shell scripts and Make-like files among the [file contexts](#file-contexts) below, and the `bash`, `sh`, and `zsh` chunks a vignette yields. Regular expressions are matched with `gregexpr(perl = TRUE)` and are case-sensitive. Match rules carry [MITRE ATT&CK](https://attack.mitre.org/) technique labels. Note: Matching text is less precise than matching a parse tree. A match has no syntax behind it, so a match inside a comment, a quoted string, or a branch that never runs is reported the same as one in a live command. ```{r matches} rx <- rules$matches emit_table( c("Rule", "Description"), rule_link(rx$name, "matches"), rx$message ) ``` ## Phases and Contexts ### Phases Phases describe when code executes. For pattern findings, they are resolved from file and code contexts. For match findings, they are resolved from file contexts alone. See [Internals](internals.html#resolve). ```{r phases-legend} phase_descriptions <- c( at_autoconf = "Autoconf is run to generate `configure`", at_build = "`R CMD build`", at_check = "`R CMD check`", at_install_src = "`R CMD INSTALL` from source", at_install_bin = "a prebuilt binary package is installed", at_load = "the namespace is loaded", at_attach = "the package is attached to the search path", at_unload = "the namespace is unloaded", at_detach = "the package is detached from the search path" ) stopifnot(setequal(names(phase_descriptions), phase_names)) emit_table( c("Phase", "Code executes when"), paste0("`", phase_names, "`"), phase_descriptions[phase_names] ) ``` ### File Contexts File contexts are files that may contain code. All are scanned, and some security-relevant files are reported as findings in their own right. In general, shell scripts and Make-like files as well as `src/install.libs.R` are reported. ```{r file-contexts} fc <- rules$file_contexts emit_table( c("Rule", "Report", "Phases", "Description"), rule_link(fc$name, "file_contexts"), ifelse(fc$report, "yes", "no"), vapply(fc$name, phase_cell, character(1L)), fc$message ) ``` ### Code Contexts Code contexts indicate where code sits within its file context. It is either defined by a rule or computed. For rule-based code contexts, a lifecycle hook like `.onLoad()` is found by matching the XML parse tree, while part of a help file like `\examples` or `\Sexpr` is found by matching a label from the extraction method. If no code-context rule matches, code context is computed from the parse tree: `in_function` when code sits inside a regular function definition, and `top_level` otherwise. #### Rule-based ```{r code-contexts-rules} cc <- rules$code_contexts emit_table( c("Rule", "Code Context", "Matched by", "Phases", "Description"), rule_link(cc$name, "code_contexts"), code_labels[cc$name], ifelse(cc$kind == "xpath", "parse tree", "segment label"), vapply(cc$name, phase_cell, character(1L)), cc$message ) ``` #### Computed ```{r code-contexts-computed} emit_table( c("Context", "Phases", "Description"), paste0("`", computed_contexts$context, "`"), rep("inherited from file context", nrow(computed_contexts)), computed_contexts$description ) ```