diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..397cace --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,103 @@ +name: secret-scan + +# Reusable secret-scanning gate. Drop this file into a repo's .github/workflows/. +# Uses the MIT-licensed gitleaks CLI directly (NOT the gitleaks-action, which +# requires a paid license for org-owned repos beyond the first). +# +# The check that appears on the PR is named "secret-scan" (the job id below). +# When requiring this as a status check in an org ruleset, require "secret-scan". + +on: + pull_request: + push: + branches: [main, master] + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: secret-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }} + +jobs: + secret-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout (full history on PRs so we scan the whole diff range) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Install gitleaks (pinned + checksum-verified) + run: | + VERSION=8.28.0 + SHA256=a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb + TARBALL="gitleaks_${VERSION}_linux_x64.tar.gz" + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/${TARBALL}" -o "$TARBALL" + echo "${SHA256} ${TARBALL}" | sha256sum -c - + tar -xz -C /usr/local/bin -f "$TARBALL" gitleaks + gitleaks version + + - name: Scan pushed/PR commits + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.sha }} + run: | + # Compute the git range to scan. Passed via env (not inline GitHub Actions + # expressions) so a branch or SHA can never break out of the shell. + ZERO=0000000000000000000000000000000000000000 + if [ "$EVENT_NAME" = "pull_request" ]; then + # PR: scan exactly the commits the PR introduces. + RANGE="${PR_BASE_SHA}..${PR_HEAD_SHA}" + elif [ "$PUSH_AFTER" = "$ZERO" ]; then + # Branch DELETE push: the after-SHA is all-zeros and there are no + # commits to scan (the ref is gone). Nothing to do. + echo "Branch deletion push (after-SHA is all-zeros); nothing to scan." + exit 0 + elif [ "$PUSH_BEFORE" = "$ZERO" ] || [ -z "$PUSH_BEFORE" ]; then + # First push to a new branch (or a deleted-then-recreated ref): there is + # no "before" commit, so github.event.before is all-zeros. A + # ${ZERO}..HEAD range is invalid and gitleaks errors out. Fall back to + # scanning just the tip commit, which is all this push actually added + # that a PR gate wouldn't already have covered. + RANGE="${PUSH_AFTER}~1..${PUSH_AFTER}" + # If the tip is a root commit (no parent), ~1 doesn't resolve; scan the + # single commit by itself. + if ! git rev-parse --verify --quiet "${PUSH_AFTER}~1" >/dev/null; then + RANGE="$PUSH_AFTER" + fi + else + # Normal push to an existing branch: scan what this push added. + RANGE="${PUSH_BEFORE}..${PUSH_AFTER}" + fi + # Backstop: a usable range must never contain the null SHA and must be + # non-empty. This mirrors scripts/compute-scan-range.sh so the two stay + # in lockstep (asserted by the secret-scan-selftest sync check). + case "$RANGE" in + *"$ZERO"*) echo "::error::computed an invalid range containing the null SHA: $RANGE"; exit 1 ;; + "") echo "::error::computed an empty range"; exit 1 ;; + esac + echo "Scanning range: $RANGE" + gitleaks git \ + --log-opts="$RANGE" \ + --redact \ + --config .gitleaks.toml \ + --exit-code 1 \ + --report-format sarif \ + --report-path gitleaks.sarif || { + echo "::error::Potential secret detected. Review the log above. If this is a real credential, ROTATE IT immediately, then remove it from history. (If it is a genuine example/placeholder, add a '# gitleaks:allow' comment on the line.)"; + exit 1; + } + + - name: Upload findings (kept even when the job fails) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: gitleaks-sarif + path: gitleaks.sarif + if-no-files-found: ignore diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..4932e54 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,26 @@ +# tjmlabs shared gitleaks config. +# Extends the built-in ruleset (100+ detectors) and adds an allowlist so the +# legitimate example/fixture env files in our templates don't trip the gate. + +[extend] +useDefault = true + +[allowlist] +description = "tjmlabs allowlisted example/fixture files and paths" +# NOTE: docs/ and README are deliberately NOT allowlisted. A real key pasted into +# a readme is a common leak path, so we keep scanning them and rely on the +# placeholder regex below to suppress obvious examples. If a legit example still +# trips the gate, add a '# gitleaks:allow' comment on that line. +paths = [ + '''(^|/)\.env\.example$''', + '''(^|/)\.env\.test$''', + '''(^|/)\.env\.sample$''', + '''(^|/)tests?/test\.env$''', + '''(^|/)tests?/fixtures/''', +] + +# Ignore obvious placeholder values so example files with dummy keys stay quiet. +regexTarget = "match" +regexes = [ + '''(?i)(your[-_]?api[-_]?key|changeme|placeholder|xxxx+|example|dummy|<[^>]+>)''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7c4644c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# tjmlabs shared pre-commit config. Ship this into each bot repo (and the +# templates, so new bots inherit it). Catches secrets at the keyboard, before +# they ever reach GitHub. Local hooks are skippable with --no-verify, which is +# why CI (secret-scan.yml) re-runs the same gitleaks as a non-skippable gate. +# +# One-time per machine: uv tool install pre-commit (or: pipx install pre-commit) +# Then in the repo: pre-commit install +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.28.0 + hooks: + - id: gitleaks + args: ["--config", ".gitleaks.toml"] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: detect-private-key # SSH/PEM private keys + - id: check-added-large-files # blocks accidental big blobs + args: ["--maxkb=1024"] + - id: check-merge-conflict