Skip to content

Commit

Permalink
Merge pull request #2497 from step-security/immutable_action_int
Browse files Browse the repository at this point in the history
Immutable action int
  • Loading branch information
sailikhith-stepsecurity authored Jan 20, 2025
2 parents 929021e + 6ebc8cc commit 3592c2c
Show file tree
Hide file tree
Showing 17 changed files with 496 additions and 12 deletions.
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
1 change: 1 addition & 0 deletions remediation/workflow/metadata/actionmetadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ func TestKnowledgeBase(t *testing.T) {

func doesActionRepoExist(filePath string) bool {
splitOnSlash := strings.Split(filePath, "/")

owner := splitOnSlash[5]
repo := splitOnSlash[6]

Expand Down
112 changes: 112 additions & 0 deletions remediation/workflow/pin/action_image_manifest.go
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
}
163 changes: 163 additions & 0 deletions remediation/workflow/pin/action_image_manifest_test.go
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
}
29 changes: 27 additions & 2 deletions remediation/workflow/pin/pinactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"regexp"
"strings"

"github.com/google/go-github/v40/github"
Expand Down Expand Up @@ -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, "@")
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 3592c2c

Please sign in to comment.