-
Notifications
You must be signed in to change notification settings - Fork 41
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
Changes from 5 commits
8daceb1
7105c41
b99b655
f6256ca
ffdfe27
088800b
3da1738
7bcb807
cc5afdc
b994863
e9482c1
361b35a
8e7b11c
4672343
c096e5f
9c234e9
a84a433
787f313
9372c3f
ea1fb43
7906268
0e48ac5
e7de507
b57f3f7
086252d
582a89d
ee52f8f
ad10892
23233ec
14cc00f
1c68758
8fe562e
7903c6b
559d233
e0c7869
571b89a
0a7ecc9
a5395f3
8673c00
09a145c
6d49773
abc34e3
e4612f7
97fa737
acfa53b
01a8c09
b9b32d3
7bc0089
25f90b0
161b8b0
4af0234
ff05a33
03f4760
adfb0fa
830739d
eadfcec
22dbc7f
6ecd88d
837e44e
f101b58
953d1e1
e62e8f4
d81817d
9512ea4
9da4543
cfc9d10
76518e2
7dd4c19
1e3d19f
ab6b1a0
0a314e6
287df91
2c6acf6
d83b06a
754651f
69f0820
6ad2893
32f4cc0
a45e0e3
0eb8144
92198fc
f1c6979
cb9e08f
630c1e2
6423e32
7a92f67
1e0ada6
729990c
735c02a
197383b
99b254c
fea221a
95abd71
6f91094
5259779
fa7c85b
929021e
4b43059
93e48d5
31acb70
837f1d1
fe96d6f
1283961
3a3a0b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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/[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 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 | ||
} | ||
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 | ||
} |
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/[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, "/", "-") | ||
respFilePath := testFilesDir + fileName | ||
|
||
resp, err := ioutil.ReadFile(respFilePath) | ||
if err != nil { | ||
t.Fatalf("error reading test file") | ||
} | ||
|
||
return resp | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
But, since this has to be pinned with semantic version tag(in case when action is immutable). Please let me know if this aligns with expected behaviour or not. I'll go ahead and make changes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sailikhith-stepsecurity lets say someone has a workflow that uses v3 of codeql-action, and we need to pin the action. Given that codeql uses immutable action releases for its latest release, we should pin to the latest immutable semantic release for v3, right? or else how should be pin it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes @varunsh-coder , with the updated changes we will pin V3 to latest semantic version release. we rely on this existing function to get the semantic version of latest commit. I've updated test cases to reflect same. For Ex: I've mocked |
||
return inputYaml, updated | ||
} | ||
leftOfAt := strings.Split(action, "@") | ||
|
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" | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
homebrew/actions/remove-disabled-formulae is replaced by remove-disabled-packages.( REF - Homebrew/actions#567. )
which is causing our test workflow to fail here - https://github.com/step-security/secure-repo/actions/runs/12809184736/job/35713468641.
So, updating our kb.