Tekton Pipelines git resolver has path traversal that allows reading arbitrary files from the resolver pod
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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/tektoncd/pipelineGo | >= 1.0.0, < 1.0.1 | 1.0.1 |
github.com/tektoncd/pipelineGo | >= 1.1.0, < 1.3.3 | 1.3.3 |
github.com/tektoncd/pipelineGo | >= 1.4.0, < 1.6.1 | 1.6.1 |
github.com/tektoncd/pipelineGo | >= 1.7.0, < 1.9.2 | 1.9.2 |
github.com/tektoncd/pipelineGo | >= 1.10.0, < 1.10.2 | 1.10.2 |
Affected products
2Patches
73ca7bc6e6dd1fix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
10fa538f9a2bfix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
961388fcf337fix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
b1fee65b88aafix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
cdb4e1e97a4ffix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
ec7755031a18fix: prevent path traversal in git resolver pathInRepo parameter
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`, }, }
318006c4e3a5fix: resolve Git Anonymous Resolver excessive memory usage
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: ¶ms{
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: ¶ms{ @@ -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- github.com/advisories/GHSA-j5q5-j9gm-2w5cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33211ghsaADVISORY
- github.com/tektoncd/pipeline/commit/10fa538f9a2b6d01c75138f1ed7ba3da0e34687cghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/318006c4e3a5ghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/3ca7bc6e6dd1d97f80b84f78370d91edaf023cbdghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/961388fcf3374bc7656d28ab58ca84987e0a75aeghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/b1fee65b88aa969069c14c120045e97c37d9ee5eghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/cdb4e1e97a4f3170f9bc2cbfff83a6c8107bc3dbghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/commit/ec7755031a183b345cf9e64bea0e0505c1b9cb78ghsax_refsource_MISCWEB
- github.com/tektoncd/pipeline/security/advisories/GHSA-j5q5-j9gm-2w5cghsax_refsource_CONFIRMWEB
News mentions
50- GSK: The AI-Driven Science FactoryGovInfoSecurity · May 19, 2026
- America's top cyber-defense agency left a GitHub repo open with with passwords, keys, tokens – and incredibly obvious filenamesThe Register Security · May 19, 2026
- Trapdoor Android Ad Fraud Scheme Hit 659 Million Daily Bid Requests Using 455 AppsThe Hacker News · May 19, 2026
- Mini Shai-Hulud returns, compromising hundreds of npm packagesCyberScoop · May 19, 2026
- Key findings from the Verizon DBIR 2026: Slower vulnerability remediation meets faster exploitationTenable Blog · May 19, 2026
- Selector extends AI-driven observability into multi-cloud environmentsHelp Net Security · May 19, 2026
- Public Instagram posts provide raw material for AI phishing campaignsHelp Net Security · May 19, 2026
- TeamPCP Supply Chain Campaign: Activity Through 2026-05-17, (Mon, May 18th)SANS Internet Storm Center · May 18, 2026
- Shai-Hulud Worm Clones Spread After Code ReleaseDark Reading · May 18, 2026
- Fuel Tank Breaches Expand Scope of Iran's Cyber OffensiveDark Reading · May 18, 2026
- TanStack weighs invitation-only pull requests after supply chain attackThe Register Security · May 18, 2026
- ⚡ Weekly Recap: Exchange 0-Day, npm Worm, Fake AI Repo, Cisco Exploit and MoreThe Hacker News · May 18, 2026
- SHub Reaper | macOS Stealer Spoofs Apple, Google, and Microsoft in a Single Attack ChainSentinelOne Labs · May 18, 2026
- Boulevard of Broken Dreams: 2 Decades of Cyber FailsDark Reading · May 18, 2026
- Project Glasswing: what Mythos showed usCloudflare Blog · May 18, 2026
- Lyrie: Open-source autonomous pentesting agentHelp Net Security · May 18, 2026
- Week in review: Cisco patches SD-WAN 0-day, unpatched Microsoft Exchange Server flaw exploitedHelp Net Security · May 17, 2026
- Living Off the Pipeline: Defending Against CI/CD SubversionSentinelOne Labs · May 15, 2026
- Microsoft to automatically roll back faulty Windows driversBleepingComputer · May 15, 2026
- TanStack Supply Chain Attack Hits Two OpenAI Employee Devices, Forces macOS UpdatesThe Hacker News · May 15, 2026
- Frequently asked questions about the continued exploitation of Cisco Catalyst SD-WAN vulnerabilities (CVE-2026-20182)Tenable Blog · May 15, 2026
- Fragnesia (CVE-2026-46300): Frequently asked questions about new Linux Kernel XFRM ESP-in-TCP privilege escalationTenable Blog · May 14, 2026
- OpenAI confirms security breach in TanStack supply chain attackBleepingComputer · May 14, 2026
- Our billing pipeline was suddenly slow. The culprit was a hidden bottleneck in ClickHouseCloudflare Blog · May 14, 2026
- G7 Countries Release AI SBOM GuidanceSecurityWeek · May 14, 2026
- Microsoft’s WinUI agent plugin trims token use by over 70% during developmentHelp Net Security · May 14, 2026
- Vector embedding security gap exposes enterprise AI pipelinesHelp Net Security · May 14, 2026
- Welcome to the vulnpocalypse, as vendors use AI to find bugs and patches multiply like rabbitsThe Register Security · May 13, 2026
- Microsoft, Palo Alto Networks Find Many Vulnerabilities by Using AI on Their Own CodeSecurityWeek · May 13, 2026
- Microsoft's MDASH AI System Finds 16 Windows Flaws Fixed in Patch TuesdayThe Hacker News · May 13, 2026
- Thus Spoke…The GentlemenCheck Point Research · May 13, 2026
- [Webinar] How Modern Attack Paths Cross Code, Pipelines, and CloudThe Hacker News · May 13, 2026
- Sandyaa: Open-source autonomous security bug hunterHelp Net Security · May 13, 2026
- Fedora Hummingbird brings the container security model to a Linux host OSHelp Net Security · May 12, 2026
- Mini Shai-Hulud Hits TanStack npm PackagesInfosecurity Magazine · May 12, 2026
- Hugging Face Packages Weaponized With a Single File TweakDark Reading · May 12, 2026
- When "idle" isn't idle: how a Linux kernel optimization became a QUIC bugCloudflare Blog · May 12, 2026
- 20 Leaders Who Built the CISO Era: 2 Decades of ChangeDark Reading · May 12, 2026
- Mini Shai-Hulud Worm Compromises TanStack, Mistral AI, Guardrails AI & More PackagesThe Hacker News · May 12, 2026
- Shai Hulud attack ships signed malicious TanStack, Mistral npm packagesBleepingComputer · May 12, 2026
- TanStack, Mistral AI, UiPath Hit in Fresh Supply Chain AttackSecurityWeek · May 12, 2026
- HEIDI: Free IDE security plugin for open-source vulnerability checksHelp Net Security · May 12, 2026
- Official CheckMarx Jenkins package compromised with infostealerBleepingComputer · May 11, 2026
- Build Application Firewalls Aim to Stop the Next Supply Chain AttackSecurityWeek · May 11, 2026
- Checkmarx tackles another TeamPCP intrusion as Jenkins plugin sabotagedThe Register Security · May 11, 2026
- Checkmarx tackles another TeamPCP intrusion as Jenkins plugin sabotagedThe Register Security · May 11, 2026
- Week in review: cPanel vulnerability actively exploited, DigiCert breach, LinkedIn job scamsHelp Net Security · May 10, 2026
- Friday Squid Blogging: Giant Squid Live in the Waters of Western AustraliaSchneier on Security · May 8, 2026
- Dirty Frag (CVE-2026-43284, CVE-2026-43500): Frequently asked questions about this Linux kernel privilege escalation vulnerability chainTenable Blog · May 8, 2026
- Why the approaching flood of vulnerabilities changes everything — and what to do about itTenable Blog · May 8, 2026