diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..6e8520f --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -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__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." diff --git a/.gitignore b/.gitignore index e270f0e..f106eee 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 2291f7f..49136b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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)