#!/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 <<EOF
${SCRIPT_NAME} — Audit file permissions for security issues

USAGE:
  ${SCRIPT_NAME} [OPTIONS]

OPTIONS:
  --scan-dirs DIRS      Space-separated directories to scan
                        (default: /usr /bin /sbin /var /opt /home /tmp)
  --exclude-path PATH   Exclude a path from scanning (repeatable)
  --verbose             Enable debug output
  --no-color            Disable colored output
  --help                Show this help

ENVIRONMENT VARIABLES:
  SCAN_DIRS             Directories to scan (default: /usr /bin /sbin /var /opt /home /tmp)

EXAMPLES:
  # Full audit with defaults
  ./file-permissions-audit.sh

  # Scan specific directories
  ./file-permissions-audit.sh --scan-dirs "/usr /home /opt"

  # Exclude paths
  ./file-permissions-audit.sh --exclude-path /home/build --exclude-path /var/cache

  # Pipe-friendly output
  ./file-permissions-audit.sh --no-color | tee audit-report.txt
EOF
}

# ══════════════════════════════════════════════════════════════════════
#                       ARGUMENT PARSING
# ══════════════════════════════════════════════════════════════════════

parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --scan-dirs)
                SCAN_DIRS="$2"; shift 2 ;;
            --exclude-path)
                EXCLUDE_PATHS+=("$2"); shift 2 ;;
            --verbose)
                VERBOSE="true"; shift ;;
            --no-color)
                COLOR="never"; shift ;;
            --help|-h)
                setup_colors
                usage
                exit 0 ;;
            *)
                err "Unknown option: $1"
                echo "Run ${SCRIPT_NAME} --help for usage" >&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 "$@"
