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