Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Loading