Skip to content

Commit 2ad992e

Browse files
Add test coverage for GitHub Actions YAML 1.2 anchor support (#362)
* Add YAML anchor support test cases and fixtures - Add comprehensive unit tests for YAML 1.2 anchors in GitHub Actions workflows - Add test fixtures demonstrating anchor usage: - anchors_env.yml: environment variable reuse - anchors_job.yml: complete job configuration reuse - anchors_multiple.yml: multiple anchor references - Update scanner tests to verify anchor workflows are parsed correctly - Update inventory tests to include new dependencies from anchor fixtures Tests confirm gopkg.in/yaml.v3 already supports YAML anchors. Next step: migrate to goccy/go-yaml for better YAML 1.2 compliance and active maintenance. * Fix testifylint: use require.NoError for error assertions * Address PR review comments: improve anchor test coverage and verify detection rules - Fix unit tests to actually verify anchors are used and values are inherited - "simple anchor and alias" test now verifies both jobs parse correctly - "anchor for steps configuration" test now uses the anchor in second job - "anchor for env variables" test now verifies both jobs' env vars - "complex nested anchor" test now verifies permissions inheritance/override - Add integration test to ensure detection rules work with YAML anchors - Created anchors_with_vulnerability.yml with intentional injection flaw - Verified rules detect vulnerabilities in both anchor definition and usage - Added expected findings to TestFindings for the new test fixture All PR feedback from Talgarr has been addressed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 8f3b2fd commit 2ad992e

File tree

7 files changed

+439
-2
lines changed

7 files changed

+439
-2
lines changed

models/github_actions_test.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,3 +594,252 @@ runs:
594594
assert.Equal(t, "koi", actionMetadata.Runs.Steps[0].With[0].Value)
595595
assert.Equal(t, 17, actionMetadata.Runs.Steps[0].Lines["uses"])
596596
}
597+
598+
// TestGithubActionsWorkflowWithAnchors tests YAML 1.2 anchor support
599+
// GitHub Actions now supports YAML anchors as of 2025-09-18
600+
// https://github.blog/changelog/2025-09-18-actions-yaml-anchors-and-non-public-workflow-templates/
601+
func TestGithubActionsWorkflowWithAnchors(t *testing.T) {
602+
t.Run("simple anchor and alias", func(t *testing.T) {
603+
workflow := `
604+
name: CI
605+
on: push
606+
607+
jobs:
608+
build: &build_template
609+
runs-on: ubuntu-latest
610+
steps:
611+
- uses: actions/checkout@v4
612+
613+
test:
614+
<<: *build_template
615+
steps:
616+
- uses: actions/checkout@v4
617+
- run: npm test
618+
`
619+
var wf GithubActionsWorkflow
620+
err := yaml.Unmarshal([]byte(workflow), &wf)
621+
require.NoError(t, err)
622+
assert.Equal(t, "CI", wf.Name)
623+
assert.Len(t, wf.Jobs, 2)
624+
625+
// Verify the first job (with anchor definition)
626+
assert.Equal(t, "build", wf.Jobs[0].ID)
627+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[0].RunsOn)
628+
assert.Len(t, wf.Jobs[0].Steps, 1)
629+
assert.Equal(t, "actions/checkout@v4", wf.Jobs[0].Steps[0].Uses)
630+
631+
// Verify the second job inherited runs-on from the anchor
632+
assert.Equal(t, "test", wf.Jobs[1].ID)
633+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[1].RunsOn)
634+
assert.Len(t, wf.Jobs[1].Steps, 2)
635+
assert.Equal(t, "actions/checkout@v4", wf.Jobs[1].Steps[0].Uses)
636+
assert.Equal(t, "npm test", wf.Jobs[1].Steps[1].Run)
637+
})
638+
639+
t.Run("anchor for environment configuration", func(t *testing.T) {
640+
workflow := `
641+
name: Deploy
642+
on: push
643+
644+
jobs:
645+
deploy-staging:
646+
runs-on: ubuntu-latest
647+
environment: &env_config
648+
name: staging
649+
url: https://staging.example.com
650+
steps:
651+
- run: echo "deploying"
652+
653+
deploy-prod:
654+
runs-on: ubuntu-latest
655+
environment:
656+
<<: *env_config
657+
name: production
658+
url: https://prod.example.com
659+
steps:
660+
- run: echo "deploying"
661+
`
662+
var wf GithubActionsWorkflow
663+
err := yaml.Unmarshal([]byte(workflow), &wf)
664+
require.NoError(t, err)
665+
assert.Len(t, wf.Jobs, 2)
666+
assert.Equal(t, "staging", wf.Jobs[0].Environment[0].Name)
667+
assert.Equal(t, "https://staging.example.com", wf.Jobs[0].Environment[0].Url)
668+
assert.Equal(t, "production", wf.Jobs[1].Environment[0].Name)
669+
assert.Equal(t, "https://prod.example.com", wf.Jobs[1].Environment[0].Url)
670+
})
671+
672+
t.Run("anchor for steps configuration", func(t *testing.T) {
673+
workflow := `
674+
name: Test
675+
on: push
676+
677+
jobs:
678+
test-node-14:
679+
runs-on: ubuntu-latest
680+
steps: &test_steps
681+
- uses: actions/checkout@v4
682+
- uses: actions/setup-node@v4
683+
with:
684+
node-version: '14'
685+
- run: npm test
686+
687+
test-node-16:
688+
runs-on: ubuntu-latest
689+
steps: *test_steps
690+
`
691+
var wf GithubActionsWorkflow
692+
err := yaml.Unmarshal([]byte(workflow), &wf)
693+
require.NoError(t, err)
694+
assert.Len(t, wf.Jobs, 2)
695+
696+
// Verify both jobs have the same steps from the anchor
697+
assert.Len(t, wf.Jobs[0].Steps, 3)
698+
assert.Equal(t, "actions/checkout@v4", wf.Jobs[0].Steps[0].Uses)
699+
assert.Equal(t, "actions/setup-node@v4", wf.Jobs[0].Steps[1].Uses)
700+
assert.Equal(t, "npm test", wf.Jobs[0].Steps[2].Run)
701+
702+
// Verify the second job uses the anchor and has identical steps
703+
assert.Len(t, wf.Jobs[1].Steps, 3)
704+
assert.Equal(t, "actions/checkout@v4", wf.Jobs[1].Steps[0].Uses)
705+
assert.Equal(t, "actions/setup-node@v4", wf.Jobs[1].Steps[1].Uses)
706+
assert.Equal(t, "npm test", wf.Jobs[1].Steps[2].Run)
707+
})
708+
709+
t.Run("anchor for permissions", func(t *testing.T) {
710+
workflow := `
711+
name: Security
712+
on: push
713+
714+
jobs:
715+
scan:
716+
runs-on: ubuntu-latest
717+
permissions: &security_perms
718+
contents: read
719+
security-events: write
720+
steps:
721+
- run: echo "scanning"
722+
723+
report:
724+
runs-on: ubuntu-latest
725+
permissions: *security_perms
726+
steps:
727+
- run: echo "reporting"
728+
`
729+
var wf GithubActionsWorkflow
730+
err := yaml.Unmarshal([]byte(workflow), &wf)
731+
require.NoError(t, err)
732+
assert.Len(t, wf.Jobs, 2)
733+
assert.Len(t, wf.Jobs[0].Permissions, 2)
734+
assert.Contains(t, wf.Jobs[0].Permissions, GithubActionsPermission{Scope: "contents", Permission: "read"})
735+
assert.Contains(t, wf.Jobs[0].Permissions, GithubActionsPermission{Scope: "security-events", Permission: "write"})
736+
assert.Equal(t, wf.Jobs[0].Permissions, wf.Jobs[1].Permissions)
737+
})
738+
739+
t.Run("multiple anchors in same workflow", func(t *testing.T) {
740+
workflow := `
741+
name: Multi
742+
on: push
743+
744+
jobs:
745+
job1:
746+
runs-on: &runner ubuntu-latest
747+
container: &container_image alpine:latest
748+
steps:
749+
- run: echo "test"
750+
751+
job2:
752+
runs-on: *runner
753+
container: *container_image
754+
steps:
755+
- run: echo "test2"
756+
`
757+
var wf GithubActionsWorkflow
758+
err := yaml.Unmarshal([]byte(workflow), &wf)
759+
require.NoError(t, err)
760+
assert.Len(t, wf.Jobs, 2)
761+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[0].RunsOn)
762+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[1].RunsOn)
763+
assert.Equal(t, "alpine:latest", wf.Jobs[0].Container.Image)
764+
assert.Equal(t, "alpine:latest", wf.Jobs[1].Container.Image)
765+
})
766+
767+
t.Run("anchor for env variables", func(t *testing.T) {
768+
workflow := `
769+
name: Env Test
770+
on: push
771+
772+
jobs:
773+
build:
774+
runs-on: ubuntu-latest
775+
env: &common_env
776+
NODE_ENV: production
777+
CI: true
778+
steps:
779+
- run: echo "build"
780+
781+
test:
782+
runs-on: ubuntu-latest
783+
env: *common_env
784+
steps:
785+
- run: echo "test"
786+
`
787+
var wf GithubActionsWorkflow
788+
err := yaml.Unmarshal([]byte(workflow), &wf)
789+
require.NoError(t, err)
790+
assert.Len(t, wf.Jobs, 2)
791+
792+
// Verify the first job has the anchor env vars
793+
assert.Len(t, wf.Jobs[0].Env, 2)
794+
assert.Contains(t, wf.Jobs[0].Env, GithubActionsEnv{Name: "NODE_ENV", Value: "production"})
795+
assert.Contains(t, wf.Jobs[0].Env, GithubActionsEnv{Name: "CI", Value: "true"})
796+
797+
// Verify the second job reuses the same env vars via alias
798+
assert.Len(t, wf.Jobs[1].Env, 2)
799+
assert.Contains(t, wf.Jobs[1].Env, GithubActionsEnv{Name: "NODE_ENV", Value: "production"})
800+
assert.Contains(t, wf.Jobs[1].Env, GithubActionsEnv{Name: "CI", Value: "true"})
801+
})
802+
803+
t.Run("complex nested anchor with merge keys", func(t *testing.T) {
804+
workflow := `
805+
name: Complex
806+
on: push
807+
808+
jobs:
809+
base: &base_job
810+
runs-on: ubuntu-latest
811+
permissions:
812+
contents: read
813+
steps:
814+
- uses: actions/checkout@v4
815+
816+
extended:
817+
<<: *base_job
818+
permissions:
819+
contents: write
820+
issues: write
821+
steps:
822+
- uses: actions/checkout@v4
823+
- run: npm build
824+
`
825+
var wf GithubActionsWorkflow
826+
err := yaml.Unmarshal([]byte(workflow), &wf)
827+
require.NoError(t, err)
828+
assert.Len(t, wf.Jobs, 2)
829+
830+
// Verify the base job
831+
assert.Equal(t, "base", wf.Jobs[0].ID)
832+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[0].RunsOn)
833+
assert.Len(t, wf.Jobs[0].Permissions, 1)
834+
assert.Contains(t, wf.Jobs[0].Permissions, GithubActionsPermission{Scope: "contents", Permission: "read"})
835+
assert.Len(t, wf.Jobs[0].Steps, 1)
836+
837+
// Verify the extended job inherited runs-on but overrode permissions
838+
assert.Equal(t, "extended", wf.Jobs[1].ID)
839+
assert.Equal(t, GithubActionsJobRunsOn{"ubuntu-latest"}, wf.Jobs[1].RunsOn)
840+
assert.Len(t, wf.Jobs[1].Permissions, 2)
841+
assert.Contains(t, wf.Jobs[1].Permissions, GithubActionsPermission{Scope: "contents", Permission: "write"})
842+
assert.Contains(t, wf.Jobs[1].Permissions, GithubActionsPermission{Scope: "issues", Permission: "write"})
843+
assert.Len(t, wf.Jobs[1].Steps, 2)
844+
})
845+
}

scanner/inventory_scanner_test.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package scanner
22

33
import (
44
"context"
5+
"testing"
6+
57
"github.com/boostsecurityio/poutine/models"
68
"github.com/boostsecurityio/poutine/opa"
79
"github.com/stretchr/testify/assert"
8-
"testing"
10+
"github.com/stretchr/testify/require"
911
)
1012

1113
func TestGithubWorkflows(t *testing.T) {
@@ -29,6 +31,10 @@ func TestGithubWorkflows(t *testing.T) {
2931
".github/workflows/workflow_run_valid.yml",
3032
".github/workflows/workflow_run_reusable.yml",
3133
".github/workflows/allowed_pr_runner.yml",
34+
".github/workflows/anchors_env.yml",
35+
".github/workflows/anchors_job.yml",
36+
".github/workflows/anchors_multiple.yml",
37+
".github/workflows/anchors_with_vulnerability.yml",
3238
})
3339
}
3440

@@ -78,6 +84,69 @@ func TestRun(t *testing.T) {
7884
assert.Equal(t, 3, len(scannedPackage.GitlabciConfigs))
7985
}
8086

87+
func TestGithubWorkflowsWithAnchors(t *testing.T) {
88+
s := NewInventoryScanner("testdata")
89+
pkgInsights := &models.PackageInsights{}
90+
err := s.Run(pkgInsights)
91+
require.NoError(t, err)
92+
93+
workflows := pkgInsights.GithubActionsWorkflows
94+
95+
// Find and validate the anchor workflows
96+
var envWorkflow, jobWorkflow, multipleWorkflow *models.GithubActionsWorkflow
97+
for i := range workflows {
98+
switch workflows[i].Path {
99+
case ".github/workflows/anchors_env.yml":
100+
envWorkflow = &workflows[i]
101+
case ".github/workflows/anchors_job.yml":
102+
jobWorkflow = &workflows[i]
103+
case ".github/workflows/anchors_multiple.yml":
104+
multipleWorkflow = &workflows[i]
105+
}
106+
}
107+
108+
// Verify anchors_env.yml parsed correctly
109+
assert.NotNil(t, envWorkflow, "anchors_env.yml should be found")
110+
assert.Equal(t, "Anchors - Environment Variables", envWorkflow.Name)
111+
assert.Len(t, envWorkflow.Jobs, 2)
112+
113+
// Both jobs should have the same environment variables (anchor was reused)
114+
job1 := envWorkflow.Jobs[0]
115+
job2 := envWorkflow.Jobs[1]
116+
assert.Len(t, job1.Env, 2)
117+
assert.Len(t, job2.Env, 2)
118+
assert.Contains(t, job1.Env, models.GithubActionsEnv{Name: "NODE_ENV", Value: "production"})
119+
assert.Contains(t, job2.Env, models.GithubActionsEnv{Name: "NODE_ENV", Value: "production"})
120+
121+
// Verify anchors_job.yml parsed correctly
122+
assert.NotNil(t, jobWorkflow, "anchors_job.yml should be found")
123+
assert.Equal(t, "Anchors - Complete Job", jobWorkflow.Name)
124+
assert.Len(t, jobWorkflow.Jobs, 2)
125+
126+
// Both jobs should be identical (complete job anchor reused)
127+
testJob := jobWorkflow.Jobs[0]
128+
altTestJob := jobWorkflow.Jobs[1]
129+
assert.Equal(t, "test", testJob.ID)
130+
assert.Equal(t, "alt-test", altTestJob.ID)
131+
assert.Equal(t, testJob.RunsOn, altTestJob.RunsOn)
132+
assert.Equal(t, testJob.Env, altTestJob.Env)
133+
assert.Len(t, testJob.Steps, 3)
134+
assert.Len(t, altTestJob.Steps, 3)
135+
assert.Equal(t, "actions/checkout@v5", testJob.Steps[0].Uses)
136+
assert.Equal(t, "actions/checkout@v5", altTestJob.Steps[0].Uses)
137+
138+
// Verify anchors_multiple.yml parsed correctly
139+
assert.NotNil(t, multipleWorkflow, "anchors_multiple.yml should be found")
140+
assert.Equal(t, "Anchors - Multiple References", multipleWorkflow.Name)
141+
assert.Len(t, multipleWorkflow.Jobs, 3)
142+
143+
// All three jobs should use the same runner and container (multiple anchors reused)
144+
for i := 0; i < 3; i++ {
145+
assert.Equal(t, models.GithubActionsJobRunsOn{"ubuntu-latest"}, multipleWorkflow.Jobs[i].RunsOn)
146+
assert.Equal(t, "node:18", multipleWorkflow.Jobs[i].Container.Image)
147+
}
148+
}
149+
81150
func TestPipelineAsCodeTekton(t *testing.T) {
82151
s := NewInventoryScanner("testdata")
83152
pkgInsights := &models.PackageInsights{}

scanner/inventory_test.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ func TestPurls(t *testing.T) {
5050
"pkg:azurepipelinestask/DownloadPipelineArtifact@2",
5151
"pkg:azurepipelinestask/Cache@2",
5252
"pkg:githubactions/org/owner@main#.github/workflows/ci.yml",
53+
"pkg:githubactions/actions/checkout@v5",
54+
"pkg:docker/node%3A18",
5355
}
5456
assert.ElementsMatch(t, i.Purls(*scannedPackage), purls)
55-
assert.Len(t, scannedPackage.BuildDependencies, 20)
57+
assert.Len(t, scannedPackage.BuildDependencies, 22)
5658
assert.Equal(t, 4, len(scannedPackage.PackageDependencies))
5759
}
5860

@@ -492,6 +494,38 @@ func TestFindings(t *testing.T) {
492494
Details: "Sources: body.pull_request.body",
493495
},
494496
},
497+
{
498+
RuleId: "injection",
499+
Purl: purl,
500+
Meta: results.FindingMeta{
501+
Path: ".github/workflows/anchors_with_vulnerability.yml",
502+
Line: 15,
503+
Job: "base_job",
504+
Step: "1",
505+
Details: "Sources: github.head_ref",
506+
EventTriggers: []string{"pull_request_target", "push"},
507+
},
508+
},
509+
{
510+
RuleId: "injection",
511+
Purl: purl,
512+
Meta: results.FindingMeta{
513+
Path: ".github/workflows/anchors_with_vulnerability.yml",
514+
Line: 15,
515+
Job: "test_job",
516+
Step: "1",
517+
Details: "Sources: github.head_ref",
518+
EventTriggers: []string{"pull_request_target", "push"},
519+
},
520+
},
521+
{
522+
RuleId: "default_permissions_on_risky_events",
523+
Purl: purl,
524+
Meta: results.FindingMeta{
525+
Path: ".github/workflows/anchors_with_vulnerability.yml",
526+
EventTriggers: []string{"pull_request_target", "push"},
527+
},
528+
},
495529
}
496530

497531
assert.Equal(t, len(findings), len(analysisResults.Findings))

0 commit comments

Comments
 (0)