Skip to content

Commit af18f63

Browse files
Merge pull request #2498 from step-security/immutable_actions_master
Immutable actions to main
2 parents da66ed5 + 8e5d1fb commit af18f63

17 files changed

+523
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
name: Remove disabled packages # Homebrew/actions/remove-disabled-packages
2+
# GITHUB_TOKEN not used

remediation/workflow/metadata/actionmetadata_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func TestKnowledgeBase(t *testing.T) {
181181

182182
func doesActionRepoExist(filePath string) bool {
183183
splitOnSlash := strings.Split(filePath, "/")
184+
184185
owner := splitOnSlash[5]
185186
repo := splitOnSlash[6]
186187

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package pin
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"regexp"
7+
"strings"
8+
9+
"net/http"
10+
11+
"github.com/google/go-containerregistry/pkg/name"
12+
"github.com/google/go-containerregistry/pkg/v1/remote"
13+
"github.com/sirupsen/logrus"
14+
)
15+
16+
var (
17+
githubImmutableActionArtifactType = "application/vnd.github.actions.package.v1+json"
18+
semanticTagRegex = regexp.MustCompile(`v[0-9]+\.[0-9]+\.[0-9]+$`)
19+
)
20+
21+
type ociManifest struct {
22+
ArtifactType string `json:"artifactType"`
23+
}
24+
25+
// isImmutableAction checks if the action is an immutable action or not
26+
// It queries the OCI manifest for the action and checks if the artifact type is "application/vnd.github.actions.package.v1+json"
27+
//
28+
// Example usage:
29+
//
30+
// # Immutable action (returns true)
31+
// isImmutableAction("actions/[email protected]")
32+
//
33+
// # Non-Immutable action (returns false)
34+
// isImmutableAction("actions/[email protected]")
35+
//
36+
// REF - https://github.com/actions/publish-immutable-action/issues/216#issuecomment-2549914784
37+
func IsImmutableAction(action string) bool {
38+
39+
artifactType, err := getOCIImageArtifactTypeForGhAction(action)
40+
if err != nil {
41+
// log the error
42+
logrus.WithFields(logrus.Fields{"action": action}).WithError(err).Error("error in getting OCI manifest for image")
43+
return false
44+
}
45+
46+
if artifactType == githubImmutableActionArtifactType {
47+
return true
48+
}
49+
return false
50+
51+
}
52+
53+
// getOCIImageArtifactTypeForGhAction retrieves the artifact type from a GitHub Action's OCI manifest.
54+
// This function is used to determine if an action is immutable by checking its artifact type.
55+
//
56+
// Example usage:
57+
//
58+
// # Immutable action (returns "application/vnd.github.actions.package.v1+json", nil)
59+
// artifactType, err := getOCIImageArtifactTypeForGhAction("actions/[email protected]")
60+
//
61+
// Returns:
62+
// - artifactType: The artifact type string from the OCI manifest
63+
// - error: An error if the action format is invalid or if there's a problem retrieving the manifest
64+
func getOCIImageArtifactTypeForGhAction(action string) (string, error) {
65+
66+
// Split the action into parts (e.g., "actions/checkout@v2" -> ["actions/checkout", "v2"])
67+
parts := strings.Split(action, "@")
68+
if len(parts) != 2 {
69+
return "", fmt.Errorf("invalid action format")
70+
}
71+
72+
// For bundled actions like github/codeql-action/analyze@v3,
73+
// we only need the repository part (github/codeql-action) to check for immutability
74+
actionPath := parts[0]
75+
if strings.Count(parts[0], "/") > 1 {
76+
pathParts := strings.Split(parts[0], "/")
77+
actionPath = strings.Join(pathParts[:2], "/")
78+
}
79+
80+
// convert v1.x.x to 1.x.x which is
81+
// use regexp to match tag version format and replace v in prefix
82+
// as immutable actions image tag is in format 1.x.x (without v prefix)
83+
// REF - https://github.com/actions/publish-immutable-action/issues/216#issuecomment-2549914784
84+
if semanticTagRegex.MatchString(parts[1]) {
85+
// v1.x.x -> 1.x.x
86+
parts[1] = strings.TrimPrefix(parts[1], "v")
87+
}
88+
89+
// Convert GitHub action to GHCR image reference using proper OCI reference format
90+
image := fmt.Sprintf("ghcr.io/%s:%s", actionPath, parts[1])
91+
imageManifest, err := getOCIManifestForImage(image)
92+
if err != nil {
93+
return "", err
94+
}
95+
96+
var ociManifest ociManifest
97+
err = json.Unmarshal([]byte(imageManifest), &ociManifest)
98+
if err != nil {
99+
return "", err
100+
}
101+
return ociManifest.ArtifactType, nil
102+
}
103+
104+
// getOCIManifestForImage retrieves the artifact type from the OCI image manifest
105+
func getOCIManifestForImage(imageRef string) (string, error) {
106+
107+
// Parse the image reference
108+
ref, err := name.ParseReference(imageRef)
109+
if err != nil {
110+
return "", fmt.Errorf("error parsing reference: %v", err)
111+
}
112+
113+
// Get the image manifest
114+
desc, err := remote.Get(ref, remote.WithTransport(http.DefaultTransport))
115+
if err != nil {
116+
return "", fmt.Errorf("error getting manifest: %v", err)
117+
}
118+
119+
return string(desc.Manifest), nil
120+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package pin
2+
3+
import (
4+
"crypto/tls"
5+
"io/ioutil"
6+
"net/http"
7+
"net/http/httptest"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
)
12+
13+
type customTransport struct {
14+
base http.RoundTripper
15+
baseURL string
16+
}
17+
18+
func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) {
19+
if strings.Contains(req.URL.Host, "ghcr.io") {
20+
req2 := req.Clone(req.Context())
21+
req2.URL.Scheme = "https"
22+
req2.URL.Host = strings.TrimPrefix(t.baseURL, "https://")
23+
return t.base.RoundTrip(req2)
24+
}
25+
return t.base.RoundTrip(req)
26+
}
27+
28+
func createGhesTestServer(t *testing.T) *httptest.Server {
29+
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30+
31+
w.Header().Set("Content-Type", "application/json")
32+
33+
if !strings.Contains(r.Host, "ghcr.io") {
34+
w.WriteHeader(http.StatusNotFound)
35+
return
36+
}
37+
// Mock manifest endpoints
38+
switch r.URL.Path {
39+
40+
case "/v2/": // simulate ping request
41+
w.WriteHeader(http.StatusOK)
42+
43+
case "/token":
44+
// for immutable actions, since image will be present in registry...it returns 200 OK with token
45+
// otherwise it returns 403 Forbidden
46+
scope := r.URL.Query().Get("scope")
47+
switch scope {
48+
case "repository:actions/checkout:pull":
49+
fallthrough
50+
case "repository:step-security/wait-for-secrets:pull":
51+
52+
w.WriteHeader(http.StatusOK)
53+
w.Write([]byte(`{"token": "test-token", "access_token": "test-token"}`))
54+
default:
55+
w.WriteHeader(http.StatusForbidden)
56+
w.Write([]byte(`{"errors": [{"code": "DENIED", "message": "requested access to the resource is denied"}]}`))
57+
}
58+
59+
case "/v2/actions/checkout/manifests/4.2.2":
60+
fallthrough
61+
case "/v2/actions/checkout/manifests/1.2.0":
62+
fallthrough
63+
case "/v2/step-security/wait-for-secrets/manifests/1.2.0":
64+
w.Write(readHttpResponseForAction(t, r.URL.Path))
65+
case "/v2/actions/checkout/manifests/1.2.3": // since this version doesn't exist
66+
fallthrough
67+
default:
68+
w.WriteHeader(http.StatusNotFound)
69+
w.Write(readHttpResponseForAction(t, "default"))
70+
}
71+
}))
72+
}
73+
74+
func Test_isImmutableAction(t *testing.T) {
75+
// Create test server that mocks GitHub Container Registry
76+
server := createGhesTestServer(t)
77+
defer server.Close()
78+
79+
// Create a custom client that redirects ghcr.io to our test server
80+
originalClient := http.DefaultClient
81+
http.DefaultClient = &http.Client{
82+
Transport: &customTransport{
83+
base: &http.Transport{
84+
TLSClientConfig: &tls.Config{
85+
InsecureSkipVerify: true,
86+
},
87+
},
88+
baseURL: server.URL,
89+
},
90+
}
91+
92+
// update default transport
93+
OriginalTransport := http.DefaultTransport
94+
http.DefaultTransport = http.DefaultClient.Transport
95+
96+
defer func() {
97+
http.DefaultClient = originalClient
98+
http.DefaultTransport = OriginalTransport
99+
}()
100+
101+
tests := []struct {
102+
name string
103+
action string
104+
want bool
105+
}{
106+
{
107+
name: "immutable action - 1",
108+
action: "actions/[email protected]",
109+
want: true,
110+
},
111+
{
112+
name: "immutable action - 2",
113+
action: "step-security/[email protected]",
114+
want: true,
115+
},
116+
{
117+
name: "non immutable action(valid action)",
118+
action: "sailikhith-stepsecurity/[email protected]",
119+
want: false,
120+
},
121+
{
122+
name: "non immutable action(invalid action)",
123+
action: "sailikhith-stepsecurity/[email protected]",
124+
want: false,
125+
},
126+
{
127+
name: " action with release tag doesn't exist",
128+
action: "actions/[email protected]",
129+
want: false,
130+
},
131+
{
132+
name: "invalid action format",
133+
action: "invalid-format",
134+
want: false,
135+
},
136+
}
137+
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
141+
got := IsImmutableAction(tt.action)
142+
if got != tt.want {
143+
t.Errorf("isImmutableAction() = %v, want %v", got, tt.want)
144+
}
145+
})
146+
}
147+
}
148+
149+
func readHttpResponseForAction(t *testing.T, actionPath string) []byte {
150+
// remove v2 prefix from action path
151+
actionPath = strings.TrimPrefix(actionPath, "/v2/")
152+
153+
fileName := strings.ReplaceAll(actionPath, "/", "-") + ".json"
154+
testFilesDir := "../../../testfiles/pinactions/immutableActionResponses/"
155+
respFilePath := filepath.Join(testFilesDir, fileName)
156+
157+
resp, err := ioutil.ReadFile(respFilePath)
158+
if err != nil {
159+
t.Fatalf("error reading test file:%v", err)
160+
}
161+
162+
return resp
163+
}

remediation/workflow/pin/pinactions.go

+27-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"regexp"
78
"strings"
89

910
"github.com/google/go-github/v40/github"
@@ -43,7 +44,7 @@ func PinAction(action, inputYaml string) (string, bool) {
4344
return inputYaml, updated // Cannot pin local actions and docker actions
4445
}
4546

46-
if isAbsolute(action) {
47+
if isAbsolute(action) || IsImmutableAction(action) {
4748
return inputYaml, updated
4849
}
4950
leftOfAt := strings.Split(action, "@")
@@ -74,8 +75,32 @@ func PinAction(action, inputYaml string) (string, bool) {
7475
}
7576

7677
pinnedAction := fmt.Sprintf("%s@%s # %s", leftOfAt[0], commitSHA, tagOrBranch)
78+
79+
// if the action with version is immutable, then pin the action with version instead of sha
80+
pinnedActionWithVersion := fmt.Sprintf("%s@%s", leftOfAt[0], tagOrBranch)
81+
if semanticTagRegex.MatchString(tagOrBranch) && IsImmutableAction(pinnedActionWithVersion) {
82+
pinnedAction = pinnedActionWithVersion
83+
}
84+
7785
updated = !strings.EqualFold(action, pinnedAction)
78-
inputYaml = strings.ReplaceAll(inputYaml, action, pinnedAction)
86+
87+
// strings.ReplaceAll is not suitable here because it would incorrectly replace substrings
88+
// For example, if we want to replace "actions/checkout@v1" to "actions/[email protected]", it would also incorrectly match and replace in "actions/[email protected]"
89+
// making new string to "actions/[email protected]"
90+
//
91+
// Instead, we use a regex pattern that ensures we only replace complete action references:
92+
// Pattern: (<action>@<version>)($|\s|"|')
93+
// - Group 1 (<action>@<version>): Captures the exact action reference
94+
// - Group 2 ($|\s|"|'): Captures the delimiter that follows (end of line, whitespace, or quotes)
95+
//
96+
// Examples:
97+
// - "actions/[email protected]" - No match (no delimiter after v1)
98+
// - "actions/checkout@v1 " - Matches (space delimiter)
99+
// - "actions/checkout@v1"" - Matches (quote delimiter)
100+
// - "actions/checkout@v1" - Matches (quote delimiter)
101+
// - "actions/checkout@v1\n" - Matches (newline is considered whitespace \s)
102+
actionRegex := regexp.MustCompile(`(` + regexp.QuoteMeta(action) + `)($|\s|"|')`)
103+
inputYaml = actionRegex.ReplaceAllString(inputYaml, pinnedAction+"$2")
79104
yamlWithPreviousActionCommentsRemoved, wasModified := removePreviousActionComments(pinnedAction, inputYaml)
80105
if wasModified {
81106
return yamlWithPreviousActionCommentsRemoved, updated

0 commit comments

Comments
 (0)