-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2497 from step-security/immutable_action_int
Immutable action int
- Loading branch information
Showing
17 changed files
with
496 additions
and
12 deletions.
There are no files selected for viewing
2 changes: 2 additions & 0 deletions
2
knowledge-base/actions/homebrew/actions/remove-disabled-packages/action-security.yml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
name: Remove disabled packages # Homebrew/actions/remove-disabled-packages | ||
# GITHUB_TOKEN not used |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
package pin | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"regexp" | ||
"strings" | ||
|
||
"net/http" | ||
|
||
"github.com/google/go-containerregistry/pkg/name" | ||
"github.com/google/go-containerregistry/pkg/v1/remote" | ||
"github.com/sirupsen/logrus" | ||
) | ||
|
||
var ( | ||
githubImmutableActionArtifactType = "application/vnd.github.actions.package.v1+json" | ||
semanticTagRegex = regexp.MustCompile(`v[0-9]+\.[0-9]+\.[0-9]+$`) | ||
) | ||
|
||
type ociManifest struct { | ||
ArtifactType string `json:"artifactType"` | ||
} | ||
|
||
// isImmutableAction checks if the action is an immutable action or not | ||
// It queries the OCI manifest for the action and checks if the artifact type is "application/vnd.github.actions.package.v1+json" | ||
// | ||
// Example usage: | ||
// | ||
// # Immutable action (returns true) | ||
// isImmutableAction("actions/[email protected]") | ||
// | ||
// # Non-Immutable action (returns false) | ||
// isImmutableAction("actions/[email protected]") | ||
// | ||
// REF - https://github.com/actions/publish-immutable-action/issues/216#issuecomment-2549914784 | ||
func IsImmutableAction(action string) bool { | ||
|
||
artifactType, err := getOCIImageArtifactTypeForGhAction(action) | ||
if err != nil { | ||
// log the error | ||
logrus.WithFields(logrus.Fields{"action": action}).WithError(err).Error("error in getting OCI manifest for image") | ||
return false | ||
} | ||
|
||
if artifactType == githubImmutableActionArtifactType { | ||
return true | ||
} | ||
return false | ||
|
||
} | ||
|
||
// getOCIImageArtifactTypeForGhAction retrieves the artifact type from a GitHub Action's OCI manifest. | ||
// This function is used to determine if an action is immutable by checking its artifact type. | ||
// | ||
// Example usage: | ||
// | ||
// # Immutable action (returns "application/vnd.github.actions.package.v1+json", nil) | ||
// artifactType, err := getOCIImageArtifactTypeForGhAction("actions/[email protected]") | ||
// | ||
// Returns: | ||
// - artifactType: The artifact type string from the OCI manifest | ||
// - error: An error if the action format is invalid or if there's a problem retrieving the manifest | ||
func getOCIImageArtifactTypeForGhAction(action string) (string, error) { | ||
|
||
// Split the action into parts (e.g., "actions/checkout@v2" -> ["actions/checkout", "v2"]) | ||
parts := strings.Split(action, "@") | ||
if len(parts) != 2 { | ||
return "", fmt.Errorf("invalid action format") | ||
} | ||
|
||
// convert v1.x.x to 1.x.x which is | ||
// use regexp to match tag version format and replace v in prefix | ||
// as immutable actions image tag is in format 1.x.x (without v prefix) | ||
// REF - https://github.com/actions/publish-immutable-action/issues/216#issuecomment-2549914784 | ||
if semanticTagRegex.MatchString(parts[1]) { | ||
// v1.x.x -> 1.x.x | ||
parts[1] = strings.TrimPrefix(parts[1], "v") | ||
} | ||
|
||
// Convert GitHub action to GHCR image reference using proper OCI reference format | ||
image := fmt.Sprintf("ghcr.io/%s:%s", parts[0], parts[1]) | ||
imageManifest, err := getOCIManifestForImage(image) | ||
if err != nil { | ||
return "", err | ||
} | ||
|
||
var ociManifest ociManifest | ||
err = json.Unmarshal([]byte(imageManifest), &ociManifest) | ||
if err != nil { | ||
return "", err | ||
} | ||
return ociManifest.ArtifactType, nil | ||
} | ||
|
||
// getOCIManifestForImage retrieves the artifact type from the OCI image manifest | ||
func getOCIManifestForImage(imageRef string) (string, error) { | ||
|
||
// Parse the image reference | ||
ref, err := name.ParseReference(imageRef) | ||
if err != nil { | ||
return "", fmt.Errorf("error parsing reference: %v", err) | ||
} | ||
|
||
// Get the image manifest | ||
desc, err := remote.Get(ref, remote.WithTransport(http.DefaultTransport)) | ||
if err != nil { | ||
return "", fmt.Errorf("error getting manifest: %v", err) | ||
} | ||
|
||
return string(desc.Manifest), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
package pin | ||
|
||
import ( | ||
"crypto/tls" | ||
"io/ioutil" | ||
"net/http" | ||
"net/http/httptest" | ||
"path/filepath" | ||
"strings" | ||
"testing" | ||
) | ||
|
||
type customTransport struct { | ||
base http.RoundTripper | ||
baseURL string | ||
} | ||
|
||
func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
if strings.Contains(req.URL.Host, "ghcr.io") { | ||
req2 := req.Clone(req.Context()) | ||
req2.URL.Scheme = "https" | ||
req2.URL.Host = strings.TrimPrefix(t.baseURL, "https://") | ||
return t.base.RoundTrip(req2) | ||
} | ||
return t.base.RoundTrip(req) | ||
} | ||
|
||
func createGhesTestServer(t *testing.T) *httptest.Server { | ||
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
|
||
w.Header().Set("Content-Type", "application/json") | ||
|
||
if !strings.Contains(r.Host, "ghcr.io") { | ||
w.WriteHeader(http.StatusNotFound) | ||
return | ||
} | ||
// Mock manifest endpoints | ||
switch r.URL.Path { | ||
|
||
case "/v2/": // simulate ping request | ||
w.WriteHeader(http.StatusOK) | ||
|
||
case "/token": | ||
// for immutable actions, since image will be present in registry...it returns 200 OK with token | ||
// otherwise it returns 403 Forbidden | ||
scope := r.URL.Query().Get("scope") | ||
switch scope { | ||
case "repository:actions/checkout:pull": | ||
fallthrough | ||
case "repository:step-security/wait-for-secrets:pull": | ||
|
||
w.WriteHeader(http.StatusOK) | ||
w.Write([]byte(`{"token": "test-token", "access_token": "test-token"}`)) | ||
default: | ||
w.WriteHeader(http.StatusForbidden) | ||
w.Write([]byte(`{"errors": [{"code": "DENIED", "message": "requested access to the resource is denied"}]}`)) | ||
} | ||
|
||
case "/v2/actions/checkout/manifests/4.2.2": | ||
fallthrough | ||
case "/v2/actions/checkout/manifests/1.2.0": | ||
fallthrough | ||
case "/v2/step-security/wait-for-secrets/manifests/1.2.0": | ||
w.Write(readHttpResponseForAction(t, r.URL.Path)) | ||
case "/v2/actions/checkout/manifests/1.2.3": // since this version doesn't exist | ||
fallthrough | ||
default: | ||
w.WriteHeader(http.StatusNotFound) | ||
w.Write(readHttpResponseForAction(t, "default")) | ||
} | ||
})) | ||
} | ||
|
||
func Test_isImmutableAction(t *testing.T) { | ||
// Create test server that mocks GitHub Container Registry | ||
server := createGhesTestServer(t) | ||
defer server.Close() | ||
|
||
// Create a custom client that redirects ghcr.io to our test server | ||
originalClient := http.DefaultClient | ||
http.DefaultClient = &http.Client{ | ||
Transport: &customTransport{ | ||
base: &http.Transport{ | ||
TLSClientConfig: &tls.Config{ | ||
InsecureSkipVerify: true, | ||
}, | ||
}, | ||
baseURL: server.URL, | ||
}, | ||
} | ||
|
||
// update default transport | ||
OriginalTransport := http.DefaultTransport | ||
http.DefaultTransport = http.DefaultClient.Transport | ||
|
||
defer func() { | ||
http.DefaultClient = originalClient | ||
http.DefaultTransport = OriginalTransport | ||
}() | ||
|
||
tests := []struct { | ||
name string | ||
action string | ||
want bool | ||
}{ | ||
{ | ||
name: "immutable action - 1", | ||
action: "actions/[email protected]", | ||
want: true, | ||
}, | ||
{ | ||
name: "immutable action - 2", | ||
action: "step-security/[email protected]", | ||
want: true, | ||
}, | ||
{ | ||
name: "non immutable action(valid action)", | ||
action: "sailikhith-stepsecurity/[email protected]", | ||
want: false, | ||
}, | ||
{ | ||
name: "non immutable action(invalid action)", | ||
action: "sailikhith-stepsecurity/[email protected]", | ||
want: false, | ||
}, | ||
{ | ||
name: " action with release tag doesn't exist", | ||
action: "actions/[email protected]", | ||
want: false, | ||
}, | ||
{ | ||
name: "invalid action format", | ||
action: "invalid-format", | ||
want: false, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
|
||
got := IsImmutableAction(tt.action) | ||
if got != tt.want { | ||
t.Errorf("isImmutableAction() = %v, want %v", got, tt.want) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func readHttpResponseForAction(t *testing.T, actionPath string) []byte { | ||
// remove v2 prefix from action path | ||
actionPath = strings.TrimPrefix(actionPath, "/v2/") | ||
|
||
fileName := strings.ReplaceAll(actionPath, "/", "-") + ".json" | ||
testFilesDir := "../../../testfiles/pinactions/immutableActionResponses/" | ||
respFilePath := filepath.Join(testFilesDir, fileName) | ||
|
||
resp, err := ioutil.ReadFile(respFilePath) | ||
if err != nil { | ||
t.Fatalf("error reading test file:%v", err) | ||
} | ||
|
||
return resp | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import ( | |
"context" | ||
"fmt" | ||
"os" | ||
"regexp" | ||
"strings" | ||
|
||
"github.com/google/go-github/v40/github" | ||
|
@@ -43,7 +44,7 @@ func PinAction(action, inputYaml string) (string, bool) { | |
return inputYaml, updated // Cannot pin local actions and docker actions | ||
} | ||
|
||
if isAbsolute(action) { | ||
if isAbsolute(action) || IsImmutableAction(action) { | ||
return inputYaml, updated | ||
} | ||
leftOfAt := strings.Split(action, "@") | ||
|
@@ -74,8 +75,32 @@ func PinAction(action, inputYaml string) (string, bool) { | |
} | ||
|
||
pinnedAction := fmt.Sprintf("%s@%s # %s", leftOfAt[0], commitSHA, tagOrBranch) | ||
|
||
// if the action with version is immutable, then pin the action with version instead of sha | ||
pinnedActionWithVersion := fmt.Sprintf("%s@%s", leftOfAt[0], tagOrBranch) | ||
if semanticTagRegex.MatchString(tagOrBranch) && IsImmutableAction(pinnedActionWithVersion) { | ||
pinnedAction = pinnedActionWithVersion | ||
} | ||
|
||
updated = !strings.EqualFold(action, pinnedAction) | ||
inputYaml = strings.ReplaceAll(inputYaml, action, pinnedAction) | ||
|
||
// strings.ReplaceAll is not suitable here because it would incorrectly replace substrings | ||
// 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]" | ||
// making new string to "actions/[email protected]" | ||
// | ||
// Instead, we use a regex pattern that ensures we only replace complete action references: | ||
// Pattern: (<action>@<version>)($|\s|"|') | ||
// - Group 1 (<action>@<version>): Captures the exact action reference | ||
// - Group 2 ($|\s|"|'): Captures the delimiter that follows (end of line, whitespace, or quotes) | ||
// | ||
// Examples: | ||
// - "actions/[email protected]" - No match (no delimiter after v1) | ||
// - "actions/checkout@v1 " - Matches (space delimiter) | ||
// - "actions/checkout@v1"" - Matches (quote delimiter) | ||
// - "actions/checkout@v1" - Matches (quote delimiter) | ||
// - "actions/checkout@v1\n" - Matches (newline is considered whitespace \s) | ||
actionRegex := regexp.MustCompile(`(` + regexp.QuoteMeta(action) + `)($|\s|"|')`) | ||
inputYaml = actionRegex.ReplaceAllString(inputYaml, pinnedAction+"$2") | ||
yamlWithPreviousActionCommentsRemoved, wasModified := removePreviousActionComments(pinnedAction, inputYaml) | ||
if wasModified { | ||
return yamlWithPreviousActionCommentsRemoved, updated | ||
|
Oops, something went wrong.