Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/example-failure-category-filter.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pkg/workflow/notify_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,14 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
if len(data.SafeOutputs.ReportFailureAsIssueCategories) > 0 {
categoriesJSON, err := json.Marshal(data.SafeOutputs.ReportFailureAsIssueCategories)
if err == nil {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_FAILURE_CATEGORIES_FILTER: '%s'\n", categoriesJSON))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_FAILURE_CATEGORIES_FILTER: %q\n", string(categoriesJSON)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No regression test covers this security fix — a category name containing a single-quote (e.g. "it's-a-category") was the exact exploit vector, so it should be the first thing a test covers.

💡 Suggested test outline
func TestConclusionJobCategoriesFilterQuoting(t *testing.T) {
    compiler := NewCompiler()
    workflowData := &WorkflowData{
        Name: "Test Workflow",
        SafeOutputs: &SafeOutputsConfig{
            NoOp: &NoOpConfig{},
            ReportFailureAsIssue: true,
            ReportFailureAsIssueCategories: []string{"it's-a-category"},
        },
    }
    job, err := compiler.buildConclusionJob(workflowData, string(constants.AgentJobName), []string{})
    require.NoError(t, err)
    require.NotNil(t, job)

    jobYAML := strings.Join(job.Steps, "")
    // Single-quote must not break YAML; value must be double-quoted
    assert.Contains(t, jobYAML, `GH_AW_FAILURE_CATEGORIES_FILTER: "[\"it's-a-category\"]"`,
        "category name with single-quote must produce valid double-quoted YAML scalar")
}

Add a parallel case for GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER (line 414). The existing TestReportFailureAsIssueWithCategoriesFilter in compiler_safe_outputs_config_test.go covers config parsing but not YAML emission — this gap is what leaves the fix unguarded.

}
}
// If excluded categories filter is configured, pass it as JSON
if len(data.SafeOutputs.ReportFailureAsIssueExcludedCategories) > 0 {
excludedCategoriesJSON, err := json.Marshal(data.SafeOutputs.ReportFailureAsIssueExcludedCategories)
if err == nil {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER: '%s'\n", excludedCategoriesJSON))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER: %q\n", string(excludedCategoriesJSON)))
}
}
}
Expand Down
64 changes: 64 additions & 0 deletions pkg/workflow/notify_comment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,70 @@ func TestConclusionJobNonWorkflowCallNoArtifactPrefix(t *testing.T) {
}
}

// TestConclusionJobCategoriesFilterQuoting verifies that category names containing
// special characters (particularly single quotes) are safely embedded as double-quoted
// YAML scalars, preventing YAML injection via the GH_AW_FAILURE_CATEGORIES_FILTER and
// GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER env vars.
func TestConclusionJobCategoriesFilterQuoting(t *testing.T) {
t.Run("included categories with single quote", func(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
Name: "Test Workflow",
SafeOutputs: &SafeOutputsConfig{
NoOp: &NoOpConfig{},
ReportFailureAsIssue: true,
ReportFailureAsIssueCategories: []string{"it's-a-category", "normal-category"},
},
}

job, err := compiler.buildConclusionJob(workflowData, string(constants.AgentJobName), []string{})
if err != nil {
t.Fatalf("Failed to build conclusion job: %v", err)
}
if job == nil {
t.Fatal("Expected conclusion job to be created")
}

jobYAML := strings.Join(job.Steps, "")
// Must use double-quoted YAML scalar (produced by %q), not single-quoted
if !strings.Contains(jobYAML, `GH_AW_FAILURE_CATEGORIES_FILTER: "[\"it's-a-category\",\"normal-category\"]"`) {
t.Errorf("Expected GH_AW_FAILURE_CATEGORIES_FILTER to be a safe double-quoted YAML scalar.\nGenerated YAML:\n%s", jobYAML)
}
if strings.Contains(jobYAML, "GH_AW_FAILURE_CATEGORIES_FILTER: '") {
t.Error("GH_AW_FAILURE_CATEGORIES_FILTER must not use single-quoted YAML scalar (injection risk)")
}
})

t.Run("excluded categories with single quote", func(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
Name: "Test Workflow",
SafeOutputs: &SafeOutputsConfig{
NoOp: &NoOpConfig{},
ReportFailureAsIssue: true,
ReportFailureAsIssueExcludedCategories: []string{"it's-excluded", "other-excluded"},
},
}

job, err := compiler.buildConclusionJob(workflowData, string(constants.AgentJobName), []string{})
if err != nil {
t.Fatalf("Failed to build conclusion job: %v", err)
}
if job == nil {
t.Fatal("Expected conclusion job to be created")
}

jobYAML := strings.Join(job.Steps, "")
// Must use double-quoted YAML scalar (produced by %q), not single-quoted
if !strings.Contains(jobYAML, `GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER: "[\"it's-excluded\",\"other-excluded\"]"`) {
t.Errorf("Expected GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER to be a safe double-quoted YAML scalar.\nGenerated YAML:\n%s", jobYAML)
}
if strings.Contains(jobYAML, "GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER: '") {
t.Error("GH_AW_FAILURE_EXCLUDED_CATEGORIES_FILTER must not use single-quoted YAML scalar (injection risk)")
}
})
}

func TestConclusionJobIncludesUsageArtifactSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
Expand Down
Loading