scan-secrets.sh 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. #!/usr/bin/env bash
  2. # scan-secrets.sh — Secret-scan a pending push diff via gitleaks + regex layer.
  3. #
  4. # Usage: scan-secrets.sh <remote> <branch>
  5. # Exit: 0 clean, 1 secret hit, 5 missing dep
  6. set -euo pipefail
  7. REMOTE="${1:?usage: scan-secrets.sh <remote> <branch>}"
  8. BRANCH="${2:?usage: scan-secrets.sh <remote> <branch>}"
  9. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  10. PATTERNS_FILE="$SCRIPT_DIR/../references/secret-patterns.txt"
  11. # ── Dep check ─────────────────────────────────────────────────────────────────
  12. if ! command -v gitleaks >/dev/null 2>&1; then
  13. cat >&2 <<'EOF'
  14. push-gate: gitleaks not installed.
  15. Install:
  16. Windows (scoop): scoop install gitleaks
  17. Windows (winget): winget install gitleaks.gitleaks
  18. macOS: brew install gitleaks
  19. Linux (apt): apt install gitleaks
  20. Any platform: https://github.com/gitleaks/gitleaks/releases
  21. EOF
  22. exit 5
  23. fi
  24. if ! command -v rg >/dev/null 2>&1; then
  25. echo "push-gate: ripgrep (rg) not installed. See https://github.com/BurntSushi/ripgrep" >&2
  26. exit 5
  27. fi
  28. # ── Range to scan ─────────────────────────────────────────────────────────────
  29. RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
  30. if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  31. echo "push-gate: remote ref ${REMOTE}/${BRANCH} not found locally — did you fetch?" >&2
  32. exit 5
  33. fi
  34. COMMIT_COUNT="$(git rev-list --count "$RANGE")"
  35. if [ "$COMMIT_COUNT" -eq 0 ]; then
  36. echo "push-gate: nothing to push (${RANGE} is empty)."
  37. exit 0
  38. fi
  39. # ── Layer 1: gitleaks on the commit range ─────────────────────────────────────
  40. echo "push-gate: scanning ${COMMIT_COUNT} commits via gitleaks (${RANGE})"
  41. GITLEAKS_REPORT="$(mktemp -t gitleaks.XXXXXX.json)"
  42. trap 'rm -f "$GITLEAKS_REPORT" "$DIFF_FILE" 2>/dev/null || true' EXIT
  43. GITLEAKS_EXIT=0
  44. gitleaks detect \
  45. --source . \
  46. --log-opts="$RANGE" \
  47. --report-format=json \
  48. --report-path="$GITLEAKS_REPORT" \
  49. --redact \
  50. --no-banner \
  51. --exit-code=1 \
  52. 2>&1 || GITLEAKS_EXIT=$?
  53. if [ "$GITLEAKS_EXIT" -ne 0 ]; then
  54. echo ""
  55. echo "═══════════════════════════════════════════════════════════════"
  56. echo " SECRET DETECTED (gitleaks)"
  57. echo "═══════════════════════════════════════════════════════════════"
  58. if command -v jq >/dev/null 2>&1 && [ -s "$GITLEAKS_REPORT" ]; then
  59. jq -r '.[] | " \(.RuleID) in \(.File):\(.StartLine) — \(.Description)"' "$GITLEAKS_REPORT" 2>/dev/null \
  60. || cat "$GITLEAKS_REPORT"
  61. else
  62. cat "$GITLEAKS_REPORT"
  63. fi
  64. echo ""
  65. echo "Refusing push. Remediate via one of:"
  66. echo " 1. If the secret is real: rotate it NOW, then rewrite history"
  67. echo " (git filter-repo, BFG, or reset + re-commit)."
  68. echo " 2. If it is a false positive: add to .gitleaksignore at repo root"
  69. echo " and commit, then re-run push-gate."
  70. exit 1
  71. fi
  72. # ── Layer 2: regex corpus on the diff ─────────────────────────────────────────
  73. echo "push-gate: regex layer on added lines"
  74. DIFF_FILE="$(mktemp -t push-gate-diff.XXXXXX)"
  75. git diff "$RANGE" > "$DIFF_FILE"
  76. # Extract added lines only (strip the leading '+'), ignore file-header lines
  77. ADDED_FILE="$(mktemp -t push-gate-added.XXXXXX)"
  78. grep -E '^\+' "$DIFF_FILE" | grep -vE '^\+\+\+ ' | sed 's/^+//' > "$ADDED_FILE" || true
  79. # Load patterns (skip blanks/comments)
  80. PATTERN_ARGS=()
  81. while IFS= read -r line; do
  82. case "$line" in
  83. ''|\#*) continue ;;
  84. *) PATTERN_ARGS+=(-e "$line") ;;
  85. esac
  86. done < "$PATTERNS_FILE"
  87. # Run ripgrep with all patterns; capture matches
  88. RAW_HITS="$(rg --no-filename --line-number --no-heading "${PATTERN_ARGS[@]}" "$ADDED_FILE" 2>/dev/null || true)"
  89. # Filter common false positives
  90. FILTERED_HITS="$(
  91. printf '%s\n' "$RAW_HITS" \
  92. | grep -viE '(example|placeholder|\<dummy\>|\<fake\>|\<TODO\>|<unset>|os\.environ|process\.env|getenv|\$\{[A-Z_]+:-|\$\{[A-Z_]+\}|\$\([A-Z_]+\)|\$env:[A-Z_]+|\.\.\.<|\.\.\.')|\.\.\.'\s*$)' \
  93. || true
  94. )"
  95. # Drop blank lines
  96. FILTERED_HITS="$(printf '%s\n' "$FILTERED_HITS" | grep -v '^$' || true)"
  97. rm -f "$ADDED_FILE" "$DIFF_FILE"
  98. if [ -n "$FILTERED_HITS" ]; then
  99. echo ""
  100. echo "═══════════════════════════════════════════════════════════════"
  101. echo " SECRET-PATTERN MATCH (regex layer)"
  102. echo "═══════════════════════════════════════════════════════════════"
  103. printf '%s\n' "$FILTERED_HITS" | head -40
  104. echo ""
  105. echo "Refusing push. These are added lines matching secret-shape patterns."
  106. echo "Each match must be confirmed safe (placeholder/reference) or redacted"
  107. echo "via history rewrite. See SKILL.md §False-positive handling."
  108. exit 1
  109. fi
  110. echo "push-gate: secret scan CLEAN (gitleaks + regex layer)"
  111. exit 0