Skip to content

Commit 9be53f1

Browse files
authored
fix(safe_jobs): hoist expression env vars into step env: block to prevent run-script guardrail failure (#32532)
1 parent 4f02b72 commit 9be53f1

9 files changed

Lines changed: 145 additions & 62 deletions

.changeset/patch-hoist-safe-job-env-expressions.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/daily-choice-test.lock.yml

Lines changed: 1 addition & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/mcp-inspector.lock.yml

Lines changed: 2 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/notion-issue-summary.lock.yml

Lines changed: 1 addition & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-copilot-arm.lock.yml

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-copilot.lock.yml

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/workflow/safe_jobs.go

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -248,34 +248,19 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool
248248
steps = append(steps, downloadSteps...)
249249

250250
// the download artifacts always creates a folder, then unpacks in that folder
251-
agentOutputArtifactFilename := "${RUNNER_TEMP}/gh-aw/safe-jobs/" + constants.AgentOutputFilename
252-
253-
// Add environment variables step with GH_AW_AGENT_OUTPUT and job-specific env vars
254-
steps = append(steps, " - name: Configure Safe Outputs Job Environment Variables\n")
255-
steps = append(steps, " id: setup-safe-job-env\n")
256-
steps = append(steps, " run: |\n")
257-
steps = append(steps, " find \"${RUNNER_TEMP}/gh-aw/safe-jobs/\" -type f -print\n")
258-
// Configure GH_AW_AGENT_OUTPUT to point to downloaded artifact file
259-
steps = append(steps, fmt.Sprintf(" echo \"GH_AW_AGENT_OUTPUT=%s\" >> \"$GITHUB_OUTPUT\"\n", agentOutputArtifactFilename))
260-
261-
// Add job-specific environment variables
262-
if jobConfig.Env != nil {
263-
for key, value := range jobConfig.Env {
264-
steps = append(steps, fmt.Sprintf(" echo \"%s=%s\" >> \"$GITHUB_OUTPUT\"\n", key, value))
265-
}
266-
}
267251

268-
// Add custom steps from the job configuration, injecting env vars from the
269-
// setup-safe-job-env step outputs so user steps can access them.
252+
// Add custom steps from the job configuration, injecting env vars directly so
253+
// user steps can access GH_AW_AGENT_OUTPUT and all job-specific env vars.
270254
if len(jobConfig.Steps) > 0 {
271-
// Build the env vars that were set in the setup-safe-job-env step so we can inject them.
255+
// GH_AW_AGENT_OUTPUT uses the runner.temp Actions expression so the path is
256+
// resolved by the runner without requiring a $GITHUB_OUTPUT write.
272257
setupEnvVars := map[string]string{
273-
"GH_AW_AGENT_OUTPUT": "${{ steps.setup-safe-job-env.outputs.GH_AW_AGENT_OUTPUT }}",
258+
"GH_AW_AGENT_OUTPUT": fmt.Sprintf("${{ runner.temp }}/gh-aw/safe-jobs/%s", constants.AgentOutputFilename),
274259
}
275-
if jobConfig.Env != nil {
276-
for key := range jobConfig.Env {
277-
setupEnvVars[key] = fmt.Sprintf("${{ steps.setup-safe-job-env.outputs.%s }}", key)
278-
}
260+
// All job-specific env vars (literal or expression-based) are injected with
261+
// their original values. Nothing goes through $GITHUB_OUTPUT.
262+
for key, value := range jobConfig.Env {
263+
setupEnvVars[key] = value
279264
}
280265
for _, step := range jobConfig.Steps {
281266
if stepMap, ok := step.(map[string]any); ok {

pkg/workflow/safe_jobs_test.go

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,8 @@ func TestBuildSafeJobs(t *testing.T) {
233233
t.Error("Expected main job output to be available as environment variable")
234234
}
235235

236-
if !strings.Contains(stepsContent, "Configure Safe Outputs Job Environment Variables") {
237-
t.Error("Expected safe jobs env setup step to use Safe Outputs terminology")
238-
}
239-
240-
if strings.Contains(stepsContent, "Configure Safe Job Environment Variables") {
241-
t.Error("Expected legacy Safe Job terminology to be absent from safe jobs env setup step")
236+
if strings.Contains(stepsContent, "Configure Safe Outputs Job Environment Variables") {
237+
t.Error("Configure Safe Outputs Job Environment Variables step should have been removed")
242238
}
243239

244240
if strings.Contains(stepsContent, "GLOBAL_VAR=global_value") {
@@ -596,6 +592,67 @@ func TestMergeSafeJobsFromIncludedConfigs(t *testing.T) {
596592
}
597593
}
598594

595+
// TestBuildSafeJobsEnvExpressionHoisting verifies that no env vars — whether expression-based
596+
// or literal — are ever written to $GITHUB_OUTPUT, and that all values are injected directly
597+
// into each downstream step's env: block.
598+
func TestBuildSafeJobsEnvExpressionHoisting(t *testing.T) {
599+
c := NewCompiler()
600+
601+
workflowData := &WorkflowData{
602+
Name: "test-workflow",
603+
SafeOutputs: &SafeOutputsConfig{
604+
Jobs: map[string]*SafeJobConfig{
605+
"publish": {
606+
RunsOn: "ubuntu-latest",
607+
Env: map[string]string{
608+
"GH_TOKEN": "${{ github.token }}",
609+
"STATIC_VAR": "literal-value",
610+
},
611+
Steps: []any{
612+
map[string]any{
613+
"name": "Publish",
614+
"run": "echo 'Publishing'",
615+
},
616+
},
617+
},
618+
},
619+
},
620+
}
621+
622+
_, err := c.buildSafeJobs(workflowData, false)
623+
if err != nil {
624+
t.Fatalf("Unexpected error building safe jobs: %v", err)
625+
}
626+
627+
jobs := c.jobManager.GetAllJobs()
628+
if len(jobs) != 1 {
629+
t.Fatalf("Expected 1 job to be created, got %d", len(jobs))
630+
}
631+
632+
var job *Job
633+
for _, j := range jobs {
634+
job = j
635+
break
636+
}
637+
638+
stepsContent := strings.Join(job.Steps, "")
639+
640+
// Neither expression-based nor literal env vars must ever be written to $GITHUB_OUTPUT.
641+
if strings.Contains(stepsContent, `GITHUB_OUTPUT`) {
642+
t.Error("No env var must ever be written to $GITHUB_OUTPUT in safe-jobs (secrets must not be stored in outputs)")
643+
}
644+
645+
// Expression-based values must be injected directly into downstream step env: blocks.
646+
if !strings.Contains(stepsContent, "GH_TOKEN: ${{ github.token }}") {
647+
t.Error("Expected GH_TOKEN expression to be injected directly into a downstream step env: block")
648+
}
649+
650+
// Literal values must also be injected directly into downstream step env: blocks.
651+
if !strings.Contains(stepsContent, "STATIC_VAR: literal-value") {
652+
t.Error("Expected literal env var STATIC_VAR to be injected directly into a downstream step env: block")
653+
}
654+
}
655+
599656
// TestSafeJobsInputTypes tests that safe-jobs inputs support all input types
600657
// and share the same InputDefinition type with workflow_dispatch inputs
601658
func TestSafeJobsInputTypes(t *testing.T) {

pkg/workflow/safe_jobs_threat_detection_test.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,68 @@ Test workflow content
368368
}
369369
}
370370

371-
// TestIsThreatDetectionExplicitlyDisabledInConfigs verifies the helper function
372-
// that checks whether any imported safe-outputs config explicitly disables detection.
371+
// TestSafeJobsExpressionEnvNeverWrittenToOutput verifies that job-level env vars containing
372+
// GitHub Actions expressions are never written to $GITHUB_OUTPUT in the compiled lock file.
373+
// This is a security guardrail: tokens like ${{ github.token }} must never be stored in outputs.
374+
func TestSafeJobsExpressionEnvNeverWrittenToOutput(t *testing.T) {
375+
c := NewCompiler()
376+
377+
markdown := `---
378+
on: issues
379+
safe-outputs:
380+
jobs:
381+
publish:
382+
env:
383+
GH_TOKEN: ${{ github.token }}
384+
STATIC: literal-value
385+
steps:
386+
- name: Do work
387+
run: echo "working"
388+
---
389+
390+
# Test Workflow
391+
Test workflow content
392+
`
393+
394+
tmpDir := testutil.TempDir(t, "test-*")
395+
testFile := tmpDir + "/test-safe-jobs-secret.md"
396+
if err := os.WriteFile(testFile, []byte(markdown), 0o644); err != nil {
397+
t.Fatalf("Failed to write test file: %v", err)
398+
}
399+
400+
if err := c.CompileWorkflow(testFile); err != nil {
401+
t.Fatalf("Failed to compile workflow: %v", err)
402+
}
403+
404+
lockFile := tmpDir + "/test-safe-jobs-secret.lock.yml"
405+
workflowBytes, err := os.ReadFile(lockFile)
406+
if err != nil {
407+
t.Fatalf("Failed to read lock file: %v", err)
408+
}
409+
workflowStr := string(workflowBytes)
410+
411+
// No env var at all may be written to $GITHUB_OUTPUT by safe-jobs.
412+
for _, line := range strings.Split(workflowStr, "\n") {
413+
if strings.Contains(line, "GH_TOKEN") && strings.Contains(line, "GITHUB_OUTPUT") {
414+
t.Errorf("GH_TOKEN must never be written to GITHUB_OUTPUT (secret leak): %s", strings.TrimSpace(line))
415+
}
416+
if strings.Contains(line, "STATIC") && strings.Contains(line, "GITHUB_OUTPUT") {
417+
t.Errorf("STATIC env var must not be written to GITHUB_OUTPUT: %s", strings.TrimSpace(line))
418+
}
419+
}
420+
421+
// The token must be injected into the step env: block, not through step outputs.
422+
if !strings.Contains(workflowStr, "GH_TOKEN: ${{ github.token }}") {
423+
t.Error("Expected GH_TOKEN expression to be injected into step env: block in compiled output")
424+
}
425+
426+
// Literal value must also be injected directly into the step env: block.
427+
if !strings.Contains(workflowStr, "STATIC: literal-value") {
428+
t.Error("Expected literal env var to be injected directly into step env: block in compiled output")
429+
}
430+
}
431+
432+
373433
func TestIsThreatDetectionExplicitlyDisabledInConfigs(t *testing.T) {
374434
tests := []struct {
375435
name string

0 commit comments

Comments
 (0)