Hide credentials in Git endpoint diagnostics

This commit is contained in:
2026-11-02 03:19:16 +00:00
parent 595745c7b1
commit 92c95ef031
12 changed files with 184 additions and 23 deletions

39
internal/git/display.go Normal file
View File

@@ -0,0 +1,39 @@
package git
import (
"net/url"
"regexp"
"strings"
)
var httpURLSpan = regexp.MustCompile(`(?i)https?://[^\s"'<>]+`)
// DisplayURL hides HTTP userinfo without changing the endpoint used by Git.
func DisplayURL(raw string) string {
lower := strings.ToLower(raw)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return raw
}
authority := raw[strings.Index(raw, "://")+3:]
if end := strings.IndexAny(authority, "/?#"); end >= 0 {
authority = authority[:end]
}
if !strings.Contains(authority, "@") {
return raw
}
parsed, err := url.Parse(raw)
if err != nil || parsed.User == nil || parsed.Host == "" {
return "[redacted URL]"
}
parsed.User = url.User("redacted")
return parsed.String()
}
func displayDiagnostic(message string) string {
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
}
type displayError struct{ cause error }
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
func (err displayError) Unwrap() error { return err.cause }

View File

@@ -0,0 +1,74 @@
package git
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
)
func TestDisplayURL(t *testing.T) {
for _, test := range []struct{ input, want string }{
{"https://user:password@example.test/repo", "https://redacted@example.test/repo"},
{"https://token@example.test/repo", "https://redacted@example.test/repo"},
{"https://user%40example:secret%2Fvalue@example.test/repo", "https://redacted@example.test/repo"},
{"http://broken%zz:secret@example.test/repo", "[redacted URL]"},
{"https://example.test/repo", "https://example.test/repo"},
{"git@example.test:owner/repo", "git@example.test:owner/repo"},
} {
if got := DisplayURL(test.input); got != test.want {
t.Errorf("DisplayURL(%q) = %q, want %q", test.input, got, test.want)
}
}
}
func TestGitFailuresRedactCredentialsAndPreserveCause(t *testing.T) {
installFakeGit(t, `case "$*" in
*check-ref-format*) exit 0 ;;
esac
printf '%s\n' "fatal: unable to access 'https://user:secret@example.test/repo': https://token@example.test/path" >&2
exit 23`)
endpoint := Endpoint{PushURL: "https://user:secret@example.test/repo", ScopedName: "fixture"}
for _, operation := range []struct {
name string
run func() error
}{
{"query", func() error { _, err := endpoint.Query(context.Background(), t.TempDir(), "v1.2.3"); return err }},
{"push", func() error {
return endpoint.PushTag(context.Background(), t.TempDir(), "v1.2.3", strings.Repeat("a", 40))
}},
} {
t.Run(operation.name, func(t *testing.T) {
err := operation.run()
var exit *exec.ExitError
if !errors.As(err, &exit) || exit.ExitCode() != 23 {
t.Fatalf("error = %v, want Git exit 23", err)
}
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "user") || !strings.Contains(err.Error(), "example.test/repo") {
t.Fatalf("unsafe or unhelpful diagnostic: %v", err)
}
})
}
}
func TestDisplayErrorPreservesWrappedCause(t *testing.T) {
cause := errors.New("failed at https://token@example.test/path")
err := displayError{cause: cause}
if !errors.Is(err, cause) || strings.Contains(err.Error(), "token") {
t.Fatalf("sanitized error = %v", err)
}
}
func TestEndpointChangeRedactsNewDestination(t *testing.T) {
fixture := newRemoteFixture(t)
endpoint, err := Resolve(t.Context(), fixture.work, "origin")
if err != nil {
t.Fatal(err)
}
gitRun(t, fixture.work, "remote", "set-url", "--push", "origin", "https://new-token@example.test/repo")
err = endpoint.Recheck(t.Context(), fixture.work)
if !errors.Is(err, ErrEndpointChanged) || strings.Contains(err.Error(), "new-token") || !strings.Contains(err.Error(), "example.test/repo") {
t.Fatalf("endpoint change error = %v", err)
}
}

View File

@@ -60,14 +60,14 @@ type Snapshot struct {
// command-scoped remote whose read and push URLs resolve to that same endpoint.
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName)
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, DisplayURL(remoteName))
}
urls, err := pushURLs(ctx, repositoryDir, remoteName)
if err != nil {
return Endpoint{}, err
}
if len(urls) != 1 {
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls))
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, DisplayURL(remoteName), len(urls))
}
scopedName, err := newScopedName()
if err != nil {
@@ -89,10 +89,10 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
if err != nil {
return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err)
return fmt.Errorf("re-resolve remote %q: %w", DisplayURL(endpoint.RemoteName), err)
}
if len(urls) != 1 || urls[0] != endpoint.PushURL {
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", "))
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, DisplayURL(endpoint.RemoteName), displayDiagnostic(strings.Join(urls, ", ")))
}
return endpoint.verifyScopedURLs(ctx, repositoryDir)
}
@@ -109,7 +109,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
if err != nil {
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, DisplayURL(endpoint.PushURL), err)
}
main := RemoteRef{State: RefAbsent}
remoteTag := RemoteRef{State: RefAbsent}
@@ -119,7 +119,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
}
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
}
switch name {
case mainRef:
@@ -165,7 +165,7 @@ func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir str
}
resolved := outputLines(readURL)
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL)
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, displayDiagnostic(strings.Join(resolved, ", ")), DisplayURL(endpoint.PushURL))
}
// The scoped remote sets its push URL explicitly to the same captured value.
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
@@ -197,7 +197,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
continue
}
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err)
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", DisplayURL(remoteName), key, err)
}
for _, value := range outputLines(output) {
config = append(config, "remote."+scopedName+"."+key+"="+value)
@@ -209,7 +209,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
if err != nil {
return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err)
return nil, fmt.Errorf("resolve push URL for remote %q: %w", DisplayURL(remoteName), err)
}
values := outputLines(output)
unique := make([]string, 0, len(values))
@@ -239,9 +239,10 @@ func run(ctx context.Context, dir string, config []string, args ...string) (stri
Stderr: &stderr,
})
if err != nil {
err = displayError{cause: err}
message := strings.TrimSpace(stderr.String())
if message != "" {
return stdout.String(), fmt.Errorf("%w: %s", err, message)
return stdout.String(), fmt.Errorf("%w: %s", err, displayDiagnostic(message))
}
return stdout.String(), err
}

View File

@@ -109,13 +109,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
if err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
}
remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) {
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
}
switch name {
case ref:
@@ -137,7 +137,7 @@ func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObj
}
refspec := tagObject + ":refs/tags/" + tag
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
}
return nil
}

View File

@@ -105,7 +105,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
}
result.Endpoint = endpoint.PushURL
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL)
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, git.DisplayURL(request.ExpectedPushURL), git.DisplayURL(endpoint.PushURL))
}
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
@@ -188,11 +188,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
queryErr = git.ErrRemoteStateUnreadable
}
}
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, queryErr, pushErr)
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr)
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, endpoint.PushURL, pushErr)
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr)
}
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete
@@ -200,7 +200,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
return result, nil
}
result.State = Conflict
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr)
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, git.DisplayURL(endpoint.PushURL), pushErr)
}
func isRemoteConflict(err error) bool {