Hide credentials in Git endpoint diagnostics
This commit is contained in:
39
internal/git/display.go
Normal file
39
internal/git/display.go
Normal 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 }
|
||||
74
internal/git/display_test.go
Normal file
74
internal/git/display_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user