VYPR
Critical severityNVD Advisory· Published Mar 23, 2026· Updated Mar 24, 2026

Tekton Pipelines git resolver has path traversal that allows reading arbitrary files from the resolver pod

CVE-2026-33211

Description

Tekton Pipelines project provides k8s-style resources for declaring CI/CD-style pipelines. Starting in version 1.0.0 and prior to versions 1.0.1, 1.3.3, 1.6.1, 1.9.2, and 1.10.2, the Tekton Pipelines git resolver is vulnerable to path traversal via the pathInRepo parameter. A tenant with permission to create ResolutionRequests (e.g. by creating TaskRuns or PipelineRuns that use the git resolver) can read arbitrary files from the resolver pod's filesystem, including ServiceAccount tokens. The file contents are returned base64-encoded in resolutionrequest.status.data. Versions 1.0.1, 1.3.3, 1.6.1, 1.9.2, and 1.10.2 contain a patch.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Tekton Pipelines git resolver is vulnerable to path traversal via `pathInRepo` parameter, allowing attackers to read arbitrary files from the resolver pod's filesystem.

Vulnerability

Description

The Tekton Pipelines git resolver fails to properly sanitize the pathInRepo parameter, making it susceptible to path traversal attacks [1]. This flaw exists in versions 1.0.0 through prior to the patched releases, as identified by the project maintainers. An attacker can supply a pathInRepo value containing ../ sequences or use symlinks within the cloned repository to escape the intended directory boundary [2].

Exploitation

To exploit this vulnerability, an attacker must have permission to create ResolutionRequests, which can be achieved by creating TaskRuns or PipelineRuns that utilize the git resolver [1]. The attacker crafts a malicious pathInRepo parameter that points to files outside the repository, such as /etc/passwd or ServiceAccount tokens. The contents of the accessed file are then returned base64-encoded in the resolutionrequest.status.data field [1].

Impact

Successful exploitation allows an attacker to read arbitrary files from the resolver pod's filesystem. This includes sensitive information like Kubernetes ServiceAccount tokens, which could be used to escalate privileges within the cluster [1]. The severity of this vulnerability is high, as it can lead to full cluster compromise if token theft is achieved.

Mitigation

The vulnerability has been patched in Tekton Pipelines versions 1.0.1, 1.3.3, 1.6.1, 1.9.2, and 1.10.2 [1]. The fix involves adding proper path validation and sanitization, as demonstrated in commit 10fa538f [2]. Users running affected versions should upgrade to a patched release immediately to prevent exploitation.

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/tektoncd/pipelineGo
>= 1.0.0, < 1.0.11.0.1
github.com/tektoncd/pipelineGo
>= 1.1.0, < 1.3.31.3.3
github.com/tektoncd/pipelineGo
>= 1.4.0, < 1.6.11.6.1
github.com/tektoncd/pipelineGo
>= 1.7.0, < 1.9.21.9.2
github.com/tektoncd/pipelineGo
>= 1.10.0, < 1.10.21.10.2

Affected products

2
  • Tektoncd/Pipelinellm-fuzzy2 versions
    >=1.0.0, <1.10.2+ 1 more
    • (no CPE)range: >=1.0.0, <1.10.2
    • (no CPE)range: >= 1.0.0, < 1.0.1

Patches

7
3ca7bc6e6dd1

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -134,11 +134,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -157,6 +158,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	KubeClient kubernetes.Interface
     	Logger     *zap.SugaredLogger
    @@ -399,7 +411,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -276,11 +276,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
10fa538f9a2b

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -133,11 +133,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -158,6 +159,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	Params     map[string]string
     	Logger     *zap.SugaredLogger
    @@ -326,7 +338,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -266,11 +266,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
961388fcf337

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -134,11 +134,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -157,6 +158,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	KubeClient kubernetes.Interface
     	Logger     *zap.SugaredLogger
    @@ -399,7 +411,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -276,11 +276,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
b1fee65b88aa

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -134,11 +134,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -157,6 +158,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	KubeClient kubernetes.Interface
     	Logger     *zap.SugaredLogger
    @@ -399,7 +411,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -266,11 +266,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
cdb4e1e97a4f

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -134,11 +134,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -157,6 +158,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	KubeClient kubernetes.Interface
     	Logger     *zap.SugaredLogger
    @@ -399,7 +411,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -276,11 +276,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
ec7755031a18

fix: prevent path traversal in git resolver pathInRepo parameter

https://github.com/tektoncd/pipelineVincent DemeesterMar 11, 2026via ghsa
5 files changed · +312 4
  • pkg/resolution/resolver/git/repository.go+43 2 modified
    @@ -133,11 +133,52 @@ func (repo *repository) execGit(ctx context.Context, subCmd string, args ...stri
     	return out, err
     }
     
    -func (repo *repository) getFileContent(path string) ([]byte, error) {
    +func (repo *repository) getFileContent(givenPath string) ([]byte, error) {
     	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
     		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
     	}
    -	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +
    +	// Resolve repo.directory itself so that filepath.Rel produces correct
    +	// results on platforms where the temp directory is a symlink (e.g.
    +	// macOS /tmp -> /private/tmp).
    +	repoDir, err := filepath.EvalSymlinks(repo.directory)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve repository directory: %w", err)
    +	}
    +
    +	absPath, err := filepath.Abs(filepath.Join(repoDir, givenPath))
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// Resolve symlinks so that in-repo symlinks work correctly while
    +	// symlinks that escape the repo are caught by the containment check.
    +	resolvedPath, err := filepath.EvalSymlinks(absPath)
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +
    +	absPath, err = filepath.Abs(resolvedPath)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	relativePath, err := filepath.Rel(repoDir, absPath)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to resolve relative path: %w", err)
    +	}
    +
    +	// Detect path traversal attempts — the relative path should never
    +	// start with ".." after symlink resolution. Log a specific message
    +	// so administrators can set up alerts for attempted exploits.
    +	if containsDotDot(relativePath) {
    +		return nil, fmt.Errorf("path %q attempts to escape the repository directory (possible path traversal attack)", givenPath)
    +	}
    +
    +	fileContents, err := os.ReadFile(absPath)
     	if err != nil {
     		if errors.Is(err, os.ErrNotExist) {
     			return nil, errors.New("file does not exist")
    
  • pkg/resolution/resolver/git/repository_test.go+202 0 modified
    @@ -20,7 +20,9 @@ package git
     import (
     	"context"
     	"encoding/base64"
    +	"os"
     	"os/exec"
    +	"path/filepath"
     	"reflect"
     	"slices"
     	"testing"
    @@ -170,3 +172,203 @@ func TestCheckout(t *testing.T) {
     		})
     	}
     }
    +
    +func TestGetFileContent(t *testing.T) {
    +	// Create a file outside any repo to simulate a sensitive target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a real git repository with a tracked file.
    +	// Resolve the temp dir so filepath.Rel works on platforms where /tmp
    +	// is a symlink (e.g. macOS /tmp -> /private/tmp).
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Dir: "tasks", Filename: "example.yaml", Content: "valid content"},
    +	})
    +	// Add a symlink that escapes and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	if err := os.Symlink(sensitiveFile, filepath.Join(repoDir, "escape-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink: %q: %v", out, err)
    +	}
    +	// Add a nested symlink escape.
    +	nestedDir := filepath.Join(repoDir, "subdir")
    +	if err := os.MkdirAll(nestedDir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.Symlink(sensitiveFile, filepath.Join(nestedDir, "nested-link")); err != nil {
    +		t.Fatal(err)
    +	}
    +	if out, err := gitCmd("add", "subdir/nested-link").Output(); err != nil {
    +		t.Fatalf("git add nested symlink: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlinks").Output(); err != nil {
    +		t.Fatalf("git commit: %q: %v", out, err)
    +	}
    +
    +	repo := &repository{directory: repoDir}
    +
    +	tests := []struct {
    +		name    string
    +		path    string
    +		wantErr bool
    +	}{
    +		{
    +			name: "valid relative path",
    +			path: "tasks/example.yaml",
    +		},
    +		{
    +			name:    "path traversal with dot-dot",
    +			path:    "../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal to parent",
    +			path:    "../secret",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal deeply nested",
    +			path:    "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "path traversal embedded",
    +			path:    "tasks/../../../../../../etc/passwd",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "non-existent file",
    +			path:    "does-not-exist.yaml",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink escaping repo directory",
    +			path:    "escape-link",
    +			wantErr: true,
    +		},
    +		{
    +			name:    "symlink in subdirectory escaping repo",
    +			path:    filepath.Join("subdir", "nested-link"),
    +			wantErr: true,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if tc.wantErr {
    +				if err == nil {
    +					t.Fatalf("expected error, got nil (content: %q)", string(content))
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("unexpected error: %v", err)
    +				}
    +			}
    +		})
    +	}
    +}
    +
    +// TestGetFileContent_SymlinkEscape_RealGitRepo creates a real git
    +// repository with a committed symlink that points outside the repo,
    +// clones it, checks out the revision, and verifies that getFileContent
    +// rejects the symlink path. This exercises the full clone → checkout →
    +// read flow with an actual git repository.
    +func TestGetFileContent_SymlinkEscape_RealGitRepo(t *testing.T) {
    +	// Create a sensitive file outside any repo to simulate a target.
    +	sensitiveDir := t.TempDir()
    +	sensitiveFile := filepath.Join(sensitiveDir, "sa-token")
    +	if err := os.WriteFile(sensitiveFile, []byte("stolen-credential"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	// Create a git repository with a normal file and a symlink escape.
    +	repoDir, _ := createTestRepo(t, []commitForRepo{
    +		{Filename: "task.yaml", Content: "apiVersion: tekton.dev/v1\nkind: Task"},
    +	})
    +
    +	// Add a symlink that points to the sensitive file and commit it.
    +	gitCmd := getGitCmd(t, repoDir)
    +	symlinkPath := filepath.Join(repoDir, "escape-link")
    +	if err := os.Symlink(sensitiveFile, symlinkPath); err != nil {
    +		t.Fatalf("failed to create symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "escape-link").Output(); err != nil {
    +		t.Fatalf("git add symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Also add a symlink in a subdirectory.
    +	subdir := filepath.Join(repoDir, "configs")
    +	if err := os.MkdirAll(subdir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	nestedSymlink := filepath.Join(subdir, "nested-escape")
    +	if err := os.Symlink(sensitiveFile, nestedSymlink); err != nil {
    +		t.Fatalf("failed to create nested symlink: %v", err)
    +	}
    +	if out, err := gitCmd("add", "configs/nested-escape").Output(); err != nil {
    +		t.Fatalf("git add nested symlink failed: %q: %v", out, err)
    +	}
    +	if out, err := gitCmd("commit", "-m", "add nested symlink escape").Output(); err != nil {
    +		t.Fatalf("git commit nested symlink failed: %q: %v", out, err)
    +	}
    +
    +	// Clone the repo (as the resolver would) and checkout main.
    +	ctx := t.Context()
    +	repo, cleanup, err := remote{url: repoDir}.clone(ctx)
    +	if err != nil {
    +		t.Fatalf("failed to clone test repo: %v", err)
    +	}
    +	defer cleanup()
    +
    +	if err := repo.checkout(ctx, "main"); err != nil {
    +		t.Fatalf("failed to checkout main: %v", err)
    +	}
    +
    +	// Verify a normal file can be read.
    +	content, err := repo.getFileContent("task.yaml")
    +	if err != nil {
    +		t.Fatalf("expected to read normal file, got error: %v", err)
    +	}
    +	if !contains(string(content), "tekton.dev") {
    +		t.Fatalf("unexpected content: %s", content)
    +	}
    +
    +	// Verify the symlink escape is blocked.
    +	tests := []struct {
    +		name string
    +		path string
    +	}{
    +		{name: "top-level symlink escape", path: "escape-link"},
    +		{name: "nested symlink escape", path: "configs/nested-escape"},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			content, err := repo.getFileContent(tc.path)
    +			if err == nil {
    +				t.Fatalf("symlink escape was NOT blocked — read %d bytes: %q", len(content), string(content))
    +			}
    +		})
    +	}
    +}
    +
    +func contains(s, substr string) bool {
    +	return len(s) >= len(substr) && searchSubstring(s, substr)
    +}
    +
    +func searchSubstring(s, substr string) bool {
    +	for i := 0; i <= len(s)-len(substr); i++ {
    +		if s[i:i+len(substr)] == substr {
    +			return true
    +		}
    +	}
    +	return false
    +}
    
  • pkg/resolution/resolver/git/resolver.go+22 1 modified
    @@ -21,6 +21,7 @@ import (
     	"errors"
     	"fmt"
     	"os"
    +	"path/filepath"
     	"regexp"
     	"strings"
     	"time"
    @@ -158,6 +159,17 @@ func validateRepoURL(url string) bool {
     	return re.MatchString(url)
     }
     
    +// containsDotDot checks if a path contains ".." components that could be
    +// used for path traversal. It handles both Unix and Windows separators.
    +func containsDotDot(path string) bool {
    +	for _, part := range strings.Split(filepath.ToSlash(path), "/") {
    +		if part == ".." {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     type GitResolver struct {
     	Params     map[string]string
     	Logger     *zap.SugaredLogger
    @@ -326,7 +338,16 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
     		return nil, fmt.Errorf("invalid git repository url: %s", paramsMap[UrlParam])
     	}
     
    -	// TODO(sbwsg): validate pathInRepo is valid relative pathInRepo
    +	// Validate pathInRepo cannot escape the repository directory via
    +	// traversal (e.g. "../../etc/passwd"). Leading slashes are stripped
    +	// for backwards compatibility — filepath.Join already handles them
    +	// safely by treating them as relative to the base directory.
    +	pathValue := paramsMap[PathParam]
    +	pathValue = strings.TrimLeft(pathValue, "/")
    +	paramsMap[PathParam] = pathValue
    +	if containsDotDot(pathValue) {
    +		return nil, fmt.Errorf("invalid path %q: must not contain '..' components", pathValue)
    +	}
     	return paramsMap, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+40 0 modified
    @@ -110,6 +110,14 @@ func TestValidateParams(t *testing.T) {
     				RevisionParam: "baz",
     			},
     		},
    +		{
    +			name: "leading slash is stripped",
    +			params: map[string]string{
    +				UrlParam:      "https://foo/bar/hello/moto",
    +				PathParam:     "/task/git-clone/0.10/git-clone.yaml",
    +				RevisionParam: "baz",
    +			},
    +		},
     		{
     			name: "bad url",
     			params: map[string]string{
    @@ -193,6 +201,38 @@ func TestValidateParams_Failure(t *testing.T) {
     				RepoParam:     "foo",
     			},
     			expectedErr: "'org' is required when 'repo' is specified",
    +		}, {
    +			name: "path traversal with dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../etc/passwd": must not contain '..' components`,
    +		}, {
    +			name: "path traversal deeply nested",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../../../../var/run/secrets/kubernetes.io/serviceaccount/token",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../../../../var/run/secrets/kubernetes.io/serviceaccount/token": must not contain '..' components`,
    +		}, {
    +			name: "path traversal with leading dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "../secret",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "../secret": must not contain '..' components`,
    +		}, {
    +			name: "path traversal embedded dot-dot",
    +			params: map[string]string{
    +				RevisionParam: "abcd1234",
    +				PathParam:     "foo/../../../etc/passwd",
    +				UrlParam:      "https://github.com/tektoncd/catalog",
    +			},
    +			expectedErr: `invalid path "foo/../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
  • test/resolvers_test.go+5 1 modified
    @@ -267,11 +267,15 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "path does not exist",
     			pathInRepo:  "/task/banana/55.55/banana.yaml",
    -			expectedErr: "error opening file \"/task/banana/55.55/banana.yaml\": file does not exist",
    +			expectedErr: "error opening file \"task/banana/55.55/banana.yaml\": file does not exist",
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
     			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
    +		}, {
    +			name:        "path traversal with dot-dot",
    +			pathInRepo:  "../../../../etc/passwd",
    +			expectedErr: `invalid path "../../../../etc/passwd": must not contain '..' components`,
     		},
     	}
     
    
318006c4e3a5

fix: resolve Git Anonymous Resolver excessive memory usage

https://github.com/tektoncd/pipelineAndrew ThorpMar 27, 2025via ghsa
12 files changed · +499 193
  • config/resolvers/resolvers-deployment.yaml+7 0 modified
    @@ -106,6 +106,9 @@ spec:
               value: "https://artifacthub.io/"
             - name: TEKTON_HUB_API
               value: "https://api.hub.tekton.dev/"
    +        volumeMounts:
    +          - name: tmp-clone-volume
    +            mountPath: "/tmp"
             securityContext:
               allowPrivilegeEscalation: false
               readOnlyRootFilesystem: true
    @@ -115,3 +118,7 @@ spec:
                 - "ALL"
               seccompProfile:
                 type: RuntimeDefault
    +      volumes:
    +        - name: tmp-clone-volume
    +          emptyDir:
    +            sizeLimit: 4Gi
    
  • docs/git-resolver.md+9 0 modified
    @@ -76,6 +76,15 @@ The differences between the two modes are:
     ### Git Clone with git clone
     
     Git clone with `git clone` is supported for anonymous and authenticated cloning.
    +This mode shallow clones the git repo before fetching and checking out the
    +provided revision.
    +
    +**Note**: if the revision is a commit SHA which is not pointed-at by a Branch
    +or Tag ref, the revision might not be able to be fetched, depending on the
    +git provider's [uploadpack.allowReachableSHA1InWant](https://git-scm.com/docs/protocol-capabilities#_allow_reachable_sha1_in_want)
    +setting. This is not an issue for major git providers such as Github and
    +Gitlab, but may be of note for smaller or self-hosted providers such as
    +Gitea.
     
     #### Task Resolution
     
    
  • .ko.yaml+2 0 added
    @@ -0,0 +1,2 @@
    +baseImageOverrides:
    +  github.com/tektoncd/pipeline/cmd/resolvers: cgr.dev/chainguard/git@sha256:566235a8ef752f37d285042ee05fc37dbb04293e50f116a231984080fb835693
    
  • pkg/remoteresolution/resolver/git/resolver_test.go+1 1 modified
    @@ -429,7 +429,7 @@ func TestResolve(t *testing.T) {
     			pathInRepo: "foo/new",
     			url:        anonFakeRepoURL,
     		},
    -		expectedErr: createError("revision error: reference not found"),
    +		expectedErr: createError("git fetch error: fatal: couldn't find remote ref non-existent-revision: exit status 128"),
     	}, {
     		name: "api: successful task from params api information",
     		args: &params{
    
  • pkg/resolution/resolver/git/repository.go+149 0 added
    @@ -0,0 +1,149 @@
    +/*
    +Copyright 2025 The Tekton 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 git
    +
    +import (
    +	"context"
    +	"encoding/base64"
    +	"errors"
    +	"fmt"
    +	"os"
    +	"os/exec"
    +	"path/filepath"
    +	"strings"
    +)
    +
    +type cmdExecutor = func(context.Context, string, ...string) *exec.Cmd
    +
    +type remote struct {
    +	url         string
    +	username    string
    +	password    string
    +	cmdExecutor cmdExecutor
    +}
    +
    +func (r remote) clone(ctx context.Context) (*repository, func(), error) {
    +	urlParts := strings.Split(r.url, "/")
    +	repoName := urlParts[len(urlParts)-1]
    +	tmpDir, err := os.MkdirTemp("", repoName+"-*")
    +	if err != nil {
    +		return nil, func() {}, err
    +	}
    +	cleanupFunc := func() {
    +		os.RemoveAll(tmpDir)
    +	}
    +
    +	repo := repository{
    +		url:       r.url,
    +		username:  r.username,
    +		password:  r.password,
    +		directory: tmpDir,
    +		executor:  r.cmdExecutor,
    +	}
    +
    +	_, err = repo.execGit(ctx, "clone", repo.url, tmpDir, "--depth=1", "--no-checkout")
    +	if err != nil {
    +		if strings.Contains(err.Error(), "could not read Username") {
    +			err = errors.New("clone error: authentication required")
    +		}
    +		return nil, cleanupFunc, err
    +	}
    +	return &repo, cleanupFunc, nil
    +}
    +
    +type repository struct {
    +	url       string
    +	username  string
    +	password  string
    +	directory string
    +	executor  cmdExecutor
    +}
    +
    +func (repo *repository) currentRevision(ctx context.Context) (string, error) {
    +	revisionSha, err := repo.execGit(ctx, "rev-list", "-n1", "HEAD")
    +	if err != nil {
    +		return "", err
    +	}
    +	return strings.TrimSpace(string(revisionSha)), nil
    +}
    +
    +func (repo *repository) checkout(ctx context.Context, revision string) error {
    +	_, err := repo.execGit(ctx, "fetch", "origin", revision, "--depth=1")
    +	if err != nil {
    +		return err
    +	}
    +
    +	_, err = repo.execGit(ctx, "checkout", "FETCH_HEAD")
    +	if err != nil {
    +		return err
    +	}
    +
    +	return nil
    +}
    +
    +func (repo *repository) execGit(ctx context.Context, subCmd string, args ...string) ([]byte, error) {
    +	if repo.executor == nil {
    +		repo.executor = exec.CommandContext
    +	}
    +
    +	args = append([]string{subCmd}, args...)
    +
    +	// We need to configure  which directory contains the cloned repository since `cd`ing
    +	// into the repository directory is not concurrency-safe
    +	configArgs := []string{"-C", repo.directory}
    +	env := []string{"GIT_TERMINAL_PROMPT=false"}
    +	if subCmd == "clone" {
    +		// NOTE: Since this is only HTTP basic auth, authentication only supports http
    +		// cloning, while unauthenticated cloning works for any other protocol supported
    +		// by the git binary which doesn't require authentication.
    +		if repo.username != "" && repo.password != "" {
    +			token := base64.URLEncoding.EncodeToString([]byte(repo.username + ":" + repo.password))
    +			env = append(
    +				env,
    +				"GIT_AUTH_HEADER=Authorization=Basic "+token,
    +			)
    +			configArgs = append(configArgs, "--config-env", "http.extraHeader=GIT_AUTH_HEADER")
    +		}
    +	}
    +	cmd := repo.executor(ctx, "git", append(configArgs, args...)...)
    +	cmd.Env = append(cmd.Env, env...)
    +
    +	out, err := cmd.Output()
    +	if err != nil {
    +		msg := string(out)
    +		var exitErr *exec.ExitError
    +		if errors.As(err, &exitErr) {
    +			msg = string(exitErr.Stderr)
    +		}
    +		err = fmt.Errorf("git %s error: %s: %w", subCmd, strings.TrimSpace(msg), err)
    +	}
    +	return out, err
    +}
    +
    +func (repo *repository) getFileContent(path string) ([]byte, error) {
    +	if _, err := os.Stat(repo.directory); errors.Is(err, os.ErrNotExist) {
    +		return nil, fmt.Errorf("repository clone no longer exists, used after cleaned? %w", err)
    +	}
    +	fileContents, err := os.ReadFile(filepath.Join(repo.directory, path))
    +	if err != nil {
    +		if errors.Is(err, os.ErrNotExist) {
    +			return nil, errors.New("file does not exist")
    +		}
    +		return nil, err
    +	}
    +	return fileContents, nil
    +}
    
  • pkg/resolution/resolver/git/repository_test.go+164 0 added
    @@ -0,0 +1,164 @@
    +/*
    + Copyright 2025 The Tekton 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 git
    +
    +import (
    +	"context"
    +	"encoding/base64"
    +	"os/exec"
    +	"reflect"
    +	"testing"
    +)
    +
    +func TestClone(t *testing.T) {
    +	type testCase struct {
    +		url       string
    +		username  string
    +		password  string
    +		expectErr string
    +	}
    +
    +	testCases := map[string]testCase{
    +		"normal usage":           {url: "https://github.com/tektoncd/pipeline"},
    +		"normal usage with .git": {url: "https://github.com/tektoncd/pipeline.git"},
    +		"private repository":     {url: "https://github.com/tektoncd/not-a-repository.git"},
    +		"with crendentials":      {url: "https://github.com/tektoncd/not-a-repository.git", username: "fake", password: "fake"},
    +	}
    +
    +	for name, test := range testCases {
    +		t.Run(name, func(t *testing.T) {
    +			executions := []*exec.Cmd{}
    +
    +			executor := func(ctx context.Context, name string, args ...string) *exec.Cmd {
    +				args = append([]string{name}, args...)
    +				// Run the command as `echo` args to avoid side effects
    +				cmd := exec.CommandContext(ctx, "echo", args...)
    +				executions = append(executions, cmd)
    +				return cmd
    +			}
    +
    +			mockCmdRemote := remote{url: test.url, username: test.username, password: test.password, cmdExecutor: executor}
    +			repo, cleanup, err := mockCmdRemote.clone(context.Background())
    +			defer cleanup()
    +			if test.expectErr != "" {
    +				if err.Error() != test.expectErr {
    +					t.Fatalf("Expected error %q but got %q", test.expectErr, err)
    +				}
    +			} else {
    +				if err != nil {
    +					t.Fatalf("Error cloning repository %q: %v", test.url, err)
    +				}
    +			}
    +
    +			expectedEnv := []string{"GIT_TERMINAL_PROMPT=false"}
    +			expectedCmd := []string{"git", "-C", repo.directory}
    +			if test.username != "" {
    +				token := base64.URLEncoding.EncodeToString([]byte(test.username + ":" + test.password))
    +				expectedCmd = append(expectedCmd, "--config-env", "http.extraHeader=GIT_AUTH_HEADER")
    +				expectedEnv = append(expectedEnv, "GIT_AUTH_HEADER=Authorization=Basic "+token)
    +			}
    +			expectedCmd = append(expectedCmd, "clone", test.url, repo.directory, "--depth=1", "--no-checkout")
    +
    +			if len(executions) != 1 {
    +				t.Fatalf("Expected 1 command execution during cloning, got %d: %v", len(executions), executions)
    +			}
    +
    +			cmd := executions[0]
    +			// Remove the `echo` prefix
    +			cmdParts := cmd.Args[1:]
    +			if !reflect.DeepEqual(cmdParts, expectedCmd) {
    +				t.Fatalf("Expected clone command to be %v but got %v", expectedCmd, cmdParts)
    +			}
    +			if !reflect.DeepEqual(cmd.Env, expectedEnv) {
    +				t.Fatalf("Expected clone command env vars to be %v but got %v", expectedEnv, cmd.Env)
    +			}
    +		})
    +	}
    +}
    +
    +func TestCheckout(t *testing.T) {
    +	repoPath, revisions := createTestRepo(
    +		t,
    +		[]commitForRepo{
    +			{
    +				Filename: "README.md",
    +				Content:  "some content",
    +				Branch:   "non-main",
    +				Tag:      "1.0.0",
    +			},
    +			{
    +				Filename: "otherfile.yaml",
    +				Content:  "some data",
    +				Branch:   "to-be-deleted",
    +			},
    +		},
    +	)
    +	gitCmd := getGitCmd(t, repoPath)
    +	if err := gitCmd("checkout", "main").Run(); err != nil {
    +		t.Fatalf("cloud not checkout main branch after repo initialization: %v", err)
    +	}
    +	if err := gitCmd("branch", "-D", "to-be-deleted").Run(); err != nil {
    +		t.Fatalf("coun't delete branch to orphan commit: %v", err)
    +	}
    +
    +	ctx := context.Background()
    +
    +	type testCase struct {
    +		revision         string
    +		expectedRevision string
    +		expectErr        string
    +	}
    +	testCases := map[string]testCase{
    +		"revision is branch":          {revision: "non-main", expectedRevision: revisions[0]},
    +		"revision is tag":             {revision: "1.0.0", expectedRevision: revisions[0]},
    +		"revision is sha":             {revision: revisions[0], expectedRevision: revisions[0]},
    +		"revision is unreachable sha": {revision: revisions[1], expectedRevision: revisions[1]},
    +		"non-existent revision":       {revision: "fake-revision", expectErr: "git fetch error: fatal: couldn't find remote ref fake-revision: exit status 128"},
    +	}
    +
    +	for name, test := range testCases {
    +		t.Run(name, func(t *testing.T) {
    +			repo, cleanup, err := remote{url: repoPath}.clone(ctx)
    +			defer cleanup()
    +
    +			if err != nil {
    +				t.Fatalf("Error cloning repository %v", err)
    +			}
    +
    +			err = repo.checkout(ctx, test.revision)
    +			if test.expectErr != "" {
    +				if err == nil {
    +					t.Fatal("Expected error checking out revision but got none")
    +				} else if err.Error() != test.expectErr {
    +					t.Fatalf("Expected error %q but got %q", test.expectErr, err)
    +				}
    +				return
    +			} else if err != nil {
    +				t.Fatalf("Error checking out revision: %v", err)
    +			}
    +
    +			revision, err := repo.currentRevision(ctx)
    +			if err != nil {
    +				t.Fatal(err)
    +			}
    +			if revision != test.expectedRevision {
    +				t.Fatalf("Expected revision to be %q but got %q", test.expectedRevision, revision)
    +			}
    +		})
    +	}
    +}
    
  • pkg/resolution/resolver/git/resolver.go+19 59 modified
    @@ -17,23 +17,14 @@ limitations under the License.
     package git
     
     import (
    -	"bytes"
     	"context"
     	"errors"
     	"fmt"
    -	"io"
     	"os"
     	"regexp"
     	"strings"
     	"time"
     
    -	"github.com/go-git/go-billy/v5/memfs"
    -	"github.com/go-git/go-git/v5"
    -	gitcfg "github.com/go-git/go-git/v5/config"
    -	"github.com/go-git/go-git/v5/plumbing"
    -	plumbTransport "github.com/go-git/go-git/v5/plumbing/transport"
    -	"github.com/go-git/go-git/v5/plumbing/transport/http"
    -	"github.com/go-git/go-git/v5/storage/memory"
     	"github.com/jenkins-x/go-scm/scm"
     	"github.com/jenkins-x/go-scm/scm/factory"
     	resolverconfig "github.com/tektoncd/pipeline/pkg/apis/config/resolver"
    @@ -180,8 +171,8 @@ func (g *GitResolver) ResolveGitClone(ctx context.Context) (framework.ResolvedRe
     	if err != nil {
     		return nil, err
     	}
    -	repo := g.Params[UrlParam]
    -	if repo == "" {
    +	repoURL := g.Params[UrlParam]
    +	if repoURL == "" {
     		urlString := conf.URL
     		if urlString == "" {
     			return nil, errors.New("default Git Repo Url was not set during installation of the git resolver")
    @@ -195,9 +186,8 @@ func (g *GitResolver) ResolveGitClone(ctx context.Context) (framework.ResolvedRe
     		}
     	}
     
    -	cloneOpts := &git.CloneOptions{
    -		URL: repo,
    -	}
    +	var username string
    +	var password string
     
     	secretRef := &secretCacheKey{
     		name: g.Params[GitTokenParam],
    @@ -212,73 +202,43 @@ func (g *GitResolver) ResolveGitClone(ctx context.Context) (framework.ResolvedRe
     		secretRef = nil
     	}
     
    -	auth := plumbTransport.AuthMethod(nil)
     	if secretRef != nil {
     		gitToken, err := g.getAPIToken(ctx, secretRef, GitTokenKeyParam)
     		if err != nil {
     			return nil, err
     		}
    -		auth = &http.BasicAuth{
    -			Username: "git",
    -			Password: string(gitToken),
    -		}
    -		cloneOpts.Auth = auth
    -	}
    -
    -	filesystem := memfs.New()
    -	repository, err := git.Clone(memory.NewStorage(), filesystem, cloneOpts)
    -	if err != nil {
    -		return nil, fmt.Errorf("clone error: %w", err)
    +		username = "git"
    +		password = string(gitToken)
     	}
     
    -	// try fetch the branch when the given revision refers to a branch name
    -	refSpec := gitcfg.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s", revision, revision))
    -	err = repository.Fetch(&git.FetchOptions{
    -		RefSpecs: []gitcfg.RefSpec{refSpec},
    -		Auth:     auth,
    -	})
    -	if err != nil {
    -		var fetchErr git.NoMatchingRefSpecError
    -		if !errors.As(err, &fetchErr) {
    -			return nil, fmt.Errorf("unexpected fetch error: %w", err)
    -		}
    -	}
    +	path := g.Params[PathParam]
     
    -	w, err := repository.Worktree()
    +	repo, cleanupFunc, err := remote{url: repoURL, username: username, password: password}.clone(ctx)
    +	defer cleanupFunc()
     	if err != nil {
    -		return nil, fmt.Errorf("worktree error: %w", err)
    +		return nil, fmt.Errorf("error resolving repository: %w", err)
     	}
     
    -	h, err := repository.ResolveRevision(plumbing.Revision(revision))
    +	err = repo.checkout(ctx, revision)
     	if err != nil {
    -		return nil, fmt.Errorf("revision error: %w", err)
    +		return nil, err
     	}
     
    -	err = w.Checkout(&git.CheckoutOptions{
    -		Hash: *h,
    -	})
    +	fullRevision, err := repo.currentRevision(ctx)
     	if err != nil {
    -		return nil, fmt.Errorf("checkout error: %w", err)
    +		return nil, err
     	}
     
    -	path := g.Params[PathParam]
    -
    -	f, err := filesystem.Open(path)
    +	fileContents, err := repo.getFileContent(path)
     	if err != nil {
     		return nil, fmt.Errorf("error opening file %q: %w", path, err)
     	}
     
    -	buf := &bytes.Buffer{}
    -	_, err = io.Copy(buf, f)
    -	if err != nil {
    -		return nil, fmt.Errorf("error reading file %q: %w", path, err)
    -	}
    -
     	return &resolvedGitResource{
    -		Revision: h.String(),
    -		Content:  buf.Bytes(),
    -		URL:      g.Params[UrlParam],
    -		Path:     g.Params[PathParam],
    +		Revision: fullRevision,
    +		Content:  fileContents,
    +		URL:      repo.url,
    +		Path:     path,
     	}, nil
     }
     
    
  • pkg/resolution/resolver/git/resolver_test.go+2 130 modified
    @@ -26,9 +26,6 @@ import (
     	"testing"
     	"time"
     
    -	"github.com/go-git/go-git/v5"
    -	"github.com/go-git/go-git/v5/plumbing"
    -	"github.com/go-git/go-git/v5/plumbing/object"
     	"github.com/google/go-cmp/cmp"
     	"github.com/jenkins-x/go-scm/scm"
     	"github.com/jenkins-x/go-scm/scm/driver/fake"
    @@ -448,7 +445,7 @@ func TestResolve(t *testing.T) {
     			pathInRepo: "foo/new",
     			url:        anonFakeRepoURL,
     		},
    -		expectedErr: createError("revision error: reference not found"),
    +		expectedErr: createError("git fetch error: fatal: couldn't find remote ref non-existent-revision: exit status 128"),
     	}, {
     		name: "api: successful task from params api information",
     		args: &params{
    @@ -679,7 +676,7 @@ func TestResolve(t *testing.T) {
     			}
     			cfg[tc.configIdentifer+DefaultTimeoutKey] = "1m"
     			if cfg[tc.configIdentifer+DefaultRevisionKey] == "" {
    -				cfg[tc.configIdentifer+DefaultRevisionKey] = plumbing.Master.Short()
    +				cfg[tc.configIdentifer+DefaultRevisionKey] = "main"
     			}
     
     			request := createRequest(tc.args)
    @@ -772,131 +769,6 @@ func TestResolve(t *testing.T) {
     	}
     }
     
    -// createTestRepo is used to instantiate a local test repository with the desired commits.
    -func createTestRepo(t *testing.T, commits []commitForRepo) (string, []string) {
    -	t.Helper()
    -	commitSHAs := []string{}
    -
    -	t.Helper()
    -	tempDir := t.TempDir()
    -
    -	repo, err := git.PlainInit(tempDir, false)
    -	if err != nil {
    -		t.Fatalf("couldn't create test repo: %v", err)
    -	}
    -
    -	worktree, err := repo.Worktree()
    -	if err != nil {
    -		t.Fatalf("getting test worktree: %v", err)
    -	}
    -	if worktree == nil {
    -		t.Fatal("test worktree not created")
    -	}
    -
    -	startingHash := writeAndCommitToTestRepo(t, worktree, tempDir, "", "README", []byte("This is a test"))
    -
    -	hashesByBranch := make(map[string][]string)
    -
    -	// Iterate over the commits and add them.
    -	for _, cmt := range commits {
    -		branch := cmt.Branch
    -		if branch == "" {
    -			branch = plumbing.Master.Short()
    -		}
    -
    -		// If we're given a revision, check out that revision.
    -		coOpts := &git.CheckoutOptions{
    -			Branch: plumbing.NewBranchReferenceName(branch),
    -		}
    -
    -		if _, ok := hashesByBranch[branch]; !ok && branch != plumbing.Master.Short() {
    -			coOpts.Hash = plumbing.NewHash(startingHash.String())
    -			coOpts.Create = true
    -		}
    -
    -		if err := worktree.Checkout(coOpts); err != nil {
    -			t.Fatalf("couldn't do checkout of %s: %v", branch, err)
    -		}
    -
    -		hash := writeAndCommitToTestRepo(t, worktree, tempDir, cmt.Dir, cmt.Filename, []byte(cmt.Content))
    -		commitSHAs = append(commitSHAs, hash.String())
    -
    -		if _, ok := hashesByBranch[branch]; !ok {
    -			hashesByBranch[branch] = []string{hash.String()}
    -		} else {
    -			hashesByBranch[branch] = append(hashesByBranch[branch], hash.String())
    -		}
    -
    -		if cmt.Tag != "" {
    -			_, err = repo.CreateTag(cmt.Tag, hash, &git.CreateTagOptions{
    -				Message: cmt.Tag,
    -				Tagger: &object.Signature{
    -					Name:  "Someone",
    -					Email: "someone@example.com",
    -					When:  time.Now(),
    -				},
    -			})
    -		}
    -		if err != nil {
    -			t.Fatalf("couldn't add tag for %s: %v", cmt.Tag, err)
    -		}
    -	}
    -
    -	return tempDir, commitSHAs
    -}
    -
    -// commitForRepo provides the directory, filename, content and revision for a test commit.
    -type commitForRepo struct {
    -	Dir      string
    -	Filename string
    -	Content  string
    -	Branch   string
    -	Tag      string
    -}
    -
    -func writeAndCommitToTestRepo(t *testing.T, worktree *git.Worktree, repoDir string, subPath string, filename string, content []byte) plumbing.Hash {
    -	t.Helper()
    -
    -	targetDir := repoDir
    -	if subPath != "" {
    -		targetDir = filepath.Join(targetDir, subPath)
    -		fi, err := os.Stat(targetDir)
    -		if os.IsNotExist(err) {
    -			if err := os.MkdirAll(targetDir, 0o700); err != nil {
    -				t.Fatalf("couldn't create directory %s in worktree: %v", targetDir, err)
    -			}
    -		} else if err != nil {
    -			t.Fatalf("checking if directory %s in worktree exists: %v", targetDir, err)
    -		}
    -		if fi != nil && !fi.IsDir() {
    -			t.Fatalf("%s already exists but is not a directory", targetDir)
    -		}
    -	}
    -
    -	outfile := filepath.Join(targetDir, filename)
    -	if err := os.WriteFile(outfile, content, 0o600); err != nil {
    -		t.Fatalf("couldn't write content to file %s: %v", outfile, err)
    -	}
    -
    -	_, err := worktree.Add(filepath.Join(subPath, filename))
    -	if err != nil {
    -		t.Fatalf("couldn't add file %s to git: %v", outfile, err)
    -	}
    -
    -	hash, err := worktree.Commit("adding file for test", &git.CommitOptions{
    -		Author: &object.Signature{
    -			Name:  "Someone",
    -			Email: "someone@example.com",
    -			When:  time.Now(),
    -		},
    -	})
    -	if err != nil {
    -		t.Fatalf("couldn't perform commit for test: %v", err)
    -	}
    -
    -	return hash
    -}
    -
     // withTemporaryGitConfig resets the .gitconfig for the duration of the test.
     func withTemporaryGitConfig(t *testing.T) {
     	t.Helper()
    
  • pkg/resolution/resolver/git/shared_test.go+142 0 added
    @@ -0,0 +1,142 @@
    +/*
    +Copyright 2025 The Tekton 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 git
    +
    +import (
    +	"os"
    +	"os/exec"
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +
    +	_ "knative.dev/pkg/system/testing"
    +)
    +
    +const defaultBranch string = "main"
    +
    +func getGitCmd(t *testing.T, dir string) func(...string) *exec.Cmd {
    +	t.Helper()
    +	return func(args ...string) *exec.Cmd {
    +		args = append([]string{"-C", dir}, args...)
    +		return exec.Command("git", args...)
    +	}
    +}
    +
    +// createTestRepo is used to instantiate a local test repository with the desired commits.
    +func createTestRepo(t *testing.T, commits []commitForRepo) (string, []string) {
    +	t.Helper()
    +	commitSHAs := []string{}
    +
    +	tempDir := t.TempDir()
    +	gitCmd := getGitCmd(t, tempDir)
    +	err := gitCmd("init", "-b", "main").Run()
    +	if err != nil {
    +		t.Fatalf("couldn't create test repo: %v", err)
    +	}
    +
    +	writeAndCommitToTestRepo(t, tempDir, "", "README", []byte("This is a test"))
    +
    +	hashesByBranch := make(map[string][]string)
    +
    +	// Iterate over the commits and add them.
    +	for _, cmt := range commits {
    +		branch := cmt.Branch
    +		if branch == "" {
    +			branch = defaultBranch
    +		}
    +
    +		// If we're given a revision, check out that revision.
    +		checkoutCmd := gitCmd("checkout")
    +		if _, ok := hashesByBranch[branch]; !ok && branch != defaultBranch {
    +			checkoutCmd.Args = append(checkoutCmd.Args, "-b")
    +		}
    +		checkoutCmd.Args = append(checkoutCmd.Args, branch)
    +
    +		if err := checkoutCmd.Run(); err != nil {
    +			t.Fatalf("couldn't do checkout of %s: %v", branch, err)
    +		}
    +
    +		hash := writeAndCommitToTestRepo(t, tempDir, cmt.Dir, cmt.Filename, []byte(cmt.Content))
    +		commitSHAs = append(commitSHAs, hash)
    +
    +		if _, ok := hashesByBranch[branch]; !ok {
    +			hashesByBranch[branch] = []string{hash}
    +		} else {
    +			hashesByBranch[branch] = append(hashesByBranch[branch], hash)
    +		}
    +
    +		if cmt.Tag != "" {
    +			err = gitCmd("tag", cmt.Tag).Run()
    +			if err != nil {
    +				t.Fatalf("couldn't add tag for %s: %v", cmt.Tag, err)
    +			}
    +		}
    +	}
    +
    +	return tempDir, commitSHAs
    +}
    +
    +// commitForRepo provides the directory, filename, content and revision for a test commit.
    +type commitForRepo struct {
    +	Dir      string
    +	Filename string
    +	Content  string
    +	Branch   string
    +	Tag      string
    +}
    +
    +func writeAndCommitToTestRepo(t *testing.T, repoDir string, subPath string, filename string, content []byte) string {
    +	t.Helper()
    +
    +	gitCmd := getGitCmd(t, repoDir)
    +
    +	targetDir := repoDir
    +	if subPath != "" {
    +		targetDir = filepath.Join(targetDir, subPath)
    +		fi, err := os.Stat(targetDir)
    +		if os.IsNotExist(err) {
    +			if err := os.MkdirAll(targetDir, 0o700); err != nil {
    +				t.Fatalf("couldn't create directory %s in worktree: %v", targetDir, err)
    +			}
    +		} else if err != nil {
    +			t.Fatalf("checking if directory %s in worktree exists: %v", targetDir, err)
    +		}
    +		if fi != nil && !fi.IsDir() {
    +			t.Fatalf("%s already exists but is not a directory", targetDir)
    +		}
    +	}
    +
    +	outfile := filepath.Join(targetDir, filename)
    +	if err := os.WriteFile(outfile, content, 0o600); err != nil {
    +		t.Fatalf("couldn't write content to file %s: %v", outfile, err)
    +	}
    +
    +	if out, err := gitCmd("add", outfile).Output(); err != nil {
    +		t.Fatalf("couldn't add file %s to git: %q: %v", outfile, out, err)
    +	}
    +
    +	if out, err := gitCmd("commit", "-m", "adding file for test").Output(); err != nil {
    +		t.Fatalf("couldn't perform commit for test: %q: %v", string(out), err)
    +	}
    +
    +	out, err := gitCmd("rev-parse", "HEAD").Output()
    +	if err != nil {
    +		t.Fatalf("couldn't parse HEAD revision: %v", err)
    +	}
    +
    +	return strings.TrimSpace(string(out))
    +}
    
  • tekton/publish.yaml+2 1 modified
    @@ -29,7 +29,7 @@ spec:
           description: Username to be used to login to the container registry
           default: "_json_key"
         - name: releaseAsLatest
    -      description: Whether to tag and publish this release as Pipelines' latest
    +      description: Whether to tag and publish this release as Pipelines latest
           default: "true"
         - name: platforms
           description: Platforms to publish for the images (e.g. linux/amd64,linux/arm64)
    @@ -130,6 +130,7 @@ spec:
             $(params.package)/cmd/entrypoint: ${COMBINED_BASE_IMAGE}
             $(params.package)/cmd/nop: ${COMBINED_BASE_IMAGE}
             $(params.package)/cmd/workingdirinit: ${COMBINED_BASE_IMAGE}
    +        $(params.package)/cmd/resolvers: cgr.dev/chainguard/git@sha256:566235a8ef752f37d285042ee05fc37dbb04293e50f116a231984080fb835693
           EOF
     
           cat /workspace/.ko.yaml
    
  • test/presubmit-tests.sh+1 1 modified
    @@ -55,7 +55,7 @@ function ko_resolve() {
           github.com/tektoncd/pipeline/cmd/nop: ghcr.io/tektoncd/pipeline/github.com/tektoncd/pipeline/combined-base-image:latest
           github.com/tektoncd/pipeline/cmd/workingdirinit: ghcr.io/tektoncd/pipeline/github.com/tektoncd/pipeline/combined-base-image:latest
     
    -      github.com/tektoncd/pipeline/cmd/git-init: cgr.dev/chainguard/git
    +      github.com/tektoncd/pipeline/cmd/resolvers: cgr.dev/chainguard/git@sha256:566235a8ef752f37d285042ee05fc37dbb04293e50f116a231984080fb835693
     EOF
     
       KO_DOCKER_REPO=example.com ko resolve -l 'app.kubernetes.io/component!=resolvers' --platform=all --push=false -R -f config 1>/dev/null
    
  • test/resolvers_test.go+1 1 modified
    @@ -282,7 +282,7 @@ func TestGitResolver_Clone_Failure(t *testing.T) {
     		}, {
     			name:        "commit does not exist",
     			commit:      "abcd0123",
    -			expectedErr: "revision error: reference not found",
    +			expectedErr: "git fetch error: fatal: couldn't find remote ref abcd0123: exit status 128",
     		},
     	}
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

10

News mentions

50