scan-secrets.sh 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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. # Exclude push-gate's own pattern corpus — it contains examples of every
  76. # secret shape it's trying to detect, so scanning it matches everything.
  77. # (Classic snake-eating-tail when push-gate is part of the pushed content.)
  78. git diff "$RANGE" -- . \
  79. ':(exclude,glob)**/push-gate/references/secret-patterns.txt' \
  80. > "$DIFF_FILE"
  81. # Extract added lines only (strip the leading '+'), ignore file-header lines
  82. ADDED_FILE="$(mktemp -t push-gate-added.XXXXXX)"
  83. grep -E '^\+' "$DIFF_FILE" | grep -vE '^\+\+\+ ' | sed 's/^+//' > "$ADDED_FILE" || true
  84. # Load patterns (skip blanks/comments)
  85. PATTERN_ARGS=()
  86. while IFS= read -r line; do
  87. case "$line" in
  88. ''|\#*) continue ;;
  89. *) PATTERN_ARGS+=(-e "$line") ;;
  90. esac
  91. done < "$PATTERNS_FILE"
  92. # Run ripgrep with all patterns; capture matches
  93. RAW_HITS="$(rg --no-filename --line-number --no-heading "${PATTERN_ARGS[@]}" "$ADDED_FILE" 2>/dev/null || true)"
  94. # Filter common false positives.
  95. # Note: the `\.\.\.'` ellipsis-apostrophe patterns were removed because they
  96. # required an embedded `'` inside a bash single-quoted string, which closes
  97. # the string early and breaks the regex ("Unmatched ( or \("). The remaining
  98. # patterns (placeholder/example/getenv/etc) cover the bulk of false positives.
  99. FILTERED_HITS="$(
  100. printf '%s\n' "$RAW_HITS" \
  101. | grep -viE '(example|placeholder|\<dummy\>|\<fake\>|\<TODO\>|<unset>|os\.environ|process\.env|getenv|\$\{[A-Z_]+:-|\$\{[A-Z_]+\}|\$\([A-Z_]+\)|\$env:[A-Z_]+|\.\.\.<)' \
  102. || true
  103. )"
  104. # Drop blank lines
  105. FILTERED_HITS="$(printf '%s\n' "$FILTERED_HITS" | grep -v '^$' || true)"
  106. rm -f "$ADDED_FILE" "$DIFF_FILE"
  107. if [ -n "$FILTERED_HITS" ]; then
  108. echo ""
  109. echo "═══════════════════════════════════════════════════════════════"
  110. echo " SECRET-PATTERN MATCH (regex layer)"
  111. echo "═══════════════════════════════════════════════════════════════"
  112. printf '%s\n' "$FILTERED_HITS" | head -40
  113. echo ""
  114. echo "Refusing push. These are added lines matching secret-shape patterns."
  115. echo "Each match must be confirmed safe (placeholder/reference) or redacted"
  116. echo "via history rewrite. See SKILL.md §False-positive handling."
  117. exit 1
  118. fi
  119. echo "push-gate: secret scan CLEAN (gitleaks + regex layer)"
  120. exit 0