Skip to content

Instantly share code, notes, and snippets.

@adyp
Last active November 27, 2025 15:58
Show Gist options
  • Select an option

  • Save adyp/7cf3c80eafac03fca8ccd42605380e2f to your computer and use it in GitHub Desktop.

Select an option

Save adyp/7cf3c80eafac03fca8ccd42605380e2f to your computer and use it in GitHub Desktop.
world_writable_checker
#!/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