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 a source= 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 clone above, to fetch a PKGBUILD;
  • read-only pacman queries: 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.