Skip to content

Commit 92783ef

Browse files
Add RedactURL to prevent credential leakage in logs
- Add RedactURL function that handles all common URL formats: - Standard URLs with userinfo (user:pass, token-only, user-only) - URL-encoded credentials (p%40ss%3Aw0rd) - SCP-like URLs with any user (git@, deploy@, token@) - Various schemes (http, https, ssh, git, ftp, sftp) - IPv6 hosts - Uses net/url for scheme URLs (handles encoding automatically) - Uses regex for SCP-style URLs - Redact credentials from parent URL, submodule URL, and resolved URL logs - Add comprehensive tests covering all credential patterns
1 parent 79d1fc5 commit 92783ef

2 files changed

Lines changed: 189 additions & 5 deletions

File tree

git/git.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/url"
1111
"os"
1212
"path"
13+
"regexp"
1314
"strings"
1415

1516
"github.com/coder/envbuilder/internal/ebutil"
@@ -434,7 +435,43 @@ func ProgressWriter(write func(line string, args ...any)) io.WriteCloser {
434435
}
435436
}
436437

437-
// resolveSubmoduleURL resolves a potentially relative submodule URL against the parent repository URL
438+
// scpLikeURLRegex matches SCP-like URLs: user@host:path (where host is not empty and path doesn't start with /)
439+
// This handles: git@github.com:org/repo, deploy@host:repo, user@10.0.0.5:project
440+
var scpLikeURLRegex = regexp.MustCompile(`^([^@]+)@([^:]+):(.+)$`)
441+
442+
// RedactURL redacts credentials from a URL for safe logging.
443+
// Handles:
444+
// - Standard URLs with userinfo: https://user:pass@host, https://token@host
445+
// - URL-encoded credentials: https://user:p%40ss@host
446+
// - SCP-like URLs: git@host:path, deploy@host:path, user@10.0.0.5:path
447+
// - Various schemes: http, https, ssh, git, ftp, sftp
448+
// - IPv6 hosts: https://user@[2001:db8::1]/path
449+
func RedactURL(u string) string {
450+
// First, try to parse as a standard URL (handles most schemes)
451+
parsed, err := url.Parse(u)
452+
if err == nil && parsed.Scheme != "" {
453+
// Successfully parsed as a URL with a scheme
454+
// Redact userinfo if present (handles user, user:pass, token, URL-encoded creds)
455+
if parsed.User != nil {
456+
parsed.User = url.User("***")
457+
}
458+
return parsed.String()
459+
}
460+
461+
// Handle SCP-like URLs: user@host:path (no scheme)
462+
// This catches: git@github.com:org/repo, deploy@host:repo, user@10.0.0.5:project
463+
if matches := scpLikeURLRegex.FindStringSubmatch(u); matches != nil {
464+
// matches[1] = user part (could be git, deploy, token, etc.)
465+
// matches[2] = host
466+
// matches[3] = path
467+
return "***@" + matches[2] + ":" + matches[3]
468+
}
469+
470+
// If we can't parse it and it's not SCP-like, return as-is
471+
// (probably not a URL with credentials)
472+
return u
473+
}
474+
438475
// ResolveSubmoduleURL resolves a potentially relative submodule URL against a parent repository URL.
439476
func ResolveSubmoduleURL(parentURL, submoduleURL string) (string, error) {
440477
// If the submodule URL is absolute (contains ://) or doesn't start with ./ or ../, return it as-is
@@ -521,13 +558,13 @@ func initSubmodules(ctx context.Context, logf func(string, ...any), repo *git.Re
521558
if origin, hasOrigin := cfg.Remotes["origin"]; hasOrigin && len(origin.URLs) > 0 {
522559
parentURL = origin.URLs[0]
523560
}
524-
logf("Parent repository URL: %s", parentURL)
561+
logf("Parent repository URL: %s", RedactURL(parentURL))
525562

526563
for _, sub := range subs {
527564
subConfig := sub.Config()
528565
logf("📦 Initializing submodule: %s", subConfig.Name)
529566
logf(" Submodule path: %s", subConfig.Path)
530-
logf(" Submodule URL (from .gitmodules): %s", subConfig.URL)
567+
logf(" Submodule URL (from .gitmodules): %s", RedactURL(subConfig.URL))
531568

532569
// Get the expected commit hash
533570
subStatus, err := sub.Status()
@@ -541,7 +578,7 @@ func initSubmodules(ctx context.Context, logf func(string, ...any), repo *git.Re
541578
if err != nil {
542579
return fmt.Errorf("resolve submodule URL for %q: %w", subConfig.Name, err)
543580
}
544-
logf(" Resolved URL: %s", resolvedURL)
581+
logf(" Resolved URL: %s", RedactURL(resolvedURL))
545582

546583
// Clone the submodule manually
547584
err = cloneSubmodule(ctx, logf, w, subConfig, subStatus.Expected, resolvedURL, opts)
@@ -619,7 +656,7 @@ func cloneSubmodule(ctx context.Context, logf func(string, ...any), parentWorktr
619656
}
620657

621658
// Clone the submodule
622-
logf(" Cloning submodule from: %s", resolvedURL)
659+
logf(" Cloning submodule from: %s", RedactURL(resolvedURL))
623660

624661
// Create .git directory for the submodule
625662
err = subFS.MkdirAll(".git", 0o755)

git/git_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,153 @@ func mustRead(t *testing.T, fs billy.Filesystem, path string) string {
533533
return string(content)
534534
}
535535

536+
func TestRedactURL(t *testing.T) {
537+
t.Parallel()
538+
539+
cases := []struct {
540+
name string
541+
input string
542+
expect string
543+
}{
544+
// Standard URLs without credentials
545+
{
546+
name: "https no creds",
547+
input: "https://github.com/org/repo.git",
548+
expect: "https://github.com/org/repo.git",
549+
},
550+
{
551+
name: "git protocol no creds",
552+
input: "git://github.com/org/repo.git",
553+
expect: "git://github.com/org/repo.git",
554+
},
555+
556+
// HTTPS with various credential formats
557+
{
558+
name: "https with user and password",
559+
input: "https://user:password@github.com/org/repo.git",
560+
expect: "https://***@github.com/org/repo.git",
561+
},
562+
{
563+
name: "https with token only (no password)",
564+
input: "https://ghp_xxxxxxxxxxxx@github.com/org/repo.git",
565+
expect: "https://***@github.com/org/repo.git",
566+
},
567+
{
568+
name: "https with user only (no password)",
569+
input: "https://user@github.com/org/repo.git",
570+
expect: "https://***@github.com/org/repo.git",
571+
},
572+
{
573+
name: "https with x-access-token",
574+
input: "https://x-access-token:ghp_secret123@github.com/org/repo.git",
575+
expect: "https://***@github.com/org/repo.git",
576+
},
577+
578+
// URL-encoded credentials
579+
{
580+
name: "https with URL-encoded password",
581+
input: "https://user:p%40ss%3Aw0rd@github.com/org/repo.git",
582+
expect: "https://***@github.com/org/repo.git",
583+
},
584+
{
585+
name: "https with URL-encoded username",
586+
input: "https://user%40domain:pass@github.com/org/repo.git",
587+
expect: "https://***@github.com/org/repo.git",
588+
},
589+
590+
// HTTP
591+
{
592+
name: "http with creds",
593+
input: "http://user:pass@example.com/repo.git",
594+
expect: "http://***@example.com/repo.git",
595+
},
596+
597+
// SSH URLs (with scheme)
598+
{
599+
name: "ssh with user",
600+
input: "ssh://git@github.com/org/repo.git",
601+
expect: "ssh://***@github.com/org/repo.git",
602+
},
603+
{
604+
name: "ssh with different user",
605+
input: "ssh://deploy@github.com/org/repo.git",
606+
expect: "ssh://***@github.com/org/repo.git",
607+
},
608+
609+
// SCP-like URLs (no scheme)
610+
{
611+
name: "scp-like git user",
612+
input: "git@github.com:org/repo.git",
613+
expect: "***@github.com:org/repo.git",
614+
},
615+
{
616+
name: "scp-like deploy user",
617+
input: "deploy@host:repo.git",
618+
expect: "***@host:repo.git",
619+
},
620+
{
621+
name: "scp-like with IP address",
622+
input: "user@10.0.0.5:project.git",
623+
expect: "***@10.0.0.5:project.git",
624+
},
625+
{
626+
name: "scp-like with token as user",
627+
input: "oauth2:ghp_secret@gitlab.com:org/repo.git",
628+
expect: "***@gitlab.com:org/repo.git",
629+
},
630+
631+
// IPv6 hosts
632+
{
633+
name: "https with IPv6 and creds",
634+
input: "https://user:pass@[2001:db8::1]/path/repo.git",
635+
expect: "https://***@[2001:db8::1]/path/repo.git",
636+
},
637+
{
638+
name: "https with IPv6 no creds",
639+
input: "https://[2001:db8::1]/path/repo.git",
640+
expect: "https://[2001:db8::1]/path/repo.git",
641+
},
642+
643+
// Other schemes
644+
{
645+
name: "ftp with creds",
646+
input: "ftp://user:pass@host/path",
647+
expect: "ftp://***@host/path",
648+
},
649+
{
650+
name: "sftp with user only",
651+
input: "sftp://user@host/path",
652+
expect: "sftp://***@host/path",
653+
},
654+
655+
// Edge cases
656+
{
657+
name: "plain path (not a URL)",
658+
input: "/local/path/to/repo",
659+
expect: "/local/path/to/repo",
660+
},
661+
{
662+
name: "relative path",
663+
input: "../sibling/repo.git",
664+
expect: "../sibling/repo.git",
665+
},
666+
{
667+
name: "file URL",
668+
input: "file:///local/repo.git",
669+
expect: "file:///local/repo.git",
670+
},
671+
}
672+
673+
for _, tc := range cases {
674+
tc := tc
675+
t.Run(tc.name, func(t *testing.T) {
676+
t.Parallel()
677+
got := git.RedactURL(tc.input)
678+
require.Equal(t, tc.expect, got)
679+
})
680+
}
681+
}
682+
536683
func TestResolveSubmoduleURL(t *testing.T) {
537684
t.Parallel()
538685

0 commit comments

Comments
 (0)