diff --git a/pkg/workflow/apm_dependencies.go b/pkg/workflow/apm_dependencies.go index ab80004a5da..0122831fc4a 100644 --- a/pkg/workflow/apm_dependencies.go +++ b/pkg/workflow/apm_dependencies.go @@ -76,6 +76,28 @@ func buildAPMAppTokenMintStep(app *GitHubAppConfig, fallbackRepoExpr string) []s return steps } +// buildAPMAppTokenInvalidationStep generates the step to invalidate the GitHub App token +// that was minted for APM cross-org repository access. This step always runs (even on failure) +// to ensure the token is properly cleaned up after the APM pack step completes. +func buildAPMAppTokenInvalidationStep() []string { + var steps []string + + steps = append(steps, " - name: Invalidate GitHub App token for APM\n") + steps = append(steps, fmt.Sprintf(" if: always() && steps.%s.outputs.token != ''\n", apmAppTokenStepID)) + steps = append(steps, " env:\n") + steps = append(steps, fmt.Sprintf(" TOKEN: ${{ steps.%s.outputs.token }}\n", apmAppTokenStepID)) + steps = append(steps, " run: |\n") + steps = append(steps, " echo \"Revoking GitHub App installation token for APM...\"\n") + steps = append(steps, " # GitHub CLI will auth with the token being revoked.\n") + steps = append(steps, " gh api \\\n") + steps = append(steps, " --method DELETE \\\n") + steps = append(steps, " -H \"Authorization: token $TOKEN\" \\\n") + steps = append(steps, " /installation/token || echo \"Token revocation failed (token may be expired or invalid).\"\n") + steps = append(steps, " echo \"Token invalidation step complete.\"\n") + + return steps +} + // GenerateAPMPackStep generates the GitHub Actions step that installs APM packages and // packs them into a bundle in the activation job. The step always uses isolated:true because // the activation job has no repo context to preserve. diff --git a/pkg/workflow/apm_dependencies_test.go b/pkg/workflow/apm_dependencies_test.go index 4cd81668ee0..d7430d82d6d 100644 --- a/pkg/workflow/apm_dependencies_test.go +++ b/pkg/workflow/apm_dependencies_test.go @@ -3,6 +3,7 @@ package workflow import ( + "fmt" "strings" "testing" @@ -537,3 +538,24 @@ func TestBuildAPMAppTokenMintStep(t *testing.T) { assert.Contains(t, combined, "repositories: ${{ github.event.repository.name }}", "Should default to event repository name") }) } + +func TestBuildAPMAppTokenInvalidationStep(t *testing.T) { + t.Run("Invalidation step targets apm-app-token step ID", func(t *testing.T) { + steps := buildAPMAppTokenInvalidationStep() + + combined := strings.Join(steps, "") + assert.Contains(t, combined, "Invalidate GitHub App token for APM", "Should have descriptive step name") + assert.Contains(t, combined, fmt.Sprintf("if: always() && steps.%s.outputs.token != ''", apmAppTokenStepID), "Should run always and check token exists") + assert.Contains(t, combined, fmt.Sprintf("TOKEN: ${{ steps.%s.outputs.token }}", apmAppTokenStepID), "Should reference apm-app-token step output") + assert.Contains(t, combined, "gh api", "Should call GitHub API to revoke token") + assert.Contains(t, combined, "--method DELETE", "Should use DELETE method to revoke token") + assert.Contains(t, combined, "/installation/token", "Should target installation token endpoint") + }) + + t.Run("Invalidation step uses always() condition for cleanup even on failure", func(t *testing.T) { + steps := buildAPMAppTokenInvalidationStep() + + combined := strings.Join(steps, "") + assert.Contains(t, combined, "always()", "Must run even if prior steps fail to ensure token cleanup") + }) +} diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index d9499be461c..d573cd4e6c6 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -444,6 +444,11 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate steps = append(steps, fmt.Sprintf(" name: %s\n", apmArtifactName)) steps = append(steps, " path: ${{ steps.apm_pack.outputs.bundle-path }}\n") steps = append(steps, " retention-days: 1\n") + // Invalidate the APM GitHub App token after use to enforce least-privilege token lifecycle. + if data.APMDependencies.GitHubApp != nil { + compilerActivationJobLog.Print("Adding APM GitHub App token invalidation step") + steps = append(steps, buildAPMAppTokenInvalidationStep()...) + } } // Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download. diff --git a/pkg/workflow/compiler_activation_jobs_test.go b/pkg/workflow/compiler_activation_jobs_test.go index 79d414443eb..df64c27dc84 100644 --- a/pkg/workflow/compiler_activation_jobs_test.go +++ b/pkg/workflow/compiler_activation_jobs_test.go @@ -470,3 +470,59 @@ func TestBuildMainJob_EngineSpecific(t *testing.T) { }) } } + +// TestBuildActivationJob_APMTokenInvalidation tests that the APM GitHub App token is invalidated after use +func TestBuildActivationJob_APMTokenInvalidation(t *testing.T) { + compiler := NewCompiler() + + t.Run("Invalidation step added when APM github-app is configured", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "Test Workflow", + Command: []string{"test"}, + APMDependencies: &APMDependenciesInfo{ + Packages: []string{"microsoft/apm-sample-package"}, + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + } + + job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml") + require.NoError(t, err, "buildActivationJob should succeed") + require.NotNil(t, job) + + stepsStr := strings.Join(job.Steps, "") + + // Token mint step should be present + assert.Contains(t, stepsStr, "id: apm-app-token", "Should mint APM GitHub App token") + + // Invalidation step should be present and use always() condition + assert.Contains(t, stepsStr, "Invalidate GitHub App token for APM", "Should have APM token invalidation step") + assert.Contains(t, stepsStr, "always() && steps.apm-app-token.outputs.token != ''", "Invalidation step should run always") + + // Invalidation step should appear after the APM bundle upload + uploadIdx := strings.Index(stepsStr, "Upload APM bundle artifact") + invalidateIdx := strings.Index(stepsStr, "Invalidate GitHub App token for APM") + require.NotEqual(t, -1, uploadIdx, "Upload APM bundle artifact step must be present") + require.NotEqual(t, -1, invalidateIdx, "Invalidate GitHub App token for APM step must be present") + assert.Greater(t, invalidateIdx, uploadIdx, "Invalidation step should appear after APM bundle upload") + }) + + t.Run("No invalidation step when APM has no github-app", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "Test Workflow", + Command: []string{"test"}, + APMDependencies: &APMDependenciesInfo{ + Packages: []string{"microsoft/apm-sample-package"}, + }, + } + + job, err := compiler.buildActivationJob(workflowData, false, "", "test.lock.yml") + require.NoError(t, err, "buildActivationJob should succeed") + require.NotNil(t, job) + + stepsStr := strings.Join(job.Steps, "") + assert.NotContains(t, stepsStr, "Invalidate GitHub App token for APM", "Should not have invalidation step when no github-app configured") + }) +}