How it works
aur-scan does one thing and refuses to do another: it reads a package and
everything it pulls in, and it never runs any of it. Every stage below holds
to that line — the scan cannot become the thing that executes the payload.
The pipeline
Fetch → Parse → Analyze → Resolve the tree → Report (and optional SBOM).
1. Fetch — hardened, read-only
Package metadata comes from the AUR RPC over a locked-down HTTP client: a 30s timeout, redirects refused, HTTPS-only, and the JSON body size-capped (16 MiB) so a hostile or MITM’d response can’t stream you out of memory. RPC URLs are built from validated, percent-encoded path segments — a package name can’t inject into the request.
The PKGBUILD itself is retrieved with a deliberately defanged git clone:
git -c core.hooksPath=/dev/null \
-c protocol.allow=never -c protocol.https.allow=always \
-c protocol.file.allow=never \
-c protocol.ext.allow=never \
-c core.symlinks=false \
-c transfer.fsckObjects=true \
clone --depth=1 --no-tags --no-recurse-submodules -- <url> .No hooks can fire, file:///ext:: protocols are blocked, symlinks are written
as plain files (no directory escape), submodules and tags are never fetched, and
-- means the URL can’t be read as a flag. The clone also runs with a scrubbed
environment, ignores system and global git config, verifies objects, and is
time-bounded (timeout_seconds). The package name is validated before
it’s ever used as a path. Nothing is built, sourced, or evaluated.
2. Parse — text, not execution
A static parser reads the PKGBUILD and every file in the package tree as
text — pure pattern/AST analysis, no bash evaluation. A payload can be run
from any file in an AUR repo, so since 3.0.0 nothing is skipped for its name,
its location in a subdirectory, or because it looks binary: NUL-bearing files
are scanned with the NULs removed. The walk is bounded (depth 8, 4096 files,
20 seconds), never follows a symlink, and skips only a real top-level .git.
The parser extracts the fields, arrays (depends, source, the checksum
arrays), the source+=() appends, and the function bodies. Arch-specific arrays
(source_x86_64, sha256sums_aarch64, depends_x86_64, …) are folded in the
way makepkg does, so a SKIP on an arch-specific download is still reported.
Heredocs and comments go through one quote-, comment-, ${ }- and $( )-aware
lexer, so echo "}", # } or a fake heredoc opener like echo "see <<END"
can’t truncate a function or hide the lines after it, and backslash-newline
continuations are spliced back together so curl evil \⏎| sh can’t slip past
a single-line rule. Variables, parameter-expansion operators, helper functions
and variables set in a sourced sidecar are resolved before the rules run; a
command word that can’t be resolved next to a fetch-and-run or an install verb
is reported as OBF-020 instead of guessed.
Scriptlets are found by content. A file is scanned as an install scriptlet
if install= could name it under any package name, if it ends in .install,
or if it defines a pre_/post_ install/upgrade/remove function. So a
scriptlet is never missed because install= was written in an unusual way, and
a name that can’t be resolved soundly is reported as META-007 (High).
Archives are read in memory. Plain tar members committed to the repo are
scanned without being extracted and reported as archive.tar:member. A member
path that escapes is SCAN-003 and a tar outside a strict, well-understood
subset is SCAN-004 (both High). An archive the scanner can’t open is
SCAN-002 (Medium): its contents weren’t reviewed, and by default the gate
doesn’t block on it (see Security for how to make it
block).
Unreadable means unreviewed, never clean. A package file the scanner can’t fully
read (oversized, symlinked, not a regular file, or a declared install= that is
missing), a walk that hits one of its bounds, or a stage that fails outright is
reported as the Critical SCAN-001, and every gate treats that package as
unreviewed.
3. Analyze — the catalog
The parsed package is run against the authoritative detection catalog — 167 codes across 13 categories (see Detection Codes). That’s pattern rules plus structural analyzers: privilege escalation, source and transport integrity, checksum laundering, a remote-exec analyzer, a multi-line decode-and-execute pass, and an IOC match against known campaigns. You can add your own in Custom Rules.
Printed text can’t hide a High or Critical finding. A heredoc body or a
print line is still scanned, and since 3.0.0 a match there is reported at the
rule’s full severity, so a lexer desync costs at most a false positive. The one
exception is narrow: a one-line echo/printf (or, in a PKGBUILD,
msg/msg2/plain/warning/error) whose only argument is one literal
quoted string with no $, backtick or redirection, and nothing open before it,
is reported as the Medium META-008, naming the rule it matched. That covers
the usual “run sudo systemctl enable …” install message. The exemption is off
for the whole package if any printer is defined, aliased or enabled, or if
eval, trap, shopt, BASH_ALIASES, command_not_found_handle, BASH_ENV
or a command-position source appears, and heredocs are never exempt.
Change detection keys. check and install compare each scan against the
package’s last one. Findings are keyed by rule, file and a hash of the
normalized snippet (only the package’s own version-shaped pkgver is masked),
so a second hit of a rule already raised, or a changed C2 address, shows up as
new. Every file typed as a scriptlet is hashed. Records written by 2.x are still
read, falling back to rule IDs.
One analyzer is opt-in and networked: with
threat intelligence enabled and your own key, declared
sha256sums are checked against VirusTotal and source= URLs against
abuse.ch/URLhaus. It is off by default — a default scan stays fully offline
and static — and even when on, every lookup fails open and only data already
public in the PKGBUILD ever leaves the machine.
4. Resolve the dependency tree
The package you name is rarely the tampered one — it’s usually something a few
levels down. aur-scan resolves the full transitive AUR dependency tree by
breadth-first walk, and a critical rule governs it:
Resolution follows only static, declared metadata (
depends/makedepends/…). It never fetches asource=artifact, follows a URL found in a PKGBUILD, or executes anything.
Every AUR package in the closure is scanned. A dependency is a trusted [repo]
leaf only when pacman says a sync repository satisfies it; a name only an AUR
package provides is resolved through the AUR and scanned, and anything left
unresolved fails the run rather than being assumed safe. check also scans the
other AUR packages that provide a dependency’s name, because the helper can
offer any of them in its place (a bootstrap package for a dependency cycle is
the usual one). Depth and node caps stop runaway expansion, and a tree cut short
by one fails the run. When a package
fetches and runs external code, that’s an opaque boundary — it’s flagged
(EXEC-REMOTE, with the URL) and the tree stops there. The scanner won’t chase
the link, because chasing it is exactly how it would run the payload.
The static-only invariant
The whole design rests on one provable property: the scan can’t compromise the machine doing the scanning. While scanning, the only subprocesses the scanner spawns are:
- the hardened
git cloneabove, to fetch a PKGBUILD; - read-only
pacmanqueries: is this name in the official repos (-Si,-Slq), does a sync repository satisfy this dependency (-Sp --print-format,-T), and which foreign (AUR) packages are installed (-Qm).
A test holds that line: every subprocess the shipped code can start must be on a
reviewed list, and ldd is not on it, because it runs its target through the
loader. The scan never invokes makepkg, never bash -c, never eval, never
sources a PKGBUILD. The PKGBUILD is read with a bounded file read and treated
as data. Building is a separate, explicit step: aur-scan install runs
makepkg, and aur-scan-wrap hands off to your AUR helper, but only after the
scan gate passes, and never as part of a scan.
That’s the line that makes pointing it at a malicious package safe — see Security for the full threat model.