Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Immutable actions to main #2498

Merged
merged 7 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
120 changes: 120 additions & 0 deletions remediation/workflow/pin/action_image_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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

Check warning on line 49 in remediation/workflow/pin/action_image_manifest.go

View check run for this annotation

Codecov / codecov/patch

remediation/workflow/pin/action_image_manifest.go#L49

Added line #L49 was not covered by tests

}

// 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")
}

// For bundled actions like github/codeql-action/analyze@v3,
// we only need the repository part (github/codeql-action) to check for immutability
actionPath := parts[0]
if strings.Count(parts[0], "/") > 1 {
pathParts := strings.Split(parts[0], "/")
actionPath = strings.Join(pathParts[:2], "/")
}

// 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", actionPath, 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
}

Check warning on line 100 in remediation/workflow/pin/action_image_manifest.go

View check run for this annotation

Codecov / codecov/patch

remediation/workflow/pin/action_image_manifest.go#L99-L100

Added lines #L99 - L100 were not covered by tests
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
Loading