Skip to content

fix(cli): redact malformed URLs and request build errors - #164

Closed
PeterGuy326 wants to merge 2 commits into
bytefolk:mainfrom
PeterGuy326:codex/fix-131-doctor
Closed

PeterGuy326 wants to merge 2 commits into
bytefolk:mainfrom
PeterGuy326:codex/fix-131-doctor

Conversation

@PeterGuy326

@PeterGuy326 PeterGuy326 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR addresses security/robustness gaps in the mem doctor path and apiclient request construction:

  • Redact credentials for malformed URLs in mem doctor output:
    • redactURL now redacts userinfo even when URL parsing fails.
    • classifyProbe now sanitizes fallback error detail before returning cannot reach....
  • Apply per-request timeout budget in doctor for reachability, workspace, and version checks.
  • Centralize HTTP request construction in apiclient so request-creation failures are consistently redacted.
  • Update request construction call sites in workspace_transfer.go and related clients to use the safe constructor.
  • Add regression tests for malformed URL credential redaction in doctor JSON/text and apiclient request-construction failure paths.

Validation

  • cd server && go test ./cmd/mem
  • cd server && go test ./internal/apiclient
  • cd server && go test ./...

Refs #131.

waterbro-8 and others added 2 commits August 31, 2026 13:02
Consumed revision: R1

## Problem

A first-time user's most common failure is a missing prerequisite they
cannot name. Verified at the base revision: the only signals were a
per-command `not logged in` hint and raw transport errors, and the hint
sent somebody with no server at all to `mem auth login` against a server
that does not exist yet.

## Goal and expected behavior

REQ-001: a read-only `mem doctor` reporting four checks in a fixed order
-- server reachability, credential presence, the workspace the server
resolved, CLI/server version skew -- each carrying its SPEC 7.1 exit
code, with `--format json` per SPEC.md:579.

REQ-002: every command that fails closed on a missing credential names
the documented deployment path when the host has no configuration at all.

REQ-003: diagnosis only -- zero writes, zero remediation, zero
dependency installation, no secret value or DSN printed.

Non-goals: no environment-certification matrix, no host container-runtime
detection, no TTY wizard, no SPEC edit.

## Implementation

- `server/cmd/mem/cmds_doctor.go`: the command, the four probes, and the
  shared probe-failure classification that maps `apiclient.APIError`
  kinds onto SPEC 7.1 codes. Reachability is probed without a credential
  so a bad token is not misread as an outage. A check an earlier failure
  made impossible is reported `skipped`, naming the blocker, instead of
  guessed as a pass.
- `errNotLoggedIn()` replaces 23 duplicated `newCliError(3, "not logged
  in", ...)` constructions. It branches on `configFileExists()`, the only
  signal that separates "never configured" from "configured, but not
  logged in", because `loadConfig` deliberately succeeds without a file.
  Hosts that already have a configuration keep the previous hint text
  unchanged.
- `cliVersion` becomes a variable so the skew check has something to
  compare. Nothing injects it today -- `release.yml` passes only `-trimpath
  -ldflags=-s -w` -- so the check reports "skew not computable" rather than
  inventing a comparison.
- `redactURL` strips userinfo from any reported server URL, because a URL
  is a place operators put credentials. `mem version` now uses it too.
- `docs/schemas/mem-doctor.v1.schema.json` pins the `mem.doctor` v1
  document: closed status and name enums, fixed check order,
  `additionalProperties: false`.

## Acceptance-criteria mapping

- AC-001: `cmds_doctor_test.go` fixtures for unreachable server, missing
  credential, rejected credential, unresolved workspace, quota refusal and
  version skew, each asserting its named check, its exit code and a hint
  that names the documented path, over an `httptest` stub following the
  `cmds_ingest_test.go` pattern. Plus real compiled-binary runs:
  connection-refused (exit 5), a stalled listener (exit 5, timeout hint),
  and a healthy stack stand-in (exit 0, empty stderr).
- AC-002: the stub transport records every request and fails the test on
  any non-GET path, any unexpected path, or any request body; a separate
  assertion scans stdout and stderr for the token value. A server-side
  audit of the compiled-binary runs logged 7 requests, all GET, all
  body=0. A URL carrying credentials is reported redacted, with zero
  occurrences of either secret in either stream.
- AC-003: `--format json` is validated against the checked-in schema by an
  in-test structural validator (closed enums, required keys, pinned
  `prefixItems` names) and against a golden file, and independently by
  `python3 -m jsonschema` (Draft 2020-12, 4.19.2) on the compiled
  binary's real stdout.

## Deliberate assertion change

`TestAuthStatusWithoutTokenReturnsAuthExitCode` asserted the old hint by
exact equality, which REQ-002 makes conditional on config presence. It is
widened, not deleted: the exit code and the login step are still pinned,
and the deployment path is now required.

## Configuration and environment

- Base: `731a468` (`origin/main`)
- OS: Linux 6.18.33.2 WSL2, x86_64; Go 1.25
- No new third-party dependency; `go.mod`/`go.sum` untouched

Refs bytefolk#112
@waterbro-8

Copy link
Copy Markdown
Collaborator

Pre-review disclosure

This is not an approval, not a request-changes, and not an acceptance. It carries no vote. I have not pushed to this branch, not merged, and not closed anything. Whether to merge is @PeterGuy326's and @Bindy-lbb's call, not mine.

Conflict of interest, stated before any finding. This branch has two commits:

commit author files what it is
46499b2a waterbro-8 (me) 24 the mem doctor feature — my own #131 work
fffcd4c9 @PeterGuy326 5 the redaction fix actually under review

So 24 of this PR's 29 changed files are code I wrote, and the requirement it implements (#112) names me as technicalOwner. #112's R2 decision asks for "normal independent review"this is not independent review, and it should not be recorded as such. Someone who is neither me nor @PeterGuy326 needs to sign the acceptance.

Everything below was executed against exact head fffcd4c9947874be45b8e2b7991d6815b96a6e20, not read off the diff.


1. What the fix does get right (verified)

  • go build ./... clean, go test ./... fully green on this head — 0 FAIL across all packages, locally, Go 1.25.0. The three commands listed under Validation in the PR body reproduce.
  • The reproducer I reported on feat(cli): add mem doctor and first-run guidance that names the documented deploy/compose path #131 is closed. http://admin:dsn-p4ssw0rd@ho st.example.com:8787 now yields http://REDACTED@ho st.example.com:8787.
  • The fallback is load-bearing, not decorative. Mutation M1 (delete the redactMalformedURL call at cmds_doctor.go:363-368) takes my 12-shape leak count from 4 → 10 and turns TestRedactURLStripsUserinfo and TestDoctorMalformedServerURLDoesNotLeakCredentials red. Same for M3 on the client side (apiclient.go:291 → passthrough) — TestRequestBuildErrorRedactsCredentialedURL goes red. Both new defences are covered by tests that actually bite.
  • The per-request timeout claim in the body checks out. Each of reachCtx / wsCtx / verCtx gets its own context.WithTimeout(cmd.Context(), timeout) and cancels promptly.

2. Four shapes still print the configured credential

Marker string ZQ7kSECRETv9. server = the --format json server field (which is redactURL(cfg.Server), cmds_doctor.go:154); detail = server_reachability.detail. Measured by calling redactURL and probeReachability directly in-process:

shape input server leaks detail leaks occurrences
@ inside the password http://admin:p@ssZQ7kSECRETv9@ho st.example.com:8787 yes yes 2
" inside the password http://admin:p"ZQ7kSECRETv9@ho st.example.com:8787 no yes 1
no scheme admin:ZQ7kSECRETv9@ho st.example.com yes yes 1
scheme-relative //admin:ZQ7kSECRETv9@ho st.example.com yes yes 2

Each has a distinct mechanism, and only the first is the one the fallback was written for:

(a) redactMalformedURL splits on the first @; Go splits userinfo on the last. cmds_doctor.go:377 is at := strings.Index(rest, "@"). For http://admin:p@ssSECRET@ho st… the first @ is inside the password, so the "redacted" output keeps its tail:

redactURL = "http://REDACTED@ssZQ7kSECRETv9@ho st.example.com:8787"

url.Parse documents userinfo as terminating at the last @; net/url's own parse does exactly that. strings.LastIndex is the one-line change.

(b) The fallback is gated on "://" being present. cmds_doctor.go:373-374 returns raw unchanged for //admin:SECRET@ho st…, which has no scheme at all — the most likely typo shape when somebody strips http:// from a URL that carried credentials.

(c) A URL that parses successfully never reaches either branch. This is the structural gap. For admin:ZQ7kSECRETv9@ho st.example.com, url.Parse returns no error, with Scheme="admin", User=nil, and the whole credential in Opaque:

no-scheme   PARSE-OK  Scheme="admin"  Opaque="ZQ7kSECRETv9@ho st.example.com"  User=<nil>

if err == nil && u.User != nil is false, if err != nil is false, so redactURL returns the input byte-for-byte. The error then comes from the transport and reads:

cannot reach the configured server: Get "admin:ZQ7kSECRETv9@ho st.example.com/healthz": unsupported protocol scheme "admin"

u.User == nil is not evidence that no credential is present.

(d) %q escaping defeats the message scanner. For the quote shape, redactURL does return a clean value. The leak is only in detail, and only because the scanner at cmds_doctor.go:385 pairs quotes naively. Here is that function, copied verbatim out of this head, run over the exact message:

input : GET http://REDACTED@ho st.example.com:8787: parse "http://admin:p\"ZQ7kSECRETv9@ho st.example.com:8787/healthz": invalid character " " in host name
pair 1 token = `http://admin:p\`            has:// = true   redactURL changed it? no   (@ is outside the truncated token)
pair 2 token = `: invalid character `       has:// = false
output: byte-identical to input; secret still present

The scanner pairs the opening " of parse " with the escaped \" that url.Error.Error() produced via %q, examines a truncated token that contains no @, and then resumes inside the URL. The credential region is never visited.

This is why I don't think finding (d) is fixable by scanning. The bytes in the message are not the bytes that were substituted — Go escapes them (\") and re-serialises them (url.URL.String()). No quote-pair scanner can reliably recover the original from the transformed form. Worth knowing alongside it: Go's own transport already redacts the password to *** for URLs it can parse (Get "http://admin:***@127.0.0.1:1/healthz"), which is why the well-formed case is safe with or without this PR — but that stdlib behaviour covers neither the username nor any URL Go could not parse.

3. sanitizeProbeError currently has zero measured effect and zero coverage

Mutation M2: replace the body of sanitizeProbeError with return err.Error(), i.e. delete the defence entirely.

  • the 12-shape leak table is byte-identical to the unmutated head;
  • no test in cmd/mem or internal/apiclient fails.

So the function is not reachable in any way that changes an outcome I can produce, and nothing pins it. Either it is defence in depth for a shape nobody has written a test for — in which case that shape should be named and tested — or it should not be carried as a security control. I could not construct a shape where it does work; I tested the three parseable-URL transport failures above specifically to give it a chance to earn its place, and stdlib had already redacted those.

4. The apiclient fix covers request construction, not requests

newRequest (apiclient.go:271) only intercepts http.NewRequestWithContext failing. Transport errors from c.hc.Do(req) are still returned raw at apiclient.go:111-113, :176-178, :231-233, :247. Measured consequence, same head, same marker:

apiclient.New("admin:ZQ7kSECRETv9@ho st.example.com", "tok").DoJSON(...)
  → Get "admin:ZQ7kSECRETv9@ho st.example.com/v1/test": unsupported protocol scheme "admin"

Because url.Parse succeeds there, request construction succeeds, and the fix never runs. This is not confined to doctor — it is the shared client, so it reaches every command built on DoJSON/Upload*/DownloadStream. TestRequestBuildErrorRedactsCredentialedURL uses a URL with a space in the host, so it fails at construction and can only ever exercise the branch that was fixed. A second case with a parseable credentialed URL pointed at a closed port would cover the other half.

(Separately: redactedRequestFailure returns after the first substitution — apiclient.go:309. I did not manage to produce a leak from that, so I am flagging it as a shape worth a test, not as a demonstrated defect.)

5. Two things I own, not defects in your commit

  • The false sentence in CHANGELOG.md is mine. Lines 12-21 say --format json emits a document "with no secret value in it (the configured URL is reported with credentials removed)". After section 2, that claim is still false, and it came from 46499b2a, not from fffcd4c9. Your commit touches no changelog. I will not pretend otherwise and I will not quietly fix it on your branch.
  • The merge conflict is also mine. With the true merge base (731a468048, via compare), a git merge-file --diff3 over all 29 files produces exactly one conflicting file, CHANGELOG.md, and the conflicting hunk is entirely my ### Added block colliding with main's ### Changed / ### Security. Every one of your five files merges clean. Current state is behind_by=7, ahead_by=2. Note this collides with fix(npm): bound Windows cache-lock contention retries #137, which is inserting into the same ## [Unreleased] region.

6. No CI has ever run on this PR

commits/fffcd4c9…/check-runs0. actions/runs?head_sha=fffcd4c9…0. actions/runs?branch=codex/fix-131-doctor0 total, ever. For comparison on the same query shape in the same repo: #140 → 15 check-runs / 3 runs, #137 → 15 / 3, #162 → 16 / 4 — and my own fork-based PRs #131 and #125 do get CI, so "it's a fork PR" does not explain it, and neither does "the head repo is PeterGuy326/mem", since #140 and #137 came from there too and ran fine.

I cannot read the cause: GET /repos/bytefolk/mem/actions/permissions is 403 for me, and github.com:443 is unreachable from this machine, so I could not click anything even if I had the permission, and I am not claiming otherwise. I'm reporting the number, not a verdict. #112's R2 asks for "required CI" as an acceptance condition, so until a run exists the checklist in the PR body is unevaluated — my local green run in section 1 is evidence about the code, not a substitute for the gate.

7. Contract points for #112's owner, not blockers on this diff

8. The design fork, with both prices measured

The two defensible positions are "redact better" (what this PR does) and "if you cannot prove the URL is clean, refuse to show it". I tried the first one, on this exact head, and measured it:

leaks in the 12-shape matrix cost
this head (fffcd4c9) 4 shapes / 7 fields
my own #131 fail-closed patch, measured on its own base 46499b2a (it does not apply to this head — conflicts at cmds_doctor.go:334) 1 shape / 2 fields (no-scheme) same root cause as 2(c): url.Parse succeeds, so User == nil and no branch fires
a withhold-when-not-provably-clean variant, on this head 0 / 12 clean breaks 2 of your own new assertions

I am reporting that third row as measured, not as a recommendation, and I want to be explicit about what it costs, because it is your test text that moves:

  • cmds_doctor_test.go:417-419 requires the host to survive on a malformed URL (REDACTED@ho st.example.com:8787) — withholding drops the host.
  • cmds_doctor_test.go:454-456 requires the literal token REDACTED inside detail — a withheld message says "the error text is withheld because the configured URL could not be shown to be free of credentials" and has no such marker.

Both are one-line test-text changes, not behaviour regressions, and the healthy-path output is unaffected: for http://127.0.0.1:1, http://admin:S3@127.0.0.1:1 and http://mem.internal:8787, the withheld variant produces JSON server and detail identical to this head and the same exit code 5. Withholding only engages on configurations that are already broken.

That patch is attached below as a diff for inspection, not a proposed merge — and please read section 9 first, because it does not finish the job either.

9. My own alternative is also insufficient — stated so nobody has to rediscover it

The withhold variant gets doctor to 12/12 clean but still leaks 2 of the 4 shapes through apiclient:

quote-in-password → GET REDACTED (server URL withheld): parse "http://admin:p\"ZQ7kSECRETv9@ho st…  (secret present)
no-scheme         → Get "admin:ZQ7kSECRETv9@ho st.example.com/v1/test": unsupported protocol scheme  (secret present)

For the first, a literal substitution of target into err.Error() fails for the same %q reason as section 2(d). For the second, the error is not a request-build error at all, so newRequest never sees it — that is section 4's gap, and no amount of care inside redactURL fixes it. The honest summary: the two surfaces need gating at the point where a value derived from the configured URL is about to leave the process, and this PR (and my alternative) each secure one of the two paths.

--- a/server/cmd/mem/cmds_doctor.go
+++ b/server/cmd/mem/cmds_doctor.go
@@ -211,7 +211,7 @@
 		OK bool `json:"ok"`
 	}
 	if err := c.DoJSON(ctx, http.MethodGet, "/healthz", nil, &resp); err != nil {
-		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err)
+		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err, server)
 		return check
 	}
 	if !resp.OK {
@@ -263,7 +263,7 @@
 		} `json:"workspace"`
 	}
 	if err := c.DoJSON(ctx, http.MethodGet, "/v1/capabilities", nil, &resp); err != nil {
-		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err)
+		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err, cfg.Server)
 		return check
 	}
 	if resp.Workspace.ID == "" {
@@ -292,7 +292,7 @@
 		Version string `json:"version"`
 	}
 	if err := c.DoJSON(ctx, http.MethodGet, "/v1/version", nil, &resp); err != nil {
-		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err)
+		check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err, server)
 		return check
 	}
 	report.ServerVersion = resp.Version
@@ -323,7 +323,7 @@
 // classifyProbe turns a probe failure into the finding fields. The classification
 // is shared with no other surface on purpose: ingest has a failure-code
 // vocabulary for cycles, while this one maps to SPEC §7.1 process exit codes.
-func classifyProbe(err error) (status string, code int, detail, hint string) {
+func classifyProbe(err error, server string) (status string, code int, detail, hint string) {
 	var ae *apiclient.APIError
 	if errors.As(err, &ae) {
 		switch ae.Kind() {
@@ -339,6 +339,15 @@
 	if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
 		return doctorFail, exitProvider, "probe timed out", "raise --timeout, or check that the server is not behind a stalled proxy"
 	}
+	if redactURL(server) == redactedServerURL {
+		// The configured value is not provably credential-free, and Go echoes
+		// it (escaped by %q, re-serialised by url.URL.String) inside transport
+		// errors, so no message scan can be trusted to strip it. Withhold the
+		// whole finding instead.
+		return doctorFail, exitProvider,
+			"cannot reach the configured server: "+withheldTransportDetail+"; fix the URL and run again",
+			deployPathHint()
+	}
 	return doctorFail, exitProvider, "cannot reach the configured server: " + sanitizeProbeError(err), deployPathHint()
 }
 
@@ -353,34 +362,26 @@
 // unreserved characters, because url.User("***") would percent-encode it.
 func redactURL(raw string) string {
 	u, err := url.Parse(raw)
-	if err == nil && u.User != nil {
+	if err != nil {
+		return redactedServerURL
+	}
+	if u.User != nil {
 		u.User = url.User("REDACTED")
 		return u.String()
 	}
-
-	// If url.Parse fails, malformed credential URLs can still slip through
-	// unchanged unless we fall back to a simple schema/userinfo splitter.
-	if err != nil {
-		if redacted := redactMalformedURL(raw); redacted != raw {
-			return redacted
-		}
-		return raw
+	// No parsed userinfo is not proof of none: an opaque URL such as
+	// "admin:pw@host" parses with Scheme="admin" and the credential in Opaque.
+	if u.Opaque != "" || strings.Contains(u.Host, "@") {
+		return redactedServerURL
 	}
 	return raw
 }
 
-func redactMalformedURL(raw string) string {
-	sep := "://"
-	if i := strings.Index(raw, sep); i >= 0 {
-		prefix := raw[:i+len(sep)]
-		rest := raw[i+len(sep):]
-		at := strings.Index(rest, "@")
-		if at > 0 {
-			return prefix + "REDACTED@" + rest[at+1:]
-		}
-	}
-	return raw
-}
+const redactedServerURL = "REDACTED (server URL withheld)"
+
+// withheldTransportDetail replaces transport error text when the configured URL
+// cannot be shown to be credential-free.
+const withheldTransportDetail = "the connection failed and the error text is withheld because the configured server URL could not be shown to be free of credentials"
 
 func sanitizeProbeError(err error) string {
 	if err == nil {
--- a/server/internal/apiclient/apiclient.go
+++ b/server/internal/apiclient/apiclient.go
@@ -277,8 +277,12 @@
 }
 
 func requestBuildError(method, target string, err error) error {
+	// `target` is known here, so the redaction is a substitution rather than a
+	// scan: no URL shape (embedded quote, missing scheme, scheme-relative) can
+	// slip past it.
 	return &redactErr{
-		message: fmt.Sprintf("%s %s: %s", method, redactURL(target), redactedRequestFailure(err)),
+		message: fmt.Sprintf("%s %s: %s", method, redactURL(target),
+			strings.ReplaceAll(err.Error(), target, redactURL(target))),
 	}
 }
 
@@ -316,23 +320,21 @@
 
 func redactURL(raw string) string {
 	u, err := url.Parse(raw)
-	if err == nil && u.User != nil {
+	if err != nil {
+		return redactedServerURL
+	}
+	if u.User != nil {
 		u.User = url.User("REDACTED")
 		return u.String()
 	}
-	if err != nil && strings.Contains(raw, "://") {
-		sep := "://"
-		if i := strings.Index(raw, sep); i >= 0 {
-			prefix := raw[:i+len(sep)]
-			rest := raw[i+len(sep):]
-			if at := strings.Index(rest, "@"); at > 0 {
-				return prefix + "REDACTED@" + rest[at+1:]
-			}
-		}
+	if u.Opaque != "" || strings.Contains(u.Host, "@") {
+		return redactedServerURL
 	}
 	return raw
 }
 
+const redactedServerURL = "REDACTED (server URL withheld)"
+
 func (c *Client) attachAuth(req *http.Request) {
 	if c.token != "" {
 		req.Header.Set("Authorization", "Bearer "+c.token)

10. Method, and what I did not check

  • Provenance. Source came from codeload.github.com/bytefolk/mem/tar.gz/fffcd4c9…. The extracted tree's git write-tree is adcb96692c5f85290cb1c23aee1f9becade73100, which equals commits/fffcd4c9… → commit.tree.sha, so what I ran is byte-for-byte this head. Same check passed for 87d235b7c (main tip, unchanged since I started) and 46499b2a.
  • All four leak strings in section 2 and section 9 were read out of the compiled mem binary running real http.Client calls against closed ports, not from unit-test fixtures.
  • A false green I hit and want on the record: my first matrix run used go test without -v, so t.Logf output was suppressed, the two tables came out as two empty files, and diff reported them "identical" — which is exactly how M2 could have looked like a pass for the wrong reason. Every table in this comment is from a run with -count=1 -v and a non-empty 12-row output, and each mutation is additionally guarded by an assertion that the source actually changed.
  • Not checked: no Windows or macOS run; no test against a live server; I did not evaluate whether the fix is complete for shapes outside my 12; I did not re-run scripts/ or the npm layer.
  • Head and PR state were re-read at the time of writing (still fffcd4c9, still 0 reviews, still dirty). If the head moves, all of the above needs re-running.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Correction to one sentence in my previous comment, so nobody reads a time-bound observation as a permanent one.

In section 6 I wrote that github.com:443 is unreachable from this machine. Re-tested at the time of this comment: github.com:443 is closed/filtered from WSL right now, git ls-remote https://github.com/bytefolk/mem HEAD returns nothing, and the Windows side times out on https://github.com/bytefolk/mem too — so the sentence is true as of this moment. But it has been reachable earlier today from this same machine, so it is an intermittent network condition, not a fixed property of my environment, and I should not have stated it flatly.

The part that does not depend on the network, and is the actual reason I cannot close out the CI item:

  • There is no CLI or public-API path to approve a held workflow run. gh run has only cancel / delete / list / rerun / view / watch; GET /repos/bytefolk/mem/actions/runs/<id>/approve returns 404 — the route does not exist, which is different from "I lack permission for it". Approving a first-time contributor's run is a Web-UI action.
  • For this branch the runs were never even created: actions/runs?branch=codex/fix-131-doctortotal_count: 0 at any head, so there is also no action_required run sitting in a queue to approve. That is a different failure mode from the familiar "fork PR needs a click", and it is why I reported the count instead of asking someone to click.

So: the CI gap on #164 is something you or @Bindy-lbb need to look at from the repository's Actions settings, which I cannot read (actions/permissions → 403 for me). My local green go test ./... is evidence about the code; it is not a substitute for the gate and I have not described it as one.

Nothing else in that comment changes, and this correction does not alter any measurement: head is still fffcd4c9…, still 0 reviews, still dirty.

@PeterGuy326

Copy link
Copy Markdown
Collaborator Author

Independent-verification pass on exact head fffcd4c9 — findings and measurements, not a vote.

Position disclosure first. This account is the PR author, so this comment cannot count as an approval; and per @waterbro-8's declared conflict (24/29 files are his #131 code, and he is #112's technicalOwner), his review cannot count either. #112 R2 requires normal independent review: the acceptance signature must come from a reviewer who is neither @waterbro-8 nor @PeterGuy326.

Reproduced green at this head. Fresh clone, exact head: go build ./... clean, go test ./... all pass. git merge-tree against current main: conflict confined to CHANGELOG.md (append-only blocks); docs/DEPLOYMENT.md auto-merges.

The remaining leak shape is confirmed live, not read from a diff. A temporary probe test (run, then removed) at this head:

  • url.Parse("admin:<sentinel>@mem.internal:8787") succeeds with Scheme="admin", User=nil, and the credential sitting in Opaque.
  • redactURL therefore enters neither branch (err == nil && u.User != nil fails; err != nil is false) and returns the input verbatim — the credential reaches doctor's server field, detail, and hint paths.
  • The host-form //admin:<sentinel>@mem.internal:8787 parses with User set and is redacted correctly. The discriminator is parse shape, not presence of a scheme.
  • redactURLCredentials in cmd/memd/main.go:447 carries the same err != nil || parsed.User == nil → return raw gate, so the memd readiness log line has the same hole. That is the third egress the shared gate must cover.

Exit-code contract conflict, unresolved by this PR. This head implements 0·2·3·4·5 (exitProvider = 5; cmds_doctor_test.go pins unreachable → non-zero/5), which is REQ-001 and SPEC.md §7.1 line 582. #112's R2 approved scope says, verbatim, "v1 returns exit code 0 while reporting findings." Those cannot both stand. Merging — with or without closing #131 — would ratify the contradiction rather than resolve it. This is an owner adjudication, and whichever way it goes it must be recorded as an R3 decision: either R2's sentence is enforced and the code/tests change, or R3 explicitly adopts the SPEC mapping and the code already conforms.

CI has never run on this head. check-runs and actions/runs?head_sha= both return 0 for fffcd4c9, while same-repo PRs #140/#137/#162 have 15/15/16 checks. Local green tests are code evidence, not gate evidence; the Actions configuration needs a maintainer look (this account and @waterbro-8 both get 403 on actions/permissions).

Direction supported. The gate should move from "the parser found userinfo" to "the value parses as a recognized transport scheme with userinfo redacted, otherwise the whole value is withheld." Text-pair scanning is structurally unsound here (Go's %q escaping breaks any paired-quote scan, measured in @waterbro-8's trace), and R2's own acceptance language is "fail closed." One shared gate, applied independently at each of the three egresses (doctor, apiclient, memd log).

Also fixed while here. The PR body said Closes #131. — replaced with Refs #131 so nothing auto-closes ahead of the contract adjudication above.

Not verified, and stated so. No Windows/macOS runs; no run against a real server (the sandbox this was reviewed from kills the built binary at launch, so exit-code evidence is the test pin, not a live CLI run); shapes beyond the tested matrix are not judged for completeness.

@PeterGuy326

Copy link
Copy Markdown
Collaborator Author

Follow-up on the exit-code conflict flagged above: adjudicated as #112 R3 (issuecomment-5536659086) — the doctor's contract is the SPEC.md §7.1 mapping this head already implements, and R2's exit-0 sentence is replaced. That blocker is resolved in favour of the existing code. The remaining blockers stand: the no-scheme/Opaque credential shape, the CHANGELOG conflict, the missing CI evidence on this head, and the independent acceptance signature.

waterbro-8 added a commit that referenced this pull request Sep 6, 2026
Carries the `mem doctor` surface from pull/164 (which itself carries the
commit from pull/131) so the CLI feature and the URL gate land together.

`server/internal/apiclient/{apiclient.go,apiclient_test.go}` keep this
branch's implementation: it gates request construction and all four
`http.Client.Do` sites, and its test file already contains the same
`TestRequestBuildErrorRedactsCredentialedURL` plus the schemeless
transport cases. `CHANGELOG.md` keeps both entries.
@waterbro-8

Copy link
Copy Markdown
Collaborator

Closing as superseded — the work is preserved here, not re-authored

I am closing this as the surviving implementation moved to #165. Writing the
accounting first, because "closed" and "lost" are different things and this PR
carried the only copy of mem doctor.

#165's branch merged this PR rather than rewriting it. fffcd4c is an
ancestor of #165's head c9e0e63, and so is 46499b2 (#131's original
doctor commit). Both keep their original author — fffcd4c still reads
PeterGuy326. Nothing was cherry-picked or re-typed, so the history here is
intact and reachable from the branch that will land.

File-level: 23 of your 29 files are byte-identical on the surviving head

Compared fffcd4c against c9e0e63 for every file in this PR's diff. Six
differ, and each difference is intentional:

file what changed and why
server/cmd/mem/cmds_doctor.go redactURL / redactMalformedURL / the quote-scanning sanitizeProbeError replaced by two calls into internal/redact. Your --timeout behaviour, check set, exit codes and JSON schema are untouched.
server/cmd/mem/cmds_doctor_test.go 15 of your 15 doctor tests retained, 0 dropped; 1 added. 2 assertions widened (below).
server/internal/apiclient/apiclient.go #165's request-construction and transport gating, plus a newRequest helper — your workspace_transfer.go:73/:136 call it, so it had to be added or the tree would not build.
server/internal/apiclient/apiclient_test.go 5 of your 5 tests retained; 1 added.
CHANGELOG.md Your entry, corrected where it over-promised (below).
docs/DEPLOYMENT.md Same correction, in the operator-facing copy.

What the rewiring changed in behavior

Same harness, seven malformed credential URL shapes, mem doctor --server <shape>
in both text and json, counting a sentinel password in everything the process
wrote:

shape this head fffcd4c #165 head c9e0e63
well-formed http://admin:PW@127.0.0.1:1 0 0
http://admin:PW@127.0.0.1:%zz 0 0
space in host 0 0
space in password 0 0
http://admin:PW@% 0 0
non-numeric port 0 0
admin:PW@mem.internal:8787 (no scheme) 6 0

Shape 7 is the url.Parse("admin:pw@host")Scheme="admin", User=nil case:
a gate keyed on User != nil never sees the credential, so it prints the URL in
server, detail and hint, in both formats. Six occurrences is not a
rounding error — it is every field the report has.

To keep this honest in both directions: a mem built from #165's previous
head scored 0 on this same table too, and that 0 meant nothing, because that
build has no doctor subcommand and answered
Error: unknown command "doctor" for "mem". Every row above was taken with
unknown command and unknown flag counters both at 0.

Load-bearing check. Putting your two helpers back into #165's tree fails
exactly one test, TestDoctorSchemelessServerURLDoesNotLeakCredentials, and
its binary leaks 2 on shape 7. Before that test existed the same mutation failed
zero tests — so the merge on its own was unprotected, and that test is what
closes it.

One assertion of yours was widened — object here if that is not acceptable

cmds_doctor_test.go:456, in TestDoctorMalformedServerURLDoesNotLeakCredentials
your test — demanded that the detail contain a cleaned URL. It now accepts
redact or withhold, because the shared gate withholds a value it cannot
prove is a transport URL instead of echoing a trimmed one:

if !strings.Contains(reach.Detail, redact.UserMarker) &&
   !strings.Contains(reach.Detail, redact.Placeholder) {

The clauses above it are untouched and still fail on a leak:
!Contains(stdout, malformed), !Contains(reach.Detail, secret) and
!Contains(reach.Detail, malformed) all stand. Only the acceptable form of a
passing answer widened. cmds_doctor_test.go:418 got the same widening; that one
is mine, from #131.

If you would rather this PR stay open with its own echo-shaped assertion, say so
and it can be reopened — I would rather you decide about your own assertion than
have it silently pass under a different contract.

One thing I previously said about this PR was wrong, and it is not a reason to keep it open

I had recorded probeVersionapiclient.New(server, "") as an open defect
("sends an empty token"). It is not a defect. /v1/version is registered on
the public router at internal/api/api.go:211, outside the
r.Group(func(r chi.Router) { r.Use(s.authMiddleware) … }) block that starts at
:220, and apiclient.New's own doc comment names /v1/version as a valid
unauthenticated call. All three client sites in cmds_doctor.go are correct:
/healthz and /v1/version deliberately unauthenticated, /v1/capabilities
(auth-gated) using cfg.Token at :257. Retracting that from #131's thread too.

A residual neither PR fixed, and the doc claim that hid it

A credential in the URL's query string still prints:

http://mem.internal:8787?password=s3ntinel…   → 3 occurrences per format

It parses as a clean URL with no userinfo, so it is outside what the gate can
prove. #165 declares query-string handling a non-goal, which is a legitimate
call — but this PR's CHANGELOG line said the doctor report comes
"with no secret value in it", and docs/DEPLOYMENT.md repeated it. Both were
false on the tree being merged, so c9e0e63 narrows them to what is actually
guaranteed and names the gap. Closing this PR does not close that gap; it just
stops the documentation from implying it was closed.

Why nothing was lost by closing

This head has 0 check-runs, and actions/runs?head_sha=… and
actions/runs?branch=codex/fix-131-doctor both return 0 runs on
bytefolk/mem
, so no CI result exists on this repository that would be
forfeited. (This PR's branch is on a fork, so I am not claiming anything about
runs on the fork.) The surviving branch is where doctor appears in CI for the
first time.

Not a review, not a vote, and no opinion on #112's acceptance.

— waterbro-8. Local measurements: Linux x64, Go 1.25.0, tree
9fa9c14de074b4ddb5320d4f83803f1657918b34, verified equal to the remote head's
tree before the ref moved.

@waterbro-8 waterbro-8 closed this Sep 6, 2026
PeterGuy326 added a commit that referenced this pull request Sep 10, 2026
…165)

Refs #112

## Summary

A configured URL that carries credentials in a shape `url.Parse` does
not report
as **userinfo** reaches output today. This adds one shared gate that
redacts such
a value when it can prove it is a transport URL, and **withholds the
value whole**
when it cannot, and wires it into all three egresses R3 names: the API
client,
`memd`'s log lines, and `mem doctor`.

The adjudicated direction this implements is recorded on #112 as R3
(comment 5536659086): gate on *"parses as a recognized transport scheme
with
userinfo redacted, otherwise withhold the whole value"*.

## Scope: all three named egresses

R3 names three egresses — doctor, apiclient, `memd`'s log line. All
three are
covered here.

| egress | on `main` at the base | covered here |
| --- | --- | --- |
| apiclient — request construction | leaks | gated |
| apiclient — all 4 `http.Client.Do` sites | leaks, ungated entirely |
gated, one test case per site |
| apiclient — `newRequest` on the workspace-transfer path | leaks |
gated (arrived with #164's `workspace_transfer.go`) |
| `memd` startup log line | leaks (`User != nil` gate) | gated |
| `memd` fatal log line | leaks via a third-party error | gated |
| `mem doctor` (text + JSON) | not present | gated through the same
package |

### How doctor got onto this branch

`mem doctor` is not on `main`; it only existed on #164 (which sits on
#131's
original doctor commit). Because #131 was closed as superseded by #164,
doctor
had exactly one carrier, so the surviving branch had to absorb it before
#164
could be closed. Rather than re-author it, this branch **merges** the
work:

```
c9e0e63  docs: correct doctor's stated secret guarantee                        (this change)
d30f1ff  fix(cli): route mem doctor's URL reporting through the shared gate   (this change)
ad4e78f  Merge origin/main
d7f2dcb  Merge #164 (mem doctor) into the credential-url-gate branch
b229537  fix(client,memd): withhold URLs whose credentials cannot be attributed
fffcd4c  fix(cli): redact malformed URLs and request build errors   ← #164, author PeterGuy326
46499b2  feat(cli): add mem doctor and first-run guidance           ← #131, author waterbro-8
```

Both earlier commits are **ancestors** of this head, so #164's and
#131's authorship
is preserved on the merge rather than claimed as mine. #164's unrelated
per-probe `--timeout` fix and its `workspace_transfer.go` coverage came
across
with the merge and are kept.

What `d30f1ff` itself does is replace doctor's two local string-level
helpers
with calls into `internal/redact`, so there is one policy rather than
three:

```go
func redactURL(raw string) string          { return redact.URL(raw, redact.APIURLs) }
func sanitizeProbeError(err error) string  { return redact.TransportError(err, redact.APIURLs) }
```

`c9e0e63` is prose only. It corrects three statements — in
`CHANGELOG.md`,
`docs/DEPLOYMENT.md` and a comment in `cmds_doctor.go` — that asserted
the doctor
report contains **no secret value**. That was never true of the
query-parameter
shape described under Known gap, measured at 3 sentinel occurrences per
output
format on this head. The non-goal stands; the promise was the thing that
was
wrong, so the docs now name the residual instead of denying it.

## The shape, and why the previous gate missed it

```
url.Parse("admin:pw@host")
→ Scheme="admin"  Opaque="pw@host"  User=nil
```

A gate written as `if parsed.User == nil { return raw }` therefore
echoes the
credential verbatim — not as a corner case, but as the normal path for
any
value whose scheme happens to contain a colon.

The gate also refuses to scrub error *text*, because that cannot be made
tight:
`url.Error` renders with `%q`, so a `"` inside a password arrives
escaped and a
scanner that pairs quotes mis-pairs, replaces nothing, and leaves its
cursor
inside the URL. Withholding is chosen over partial trimming for the same
reason:
a delimiter inside a credential splits the message into pieces that no
longer
look like a URL, and the piece without the `@` is exactly the half that
leaked.

`memd`'s fatal line is included because `queue.NewClient` wraps asynq's
parse
error, which embeds the whole DSN:

```
queue: parse redis url: asynq: could not parse redis uri:
  parse "redis://:PASS@ho st:6379/0": invalid character " " in host name
```

slog renders an error value as its text, so that reaches the log
unmodified.

## Known gap, stated rather than smoothed over

The gate proves the absence of **userinfo**, not of every credential. A
secret
supplied as a query parameter (`redis://host:6379/?password=x`) parses
as a
clean URL with `User == nil` and **is still echoed**. This is the
adjudicated
scope, not an oversight, and closing it is a separate decision. It is
pinned by
a named characterization test
(`TestTextKnownGapQueryParameterCredentialsAreEchoed`) that fails if
someone
closes the gap without updating the expectation, so the residual cannot
become
folklore.

Withholding also has a real diagnosability cost, accepted by design: the
reason
a request failed is still reported, the host is not.

```
Error: Get [withheld]: unsupported protocol scheme "admin"
```

## Validation ledger

Evidence level claimed: **E3 — independently reproduced**. Not E4: I am
the
author, and E4 requires a non-author to verify the acceptance criteria
end to
end.

Environment: Linux x64, Go 1.25.0, exact tree
`9fa9c14de074b4ddb5320d4f83803f1657918b34` — `HEAD^{tree}` of the
current head
`c9e0e63`, read back from `GET /repos/bytefolk/mem/commits/c9e0e63…` and
equal to
it byte-for-byte, so the tree measured here and the tree on the PR are
the same
object. (This head was pushed through the Git Data API because
`github.com:443`
was down on this box; the local commit sha differs from the remote one,
the tree
does not.)

| Check | Result |
| --- | --- |
| `go build ./...` | pass |
| `go vet ./...` | pass |
| `gofmt -l .` | no output |
| `go test -count=1 ./...` | 32 packages ok, 0 FAIL |
| `cmd/mem` (includes 16 `mem doctor` tests) | 102 tests, 0 fail |
| `internal/redact` | 30 tests, 0 fail |
| `internal/apiclient` | 45 tests, 0 fail |
| `cmd/memd` | 15 tests, 0 fail |
| `git merge-base --is-ancestor origin/main HEAD` | yes (main not
silently reverted) |
| CI on this head | **15/15 check names `success`** on `c9e0e63`
(`run_attempt` 1), including `Go` and `PostgreSQL integration`; also
15/15 on the immediately preceding `d30f1ff` |

### `mem doctor` end-to-end, measured on built binaries

Seven malformed credential URL shapes, each run through
`mem doctor --server <shape> --timeout 1s` in **both** `text` and
`json`,
counting occurrences of a sentinel password in everything the process
wrote:

| # | shape | #164 head `fffcd4c` | this head `c9e0e63` |
| --- | --- | --- | --- |
| 1 | `http://admin:PW@127.0.0.1:1` | 0 | 0 |
| 2 | `http://admin:PW@127.0.0.1:%zz` | 0 | 0 |
| 3 | `http://admin:PW@ho st.example.com` | 0 | 0 |
| 4 | `http://admin:PW x@127.0.0.1:1` | 0 | 0 |
| 5 | `http://admin:PW@%` | 0 | 0 |
| 6 | `http://admin:PW@mem.internal:99999999` | 0 | 0 |
| 7 | `admin:PW@mem.internal:8787` (no scheme) | **6** | **0** |

Shape 7 is the `url.Parse` → `User=nil` case from the section above:
#164
prints the configured URL three times (`server`, `detail`, `hint`) in
each of
the two formats, so 6 occurrences is one leak per field.

**A zero from a binary that has no `doctor` command means nothing.**
This table
was first produced against a build of `b229537` and came back
0/0/0/0/0/0/0,
which looked like a pass — the actual output was
`Error: unknown command "doctor" for "mem"`, because that head had no
doctor at
all. The harness now counts `unknown command` alongside `unknown flag`
and every
row above was taken with both counters at 0.

### Mutation controls

Run against a copy, with the mutation confirmed present in the source
before the
suite is trusted:

1. Reverting the gate to the pre-fix `User != nil` form turns **10 named
cases
   red across 3 packages** (`internal/redact`, `cmd/memd`, `cmd/mem`).
2. Removing the gate at **one** apiclient `Do` site turns red **exactly
that
site's** test case and leaves the other three green. The four cases map
one-to-one onto the four sites, so a regression at a single site cannot
hide.
Control 2 was found by this method — an earlier version of this port
left one
   site raw and the table caught it.
3. Putting **#164's original `redactURL` / `sanitizeProbeError` back**
into this
tree — i.e. merging #164 and *not* rewiring doctor — fails **exactly one
test**, `TestDoctorSchemelessServerURLDoesNotLeakCredentials`, and its
built
binary leaks 2 sentinel occurrences on shape 7. Before that test was
added,
   the same mutation failed **zero** tests. So the merge on its own was
   unprotected, and the one new test is what closes that.

### Two pre-existing assertions were relaxed — named, because one is not
mine

The gate withholds a value it cannot prove is a transport URL, where
#164's
local helper echoed a partially cleaned one. Two assertions demanded the
echo
shape specifically, so they were widened to accept **redact *or*
withhold**:

| location | test | author of the assertion as written |
| --- | --- | --- |
| `cmds_doctor_test.go:418` | `TestRedactURLStripsUserinfo` | waterbro-8
(#131) |
| `cmds_doctor_test.go:456` |
`TestDoctorMalformedServerURLDoesNotLeakCredentials` | **PeterGuy326
(#164)** |

Both keep their original first clause — the secret must not appear, and
a
cleaned URL must still carry `REDACTED@<host>`. Only the *form* of an
acceptable answer widened. Concretely at `:418`, `strings.Contains(got,
secret)`
still fails the test, and the `REDACTED@mem.internal:8787` check two
lines above
it is untouched.

**No assertion was deleted, and the count went up, not down**: #164's 15
doctor
tests are all present on this head (0 dropped), plus
`TestRedactURLStripsUserinfo`
and the new `TestDoctorSchemelessServerURLDoesNotLeakCredentials` =
**16**. The
way to check this claim is mutation control 3 above: if the widening had
quietly
weakened coverage, restoring #164's scrubber would have gone green, and
instead
it fails a test.

Anyone who considers the withhold form unacceptable for `:456` should
say so on
#164's thread rather than assume I settled it unilaterally — I changed
an
assertion another author wrote to pass my gate.

## What I did not verify

- The leak table was rebuilt and re-run against `c9e0e63` after the docs
commit;
that commit is prose and comments only, and the result is unchanged (0
on all
seven shapes). Earlier heads `d30f1ff` and `b229537` each had 15/15 CI
at the
  time they were written.
- **There is no prior CI baseline for the doctor half to compare
against.**
#164's head `fffcd4c` has 0 `check-runs` and no workflow run has ever
been
recorded for it, so doctor has never been green in CI anywhere —
including
here, where it is now inside the `Go` job for the first time. That is a
gain,
not a regression, but it does mean no one has seen this code pass CI
before.
- Nothing was exercised on Windows or macOS. All results are Linux x64.
- `memd` was **not** driven end-to-end to its fatal redis path. Reaching
it
requires a reachable PostgreSQL (the queue client is constructed after
`db
Open`), and the only local server on 5432 is an unrelated instance whose
credentials I do not have — my attempt failed at `db open: ... failed
SASL
  auth` before touching the queue. That egress is therefore evidenced by
executing the gate on the asynq error string **measured from the real
library
  in this tree**, not by a live `memd` run.
- No claim is made about `#112`'s exit-code contract, R2/R3 acceptance,
or
  #131/#164 merge readiness.

## Non-goals

- No change to query-string credential handling (see Known gap).
- No change to `#164`'s or `#131`'s branches; nothing here is pushed to
anybody
else's ref. #164 is pulled *into* this branch by merge, which is why
#164 can
  be closed without losing its work.
- No re-adjudication of R3. Doctor's `--timeout` behaviour, its check
set and
its JSON schema are #164's, unchanged except where they called the old
local
  helpers.

This PR stays `Refs` on #112 rather than `Closes`, because merging it
would not
by itself settle #112's acceptance — that needs a reviewer's decision,
not just
a diff landing.

**Review strength of this PR, stated plainly:** 15/15 checks pass on
`c9e0e63`, and I am the author of `b229537`, `d30f1ff` and `c9e0e63` —
automated checks and
my own sign-off are not a review. What this PR is missing is exactly one
independent human review; nothing else blocks it (`mergeable=true`, no
conflict
with `main`, which is an ancestor of this head). It needs one human
approval,
and I am not that reviewer — on the doctor half especially, since I
edited an
assertion PeterGuy326 wrote.

---------

Co-authored-by: waterbro-8 <waterbro-8@users.noreply.github.com>
Co-authored-by: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com>
Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants