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

Add immutable action check #2496

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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

This file was deleted.

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
110 changes: 110 additions & 0 deletions remediation/workflow/pin/action_image_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package pin

import (
"encoding/json"
"fmt"
"regexp"
"strings"

"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"
tagRegex = 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/checkout@v4.2.2")
//
// # Non-Immutable action (returns false)
// isImmutableAction("actions/checkout@v4.2.3")
//
// 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/checkout@v4.2.2")
//
// 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 tagRegex.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
}

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

View check run for this annotation

Codecov / codecov/patch

remediation/workflow/pin/action_image_manifest.go#L89-L90

Added lines #L89 - L90 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)
if err != nil {
return "", fmt.Errorf("error getting manifest: %v", err)
}

return string(desc.Manifest), nil
}
134 changes: 134 additions & 0 deletions remediation/workflow/pin/action_image_manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package pin

import (
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

var (
testFilesDir = "../../../testfiles/pinactions/immutableActionResponses/"
)

func createTestServer(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

w.Header().Set("Content-Type", "application/json")

// Mock manifest endpoints
switch r.URL.Path {

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/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 := createTestServer(t)
defer server.Close()

// Create a custom client that redirects ghcr.io to our test server
originalClient := http.DefaultClient
http.DefaultClient = &http.Client{
Transport: &http.Transport{
Proxy: func(req *http.Request) (*url.URL, error) {
if strings.Contains(req.URL.Host, "ghcr.io") {
return url.Parse(server.URL)
}
return nil, nil
},
},
}
defer func() {
http.DefaultClient = originalClient
}()

tests := []struct {
name string
action string
want bool
}{
{
name: "immutable action - 1",
sailikhith-stepsecurity marked this conversation as resolved.
Show resolved Hide resolved
action: "actions/checkout@v4.2.2",
want: true,
},
{
name: "immutable action - 2",
action: "step-security/wait-for-secrets@v1.2.0",
want: true,
},
{
name: "non immutable action(valid action)",
action: "sailikhith-stepsecurity/hello-action@v1.0.2",
want: false,
},
{
name: "non immutable action(invalid action)",
action: "sailikhith-stepsecurity/no-such-action@v1.0.2",
want: false,
},
{
name: " action with release tag doesn't exist",
action: "actions/checkout@1.2.3",
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, "/", "-")
respFilePath := testFilesDir + fileName

resp, err := ioutil.ReadFile(respFilePath)
if err != nil {
t.Fatalf("error reading test file")
}

return resp
}
2 changes: 1 addition & 1 deletion remediation/workflow/pin/pinactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it is an immutable action, we might need to still pin it. e.g. if it is @v1 it should be pinned to the semantic tag that corresponds to v1, e.g. v1.2.3. if it is an immutable action and already in semantic version, then we can ignore it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per my understanding from readme here, action can be immutable only with semantic versioning tag .

In mentioned example, If action is with @v1 tag...

  1. we won't be able to find v1 tag in the ghcr image tags...so, we return action as not immutable wrt v1 tag
  2. Then we go ahead and pin this action as per existing logic using sha.

But, since this has to be pinned with semantic version tag(in case when action is immutable).
we might to need to add a check if action is immutable with semantic-versioning-tag and pin it with semantic-versioning-tag instead of sha.

Please let me know if this aligns with expected behaviour or not. I'll go ahead and make changes.

return inputYaml, updated
}
leftOfAt := strings.Split(action, "@")
Expand Down
8 changes: 8 additions & 0 deletions testfiles/pinactions/immutableActionResponses/default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"errors":[
{
"code":"MANIFEST_UNKNOWN",
"message":"manifest unknown"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.github.actions.package.v1+json",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"size": 2,
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
},
"layers": [
{
"mediaType": "application/vnd.github.actions.package.layer.v1.tar+gzip",
"size": 424913,
"digest": "sha256:309d5e1a7604a5e688e2b55a6763e4109eb07349dca6d3c44e85c57ab2bb4f3f",
"annotations": {
"org.opencontainers.image.title": "actions-checkout_4.2.2.tar.gz"
}
},
{
"mediaType": "application/vnd.github.actions.package.layer.v1.zip",
"size": 546845,
"digest": "sha256:e9808fe811a75b46234757f9566987635166bca838090fcbc8021a0d45c737b3",
"annotations": {
"org.opencontainers.image.title": "actions-checkout_4.2.2.zip"
}
}
],
"annotations": {
"org.opencontainers.image.created": "2024-10-23T14:46:13.071Z",
"action.tar.gz.digest": "sha256:309d5e1a7604a5e688e2b55a6763e4109eb07349dca6d3c44e85c57ab2bb4f3f",
"action.zip.digest": "sha256:e9808fe811a75b46234757f9566987635166bca838090fcbc8021a0d45c737b3",
"com.github.package.type": "actions_oci_pkg",
"com.github.package.version": "4.2.2",
"com.github.source.repo.id": "197814629",
"com.github.source.repo.owner.id": "44036562",
"com.github.source.commit": "11bd71901bbe5b1630ceea73d27597364c9af683"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.github.actions.package.v1+json",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"size": 2,
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
},
"layers": [
{
"mediaType": "application/vnd.github.actions.package.layer.v1.tar+gzip",
"size": 689381,
"digest": "sha256:6390cea2d46095ef08dd2746d4323b11b7d1190d7e9ad9ef4a23b8ee5481d295",
"annotations": {
"org.opencontainers.image.title": "step-security-wait-for-secrets_1.2.0.tar.gz"
}
},
{
"mediaType": "application/vnd.github.actions.package.layer.v1.zip",
"size": 723541,
"digest": "sha256:56f5004c2b1bff0f148c3998aa0f5bd47a315a602428031b8ba72d881edfb429",
"annotations": {
"org.opencontainers.image.title": "step-security-wait-for-secrets_1.2.0.zip"
}
}
],
"annotations": {
"org.opencontainers.image.created": "2024-10-24T05:13:19.501Z",
"action.tar.gz.digest": "sha256:6390cea2d46095ef08dd2746d4323b11b7d1190d7e9ad9ef4a23b8ee5481d295",
"action.zip.digest": "sha256:56f5004c2b1bff0f148c3998aa0f5bd47a315a602428031b8ba72d881edfb429",
"com.github.package.type": "actions_oci_pkg",
"com.github.package.version": "1.2.0",
"com.github.source.repo.id": "498456330",
"com.github.source.repo.owner.id": "88700172",
"com.github.source.commit": "5809f7d044804a5a1d43217fa8f3e855939fc9ef"
}
}
Loading