Skip to content

Commit e34e927

Browse files
toggle replace action for major tag
1 parent 403fc4d commit e34e927

8 files changed

Lines changed: 246 additions & 40 deletions

File tree

remediation/workflow/maintainedactions/maintainedActions.go

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,41 @@ func LoadMaintainedActions(jsonPath string) (map[string]string, error) {
5656
return actionMap, nil
5757
}
5858

59-
// ReplaceActions replaces original actions with Step Security actions in a workflow
60-
func ReplaceActions(inputYaml string, customerMaintainedActions map[string]string) (string, bool, error) {
59+
// resolveVersion determines the version to use for the replacement action.
60+
// When replaceByMajorTag is true, it matches the major version from the original action.
61+
// When false (default), it uses the latest release of the new action.
62+
func resolveVersion(originalUses, actionName, newAction string, replaceByMajorTag bool) (string, error) {
63+
if !replaceByMajorTag {
64+
return GetLatestRelease(newAction)
65+
}
66+
67+
parts := strings.SplitN(originalUses, "@", 2)
68+
if len(parts) < 2 || parts[1] == "" {
69+
return "", fmt.Errorf("no ref found in %s", originalUses)
70+
}
71+
ref := parts[1]
72+
var version string
73+
var err error
74+
if len(ref) == 40 && pin.IsAllHex(ref) {
75+
version, err = GetMajorTagFromSHA(actionName, ref)
76+
if err != nil || version == "" {
77+
return "", fmt.Errorf("unable to resolve SHA %s to major tag", ref)
78+
}
79+
} else {
80+
version = ref
81+
}
82+
majorVersion := getMajorVersion(version)
83+
tag, exists, err := GetMajorTagIfExists(newAction, majorVersion)
84+
if err != nil || !exists {
85+
return "", fmt.Errorf("major tag %s not found on %s", majorVersion, newAction)
86+
}
87+
return tag, nil
88+
}
89+
90+
// ReplaceActions replaces original actions with Step Security actions in a workflow.
91+
// When replaceByMajorTag is true, the replacement action uses the same major version as the original.
92+
// When false (default), it uses the latest release of the replacement action.
93+
func ReplaceActions(inputYaml string, customerMaintainedActions map[string]string, replaceByMajorTag bool) (string, bool, error) {
6194
workflow := metadata.Workflow{}
6295
updated := false
6396

@@ -79,31 +112,16 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin
79112
for stepIdx, step := range job.Steps {
80113
actionName := strings.Split(step.Uses, "@")[0]
81114
if newAction, ok := actionMap[actionName]; ok {
82-
parts := strings.SplitN(step.Uses, "@", 2)
83-
if len(parts) < 2 || parts[1] == "" {
84-
continue
85-
}
86-
ref := parts[1]
87-
var version string
88-
if len(ref) == 40 && pin.IsAllHex(ref) {
89-
version, err = GetMajorTagFromSHA(actionName, ref)
90-
if err != nil || version == "" {
91-
continue
92-
}
93-
} else {
94-
version = ref
95-
}
96-
majorVersion := getMajorVersion(version)
97-
tag, exists, err := GetMajorTagIfExists(newAction, majorVersion)
98-
if err != nil || !exists {
115+
version, err := resolveVersion(step.Uses, actionName, newAction, replaceByMajorTag)
116+
if err != nil {
99117
continue
100118
}
101119
replacements = append(replacements, replacement{
102120
jobName: jobName,
103121
stepIdx: stepIdx,
104122
newAction: newAction,
105123
originalAction: step.Uses,
106-
latestVersion: tag,
124+
latestVersion: version,
107125
})
108126
}
109127
}
@@ -115,31 +133,16 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin
115133
if len(step.Uses) > 0 {
116134
actionName := strings.Split(step.Uses, "@")[0]
117135
if newAction, ok := actionMap[actionName]; ok {
118-
parts := strings.SplitN(step.Uses, "@", 2)
119-
if len(parts) < 2 || parts[1] == "" {
120-
continue
121-
}
122-
ref := parts[1]
123-
var version string
124-
if len(ref) == 40 && pin.IsAllHex(ref) {
125-
version, err = GetMajorTagFromSHA(actionName, ref)
126-
if err != nil || version == "" {
127-
continue
128-
}
129-
} else {
130-
version = ref
131-
}
132-
majorVersion := getMajorVersion(version)
133-
tag, exists, err := GetMajorTagIfExists(newAction, majorVersion)
134-
if err != nil || !exists {
136+
version, err := resolveVersion(step.Uses, actionName, newAction, replaceByMajorTag)
137+
if err != nil {
135138
continue
136139
}
137140
replacements = append(replacements, replacement{
138141
jobName: "composite",
139142
stepIdx: stepIdx,
140143
newAction: newAction,
141144
originalAction: step.Uses,
142-
latestVersion: tag,
145+
latestVersion: version,
143146
})
144147
}
145148
}

remediation/workflow/maintainedactions/maintainedactions_test.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func TestReplaceActions(t *testing.T) {
8888
t.Errorf("ReplaceActions() unable to json file %v", err)
8989
return
9090
}
91-
got, updated, replaceErr := ReplaceActions(string(input), actionMap)
91+
got, updated, replaceErr := ReplaceActions(string(input), actionMap, true)
9292

9393
// Check error
9494
if (replaceErr != nil) != tt.wantErr {
@@ -116,6 +116,110 @@ func TestReplaceActions(t *testing.T) {
116116
}
117117
}
118118

119+
func TestReplaceActionsLatestRelease(t *testing.T) {
120+
const inputDirectory = "../../../testfiles/maintainedActions/input"
121+
const outputDirectory = "../../../testfiles/maintainedActions/output"
122+
123+
// Activate httpmock
124+
httpmock.Activate()
125+
defer httpmock.DeactivateAndReset()
126+
127+
// Mock GitHub API responses for GetLatestRelease (GET /repos/{owner}/{repo}/releases/latest)
128+
httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/releases/latest",
129+
httpmock.NewStringResponder(200, `{"id":1,"tag_name":"v6.1.0","name":"v6.1.0"}`))
130+
131+
httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/releases/latest",
132+
httpmock.NewStringResponder(200, `{"id":2,"tag_name":"v5.3.1","name":"v5.3.1"}`))
133+
134+
httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/releases/latest",
135+
httpmock.NewStringResponder(200, `{"id":3,"tag_name":"v2.0.0","name":"v2.0.0"}`))
136+
137+
httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/releases/latest",
138+
httpmock.NewStringResponder(200, `{"id":4,"tag_name":"v4.0.0","name":"v4.0.0"}`))
139+
140+
tests := []struct {
141+
name string
142+
inputFile string
143+
outputFile string
144+
wantUpdated bool
145+
wantErr bool
146+
}{
147+
{
148+
name: "one job with latest release versions",
149+
inputFile: "oneJob.yml",
150+
outputFile: "oneJobLatest.yml",
151+
wantUpdated: true,
152+
wantErr: false,
153+
},
154+
{
155+
name: "no changes needed - already using maintained actions",
156+
inputFile: "noChangesNeeded.yml",
157+
outputFile: "noChangesNeeded.yml",
158+
wantUpdated: false,
159+
wantErr: false,
160+
},
161+
{
162+
name: "double job with latest release versions",
163+
inputFile: "doubleJob.yml",
164+
outputFile: "doubleJobLatest.yml",
165+
wantUpdated: true,
166+
wantErr: false,
167+
},
168+
{
169+
name: "composite action with latest release versions",
170+
inputFile: "compositeAction.yml",
171+
outputFile: "compositeActionLatest.yml",
172+
wantUpdated: true,
173+
wantErr: false,
174+
},
175+
{
176+
name: "replacement happens even when major version differs (latest release used)",
177+
inputFile: "noMatchingMajorVersion.yml",
178+
outputFile: "noMatchingMajorVersionLatest.yml",
179+
wantUpdated: true,
180+
wantErr: false,
181+
},
182+
}
183+
184+
for _, tt := range tests {
185+
t.Run(tt.name, func(t *testing.T) {
186+
// Read input file
187+
input, err := ioutil.ReadFile(path.Join(inputDirectory, tt.inputFile))
188+
if err != nil {
189+
t.Fatalf("error reading input file: %v", err)
190+
}
191+
actionMap, err := LoadMaintainedActions("maintainedActions.json")
192+
if err != nil {
193+
t.Errorf("ReplaceActions() unable to json file %v", err)
194+
return
195+
}
196+
got, updated, replaceErr := ReplaceActions(string(input), actionMap, false)
197+
198+
// Check error
199+
if (replaceErr != nil) != tt.wantErr {
200+
t.Errorf("ReplaceActions() error = %v, wantErr %v", replaceErr, tt.wantErr)
201+
return
202+
}
203+
204+
// Check if updated flag matches
205+
if updated != tt.wantUpdated {
206+
t.Errorf("ReplaceActions() updated = %v, wantUpdated %v", updated, tt.wantUpdated)
207+
}
208+
209+
// Read expected output file
210+
expectedOutput, err := ioutil.ReadFile(path.Join(outputDirectory, tt.outputFile))
211+
if err != nil {
212+
t.Fatalf("error reading expected output file: %v", err)
213+
}
214+
215+
// Compare output with expected
216+
if got != string(expectedOutput) {
217+
t.Errorf("ReplaceActions() = %v, want %v", got, string(expectedOutput))
218+
}
219+
})
220+
}
221+
}
222+
119223
func TestSome(t *testing.T) {
120224

121225
version, err := GetMajorTagFromSHA("tj-actions/changed-files", "00f80efd45353091691a96565de08f4f50c685f8")

remediation/workflow/secureworkflow.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d
2525
enableLogging := false
2626
addEmptyTopLevelPermissions := false
2727
skipHardenRunnerForContainers := false
28+
replaceActionByMajorTag := false
2829
exemptedActions, pinToImmutable, maintainedActionsMap, actionCommitMap, runnerLabelMap := []string{}, false, map[string]string{}, map[string]string{}, map[string]string{}
2930
hardenRunnerConfig := hardenrunner.HardenRunnerConfig{}
3031

@@ -98,6 +99,10 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d
9899
skipHardenRunnerForContainers = true
99100
}
100101

102+
if queryStringParams["replaceActionByMajorTag"] == "true" {
103+
replaceActionByMajorTag = true
104+
}
105+
101106
if enableLogging {
102107
// Log query parameters
103108
paramsJSON, _ := json.MarshalIndent(queryStringParams, "", " ")
@@ -151,7 +156,7 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d
151156
}
152157

153158
if replaceMaintainedActions {
154-
secureWorkflowReponse.FinalOutput, replacedMaintainedActions, err = maintainedactions.ReplaceActions(secureWorkflowReponse.FinalOutput, maintainedActionsMap)
159+
secureWorkflowReponse.FinalOutput, replacedMaintainedActions, err = maintainedactions.ReplaceActions(secureWorkflowReponse.FinalOutput, maintainedActionsMap, replaceActionByMajorTag)
155160
if err != nil {
156161
log.Printf("Error replacing maintained actions: %v", err)
157162
secureWorkflowReponse.HasErrors = true

remediation/workflow/secureworkflow_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,13 @@ func TestSecureWorkflow(t *testing.T) {
231231
queryParams["addHardenRunner"] = "true"
232232
queryParams["pinActions"] = "true"
233233
queryParams["addPermissions"] = "false"
234+
queryParams["replaceActionByMajorTag"] = "true"
234235
case "compositeAction.yml":
235236
queryParams["addMaintainedActions"] = "true"
236237
queryParams["addHardenRunner"] = "false"
237238
queryParams["pinActions"] = "true"
238239
queryParams["addPermissions"] = "false"
240+
queryParams["replaceActionByMajorTag"] = "true"
239241
}
240242
queryParams["addProjectComment"] = "false"
241243

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: 'Test Composite Action'
2+
description: 'Test composite action for maintained actions replacement'
3+
branding:
4+
icon: 'arrow-up'
5+
color: 'blue'
6+
inputs:
7+
component:
8+
description: 'Component Name'
9+
required: true
10+
runs:
11+
using: 'composite'
12+
steps:
13+
- uses: step-security/action-semantic-pull-request@v6
14+
with:
15+
types: feat,fix,chore
16+
17+
- uses: step-security/skip-duplicate-actions@v5
18+
with:
19+
do_not_skip: '["release"]'
20+
21+
- uses: step-security/git-restore-mtime-action@v2
22+
with:
23+
pattern: '**/*'
24+
25+
- name: Run custom script
26+
run: echo "Running custom script"
27+
shell: bash
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Test Workflow - Double Job
2+
on: push
3+
4+
jobs:
5+
test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: step-security/action-semantic-pull-request@v6
9+
with:
10+
types: feat,fix,chore
11+
- uses: step-security/skip-duplicate-actions@v5
12+
with:
13+
do_not_skip: '["release"]'
14+
- uses: step-security/git-restore-mtime-action@v2
15+
with:
16+
pattern: '**/*'
17+
18+
build:
19+
runs-on: ubuntu-latest
20+
needs: test
21+
steps:
22+
- uses: actions/checkout@v3
23+
- uses: actions/setup-node@v3
24+
with:
25+
node-version: '16'
26+
- uses: actions/cache@v3
27+
with:
28+
path: ~/.npm
29+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
30+
restore-keys: |
31+
${{ runner.os }}-node-
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
name: Test Workflow - No Matching Major Version
2+
on: push
3+
4+
jobs:
5+
test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v3
9+
- uses: step-security/action-semantic-pull-request@v6
10+
with:
11+
types: feat,fix,chore
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Test Workflow
2+
on: push
3+
4+
jobs:
5+
test:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v3
9+
- uses: step-security/action-semantic-pull-request@v6
10+
with:
11+
types: feat,fix,chore
12+
- uses: step-security/skip-duplicate-actions@v5
13+
with:
14+
do_not_skip: '["release"]'
15+
- uses: step-security/git-restore-mtime-action@v2
16+
with:
17+
pattern: '**/*'
18+
- uses: step-security/actions-cache/restore@v4
19+
with:
20+
path: ~/.npm
21+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
22+
restore-keys: |
23+
${{ runner.os }}-node-

0 commit comments

Comments
 (0)