Skip to content

Commit 9664be9

Browse files
docs: changelog and readme for v0.8.0
1 parent 86b1472 commit 9664be9

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.0] - 2026-08-24
11+
1012
### Added
13+
- **Install-scriptlet scanning — the `xsnow` / `xsnow-bin` worm class.** A
14+
pacman install scriptlet is never executed by makepkg: it is embedded in the
15+
built package as `.INSTALL` and run by libalpm **as root on the installing
16+
machine**, on every install and every upgrade, with no `$pkgdir`. Nothing in
17+
the PKGBUILD sources it — the only link is a filename string in `install=`. A
18+
scanner that reviews the PKGBUILD alone can therefore report a package clean
19+
with complete syntactic honesty while never having seen its payload. This is
20+
exactly what the hostile `xsnow` package exploited: a dot-prefixed
21+
`.xsnow.install` that fetched a binary over Tor into `/usr/local/bin`,
22+
persisted it as a systemd unit, harvested `~/.ssh`, `/root/.ssh` and
23+
`/home/*/.ssh`, and pushed **itself** back to every AUR repository the
24+
victim's key could reach — so each new victim's PKGBUILD looked as clean as
25+
the last one's.
26+
- **`REF-001``REF-004` — reference resolution.** Every file the package names
27+
(`install=`, local `source=()` entries) is resolved against the file set the
28+
scanner actually received, with iterative variable substitution so
29+
`install=$pkgname.install` resolves through `pkgname=jdk${java_}-graalvm-bin`.
30+
A referenced file that was **not supplied** means the scan is *incomplete*,
31+
which is a different claim from "the package is clean" and now blocks an `OK`
32+
verdict (`REF-001`, `REF-003`). A dot-prefixed `install=` is concealment —
33+
pacman resolves it, `ls` and dotfile-skipping tools do not — and is **fatal on
34+
its own** (`REF-002`). `REF-004` reports a local source file that is genuinely
35+
absent.
36+
- **Eleven rules for the worm family.** `PERSIST-007` (remote payload into a
37+
system binary directory), `PERSIST-008` (`chmod +x` on a system path),
38+
`PERSIST-009` (scriptlet writes a systemd unit), `PERSIST-010` (timer
39+
directives in a scriptlet), `PKGMGR-001` (`pacman -S` from a scriptlet),
40+
`EXFIL-004` (`.onion` C2), `EXFIL-005` (SOCKS proxying), `CRED-004`
41+
(root/all-user SSH enumeration), `CRED-005` (`~/.ssh` moved or symlinked
42+
away), `WORM-001` (script copies itself), `WORM-002`/`WORM-003` (AUR push
43+
credentials, `git push`), and `HOOK-001` (scriptlet detaches into the
44+
background). The install-scoped rules only fire in files pacman actually
45+
executes; `WORM-002`/`WORM-003` are further scoped away from maintainer
46+
tooling (`release`, `update.sh`) that makepkg never runs.
47+
- **Redirection targets in the deobfuscated command view.** The payload of a
48+
heredoc-written systemd unit lives in a `Redirect`, not in the command's
49+
arguments, so `cat <<EOF >/etc/systemd/system/X.service` was previously
50+
invisible to every command-scoped rule. Redirect operators and targets are now
51+
rendered, along with a background (`&`) flag.
52+
- **Trusted file manifest in the auditor prompt.** The model is told exactly
53+
which files it received, so it can distinguish "this package has no install
54+
scriptlet" from "I was not given the install scriptlet" — a distinction the
55+
PKGBUILD alone cannot express.
56+
57+
### Changed
58+
- **Static findings are folded into the checklist rather than bolted on.**
59+
Deterministic rule hits are converted to first-class `Check` entries and run
60+
through the same `deriveVerdict` as the model's own answers, so one derivation
61+
produces the verdict, per-finding severities, confidence and summary. The
62+
model may always **escalate**; it has no mechanism to **clear** a finding in
63+
the non-overridable set. This is a floor, not an override: an ordinary
64+
critical hit in a PKGBUILD is still the model's call, and
65+
`AURSCAN_STRICT_FLOOR=1` widens it for those who want it.
66+
- **Collectors no longer overstate their coverage.** `maxTotalBytes` raised from
67+
240 KB to 512 KB (`openssl-1.1` ships 41 patches totalling 382 KB, whose tail
68+
was silently dropped), and a file the collector skips — oversized, non-text,
69+
or past the aggregate cap — is now **recorded** rather than discarded. It
70+
appears in the file set marked as omitted, so reference resolution does not
71+
report the scanner's own truncation as a missing source, and the prompt lists
72+
it as *not reviewed* instead of asserting the supplied set is exhaustive.
73+
- **Verdict-cache version bumped to `v3`** for the new checklist ids
74+
(`install_scriptlet_worm`, `hidden_install_scriptlet`,
75+
`scriptlet_system_takeover`, `incomplete_scan`).
76+
77+
### Fixed
78+
79+
The rule catalog was calibrated against **158 real AUR packages** cloned from
80+
upstream. The unit suite was green at every stage below, including the stages
81+
where a rule was wrong on every real package it touched. Across the corpus,
82+
**53 MALICIOUS verdicts and ~120 findings became 1 and 20**, with no known false
83+
positives remaining. The recurring defect was rules reporting the *scanner's*
84+
confusion as a property of the package.
85+
86+
- **`PERSIST-002` matched a bare `.timer` in any file** — a `REUSE.toml` listing
87+
`"*.timer"` among its licence globs read as systemd persistence, 51 hits, all
88+
wrong. It now requires an *action* (`systemctl enable|start … .timer`, or a
89+
redirection into a systemd directory) and is scoped to shell content.
90+
Installing a timer unit into `$pkgdir` is normal packaging and no longer
91+
fires. `PERSIST-001`/`PERSIST-004` gained the same file scoping.
92+
- **`PRIV-001` flagged `optdepends = sudo:` in `.SRCINFO`** — a dependency
93+
*declaration*, not an invocation. Command rules are now scoped to shell files:
94+
a non-shell file fails to parse and previously fell back to raw-text matching.
95+
- **`DLE-001`/`DLE-002` matched `wget … | sha256sum`**, because `sh` matched the
96+
head of `sha256sum`. Both codes are non-overridable, so a missing word
97+
boundary would have made an ordinary checksum helper permanently unpassable.
98+
- **`CHK-005` rewritten from a regex to positional pairing.** `SKIP` is correct
99+
and universal for a VCS checkout and for a detached signature verified by gpg.
100+
The crux was **brace expansion**: `source=(url{,.sig})` is one array token but
101+
two sources, so every checksum after it was off by one and the trailing `SKIP`
102+
— belonging to the signature — was attributed to whatever came next. Now also
103+
covers `b2sums`/`sha512sums`/etc. (previously `sha256sums` only) and
104+
arch-suffixed arrays, and exempts local files, whose integrity is the
105+
repository's. 13 hits became 2, both genuine.
106+
- **Source-array parsing unified.** Quote stripping (never truncation — the
107+
`name::url` separator routinely sits outside the quotes), brace expansion,
108+
`$( )`-aware tokenisation (a substitution may contain spaces) and
109+
local-vs-remote classification (the scheme is often inside `$url`) are now done
110+
once and shared, after four separate bugs lived in the same decision.
111+
- **Host classification split into three questions.** Canonical distribution
112+
points and language registries (`ftp.gnu.org`, `files.pythonhosted.org`,
113+
`registry.npmjs.org`, Maven Central, …) no longer trip a `url=` mismatch — a
114+
project's homepage is never its distribution host. GitHub's raw and object
115+
origins inherit `github.com`. Community asset hosts (`opendesktop.org`,
116+
`pling.com`, `store.kde.org`, …) are classified as **generic** rather than
117+
allowlisted: the upload path is chosen by whoever uploads, so the host
118+
establishes nothing about who produced the file.
119+
11120
- **Verdict reproducibility (discussion #56).** An identical re-scan no longer
12121
risks flipping the verdict. Two changes: sampling **temperature now defaults to
13122
0** (greedy) on the `api` and `openai` backends — the `api` path previously
@@ -415,7 +524,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
415524
- Makefile, installer with update/uninstall, AUR `PKGBUILD`, and CI that
416525
attaches UPX-packed release artifacts on tags.
417526

418-
[Unreleased]: https://github.com/manticore-projects/aurscan/compare/v0.7.1...HEAD
527+
[Unreleased]: https://github.com/manticore-projects/aurscan/compare/v0.8.0...HEAD
528+
[0.8.0]: https://github.com/manticore-projects/aurscan/compare/v0.7.1...v0.8.0
419529
[0.7.1]: https://github.com/manticore-projects/aurscan/compare/v0.7.0...v0.7.1
420530
[0.7.0]: https://github.com/manticore-projects/aurscan/compare/v0.6.4...v0.7.0
421531
[0.2.2]: https://github.com/manticore-projects/aurscan/compare/v0.2.1...v0.2.2

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ In July 2025 the AUR packages `firefox-patch-bin`, `librewolf-fix-bin`, and `zen
6464

6565
Then in June 2026 the **Atomic Arch** campaign made the point at scale. Attackers adopted **1,500+ orphaned** AUR packages and added a post-install step running `npm install atomic-lockfile`, later `bun install js-digest`, which pulled a Rust credential stealer and, when built as root, an **eBPF rootkit**. Some used git commit forgery to impersonate a trusted maintainer. The package name and history were unchanged. Only the build instructions, and who wrote them, had quietly changed.
6666

67-
aurscan is built for exactly this: the unfamiliar trick, not just the one you happen to know. Its prompt and static rules encode both of the signatures above, and the model is there to catch the next one nobody has seen yet.
67+
Then in August 2026 `xsnow` and `xsnow-bin` moved the payload out of the PKGBUILD entirely. The PKGBUILD was clean. The attack lived in a dot-prefixed **`.xsnow.install`** scriptlet, which `makepkg` never executes — it is embedded in the built package as `.INSTALL` and run by pacman **as root on the installing machine**, on every install and every upgrade. It fetched a binary over Tor into `/usr/local/bin`, persisted it as a systemd unit, harvested `~/.ssh`, `/root/.ssh` and `/home/*/.ssh`, and then pushed **itself** into every AUR repository the victim's key could reach — so each new victim's PKGBUILD looked exactly as clean as the last one's. The only link between the PKGBUILD and the payload was a filename string in `install=`, and the leading dot hid the file from `ls` and from any tool that skips dotfiles.
68+
69+
aurscan is built for exactly this: the unfamiliar trick, not just the one you happen to know. Its prompt and static rules encode all three of the signatures above, and the model is there to catch the next one nobody has seen yet.
6870

6971
## Install
7072

@@ -419,6 +421,7 @@ aurscan --debug --score ./PKGBUILD
419421
| `AURSCAN_TIMEOUT` | `180` | per-request budget in **seconds**; raise it for slow CPU-only models |
420422
| `AURSCAN_INSTRUCTIONS` || path to extra auditor instructions (appended) |
421423
| `AURSCAN_RULES_ONLY` || `1` = static rules only, never call a model |
424+
| `AURSCAN_STRICT_FLOOR` || `1` = any critical static hit prevents an `OK` verdict, not only the non-overridable ones |
422425
| `AURSCAN_NO_CACHE` || `1` = disable the verdict cache (no read, no write) |
423426
| `AURSCAN_CACHE_DIR` | `$XDG_CACHE_HOME/aurscan/verdicts` | verdict-cache location |
424427
| `AURSCAN_CACHE_TTL` | `30` | verdict-cache lifetime in **days**; `0` = never expire |
@@ -457,6 +460,10 @@ aurscan --rules-only <pkgname|./dir> # or set AURSCAN_RULES_ONLY=1
457460

458461
**Quote-aware — obfuscation does not slip past.** The command, flag and path rules do not match raw text. The `PKGBUILD` and `.install` scripts are parsed with a real shell parser ([`mvdan.cc/sh`](https://github.com/mvdan/sh), pure-Go, vendored, **never executed**) and the rules run against the *deobfuscated* command view. So split-token tricks like `s"ud"o`, `cu""rl … | sh`, `su$'\x64'o` and `${IFS:0:0}sudo` are caught as the commands they actually run, while a `sudo` printed inside an `echo` instruction is correctly ignored instead of false-flagging. The splicing itself is also reported as **`OBF-004` (critical)** — a PKGBUILD has no honest reason to disguise a command name, so any attempt is treated as a strong signal in its own right, even when the disguised command is otherwise harmless.
459462

463+
**Install scriptlets are scanned, and a missing one is not a pass.** A `.install` scriptlet is where a package gets root on *your* machine, and it is reachable from the PKGBUILD only through a filename string in `install=`. aurscan resolves every referenced file — `install=`, local `source=()` entries — against the files it actually received, substituting PKGBUILD variables to do it. If a referenced file was **not supplied**, the scan is *incomplete*, which is a different claim from "clean": an `OK` verdict is no longer possible (**`REF-001`/`REF-003`**). A dot-prefixed `install=` is treated as concealment and is fatal on its own (**`REF-002`**). Within a scriptlet, rules cover the root-level behaviour that has no legitimate form there: a remote payload dropped into a system binary directory and made executable, a systemd unit written and enabled, `pacman` pulling in the payload's own dependencies, `.onion` C2 and SOCKS proxying, SSH material read or relocated, AUR push credentials used, and a script that copies **itself** — the signature of a worm, full stop.
464+
465+
**A rule hit is a fact; a model verdict is a judgement.** Deterministic findings are folded into the auditor's checklist as first-class checks and derived through the same code path as the model's own answers, so one derivation produces the verdict, severities, confidence and summary. The model can always **escalate**. It has no mechanism to **clear** a finding in the non-overridable set — which is how a scanner ends up reporting "97% confidence, no concerns" on a package whose payload it never read. This is deliberately a narrow floor: an ordinary critical hit in a PKGBUILD is still the model's call to dismiss, and `AURSCAN_STRICT_FLOOR=1` widens it if you would rather it were not.
466+
460467
**Build-cache hygiene — your `$HOME` should stay yours.** A `go build`/`go install` without a confined `GOPATH`/`GOMODCACHE` writes the module cache to `~/go/pkg/mod` (read-only files, unless `-modcacherw`); a `cargo build`/`cargo fetch` without `CARGO_HOME` writes registry and git caches to `~/.cargo`. Not malicious — failure by omission — but a scanner that promises "nothing ran yet" should tell you the build will write outside `$srcdir`. Reported as **`BLD-001`/`BLD-002` (info)**. Suppressed when the PKGBUILD exports or inline-prefixes the variable (an export in `prepare()` covers `build()` — same makepkg process) or, for Go, builds vendored with `-mod=vendor`. These checks use the same command-position-aware view: an echo'd `go build` does not fire.
461468

462469
## Reproducibility

0 commit comments

Comments
 (0)