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
65 changes: 65 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Secret scan (gitleaks)

# Scans the git history for committed credentials. This is defense-in-depth behind GitHub's
# secret-scanning push protection, which rejects a push *before* the secret reaches GitHub:
# - push protection does not apply to pushes on a fork, so a fork PR can carry a leaked key
# into review; this job runs on those PRs.
# - push protection only knows provider patterns it recognizes; gitleaks also flags generic
# private-key blocks and service-account JSON, which is the shape of this project's
# deployer credentials (see dev/deployment.md).
#
# Deliberately NOT path-filtered — unlike ci.yml, which only runs when source changes. A secret
# can be committed in any file, so every PR is scanned.
#
# `--redact` keeps matched secret material out of the Actions log, which is public on this repo.

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: gitleaks-${{ github.ref }}
cancel-in-progress: true

env:
GITLEAKS_VERSION: "8.30.1"
# sha256 of gitleaks_<version>_linux_x64.tar.gz from the release's checksums.txt
GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"

jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Full history: a secret is a leak even if it was "removed" in a later commit, since
# the blob stays reachable. Shallow clones would scan only the tip.
fetch-depth: 0

- name: Install gitleaks (pinned + checksum-verified)
run: |
set -euo pipefail
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -fsSLO "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -
tar -xzf "$TARBALL" gitleaks
./gitleaks version

- name: Scan history
run: |
set -euo pipefail
# Exits non-zero when a leak is found, failing the check.
./gitleaks git --redact --no-banner .

- name: What to do if this failed
if: failure()
run: |
echo "::error::A credential was found in the git history."
echo "Rotate the credential FIRST — assume it is compromised the moment it is pushed."
echo "Removing the commit does not un-leak it: the blob stays reachable, and this repo is public."
echo "Then purge it from history (git filter-repo) and force-push. See SECURITY.md."
27 changes: 26 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ dist/
.vscode/
.DS_Store

# Secrets
# Secrets — first line of defense only. GitHub push protection (rejects the push) and the
# gitleaks CI job (fails the PR) are the backstops, since no filename list is exhaustive.
.env
.env.*
*.env
!.env.example
.envrc

# Cloud credentials. Deployer service-account keys are fetched per tier (see dev/deployment.md),
# so they routinely land in a working tree — these are the names they land under.
*-key.json
*_key.json
*-keys.json
*-deployer.json
gcp-*.json
service-account*.json
service_account*.json
credentials.json
client_secret*.json
application_default_credentials.json

# Private keys / certificates
*.pem
*.key
*.p12
*.pfx
id_rsa
id_ed25519
40 changes: 38 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,44 @@ exhaustion) rather than data disclosure. Full rationale and the guarded-SQL thre
themselves, not sensitive data — the cap is about log-line hygiene (one pathological query
can't inflate a line), not confidentiality. Client IPs are not logged at the application level
(Cloud Run's own request log already has caller IP, correlatable by timestamp).
- **CI checks** on every PR: `ruff` (lint), `bandit` (static security lint), `pip-audit`
(dependency CVEs), and the `tests` suite.
- **CI checks.** [gitleaks](.github/workflows/gitleaks.yml) (committed credentials) runs on **every**
PR — deliberately not path-filtered, since a credential can be committed in any file.
[ci.yml](.github/workflows/ci.yml) runs `ruff` (lint + format), `bandit` (static security lint),
`pip-audit` (dependency CVEs), and the `tests` suite on Python 3.11/3.12 — but **only** for PRs
touching `src/idc_api/**`, `tests/**`, `pyproject.toml`, `uv.lock`, or `ci.yml` itself.
`actionlint` likewise runs only when a workflow changes. A docs-only PR therefore runs `gitleaks`
and nothing else.
- **Dependency vulnerabilities, caught twice.** `pip-audit` fails CI on a PR whose dependencies
carry a known CVE, and **Dependabot alerts + automated security updates** are enabled on the
repository, so a CVE disclosed *after* a PR merges still opens a fix PR against `main` rather
than waiting for someone to notice. [dependabot.yml](.github/dependabot.yml) separately schedules
weekly grouped *version* updates for the `uv` and `github-actions` ecosystems; security updates
are the repo setting, not that file, and are ungrouped so a fix ships on its own.
- **Credential hygiene, in three layers.** The service itself holds no secrets, but the *deploy*
path does: each tier's deployer service-account JSON key. Those live in GitHub **Environment
secrets**, never in the repo — and three independent guards keep them out of it:
1. **Push protection** (GitHub secret scanning) rejects a push containing a recognized
credential *before* it reaches GitHub. This is the only guard that prevents rather than
detects. It does not cover pushes to forks.
2. **[gitleaks](.github/workflows/gitleaks.yml)** scans the full history on every PR, including
from forks, and flags generic private-key blocks and service-account JSON that provider
pattern-matching misses. Findings are redacted in the log (this repo's Actions logs are
public).
3. **`.gitignore`** covers the filenames deployer keys actually land under. It is the weakest
layer — a filename list is never exhaustive — and exists to catch the common `git add -A`.

If a credential is ever committed: **rotate it first.** Deleting the commit does not un-leak it;
the blob stays reachable and this repo is public. Purge from history afterwards, not instead.

> **These are repository settings, not files.** Secret scanning, push protection, Dependabot alerts,
> and Dependabot security updates are all enabled under *Settings → Code security*. They are not
> visible in any diff, so they can be switched off without a code review — this section is the only
> record that they are meant to be on. Verify with:
>
> ```bash
> gh api repos/ImagingDataCommons/IDC-REST-MCP \
> --jq '.security_and_analysis | {secret_scanning, secret_scanning_push_protection, dependabot_security_updates}'
> ```
- **Non-root container** — `Dockerfile` drops to an unprivileged user before serving.

## Known residual risks (public deployment)
Expand Down