diff --git a/pkg/build/push.go b/pkg/build/push.go index 2954f02843e..3039ce639bf 100644 --- a/pkg/build/push.go +++ b/pkg/build/push.go @@ -20,6 +20,8 @@ import ( "context" "errors" "fmt" + "io/fs" + "net/url" "os" "path/filepath" "regexp" @@ -28,6 +30,7 @@ import ( "cloud.google.com/go/storage" "github.com/sirupsen/logrus" + "sigs.k8s.io/release-sdk/git" "sigs.k8s.io/release-utils/helpers" "sigs.k8s.io/release-utils/tar" @@ -530,10 +533,15 @@ func (bi *Instance) StageLocalSourceTree(workDir, buildVersion string) error { return fmt.Errorf("compile tarball exclude regex: %w", err) } - excludeGit := regexp.MustCompile(`(^|/)\.git(/|$)`) + // The tarball ships the workspace clones, including their git + // directories, as the release process relies on their state. Make sure + // no repository leaks the credentials embedded in its remote URLs: + if err := stripRemoteCredentials(filepath.Join(workDir, "src")); err != nil { + return fmt.Errorf("stripping remote credentials from workspace repositories: %w", err) + } if err := tar.Compress( - tarballPath, filepath.Join(workDir, "src"), excludeBuildDir, excludeGit, + tarballPath, filepath.Join(workDir, "src"), excludeBuildDir, ); err != nil { return fmt.Errorf("create tarball: %w", err) } @@ -554,6 +562,60 @@ func (bi *Instance) StageLocalSourceTree(workDir, buildVersion string) error { return nil } +// stripRemoteCredentials replaces the remote URLs of every git repository +// found under path with their equivalents without credentials. The workspace +// clones authenticate with the GitHub token embedded in their remote URLs, +// which must not leak into the staged source tarball. +func stripRemoteCredentials(path string) error { + return filepath.WalkDir(path, func(entry string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() || d.Name() != ".git" { + return nil + } + + repoPath := filepath.Dir(entry) + + repo, err := git.OpenRepo(repoPath) + if err != nil { + return fmt.Errorf("opening workspace repository %s: %w", repoPath, err) + } + + remotes, err := repo.Remotes() + if err != nil { + return fmt.Errorf("listing remotes of %s: %w", repoPath, err) + } + + for _, remote := range remotes { + for _, remoteURL := range remote.URLs() { + parsed, err := url.Parse(remoteURL) + if err != nil || parsed.User == nil { + continue + } + + parsed.User = nil + + logrus.Infof( + "Removing credentials from remote %s of %s", remote.Name(), repoPath, + ) + + if err := repo.SetURL(remote.Name(), parsed.String()); err != nil { + return fmt.Errorf("setting URL of remote %s: %w", remote.Name(), err) + } + + // SetURL replaces all remote URLs; break to avoid + // iterating the now-stale URL list. + break + } + } + + // Do not descend into the git directory itself + return filepath.SkipDir + }) +} + // DeleteLocalSourceTarball the deletion of the tarball is now decoupled from // StageLocalSourceTree to be able to use it during the anago.stage function. func (bi *Instance) DeleteLocalSourceTarball(workDir string) error { diff --git a/pkg/build/push_test.go b/pkg/build/push_test.go new file mode 100644 index 00000000000..aa07c76619d --- /dev/null +++ b/pkg/build/push_test.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package build + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "sigs.k8s.io/release-sdk/git" + "sigs.k8s.io/release-utils/command" +) + +func testGit(t *testing.T, dir string, args ...string) { + t.Helper() + + _, err := command.NewWithWorkDir(dir, "git", args...).RunSilentSuccessOutput() + require.NoError(t, err) +} + +func TestStripRemoteCredentials(t *testing.T) { + t.Parallel() + + workDir := t.TempDir() + repoPath := filepath.Join(workDir, "src", "k8s.io", "kubernetes") + require.NoError(t, os.MkdirAll(repoPath, 0o755)) + testGit(t, repoPath, "init", "-q") + testGit(t, repoPath, "remote", "add", "origin", "https://git:supersecret@github.com/kubernetes/kubernetes") + testGit(t, repoPath, "remote", "add", "clean", "https://github.com/kubernetes/release") + + require.NoError(t, stripRemoteCredentials(filepath.Join(workDir, "src"))) + + repo, err := git.OpenRepo(repoPath) + require.NoError(t, err) + remotes, err := repo.Remotes() + require.NoError(t, err) + require.Len(t, remotes, 2) + + urls := map[string]string{} + + for _, remote := range remotes { + require.Len(t, remote.URLs(), 1) + urls[remote.Name()] = remote.URLs()[0] + } + + // The credentials are stripped, remotes without credentials are untouched + require.Equal(t, "https://github.com/kubernetes/kubernetes", urls["origin"]) + require.Equal(t, "https://github.com/kubernetes/release", urls["clean"]) + + // No repositories under the path is not an error + require.NoError(t, stripRemoteCredentials(t.TempDir())) +} diff --git a/pkg/notes/notes_test.go b/pkg/notes/notes_test.go index 7f484b566b1..f26a6df5db2 100644 --- a/pkg/notes/notes_test.go +++ b/pkg/notes/notes_test.go @@ -231,7 +231,7 @@ func TestPrettySIG(t *testing.T) { } for input, expected := range cases { - require.Equal(t, expected, (prettySIG(input))) + require.Equal(t, expected, prettySIG(input)) } }