Find a file
joe a9e4da9e77
Fold the quick check back into one command with a yes gate
Two pastes was friction for a check people should run casually. Chain the
fetch, the less, a typed-yes prompt and the run together, so nothing executes
until the file has been shown and confirmed.

The prompt takes a word rather than one keypress: input typed during less is
buffered and handed straight to read on exit, which a single-key gate would
happily answer itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:11:26 +01:00
.gitignore Rename check.py to aurcheck.py 2026-08-05 18:36:32 +01:00
aurcheck.py Rename check.py to aurcheck.py 2026-08-05 18:36:32 +01:00
fetch.sh Add AUR malware feed checker 2026-08-05 18:27:57 +01:00
README.md Fold the quick check back into one command with a yes gate 2026-08-05 19:11:26 +01:00

aur-malware

Cross-references your installed AUR packages against the aur-audit malware feed and reports any hits.

The feed classifies flagged AUR packages into two tiers:

Tier Meaning
black Confirmed malicious (e.g. runs a bundled binary via sudo)
red Suspicious (e.g. bundled ELF with exec + network + obfuscation)

Each package carries blackFlags / redFlags explaining why it was flagged.

Quick check

aurcheck.py is a single self-contained standard-library script, so checking a system needs no clone and no pip install — fetch it, read it, then decide:

curl -fsSL https://git.nuke.ie/joe/aur-malware/raw/branch/main/aurcheck.py --output ./aurcheck.py &&
  less ./aurcheck.py &&
  printf 'Run it? [type yes] ' && read -r a && [ "$a" = yes ] &&
  chmod +x ./aurcheck.py && ./aurcheck.py

Piping a downloaded script straight into a shell is exactly the habit that gets people owned by AUR packages in the first place, so nothing executes until you've been shown the file and typed yes at it. It's ~430 lines, stdlib only, no shell-outs beyond pacman. Anything other than yes — enter, n, Ctrl-C — breaks the chain, leaving the script on disk unexecuted and still not +x.

The prompt takes a word rather than a single y keypress on purpose: keystrokes you type while less is open sit in the terminal buffer and get handed to read the moment it exits, so a one-key gate can answer itself if you were mashing space to page through. Works in sh, bash and zsh.

Be honest with yourself about what this is: the shell can force you to look at the file, but only you can decide the code is fine, and a prompt at the end of a command you already pasted is a weak place to make that call. If you don't intend to actually read 430 lines, stop after less and run the rest later, or don't run it at all.

That last step fetches the current feed and reports on the AUR packages you have installed. Exit status 0 means clean, 2 means something was flagged (see Exit codes). Needs Python 3 and pacman; the cached red.json / black.json are written next to the script.

Files

File Purpose
aurcheck.py Main tool — fetches the feed and checks it against your packages
fetch.sh Minimal helper that dumps the raw feed JSON to disk
red.json Cached "red" feed (written on each aurcheck.py fetch)
black.json Cached "black" feed (written on each aurcheck.py fetch)

Usage

./aurcheck.py                     # check installed AUR pkgs (pacman -Qmq), fetch fresh
./aurcheck.py -c linux-devmgmt    # check a specific package (takes several names)
./aurcheck.py --no-fetch          # use cached red.json/black.json (offline)
./aurcheck.py -f packages.txt     # check a newline-delimited package-name list
./aurcheck.py --all               # dump the whole feed, ignore your package list
./aurcheck.py --status            # how fresh is the audit data?
./aurcheck.py --json              # machine-readable output

By default it checks only the AUR packages you actually have installed (pacman -Qmq), so you don't wade through hundreds of irrelevant entries.

-c/--check reports on named packages whether or not they're installed — useful before you install something. Unlike the other modes, it explicitly prints a CLEAN line for packages that aren't in either feed, so a silent result never gets mistaken for "not checked".

It also verifies the package exists, via the AUR RPC (rpc/v5/info), so a typo can't come back looking clean:

 NOT FOUND  linux-devmgt
  no such package in the AUR or your sync repos — check the spelling

 CLEAN  firefox
  not listed in the red or black feed
  ? official repo package (extra), not from the AUR

 BLACK  lib32-sdl2_image-bin 2.8.12-1
  ■ Executes bundled binary 'preprocessor' with sudo privileges ...
  ? no longer in the AUR — package may have been deleted

A name not in the AUR is checked against your local sync db (pacman -Si, no network) before being called missing, so official-repo packages are identified as such rather than reported as typos. --no-fetch skips the lookup entirely and says so (? AUR existence not verified).

In --json mode every entry carries an existence field — aur, repo (plus repo: <name>), missing, or unverified — orthogonal to tier, which stays the feed verdict:

[{"tier": "CLEAN", "packageName": "linux-devmgt", "existence": "missing"},
 {"tier": "CLEAN", "packageName": "firefox", "existence": "repo", "repo": "extra"}]

Feed freshness

A clean result is only as good as the last time the scanner ran, so every run prints the newest analysis date, with a warning past STALE_HOURS (24h):

feed: 101 black + 325 red  |  newest analysis 2026-08-01 14:19 UTC (4d 3h ago)
warning: last flagged-package analysis was 4d 3h ago — anything published
since may be unaudited ...

--status gives the full picture, including the service-wide freshness that the flagged feeds alone can't show:

  black  101 pkg(s)
    published  2026-07-30 15:34 UTC  ->  2026-08-01 11:51 UTC
    analysed   2026-07-30 16:20 UTC  ->  2026-08-01 13:59 UTC  (4d 3h ago)
  red    325 pkg(s)
    ...
  all scanned
    newest analysis anywhere in the feed: 2026-08-02 20:13 UTC  (2d 21h ago)

 STALE  scanner last ran 2d 21h ago (2026-08-02 20:13 UTC)

The two dates differ on purpose. The inline note uses the red/black feeds already in memory (no extra request), and the newest flagged package can easily be older than the newest scan — a quiet week isn't an outage. --status additionally fetches one page of filter=scanned (newest-first, server caps pages at 500, so one page suffices) to get the real "is the scanner alive?" answer. With --no-fetch it reports from cache and says so.

Health baseline: when running, the scanner analyses a package a median 47100 min after it hits the AUR (p90 ~140 min). Silence much beyond that means it has stopped, not that nothing was published.

Exit codes

Code Meaning
0 No flagged packages found (clean); --status: feed is fresh
1 -c: a named package doesn't exist anywhere; --status: feed is stale
2 One or more flagged packages found

The non-zero exit on hits makes it drop-in usable for cron/systemd timers or a pacman hook — it fails loudly only when something is actually wrong.

How it works

  1. Fetches filter=black and filter=red from the API, following nextCursor pagination, and caches each to disk.
  2. If the network is unavailable, falls back to the cached JSON automatically.
  3. Builds a name→tier lookup (black wins over red if a package is in both).
  4. Intersects that with your installed AUR packages and prints the hits, black first, with every flag reason.

Only the Python standard library is used — no pip dependencies.

API reference

GET https://aur-audit.wtako.net/packages?filter=<black|red>&limit=<n>&cursor=<c>

Returns {"packages": [...], "nextCursor": <str|null>}. Each package object:

{
  "packageName": "lib32-sdl2_image-bin",
  "version": "2.8.12-1",
  "aurUrl": "https://aur.archlinux.org/packages/lib32-sdl2_image-bin",
  "blackFlags": ["Executes bundled binary 'preprocessor' with sudo ..."],
  "redFlags":   ["Binary executable found (ELF): preprocessor"],
  "yellowFlags": []
}