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 one

Like 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:

CodeFires whenSeverity
DIFF-001The package raises findings it didn’t raise last timeWorst new finding
DIFF-002The maintainer changed (High if an orphaned package was just adopted)High / Medium
DIFF-003A source now points at a host/owner/repo it didn’t use beforeHigh
DIFF-004An 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 json
Comparing 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:

CodeDetectsSeverity
SQUAT-001A name that renders identically to a trusted one: Cyrillic а for ASCII a, or foo_bar for foo-barCritical
SQUAT-002One look-alike or keyboard-adjacent keystroke from a high-value package, and no age or community standing of its ownHigh
SQUAT-003A -bin/-git variant in different hands from its base, as context onlyLow
SQUAT-004A package inside a namespace you declared you own, published by an account you didn’t authorizeCritical

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

CodeFires whenSeverity
OWN-001The package is orphaned (11.9% of the AUR)Low
OWN-002Orphaned and flagged out-of-date (4.2%)Medium
OWN-003Flagged out-of-date for over a yearLow
OWN-004Submitted in the last 30 days, zero votes, and runs build or install codeLow

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-wrap and system --rescan supply it. scan ./dir, diff, the pacman hook (offline by design, with no network calls inside a transaction), and system without --rescan don’t, and they emit no SQUAT-* or OWN-* 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.

CodeDetectsSeverity
BIN-001A prebuilt binary committed in the package directoryHigh
BIN-002That binary is executed during the buildCritical
BIN-003A prebuilt eBPF object (kernel code with no source to read)Critical
BIN-004Library search paths in a world-writable, relative, or out-of-tree locationHigh
BIN-005A 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 declared url=. SRC-008 compares 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 in rules.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 in config.toml can’t break your shell at install time.
  • aur-scan version validates 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_apikey used to do nothing at all; now it’s a hard error.

Fixes worth knowing before you test

  • check --no-confirm exited 0 on a Critical. The gate only ran when --fail-on was passed, and the shell integration didn’t pass it, so with AUR_SCAN_INTERACTIVE=0 it wasn’t gating. Fixed on both sides, and backported to stable in 2.1.1.
  • AUR_SCAN_MODE=install was 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, rsync and ssh host cmd when sourced from a shell rc.
  • False positives fixed: SHELL-002 matching the nc at the end of MEGAsync (#32), and DIFF-003 firing 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.