diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index aa55bfd..0105231 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,8 +1,8 @@ # Mole Security Audit Report -**Date:** December 18, 2025 +**Date:** December 22, 2025 -**Audited Version:** Current `main` branch (V1.13.10) +**Audited Version:** Current `main` branch (V1.14.0) **Status:** Passed @@ -42,7 +42,7 @@ The interactive analyzer (`mo analyze`) operates on a different security model f - **Manual Confirmation**: Deletions are not automated; they require explicit user selection and confirmation. - **OS-Level Enforcement**: Unlike the automated scripts, the analyzer relies on the operating system's built-in protections (e.g., inability to delete `/System` due to Read-Only Volume or SIP) rather than a hardcoded application-level blocklist. -## 3. Conservative Cleaning Logic +## 3. Conservative Cleaning Logic (Updated) Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentionally conservative: @@ -60,20 +60,20 @@ Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentio - **System Integrity Protection (SIP) Awareness** Mole respects macOS SIP. It detects if SIP is enabled and automatically skips protected directories (like `/Library/Updates`) to avoid triggering permission errors. -- **Spotlight Preservation (Critical Fix)** - User-level Spotlight caches (`~/Library/Metadata/CoreSpotlight`) are strictly excluded from automated cleaning. This prevents corruption of System Settings and ensures stable UI performance for indexed searches. +- **Spotlight & System Settings Preservation** + User-level Spotlight caches (`~/Library/Metadata/CoreSpotlight`) remain excluded to prevent UI corruption. New centralized `is_critical_system_component` guarding System Settings / Control Center / Background Task Management / SFL / TCC prevents accidental cleanup even when names change across macOS versions. - **Time Machine Preservation** - Before cleaning failed backups, Mole checks for the `backupd` process. If a backup is currently running, the cleanup task is strictly **aborted** to prevent data corruption. + Before cleaning failed backups, Mole checks for the `backupd` process and uses strict timeouts to avoid hangs. Cleanup is **aborted** if a backup is running or the destination is unresponsive. - **VPN & Proxy Protection** Mole includes a comprehensive protection layer for VPN and Proxy applications (e.g., Shadowsocks, V2Ray, Tailscale). It protects both their application bundles and data directories from automated cleanup to prevent network configuration loss. -- **AI & LLM Data Protection (New in v1.12.25)** - Mole now explicitly protects data for AI tools (Cursor, Claude, ChatGPT, Ollama, LM Studio, etc.). Both the automated cleaning logic (`bin/clean.sh`) and orphan detection (`lib/core/app_protection.sh`) exclude these applications to prevent loss of: - - Local LLM models (which can be gigabytes in size). - - Authentication tokens and session states. - - Chat history and local configurations. +- **AI & LLM Data Protection** + Mole explicitly protects data for AI tools (Cursor, Claude, ChatGPT, Ollama, LM Studio, etc.). Automated cleaning and orphan detection exclude these apps to prevent loss of models, tokens, sessions, and configs. + +- **Safer Globbing** + Automated cleanup loops now use scoped `nullglob` to avoid unintended literal patterns when directories are empty, reducing edge-case surprises. ## 4. Atomic Operations & Crash Safety diff --git a/analyze b/analyze new file mode 100755 index 0000000..fa96e61 Binary files /dev/null and b/analyze differ diff --git a/bin/analyze-go b/bin/analyze-go index ddbd8d6..bfd23c4 100755 Binary files a/bin/analyze-go and b/bin/analyze-go differ diff --git a/bin/status-go b/bin/status-go index 941b375..2559691 100755 Binary files a/bin/status-go and b/bin/status-go differ diff --git a/bin/uninstall.sh b/bin/uninstall.sh index b9caa6d..05d62cf 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -27,44 +27,6 @@ total_items=0 files_cleaned=0 total_size_cleaned=0 -# Compact the "last used" descriptor for aligned summaries -format_last_used_summary() { - local value="$1" - - case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; - esac - - if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}d ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}w ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}y ago" - return 0 - fi - echo "$value" -} - # Scan applications and collect information scan_applications() { # Application scan with intelligent caching (24h TTL) @@ -211,9 +173,9 @@ scan_applications() { local last_used_epoch=0 if [[ -d "$app_path" ]]; then - # Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed + # Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed local metadata_date - metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index eda7f65..92426af 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -210,9 +210,9 @@ scan_applications() { local last_used_epoch=0 if [[ -d "$app_path" ]]; then - # Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed + # Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed local metadata_date - metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go index 8d68d50..4d63990 100644 --- a/cmd/analyze/delete.go +++ b/cmd/analyze/delete.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "strings" "sync/atomic" @@ -28,10 +29,19 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { var totalCount int64 var errors []string - for _, path := range paths { + // Delete deeper paths first to avoid parent removal triggering child not-exist errors + pathsToDelete := append([]string(nil), paths...) + sort.Slice(pathsToDelete, func(i, j int) bool { + return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator)) + }) + + for _, path := range pathsToDelete { count, err := deletePathWithProgress(path, counter) totalCount += count if err != nil { + if os.IsNotExist(err) { + continue // Parent already removed - not an actionable error + } errors = append(errors, err.Error()) } } diff --git a/cmd/analyze/delete_test.go b/cmd/analyze/delete_test.go new file mode 100644 index 0000000..1b8e281 --- /dev/null +++ b/cmd/analyze/delete_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "parent") + child := filepath.Join(parent, "child") + + // Create structure: + // parent/fileA + // parent/child/fileC + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(parent, "fileA"), []byte("a"), 0o644); err != nil { + t.Fatalf("write fileA: %v", err) + } + if err := os.WriteFile(filepath.Join(child, "fileC"), []byte("c"), 0o644); err != nil { + t.Fatalf("write fileC: %v", err) + } + + var counter int64 + msg := deleteMultiplePathsCmd([]string{parent, child}, &counter)() + progress, ok := msg.(deleteProgressMsg) + if !ok { + t.Fatalf("expected deleteProgressMsg, got %T", msg) + } + if progress.err != nil { + t.Fatalf("unexpected error: %v", progress.err) + } + if progress.count != 2 { + t.Fatalf("expected 2 files deleted, got %d", progress.count) + } + if _, err := os.Stat(parent); !os.IsNotExist(err) { + t.Fatalf("expected parent to be removed, err=%v", err) + } + if _, err := os.Stat(child); !os.IsNotExist(err) { + t.Fatalf("expected child to be removed, err=%v", err) + } +} diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 01e0d52..2dc89d4 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -579,10 +579,12 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if len(pathsToDelete) == 1 { - m.status = fmt.Sprintf("Deleting %s...", filepath.Base(pathsToDelete[0])) - } else { - m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) + targetPath := pathsToDelete[0] + m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) + return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) } + + m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) case "esc", "q": // Cancel delete with ESC or Q diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 7224a98..df81b57 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -607,49 +607,64 @@ func getDirectorySizeFromDu(path string) (int64, error) { } func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) { - ctx, cancel := context.WithTimeout(context.Background(), duTimeout) - defer cancel() + runDuSize := func(target string) (int64, error) { + if _, err := os.Stat(target); err != nil { + return 0, err + } - args := []string{"-sk"} - // macOS du uses -I to ignore files/directories matching a pattern + ctx, cancel := context.WithTimeout(context.Background(), duTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "du", "-sk", target) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return 0, fmt.Errorf("du timeout after %v", duTimeout) + } + if stderr.Len() > 0 { + return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String()) + } + return 0, fmt.Errorf("du failed: %v", err) + } + fields := strings.Fields(stdout.String()) + if len(fields) == 0 { + return 0, fmt.Errorf("du output empty") + } + kb, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse du output: %v", err) + } + if kb <= 0 { + return 0, fmt.Errorf("du size invalid: %d", kb) + } + return kb * 1024, nil + } + + // When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library" if excludePath != "" { - // Extract just the directory name from the full path - excludeName := filepath.Base(excludePath) - args = append(args, "-I", excludeName) - } - args = append(args, path) - - cmd := exec.CommandContext(ctx, "du", args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return 0, fmt.Errorf("du timeout after %v", duTimeout) + totalSize, err := runDuSize(path) + if err != nil { + return 0, err } - if stderr.Len() > 0 { - return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String()) + excludeSize, err := runDuSize(excludePath) + if err != nil { + if !os.IsNotExist(err) { + return 0, err + } + excludeSize = 0 } - return 0, fmt.Errorf("du failed: %v", err) + if excludeSize > totalSize { + excludeSize = 0 + } + return totalSize - excludeSize, nil } - fields := strings.Fields(stdout.String()) - if len(fields) == 0 { - return 0, fmt.Errorf("du output empty") - } - kb, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - return 0, fmt.Errorf("failed to parse du output: %v", err) - } - if kb <= 0 { - return 0, fmt.Errorf("du size invalid: %d", kb) - } - return kb * 1024, nil + + return runDuSize(path) } -func getDirectoryLogicalSize(path string) (int64, error) { - return getDirectoryLogicalSizeWithExclude(path, "") -} func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) { var total int64 diff --git a/cmd/analyze/scanner_test.go b/cmd/analyze/scanner_test.go new file mode 100644 index 0000000..718d276 --- /dev/null +++ b/cmd/analyze/scanner_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFileWithSize(t *testing.T, path string, size int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + content := make([]byte, size) + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) { + base := t.TempDir() + homeFile := filepath.Join(base, "fileA") + libFile := filepath.Join(base, "Library", "fileB") + projectLibFile := filepath.Join(base, "Projects", "Library", "fileC") + + writeFileWithSize(t, homeFile, 100) + writeFileWithSize(t, libFile, 200) + writeFileWithSize(t, projectLibFile, 300) + + total, err := getDirectoryLogicalSizeWithExclude(base, "") + if err != nil { + t.Fatalf("getDirectoryLogicalSizeWithExclude (no exclude) error: %v", err) + } + if total != 600 { + t.Fatalf("expected total 600 bytes, got %d", total) + } + + excluding, err := getDirectoryLogicalSizeWithExclude(base, filepath.Join(base, "Library")) + if err != nil { + t.Fatalf("getDirectoryLogicalSizeWithExclude (exclude Library) error: %v", err) + } + if excluding != 400 { + t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding) + } +} diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 8837af3..bee91b9 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -484,10 +484,17 @@ clean_project_artifacts() { local path="$1" # Find the project root by looking for direct child of search paths - local search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") + local search_roots=() + if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then + search_roots=("${PURGE_SEARCH_PATHS[@]}") + else + search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") + fi for root in "${search_roots[@]}"; do - if [[ "$path" == "$root/"* ]]; then + # Normalize trailing slash for consistent matching + root="${root%/}" + if [[ -n "$root" && "$path" == "$root/"* ]]; then # Remove root prefix and get first directory component local relative_path="${path#"$root"/}" # Extract first directory name diff --git a/lib/clean/user.sh b/lib/clean/user.sh index be80970..f4d4545 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -119,6 +119,11 @@ clean_sandboxed_app_caches() { local containers_dir="$HOME/Library/Containers" [[ ! -d "$containers_dir" ]] && return 0 + # Enable nullglob for safe globbing; restore afterwards + local _ng_state + _ng_state=$(shopt -p nullglob || true) + shopt -s nullglob + if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning sandboxed apps..." fi @@ -146,6 +151,9 @@ clean_sandboxed_app_caches() { ((total_items++)) note_activity fi + + # Restore nullglob to previous state + eval "$_ng_state" } # Process a single container cache directory (reduces nesting) @@ -155,14 +163,10 @@ process_container_cache() { # Extract bundle ID and check protection status early local bundle_id=$(basename "$container_dir") - local bundle_id_lower=$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]') - - # Check explicit critical system components (case-insensitive regex) - if [[ "$bundle_id_lower" =~ backgroundtaskmanagement || "$bundle_id_lower" =~ loginitems || "$bundle_id_lower" =~ systempreferences || "$bundle_id_lower" =~ systemsettings || "$bundle_id_lower" =~ settings || "$bundle_id_lower" =~ preferences || "$bundle_id_lower" =~ controlcenter || "$bundle_id_lower" =~ biometrickit || "$bundle_id_lower" =~ sfl || "$bundle_id_lower" =~ tcc ]]; then + if is_critical_system_component "$bundle_id"; then return 0 fi - - if should_protect_data "$bundle_id" || should_protect_data "$bundle_id_lower"; then + if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')"; then return 0 fi @@ -180,10 +184,14 @@ process_container_cache() { if [[ "$DRY_RUN" != "true" ]]; then # Clean contents safely (rm -rf is restricted by safe_remove) + local _ng_item_state + _ng_item_state=$(shopt -p nullglob || true) + shopt -s nullglob for item in "$cache_dir"/*; do [[ -e "$item" ]] || continue safe_remove "$item" true || true done + eval "$_ng_item_state" fi fi } @@ -259,6 +267,9 @@ clean_application_support_logs() { local found_any=false # Clean log directories and cache patterns + local _ng_app_state + _ng_app_state=$(shopt -p nullglob || true) + shopt -s nullglob for app_dir in ~/Library/Application\ Support/*; do [[ -d "$app_dir" ]] || continue @@ -276,7 +287,7 @@ clean_application_support_logs() { continue fi - if [[ "$app_name_lower" =~ backgroundtaskmanagement || "$app_name_lower" =~ loginitems || "$app_name_lower" =~ systempreferences || "$app_name_lower" =~ systemsettings || "$app_name_lower" =~ settings || "$app_name_lower" =~ preferences || "$app_name_lower" =~ controlcenter || "$app_name_lower" =~ biometrickit || "$app_name_lower" =~ sfl || "$app_name_lower" =~ tcc ]]; then + if is_critical_system_component "$app_name"; then continue fi @@ -291,15 +302,20 @@ clean_application_support_logs() { found_any=true if [[ "$DRY_RUN" != "true" ]]; then + local _ng_candidate_state + _ng_candidate_state=$(shopt -p nullglob || true) + shopt -s nullglob for item in "$candidate"/*; do [[ -e "$item" ]] || continue safe_remove "$item" true > /dev/null 2>&1 || true done + eval "$_ng_candidate_state" fi fi fi done done + eval "$_ng_app_state" # Clean Group Containers logs local known_group_containers=( diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 5541192..ba11b04 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -435,6 +435,24 @@ readonly DATA_PROTECTED_BUNDLES=( "org.sparkle-project.Sparkle" # Sparkle (update framework) ) +# Centralized check for critical system components (case-insensitive) +is_critical_system_component() { + local token="$1" + [[ -z "$token" ]] && return 1 + + local lower + lower=$(echo "$token" | tr '[:upper:]' '[:lower:]') + + case "$lower" in + *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc* ) + return 0 + ;; + *) + return 1 + ;; + esac +} + # Legacy function - preserved for backward compatibility # Use should_protect_from_uninstall() or should_protect_data() instead readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") diff --git a/lib/core/ui.sh b/lib/core/ui.sh index d2018e2..c7eaad8 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -60,6 +60,22 @@ get_display_width() { local padding=$((extra_bytes / 2)) width=$((char_count + padding)) + # Adjust for zero-width joiners and emoji variation selectors (common in filenames/emojis) + # These characters add bytes but no visible width; subtract their count if present. + local zwj=$'\u200d' # zero-width joiner + local vs16=$'\ufe0f' # emoji variation selector + local zero_width=0 + + local without_zwj=${str//$zwj/} + zero_width=$((zero_width + (char_count - ${#without_zwj}))) + + local without_vs=${str//$vs16/} + zero_width=$((zero_width + (char_count - ${#without_vs}))) + + if ((zero_width > 0 && width > zero_width)); then + width=$((width - zero_width)) + fi + echo "$width" } diff --git a/tests/app_protection.bats b/tests/app_protection.bats new file mode 100644 index 0000000..b475934 --- /dev/null +++ b/tests/app_protection.bats @@ -0,0 +1,32 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "is_critical_system_component matches known system services" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/app_protection.sh" +is_critical_system_component "backgroundtaskmanagement" && echo "yes" +is_critical_system_component "SystemSettings" && echo "yes" +EOF + [ "$status" -eq 0 ] + [[ "${lines[0]}" == "yes" ]] + [[ "${lines[1]}" == "yes" ]] +} + +@test "is_critical_system_component ignores non-system names" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/app_protection.sh" +if is_critical_system_component "myapp"; then + echo "bad" +else + echo "ok" +fi +EOF + [ "$status" -eq 0 ] + [[ "$output" == "ok" ]] +}