promote(security): withhold the Kestrel key from the public host, redact the cache - #78
Conversation
…act the cache Promotes the security-relevant slice of the fork's `dev` into the org repo. All of this is already merged and reviewed on trentleslie/biomapper2 `dev` (PRs #46-#53); this PR is path-scoped to the client and its config so the fixes land ahead of the much larger studies/ promotion. Three defects, each with a test that fails without the fix: 1. **The API key could be sent to a third-party host.** `KESTREL_API_URL` now defaults to the public, keyless Kestrel, and `kestrel_host_accepts_credentials` refuses to attach the credential to that host at all — compared by normalized hostname, failing closed on an unparseable URL. `_KestrelCachedSession` also scrubs the header on a cross-origin redirect, which `requests` does only for `Authorization`. 2. **The key was persisted to disk in cleartext.** `requests_cache`'s default ignored-parameter list matches case-sensitively and we send `X-API-Key`; that one-character mismatch wrote the credential into every cached record. `CACHE_IGNORED_PARAMETERS` enumerates the casings actually sent, and `CACHE_DIR` is now 0700. 3. **No default request timeout.** The kwarg was forwarded but never defaulted, so mapping-path callers had none and a wedged request could hang a run forever. The value is derived from the observed successful-request duration distribution rather than chosen — `studies/analysis/request_timeout.py` and its committed artifact are included precisely so the test can assert the constant against the derivation instead of against taste. Also here because they live in the same function and cannot be separated without inventing a state that was never green: a retry ladder for transient 5xx/connection errors, per-endpoint request counters, and a DORMANT bisect-on-5xx path (`KESTREL_BISECT_ON_5XX_ENABLED = False`, budgets counted in request volume, every cap failing loud). `get_kestrel_api_key` is no longer memoized — the old cache keyed on `is None`, so it froze the value for the process lifetime and made results depend on call order. Two deltas from the fork's copy, both deliberate: - `pyproject.toml` gets `pythonpath = ["."]` so `studies.analysis` resolves. The fork's line is identical; only the comment differs. - `utils.py:631` drops a quoted annotation that trips UP037. The fork is lint-red on this line and should take the same fix. Verification: `uv run ruff check` clean, `uv run pytest -m "not external"` 329 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t goes to Rebasing this onto #79 put the host guard and the request URL on different sources. #74 made the backend resolvable AFTER import (kg-regression's --kestrel-url sets the env late), so the client builds its URL from get_kestrel_api_url() -- but the guard added here still judged the import-time KESTREL_API_URL constant. Split those two and the guard makes a decision about one host while the request goes to another: override to the public endpoint after import and the constant still names the internal host, so the key is sent to the public host. That is the exact leak this branch exists to close, and the merge would have re-opened it silently. Resolve the URL once per call and use it for all three: the credential decision, the request, and the 401/403 remediation hint. The withheld-key warning now takes the URL it was withheld from rather than reading the constant, so it names the right host under an override. test_kestrel_auth_header now patches the RESOLVER rather than the constant -- pinning a value production no longer consults is not a test. Adds a regression guard asserting the guard-judged URL and the sent-to URL agree; it is structural rather than header-based on purpose, because a header assertion passes whenever the constant and resolver happen to agree, which is the case on the default config. Also clears this branch's pre-existing lint debt, which was invisible until #79 gave dev a CI gate: a duplicate 'import pytest' from the merge, an incompatible log_message override, and 17 pyright argument-type errors on the duck-typed transport doubles (scoped file-level suppression, rationale in the file). Verification: ruff clean, black clean, pyright 0 errors, 302 passed / 62 deselected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
41c13e6 to
934c2f7
Compare
Rebased onto the refreshed
|
Why now
Org
mainanddevboth still carryKESTREL_API_URL = os.getenv("KESTREL_API_URL", "https://kestrel.nathanpricelab.com/api")with no hostname-based key withholding and no cache redaction. Every fix below is already merged and reviewed ontrentleslie/biomapper2dev(PRs #46–#53). This PR is path-scoped to the client and its config so the security fixes land now rather than waiting on the much largerstudies/promotion that follows.The three defects
Each has a test that fails without its fix.
1 · The API key could be sent to a third-party host.
KESTREL_API_URLnow defaults to the public, keyless Kestrel (kestrel.krakenkg.com/api). Any environment that had setKESTREL_API_KEYfor the internal endpoint and never setKESTREL_API_URLwould have started leaking that key to a third party the moment the default changed — sokestrel_host_accepts_credentials()refuses to attach the credential to the public host at all. Compared by normalized hostname, so a trailing slash, a different path, an FQDN trailing dot, or an embeddeduser@cannot smuggle it through; fails closed when the URL has no parseable host._KestrelCachedSession.rebuild_authadditionally scrubs the header on a cross-origin redirect, whichrequestsdoes only forAuthorization— otherwise a 301 from the internal host to the public one (the obvious way to retire it) hands over the key.2 · The key was persisted to disk in cleartext.
requests_cacheships a default ignored-parameter list containingX-API-KEY, but it matches case-sensitively and we sendX-API-Key. That one-character mismatch wrote the credential into every cached record.CACHE_IGNORED_PARAMETERSnow enumerates the casings actually sent,CACHE_DIRis0700, and session construction is centralized inget_session()so no call site can rebuild a session without the redaction arguments.3 · No default request timeout. The
timeoutkwarg was forwarded to the transport but never defaulted, so the mapping-path callers that supply none had no timeout at all and a wedged request could hang a run indefinitely. The value is derived, not chosen:studies/analysis/request_timeout.pycomputes it from the observed successful-request duration distribution, and the test asserts the shipped constant against that derivation. A default under the server's own limit is worse than none — it converts a recoverable server error into a client-side abort — which is why the derivation and its committed artifact are in this PR rather than left behind.Also included, and why
These live inside
bulk_kestrel_requestand cannot be separated from the above without inventing a version of that function that was never green as a unit:KESTREL_BISECT_ON_5XX_ENABLED = False). Its premise — that the server errors are payload-determined — is not yet confirmed by the gated diagnostic. If the cause is load, bisecting amplifies it, so budgets are counted in request volume rather than recursion depth and every cap fails loud.get_kestrel_api_keyis no longer memoized. The old cache keyed onis None, so once an unset key legitimately returnedNonethe memo only ever engaged when a key was present — freezing it for the process lifetime and making the result depend on call order.config.pyalso carries constants for Tier B and the category validator. They are inert here (the modules that read them arrive with thestudies/promotion) and are included to keep this file byte-identical to the fork's, so the follow-up merge does not conflict.Two deliberate deltas from the fork's copy
pyproject.tomlgainspythonpath = ["."]sostudies.analysisresolves under pytest. The fork's line is identical; only the comment text differs.src/biomapper2/utils.py:631drops a quoted annotation that trips ruffUP037. The fork is lint-red on this line and should take the same one-line fix.Verification
uv run ruff check— clean (this is what org CI runs).uv run pytest -m "not external"— 329 passed, 7 deselected.README.md,.env.example, andconfig.pyondev.Not in scope
Rotating the exposed Kestrel API key. That is an operator action and is still outstanding regardless of this merge.
🤖 Generated with Claude Code