scan-secrets.sh 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  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. # Two cases:
  30. # (a) origin/<branch> exists → diff range scan (incremental push)
  31. # (b) origin/<branch> missing → full branch scan (first push to new remote)
  32. # The well-known empty-tree SHA lets us express "everything as added" for the
  33. # regex layer's diff-based extraction without special-casing its plumbing.
  34. EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
  35. if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  36. RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
  37. GITLEAKS_LOG_OPTS="$RANGE"
  38. DIFF_RANGE="$RANGE"
  39. COMMIT_COUNT="$(git rev-list --count "$RANGE")"
  40. if [ "$COMMIT_COUNT" -eq 0 ]; then
  41. echo "push-gate: nothing to push (${RANGE} is empty)."
  42. exit 0
  43. fi
  44. SCAN_LABEL="${COMMIT_COUNT} commits via gitleaks (${RANGE})"
  45. else
  46. COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
  47. if [ "$COMMIT_COUNT" -eq 0 ]; then
  48. echo "push-gate: branch ${BRANCH} has no commits."
  49. exit 0
  50. fi
  51. GITLEAKS_LOG_OPTS="$BRANCH"
  52. DIFF_RANGE="${EMPTY_TREE}..${BRANCH}"
  53. SCAN_LABEL="full branch — ${COMMIT_COUNT} commits via gitleaks (first push to new remote)"
  54. fi
  55. # ── Layer 1: gitleaks on the commit range ─────────────────────────────────────
  56. echo "push-gate: scanning ${SCAN_LABEL}"
  57. GITLEAKS_REPORT="$(mktemp -t gitleaks.XXXXXX.json)"
  58. trap 'rm -f "$GITLEAKS_REPORT" "$DIFF_FILE" 2>/dev/null || true' EXIT
  59. # Config: default rule set + allowlist for public-by-design tokens (e.g. Mapbox pk.*).
  60. # Guarded so push-gate still runs with the built-in default config if it's absent.
  61. GL_PUBTOKEN_CFG="$SCRIPT_DIR/../references/gitleaks-config.toml"
  62. GL_CONFIG_ARG=()
  63. [ -f "$GL_PUBTOKEN_CFG" ] && GL_CONFIG_ARG=(--config "$GL_PUBTOKEN_CFG")
  64. GITLEAKS_EXIT=0
  65. gitleaks detect \
  66. --source . \
  67. "${GL_CONFIG_ARG[@]}" \
  68. --log-opts="$GITLEAKS_LOG_OPTS" \
  69. --report-format=json \
  70. --report-path="$GITLEAKS_REPORT" \
  71. --redact \
  72. --no-banner \
  73. --exit-code=1 \
  74. 2>&1 || GITLEAKS_EXIT=$?
  75. if [ "$GITLEAKS_EXIT" -ne 0 ]; then
  76. echo ""
  77. echo "═══════════════════════════════════════════════════════════════"
  78. echo " SECRET DETECTED (gitleaks)"
  79. echo "═══════════════════════════════════════════════════════════════"
  80. if command -v jq >/dev/null 2>&1 && [ -s "$GITLEAKS_REPORT" ]; then
  81. jq -r '.[] | " \(.RuleID) in \(.File):\(.StartLine) — \(.Description)"' "$GITLEAKS_REPORT" 2>/dev/null \
  82. || cat "$GITLEAKS_REPORT"
  83. else
  84. cat "$GITLEAKS_REPORT"
  85. fi
  86. echo ""
  87. echo "Refusing push. Remediate via one of:"
  88. echo " 1. If the secret is real: rotate it NOW, then rewrite history"
  89. echo " (git filter-repo, BFG, or reset + re-commit)."
  90. echo " 2. If it is a false positive: add to .gitleaksignore at repo root"
  91. echo " and commit, then re-run push-gate."
  92. exit 1
  93. fi
  94. # ── Layer 2: regex corpus on the diff ─────────────────────────────────────────
  95. echo "push-gate: regex layer on added lines"
  96. DIFF_FILE="$(mktemp -t push-gate-diff.XXXXXX)"
  97. # Exclude push-gate's own pattern corpus — it contains examples of every
  98. # secret shape it's trying to detect, so scanning it matches everything.
  99. # (Classic snake-eating-tail when push-gate is part of the pushed content.)
  100. git diff "$DIFF_RANGE" -- . \
  101. ':(exclude,glob)**/push-gate/references/secret-patterns.txt' \
  102. > "$DIFF_FILE"
  103. # Extract added lines only (strip the leading '+'), ignore file-header lines
  104. ADDED_FILE="$(mktemp -t push-gate-added.XXXXXX)"
  105. grep -E '^\+' "$DIFF_FILE" | grep -vE '^\+\+\+ ' | sed 's/^+//' > "$ADDED_FILE" || true
  106. # Load patterns (skip blanks/comments)
  107. PATTERN_ARGS=()
  108. while IFS= read -r line; do
  109. case "$line" in
  110. ''|\#*) continue ;;
  111. *) PATTERN_ARGS+=(-e "$line") ;;
  112. esac
  113. done < "$PATTERNS_FILE"
  114. # Run ripgrep with all patterns; capture matches
  115. RAW_HITS="$(rg --no-filename --line-number --no-heading "${PATTERN_ARGS[@]}" "$ADDED_FILE" 2>/dev/null || true)"
  116. # Filter common false positives.
  117. # Note: the `\.\.\.'` ellipsis-apostrophe patterns were removed because they
  118. # required an embedded `'` inside a bash single-quoted string, which closes
  119. # the string early and breaks the regex ("Unmatched ( or \("). The remaining
  120. # patterns (placeholder/example/getenv/etc) cover the bulk of false positives.
  121. FILTERED_HITS="$(
  122. printf '%s\n' "$RAW_HITS" \
  123. | grep -viE '(example|placeholder|\<dummy\>|\<fake\>|\<TODO\>|<unset>|os\.environ|process\.env|getenv|\$\{[A-Z_]+:-|\$\{[A-Z_]+\}|\$\([A-Z_]+\)|\$env:[A-Z_]+|\.\.\.<|pk\.eyJ[A-Za-z0-9_-]{6,})' \
  124. || true
  125. )"
  126. # Drop blank lines
  127. FILTERED_HITS="$(printf '%s\n' "$FILTERED_HITS" | grep -v '^$' || true)"
  128. rm -f "$ADDED_FILE" "$DIFF_FILE"
  129. if [ -n "$FILTERED_HITS" ]; then
  130. echo ""
  131. echo "═══════════════════════════════════════════════════════════════"
  132. echo " SECRET-PATTERN MATCH (regex layer)"
  133. echo "═══════════════════════════════════════════════════════════════"
  134. printf '%s\n' "$FILTERED_HITS" | head -40
  135. echo ""
  136. echo "Refusing push. These are added lines matching secret-shape patterns."
  137. echo "Each match must be confirmed safe (placeholder/reference) or redacted"
  138. echo "via history rewrite. See SKILL.md §False-positive handling."
  139. exit 1
  140. fi
  141. echo "push-gate: secret scan CLEAN (gitleaks + regex layer)"
  142. exit 0