|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Config.Password.Scan.sh |
| 4 | +# ========================= |
| 5 | +# MEARVK LLC — NitroWebExpress™ |
| 6 | +# |
| 7 | +# Scans all configuration-style files in the project (*.xml, *.properties, |
| 8 | +# *.conf, *.cfg, *.ini, *.yml, *.yaml, *.json, *.env) for password / secret / |
| 9 | +# credential fields that contain a *non-default* value — i.e. a real, |
| 10 | +# hardcoded secret rather than a placeholder, empty value, or an indirection |
| 11 | +# (password-env, credentials-file, etc.) that points elsewhere for the |
| 12 | +# real secret. |
| 13 | +# |
| 14 | +# Usage: |
| 15 | +# bash/Config.Password.Scan.sh # scan and print report only |
| 16 | +# bash/Config.Password.Scan.sh --update-gitignore # also add flagged files |
| 17 | +# to .gitignore |
| 18 | +# |
| 19 | +# Exit codes: |
| 20 | +# 0 = no non-default passwords found |
| 21 | +# 1 = one or more non-default passwords found (see report) |
| 22 | +# |
| 23 | +set -uo pipefail |
| 24 | + |
| 25 | +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 26 | +cd "$PROJECT_ROOT" || exit 2 |
| 27 | + |
| 28 | +GITIGNORE="$PROJECT_ROOT/.gitignore" |
| 29 | +UPDATE_GITIGNORE=false |
| 30 | +[[ "${1:-}" == "--update-gitignore" ]] && UPDATE_GITIGNORE=true |
| 31 | + |
| 32 | +# ───────────────────────────────────────────────────────────────────────── |
| 33 | +# Directories to skip entirely (build output, VCS internals, IDE metadata) |
| 34 | +# ───────────────────────────────────────────────────────────────────────── |
| 35 | +PRUNE_DIRS=( |
| 36 | + "*/target/*" |
| 37 | + "*/build/*" |
| 38 | + "*/out/*" |
| 39 | + "*/.git/*" |
| 40 | + "*/.idea/*" |
| 41 | + "*/.vscode/*" |
| 42 | + "*/node_modules/*" |
| 43 | +) |
| 44 | + |
| 45 | +# ───────────────────────────────────────────────────────────────────────── |
| 46 | +# File extensions considered "configuration" files |
| 47 | +# ───────────────────────────────────────────────────────────────────────── |
| 48 | +CONFIG_GLOBS=( |
| 49 | + "*.xml" "*.properties" "*.conf" "*.cfg" "*.ini" |
| 50 | + "*.yml" "*.yaml" "*.json" "*.env" |
| 51 | +) |
| 52 | + |
| 53 | +# ───────────────────────────────────────────────────────────────────────── |
| 54 | +# Keys that indicate a credential field |
| 55 | +# ───────────────────────────────────────────────────────────────────────── |
| 56 | +KEY_PATTERN='(password|passwd|pwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|client[_-]?secret)' |
| 57 | + |
| 58 | +# ───────────────────────────────────────────────────────────────────────── |
| 59 | +# Values considered "default" / safe / not a real leaked secret. |
| 60 | +# Matched case-insensitively against the captured value. |
| 61 | +# ───────────────────────────────────────────────────────────────────────── |
| 62 | +DEFAULT_VALUE_PATTERN='^(|changeit|change_me|changeme|replace_me|your_api_key_here|your_cx_here|xxxxx*|password|secret|todo|n\/a|none)$' |
| 63 | + |
| 64 | +# Keys that are *indirections* to a real secret stored elsewhere |
| 65 | +# (env var name, path to a credentials file, etc.) — not the secret itself. |
| 66 | +INDIRECTION_KEY_PATTERN='(password-env|credentials-file|secret-file|password-file)' |
| 67 | + |
| 68 | +echo "══════════════════════════════════════════════════════════════════════" |
| 69 | +echo " Config Password Scan — MEARVK LLC NitroWebExpress™" |
| 70 | +echo " Root: $PROJECT_ROOT" |
| 71 | +echo "══════════════════════════════════════════════════════════════════════" |
| 72 | + |
| 73 | +# Build the prune expression for `find` |
| 74 | +find_prune_expr=() |
| 75 | +for d in "${PRUNE_DIRS[@]}"; do |
| 76 | + find_prune_expr+=( -path "$d" -o ) |
| 77 | +done |
| 78 | +unset 'find_prune_expr[${#find_prune_expr[@]}-1]' # drop trailing -o |
| 79 | + |
| 80 | +# Build the name expression for `find` |
| 81 | +find_name_expr=() |
| 82 | +for g in "${CONFIG_GLOBS[@]}"; do |
| 83 | + find_name_expr+=( -name "$g" -o ) |
| 84 | +done |
| 85 | +unset 'find_name_expr[${#find_name_expr[@]}-1]' # drop trailing -o |
| 86 | + |
| 87 | +mapfile -d '' -t FILES < <( |
| 88 | + find "$PROJECT_ROOT" \ |
| 89 | + \( "${find_prune_expr[@]}" \) -prune -o \ |
| 90 | + \( "${find_name_expr[@]}" \) -type f -print0 2>/dev/null |
| 91 | +) |
| 92 | + |
| 93 | +declare -a FLAGGED_FILES=() |
| 94 | +TOTAL_HITS=0 |
| 95 | + |
| 96 | +for f in "${FILES[@]}"; do |
| 97 | + rel="${f#"$PROJECT_ROOT"/}" |
| 98 | + |
| 99 | + # Read line by line looking for key/value pairs in XML, properties, JSON, etc. |
| 100 | + while IFS= read -r line_no_and_content; do |
| 101 | + line_no="${line_no_and_content%%:*}" |
| 102 | + content="${line_no_and_content#*:}" |
| 103 | + |
| 104 | + # Skip indirection keys (password-env, credentials-file, ...) |
| 105 | + if echo "$content" | grep -qiE "$INDIRECTION_KEY_PATTERN"; then |
| 106 | + continue |
| 107 | + fi |
| 108 | + |
| 109 | + # Extract candidate value depending on format: |
| 110 | + # XML: <password>VALUE</password> |
| 111 | + # properties: key.password=VALUE |
| 112 | + # JSON/YAML: "password": "VALUE" or password: VALUE |
| 113 | + value="" |
| 114 | + |
| 115 | + if [[ "$content" =~ \<[A-Za-z0-9_-]*($KEY_PATTERN)[A-Za-z0-9_-]*\>([^\<]*)\< ]]; then |
| 116 | + value="${BASH_REMATCH[3]}" |
| 117 | + elif [[ "$content" =~ [\"\']?[A-Za-z0-9_.-]*($KEY_PATTERN)[A-Za-z0-9_.-]*[\"\']?[[:space:]]*[:=][[:space:]]*[\"\']?([^\"\',}]*) ]]; then |
| 118 | + value="${BASH_REMATCH[3]}" |
| 119 | + else |
| 120 | + continue |
| 121 | + fi |
| 122 | + |
| 123 | + # Trim whitespace |
| 124 | + value="$(echo -n "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" |
| 125 | + |
| 126 | + # Skip if value matches a known default/placeholder pattern |
| 127 | + if echo "$value" | grep -qiE "$DEFAULT_VALUE_PATTERN"; then |
| 128 | + continue |
| 129 | + fi |
| 130 | + |
| 131 | + # Skip empty values (already covered above but double-guard) |
| 132 | + [[ -z "$value" ]] && continue |
| 133 | + |
| 134 | + echo " [FOUND] $rel:$line_no -> ${content#"${content%%[![:space:]]*}"}" |
| 135 | + FLAGGED_FILES+=("$rel") |
| 136 | + TOTAL_HITS=$((TOTAL_HITS + 1)) |
| 137 | + |
| 138 | + done < <(grep -nEi "$KEY_PATTERN" "$f" 2>/dev/null) |
| 139 | +done |
| 140 | + |
| 141 | +echo "──────────────────────────────────────────────────────────────────────" |
| 142 | +echo " Total non-default password/secret hits: $TOTAL_HITS" |
| 143 | +echo " Distinct files flagged: $(printf '%s\n' "${FLAGGED_FILES[@]}" | sort -u | wc -l)" |
| 144 | +echo "══════════════════════════════════════════════════════════════════════" |
| 145 | + |
| 146 | +if [[ "$TOTAL_HITS" -eq 0 ]]; then |
| 147 | + echo "No non-default passwords found. Nothing to do." |
| 148 | + exit 0 |
| 149 | +fi |
| 150 | + |
| 151 | +# De-duplicate flagged files |
| 152 | +mapfile -t UNIQUE_FLAGGED < <(printf '%s\n' "${FLAGGED_FILES[@]}" | sort -u) |
| 153 | + |
| 154 | +if [[ "$UPDATE_GITIGNORE" == true ]]; then |
| 155 | + echo |
| 156 | + echo "Updating .gitignore with flagged files..." |
| 157 | + |
| 158 | + { |
| 159 | + echo "" |
| 160 | + echo "### Config files with non-default passwords (auto-detected by bash/Config.Password.Scan.sh) ###" |
| 161 | + for rel in "${UNIQUE_FLAGGED[@]}"; do |
| 162 | + if ! grep -qxF "$rel" "$GITIGNORE" 2>/dev/null; then |
| 163 | + echo "$rel" |
| 164 | + fi |
| 165 | + done |
| 166 | + } >> "$GITIGNORE" |
| 167 | + |
| 168 | + echo "Done. .gitignore updated with $(echo "${#UNIQUE_FLAGGED[@]}") candidate file(s)." |
| 169 | + echo "NOTE: Files already tracked by git must also be removed from the index:" |
| 170 | + echo " git rm --cached <file>" |
| 171 | +else |
| 172 | + echo |
| 173 | + echo "Flagged files (not yet added to .gitignore — rerun with --update-gitignore):" |
| 174 | + for rel in "${UNIQUE_FLAGGED[@]}"; do |
| 175 | + echo " $rel" |
| 176 | + done |
| 177 | +fi |
| 178 | + |
| 179 | +exit 1 |
0 commit comments