Skip to content

Commit 2464ff7

Browse files
authored
Merge pull request #2562 from vamshi-stepsecurity/cherry-pick/feat-exempt-images
feat: Exempt images for Pinning and Maintained Actions support for Composite actions
2 parents 5e42463 + 6a28ab8 commit 2464ff7

File tree

13 files changed

+234
-11
lines changed

13 files changed

+234
-11
lines changed

remediation/docker/securedockerfile.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/google/go-containerregistry/pkg/authn"
1010
"github.com/google/go-containerregistry/pkg/name"
1111
"github.com/google/go-containerregistry/pkg/v1/remote"
12+
"github.com/step-security/secure-repo/remediation/workflow/pin"
1213
)
1314

1415
var Tr http.RoundTripper = remote.DefaultTransport
@@ -20,7 +21,11 @@ type SecureDockerfileResponse struct {
2021
DockerfileFetchError bool
2122
}
2223

23-
func SecureDockerFile(inputDockerFile string) (*SecureDockerfileResponse, error) {
24+
type DockerfileConfig struct {
25+
ExemptedImages []string
26+
}
27+
28+
func SecureDockerFile(inputDockerFile string, opts ...DockerfileConfig) (*SecureDockerfileResponse, error) {
2429
reader := strings.NewReader(inputDockerFile)
2530
cmds, err := dockerfile.ParseReader(reader)
2631
if err != nil {
@@ -32,6 +37,12 @@ func SecureDockerFile(inputDockerFile string) (*SecureDockerfileResponse, error)
3237
response.OriginalInput = inputDockerFile
3338
response.IsChanged = false
3439

40+
// Get exempted images list, default to empty if no config provided
41+
var exemptedImages []string
42+
if len(opts) > 0 {
43+
exemptedImages = opts[0].ExemptedImages
44+
}
45+
3546
for _, c := range cmds {
3647
if strings.Contains(c.Cmd, "FROM") && strings.Contains(c.Value[0], ":") {
3748
// For being fixable
@@ -64,6 +75,12 @@ func SecureDockerFile(inputDockerFile string) (*SecureDockerfileResponse, error)
6475
// is already pinned
6576
isPinned = true
6677
}
78+
79+
// Check if image is exempted (skip pinning)
80+
if len(exemptedImages) > 0 && pin.ActionExists(image, exemptedImages) {
81+
continue
82+
}
83+
6784
if !isPinned {
6885
sha, err := getSHA(image, tag)
6986
if err != nil {

remediation/docker/securedockerfile_test.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@ func TestSecureDockerFile(t *testing.T) {
4040
httpmock.RegisterResponder("GET", "https://index.docker.io/v2/library/python/manifests/3.7", httpmock.NewStringResponder(200, resp))
4141

4242
tests := []struct {
43-
fileName string
44-
isChanged bool
43+
fileName string
44+
isChanged bool
45+
exemptedImages []string
46+
useExemptConfig bool
4547
}{
46-
{fileName: "Dockerfile-not-pinned", isChanged: true},
47-
{fileName: "Dockerfile-not-pinned-as", isChanged: true},
48-
{fileName: "Dockerfile-multiple-images", isChanged: true},
48+
{fileName: "Dockerfile-not-pinned", isChanged: true, useExemptConfig: false},
49+
{fileName: "Dockerfile-not-pinned-as", isChanged: true, useExemptConfig: false},
50+
{fileName: "Dockerfile-multiple-images", isChanged: true, useExemptConfig: false},
51+
{fileName: "Dockerfile-exempted", isChanged: false, exemptedImages: []string{"python"}, useExemptConfig: true},
52+
{fileName: "Dockerfile-exempted-wildcard", isChanged: true, exemptedImages: []string{"amazon*", "alpine"}, useExemptConfig: true},
4953
}
5054

5155
for _, test := range tests {
@@ -55,7 +59,15 @@ func TestSecureDockerFile(t *testing.T) {
5559
log.Fatal(err)
5660
}
5761

58-
output, err := SecureDockerFile(string(input))
62+
var output *SecureDockerfileResponse
63+
if test.useExemptConfig {
64+
config := DockerfileConfig{
65+
ExemptedImages: test.exemptedImages,
66+
}
67+
output, err = SecureDockerFile(string(input), config)
68+
} else {
69+
output, err = SecureDockerFile(string(input))
70+
}
5971
if err != nil {
6072
t.Fatalf("Error not expected: %s", err)
6173
}

remediation/workflow/maintainedactions/maintainedActions.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,29 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin
9393
}
9494
}
9595
}
96+
97+
// For composite actions
98+
if workflow.Runs.Using == "composite" {
99+
for stepIdx, step := range workflow.Runs.Steps {
100+
if len(step.Uses) > 0 {
101+
actionName := strings.Split(step.Uses, "@")[0]
102+
if newAction, ok := actionMap[actionName]; ok {
103+
latestVersion, err := GetLatestRelease(newAction)
104+
if err != nil {
105+
return inputYaml, updated, fmt.Errorf("unable to get latest release: %v", err)
106+
}
107+
replacements = append(replacements, replacement{
108+
jobName: "composite", // special marker for composite actions
109+
stepIdx: stepIdx,
110+
newAction: newAction,
111+
originalAction: step.Uses,
112+
latestVersion: latestVersion,
113+
})
114+
}
115+
}
116+
}
117+
}
118+
96119
if len(replacements) == 0 {
97120
// No changes needed
98121
return inputYaml, false, nil
@@ -115,9 +138,19 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin
115138

116139
func replaceAction(t *yaml.Node, inputLines []string, replacements []replacement, updated bool) ([]string, bool) {
117140
for _, r := range replacements {
118-
jobsNode := permissions.IterateNode(t, "jobs", "!!map", 0)
119-
jobNode := permissions.IterateNode(jobsNode, r.jobName, "!!map", 0)
120-
stepsNode := permissions.IterateNode(jobNode, "steps", "!!seq", 0)
141+
var stepsNode *yaml.Node
142+
143+
if r.jobName == "composite" {
144+
// Handle composite actions
145+
runsNode := permissions.IterateNode(t, "runs", "!!map", 0)
146+
stepsNode = permissions.IterateNode(runsNode, "steps", "!!seq", 0)
147+
} else {
148+
// Handle regular workflow jobs
149+
jobsNode := permissions.IterateNode(t, "jobs", "!!map", 0)
150+
jobNode := permissions.IterateNode(jobsNode, r.jobName, "!!map", 0)
151+
stepsNode = permissions.IterateNode(jobNode, "steps", "!!seq", 0)
152+
}
153+
121154
if stepsNode == nil {
122155
continue
123156
}

remediation/workflow/maintainedactions/maintainedactions_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ func TestReplaceActions(t *testing.T) {
7777
wantUpdated: true,
7878
wantErr: false,
7979
},
80+
{
81+
name: "composite action with actions to replace",
82+
inputFile: "compositeAction.yml",
83+
outputFile: "compositeAction.yml",
84+
wantUpdated: true,
85+
wantErr: false,
86+
},
8087
}
8188

8289
for _, tt := range tests {

remediation/workflow/secureworkflow_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ func TestSecureWorkflow(t *testing.T) {
225225
{fileName: "multiplejobperms.yml", wantPinnedActions: false, wantAddedHardenRunner: false, wantAddedPermissions: true, wantError: false},
226226
{fileName: "error.yml", wantPinnedActions: false, wantAddedHardenRunner: false, wantAddedPermissions: false, wantError: false},
227227
{fileName: "missingaction.yml", wantPinnedActions: false, wantAddedHardenRunner: false, wantAddedPermissions: false, wantError: true},
228+
{fileName: "compositeAction.yml", wantPinnedActions: true, wantAddedHardenRunner: false, wantAddedPermissions: false, wantAddedMaintainedActions: true, wantError: false},
228229
}
229230
for _, test := range tests {
230231
var err error
@@ -256,12 +257,17 @@ func TestSecureWorkflow(t *testing.T) {
256257
queryParams["addHardenRunner"] = "true"
257258
queryParams["pinActions"] = "true"
258259
queryParams["addPermissions"] = "false"
260+
case "compositeAction.yml":
261+
queryParams["addMaintainedActions"] = "true"
262+
queryParams["addHardenRunner"] = "false"
263+
queryParams["pinActions"] = "true"
264+
queryParams["addPermissions"] = "false"
259265
}
260266
queryParams["addProjectComment"] = "false"
261267

262268
var output *permissions.SecureWorkflowReponse
263269
var actionMap map[string]string
264-
if test.fileName == "oneJob.yml" {
270+
if test.fileName == "oneJob.yml" || test.fileName == "compositeAction.yml" {
265271
actionMap, err = maintainedactions.LoadMaintainedActions("maintainedactions/maintainedActions.json")
266272
if err != nil {
267273
t.Errorf("unable to load the file %s", err)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Test file for exempted images
2+
# python should NOT be pinned because it's in the exempted list
3+
FROM python:3.7
4+
5+
RUN apt-get update && apt-get install -y vim
6+
7+
WORKDIR /app
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Test file for wildcard exemptions
2+
# amazonlinux should NOT be pinned (matches amazon*)
3+
# alpine should NOT be pinned (exact match)
4+
# python SHOULD be pinned (not exempted)
5+
FROM amazonlinux:2023
6+
7+
RUN yum install -y python3
8+
9+
FROM alpine:3.18
10+
11+
RUN apk add --no-cache bash
12+
13+
FROM python:3.7
14+
15+
RUN pip install requests
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Test file for exempted images
2+
# python should NOT be pinned because it's in the exempted list
3+
FROM python:3.7
4+
5+
RUN apt-get update && apt-get install -y vim
6+
7+
WORKDIR /app
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Test file for wildcard exemptions
2+
# amazonlinux should NOT be pinned (matches amazon*)
3+
# alpine should NOT be pinned (exact match)
4+
# python SHOULD be pinned (not exempted)
5+
FROM amazonlinux:2023
6+
7+
RUN yum install -y python3
8+
9+
FROM alpine:3.18
10+
11+
RUN apk add --no-cache bash
12+
13+
FROM python:3.7@sha256:5fb6f4b9d73ddeb0e431c938bee25c69157a1e3c880a81ff72c43a8055628de5
14+
15+
RUN pip install requests
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: amannn/action-semantic-pull-request@v5
14+
with:
15+
types: feat,fix,chore
16+
17+
- uses: fkirc/skip-duplicate-actions@v5
18+
with:
19+
do_not_skip: '["release"]'
20+
21+
- uses: chetan/git-restore-mtime-action@v1
22+
with:
23+
pattern: '**/*'
24+
25+
- name: Run custom script
26+
run: echo "Running custom script"
27+
shell: bash

0 commit comments

Comments
 (0)