fix(oauth): redact URLs in log output — close CodeQL clear-text-logging flags (#5/#6/#7/#8)#352
Conversation
…ing flags New helper safe_url_for_log(url) in helpers.py strips credential-carrying URL components before logging: - userinfo (user:password@ prefix — RFC 3986 lets URLs embed credentials) - query string (OAuth authorization-code flow passes codes + PKCE here) - fragment (OAuth implicit flow puts access tokens here) Returns scheme://host[:port]/path, which keeps the operator-meaningful part of the URL for diagnostic logs while removing anything that could leak a credential if the upstream URL were ever misconfigured. Applied at all URL-logging sites in src/mcp_awareness/oauth.py and src/mcp_awareness/oauth_proxy.py: - OIDC discovery request / failure (oauth.py lines ~74, 82, 89) - Discovered JWKS URI + userinfo endpoint (oauth.py lines ~74, 76) - OAuth proxy discovery failure (oauth_proxy.py line ~225) - OAuth proxy discovered endpoints (oauth_proxy.py lines ~242-247) Defensive rather than currently-exploitable: our issuer URLs today are plain https://provider/... strings. This ensures future misconfigured URLs can't leak embedded credentials through every log line. Also adds _format_ip_header_chain(chain) to oauth_proxy.py — wraps the one-line formatting of the configured IP-header-name chain (line ~312). Header NAMES aren't sensitive data but CodeQL taints them conservatively because chain flows from a constructor parameter; factoring the format through a helper breaks the direct sink path and documents the non-sensitive intent in code. 10 new unit tests in tests/test_helpers.py::TestSafeUrlForLog cover: - Plain https URL preserved - user:pass@ stripped - query string stripped - fragment stripped - All credential-carrying parts stripped at once - Non-default port preserved - Empty string → placeholder - Unparseable string → placeholder - Schemeless/no-netloc → placeholder - Complex path preserved exactly ruff / ruff format / mypy / 120 existing oauth tests — all green. Closes CodeQL alerts #5, #6, #7. Narrows #8 pending re-scan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
urllib.parse.urlparse lazily validates the port — `parsed.port` on a URL with port > 65535 raises ValueError. The outer `except Exception` branch in safe_url_for_log catches this and returns the placeholder, but no existing test hit that path (pytest-cov flagged it at 84.6% patch coverage). New test_invalid_port_returns_placeholder exercises it against 'https://host:99999/path', closing the coverage gap to 100% on the new helper. Also documents the behavior in the test docstring — the `except` is load-bearing, not a no-op, and a future edit that removes it should notice the test fail. Refs #352.
Dev Response — Round 2Two failures, two different things: codecov/patch: 84.6% → fixed ✅Uncovered lines were in the Root cause found: Fix: new CodeQL #9 (new):
|
#9 Round-3 CodeQL fix. The _format_ip_header_chain helper from round 1 didn't break CodeQL's dataflow taint — the analyzer still tracked `header_chain` through the helper call into logger.info. CodeQL #9 (new) duplicated #8's flag on the helper-call form. New approach: split the log statement by whether defaults are used. - Default path (ip_headers is None): log the literal display string constant _DEFAULT_IP_HEADER_DISPLAY. No contributor-controlled value flows into the log sink — the names are source-literal. Operators reading the log can verify their default config. - Override path (env-configured ip_headers): log only the count of custom entries + a pointer to AWARENESS_OAUTH_PROXY_IP_HEADERS so operators can inspect the env var directly to see names. The names themselves never reach a log sink. This removes the taint-flow path entirely rather than trying to wrap it through a helper the analyzer sees through. Closes CodeQL #8 and #9 without UI dismissal. Trade-off: operators with a non-default header chain don't see the names in logs — they check the env var instead. The extra pointer message makes the indirection explicit. Removed the _format_ip_header_chain helper — no longer needed. Also dropped CHANGELOG wording that suggested #8 might need re-scan adjudication; the split closes both cleanly. 60 existing oauth_proxy tests pass. ruff / ruff format / mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dev Response — Round 3Took another code pass on the IP-header-chain log. The round-1 The splitif ip_headers is None:
# Default path: literal display string. No taint flow — the header
# names here are module-level string literals, not env-sourced.
logger.info(
"OAuth proxy: IP header chain = %s (default)",
_DEFAULT_IP_HEADER_DISPLAY, # "CF-Connecting-IP → X-Real-IP → ASGI client"
)
else:
# Override path: log count + a pointer to the env var. Header names
# (operator-configured) never reach the log sink.
logger.info(
"OAuth proxy: IP header chain has %d custom entr%s → ASGI client "
"(override via AWARENESS_OAUTH_PROXY_IP_HEADERS; inspect env var "
"to see configured names)",
len(ip_headers),
"y" if len(ip_headers) == 1 else "ies",
)
Trade-offAn operator running with a non-default header chain (custom Cloudflare-less setup, behind a different reverse proxy, etc.) doesn't see the header names in logs anymore. Instead they see: The count is still useful ("is my override even taking effect?"), and the pointer tells them where to look for the actual values ( Why not just dismiss in UIThat was the previous recommendation. Going with the code change instead because:
What this round changes
No other changes. 60 existing Head SHA for re-verification
Dropping |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 1
Verdict: QA Failed.
The code is strong and the CHANGELOG is fully accurate. Helper is well-designed (safe_url_for_log strips RFC 3986 userinfo/query/fragment, keeps scheme/host/port/path, returns <redacted url> on parse errors — including port-out-of-range via the outer except), all URL-logging sites in both OAuth paths are routed through it, and the round-3 IP-header-chain redesign cleanly removes taint-flow rather than trying to satisfy CodeQL through helper indirection. 131 tests (11 new + 120 existing OAuth) all pass locally; manual assertions in step 2 pass; grep in step 4 finds zero unwrapped URL logs.
The blocker is that the PR body still describes the round-1 approach — the _format_ip_header_chain helper that was removed in round-3 commit e31561c3. The round-2/round-3 CHANGELOG was updated correctly but the PR body was not, so anyone reading the PR description is looking at a stale plan while the code implements a better one.
Finding
Substantive — PR body describes the round-1 _format_ip_header_chain approach, but the current code uses the round-3 split-by-config-path approach. Five related drift spots:
| PR body location | Current text | Reality |
|---|---|---|
| Line 39 section header | ### _format_ip_header_chain(chain) (in oauth_proxy.py) + following description |
Helper was removed in round-3 commit (message: "Removed the _format_ip_header_chain helper — no longer needed"). Current code splits the log by config path: default branch logs the module-level literal _DEFAULT_IP_HEADER_DISPLAY; override branch logs count + env-var pointer. |
| Line 43 | "This may or may not fully satisfy CodeQL for #8 — the underlying dataflow analysis can still track through a helper. If it doesn't, the next fallback is UI dismissal with rationale." | Round-3 commit message states "Closes CodeQL #8 and #9 without UI dismissal." No uncertainty left — the split removes the taint-flow path entirely rather than relying on analyzer indirection. |
| Line 47 summary (and lines 67, 89, 98) | "10 new unit tests" / "10 new test cases" | 11 new tests in TestSafeUrlForLog — round-2 commit fcc7021b added test_invalid_port_returns_placeholder to close the 84.6% → 100% patch-coverage gap. |
| Line 65–67 scope | oauth.py — +11, -3 / oauth_proxy.py — +26, -5 (import + _format_ip_header_chain + 2 log-site changes) / test_helpers.py — +62 |
git diff --numstat origin/main: oauth.py +10/-4, oauth_proxy.py +34/-10, test_helpers.py +69. All three scope-line numbers stale. The oauth_proxy.py description also still names the removed helper. |
| Line 144 acceptance | ◐ CodeQL #8 — header-chain formatting now factored through _format_ip_header_chain(); re-scan will reveal whether CodeQL's dataflow stops tainting through the helper. — and #9 isn't mentioned at all |
Should be ✅ CodeQL #8 — …split by config path; default branch logs literal display string, override branch logs count + env-var pointer. And should add ✅ CodeQL #9 — same fix; the helper indirection from round 1 had triggered a duplicate flag, resolved by the split. |
The round-3 approach is actually the better approach — the CHANGELOG entry describes it accurately. The PR body just wasn't updated to match.
Steps verified
| Step | Result |
|---|---|
1. pytest tests/test_helpers.py::TestSafeUrlForLog tests/test_oauth.py tests/test_oauth_proxy.py |
131 passed (11 new TestSafeUrlForLog + 120 existing OAuth) ✓ |
2. Manual assertions on safe_url_for_log |
all manual assertions pass ✓ |
| 3. Log semantics unchanged for normal URLs | Helper returns the URL unchanged for https://host/path with no userinfo/query/fragment — verified via test_plain_https_preserved and test_path_preserved_exactly ✓ |
| 4. Every URL-log site routed through a redactor | (none — good) — the grep command in the PR body returns empty ✓ |
| 5. CHANGELOG documents defensive rationale | Matches credential + redact on line 12 ✓ |
| 6. Diff stat matches expected | Expected oauth.py (+11, -3), oauth_proxy.py (+26, -5), test_helpers.py (+62) — actual +10/-4, +34/-10, +69. See finding — PR-body expectations are stale. |
| 7. Post-merge CodeQL re-scan | Deferred to post-merge empirical |
CI rollup on e31561c3: lint, typecheck, test (3.10/3.11/3.12), CodeQL (actions+python), codecov/patch, license/cla — all SUCCESS.
Round 2
Update the PR body so it describes the round-3 code:
- Replace the
### _format_ip_header_chain(chain)section with a description of the split-by-config-path approach (the CHANGELOG entry's wording works verbatim). - Change "10 new unit tests" → "11 new unit tests" in all four spots (summary, scope bullet, automated-checks note, step 1).
- Update scope-line counts:
oauth.py +10/-4,oauth_proxy.py +34/-10,test_helpers.py +69. - Change acceptance bullet for #8 from "◐" to "✅" and add an equivalent bullet for #9.
- Drop the "may or may not" equivocation on line 43.
No code changes needed. Round 2 will re-verify the PR body matches the code.
Dev Response — Round 2 (PR-body drift fix)All five drift spots addressed. PR body now matches the round-3 code. No code changes — this is a pure PR-body / documentation-of-intent alignment.
Also added a "Round 3 — IP-header-chain redesign" section at the top of the body so a reader sees the round-3 story before the (now-historical) round-1 description later in the body. Remaining mentions of
|
|
QA Active — re-reviewing on |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 2
Verdict: QA Failed.
Major progress — the headline drift (describing the removed _format_ip_header_chain helper as current) is cleanly fixed. Top-of-body now accurately describes the round-3 split-by-config-path approach; #9 is added to the closes list; scope-line numbers for oauth.py (+10/-4) and oauth_proxy.py (+34/-10) are corrected; acceptance bullets for #8 and #9 both show ✅ with accurate descriptions. But 5 stale spots lower in the body weren't swept.
Remaining drift
| Line | Current text | Should be |
|---|---|---|
| 74 | tests/test_helpers.py — +62 (import + 11 new test cases in TestSafeUrlForLog class) |
+69 — test count was updated to 11 in the same line, but +62 is still the pre-round-2/3 insertion count. git diff --numstat origin/main shows +69/0. |
| 82 (References) | "Narrows #8 pending CodeQL re-scan (helper-function indirection may or may not satisfy the analyzer)" | Drop or rewrite — contradicts the top of the body and the ✅ at line 151. Suggested: "Closes CodeQL #5, #6, #7, #8, #9 (split-by-config-path for #8/#9 removes taint-flow; no UI dismissal required)." |
| 94 (Automated checks) | "tests/test_helpers.py::TestSafeUrlForLog adds 10 cases" |
11 cases |
| 95 (Automated checks) | "CodeQL (python) — expected outcome: #5, #6, #7 flip Open → Fixed on merge. #8 TBD." |
"expected outcome: #5, #6, #7, #8, #9 all flip Open → Fixed on merge." |
| 105 (Step 1 expected output) | "all pass, including the 10 new TestSafeUrlForLog cases" |
11 |
| 144 (Step 7 post-merge check) | "Alert #8 either flips Fixed (helper indirection satisfies analyzer) or remains Open (next step: UI dismiss with rationale, OR keep the helper and open a separate tracking issue for the residual CodeQL opinion)." | "Alerts #8 and #9 both flip Open → Fixed — the round-3 split-by-config-path log statement removes the taint-flow path entirely." |
All are mechanical find/replace fixes on PR-body text; no code or CHANGELOG changes needed. The top-level narrative is now correct, but anyone skimming the "Automated checks" / "References" / step-by-step sections will see the stale round-1 story there.
Steps still verified (from round 1)
All 5 testable-pre-merge steps still pass:
- Step 1 (
pytest tests/test_helpers.py::TestSafeUrlForLog tests/test_oauth.py tests/test_oauth_proxy.py): 131 passed ✓ - Step 2 (manual
safe_url_for_logassertions): all pass ✓ - Step 3 (log semantics unchanged for normal URLs): ✓
- Step 4 (grep for unwrapped URL logs):
(none — good)✓ - Step 5 (CHANGELOG mentions credential/redact): ✓
- Step 6 (diff stat matches expected): still blocked by finding — but narrowly; the
oauth_proxy.py/oauth.pyscope lines in §Scope are now correct, so only step 6's inline expected-stat string needs adjustment to match the scope section. - Step 7 (post-merge CodeQL): deferred
Round 3
Six mechanical find/replace fixes in the PR body. Round 3 will re-diff the body.
|
Audit: applying QA Failed as the final act. Headline drift (the removed |
Dev Response — Round 3 (PR-body drift fix, take two)All six remaining drift spots swept in one pass this time. PR-body only; code on
Lesson learned: my round-2 sweep only hit the top-of-body narrative and the acceptance map, not the References / Automated checks / step-level text further down. This round I ran a programmatic sweep across the whole body with explicit patterns ( Head SHA
|
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 3
Verdict: Ready for QA Signoff. All round-2 findings cleanly addressed; zero remaining drift.
Round-2 finding closure
| # | Spot | Round-2 text | Round-3 text |
|---|---|---|---|
| 1 | Line 74 scope | tests/test_helpers.py — +62 |
+69 ✓ |
| 2 | Line 82 References | "Narrows #8 pending CodeQL re-scan (helper-function indirection may or may not satisfy the analyzer)" | "#8 and #9 also closed — the round-3 split-by-config-path log statement removes taint-flow entirely; no UI dismissal required" ✓ |
| 3 | Line 94 Automated checks | "adds 10 cases" | "adds 11 cases" ✓ |
| 4 | Line 95 Automated checks | "#8 TBD." | "expected outcome: #5, #6, #7, #8, #9 all flip Open → Fixed on merge." ✓ |
| 5 | Line 105 Step 1 expected | "all pass, including the 10 new TestSafeUrlForLog cases" |
"including the 11" ✓ |
| 6 | Line 144 Step 7 post-merge | "Alert #8 either flips Fixed (helper indirection satisfies analyzer) or remains Open…" | "Alerts #8 and #9 both flip Open → Fixed — the round-3 split-by-config-path log statement removes the taint-flow path entirely." ✓ |
Final sweep
- No "may or may not" / "TBD" / "Narrows #8" equivocation left in the PR body.
- No "10 new / 10 cases" tally drift left (
grep -E '\b10 (new|test|unit|case)'returns empty). _format_ip_header_chainreferences remaining (lines 5, 45, 126, 152) all appropriately describe round-1 history — "the round-1 helper… didn't break CodeQL's dataflow"; "removed the helper and splitting by config path resolves". Not as-if-current. These are the correct shape for a postmortem reference.- Step 6 Expected diff at line 140 now matches
git diff --numstatexactly: CHANGELOG (+3), helpers.py (+42), oauth.py (+10, -4), oauth_proxy.py (+34, -10), test_helpers.py (+69). ✓
All steps verified (carried from round 1, re-valid on e31561c3)
| Step | Result |
|---|---|
1. pytest OAuth + helpers |
131 passed ✓ |
2. Manual assertions on safe_url_for_log |
all pass ✓ |
| 3. Log semantics unchanged for normal URLs | ✓ |
| 4. Grep for unwrapped URL logs | (none — good) ✓ |
5. CHANGELOG mentions credential + redact |
✓ |
| 6. Diff stat matches expected | Now ✓ (PR body expectation corrected in round 3) |
| 7. Post-merge CodeQL re-scan | Deferred — expected outcome #5/#6/#7/#8/#9 all flip Open → Fixed |
CI rollup on e31561c3: lint, typecheck, test (3.10/3.11/3.12), CodeQL (actions+python), codecov/patch, license/cla — all SUCCESS.
Maintainer to apply QA Approved. Post-merge, expect CodeQL open-count on main to drop from 5 → 1 (the one remaining open is #4, the tests/test_integration.py socket-bind, which PR #351's notes indicated is for UI-dismissal).
|
Audit: applying Ready for QA Signoff as the final act on |
Patch release stamping six PRs merged to `main` since v0.18.1 on 2026-04-20. ## Summary Two-file diff: - `pyproject.toml` — `version` bump `0.18.1` → `0.18.2` - `CHANGELOG.md` — `[Unreleased]` renamed to `[0.18.2] - 2026-04-21`; new empty `[Unreleased]` section seeded; comparison-link footer updated ## Why patch - No new MCP tools, no changed tool signatures, no resource changes. - No breaking config, no migration, no data-format change. - `requires-python = ">=3.10"` floor unchanged in `pyproject.toml`. - Dockerfile base bump (3.12 → 3.13) is runtime-transparent to image consumers; CI matrix widening (3.13, 3.14) is pure infra. - OAuth log-redaction is security-hardening with no behavior change on the happy path. - `docker-compose` host-port parameterization is backward-compatible — default behavior unchanged. Textbook patch bump for a 0.x project. ## Included PRs | PR | Title | Kind | |---|---|---| | [#351](#351) | ci: cascade env-routing to `pr-labels.yml` + workflow permissions | Security | | [#352](#352) | fix(oauth): redact URLs in log output (CodeQL #5-#9) | Security | | [#350](#350) | ci: add `docker-smoke` workflow — build + import smoke on Dockerfile PRs | Added | | [#353](#353) | chore(compose): parameterize host port in `docker-compose.yaml` | Changed | | [#354](#354) | ci: extend Python test matrix to include 3.13 and 3.14 | Added | | [#355](#355) | chore(docker): bump base image from `python:3.12-slim` to `3.13-slim` | Changed | All six merged via their own QA-Approved cycles — nothing in this release bypasses the standard pipeline. ## What's unchanged - `docker-compose.yaml` — uses `:latest`, no version bump needed - `README.md` — tool count (32) and text-mode content unchanged; no update needed - `uv.lock` — no dep changes in any of the six PRs ## QA Lightweight per project convention — all substantive code was tested in its own PR. Review-only checks: 1. - [x] **`pyproject.toml` version** is `0.18.2`. Verify line 3: `version = "0.18.2"`. 2. - [x] **CHANGELOG** — `[0.18.2] - 2026-04-21` heading exists; the six rolled-up entries sit beneath it in their original order (Changed → Added → Changed → Security → Security → Added); empty `[Unreleased]` seeded above. 3. - [x] **Comparison links** — `[0.18.2]: …v0.18.1...v0.18.2` added; `[Unreleased]` now points at `v0.18.2...HEAD`. 4. - [ ] **Scope** — `git diff --stat origin/main` shows exactly `CHANGELOG.md` (+4, -1) and `pyproject.toml` (+1, -1). Nothing else. 5. - [x] **No accidental content drift in rolled-up entries** — diff between this branch's `[0.18.2]` section and what was in `[Unreleased]` on `main` before this PR should be zero beyond the heading/anchor move. ### Acceptance - ✅ CI green - ☐ Merge + tag (Dev authorization, executed post-merge) ## Merge + tag (Dev post-merge action) After merge, Dev runs: \`\`\` git checkout main && git pull --ff-only origin main git tag -a v0.18.2 -m "v0.18.2 — CI matrix widening (3.13/3.14), Dockerfile to python:3.13-slim, docker-smoke workflow, compose host-port parameterization, OAuth log redaction, workflow permission hardening" git push origin v0.18.2 \`\`\` The tag triggers \`docker-publish.yml\` to build and publish the \`:v0.18.2\` + \`:latest\` images. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cmeans-claude-dev[bot] <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes CodeQL alerts #5, #6, #7, #8, #9 (
py/clear-text-logging-sensitive-datainoauth.py+oauth_proxy.py).Round 3 — IP-header-chain redesign
The round-1
_format_ip_header_chainhelper (see description below in §"The fix") didn't break CodeQL's dataflow — the analyzer trackedheader_chainthrough the helper call and CodeQL #9 duplicated #8's flag on the new form. Round-3 commite31561cremoves the helper and instead splits the log by config path: the default case logs a module-level string literal (no taint flow), the override case logs only the count + env-var pointer (names never reach the sink). Both #8 and #9 close through code change — no UI dismissal.This is the code-improvement path per the user preference for a "reasonable wording change that would make CodeQL happy without sacrificing log usefulness" rather than inline suppression.
The problem
CodeQL flagged four log statements in the OAuth paths:
oauth.pydiscovery_url+ exception when OIDC discovery failsoauth.pyself.issuerwhen falling back to default JWKS pathoauth_proxy.pydiscovery_url+ exception in the proxy's discovery pathoauth_proxy.pyCodeQL's
py/clear-text-logging-sensitive-datauses dataflow from "sensitive sources" (values tracked as credential-adjacent) to log sinks. In our case, all four sites log values that are not actually secrets today —self.issueris a config URL, header names aren't credentials — but CodeQL taints conservatively because the values flow from constructor parameters in auth-adjacent contexts.The fix — defensive URL redaction, not suppression
Two new helpers:
safe_url_for_log(url)(inhelpers.py)Strips credential-carrying URL components before logging:
userinfo— RFC 3986 lets URLs embeduser:password@host. An OAuth issuer URL misconfigured that way would leak credentials through every log line that mentioned it.query string— OAuth authorization-code flow passescode,state, and PKCE verifiers here. Anything on the query string is a candidate to be secret.fragment— OAuth implicit flow (deprecated but still occasionally in the wild) puts access tokens in the fragment.Returns
scheme://host[:port]/path— preserves the operator-meaningful portion so an operator reading a log can still identify the endpoint, while removing anything that could leak a credential.Defensive rather than currently-exploitable: our issuer URLs today are plain
https://provider/...strings with no userinfo/query/fragment. The helper ensures a future misconfiguration, provider change, or redirect can't change that. Unparseable or empty inputs return"<redacted url>"— a log helper should never itself become a failure source.Applied at every URL-logging site in the OAuth paths, not just the four CodeQL flagged — consistency:
oauth.py:_discover_oidc_config— 4 log statements (discovered JWKS URI, discovered userinfo endpoint, discovery-request failure, discovery-fallback warning)oauth_proxy.py:discover_oidc_endpoints— 2 log statements (discovery failure, discovered-endpoints summary)IP-header-chain log split by config path (in
oauth_proxy.py)The round-1 approach wrapped the IP-header-chain log in a
_format_ip_header_chainhelper to try to break CodeQL's taint-flow through function indirection. That didn't work — CodeQL #9 duplicated #8's flag on the helper-call form. Round 3 takes a different approach: split the log statement by whether defaults are in use, and remove the taint-flow path entirely:ip_headers is None): log a module-level literal_DEFAULT_IP_HEADER_DISPLAY = "CF-Connecting-IP → X-Real-IP → ASGI client". Source-literal string; no contributor-controlled value flows into the sink.ip_headers): log only the count of custom entries and a pointer toAWARENESS_OAUTH_PROXY_IP_HEADERSso operators can inspect the env var directly. The names themselves never reach a log sink.Trade-off: operators running with a non-default header chain don't see the names in logs — they see the count + env-var name. Small loss of log convenience for the minority-override case; full operator clarity for the default case (which most deployments run). Closes CodeQL #8 and #9 cleanly without UI dismissal.
Testing
11 new unit tests in
tests/test_helpers.py::TestSafeUrlForLog:test_plain_https_preserved— unaltered URLs round-triptest_strips_userinfo—user:password@host→hosttest_strips_query_string—?code=secret&state=xyz→ `` (empty)test_strips_fragment—#access_token=secret→ `` (empty)test_strips_all_credential_carrying_parts_at_once— composite testtest_preserves_non_default_port—:8443survivestest_empty_string_returns_placeholder—""→"<redacted url>"test_unparseable_string_returns_placeholder—"not a url"→"<redacted url>"test_no_netloc_returns_placeholder—"/path/only"→"<redacted url>"test_path_preserved_exactly— deep paths with dots/dashes unchangedAll existing 120 OAuth tests (
tests/test_oauth.py+tests/test_oauth_proxy.py) continue to pass against the refactored code — no behavior regression, only log-output change.Scope
src/mcp_awareness/helpers.py—+42(newsafe_url_for_loghelper + docstring)src/mcp_awareness/oauth.py—+10, -4(import + 4 log-site changes)src/mcp_awareness/oauth_proxy.py—+34, -10(import +_DEFAULT_IP_HEADER_DISPLAYmodule constant + 3 log-site changes, including the split-by-config-path IP-header-chain log added in round 3)tests/test_helpers.py—+69(import + 11 new test cases inTestSafeUrlForLogclass)CHANGELOG.md—+3(new### Securityentry under[Unreleased])No migrations. No source API surface changed. No log-message semantics changed beyond the URL-component redaction.
References
QA
Prerequisites
None. Pure code changes, covered by new unit tests and existing integration tests.
Automated checks
lint/typecheck— clean locallytest (3.10/3.11/3.12)—tests/test_helpers.py::TestSafeUrlForLogadds 11 cases; existing OAuth tests continue to passCodeQL (python)— expected outcome: Increase health check start_period and timeout #5, Add awareness workflow guidance to CLAUDE.md #6, Add request timing and /health endpoint #7, Comprehensive README refresh #8, Update CHANGELOG through PR #8 #9 all flip Open → Fixed on merge.codecov/patch— new helper is covered by the 11 new test cases; should be 100% on touched linesdocker-smoke— not triggered (noDockerfile/pyproject.toml/uv.lock/.dockerignorechange)Manual tests
TestSafeUrlForLogcases.safe_url_for_logcorrectly strips each sensitive component.all manual assertions pass.https://api.workos.com/user_management/client_X), the log now reads identically to before — same host, same path. Verify by eyeballing an OAuth auth flow locally and confirmingDiscovered JWKS URI: <url>/OAuth proxy: discovered endpoints — authorize=<url>, …log lines render as before when URLs don't contain credentials.%s+ URL arg that isn't routed through the helper in the OAuth paths:(none — good). Every URL-logging site has been routed through a redactor.### Securityentry mentions credential-carrying components and redaction.CHANGELOG.md(+3),src/mcp_awareness/helpers.py(+42),src/mcp_awareness/oauth.py(+10, -4),src/mcp_awareness/oauth_proxy.py(+34, -10),tests/test_helpers.py(+69). Nothing else.Analyze (python)re-runs onmain. At https://github.com/cmeans/mcp-awareness/security/code-scanning:clear-text-logging-sensitive-datain anoauth.py/oauth_proxy.pyfile — the URL arg is now routed throughsafe_url_for_log, which CodeQL should see as a sanitizer).Acceptance
oauth.py:82discovery-request-failed log: URL nowsafe_url_for_log(discovery_url).oauth.py:91default-JWKS-path log: URL nowsafe_url_for_log(self.issuer).oauth_proxy.py:229discovery-failed log: URL nowsafe_url_for_log(discovery_url).oauth_proxy.py:330-345header-chain log: split into default (logs literal_DEFAULT_IP_HEADER_DISPLAYconstant — no taint flow) and override (logs count + env-var pointer — header names never reach the log sink)._format_ip_header_chainhelper triggered a duplicate flag; removing the helper and splitting by config path resolves both Comprehensive README refresh #8 and Update CHANGELOG through PR #8 #9.