Skip to content

Commit f14be98

Browse files
fedorovclaude
andcommitted
ci: guard against committed credentials
The repo is public and nothing stopped a service-account key from being committed. History is clean (verified with gitleaks over all 70 commits), so this is prevention, not cleanup. Three layers, weakest last: - GitHub secret-scanning push protection, enabled on the repo (settings, not in this diff). Rejects the push before the secret reaches GitHub -- the only guard that prevents rather than detects. - gitleaks on every PR, not path-filtered: a secret can land in any file, and fork PRs are not covered by push protection. Pinned + checksum- verified, matching actionlint.yml. --redact keeps key material out of the Actions log, which is public here. - .gitignore for the names deployer keys land under. dev/deployment.md tells operators to fetch per-tier SA JSON keys, so `gcp-sa-key.json` and friends routinely sit in a working tree one `git add -A` from a commit. Previously only `.env` and `*.env` were covered -- not `.env.local`, and no JSON/PEM shape at all. SECURITY.md documents the layers and the response: rotate first, purge second. Deleting the commit does not un-leak a pushed key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent df643d0 commit f14be98

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

.github/workflows/gitleaks.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Secret scan (gitleaks)
2+
3+
# Scans the git history for committed credentials. This is defense-in-depth behind GitHub's
4+
# secret-scanning push protection, which rejects a push *before* the secret reaches GitHub:
5+
# - push protection does not apply to pushes on a fork, so a fork PR can carry a leaked key
6+
# into review; this job runs on those PRs.
7+
# - push protection only knows provider patterns it recognizes; gitleaks also flags generic
8+
# private-key blocks and service-account JSON, which is the shape of this project's
9+
# deployer credentials (see dev/deployment.md).
10+
#
11+
# Deliberately NOT path-filtered — unlike ci.yml, which only runs when source changes. A secret
12+
# can be committed in any file, so every PR is scanned.
13+
#
14+
# `--redact` keeps matched secret material out of the Actions log, which is public on this repo.
15+
16+
on:
17+
push:
18+
branches: [main]
19+
pull_request:
20+
workflow_dispatch:
21+
22+
permissions:
23+
contents: read
24+
25+
concurrency:
26+
group: gitleaks-${{ github.ref }}
27+
cancel-in-progress: true
28+
29+
env:
30+
GITLEAKS_VERSION: "8.30.1"
31+
# sha256 of gitleaks_<version>_linux_x64.tar.gz from the release's checksums.txt
32+
GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
33+
34+
jobs:
35+
gitleaks:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v7
39+
with:
40+
# Full history: a secret is a leak even if it was "removed" in a later commit, since
41+
# the blob stays reachable. Shallow clones would scan only the tip.
42+
fetch-depth: 0
43+
44+
- name: Install gitleaks (pinned + checksum-verified)
45+
run: |
46+
set -euo pipefail
47+
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
48+
curl -fsSLO "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
49+
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -
50+
tar -xzf "$TARBALL" gitleaks
51+
./gitleaks version
52+
53+
- name: Scan history
54+
run: |
55+
set -euo pipefail
56+
# Exits non-zero when a leak is found, failing the check.
57+
./gitleaks git --redact --no-banner .
58+
59+
- name: What to do if this failed
60+
if: failure()
61+
run: |
62+
echo "::error::A credential was found in the git history."
63+
echo "Rotate the credential FIRST — assume it is compromised the moment it is pushed."
64+
echo "Removing the commit does not un-leak it: the blob stays reachable, and this repo is public."
65+
echo "Then purge it from history (git filter-repo) and force-push. See SECURITY.md."

.gitignore

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ dist/
2020
.vscode/
2121
.DS_Store
2222

23-
# Secrets
23+
# Secrets — first line of defense only. GitHub push protection (rejects the push) and the
24+
# gitleaks CI job (fails the PR) are the backstops, since no filename list is exhaustive.
2425
.env
26+
.env.*
2527
*.env
28+
!.env.example
29+
.envrc
30+
31+
# Cloud credentials. Deployer service-account keys are fetched per tier (see dev/deployment.md),
32+
# so they routinely land in a working tree — these are the names they land under.
33+
*-key.json
34+
*_key.json
35+
*-keys.json
36+
*-deployer.json
37+
gcp-*.json
38+
service-account*.json
39+
service_account*.json
40+
credentials.json
41+
client_secret*.json
42+
application_default_credentials.json
43+
44+
# Private keys / certificates
45+
*.pem
46+
*.key
47+
*.p12
48+
*.pfx
49+
id_rsa
50+
id_ed25519

SECURITY.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,23 @@ exhaustion) rather than data disclosure. Full rationale and the guarded-SQL thre
3636
themselves, not sensitive data — the cap is about log-line hygiene (one pathological query
3737
can't inflate a line), not confidentiality. Client IPs are not logged at the application level
3838
(Cloud Run's own request log already has caller IP, correlatable by timestamp).
39-
- **CI checks** on every PR: `ruff` (lint), `bandit` (static security lint), `pip-audit`
40-
(dependency CVEs), and the `tests` suite.
39+
- **CI checks** on every PR: `ruff` (lint + format), `bandit` (static security lint), `pip-audit`
40+
(dependency CVEs), `gitleaks` (committed credentials), and the `tests` suite.
41+
- **Credential hygiene, in three layers.** The service itself holds no secrets, but the *deploy*
42+
path does: each tier's deployer service-account JSON key. Those live in GitHub **Environment
43+
secrets**, never in the repo — and three independent guards keep them out of it:
44+
1. **Push protection** (GitHub secret scanning) rejects a push containing a recognized
45+
credential *before* it reaches GitHub. This is the only guard that prevents rather than
46+
detects. It does not cover pushes to forks.
47+
2. **[gitleaks](.github/workflows/gitleaks.yml)** scans the full history on every PR, including
48+
from forks, and flags generic private-key blocks and service-account JSON that provider
49+
pattern-matching misses. Findings are redacted in the log (this repo's Actions logs are
50+
public).
51+
3. **`.gitignore`** covers the filenames deployer keys actually land under. It is the weakest
52+
layer — a filename list is never exhaustive — and exists to catch the common `git add -A`.
53+
54+
If a credential is ever committed: **rotate it first.** Deleting the commit does not un-leak it;
55+
the blob stays reachable and this repo is public. Purge from history afterwards, not instead.
4156
- **Non-root container**`Dockerfile` drops to an unprivileged user before serving.
4257

4358
## Known residual risks (public deployment)

0 commit comments

Comments
 (0)