From 2d398159e2a0c860785441ccba596a7e63efa19e Mon Sep 17 00:00:00 2001 From: akula Date: Thu, 30 Jul 2026 10:07:03 -0400 Subject: [PATCH] Keys never live in a repo: an ignore list and a scanner that actually looks On 2026-07-30 a VPN profile with an unencrypted private key was found in the planning repo. It had been there since 2026-07-01, readable by five people, and the whole VPN trust chain had to be burned. Secret scanning did not miss it -- it never looked. Three holes, all closed here: wrong repo - scanning ran on 3 of 14 repos. This goes on every one. wrong trigger - it fired on pull requests only, and that repo pushes straight to main. This fires on pushes too, every branch. wrong scope - it read only a pull request's diff. The weekly run reads the ENTIRE history, so anything that slipped in earlier surfaces. Fail-closed: a hit fails the job, and so does a scanner that could not run. A scan that cannot run must never report success. The ignore list is the second layer -- it stops the file being staged at all. It is preventive only: nothing already committed is untracked or removed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/gitleaks.yml | 90 ++++++++++++++++++++++++++++++++++ .gitignore | 65 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .github/workflows/gitleaks.yml create mode 100644 .gitignore diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..0aba39c --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,90 @@ +name: gitleaks + +# Secret scanning that covers the ways a secret actually gets in. +# +# WHY THIS EXISTS. On 2026-07-30 a VPN profile with an unencrypted private key +# was found in the planning repo, committed 2026-07-01 and readable by five +# people for 29 days. Secret scanning did not miss it — it never looked. Three +# holes, all closed here: +# +# 1. WRONG REPO — scanning ran on 3 of 14 repos. This workflow goes on every +# repo in the org, public ones included. +# 2. WRONG TRIGGER — it fired on pull requests only, and that repo pushes +# straight to main. This fires on pushes too, on every branch. +# 3. WRONG SCOPE — it read only the diff of a pull request, so anything that +# arrived another way stayed invisible. The weekly run reads the ENTIRE +# history, so a secret that slipped in before this workflow existed still +# gets found. +# +# FAIL-CLOSED, deliberately. A hit fails the job, and so does an error running +# the scanner. A scan that could not run must never report success — that is +# the same trap as a test that passes while measuring nothing. +# +# ON A HIT: the credential is compromised. Remove it, ROTATE it (git history +# keeps the old value), confirm it is covered by .gitignore. Rewriting history +# alone does not make a leaked key safe — only rotation does. + +on: + pull_request: + push: + schedule: + - cron: '23 6 * * 1' # Mondays 06:23 UTC — the full-history sweep + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gitleaks-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # every commit — the scheduled sweep reads the whole history, and a + # shallow clone would silently scan almost nothing + fetch-depth: 0 + + - name: Scan for secrets + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.sha }} + run: | + set -euo pipefail + V=8.18.4 + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_x64.tar.gz" \ + | tar -xz -C /tmp gitleaks + + # Pick the range. Anything unusual — a brand-new branch (all-zero + # "before"), a scheduled run, a manual run — falls through to the + # whole history rather than skipping the scan. + RANGE="" + case "$EVENT" in + pull_request) RANGE="${PR_BASE}..${PR_HEAD}" ;; + push) RANGE="${PUSH_BEFORE}..${PUSH_AFTER}" ;; + esac + if printf '%s' "$RANGE" | grep -qE '^0{40}\.\.|\.\.0{40}$|^\.\.|^$'; then + RANGE="" + fi + + if [ -n "$RANGE" ]; then + echo "scanning range $RANGE" + /tmp/gitleaks detect --source=. --redact --log-opts="$RANGE" + else + echo "scanning the ENTIRE history" + /tmp/gitleaks detect --source=. --redact + fi + + - name: What to do if that failed + if: failure() + run: | + echo "::error::A secret was found, or the scan could not run. Either way this does not merge." + echo "If a credential was found: it is compromised. Remove it, ROTATE it (git history keeps the old value)," + echo "and confirm .gitignore covers it. Rewriting history alone does NOT make a leaked key safe." + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be81d65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ + +# ───────────────────────────────────────────────────────────────────────────── +# CREDENTIALS NEVER LIVE IN A REPO. +# +# Added org-wide 2026-07-30. A VPN connection profile carrying an unencrypted +# private key had been sitting in the planning repo since 2026-07-01 — pushed +# as a side effect of a folder move, not a decision, and readable by five +# people for 29 days. The whole VPN trust chain was burned the day it was found. +# +# It got in because nothing scanned that path: secret scanning ran on only 3 of +# 14 repos, fired on pull requests only, and read just the diff. All three holes +# are closed by .github/workflows/gitleaks.yml, added in the same change as +# this block. This list is the second layer — it stops the file being staged in +# the first place. +# +# This is a PREVENTIVE list. It does not untrack anything already committed; +# nothing existing is removed by adding it. +# ───────────────────────────────────────────────────────────────────────────── + +# VPN / connection profiles +*.ovpn +*.tblk +*.mobileconfig + +# keys and certificates +*.pem +*.key +*.p8 +*.p12 +*.pfx +*.jks +*.keystore +*.crt +*.cer +*.der +*.asc +*.gpg +*.kdbx +*_rsa +*_dsa +*_ecdsa +*_ed25519 +*.ppk + +# environment files — .env.example stays, it holds names not values +.env +.env.* +!.env.example +!.env.sample +!.env.template + +# cloud + service credentials +.aws/credentials +credentials.json +service-account*.json +gcloud-service-key.json +*-serviceaccount.json +.npmrc +.pypirc +.netrc +kubeconfig +*.kubeconfig +terraform.tfvars +*.auto.tfvars +.pulumi/credentials.json