From 35cca24ea2e929a3b0e22253a59f59309d8a82ce Mon Sep 17 00:00:00 2001 From: RossTarrant Date: Mon, 27 Jul 2026 09:55:57 +0100 Subject: [PATCH] centralise sanitisation for inputs and outputs --- pkg/github/__toolsnaps__/search_code.snap | 3 + .../search_code_ff_fields_param.snap | 3 + pkg/github/actions.go | 16 +- pkg/github/actions_test.go | 125 ++- pkg/github/code_scanning.go | 10 +- pkg/github/code_scanning_test.go | 22 + pkg/github/dependabot.go | 10 +- pkg/github/dependabot_test.go | 22 + pkg/github/discussions.go | 107 +- pkg/github/discussions_test.go | 4 +- pkg/github/input_policy.go | 56 ++ pkg/github/issues.go | 28 +- pkg/github/issues_test.go | 82 +- pkg/github/labels.go | 14 +- pkg/github/labels_test.go | 38 +- pkg/github/minimal_types.go | 172 ++-- pkg/github/output_sanitization.go | 656 +++++++++++++ pkg/github/projects.go | 10 +- pkg/github/projects_test.go | 57 +- pkg/github/projects_v2_test.go | 8 +- pkg/github/pullrequests.go | 36 +- pkg/github/pullrequests_test.go | 75 +- pkg/github/repositories.go | 132 ++- pkg/github/repositories_test.go | 67 +- pkg/github/sanitization_baseline_test.go | 924 ++++++++++++++++++ pkg/github/search.go | 55 +- pkg/github/search_test.go | 80 +- pkg/github/search_utils.go | 29 +- pkg/github/secret_scanning.go | 10 +- pkg/github/secret_scanning_test.go | 22 + pkg/github/security_advisories.go | 43 +- pkg/github/security_advisories_test.go | 110 ++- 32 files changed, 2609 insertions(+), 417 deletions(-) create mode 100644 pkg/github/input_policy.go create mode 100644 pkg/github/output_sanitization.go create mode 100644 pkg/github/sanitization_baseline_test.go diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index 313c2f4c5f..f6cce354d5 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -32,6 +32,9 @@ }, "sort": { "description": "Sort field ('indexed' only)", + "enum": [ + "indexed" + ], "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap b/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap index 00d4686712..eafec0a6b7 100644 --- a/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap +++ b/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap @@ -46,6 +46,9 @@ }, "sort": { "description": "Sort field ('indexed' only)", + "enum": [ + "indexed" + ], "type": "string" } }, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c16efa0f18..598039e2c3 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -84,7 +84,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo // Continue with other jobs even if one fails jobResult = map[string]any{ "job_id": job.GetID(), - "job_name": job.GetName(), + "job_name": sanitizeOutputText(job.GetName()), "error": err.Error(), } // Enable reporting of status codes and error causes @@ -139,7 +139,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin "job_id": jobID, } if jobName != "" { - result["job_name"] = jobName + result["job_name"] = sanitizeOutputText(jobName) } if returnContent { @@ -788,7 +788,7 @@ func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resour } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflow) + r, err := json.Marshal(sanitizedWorkflowCopy(workflow)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) } @@ -802,7 +802,7 @@ func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo stri return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowRun) + r, err := json.Marshal(sanitizedWorkflowRunCopy(workflowRun)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) } @@ -815,7 +815,7 @@ func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo stri return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowJob) + r, err := json.Marshal(sanitizedWorkflowJobCopy(workflowJob)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) } @@ -834,7 +834,7 @@ func listWorkflows(ctx context.Context, client *github.Client, owner, repo strin } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflows) + r, err := json.Marshal(sanitizedWorkflowsCopy(workflows)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) } @@ -884,7 +884,7 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowRuns) + r, err := json.Marshal(sanitizedWorkflowRunsCopy(workflowRuns)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) } @@ -919,7 +919,7 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin } response := map[string]any{ - "jobs": workflowJobs, + "jobs": sanitizedWorkflowJobsCopy(workflowJobs), } defer func() { _ = resp.Body.Close() }() diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 4ed9c87d69..1a03a1a966 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -114,22 +114,66 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { } } +func unsafeWorkflowRunFixture() *github.WorkflowRun { + return &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr(baselineUnsafeText), + DisplayTitle: github.Ptr(baselineUnsafeText), + HeadBranch: github.Ptr("feature/exactkeep\u200B ```go onclick=alert(1)\nfmt.Println(\"x\")\n```" + +func TestSanitizationIssueOutputPaths(t *testing.T) { + t.Run("rest get issue sanitizes title and body", func(t *testing.T) { + mockIssue := &gogithub.Issue{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr(baselineUnsafeText), + Body: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"), + User: &gogithub.User{Login: gogithub.Ptr("author")}, + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + })) + deps := BaseDeps{ + Client: client, + GQLClient: defaultGQLClient, + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + serverTool := IssueRead(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + result, err := handler(ContextWithDeps(context.Background(), deps), &mcp.CallToolRequest{ + Params: createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }).Params, + }) + + require.NoError(t, err) + require.False(t, result.IsError) + var issue MinimalIssue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &issue)) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue.Title) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue.Body) + assert.Equal(t, baselineUnsafeText, mockIssue.GetTitle()) + assert.Equal(t, baselineUnsafeText, mockIssue.GetBody()) + }) + + t.Run("rest search issue sanitizes embedded display fields without mutating source object", func(t *testing.T) { + sourceIssue := &gogithub.Issue{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr(baselineUnsafeText), + Body: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + Labels: []*gogithub.Label{{ + Name: gogithub.Ptr(baselineUnsafeText), + Description: gogithub.Ptr(baselineUnsafeText), + }}, + Milestone: &gogithub.Milestone{ + Title: gogithub.Ptr(baselineUnsafeText), + Description: gogithub.Ptr(baselineUnsafeText), + }, + } + searchHit := SearchIssueResult{Issue: sourceIssue} + + raw, err := json.Marshal(searchHit) + require.NoError(t, err) + var issue map[string]any + require.NoError(t, json.Unmarshal(raw, &issue)) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue["title"]) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue["body"]) + labels := issue["labels"].([]any) + label := labels[0].(map[string]any) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), label["name"]) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), label["description"]) + milestone := issue["milestone"].(map[string]any) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), milestone["title"]) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), milestone["description"]) + assert.Equal(t, baselineUnsafeText, sourceIssue.GetTitle()) + assert.Equal(t, baselineUnsafeText, sourceIssue.GetBody()) + assert.Equal(t, baselineUnsafeText, sourceIssue.Labels[0].GetName()) + assert.Equal(t, baselineUnsafeText, sourceIssue.Milestone.GetTitle()) + }) + + t.Run("graphql issue fragment conversion sanitizes title and body", func(t *testing.T) { + now := githubv4.DateTime{Time: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)} + fragment := IssueFragment{ + Number: githubv4.Int(42), + Title: githubv4.String(baselineUnsafeText), + Body: githubv4.String(baselineUnsafeText), + State: githubv4.String("OPEN"), + CreatedAt: now, + UpdatedAt: now, + } + fragment.Author.Login = githubv4.String("author") + fragment.Labels.Nodes = append(fragment.Labels.Nodes, struct { + Name githubv4.String + ID githubv4.String + Description githubv4.String + }{Name: githubv4.String(baselineUnsafeText)}) + + issue := fragmentToMinimalIssue(fragment) + + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue.Title) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), issue.Body) + assert.Equal(t, []string{sanitize.Sanitize(baselineUnsafeText)}, issue.Labels) + }) +} + +func TestSanitizationPullRequestOutputPaths(t *testing.T) { + t.Run("rest get pull request sanitizes title and body without mutating source object", func(t *testing.T) { + mockPR := &gogithub.PullRequest{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr(baselineUnsafeText), + Body: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/pull/42"), + User: &gogithub.User{Login: gogithub.Ptr("author")}, + Labels: []*gogithub.Label{{ + Name: gogithub.Ptr(baselineUnsafeText), + }}, + Milestone: &gogithub.Milestone{ + Title: gogithub.Ptr(baselineUnsafeText), + }, + Head: &gogithub.PullRequestBranch{ + Ref: gogithub.Ptr("feature"), + SHA: gogithub.Ptr("abc123"), + Repo: &gogithub.Repository{ + FullName: gogithub.Ptr("owner/repo"), + Description: gogithub.Ptr(baselineUnsafeText), + }, + }, + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + })) + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + serverTool := PullRequestRead(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + result, err := handler(ContextWithDeps(context.Background(), deps), &mcp.CallToolRequest{ + Params: createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }).Params, + }) + + require.NoError(t, err) + require.False(t, result.IsError) + var pr MinimalPullRequest + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &pr)) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), pr.Title) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), pr.Body) + assert.Equal(t, []string{sanitize.Sanitize(baselineUnsafeText)}, pr.Labels) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), pr.Milestone) + require.NotNil(t, pr.Head) + require.NotNil(t, pr.Head.Repo) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), pr.Head.Repo.Description) + assert.Equal(t, baselineUnsafeText, mockPR.GetTitle()) + assert.Equal(t, baselineUnsafeText, mockPR.GetBody()) + assert.Equal(t, baselineUnsafeText, mockPR.Labels[0].GetName()) + assert.Equal(t, baselineUnsafeText, mockPR.Milestone.GetTitle()) + assert.Equal(t, baselineUnsafeText, mockPR.Head.Repo.GetDescription()) + }) + + t.Run("rest list pull requests sanitizes title and body without mutating source objects", func(t *testing.T) { + mockPRs := []*gogithub.PullRequest{ + { + Number: gogithub.Ptr(42), + Title: gogithub.Ptr(baselineUnsafeText), + Body: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/pull/42"), + }, + { + Number: gogithub.Ptr(43), + Title: gogithub.Ptr("safe title"), + Body: gogithub.Ptr("safe body"), + State: gogithub.Ptr("closed"), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/pull/43"), + }, + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "state": "all", + "sort": "created", + "direction": "desc", + "per_page": "30", + "page": "1", + }).andThen(mockResponse(t, http.StatusOK, mockPRs)), + })) + deps := BaseDeps{Client: client} + serverTool := ListPullRequests(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + result, err := handler(ContextWithDeps(context.Background(), deps), &mcp.CallToolRequest{ + Params: createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "state": "all", + "sort": "created", + "direction": "desc", + "perPage": float64(30), + "page": float64(1), + }).Params, + }) + + require.NoError(t, err) + require.False(t, result.IsError) + var prs []MinimalPullRequest + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &prs)) + require.Len(t, prs, 2) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), prs[0].Title) + assert.Equal(t, sanitize.Sanitize(baselineUnsafeText), prs[0].Body) + assert.Equal(t, "safe title", prs[1].Title) + assert.Equal(t, "safe body", prs[1].Body) + assert.Equal(t, baselineUnsafeText, mockPRs[0].GetTitle()) + assert.Equal(t, baselineUnsafeText, mockPRs[0].GetBody()) + }) +} + +func TestSanitizationCollaborationTextPaths(t *testing.T) { + sanitizedUnsafeText := sanitize.Sanitize(baselineUnsafeText) + + comment := convertToMinimalIssueComment(&gogithub.IssueComment{ + ID: gogithub.Ptr(int64(1)), + Body: gogithub.Ptr(baselineUnsafeText), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/1#issuecomment-1"), + }) + assert.Equal(t, sanitizedUnsafeText, comment.Body) + + review := convertToMinimalPullRequestReview(&gogithub.PullRequestReview{ + ID: gogithub.Ptr(int64(2)), + State: gogithub.Ptr("COMMENTED"), + Body: gogithub.Ptr(baselineUnsafeText), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo/pull/1#pullrequestreview-2"), + }) + assert.Equal(t, sanitizedUnsafeText, review.Body) + + reviewURL, err := url.Parse("https://github.com/owner/repo/pull/1#discussion_r1") + require.NoError(t, err) + reviewComment := convertToMinimalReviewComment(reviewCommentNode{ + Body: baselineUnsafeText, + Path: "README.md", + URL: githubv4.URI{URL: reviewURL}, + }) + assert.Equal(t, sanitizedUnsafeText, reviewComment.Body) + assert.Equal(t, "README.md", reviewComment.Path) + + discussion := fragmentToDiscussion(NodeFragment{ + Number: githubv4.Int(1), + Title: githubv4.String(baselineUnsafeText), + URL: githubv4.String("https://github.com/owner/repo/discussions/1"), + CreatedAt: githubv4.DateTime{Time: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)}, + UpdatedAt: githubv4.DateTime{Time: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)}, + }) + assert.Equal(t, sanitizedUnsafeText, discussion.GetTitle()) + + detail := discussionDetailFragment{ + Number: githubv4.Int(1), + Title: githubv4.String(baselineUnsafeText), + Body: githubv4.String(baselineUnsafeText), + URL: githubv4.String("https://github.com/owner/repo/discussions/1"), + CreatedAt: githubv4.DateTime{Time: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)}, + } + detail.Category.Name = githubv4.String(baselineUnsafeText) + detailResponse := discussionDetailResponse(detail) + assert.Equal(t, sanitizedUnsafeText, detailResponse["title"]) + assert.Equal(t, sanitizedUnsafeText, detailResponse["body"]) + assert.Equal(t, sanitizedUnsafeText, detailResponse["category"].(map[string]any)["name"]) + + discussionComment := convertToMinimalDiscussionComment(githubv4.ID("DC_1"), githubv4.String(baselineUnsafeText), githubv4.Boolean(true)) + assert.Equal(t, sanitizedUnsafeText, discussionComment.Body) +} + +func TestSanitizationProjectDisplayTextPaths(t *testing.T) { + sanitizedUnsafeText := sanitize.Sanitize(baselineUnsafeText) + + project := convertToMinimalProject(&gogithub.ProjectV2{ + Title: gogithub.Ptr(baselineUnsafeText), + Description: gogithub.Ptr(baselineUnsafeText), + ShortDescription: gogithub.Ptr(baselineUnsafeText), + }) + require.NotNil(t, project) + require.NotNil(t, project.Title) + require.NotNil(t, project.Description) + require.NotNil(t, project.ShortDescription) + assert.Equal(t, sanitizedUnsafeText, *project.Title) + assert.Equal(t, sanitizedUnsafeText, *project.Description) + assert.Equal(t, sanitizedUnsafeText, *project.ShortDescription) + + content := convertIssueToMinimalProjectItemContent(&gogithub.Issue{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + Labels: []*gogithub.Label{{Name: gogithub.Ptr(baselineUnsafeText)}}, + }) + require.NotNil(t, content) + assert.Equal(t, sanitizedUnsafeText, content.Title) + assert.Equal(t, []string{sanitizedUnsafeText}, content.Labels) + + prContent := convertPullRequestToMinimalProjectItemContent(&gogithub.PullRequest{ + Number: gogithub.Ptr(43), + Title: gogithub.Ptr(baselineUnsafeText), + State: gogithub.Ptr("open"), + Labels: []*gogithub.Label{{Name: gogithub.Ptr(baselineUnsafeText)}}, + }) + require.NotNil(t, prContent) + assert.Equal(t, sanitizedUnsafeText, prContent.Title) + assert.Equal(t, []string{sanitizedUnsafeText}, prContent.Labels) + + draftIssueContent := convertDraftIssueToMinimalProjectItemContent(&gogithub.ProjectV2DraftIssue{ + Title: gogithub.Ptr(baselineUnsafeText), + }) + require.NotNil(t, draftIssueContent) + assert.Equal(t, sanitizedUnsafeText, draftIssueContent.Title) + + fields := convertToMinimalProjectItemFields([]*gogithub.ProjectV2ItemFieldValue{ + { + ID: gogithub.Ptr(int64(1)), + Name: gogithub.Ptr(baselineUnsafeText), + DataType: gogithub.Ptr("text"), + Value: baselineUnsafeText, + }, + }) + require.Len(t, fields, 1) + assert.Equal(t, sanitizedUnsafeText, fields[0].Name) + assert.Equal(t, sanitizedUnsafeText, fields[0].Value) + + option := minimalProjectFieldValue(&gogithub.ProjectV2FieldOption{ + ID: gogithub.Ptr("option-id"), + Name: &gogithub.ProjectV2TextContent{Raw: gogithub.Ptr(baselineUnsafeText)}, + }) + assert.Equal(t, minimalProjectOptionValue{ID: "option-id", Name: sanitizedUnsafeText}, option) + + body := githubv4.String(baselineUnsafeText) + status := githubv4.String("ON_TRACK") + statusUpdate := convertToMinimalStatusUpdate(statusUpdateNode{ + ID: githubv4.ID("SU_1"), + Body: &body, + Status: &status, + CreatedAt: githubv4.DateTime{Time: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)}, + }) + assert.Equal(t, sanitizedUnsafeText, statusUpdate.Body) + assert.Equal(t, "ON_TRACK", statusUpdate.Status) +} + +func TestSanitizeCommitMessage(t *testing.T) { + t.Run("preserves valid trailer addresses while sanitizing the message", func(t *testing.T) { + message := "Subject\n\n" + + "Co-authored-by: Copilot \n" + + "Signed-off-by: \"Copilot App\" <223556219+Copilot@users.noreply.github.com>" + + assert.Equal(t, "Subject\n\n"+ + "Co-authored-by: Copilot \n"+ + "Signed-off-by: "Copilot App" <223556219+Copilot@users.noreply.github.com>", + sanitizeCommitMessage(message)) + }) + + t.Run("does not restore address-like text outside a trailer", func(t *testing.T) { + message := "Contact Copilot " + assert.Equal(t, sanitizeOutputText(message), sanitizeCommitMessage(message)) + }) + + t.Run("does not restore a trailer address with trailing HTML", func(t *testing.T) { + message := "Co-authored-by: Copilot " + assert.Equal(t, sanitizeOutputText(message), sanitizeCommitMessage(message)) + }) + + t.Run("does not restore HTML smuggled through address syntax", func(t *testing.T) { + displayNameHTML := `Co-authored-by: ">" ` + sanitized := sanitizeCommitMessage(displayNameHTML) + assert.NotContains(t, sanitized, "onerror") + assert.NotContains(t, sanitized, "alert(1)") + assert.Contains(t, sanitized, "") + + addressHTML := `Co-authored-by: Copilot <""@example.com>` + assert.Equal(t, sanitizeOutputText(addressHTML), sanitizeCommitMessage(addressHTML)) + }) + + t.Run("avoids collisions with placeholder-like message text", func(t *testing.T) { + message := "GITHUBMCPCOMMITTRAILEREMAIL0PLACEHOLDER\n\nCo-authored-by: Copilot " + assert.Equal(t, message, sanitizeCommitMessage(message)) + }) +} + +func TestSanitizationOutputBypassesAndOutliers(t *testing.T) { + sanitizedUnsafeText := sanitize.Sanitize(baselineUnsafeText) + + repo := &gogithub.Repository{ + ID: gogithub.Ptr(int64(1)), + Name: gogithub.Ptr("repo"), + FullName: gogithub.Ptr("owner/repo"), + Description: gogithub.Ptr(baselineUnsafeText), + HTMLURL: gogithub.Ptr("https://github.com/owner/repo"), + } + minimalRepo := convertToMinimalRepository(repo) + assert.Equal(t, sanitizedUnsafeText, minimalRepo.Description) + assert.Equal(t, baselineUnsafeText, repo.GetDescription()) + + searchResult := sanitizedRepositoriesSearchResultCopy(&gogithub.RepositoriesSearchResult{ + Repositories: []*gogithub.Repository{repo}, + }) + require.Len(t, searchResult.Repositories, 1) + assert.Equal(t, sanitizedUnsafeText, searchResult.Repositories[0].GetDescription()) + assert.Equal(t, baselineUnsafeText, repo.GetDescription()) + + release := &gogithub.RepositoryRelease{ + Name: gogithub.Ptr(baselineUnsafeText), + Body: gogithub.Ptr(baselineUnsafeText), + } + minimalRelease := convertToMinimalRelease(release) + assert.Equal(t, sanitizedUnsafeText, minimalRelease.Name) + assert.Equal(t, sanitizedUnsafeText, minimalRelease.Body) + sanitizedRelease := sanitizedReleaseCopy(release) + assert.Equal(t, sanitizedUnsafeText, sanitizedRelease.GetName()) + assert.Equal(t, sanitizedUnsafeText, sanitizedRelease.GetBody()) + assert.Equal(t, baselineUnsafeText, release.GetName()) + assert.Equal(t, baselineUnsafeText, release.GetBody()) + + securityAdvisory := &gogithub.SecurityAdvisory{ + Summary: gogithub.Ptr(baselineUnsafeText), + Description: gogithub.Ptr(baselineUnsafeText), + CollaboratingTeams: []*gogithub.Team{{ + Name: gogithub.Ptr(baselineUnsafeText), + Description: gogithub.Ptr(baselineUnsafeText), + Slug: gogithub.Ptr("team\u200B