What's in 2.2.0
Everything on this page is in the 2.2.0 release candidate (currently
2.2.0-rc.2), not in
stable 2.1.1. It’s a big one: 20 new detection codes (118 → 138) and every
scanning path rewired, so it gets soak time before it’s promoted. To try it:
paru -S aur-scanner-rc # conflicts with aur-scanner; install oneLike every RC, it fails closed. Stay on aur-scanner for machines you
care about, and if something misfires,
open an issue. Every
threshold below was set by measuring against all 15,436 official package names
and all 119,170 AUR packages, so a false positive means a measurement was wrong,
and we want to hear about it.
Change detection
A PKGBUILD that was clean last week and is clean today is not the same as one
that was clean last week and grew a curl | sh today. A single scan scores
them the same. Every AUR campaign on record (the 2018 xeactor hijack, the June
2026 Atomic Arch wave) worked by changing packages people had already decided
to trust. The change is the signal.
check and install now record a small fingerprint of every package they scan
(hashes, origins and finding IDs under $XDG_CACHE_HOME/aur-scan/history,
mode 0700, not copies of the PKGBUILDs) and compare the next scan against it:
| Code | Fires when | Severity |
|---|---|---|
DIFF-001 | The package raises findings it didn’t raise last time | Worst new finding |
DIFF-002 | The maintainer changed (High if an orphaned package was just adopted) | High / Medium |
DIFF-003 | A source now points at a host/owner/repo it didn’t use before | High |
DIFF-004 | An install scriptlet or ALPM hook was added (High) or changed (Medium) | High / Medium |
What it deliberately ignores:
- A first scan. There’s nothing to compare against yet.
- A plain version bump. Sources compare at
host/owner/repo, so new tags and release tarballs don’t read as upstream moving. - A broken cache. A corrupt or unwritable history degrades to a first scan. It can never turn a good scan into a failure.
A --local directory claiming a package name you didn’t ask for is scanned but
not recorded, so a fork that declares pkgname=firefox can’t overwrite the
real baseline and silence the next real change.
aur-scan diff
The same comparison, run explicitly on two directories and keeping no state, so it’s safe in CI:
aur-scan diff ./mytool-1.0 ./mytool-1.1 # review an update
aur-scan diff ./old ./new --fail-on critical # gate on NEW findings only
aur-scan diff ./old ./new --format jsonComparing 1.0-1 -> 1.1-1 (mytool)
ADDED (4)
+ CRITICAL DLE-001 Curl pipe to shell
+ CRITICAL PERSIST-002 Systemd timer creation (install script)
+ CRITICAL EXEC-REMOTE Fetches and runs code from https://cdn.evil.example/x.sh
+ HIGH FUNC-001 Network access in build function
STRUCTURAL CHANGES
~ gained an install script (runs as root)
~ now fetches from github.com/notrealauthor/mytool
~ no longer fetches from github.com/realauthor/mytool
1 finding(s) carried over unchanged--fail-on trips only on newly added findings, so a package with
long-standing Mediums can still be approved. Structural changes get their own
section because an install script appearing or upstream changing owner may
produce no findings on the day it happens, and it’s still the most important
line in the diff.
Name impersonation
The package name is the only thing most people read before paru -S. Three
different attacks live there, and they need different evidence:
| Code | Detects | Severity |
|---|---|---|
SQUAT-001 | A name that renders identically to a trusted one: Cyrillic а for ASCII a, or foo_bar for foo-bar | Critical |
SQUAT-002 | One look-alike or keyboard-adjacent keystroke from a high-value package, and no age or community standing of its own | High |
SQUAT-003 | A -bin/-git variant in different hands from its base, as context only | Low |
SQUAT-004 | A package inside a namespace you declared you own, published by an account you didn’t authorize | Critical |
Rendering collisions measured zero false positives: two honest packages never display the same name. Edit-distance matching measured 23,662 false positives for two-character edits and 2,039 for one-character insert/delete, so it isn’t tuned down, it isn’t there.
SQUAT-003 is Low on purpose. The AUR reserves no namespace, and 42.5% of real
build variants (5,650 packages) have a different maintainer than their base for
perfectly ordinary reasons. No metadata separates the impostor from those
thousands, so the scanner shows you the shape instead of accusing them.
[[owned_namespaces]]: make it decisive
The one thing that settles it is something only you know: which names you publish. Declare them in your config:
[[owned_namespaces]]
prefix = "aur-scanner"
maintainers = ["KiefStudio"]Now any aur-scanner or aur-scanner-<variant> package maintained by anyone
else, orphaned ones included, is a Critical SQUAT-004. The prefix matches the
exact name or a --separated suffix, so aur-scannerfoo isn’t in it. Empty by
default; nothing is assumed on your behalf.
Ownership signals
| Code | Fires when | Severity |
|---|---|---|
OWN-001 | The package is orphaned (11.9% of the AUR) | Low |
OWN-002 | Orphaned and flagged out-of-date (4.2%) | Medium |
OWN-003 | Flagged out-of-date for over a year | Low |
OWN-004 | Submitted in the last 30 days, zero votes, and runs build or install code | Low |
Zero votes alone is half the AUR and is never reported by itself.
Where name and ownership checks run. They need registry context (who maintains what, and the official package list).
check,install,aur-scan-wrapandsystem --rescansupply it.scan ./dir,diff, the pacman hook (offline by design, with no network calls inside a transaction), andsystemwithout--rescandon’t, and they emit noSQUAT-*orOWN-*findings rather than guess. On those paths the codes are not evaluated, which isn’t the same as clean.
Static binary analysis
A -bin package downloading a prebuilt tarball is normal. That’s what -bin
means. The signal is an executable committed into the AUR repository itself,
which is supposed to hold a build recipe, not an artifact. That’s the shape from
#29: an ELF sitting
beside the PKGBUILD, run with sudo during build().
A hand-written, bounds-checked ELF reader (no new dependency in a supply-chain
tool) parses the header, sections, DT_NEEDED, RPATH/RUNPATH, imported
symbols and section entropy. Nothing is executed, and ldd specifically is
off-limits: on glibc it runs its target through the loader, which on a hostile
binary means running the payload.
| Code | Detects | Severity |
|---|---|---|
BIN-001 | A prebuilt binary committed in the package directory | High |
BIN-002 | That binary is executed during the build | Critical |
BIN-003 | A prebuilt eBPF object (kernel code with no source to read) | Critical |
BIN-004 | Library search paths in a world-writable, relative, or out-of-tree location | High |
BIN-005 | A packed or encrypted section (over 7.5 bits/byte of entropy) | Medium |
It reads binaries in the AUR repo, not the upstream tarball a -bin package
downloads. For those, threat intelligence checks the
declared sha256sums against VirusTotal when you opt in.
Other new detections
SRC-010: a source fetches the same repository name from a different owner than the declaredurl=.SRC-008compares hosts and couldn’t see this.DEEP-003: Unicode bidirectional control characters (“Trojan Source”, CVE-2021-42574), which make the code you read differ from the code that runs.ESCAPE-001: extracting or copying to an absolute system path instead of$srcdir/$pkgdir.- Local
source=()files are read now. A payload in a build-fix.patch, or a side script the PKGBUILD sources, used to be invisible. PERM-001/PERM-002(world-writable permissions) ship as a community TOML file inrules.d/, so they double as a worked custom rule example.
Quality of life
- Shell completions for bash, zsh and fish, installed by the packages and
generated from the command tree so they can’t drift.
aur-scan completions <shell>prints one for source builds. It deliberately skips your config, so a typo inconfig.tomlcan’t break your shell at install time. aur-scan versionvalidates your config and exits 2 if it’s broken, so you can check before an upgrade instead of finding out when the pacman hook aborts.- Every config key is validated, inside every table. A typo like
virustotal_apikeyused to do nothing at all; now it’s a hard error.
Fixes worth knowing before you test
check --no-confirmexited 0 on a Critical. The gate only ran when--fail-onwas passed, and the shell integration didn’t pass it, so withAUR_SCAN_INTERACTIVE=0it wasn’t gating. Fixed on both sides, and backported to stable in 2.1.1.AUR_SCAN_MODE=installwas weaker than documented. It ran without registry context and only blocked on Critical. Both modes now honor the same threshold.- Finding text is sanitized. Findings quote package-controlled text, and printing it raw let an escape sequence in the scanned file drive your terminal.
- Shell integrations no longer print to stdout, which broke
scp,rsyncandssh host cmdwhen sourced from a shell rc. - False positives fixed:
SHELL-002matching thencat the end ofMEGAsync(#32), andDIFF-003firing on a routine patch file.
Known gaps, named: decompression bombs and hostile makedepends aren’t
detected yet, and VirusTotal still keys off declared sha256sums rather than
hashing a committed binary.
The full list is in the changelog, under 2.2.0-rc.1 and rc.2.