This vignette is for someone reading pkgaudit’s source code. For how to use pkgaudit and what it reports, see Getting Started; for the rule set, see Rule Coverage.
pkgaudit steps
audit_package() orchestrates a series of function calls,
which may be loosely grouped as four steps:
- Discover
- Extract
- Analyze
- Resolve
Discover
find_file_contexts() runs one list.files()
per file-context rule, using a file context rule’s path,
filename and recursive fields. If
report = TRUE in a rule, the file context is reported in
the pkgaudit object’s $file_contexts data frame.
Extract
For each file context, whether reported or not,
extract_segments() dispatches to S3 methods that read each
file into segments: contiguous runs of code in one language,
blank-padded so that line n of a segment is line n of
the source file. Dispatch is based on the file-context rule’s
type. The methods live in R/extract_*.R.
| Extractors |
|---|
| extract_R.R |
| extract_Rd.R |
| extract_rmd.R |
| extract_rnw.R |
| extract_rsp.R |
| extract_shell.R |
A type read like another inherits from it rather than repeating a
method. For example, make inherits shell and
qmd inherits Rmd.
extract_segments() has a default method that fails closed:
a type with no extractor yields no segments.
Analyze
For each segment extracted, analyze_segment() dispatches
to S3 methods that call functions to find code contexts, patterns, or
matches in each segment. Dispatch is based on the segment’s
language, such as R or shell. The methods live in
R/analyze_*.R.
| Analyzers |
|---|
| analyze_R.R |
| analyze_shell.R |
analyze_segment.R (in R/analyze_R.R) calls
functions including parse_code(), which uses
parse() and xmlparsedata::xml_parse_data() to
derive an XML parse tree for R code; and find_patterns()
and find_indirect(), which use the parse tree and
xml2::xml_find_all() to find patterns and indirect calls
using function names (e.g., do.call("system", ...)).
determine_code_contexts() determines the code context of
patterns and indirect calls.
analyze_segment.shell (in
R/analyze_shell.R) calls functions including
find_matches(), which uses
gregexpr(perl = TRUE) to find text matching regular
expressions in shell and Make-like files, and bash,
sh, and zsh vignette chunks.
analyze_segment() has a default method that fails
closed: a language with no analyzer yields no findings and no error,
recording a coverage row instead.
Resolve
A set of internal functions uses file or code contexts to resolve the phases in which a pattern or match may run. The supported phases are:
| Phase | Code executes when |
|---|---|
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 |
Pattern phases are resolved from code and file contexts, in that
order. A pattern in a code context defined by a rule, such as
.onLoad, takes that context’s phases. A pattern in
top-level code takes its file context’s phases. A pattern in a regular
function definition depends on the assume_called field of
the file-context rule: if TRUE, it takes the phases of
whatever encloses it; if FALSE, as under R/,
it receives no phase. (A helper defined under R/ may still
be called from a lifecycle hook, and human reviewers should verify
whether that occurs.) Match phases are resolved from file contexts. A
rule can belong to several phases.
Phases were established by running instrumented packages and recording which sites fired, rather than read from documentation.
Call list
The audit_package() call list below is derived from
pkgaudit’s source code. A name appears once, at the first place it is
reached; a function called from several places is not repeated. The tree
is cut at four levels deep.
audit_package()
├─ .check_path()
│ └─ .stop_arg()
├─ .check_rules()
│ └─ .stop_arg()
├─ .validate_origin()
├─ .empty_patterns()
│ └─ .empty_phase_cols()
├─ .empty_matches()
│ └─ .empty_phase_cols()
├─ .empty_coverage()
│ └─ .empty_phase_cols()
├─ .empty_errors()
├─ find_file_contexts()
│ ├─ .empty_file_contexts()
│ │ └─ .empty_phase_cols()
│ ├─ .error_row()
│ └─ .relativize()
├─ .report_file_contexts()
├─ .load_rd_macros()
├─ .scan_file_contexts()
│ └─ .named_contexts()
├─ new_source()
│ └─ .source_class()
├─ extract_segments()
│ ├─ .over_scan_limit()
│ ├─ .error_row()
│ ├─ extract_segments.R()
│ │ ├─ read_code()
│ │ └─ new_segment()
│ ├─ extract_segments.Rd()
│ │ ├─ read_Rd_code()
│ │ │ ├─ .empty_Rd_code()
│ │ │ ├─ .parse_Rd_safe()
│ │ │ └─ .Rd_fragments()
│ │ └─ new_segment()
│ ├─ extract_segments.Rmd()
│ │ ├─ read_code()
│ │ ├─ .rmd_chunks()
│ │ │ ├─ .rmd_header()
│ │ │ ├─ .unquote_lines()
│ │ │ └─ .rmd_pipe_eval()
│ │ ├─ new_segment()
│ │ ├─ .blank_except()
│ │ └─ .rmd_inline()
│ │ ├─ .inline_code()
│ │ └─ .mask_verbatim()
│ ├─ extract_segments.Rnw()
│ │ ├─ read_code()
│ │ ├─ .rnw_code()
│ │ │ └─ .inline_code()
│ │ ├─ new_segment()
│ │ └─ .blank_except()
│ ├─ extract_segments.rsp()
│ │ ├─ read_code()
│ │ ├─ .rsp_code()
│ │ │ └─ .fixed_positions()
│ │ └─ new_segment()
│ ├─ extract_segments.shell()
│ │ ├─ read_code()
│ │ └─ new_segment()
│ └─ extract_segments.default()
├─ analyze_segment()
│ ├─ analyze_segment.R()
│ │ ├─ parse_code()
│ │ ├─ new_findings()
│ │ ├─ .error_row()
│ │ ├─ .applicable_contexts()
│ │ ├─ find_code_contexts()
│ │ │ ├─ .empty_code_contexts()
│ │ │ └─ .xml_find_all_safe()
│ │ ├─ find_patterns()
│ │ │ ├─ .empty_found()
│ │ │ └─ .xml_find_all_safe()
│ │ ├─ find_indirect()
│ │ │ ├─ .empty_indirect()
│ │ │ ├─ .function_owners()
│ │ │ ├─ .xml_find_all_safe()
│ │ │ └─ .string_value()
│ │ ├─ .preview()
│ │ ├─ .node_continues()
│ │ └─ determine_code_contexts()
│ │ ├─ .xml_find_all_safe()
│ │ ├─ .deepest_context()
│ │ └─ .has_function_ancestor()
│ ├─ analyze_segment.shell()
│ │ ├─ find_matches()
│ │ │ ├─ .gregexpr_safe()
│ │ │ └─ .error_row()
│ │ ├─ .rules_for()
│ │ ├─ .preview()
│ │ └─ new_findings()
│ └─ analyze_segment.default()
│ ├─ new_findings()
│ └─ .segment_coverage()
├─ .merge_coverage()
├─ build_coverage()
│ ├─ .claiming_rule()
│ ├─ .in_scope()
│ ├─ .file_extension()
│ ├─ .error_reason()
│ ├─ .segment_language()
│ └─ .coverage_lines()
├─ .attach_phases()
│ └─ .phase_lookup()
│ └─ .empty_phase_cols()
├─ .resolve_pattern_phases()
│ ├─ .empty_phase_cols()
│ └─ .phase_lookup()
├─ .resolve_match_phases()
│ └─ .empty_phase_cols()
├─ hash_manifest()
├─ .build_metadata()
│ ├─ .read_description()
│ ├─ .rules_provenance()
│ └─ .pkgaudit_version()
└─ new_pkgaudit()
├─ .validate_result_df()
└─ .validate_metadata()
Invariants
Scanned files are never executed
Since pkgaudit is intended for reviewing untrusted packages, it must
never execute code contained in those packages.
tests/testthat/test-no_execution.R builds a package where
every execution site writes a marker file, scans it, exports it, and
requires that none of the markers exist afterwards.
Three calls in the read path look like execution and are not.
tools::parse_Rd() parses without evaluating;
tools::loadPkgRdMacros() reads macro definitions as text;
and parse(text = ) produces an unevaluated expression.
Macros come from the package’s own man/macros/ and from any
provider named in its RdMacros field; both are read as
text, not evaluated.
File size limits are applied before extraction
A file over 10 MB is not read. extract_segments()
applies this restriction before UseMethod() so that no
method can skip it. tests/testthat/test-segments.R verifies
that an oversized file is not scanned and that the refusal is recorded
in errors.
Failures are contained and counted
Listing files, reading a file, parsing code, and evaluating an XPath
or a regular expression are each wrapped in tryCatch(): a
failure becomes a row in errors rather than aborting the
scan.
Rules are integrity-checked
Rules live in a SQLite database built from YAML by
inst/scripts/build_db.R. load_rules() verifies
the database against its SHA-256 sidecar on every call. The check is
time-of-check to time-of-use; see “Security considerations” in
?load_rules.
load_rules() also refuses a database with a gap in its
phase model, so a rule whose findings would carry no phases can never
ship.