1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 16:49:41 +00:00
Files
Mole/lib/core/file_ops.sh
Tw93 7dc854cf30 fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX

Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.

Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
  /usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
  logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)

Path Protection:
 Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
 Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
 Additional: Keychain files, system preferences via should_protect_path()

This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.

Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00

388 lines
12 KiB
Bash

#!/bin/bash
# Mole - File Operations
# Safe file and directory manipulation with validation
set -euo pipefail
# Prevent multiple sourcing
if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then
return 0
fi
readonly MOLE_FILE_OPS_LOADED=1
# Ensure dependencies are loaded
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then
# shellcheck source=lib/core/base.sh
source "$_MOLE_CORE_DIR/base.sh"
fi
if [[ -z "${MOLE_LOG_LOADED:-}" ]]; then
# shellcheck source=lib/core/log.sh
source "$_MOLE_CORE_DIR/log.sh"
fi
if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then
# shellcheck source=lib/core/timeout.sh
source "$_MOLE_CORE_DIR/timeout.sh"
fi
# ============================================================================
# Path Validation
# ============================================================================
# Validate path for deletion (absolute, no traversal, not system dir)
validate_path_for_deletion() {
local path="$1"
# Check path is not empty
if [[ -z "$path" ]]; then
log_error "Path validation failed: empty path"
return 1
fi
# Check path is absolute
if [[ "$path" != /* ]]; then
log_error "Path validation failed: path must be absolute: $path"
return 1
fi
# Check for path traversal attempts
# Only reject .. when it appears as a complete path component (/../ or /.. or ../)
# This allows legitimate directory names containing .. (e.g., Firefox's "name..files")
if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then
log_error "Path validation failed: path traversal not allowed: $path"
return 1
fi
# Check path doesn't contain dangerous characters
if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then
log_error "Path validation failed: contains control characters: $path"
return 1
fi
# Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt)
case "$path" in
/System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*)
return 0
;;
esac
# Allow known safe paths under /private
case "$path" in
/private/tmp | /private/tmp/* | \
/private/var/tmp | /private/var/tmp/* | \
/private/var/log | /private/var/log/* | \
/private/var/folders | /private/var/folders/* | \
/private/var/db/diagnostics | /private/var/db/diagnostics/* | \
/private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \
/private/var/db/powerlog | /private/var/db/powerlog/* | \
/private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*)
return 0
;;
esac
# Check path isn't critical system directory
case "$path" in
/ | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions)
log_error "Path validation failed: critical system directory: $path"
return 1
;;
/private)
log_error "Path validation failed: critical system directory: $path"
return 1
;;
/etc | /etc/* | /private/etc | /private/etc/*)
log_error "Path validation failed: /etc contains critical system files: $path"
return 1
;;
/var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*)
log_error "Path validation failed: /var/db contains system databases: $path"
return 1
;;
esac
# Check if path is protected (keychains, system settings, etc)
if declare -f should_protect_path > /dev/null 2>&1; then
if should_protect_path "$path"; then
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
log_warning "Path validation: protected path skipped: $path"
fi
return 1
fi
fi
return 0
}
# ============================================================================
# Safe Removal Operations
# ============================================================================
# Safe wrapper around rm -rf with validation
safe_remove() {
local path="$1"
local silent="${2:-false}"
# Validate path
if ! validate_path_for_deletion "$path"; then
return 1
fi
# Check if path exists
if [[ ! -e "$path" ]]; then
return 0
fi
# Dry-run mode: log but don't delete
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
if [[ "${MO_DEBUG:-}" == "1" ]]; then
local file_type="file"
[[ -d "$path" ]] && file_type="directory"
[[ -L "$path" ]] && file_type="symlink"
local file_size=""
local file_age=""
if [[ -e "$path" ]]; then
local size_kb
size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0")
if [[ "$size_kb" -gt 0 ]]; then
file_size=$(bytes_to_human "$((size_kb * 1024))")
fi
if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then
local mod_time
mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0")
local now
now=$(date +%s 2> /dev/null || echo "0")
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
file_age=$(((now - mod_time) / 86400))
fi
fi
fi
debug_file_action "[DRY RUN] Would remove" "$path" "$file_size" "$file_age"
else
debug_log "[DRY RUN] Would remove: $path"
fi
return 0
fi
debug_log "Removing: $path"
# Perform the deletion
# Use || to capture the exit code so set -e won't abort on rm failures
local error_msg
local rm_exit=0
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
if [[ $rm_exit -eq 0 ]]; then
return 0
else
# Check if it's a permission error
if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then
MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0}
MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1))
export MOLE_PERMISSION_DENIED_COUNT
debug_log "Permission denied: $path (may need Full Disk Access)"
else
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
fi
return 1
fi
}
# Safe sudo removal with symlink protection
safe_sudo_remove() {
local path="$1"
# Validate path
if ! validate_path_for_deletion "$path"; then
log_error "Path validation failed for sudo remove: $path"
return 1
fi
# Check if path exists
if [[ ! -e "$path" ]]; then
return 0
fi
# Additional check: reject symlinks for sudo operations
if [[ -L "$path" ]]; then
log_error "Refusing to sudo remove symlink: $path"
return 1
fi
# Dry-run mode: log but don't delete
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
if [[ "${MO_DEBUG:-}" == "1" ]]; then
local file_type="file"
[[ -d "$path" ]] && file_type="directory"
local file_size=""
local file_age=""
if sudo test -e "$path" 2> /dev/null; then
local size_kb
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
if [[ "$size_kb" -gt 0 ]]; then
file_size=$(bytes_to_human "$((size_kb * 1024))")
fi
if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then
local mod_time
mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0")
local now
now=$(date +%s 2> /dev/null || echo "0")
if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then
file_age=$(((now - mod_time) / 86400))
fi
fi
fi
debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age"
else
debug_log "[DRY RUN] Would remove (sudo): $path"
fi
return 0
fi
debug_log "Removing (sudo): $path"
# Perform the deletion
if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation
return 0
else
log_error "Failed to remove (sudo): $path"
return 1
fi
}
# ============================================================================
# Safe Find and Delete Operations
# ============================================================================
# Safe file discovery and deletion with depth and age limits
safe_find_delete() {
local base_dir="$1"
local pattern="$2"
local age_days="${3:-7}"
local type_filter="${4:-f}"
# Validate base directory exists and is not a symlink
if [[ ! -d "$base_dir" ]]; then
log_error "Directory does not exist: $base_dir"
return 1
fi
if [[ -L "$base_dir" ]]; then
log_error "Refusing to search symlinked directory: $base_dir"
return 1
fi
# Validate type filter
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
return 1
fi
debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
if [[ "$age_days" -gt 0 ]]; then
find_args+=("-mtime" "+$age_days")
fi
# Iterate results to respect should_protect_path
while IFS= read -r -d '' match; do
if should_protect_path "$match"; then
continue
fi
safe_remove "$match" true || true
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
return 0
}
# Safe sudo discovery and deletion
safe_sudo_find_delete() {
local base_dir="$1"
local pattern="$2"
local age_days="${3:-7}"
local type_filter="${4:-f}"
# Validate base directory (use sudo for permission-restricted dirs)
if ! sudo test -d "$base_dir" 2> /dev/null; then
debug_log "Directory does not exist (skipping): $base_dir"
return 0
fi
if sudo test -L "$base_dir" 2> /dev/null; then
log_error "Refusing to search symlinked directory: $base_dir"
return 1
fi
# Validate type filter
if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then
log_error "Invalid type filter: $type_filter (must be 'f' or 'd')"
return 1
fi
debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
if [[ "$age_days" -gt 0 ]]; then
find_args+=("-mtime" "+$age_days")
fi
# Iterate results to respect should_protect_path
while IFS= read -r -d '' match; do
if should_protect_path "$match"; then
continue
fi
safe_sudo_remove "$match" || true
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
return 0
}
# ============================================================================
# Size Calculation
# ============================================================================
# Get path size in KB (returns 0 if not found)
get_path_size_kb() {
local path="$1"
[[ -z "$path" || ! -e "$path" ]] && {
echo "0"
return
}
# Direct execution without timeout overhead - critical for performance in loops
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
local size
size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
# Ensure size is a valid number (fix for non-numeric du output)
if [[ "$size" =~ ^[0-9]+$ ]]; then
echo "$size"
else
echo "0"
fi
}
# Calculate total size for multiple paths
calculate_total_size() {
local files="$1"
local total_kb=0
while IFS= read -r file; do
if [[ -n "$file" && -e "$file" ]]; then
local size_kb
size_kb=$(get_path_size_kb "$file")
((total_kb += size_kb))
fi
done <<< "$files"
echo "$total_kb"
}