#!/usr/bin/env bash ######################################################################################### #### file-permissions-audit.sh — Find world-writable files, SUID/SGID binaries, #### #### and files owned by nobody or with no valid owner #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./file-permissions-audit.sh #### #### ./file-permissions-audit.sh --scan-dirs /usr /bin /home #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── SCAN_DIRS="${SCAN_DIRS:-/usr /bin /sbin /var /opt /home /tmp}" EXCLUDE_PATHS=() VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME COUNT_WORLD_WRITABLE=0 COUNT_SUID=0 COUNT_SGID=0 COUNT_NOBODY=0 COUNT_UNOWNED=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } build_exclude_args() { local args=() for path in "${EXCLUDE_PATHS[@]+"${EXCLUDE_PATHS[@]}"}"; do args+=(-not -path "${path}/*") done # Always exclude /proc and /sys args+=(-not -path "/proc/*" -not -path "/sys/*") echo "${args[@]}" } get_file_info() { local file="$1" local octal symbolic owner group ftype octal=$(stat -c '%a' "$file" 2>/dev/null || echo "????") symbolic=$(stat -c '%A' "$file" 2>/dev/null || echo "??????????") owner=$(stat -c '%U' "$file" 2>/dev/null || echo "UNKNOWN") group=$(stat -c '%G' "$file" 2>/dev/null || echo "UNKNOWN") if [[ -d "$file" ]]; then ftype="dir" elif [[ -L "$file" ]]; then ftype="link" else ftype="file" fi echo "${octal} ${symbolic} ${owner}:${group} ${ftype}" } print_file_entry() { local color="$1" local file="$2" local info info=$(get_file_info "$file") local octal symbolic ownership ftype octal=$(echo "$info" | awk '{print $1}') symbolic=$(echo "$info" | awk '{print $2}') ownership=$(echo "$info" | awk '{print $3}') ftype=$(echo "$info" | awk '{print $4}') printf " %b%-4s %-11s %-20s %-6s%b %s\n" "$color" "$octal" "$symbolic" "$ownership" "$ftype" "$RESET" "$file" } # ══════════════════════════════════════════════════════════════════════ # SCAN FUNCTIONS # ══════════════════════════════════════════════════════════════════════ scan_world_writable() { section_header "World-Writable Files & Directories" printf " ${BOLD}%-4s %-11s %-20s %-6s${RESET} %s\n" "PERM" "MODE" "OWNER:GROUP" "TYPE" "PATH" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=0 local exclude_args exclude_args=$(build_exclude_args) for dir in $SCAN_DIRS; do [[ -d "$dir" ]] || continue # shellcheck disable=SC2086 while IFS= read -r file; do [[ -z "$file" ]] && continue print_file_entry "$CYAN" "$file" count=$((count + 1)) done < <(find "$dir" -xdev -perm -0002 -not -type l $exclude_args 2>/dev/null) done COUNT_WORLD_WRITABLE=$count echo "" log "Found ${count} world-writable entries" } scan_suid() { section_header "SUID Binaries" printf " ${BOLD}%-4s %-11s %-20s %-6s${RESET} %s\n" "PERM" "MODE" "OWNER:GROUP" "TYPE" "PATH" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=0 local exclude_args exclude_args=$(build_exclude_args) for dir in $SCAN_DIRS; do [[ -d "$dir" ]] || continue # shellcheck disable=SC2086 while IFS= read -r file; do [[ -z "$file" ]] && continue local owner owner=$(stat -c '%U' "$file" 2>/dev/null || echo "UNKNOWN") local color="$YELLOW" if [[ "$owner" == "root" ]]; then color="$RED" fi print_file_entry "$color" "$file" count=$((count + 1)) done < <(find "$dir" -xdev -type f -perm -4000 $exclude_args 2>/dev/null) done COUNT_SUID=$count echo "" log "Found ${count} SUID binaries" } scan_sgid() { section_header "SGID Binaries" printf " ${BOLD}%-4s %-11s %-20s %-6s${RESET} %s\n" "PERM" "MODE" "OWNER:GROUP" "TYPE" "PATH" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=0 local exclude_args exclude_args=$(build_exclude_args) for dir in $SCAN_DIRS; do [[ -d "$dir" ]] || continue # shellcheck disable=SC2086 while IFS= read -r file; do [[ -z "$file" ]] && continue print_file_entry "$YELLOW" "$file" count=$((count + 1)) done < <(find "$dir" -xdev -type f -perm -2000 $exclude_args 2>/dev/null) done COUNT_SGID=$count echo "" log "Found ${count} SGID binaries" } scan_nobody() { section_header "Files Owned by nobody/nogroup" printf " ${BOLD}%-4s %-11s %-20s %-6s${RESET} %s\n" "PERM" "MODE" "OWNER:GROUP" "TYPE" "PATH" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=0 local exclude_args exclude_args=$(build_exclude_args) for dir in $SCAN_DIRS; do [[ -d "$dir" ]] || continue # shellcheck disable=SC2086 while IFS= read -r file; do [[ -z "$file" ]] && continue print_file_entry "$YELLOW" "$file" count=$((count + 1)) done < <(find "$dir" -xdev \( -user nobody -o -group nogroup \) $exclude_args 2>/dev/null) done COUNT_NOBODY=$count echo "" log "Found ${count} files owned by nobody/nogroup" } scan_unowned() { section_header "Files With No Valid Owner" printf " ${BOLD}%-4s %-11s %-20s %-6s${RESET} %s\n" "PERM" "MODE" "OWNER:GROUP" "TYPE" "PATH" printf " %s\n" "$(printf '%.0s─' {1..78})" local count=0 local exclude_args exclude_args=$(build_exclude_args) for dir in $SCAN_DIRS; do [[ -d "$dir" ]] || continue # shellcheck disable=SC2086 while IFS= read -r file; do [[ -z "$file" ]] && continue print_file_entry "$RED" "$file" count=$((count + 1)) done < <(find "$dir" -xdev \( -nouser -o -nogroup \) $exclude_args 2>/dev/null) done COUNT_UNOWNED=$count echo "" log "Found ${count} files with no valid owner" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { echo "" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo -e " ${BOLD}Permissions Audit Summary${RESET}" echo -e " ${BOLD}══════════════════════════════════════════${RESET}" echo "" printf " %-30s %b\n" "World-writable:" "${CYAN}${COUNT_WORLD_WRITABLE}${RESET}" printf " %-30s %b\n" "SUID binaries:" "${RED}${COUNT_SUID}${RESET}" printf " %-30s %b\n" "SGID binaries:" "${YELLOW}${COUNT_SGID}${RESET}" printf " %-30s %b\n" "Owned by nobody/nogroup:" "${YELLOW}${COUNT_NOBODY}${RESET}" printf " %-30s %b\n" "No valid owner:" "${RED}${COUNT_UNOWNED}${RESET}" local total=$((COUNT_WORLD_WRITABLE + COUNT_SUID + COUNT_SGID + COUNT_NOBODY + COUNT_UNOWNED)) echo "" printf " %-30s %d\n" "Total findings:" "$total" echo "" } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors echo "" echo -e "${BOLD}File Permissions Audit — $(hostname -f 2>/dev/null || hostname)${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" echo -e "${DIM}Scanning: ${SCAN_DIRS}${RESET}" scan_world_writable scan_suid scan_sgid scan_nobody scan_unowned print_summary } main "$@"