Last active
November 27, 2025 15:58
-
-
Save adyp/7cf3c80eafac03fca8ccd42605380e2f to your computer and use it in GitHub Desktop.
world_writable_checker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # world_writable_checker.sh | |
| # Find world-writable files, safely avoiding problematic, synthetic, and network filesystems. | |
| # | |
| # Usage: | |
| # world_writable_checker.sh [options] [--] <path> [more paths...] | |
| # | |
| # Options: | |
| # --user, -u Non-root mode (only readable files) | |
| # --exclude, -x PAT Comma-separated patterns to exclude (dir/file name fragments) | |
| # --out, -o FILE Write results to FILE (and also stdout unless --quiet) | |
| # --auto-out, -O Like --out, but filename inferred from targets + timestamp | |
| # --debug, -d Verbose internal state | |
| # --dry-run, -n Show what would run, do not execute find | |
| # --skip-sticky, -y Skip world-writable entries that also have the sticky bit set (+t) | |
| # --skip-network Do not enter remote network filesystems (NFS, CIFS/SMB, etc.) | |
| # --quiet, -q Suppress normal stdout output and mute find errors | |
| # --help, -h Show help and exit | |
| set -euo pipefail | |
| DEBUG=false | |
| USER_MODE=false | |
| DRY_RUN=false | |
| SKIP_STICKY=false | |
| SKIP_NETWORK=false | |
| QUIET=false | |
| AUTO_OUT=false | |
| EXCLUDE_PATTERNS="" | |
| TARGET_PATHS=() | |
| OUTPUT_FILE="" | |
| # Known remote filesystem types to prune when --skip-network is used | |
| NETWORK_FSTYPES="nfs nfs4 cifs smbfs smb2 smb3 fuse.sshfs sshfs afs ceph glusterfs" | |
| show_help() { | |
| cat <<'EOF' | |
| world_writable_checker.sh - find world-writable files/directories safely | |
| Usage: | |
| world_writable_checker.sh [options] [--] <path> [more paths...] | |
| Options: | |
| --user, -u Run in non-root mode (only readable files are considered) | |
| --exclude, -x PAT Comma-separated patterns to exclude (matched in path components) | |
| --out, -o FILE Write results to FILE (and also to stdout unless --quiet is used) | |
| --auto-out, -O Auto-generate output filename from targets and timestamp | |
| --debug, -d Enable verbose debug logging to stderr | |
| --dry-run, -n Show the constructed find command and exit (no scan) | |
| --skip-sticky, -y Skip world-writable entries that also have the sticky bit set (+t) | |
| --skip-network Do not enter remote network filesystems (NFS, CIFS/SMB, etc.) | |
| --quiet, -q Suppress normal stdout output and mute find errors | |
| --help, -h Show this help message and exit | |
| Notes: | |
| - Symlinks are not reported as hits. | |
| - Known synthetic/problematic paths like /proc, /sys, /dev are excluded. | |
| - Use "--" to mark the end of options; everything after is treated as a target path. | |
| EOF | |
| } | |
| debug() { | |
| if [[ "$DEBUG" == true ]]; then | |
| echo "[DEBUG] $*" >&2 | |
| fi | |
| } | |
| build_exclude_patterns() { | |
| local exclude_patterns="" | |
| # Always exclude known synthetic/problematic dirs | |
| for bad_dir in /dev /proc /sys /debugfs /pstore /selinux /traces /sys/fs/cgroup; do | |
| if [[ -d "$bad_dir" ]]; then | |
| exclude_patterns="$exclude_patterns -path '$bad_dir/*' -o" | |
| fi | |
| done | |
| # User-specified excludes | |
| if [[ -n "${EXCLUDE_PATTERNS:-}" ]]; then | |
| IFS=',' read -ra PATTERNS <<< "$EXCLUDE_PATTERNS" | |
| for pattern in "${PATTERNS[@]}"; do | |
| exclude_patterns="$exclude_patterns -path '*/$pattern/*' -o" | |
| done | |
| fi | |
| exclude_patterns="${exclude_patterns% -o}" | |
| debug "Built exclude patterns: $exclude_patterns" | |
| echo "$exclude_patterns" | |
| } | |
| build_network_prune() { | |
| local expr="" | |
| for t in $NETWORK_FSTYPES; do | |
| expr="$expr -fstype $t -o" | |
| done | |
| expr="${expr% -o}" | |
| debug "Built network prune expression: $expr" | |
| echo "$expr" | |
| } | |
| auto_output_name() { | |
| local ts base joined | |
| ts="$(date +%Y%m%d_%H%M%S)" | |
| joined="" | |
| for p in "${TARGET_PATHS[@]}"; do | |
| base="$(basename "$p")" | |
| base="${base// /_}" | |
| base="${base////_}" | |
| joined+="${joined:+_}$base" | |
| done | |
| [[ -z "$joined" ]] && joined="scan" | |
| echo "world_writable_${joined}_${ts}.log" | |
| } | |
| write_header() { | |
| { | |
| echo "# World-writable files found (excluding problematic filesystems, skipping symlinks):" | |
| echo "# Targets: ${TARGET_PATHS[*]}" | |
| echo "# User mode: $USER_MODE" | |
| echo "# Skip sticky: $SKIP_STICKY" | |
| echo "# Skip network: $SKIP_NETWORK" | |
| echo "# Quiet: $QUIET" | |
| [[ "$DRY_RUN" == true ]] && echo "# DRY-RUN MODE - No files will be processed" | |
| echo "# Scan started: $(date)" | |
| echo "--------------------------------------------------------------------------------" | |
| } | |
| } | |
| write_footer() { | |
| { | |
| echo "--------------------------------------------------------------------------------" | |
| echo "# Scan ended: $(date)" | |
| } | |
| } | |
| # Argument parsing with explicit end-of-options marker | |
| END_OF_OPTS=false | |
| while [[ $# -gt 0 ]]; do | |
| if [[ "$END_OF_OPTS" == true ]]; then | |
| TARGET_PATHS+=("$1") | |
| shift | |
| continue | |
| fi | |
| case "$1" in | |
| --) | |
| END_OF_OPTS=true | |
| shift | |
| ;; | |
| --help|-h) | |
| show_help | |
| exit 0 | |
| ;; | |
| --user|-u) | |
| USER_MODE=true | |
| shift | |
| ;; | |
| --debug|-d) | |
| DEBUG=true | |
| shift | |
| ;; | |
| --dry-run|-n) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| --skip-sticky|-y) | |
| SKIP_STICKY=true | |
| shift | |
| ;; | |
| --skip-network) | |
| SKIP_NETWORK=true | |
| shift | |
| ;; | |
| --quiet|-q) | |
| QUIET=true | |
| shift | |
| ;; | |
| --exclude|-x) | |
| shift | |
| EXCLUDE_PATTERNS="${EXCLUDE_PATTERNS}${EXCLUDE_PATTERNS:+,}$1" | |
| shift | |
| ;; | |
| --out|-o) | |
| shift | |
| OUTPUT_FILE="$1" | |
| AUTO_OUT=false | |
| shift | |
| ;; | |
| --auto-out|-O) | |
| AUTO_OUT=true | |
| shift | |
| ;; | |
| --*) | |
| echo "Unknown option: $1" >&2 | |
| echo "Use --help for usage." >&2 | |
| exit 1 | |
| ;; | |
| -*) | |
| echo "Unknown option: $1" >&2 | |
| echo "Use --help for usage." >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| TARGET_PATHS+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| if [[ ${#TARGET_PATHS[@]} -eq 0 ]]; then | |
| echo "Error: No target paths specified" >&2 | |
| echo "Use --help for usage." >&2 | |
| exit 1 | |
| fi | |
| # If -O/--auto-out was requested and no explicit -o was given, compute filename | |
| if [[ "$AUTO_OUT" == true && -z "$OUTPUT_FILE" ]]; then | |
| OUTPUT_FILE="$(auto_output_name)" | |
| debug "Auto-generated output file: $OUTPUT_FILE" | |
| fi | |
| debug "Target paths: ${TARGET_PATHS[*]}" | |
| debug "User mode: $USER_MODE" | |
| debug "Skip sticky: $SKIP_STICKY" | |
| debug "Skip network: $SKIP_NETWORK" | |
| debug "Quiet: $QUIET" | |
| debug "Auto out: $AUTO_OUT" | |
| debug "Exclude patterns: '$EXCLUDE_PATTERNS'" | |
| debug "Output file: '$OUTPUT_FILE'" | |
| EXCLUDE_CLAUSE="$(build_exclude_patterns)" | |
| # Build base find command as a single string | |
| FIND_CMD="find" | |
| for p in "${TARGET_PATHS[@]}"; do | |
| FIND_CMD+=" '$(printf "%s" "$p" | sed "s/'/'\"'\"'/g")'" | |
| done | |
| # Prune excluded paths (synthetic dirs, user patterns) | |
| if [[ -n "$EXCLUDE_CLAUSE" ]]; then | |
| FIND_CMD+=" \\( $EXCLUDE_CLAUSE \\) -prune -o" | |
| fi | |
| # Optional: prune remote network filesystems | |
| if [[ "$SKIP_NETWORK" == true ]]; then | |
| NET_EXPR="$(build_network_prune)" | |
| if [[ -n "$NET_EXPR" ]]; then | |
| FIND_CMD+=" \\( $NET_EXPR \\) -prune -o" | |
| fi | |
| fi | |
| # Permission filters | |
| PERM_FILTER="-perm -0002" | |
| STICKY_FILTER="" | |
| if [[ "$SKIP_STICKY" == true ]]; then | |
| STICKY_FILTER=" ! -perm -1000" | |
| fi | |
| # Match branch: | |
| # - skip symlinks: ! -type l | |
| # - only regular files and directories: ( -type f -o -type d ) | |
| # - world-writable: -perm -0002 | |
| # - optional: readable for non-root | |
| if [[ "$USER_MODE" == true ]]; then | |
| FIND_CMD+=" \\( ! -type l \\( -type f -o -type d \\) -readable ${PERM_FILTER}${STICKY_FILTER} -ls \\)" | |
| else | |
| FIND_CMD+=" \\( ! -type l \\( -type f -o -type d \\) ${PERM_FILTER}${STICKY_FILTER} -ls \\)" | |
| fi | |
| debug "Final find command string: $FIND_CMD" | |
| # Dry run: show command and exit | |
| if [[ "$DRY_RUN" == true ]]; then | |
| write_header >&2 | |
| echo "# DRY-RUN: Would execute:" >&2 | |
| echo " $FIND_CMD" >&2 | |
| echo "# DRY-RUN: Results would be written to: ${OUTPUT_FILE:-stdout}" >&2 | |
| write_footer >&2 | |
| exit 0 | |
| fi | |
| write_header >&2 # metadata to stderr | |
| debug "Executing find with synchronous output..." | |
| # Build redirection for find's stderr based on quiet flag | |
| if [[ "$QUIET" == true ]]; then | |
| FIND_REDIR="2>/dev/null" | |
| else | |
| FIND_REDIR="" | |
| fi | |
| if [[ -n "$OUTPUT_FILE" ]]; then | |
| if [[ "$QUIET" == true ]]; then | |
| # Only to file, errors muted | |
| # shellcheck disable=SC2086 | |
| stdbuf -oL -eL bash -c "$FIND_CMD $FIND_REDIR" >"$OUTPUT_FILE" | |
| else | |
| # To both stdout and file, errors visible | |
| # shellcheck disable=SC2086 | |
| stdbuf -oL -eL bash -c "$FIND_CMD $FIND_REDIR" | tee "$OUTPUT_FILE" | |
| fi | |
| else | |
| # Just stdout | |
| # shellcheck disable=SC2086 | |
| stdbuf -oL -eL bash -c "$FIND_CMD $FIND_REDIR" | |
| fi | |
| status=$? | |
| write_footer >&2 | |
| if [[ $status -ne 0 && "$QUIET" == false ]]; then | |
| echo "Scan completed with find exit code $status" >&2 | |
| fi | |
| exit "$status" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment