From 3f753a9ae392ddd0f12428a2eb087aa8858b2b39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:36:14 +0000 Subject: [PATCH 1/3] fix: URL-path-encode owner/repo in REST API calls to prevent path injection Alerts #641 and #642 flagged that ownerRepo (derived from user-supplied repo input) was interpolated into GitHub REST API paths via fmt.Sprintf without URL-path encoding. A crafted repo string containing '..', '%2F', or other special characters could alter the API path. Fix: add escapeOwnerRepo() which applies url.PathEscape to each component of the 'owner/repo' pair before interpolation in ghAPIGet and ghAPIGetArray. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/outcome_eval.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/cli/outcome_eval.go b/pkg/cli/outcome_eval.go index d2af77bcd92..07a8bb0517b 100644 --- a/pkg/cli/outcome_eval.go +++ b/pkg/cli/outcome_eval.go @@ -3,6 +3,7 @@ package cli import ( "encoding/json" "fmt" + "net/url" "slices" "strings" "time" @@ -224,11 +225,21 @@ func ComputeOutcomeSummary(reports []OutcomeReport, mapping *github.ObjectiveMap return s } +// escapeOwnerRepo URL-path-encodes each component of an "owner/repo" string to +// prevent path traversal when the value is interpolated into an API URL. +func escapeOwnerRepo(ownerRepo string) string { + parts := strings.SplitN(ownerRepo, "/", 2) + if len(parts) == 2 { + return url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + } + return url.PathEscape(ownerRepo) +} + // ghAPIGet calls the GitHub REST API via gh cli and returns the parsed JSON. func ghAPIGet(endpoint string, repo string) (map[string]any, error) { ownerRepo, host := repoutil.NormalizeRepoForAPI(repo) outcomeEvalLog.Printf("gh api GET: repo=%s, endpoint=%s, host=%q", ownerRepo, endpoint, host) - args := []string{"api", fmt.Sprintf("repos/%s/%s", ownerRepo, endpoint)} + args := []string{"api", fmt.Sprintf("repos/%s/%s", escapeOwnerRepo(ownerRepo), endpoint)} var output []byte var err error if host != "" { @@ -250,7 +261,7 @@ func ghAPIGet(endpoint string, repo string) (map[string]any, error) { // ghAPIGetArray calls the GitHub REST API and returns a JSON array. func ghAPIGetArray(endpoint string, repo string) ([]map[string]any, error) { ownerRepo, host := repoutil.NormalizeRepoForAPI(repo) - args := []string{"api", fmt.Sprintf("repos/%s/%s", ownerRepo, endpoint)} + args := []string{"api", fmt.Sprintf("repos/%s/%s", escapeOwnerRepo(ownerRepo), endpoint)} var output []byte var err error if host != "" { From 60188f8d2fb7cbfca53fd5edeb4b86d8787e8a91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:35:06 +0000 Subject: [PATCH 2/3] Harden outcome_eval endpoint path handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/outcome_eval.go | 17 ++++++++++++++ pkg/cli/outcome_eval_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/pkg/cli/outcome_eval.go b/pkg/cli/outcome_eval.go index 07a8bb0517b..277b87eb2e7 100644 --- a/pkg/cli/outcome_eval.go +++ b/pkg/cli/outcome_eval.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "net/url" "slices" @@ -235,8 +236,21 @@ func escapeOwnerRepo(ownerRepo string) string { return url.PathEscape(ownerRepo) } +func validateAPIEndpoint(endpoint string) error { + if strings.HasPrefix(endpoint, "/") { + return errors.New("endpoint must not start with '/'") + } + if slices.Contains(strings.Split(endpoint, "/"), "..") { + return errors.New("endpoint must not contain '..' path segments") + } + return nil +} + // ghAPIGet calls the GitHub REST API via gh cli and returns the parsed JSON. func ghAPIGet(endpoint string, repo string) (map[string]any, error) { + if err := validateAPIEndpoint(endpoint); err != nil { + return nil, fmt.Errorf("invalid endpoint %q: %w", endpoint, err) + } ownerRepo, host := repoutil.NormalizeRepoForAPI(repo) outcomeEvalLog.Printf("gh api GET: repo=%s, endpoint=%s, host=%q", ownerRepo, endpoint, host) args := []string{"api", fmt.Sprintf("repos/%s/%s", escapeOwnerRepo(ownerRepo), endpoint)} @@ -260,6 +274,9 @@ func ghAPIGet(endpoint string, repo string) (map[string]any, error) { // ghAPIGetArray calls the GitHub REST API and returns a JSON array. func ghAPIGetArray(endpoint string, repo string) ([]map[string]any, error) { + if err := validateAPIEndpoint(endpoint); err != nil { + return nil, fmt.Errorf("invalid endpoint %q: %w", endpoint, err) + } ownerRepo, host := repoutil.NormalizeRepoForAPI(repo) args := []string{"api", fmt.Sprintf("repos/%s/%s", escapeOwnerRepo(ownerRepo), endpoint)} var output []byte diff --git a/pkg/cli/outcome_eval_test.go b/pkg/cli/outcome_eval_test.go index 4dd5d3fa2be..9d5a950451f 100644 --- a/pkg/cli/outcome_eval_test.go +++ b/pkg/cli/outcome_eval_test.go @@ -122,6 +122,49 @@ func TestNormalizeRepoForAPI(t *testing.T) { } } +func TestEscapeOwnerRepo(t *testing.T) { + tests := []struct { + name string + ownerRepo string + want string + }{ + {name: "normal owner/repo", ownerRepo: "github/gh-aw", want: "github/gh-aw"}, + {name: "traversal in repo segment", ownerRepo: "owner/../etc/passwd", want: "owner/..%2Fetc%2Fpasswd"}, + {name: "percent encoded slash is neutralized", ownerRepo: "owner/repo%2Ftraversal", want: "owner/repo%252Ftraversal"}, + {name: "no slash fallback", ownerRepo: "noSlash", want: "noSlash"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, escapeOwnerRepo(tt.ownerRepo)) + }) + } +} + +func TestValidateAPIEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + wantErr string + }{ + {name: "relative endpoint allowed", endpoint: "issues/comments/123"}, + {name: "leading slash rejected", endpoint: "/issues/comments/123", wantErr: "must not start"}, + {name: "dotdot segment rejected", endpoint: "issues/../comments/123", wantErr: "must not contain"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAPIEndpoint(tt.endpoint) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + func TestIsBotUser(t *testing.T) { assert.True(t, isBotUser("github-actions[bot]"), "github-actions[bot] is a bot") assert.True(t, isBotUser("github-actions"), "github-actions is a bot") From 7ea16ea78f52fd2c1f284a096babead6c94d1968 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:22:35 +0000 Subject: [PATCH 3/3] chore: start PR finisher triage Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/skills/agentic-workflows/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 72145f10bda..aee174f47cd 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -22,6 +22,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/charts-trending.md` - `.github/aw/charts.md` - `.github/aw/cli-commands.md` +- `.github/aw/configure-agentic-engine.md` - `.github/aw/context.md` - `.github/aw/create-agentic-workflow-trigger-details.md` - `.github/aw/create-agentic-workflow.md`