A malformed DATABASE_URL causes the full DSN, including the password, to be written to the pod log at startup and again on every background retry. Verified end-to-end on origin/main, not in a PR branch.
Mechanism
(*url.Error).Error() renders as fmt.Sprintf("%s %q: %s", Op, URL, Err) — it embeds the URL it was given.
The subtle part: no format string in our code names the URL. migrate.NewWithSourceInstance → database.Open parses the DSN internally and returns a *url.Error already carrying it. internal/infrastructure/postgres/pool.go:138 then wraps it:
return fmt.Errorf("init migrator: %w", err)
and internal/container/container.go:948 calls postgres.Migrate(dsn), surfacing the result through slog.Warn at startup and on each retry.
So a grep for url.Parse does not find this site — the parse happens inside a dependency.
Why unwrapping is not sufficient
The obvious remedy — unwrap to drop the outer URL — still leaks. net/url causes quote the fragment they choked on, so:
postgres://user:<password>@host/db -> invalid URL escape "%zz"
where the quoted fragment is part of the password itself. This was found by mutation testing on #159: a fix that unwrapped instead of discarding survived the mutation.
The form that holds is to discard the cause entirely for *url.Error and substitute a sentinel, which is what internal/platform/llm already does under its own no-echo rule. Note safeErrSummary normalises and truncates — it is not a redactor and must not be used as one here.
Four net/url failure modes were confirmed to leak: invalid percent-escape, invalid port, unclosed IPv6 bracket, and a control character in the URL.
Scope
#159 fixed 9 test-path sites (dbtest.go, invalid_index_live_test.go, the migrate-down harness). This production path was deliberately left out: turning an approved, test-only PR into a production change was not a trade worth making mid-review.
Suggested fix
Route the Migrate error through the same discard-the-cause treatment, and add a test that renders the error for a password-bearing DSN and asserts the password is absent. Mutation-verify it: reverting the fix must make the test fail, and an unwrap-only variant must also fail — otherwise the test is not pinning the real behaviour.
Keep diagnosability: an operator must still be able to distinguish a malformed DSN from a connection failure, so replace the cause with a sentinel rather than dropping the error.
cc @dealako
A malformed
DATABASE_URLcauses the full DSN, including the password, to be written to the pod log at startup and again on every background retry. Verified end-to-end onorigin/main, not in a PR branch.Mechanism
(*url.Error).Error()renders asfmt.Sprintf("%s %q: %s", Op, URL, Err)— it embeds the URL it was given.The subtle part: no format string in our code names the URL.
migrate.NewWithSourceInstance→database.Openparses the DSN internally and returns a*url.Erroralready carrying it.internal/infrastructure/postgres/pool.go:138then wraps it:and
internal/container/container.go:948callspostgres.Migrate(dsn), surfacing the result throughslog.Warnat startup and on each retry.So a grep for
url.Parsedoes not find this site — the parse happens inside a dependency.Why unwrapping is not sufficient
The obvious remedy — unwrap to drop the outer URL — still leaks.
net/urlcauses quote the fragment they choked on, so:where the quoted fragment is part of the password itself. This was found by mutation testing on #159: a fix that unwrapped instead of discarding survived the mutation.
The form that holds is to discard the cause entirely for
*url.Errorand substitute a sentinel, which is whatinternal/platform/llmalready does under its own no-echo rule. NotesafeErrSummarynormalises and truncates — it is not a redactor and must not be used as one here.Four
net/urlfailure modes were confirmed to leak: invalid percent-escape, invalid port, unclosed IPv6 bracket, and a control character in the URL.Scope
#159 fixed 9 test-path sites (
dbtest.go,invalid_index_live_test.go, the migrate-down harness). This production path was deliberately left out: turning an approved, test-only PR into a production change was not a trade worth making mid-review.Suggested fix
Route the
Migrateerror through the same discard-the-cause treatment, and add a test that renders the error for a password-bearing DSN and asserts the password is absent. Mutation-verify it: reverting the fix must make the test fail, and an unwrap-only variant must also fail — otherwise the test is not pinning the real behaviour.Keep diagnosability: an operator must still be able to distinguish a malformed DSN from a connection failure, so replace the cause with a sentinel rather than dropping the error.
cc @dealako