#!/usr/bin/env bash ######################################################################################### #### permissions-fixer.sh — Find and fix common file permission problems #### #### Scans web roots, shared dirs, home dirs, /tmp for broken permissions, ACLs, #### #### orphaned ownership, and missing setgid bits #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.0 #### #### #### #### Usage: #### #### ./permissions-fixer.sh # audit mode #### #### ./permissions-fixer.sh --fix # apply fixes #### #### ./permissions-fixer.sh --scan /var/www /srv/shared # specific paths #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── SCAN_PATHS=() FIX_MODE="false" DRY_RUN="false" WEB_USER="" SKIP_WEB="false" SKIP_HOME="false" SKIP_MOUNT="false" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" YES="false" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME COUNT_WARN=0 COUNT_FAIL=0 COUNT_FIX=0 COUNT_SKIP=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } tag_fail() { echo -e " ${RED}[FAIL]${RESET} $*"; ((COUNT_FAIL++)) || true; } tag_warn() { echo -e " ${YELLOW}[WARN]${RESET} $*"; ((COUNT_WARN++)) || true; } tag_fix() { echo -e " ${GREEN}[FIX]${RESET} $*"; ((COUNT_FIX++)) || true; } tag_skip() { echo -e " ${DIM}[SKIP]${RESET} $*"; ((COUNT_SKIP++)) || true; } section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } # ── Helpers ─────────────────────────────────────────────────────────── apply_fix() { local description="$1" shift if [[ "$FIX_MODE" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then tag_skip "(dry-run) $description" else if "$@" 2>/dev/null; then tag_fix "$description" else err "Failed to apply: $description" fi fi fi } detect_web_user() { if [[ -n "$WEB_USER" ]]; then verbose "Web user override: $WEB_USER" return fi for proc in nginx apache2 httpd; do if pgrep -x "$proc" &>/dev/null; then case "$proc" in nginx) WEB_USER="$(ps -eo user,comm --no-headers | awk '$2=="nginx" && $1!="root" {print $1; exit}')" ;; apache2) WEB_USER="$(ps -eo user,comm --no-headers | awk '$2=="apache2" && $1!="root" {print $1; exit}')" ;; httpd) WEB_USER="$(ps -eo user,comm --no-headers | awk '$2=="httpd" && $1!="root" {print $1; exit}')" ;; esac if [[ -n "$WEB_USER" ]]; then verbose "Detected web user from running $proc: $WEB_USER" return fi fi done for candidate in www-data nginx apache; do if id "$candidate" &>/dev/null; then WEB_USER="$candidate" verbose "Detected web user from passwd: $WEB_USER" return fi done WEB_USER="" verbose "Could not detect web server user" } # ══════════════════════════════════════════════════════════════════════ # CHECK FUNCTIONS # ══════════════════════════════════════════════════════════════════════ check_web_root() { if [[ "$SKIP_WEB" == "true" ]]; then verbose "Skipping web root checks (--skip-web)" return fi section_header "Web Root Permissions" detect_web_user if [[ -z "$WEB_USER" ]]; then tag_warn "No web server user detected — skipping web root checks" log "Use --web-user USER to specify manually" return fi log "Web server user: ${WEB_USER}" local web_root="/var/www" for p in "${SCAN_PATHS[@]}"; do if [[ "$p" == /var/www* ]] || [[ "$p" == /srv/www* ]]; then web_root="$p" break fi done if [[ ! -d "$web_root" ]]; then verbose "Web root ${web_root} does not exist — skipping" return fi log "Scanning web root: ${web_root}" # Check file ownership while IFS= read -r file; do [[ -z "$file" ]] && continue local owner owner=$(stat -c '%U' "$file" 2>/dev/null || echo "UNKNOWN") tag_fail "Not owned by ${WEB_USER}: ${file} (owner: ${owner})" apply_fix "chown ${WEB_USER}: ${file}" chown "${WEB_USER}:" "$file" done < <(find "$web_root" -xdev -not -user "$WEB_USER" -not -path "*/\.git/*" 2>/dev/null || true) # Check directory permissions (should be 755) while IFS= read -r dir; do [[ -z "$dir" ]] && continue local perms perms=$(stat -c '%a' "$dir" 2>/dev/null || echo "0") if [[ "$perms" != "755" ]]; then tag_warn "Directory not 755: ${dir} (${perms})" apply_fix "chmod 755 ${dir}" chmod 755 "$dir" else verbose "OK: ${dir} (${perms})" fi done < <(find "$web_root" -xdev -type d -not -path "*/\.git/*" 2>/dev/null || true) # Check file permissions (should be 644) while IFS= read -r file; do [[ -z "$file" ]] && continue local perms perms=$(stat -c '%a' "$file" 2>/dev/null || echo "0") if [[ "$perms" != "644" ]]; then tag_warn "File not 644: ${file} (${perms})" apply_fix "chmod 644 ${file}" chmod 644 "$file" else verbose "OK: ${file} (${perms})" fi done < <(find "$web_root" -xdev -type f -not -path "*/\.git/*" 2>/dev/null || true) } check_world_writable() { section_header "World-Writable Files" local exclude_args=(-not -path "/tmp/*" -not -path "/var/tmp/*" -not -path "/dev/shm/*" -not -path "/proc/*" -not -path "/sys/*") local found=0 for scan_dir in "${SCAN_PATHS[@]}"; do [[ -d "$scan_dir" ]] || continue [[ "$scan_dir" == "/tmp" || "$scan_dir" == "/var/tmp" || "$scan_dir" == "/dev/shm" ]] && continue while IFS= read -r file; do [[ -z "$file" ]] && continue tag_fail "World-writable: ${file} ($(stat -c '%a %U:%G' "$file" 2>/dev/null))" apply_fix "chmod o-w ${file}" chmod o-w "$file" ((found++)) || true done < <(find "$scan_dir" -xdev -perm -0002 -not -type l "${exclude_args[@]}" 2>/dev/null || true) done if [[ "$found" -eq 0 ]]; then log "No world-writable files found outside /tmp" fi } check_orphaned_files() { section_header "Orphaned Files (No Valid Owner/Group)" local found=0 for scan_dir in "${SCAN_PATHS[@]}"; do [[ -d "$scan_dir" ]] || continue while IFS= read -r file; do [[ -z "$file" ]] && continue local uid gid uid=$(stat -c '%u' "$file" 2>/dev/null || echo "?") gid=$(stat -c '%g' "$file" 2>/dev/null || echo "?") tag_fail "Orphaned file: ${file} (UID=${uid} GID=${gid})" ((found++)) || true done < <(find "$scan_dir" -xdev \( -nouser -o -nogroup \) -not -path "/proc/*" -not -path "/sys/*" 2>/dev/null || true) done if [[ "$found" -eq 0 ]]; then log "No orphaned files found" else log "Orphaned files require manual owner assignment — not auto-fixed" fi } check_setgid_shared() { section_header "Shared Directory Setgid" local found=0 local dirs_to_check=() for p in "${SCAN_PATHS[@]}"; do if [[ "$p" == /srv* ]]; then dirs_to_check+=("$p") fi done [[ -d "/srv" ]] && dirs_to_check+=("/srv") # Deduplicate local -A seen=() local unique_dirs=() for d in "${dirs_to_check[@]}"; do if [[ -z "${seen[$d]+x}" ]]; then seen[$d]=1 unique_dirs+=("$d") fi done for scan_dir in "${unique_dirs[@]}"; do [[ -d "$scan_dir" ]] || continue while IFS= read -r dir; do [[ -z "$dir" ]] && continue local perms perms=$(stat -c '%a' "$dir" 2>/dev/null || echo "0") # Check if group-writable (bit 1 of group octal) but not setgid local group_octal="${perms:1:1}" if (( group_octal % 2 == 0 )); then verbose "Not group-writable, skipping: $dir" continue fi # Check setgid local mode mode=$(stat -c '%04a' "$dir" 2>/dev/null || echo "0000") if [[ "${mode:0:1}" != "2" && "${mode:0:1}" != "3" && "${mode:0:1}" != "6" && "${mode:0:1}" != "7" ]]; then tag_warn "Group-writable but no setgid: ${dir} (${mode})" apply_fix "chmod g+s ${dir}" chmod g+s "$dir" ((found++)) || true else verbose "OK setgid: ${dir} (${mode})" fi done < <(find "$scan_dir" -xdev -type d -not -path "/proc/*" -not -path "/sys/*" 2>/dev/null || true) done if [[ "$found" -eq 0 ]]; then log "No missing setgid bits found on shared directories" fi } check_home_dirs() { if [[ "$SKIP_HOME" == "true" ]]; then verbose "Skipping home directory checks (--skip-home)" return fi section_header "Home Directory Permissions" [[ -d "/home" ]] || { verbose "/home does not exist — skipping"; return; } local found=0 for home_dir in /home/*/; do [[ -d "$home_dir" ]] || continue home_dir="${home_dir%/}" local perms perms=$(stat -c '%a' "$home_dir" 2>/dev/null || echo "0") # Check home dir is 700 or 750 if [[ "$perms" != "700" && "$perms" != "750" ]]; then tag_warn "Home dir too open: ${home_dir} (${perms})" apply_fix "chmod 750 ${home_dir}" chmod 750 "$home_dir" ((found++)) || true else verbose "OK: ${home_dir} (${perms})" fi # Check .ssh directory local ssh_dir="${home_dir}/.ssh" if [[ -d "$ssh_dir" ]]; then local ssh_perms ssh_perms=$(stat -c '%a' "$ssh_dir" 2>/dev/null || echo "0") if [[ "$ssh_perms" != "700" ]]; then tag_fail "SSH dir not 700: ${ssh_dir} (${ssh_perms})" apply_fix "chmod 700 ${ssh_dir}" chmod 700 "$ssh_dir" ((found++)) || true else verbose "OK: ${ssh_dir} (${ssh_perms})" fi # Check authorized_keys if [[ -f "${ssh_dir}/authorized_keys" ]]; then local ak_perms ak_perms=$(stat -c '%a' "${ssh_dir}/authorized_keys" 2>/dev/null || echo "0") if [[ "$ak_perms" != "600" ]]; then tag_fail "authorized_keys not 600: ${ssh_dir}/authorized_keys (${ak_perms})" apply_fix "chmod 600 ${ssh_dir}/authorized_keys" chmod 600 "${ssh_dir}/authorized_keys" ((found++)) || true fi fi # Check private keys while IFS= read -r key_file; do [[ -z "$key_file" ]] && continue local key_perms key_perms=$(stat -c '%a' "$key_file" 2>/dev/null || echo "0") if [[ "$key_perms" != "600" ]]; then tag_fail "Private key not 600: ${key_file} (${key_perms})" apply_fix "chmod 600 ${key_file}" chmod 600 "$key_file" ((found++)) || true fi done < <(find "$ssh_dir" -maxdepth 1 -type f \( -name "id_*" -not -name "*.pub" \) 2>/dev/null || true) fi done if [[ "$found" -eq 0 ]]; then log "All home directory permissions look correct" fi } check_broken_acls() { section_header "Broken ACL Masks" if ! command -v getfacl &>/dev/null; then log "getfacl not installed — skipping ACL checks" return fi local found=0 for scan_dir in "${SCAN_PATHS[@]}"; do [[ -d "$scan_dir" ]] || continue while IFS= read -r file; do [[ -z "$file" ]] && continue # Only check files that actually have ACLs (+ in ls output or via getfacl) local acl_output acl_output=$(getfacl -p "$file" 2>/dev/null || true) [[ -z "$acl_output" ]] && continue local has_named_entries="false" local mask_line="" while IFS= read -r line; do case "$line" in user:?*:*) has_named_entries="true" ;; group:?*:*) has_named_entries="true" ;; mask::*) mask_line="$line" ;; esac done <<< "$acl_output" if [[ "$has_named_entries" == "false" || -z "$mask_line" ]]; then continue fi local mask_perms="${mask_line##*::}" # Check each named entry against the mask while IFS= read -r line; do local entry_perms="" case "$line" in user:?*:*) entry_perms="${line##*:}" ;; group:?*:*) entry_perms="${line##*:}" ;; *) continue ;; esac # Compare: if entry has bits the mask doesn't, effective is reduced local reduced="false" if [[ "$entry_perms" == *r* && "$mask_perms" != *r* ]]; then reduced="true"; fi if [[ "$entry_perms" == *w* && "$mask_perms" != *w* ]]; then reduced="true"; fi if [[ "$entry_perms" == *x* && "$mask_perms" != *x* ]]; then reduced="true"; fi if [[ "$reduced" == "true" ]]; then tag_warn "ACL mask restricts effective perms: ${file} (entry=${entry_perms}, mask=${mask_perms})" ((found++)) || true break fi done <<< "$acl_output" done < <(find "$scan_dir" -xdev -maxdepth 3 -not -path "/proc/*" -not -path "/sys/*" 2>/dev/null | head -5000 || true) done if [[ "$found" -eq 0 ]]; then log "No broken ACL masks found" else log "ACL mask issues require manual review — not auto-fixed" fi } check_immutable_files() { section_header "Immutable Files (chattr +i)" if ! command -v lsattr &>/dev/null; then log "lsattr not installed — skipping immutable file checks" return fi local found=0 for scan_dir in "${SCAN_PATHS[@]}"; do [[ -d "$scan_dir" ]] || continue while IFS= read -r line; do [[ -z "$line" ]] && continue # lsattr format: "----i---------e------- /path/to/file" # skip directory headers like "/tmp/subdir:" (no attribute field) [[ "$line" =~ ^[[:space:]]*[-a-zA-Z]+[[:space:]]+ ]] || continue local attrs file attrs="${line%% *}" file="${line#* }" # check for immutable flag (4th char in attrs is 'i') if [[ "$attrs" == ????i* ]]; then tag_warn "Immutable file: ${file}" ((found++)) || true fi done < <(lsattr -R "$scan_dir" 2>/dev/null | grep -v "^lsattr:" || true) done if [[ "$found" -eq 0 ]]; then log "No immutable files found" else log "Immutable files reported for awareness — not auto-fixed" fi } check_mount_options() { if [[ "$SKIP_MOUNT" == "true" ]]; then verbose "Skipping mount option checks (--skip-mount)" return fi section_header "Mount Option Audit" local found=0 local -A mount_checks=( ["/tmp"]="noexec nosuid nodev" ["/var/tmp"]="noexec nosuid nodev" ["/dev/shm"]="noexec nosuid nodev" ) for mount_point in "${!mount_checks[@]}"; do local expected_opts="${mount_checks[$mount_point]}" local current_opts current_opts=$(findmnt -n -o OPTIONS "$mount_point" 2>/dev/null || echo "") if [[ -z "$current_opts" ]]; then verbose "${mount_point} is not a separate mount point — skipping" continue fi log "Checking mount: ${mount_point} (${current_opts})" for opt in $expected_opts; do if echo "$current_opts" | grep -qw "$opt"; then verbose "OK: ${mount_point} has ${opt}" else tag_fail "Missing mount option: ${mount_point} needs ${opt}" ((found++)) || true fi done done if [[ "$found" -eq 0 ]]; then log "All checked mount points have recommended security options" else log "Mount option changes require fstab edit + remount — not auto-fixed" fi } # ══════════════════════════════════════════════════════════════════════ # CONFIRMATION # ══════════════════════════════════════════════════════════════════════ confirm_fix() { if [[ "$FIX_MODE" != "true" || "$DRY_RUN" == "true" || "$YES" == "true" ]]; then return 0 fi echo "" echo -e " ${BOLD}${YELLOW}⚠ WARNING: --fix mode will modify file permissions${RESET}" echo -e " ${YELLOW}Back up critical data before proceeding.${RESET}" echo "" read -rp " Continue with fixes? [y/N] " answer case "$answer" in [yY][eE][sS]|[yY]) return 0 ;; *) log "Aborted by user."; exit 0 ;; esac } require_root_for_fix() { if [[ "$FIX_MODE" == "true" && "$DRY_RUN" != "true" && $EUID -ne 0 ]]; then die "--fix mode requires root privileges. Run with sudo." fi } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { echo "" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo -e " ${BOLD}Permissions Fixer Summary${RESET}" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo "" printf " %-30s %b\n" "Warnings [WARN]:" "${YELLOW}${COUNT_WARN}${RESET}" printf " %-30s %b\n" "Security risks [FAIL]:" "${RED}${COUNT_FAIL}${RESET}" printf " %-30s %b\n" "Fixes applied [FIX]:" "${GREEN}${COUNT_FIX}${RESET}" printf " %-30s %b\n" "Skipped (dry-run) [SKIP]:" "${DIM}${COUNT_SKIP}${RESET}" local total=$((COUNT_WARN + COUNT_FAIL + COUNT_FIX + COUNT_SKIP)) echo "" printf " %-30s %d\n" "Total findings:" "$total" if [[ "$FIX_MODE" == "true" && "$DRY_RUN" == "true" ]]; then echo "" log "Dry-run mode — no changes were made. Re-run without --dry-run to apply." fi echo "" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; esac done # Default scan paths if none specified if [[ ${#SCAN_PATHS[@]} -eq 0 ]]; then SCAN_PATHS=(/var/www /home /srv /tmp) fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { setup_colors parse_args "$@" setup_colors # re-init in case --no-color was passed local mode_label="Audit" if [[ "$FIX_MODE" == "true" && "$DRY_RUN" == "true" ]]; then mode_label="Dry-run" elif [[ "$FIX_MODE" == "true" ]]; then mode_label="Fix" fi echo "" echo -e "${BOLD}Permissions Fixer — $(hostname -f 2>/dev/null || hostname)${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" echo -e "${DIM}Mode: ${mode_label}${RESET}" echo -e "${DIM}Scanning: ${SCAN_PATHS[*]}${RESET}" require_root_for_fix confirm_fix check_web_root check_world_writable check_orphaned_files check_setgid_shared check_home_dirs check_broken_acls check_immutable_files check_mount_options print_summary # Exit code based on severity if [[ "$COUNT_FAIL" -gt 0 ]]; then exit 2 elif [[ "$COUNT_WARN" -gt 0 ]]; then exit 1 fi exit 0 } main "$@"