Skip to content

Commit 569b1a5

Browse files
authored
Merge pull request #18 from ImagingDataCommons/ci/secret-scanning
ci: guard against committed credentials
2 parents 8100fbf + 797f692 commit 569b1a5

3 files changed

Lines changed: 129 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: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,44 @@ 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.** [gitleaks](.github/workflows/gitleaks.yml) (committed credentials) runs on **every**
40+
PR — deliberately not path-filtered, since a credential can be committed in any file.
41+
[ci.yml](.github/workflows/ci.yml) runs `ruff` (lint + format), `bandit` (static security lint),
42+
`pip-audit` (dependency CVEs), and the `tests` suite on Python 3.11/3.12 — but **only** for PRs
43+
touching `src/idc_api/**`, `tests/**`, `pyproject.toml`, `uv.lock`, or `ci.yml` itself.
44+
`actionlint` likewise runs only when a workflow changes. A docs-only PR therefore runs `gitleaks`
45+
and nothing else.
46+
- **Dependency vulnerabilities, caught twice.** `pip-audit` fails CI on a PR whose dependencies
47+
carry a known CVE, and **Dependabot alerts + automated security updates** are enabled on the
48+
repository, so a CVE disclosed *after* a PR merges still opens a fix PR against `main` rather
49+
than waiting for someone to notice. [dependabot.yml](.github/dependabot.yml) separately schedules
50+
weekly grouped *version* updates for the `uv` and `github-actions` ecosystems; security updates
51+
are the repo setting, not that file, and are ungrouped so a fix ships on its own.
52+
- **Credential hygiene, in three layers.** The service itself holds no secrets, but the *deploy*
53+
path does: each tier's deployer service-account JSON key. Those live in GitHub **Environment
54+
secrets**, never in the repo — and three independent guards keep them out of it:
55+
1. **Push protection** (GitHub secret scanning) rejects a push containing a recognized
56+
credential *before* it reaches GitHub. This is the only guard that prevents rather than
57+
detects. It does not cover pushes to forks.
58+
2. **[gitleaks](.github/workflows/gitleaks.yml)** scans the full history on every PR, including
59+
from forks, and flags generic private-key blocks and service-account JSON that provider
60+
pattern-matching misses. Findings are redacted in the log (this repo's Actions logs are
61+
public).
62+
3. **`.gitignore`** covers the filenames deployer keys actually land under. It is the weakest
63+
layer — a filename list is never exhaustive — and exists to catch the common `git add -A`.
64+
65+
If a credential is ever committed: **rotate it first.** Deleting the commit does not un-leak it;
66+
the blob stays reachable and this repo is public. Purge from history afterwards, not instead.
67+
68+
> **These are repository settings, not files.** Secret scanning, push protection, Dependabot alerts,
69+
> and Dependabot security updates are all enabled under *Settings → Code security*. They are not
70+
> visible in any diff, so they can be switched off without a code review — this section is the only
71+
> record that they are meant to be on. Verify with:
72+
>
73+
> ```bash
74+
> gh api repos/ImagingDataCommons/IDC-REST-MCP \
75+
> --jq '.security_and_analysis | {secret_scanning, secret_scanning_push_protection, dependabot_security_updates}'
76+
> ```
4177
- **Non-root container**`Dockerfile` drops to an unprivileged user before serving.
4278
4379
## Known residual risks (public deployment)

0 commit comments

Comments
 (0)