Scope: this repo's REST API + MCP server (src/idc_api/).
This service serves the public NCI Imaging Data Commons index — an open, de-identified dataset, not secret data. The DuckDB backend is opened read-only, so no request can modify or delete it. The realistic risk is therefore abuse of the server (cost, availability, resource exhaustion) rather than data disclosure. Full rationale and the guarded-SQL threat model live in dev/api_v3_plan.md → "Safety for the guarded SQL tool".
-
Read-only, hardened DuckDB connection —
enable_external_access=false,autoload/autoinstall_known_extensions=false,lock_configuration=true(frozen at connect time). SeeDuckDBBackend._hardening_configin duckdb_backend.py. Regression-tested in tests/test_backend_guards.py: non-SELECT statements, multi-statement SQL, local/remote file access, and extension-loading/export statements (INSTALL,LOAD,COPY ... TO,SET) are all rejected. -
Row/response caps —
run_sqland manifest endpoints clampmax_rowsto a hard server ceiling (IDC_API_SQL_MAX_ROWS_CAP,IDC_API_MANIFEST_HARD_CAP); a caller cannot request an unbounded dump. -
Parameterized SQL everywhere we author it — values are always bound (
?placeholders); identifiers that can't be bound (table/column names) are validated against allow-lists before being interpolated. This is an architecture invariant — see CLAUDE.md — enforced in code review and spot-checked bybanditin CI (SQL-construction findings are individually annotated with why the identifier is trusted, not blanket-suppressed). -
Structured audit logging — every REST request and MCP tool call emits one JSON log line (path/tool, status/outcome, duration, row count where applicable) to stdout, which Cloud Run ships to Cloud Logging automatically. For the guarded SQL endpoint/tool, a rendering of the query is included — by default the first 200 chars (
IDC_API_SQL_LOG_MODE=snippet), or a short digest instead (IDC_API_SQL_LOG_MODE=hash) if you'd rather correlate repeated queries without putting query text in logs. Either way this is public-schema SQL the caller wrote 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. gitleaks (committed credentials) runs on every PR — deliberately not path-filtered, since a credential can be committed in any file. ci.yml runs
ruff(lint + format),bandit(static security lint),pip-audit(dependency CVEs), and thetestssuite on Python 3.11/3.12 — but only for PRs touchingsrc/idc_api/**,tests/**,pyproject.toml,uv.lock, orci.ymlitself.actionlintlikewise runs only when a workflow changes. A docs-only PR therefore runsgitleaksand nothing else. -
Dependency vulnerabilities, caught twice.
pip-auditfails 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 againstmainrather than waiting for someone to notice. dependabot.yml separately schedules weekly grouped version updates for theuvandgithub-actionsecosystems; 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:
- 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.
- gitleaks 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).
.gitignorecovers the filenames deployer keys actually land under. It is the weakest layer — a filename list is never exhaustive — and exists to catch the commongit 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:
gh api repos/ImagingDataCommons/IDC-REST-MCP \ --jq '.security_and_analysis | {secret_scanning, secret_scanning_push_protection, dependabot_security_updates}'
- Non-root container —
Dockerfiledrops to an unprivileged user before serving.
| Risk | Status |
|---|---|
| No per-IP rate limiting in front of the service | Mitigated by Cloud Run --max-instances/--concurrency caps (see dev/deployment.md); a dedicated edge rate limit (Cloud Armor / API Gateway) is an infra decision outside this repo. |
CORS allows all origins (*) |
Intentional — the API serves only public, read-only data. Revisit if private data or auth is ever added. |
| MCP DNS-rebinding protection defaults off | Documented trade-off for the unauthenticated hosted transport; operators who want it set IDC_API_MCP_DNS_REBINDING_PROTECTION=true plus an allowed-hosts list. |
| DuckDB sandbox escape (0-day in DuckDB itself) | No code-level mitigation beyond the hardening config above and re-running the guard tests on every DuckDB upgrade; the container itself holds no secrets and reaches no internal network. |
If you run your own fork or self-hosted deployment, you own its network exposure, auth (if you add non-public data), and update cadence — this document describes the project's own hosted service.
Please use GitHub's private "Report a vulnerability" flow so it isn't publicly disclosed before a fix ships. For non-sensitive hardening suggestions (e.g. a missing test case), a regular GitHub issue is fine.