#!/usr/bin/env bash
# ci-secret-scan — install gitleaks and scan for leaked secrets in CI or locally.
#
# Copy this file to .github/ci/secret-scan in consumer repositories. The body is
# the org-wide canonical implementation; sync via scripts/github-repo-lint.
# Bump ci_secret_scan_version AND ci_secret_scan_sha256 together when
# scripts/github-repo-lint reports an eligible newer gitleaks release (same
# release-age gate as dep-updater).
#
# Supply chain: the version is pinned (never `latest`), the download is HTTPS from
# github.com release assets, and the tarball is checked against a hardcoded
# SHA-256 before extraction — a swapped or tampered asset fails loudly. A
# GITLEAKS_VERSION override has no known digest, so checksum verification is
# skipped for it with a warning.
#
# Environment:
#   GITLEAKS_VERSION          Pin override (default: 8.30.1; skips checksum verification)
#   GITLEAKS_BASE             PR base SHA for diff scan (optional)
#   GITLEAKS_HEAD             PR head SHA for diff scan (optional)
#   CI_SECRET_SCAN_BIN_DIR    Install directory (default: /usr/local/bin on Unix,
#                             ~/.cache/repository-helpers/bin on Windows; sudo only
#                             for the Unix /usr/local/bin default)

set -euo pipefail

ci_secret_scan_version='8.30.1'
readonly ci_secret_scan_version

ci_secret_scan_download_retries=3
readonly ci_secret_scan_download_retries

ci_secret_scan_gitleaks_leak_exit=1
readonly ci_secret_scan_gitleaks_leak_exit

# SHA-256 of gitleaks_${ci_secret_scan_version}_<os>_<arch>.{tar.gz,zip}, keyed <os>_<arch>.
# These come from the release's gitleaks_<ver>_checksums.txt. Regenerate on a bump:
#   curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v<VER>/gitleaks_<VER>_checksums.txt" \
#     | grep -E 'linux_x64|linux_arm64|darwin_x64|darwin_arm64|windows_x64|windows_arm64'
declare -rA ci_secret_scan_sha256=(
  [darwin_arm64]='b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5'
  [darwin_x64]='dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709'
  [linux_arm64]='e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080'
  [linux_x64]='551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb'
  [windows_arm64]='b95f5e4f5c425cedca7ee203d9afd29597e692c4924a12ed42f970537c72cc0f'
  [windows_x64]='d29144deff3a68aa93ced33dddf84b7fdc26070add4aa0f4513094c8332afc4e'
)

ci_secret_scan_repo_dir() {
  # css_* locals: scripts/dev/secret-scan has script-level readonly script_dir.
  local css_script_dir
  css_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  cd "${css_script_dir}/../.." && pwd
}

ci_secret_scan_linux_arch() {
  local arch
  arch="$(uname -m)"
  case "$arch" in
    x86_64) printf '%s\n' 'x64' ;;
    aarch64 | arm64) printf '%s\n' 'arm64' ;;
    *)
      printf 'ERROR: unsupported architecture for gitleaks: %s\n' "$arch" >&2
      return 1
      ;;
  esac
}

# OS token for gitleaks release assets: linux | darwin | windows
ci_secret_scan_release_os() {
  local os
  os="$(uname -s)"
  case "$os" in
    Linux) printf '%s\n' 'linux' ;;
    Darwin) printf '%s\n' 'darwin' ;;
    MINGW* | MSYS* | CYGWIN*) printf '%s\n' 'windows' ;;
    *)
      printf 'ERROR: unsupported OS for gitleaks: %s\n' "$os" >&2
      printf 'ERROR: install gitleaks %s on PATH from https://github.com/gitleaks/gitleaks/releases (verify checksums.txt), then re-run\n' \
        "${GITLEAKS_VERSION:-$ci_secret_scan_version}" >&2
      return 1
      ;;
  esac
}

# Archive extension for the release asset (windows uses zip; others tar.gz).
ci_secret_scan_archive_ext() {
  local -r os="$1"
  case "$os" in
    windows) printf '%s\n' 'zip' ;;
    *) printf '%s\n' 'tar.gz' ;;
  esac
}

# Prints the installed gitleaks version (leading `v` stripped), or nothing when
# absent or broken (a non-zero `gitleaks version` must not abort the caller).
ci_secret_scan_installed_version() {
  local out
  command -v gitleaks >/dev/null 2>&1 || return 0
  out="$(gitleaks version 2>/dev/null)" || return 0
  out="${out#v}"
  printf '%s\n' "${out%%[[:space:]]*}"
}

# Ensure the gitleaks that will run is the pinned (SHA-256-verified) one. A
# pre-existing gitleaks on PATH at the wrong version — stale ~/.cache copy, an
# old self-hosted-runner image — is replaced with a verified install rather than
# trusted blindly. Any gitleaks is accepted when GITLEAKS_VERSION overrides the
# pin (its digest is unknown either way).
ci_secret_scan_ensure() {
  local want installed
  want="${GITLEAKS_VERSION:-$ci_secret_scan_version}"
  installed="$(ci_secret_scan_installed_version)"
  if [[ -n "$installed" && "$installed" == "$want" ]]; then
    return 0
  fi
  ci_secret_scan_install_gitleaks
  hash -r 2>/dev/null || true
  installed="$(ci_secret_scan_installed_version)"
  if [[ "$installed" != "$want" ]]; then
    printf 'ERROR: installed gitleaks %s but PATH still resolves %s\n' \
      "$want" "${installed:-<none>}" >&2
    printf 'ERROR: put CI_SECRET_SCAN_BIN_DIR (or the install dir) ahead of the stale gitleaks on PATH\n' >&2
    return 1
  fi
  return 0
}

# Prints the SHA-256 of a file using whichever tool is present (GNU or BSD).
ci_secret_scan_sha256_of() {
  local file="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$file" | awk '{ print $1 }'
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$file" | awk '{ print $1 }'
  else
    printf 'ERROR: neither sha256sum nor shasum found for checksum verification\n' >&2
    return 1
  fi
}

# Verify a downloaded tarball against the hardcoded digest for this platform.
# No-op (with a warning) when GITLEAKS_VERSION overrides the pinned version or
# no digest is recorded for the platform.
ci_secret_scan_verify_checksum() {
  local tarball="$1" os="$2" arch="$3" version="$4"
  local want got

  if [[ "$version" != "$ci_secret_scan_version" ]]; then
    printf 'WARNING: GITLEAKS_VERSION=%s overrides the pin; skipping checksum verification\n' \
      "$version" >&2
    return 0
  fi
  want="${ci_secret_scan_sha256[${os}_${arch}]:-}"
  if [[ -z "$want" ]]; then
    printf 'WARNING: no recorded SHA-256 for gitleaks %s_%s; skipping checksum verification\n' \
      "$os" "$arch" >&2
    return 0
  fi
  got="$(ci_secret_scan_sha256_of "$tarball")" || return 1
  if [[ "$got" != "$want" ]]; then
    printf 'ERROR: gitleaks tarball checksum mismatch (%s_%s v%s)\n  expected %s\n  got      %s\n' \
      "$os" "$arch" "$version" "$want" "$got" >&2
    return 1
  fi
  return 0
}

ci_secret_scan_install_gitleaks() {
  local arch version url tmpdir archive dest_dir attempt http_code os ext binary
  os="$(ci_secret_scan_release_os)"
  arch="$(ci_secret_scan_linux_arch)"
  version="${GITLEAKS_VERSION:-$ci_secret_scan_version}"
  ext="$(ci_secret_scan_archive_ext "$os")"
  url="https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_${os}_${arch}.${ext}"
  tmpdir="$(mktemp -d)"
  archive="${tmpdir}/gitleaks.${ext}"
  if [[ "$os" == 'windows' ]]; then
    dest_dir="${CI_SECRET_SCAN_BIN_DIR:-${HOME}/.cache/repository-helpers/bin}"
    binary='gitleaks.exe'
  else
    dest_dir="${CI_SECRET_SCAN_BIN_DIR:-/usr/local/bin}"
    binary='gitleaks'
  fi

  for attempt in $(seq 1 "$ci_secret_scan_download_retries"); do
    http_code=''
    http_code="$(curl -fSL --retry 2 --retry-delay 1 -o "$archive" -w '%{http_code}' "$url" 2>/dev/null || true)"
    if [[ "$http_code" == '200' ]] \
      && [[ -s "$archive" ]] \
      && ci_secret_scan_archive_looks_valid "$archive" "$ext"; then
      # A checksum mismatch is not transient — fail immediately, do not retry.
      if ! ci_secret_scan_verify_checksum "$archive" "$os" "$arch" "$version"; then
        rm -rf "$tmpdir"
        return 1
      fi
      if ci_secret_scan_extract_binary "$archive" "$ext" "$tmpdir" "$binary"; then
        if [[ "$os" != 'windows' && "$dest_dir" == '/usr/local/bin' ]]; then
          sudo install -m 0755 "${tmpdir}/${binary}" "${dest_dir}/${binary}"
        else
          # Prefer cp+chmod: Git Bash often lacks GNU coreutils `install`.
          mkdir -p "$dest_dir"
          cp "${tmpdir}/${binary}" "${dest_dir}/${binary}"
          chmod 0755 "${dest_dir}/${binary}"
          PATH="${dest_dir}:${PATH}"
          export PATH
        fi
        rm -rf "$tmpdir"
        return 0
      fi
    fi
    rm -f "$archive"
    if (( attempt < ci_secret_scan_download_retries )); then
      sleep $((attempt * 2))
    fi
  done

  rm -rf "$tmpdir"
  printf 'ERROR: gitleaks download failed after %s attempts (url=%s)\n' \
    "$ci_secret_scan_download_retries" "$url" >&2
  return 1
}

# True when the downloaded archive matches the expected format for $ext.
ci_secret_scan_archive_looks_valid() {
  local -r archive="$1"
  local -r ext="$2"
  case "$ext" in
    zip)
      file -b "$archive" | grep -qiE 'zip archive|Zip archive'
      ;;
    tar.gz)
      file -b "$archive" | grep -qi 'gzip'
      ;;
    *)
      return 1
      ;;
  esac
}

# Extract $binary from $archive into $dest_dir. Returns 0 when the binary exists.
ci_secret_scan_extract_binary() {
  local -r archive="$1"
  local -r ext="$2"
  local -r dest_dir="$3"
  local -r binary="$4"
  case "$ext" in
    zip)
      command -v unzip >/dev/null 2>&1 || {
        printf 'ERROR: unzip not found (needed to extract windows gitleaks zip)\n' >&2
        return 1
      }
      unzip -qo "$archive" "$binary" -d "$dest_dir" 2>/dev/null \
        || unzip -qo "$archive" -d "$dest_dir" 2>/dev/null \
        || return 1
      ;;
    tar.gz)
      tar -tzf "$archive" "$binary" >/dev/null 2>&1 || return 1
      tar -xzf "$archive" -C "$dest_dir" "$binary" || return 1
      ;;
    *)
      return 1
      ;;
  esac
  [[ -f "${dest_dir}/${binary}" ]]
}

ci_secret_scan_resolve_repository() {
  local repo_dir="$1"
  local remote

  if [[ -n "${GITHUB_REPOSITORY:-}" ]]; then
    printf '%s\n' "$GITHUB_REPOSITORY"
    return 0
  fi
  remote="$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)"
  if [[ "$remote" =~ github\.com[:/]([^/]+/[^/.]+)(\.git)?$ ]]; then
    printf '%s\n' "${BASH_REMATCH[1]}"
    return 0
  fi
  return 1
}

ci_secret_scan_resolve_branch() {
  local repo_dir="$1"
  local branch

  if [[ -n "${GITHUB_HEAD_REF:-}" ]]; then
    printf '%s\n' "$GITHUB_HEAD_REF"
    return 0
  fi
  if [[ "${GITHUB_REF:-}" =~ ^refs/heads/(.+)$ ]]; then
    printf '%s\n' "${BASH_REMATCH[1]}"
    return 0
  fi
  branch="$(git -C "$repo_dir" branch --show-current 2>/dev/null || true)"
  if [[ -n "$branch" ]]; then
    printf '%s\n' "$branch"
    return 0
  fi
  return 1
}

# Prints remediation when gitleaks reports leaks (exit 1). CI scans run after push, so
# secrets may already exist in remote git objects; this is pre-merge triage, not prevention.
ci_secret_scan_print_mitigation() {
  local repo_dir="$1"
  local repository branch

  printf '\n' >&2
  printf 'ERROR: SECRET_SCAN_LEAK gitleaks reported one or more secrets\n' >&2
  printf '\n' >&2
  printf 'Workflow secret scanning runs after push. Leaked material may already be in\n' >&2
  printf 'remote git objects for this branch. Use this job for pre-merge triage and\n' >&2
  printf 'backfill detection — not as the primary prevention layer.\n' >&2
  printf '\n' >&2
  printf 'Mitigation (complete all steps):\n' >&2
  printf '  1. Revoke and rotate every credential gitleaks reported (assume compromise).\n' >&2
  printf '  2. Close the PR without merging. Do not merge until a clean branch replaces it.\n' >&2
  if repository="$(ci_secret_scan_resolve_repository "$repo_dir")" \
    && branch="$(ci_secret_scan_resolve_branch "$repo_dir")"; then
    printf '  3. Delete the remote branch:\n' >&2
    printf '       git push origin --delete %s\n' "$branch" >&2
    printf '     or:\n' >&2
    printf '       gh api -X DELETE repos/%s/git/refs/heads/%s\n' "$repository" "$branch" >&2
    printf '  4. Delete the local branch and return to the default branch:\n' >&2
    printf '       git switch main\n       git branch -D %s\n' "$branch" >&2
  else
    printf '  3. Delete the remote branch that introduced the secret:\n' >&2
    printf '       git push origin --delete <branch>\n' >&2
    printf '  4. Delete the local branch and return to the default branch:\n' >&2
    printf '       git switch main\n       git branch -D <branch>\n' >&2
  fi
  printf '  5. Remove the secret from your working tree, then open a new PR from a fresh\n' >&2
  printf '     branch (never recommit the same credential).\n' >&2
  printf '  6. If the secret reached main, treat it as an incident: rotate again, audit\n' >&2
  printf '     access logs, and coordinate history rewrite only with org owners.\n' >&2
  printf '\n' >&2
  printf 'Prevention before the next push:\n' >&2
  printf '  - Run scripts/dev/secret-scan (repository-helpers) or gitleaks locally\n' >&2
  printf '  - Add a pre-push hook / IDE scan where practical\n' >&2
  printf '  - Enable GitHub push protection and org secret scanning where available\n' >&2
  printf '\n' >&2
}

ci_secret_scan_run() {
  local repo_dir base head scan_rc
  repo_dir="$1"
  base="${GITLEAKS_BASE:-}"
  head="${GITLEAKS_HEAD:-}"
  scan_rc=0

  if [[ -n "$base" && -n "$head" ]]; then
    gitleaks detect --source "$repo_dir" --log-opts "${base}..${head}" || scan_rc=$?
  else
    gitleaks detect --source "$repo_dir" || scan_rc=$?
  fi

  if (( scan_rc == ci_secret_scan_gitleaks_leak_exit )); then
    ci_secret_scan_print_mitigation "$repo_dir"
  fi
  return "$scan_rc"
}

ci_secret_scan_main() {
  local repo_dir
  repo_dir="$(ci_secret_scan_repo_dir)"

  ci_secret_scan_ensure

  ci_secret_scan_run "$repo_dir"
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  ci_secret_scan_main
fi
