diff --git a/remediation/workflow/maintainedactions/getlatestrelease.go b/remediation/workflow/maintainedactions/getlatestrelease.go index af5359b87..016c5fea5 100644 --- a/remediation/workflow/maintainedactions/getlatestrelease.go +++ b/remediation/workflow/maintainedactions/getlatestrelease.go @@ -64,3 +64,91 @@ func GetLatestRelease(ownerRepo string) (string, error) { return getMajorVersion(release.GetTagName()), nil } + +// GetMajorTagFromSHA finds the major version tag (e.g., "v5") on ownerRepo +// whose commit matches the given SHA, by listing all tags with prefix "tags/v". +// Returns ("", nil) if no matching tag is found. +func GetMajorTagFromSHA(ownerRepo, sha string) (string, error) { + splitOnSlash := strings.Split(ownerRepo, "/") + if len(splitOnSlash) < 2 { + return "", fmt.Errorf("invalid owner/repo format: %s", ownerRepo) + } + owner := splitOnSlash[0] + repo := splitOnSlash[1] + + ctx := context.Background() + client := github.NewClient(nil) + + token := os.Getenv("PAT") + if token != "" { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + client = github.NewClient(oauth2.NewClient(ctx, ts)) + } + + refs, _, err := client.Git.ListMatchingRefs(ctx, owner, repo, &github.ReferenceListOptions{ + Ref: "tags/v", + }) + if err != nil { + return "", err + } + + for _, ref := range refs { + var refSHA string + if ref.GetObject().GetType() == "commit" { + refSHA = ref.GetObject().GetSHA() + } else { + // annotated tag — dereference to get the commit SHA + refSHA, _, err = client.Repositories.GetCommitSHA1(ctx, owner, repo, ref.GetRef(), "") + if err != nil { + continue + } + } + if refSHA == sha { + tag := strings.TrimPrefix(ref.GetRef(), "refs/tags/") + return getMajorVersion(tag), nil + } + } + return "", nil +} + +// GetMajorTagIfExists checks whether ownerRepo has a tag exactly matching +// majorVersion (e.g., "v5"). Returns (majorVersion, true, nil) when the tag +// exists, ("", false, nil) when it is absent (404), and ("", false, err) for +// unexpected API failures. +func GetMajorTagIfExists(ownerRepo, majorVersion string) (string, bool, error) { + splitOnSlash := strings.Split(ownerRepo, "/") + if len(splitOnSlash) < 2 { + return "", false, fmt.Errorf("invalid owner/repo format: %s", ownerRepo) + } + owner := splitOnSlash[0] + repo := splitOnSlash[1] + + ctx := context.Background() + client := github.NewClient(nil) + + _, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+majorVersion) + if err == nil { + return majorVersion, true, nil + } + if resp != nil && resp.StatusCode == 404 { + return "", false, nil + } + + // First attempt failed for a non-404 reason — retry with token. + token := os.Getenv("PAT") + if token == "" { + return "", false, nil + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client = github.NewClient(tc) + + _, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/tags/"+majorVersion) + if err == nil { + return majorVersion, true, nil + } + if resp != nil && resp.StatusCode == 404 { + return "", false, nil + } + return "", false, fmt.Errorf("failed to check tag %s on %s: %w", majorVersion, ownerRepo, err) +} diff --git a/remediation/workflow/maintainedactions/getlatestrelease_test.go b/remediation/workflow/maintainedactions/getlatestrelease_test.go new file mode 100644 index 000000000..c844d3037 --- /dev/null +++ b/remediation/workflow/maintainedactions/getlatestrelease_test.go @@ -0,0 +1,389 @@ +package maintainedactions + +import ( + "io/ioutil" + "net/http" + "path" + "testing" + + "github.com/jarcoal/httpmock" +) + +func TestGetMajorVersion(t *testing.T) { + cases := map[string]string{ + "v5": "v5", + "v5.5.5": "v5", + "5.5.5": "5", + "5": "5", + "v": "v", + "": "", + } + for in, want := range cases { + if got := getMajorVersion(in); got != want { + t.Errorf("getMajorVersion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGetLatestRelease_InvalidRepo(t *testing.T) { + if _, err := GetLatestRelease("no-slash"); err == nil { + t.Fatal("expected error for invalid owner/repo") + } +} + +func TestGetLatestRelease_NoPATFails(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "") + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/releases/latest", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + if _, err := GetLatestRelease("owner/repo"); err == nil { + t.Fatal("expected error when first call fails and no PAT is set") + } +} + +func TestGetLatestRelease_PATRetrySucceeds(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + calls := 0 + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/releases/latest", + func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return httpmock.NewStringResponse(500, `{"message":"boom"}`), nil + } + return httpmock.NewStringResponse(200, `{"tag_name":"v3.2.1"}`), nil + }) + v, err := GetLatestRelease("owner/repo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v3" { + t.Errorf("got %q, want v3", v) + } +} + +func TestGetLatestRelease_PATRetryFails(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/releases/latest", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + if _, err := GetLatestRelease("owner/repo"); err == nil { + t.Fatal("expected error when both attempts fail") + } +} + +// GetMajorTagFromSHA + +func TestGetMajorTagFromSHA_InvalidRepo(t *testing.T) { + if _, err := GetMajorTagFromSHA("no-slash", "abc"); err == nil { + t.Fatal("expected error for invalid owner/repo") + } +} + +func TestGetMajorTagFromSHA_ListError(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + if _, err := GetMajorTagFromSHA("owner/repo", "anything"); err == nil { + t.Fatal("expected error from ListMatchingRefs failure") + } +} + +func TestGetMajorTagFromSHA_CommitMatch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v2.0.0","object":{"sha":"aaaa","type":"commit"}}, + {"ref":"refs/tags/v5.1.0","object":{"sha":"bbbb","type":"commit"}} + ]`)) + v, err := GetMajorTagFromSHA("owner/repo", "bbbb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v5" { + t.Errorf("got %q, want v5", v) + } +} + +func TestGetMajorTagFromSHA_AnnotatedTagMatch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v3.0.0","object":{"sha":"tagsha","type":"tag"}} + ]`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/commits/refs/tags/v3.0.0", + httpmock.NewStringResponder(200, `commitsha`)) + v, err := GetMajorTagFromSHA("owner/repo", "commitsha") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v3" { + t.Errorf("got %q, want v3", v) + } +} + +func TestGetMajorTagFromSHA_AnnotatedDerefError(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v3.0.0","object":{"sha":"tagsha","type":"tag"}}, + {"ref":"refs/tags/v4.0.0","object":{"sha":"match","type":"commit"}} + ]`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/commits/refs/tags/v3.0.0", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + v, err := GetMajorTagFromSHA("owner/repo", "match") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v4" { + t.Errorf("got %q, want v4 (deref error should be skipped, not fatal)", v) + } +} + +func TestGetMajorTagFromSHA_NoMatch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v2.0.0","object":{"sha":"aaaa","type":"commit"}} + ]`)) + v, err := GetMajorTagFromSHA("owner/repo", "nomatch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "" { + t.Errorf("got %q, want empty string", v) + } +} + +func TestGetMajorTagFromSHA_WithPAT(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v1.0.0","object":{"sha":"match","type":"commit"}} + ]`)) + v, err := GetMajorTagFromSHA("owner/repo", "match") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v1" { + t.Errorf("got %q, want v1", v) + } +} + +// GetMajorTagIfExists + +func TestGetMajorTagIfExists_InvalidRepo(t *testing.T) { + if _, _, err := GetMajorTagIfExists("no-slash", "v1"); err == nil { + t.Fatal("expected error for invalid owner/repo") + } +} + +func TestGetMajorTagIfExists_NonErrorNoPAT(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "") + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/ref/tags/v5", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + tag, exists, err := GetMajorTagIfExists("owner/repo", "v5") + if err != nil || exists || tag != "" { + t.Errorf("got tag=%q exists=%v err=%v, want empty/false/nil", tag, exists, err) + } +} + +func TestGetMajorTagIfExists_PATRetrySucceeds(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + calls := 0 + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/ref/tags/v5", + func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return httpmock.NewStringResponse(500, `{"message":"boom"}`), nil + } + return httpmock.NewStringResponse(200, + `{"ref":"refs/tags/v5","object":{"sha":"x","type":"commit"}}`), nil + }) + tag, exists, err := GetMajorTagIfExists("owner/repo", "v5") + if err != nil || !exists || tag != "v5" { + t.Errorf("got tag=%q exists=%v err=%v, want v5/true/nil", tag, exists, err) + } +} + +func TestGetMajorTagIfExists_PATRetry404(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + calls := 0 + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/ref/tags/v5", + func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return httpmock.NewStringResponse(500, `{"message":"boom"}`), nil + } + return httpmock.NewStringResponse(404, `{"message":"Not Found"}`), nil + }) + tag, exists, err := GetMajorTagIfExists("owner/repo", "v5") + if err != nil || exists || tag != "" { + t.Errorf("got tag=%q exists=%v err=%v, want empty/false/nil", tag, exists, err) + } +} + +func TestGetMajorTagIfExists_PATRetryFails(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + t.Setenv("PAT", "fake-token") + httpmock.RegisterResponder("GET", "https://api.github.com/repos/owner/repo/git/ref/tags/v5", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + _, exists, err := GetMajorTagIfExists("owner/repo", "v5") + if err == nil { + t.Fatal("expected wrapped error when both attempts fail with non-404") + } + if exists { + t.Error("expected exists=false") + } +} + +// resolveVersion + +func TestResolveVersion_NoRef(t *testing.T) { + if _, err := resolveVersion("actions/checkout", "actions/checkout", "new/action", true); err == nil { + t.Fatal("expected error when originalUses has no @ref") + } +} + +func TestResolveVersion_SHAResolved(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/orig/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[ + {"ref":"refs/tags/v5.2.0","object":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","type":"commit"}} + ]`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/new/repo/git/ref/tags/v5", + httpmock.NewStringResponder(200, + `{"ref":"refs/tags/v5","object":{"sha":"x","type":"commit"}}`)) + uses := "orig/repo@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + v, err := resolveVersion(uses, "orig/repo", "new/repo", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "v5" { + t.Errorf("got %q, want v5", v) + } +} + +func TestResolveVersion_SHALookupFails(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/orig/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(500, `{"message":"boom"}`)) + uses := "orig/repo@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, err := resolveVersion(uses, "orig/repo", "new/repo", true); err == nil { + t.Fatal("expected error when SHA lookup fails") + } +} + +func TestResolveVersion_SHANoMatch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("GET", "https://api.github.com/repos/orig/repo/git/matching-refs/tags/v", + httpmock.NewStringResponder(200, `[]`)) + uses := "orig/repo@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, err := resolveVersion(uses, "orig/repo", "new/repo", true); err == nil { + t.Fatal("expected error when SHA has no matching tag") + } +} + +// LoadMaintainedActions + +func TestLoadMaintainedActions_ReadError(t *testing.T) { + if _, err := LoadMaintainedActions("/nonexistent/path/to/file.json"); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestLoadMaintainedActions_InvalidJSON(t *testing.T) { + dir := t.TempDir() + f := path.Join(dir, "bad.json") + if err := ioutil.WriteFile(f, []byte("{not valid json"), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + if _, err := LoadMaintainedActions(f); err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +// ReplaceActions + +func TestReplaceActions_InvalidYAML(t *testing.T) { + // A mapping key cannot also be a sequence at the same indent — yaml.Unmarshal errors. + bad := "foo: bar\n- item" + if _, _, err := ReplaceActions(bad, map[string]string{}, false); err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestReplaceActions_ReusableWorkflowSkipped(t *testing.T) { + // A job that calls a reusable workflow (has top-level `uses:`) should be skipped. + // No HTTP calls should be made, and no replacements should occur. + httpmock.Activate() + defer httpmock.DeactivateAndReset() + input := `name: reusable +on: push +jobs: + call: + uses: ./.github/workflows/other.yml +` + actionMap := map[string]string{"amannn/action-semantic-pull-request": "step-security/action-semantic-pull-request"} + got, updated, err := ReplaceActions(input, actionMap, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated { + t.Error("expected updated=false for reusable workflow") + } + if got != input { + t.Errorf("expected input unchanged, got %q", got) + } +} + +func TestReplaceActions_CompositeResolverFailureSkipped(t *testing.T) { + // Composite action where the resolver fails on the single mapped step should + // skip that step (log + continue) and return unchanged input. + httpmock.Activate() + defer httpmock.DeactivateAndReset() + // Fork has no matching major version. + httpmock.RegisterResponder("GET", + "https://api.github.com/repos/step-security/action-semantic-pull-request/git/ref/tags/v3", + httpmock.NewStringResponder(404, `{"message":"Not Found"}`)) + + input := `name: composite +runs: + using: composite + steps: + - uses: amannn/action-semantic-pull-request@v3 +` + actionMap := map[string]string{ + "amannn/action-semantic-pull-request": "step-security/action-semantic-pull-request", + } + got, updated, err := ReplaceActions(input, actionMap, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated { + t.Error("expected updated=false when composite resolver fails") + } + if got != input { + t.Errorf("expected input unchanged, got %q", got) + } +} diff --git a/remediation/workflow/maintainedactions/maintainedActions.go b/remediation/workflow/maintainedactions/maintainedActions.go index d0be4884d..b537b2090 100644 --- a/remediation/workflow/maintainedactions/maintainedActions.go +++ b/remediation/workflow/maintainedactions/maintainedActions.go @@ -4,10 +4,12 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "strings" "github.com/step-security/secure-repo/remediation/workflow/metadata" "github.com/step-security/secure-repo/remediation/workflow/permissions" + "github.com/step-security/secure-repo/remediation/workflow/pin" "gopkg.in/yaml.v3" ) @@ -55,8 +57,44 @@ func LoadMaintainedActions(jsonPath string) (map[string]string, error) { return actionMap, nil } -// ReplaceActions replaces original actions with Step Security actions in a workflow -func ReplaceActions(inputYaml string, customerMaintainedActions map[string]string) (string, bool, error) { +// resolveVersion determines the version to use for the replacement action. +// When replaceByMajorTag is true, it matches the major version from the original action. +// When false (default), it uses the latest release of the new action. +func resolveVersion(originalUses, actionName, newAction string, replaceByMajorTag bool) (string, error) { + if !replaceByMajorTag { + return GetLatestRelease(newAction) + } + + parts := strings.SplitN(originalUses, "@", 2) + if len(parts) < 2 || parts[1] == "" { + return "", fmt.Errorf("no ref found in %s", originalUses) + } + ref := parts[1] + var version string + var err error + if len(ref) == 40 && pin.IsAllHex(ref) { + version, err = GetMajorTagFromSHA(actionName, ref) + if err != nil { + return "", fmt.Errorf("unable to resolve SHA %s to major tag: %w", ref, err) + } + if version == "" { + return "", fmt.Errorf("unable to resolve SHA %s to major tag", ref) + } + } else { + version = ref + } + majorVersion := getMajorVersion(version) + tag, exists, err := GetMajorTagIfExists(newAction, majorVersion) + if err != nil || !exists { + return "", fmt.Errorf("major tag %s not found on %s", majorVersion, newAction) + } + return tag, nil +} + +// ReplaceActions replaces original actions with Step Security actions in a workflow. +// When replaceByMajorTag is true, the replacement action uses the same major version as the original. +// When false (default), it uses the latest release of the replacement action. +func ReplaceActions(inputYaml string, customerMaintainedActions map[string]string, replaceByMajorTag bool) (string, bool, error) { workflow := metadata.Workflow{} updated := false @@ -76,19 +114,19 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin continue } for stepIdx, step := range job.Steps { - // fmt.Println("step ", step.Uses) actionName := strings.Split(step.Uses, "@")[0] if newAction, ok := actionMap[actionName]; ok { - latestVersion, err := GetLatestRelease(newAction) + version, err := resolveVersion(step.Uses, actionName, newAction, replaceByMajorTag) if err != nil { - return inputYaml, updated, fmt.Errorf("unable to get latest release: %v", err) + log.Printf("skipping replacement of %s: %v", step.Uses, err) + continue } replacements = append(replacements, replacement{ jobName: jobName, stepIdx: stepIdx, newAction: newAction, originalAction: step.Uses, - latestVersion: latestVersion, + latestVersion: version, }) } } @@ -100,16 +138,17 @@ func ReplaceActions(inputYaml string, customerMaintainedActions map[string]strin if len(step.Uses) > 0 { actionName := strings.Split(step.Uses, "@")[0] if newAction, ok := actionMap[actionName]; ok { - latestVersion, err := GetLatestRelease(newAction) + version, err := resolveVersion(step.Uses, actionName, newAction, replaceByMajorTag) if err != nil { - return inputYaml, updated, fmt.Errorf("unable to get latest release: %v", err) + log.Printf("skipping replacement of %s: %v", step.Uses, err) + continue } replacements = append(replacements, replacement{ - jobName: "composite", // special marker for composite actions + jobName: "composite", stepIdx: stepIdx, newAction: newAction, originalAction: step.Uses, - latestVersion: latestVersion, + latestVersion: version, }) } } diff --git a/remediation/workflow/maintainedactions/maintainedactions_test.go b/remediation/workflow/maintainedactions/maintainedactions_test.go index 0e47e07ba..4af2f06b7 100644 --- a/remediation/workflow/maintainedactions/maintainedactions_test.go +++ b/remediation/workflow/maintainedactions/maintainedactions_test.go @@ -16,38 +16,21 @@ func TestReplaceActions(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() - // Mock GitHub API responses for getting latest releases - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v5.5.5", - "name": "v5.5.5", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) + // Mock GitHub API responses for checking major version tags on forks + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/git/ref/tags/v5", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v5","object":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","type":"commit"}}`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v5.3.2", - "name": "v5.3.2", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/git/ref/tags/v3", + httpmock.NewStringResponder(404, `{"message":"Not Found"}`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v2.1.0", - "name": "v2.1.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/git/ref/tags/v5", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v5","object":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","type":"commit"}}`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v1.0.0", - "name": "v1.0.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/git/ref/tags/v1", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v1","object":{"sha":"cccccccccccccccccccccccccccccccccccccccc","type":"commit"}}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/git/ref/tags/v1", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v1","object":{"sha":"dddddddddddddddddddddddddddddddddddddddd","type":"commit"}}`)) tests := []struct { name string @@ -58,32 +41,39 @@ func TestReplaceActions(t *testing.T) { }{ { name: "one job with actions to replace", - inputFile: "oneJob.yml", - outputFile: "oneJob.yml", + inputFile: "oneJob_majorTag.yml", + outputFile: "oneJob_majorTag.yml", wantUpdated: true, wantErr: false, }, { name: "no changes needed - already using maintained actions", - inputFile: "noChangesNeeded.yml", - outputFile: "noChangesNeeded.yml", + inputFile: "noChangesNeeded_majorTag.yml", + outputFile: "noChangesNeeded_majorTag.yml", wantUpdated: false, wantErr: false, }, { name: "double job with actions to replace", - inputFile: "doubleJob.yml", - outputFile: "doubleJob.yml", + inputFile: "doubleJob_majorTag.yml", + outputFile: "doubleJob_majorTag.yml", wantUpdated: true, wantErr: false, }, { name: "composite action with actions to replace", - inputFile: "compositeAction.yml", - outputFile: "compositeAction.yml", + inputFile: "compositeAction_majorTag.yml", + outputFile: "compositeAction_majorTag.yml", wantUpdated: true, wantErr: false, }, + { + name: "no replacement when fork does not have matching major version", + inputFile: "noMatchingMajorVersion_majorTag.yml", + outputFile: "noMatchingMajorVersion_majorTag.yml", + wantUpdated: false, + wantErr: false, + }, } for _, tt := range tests { @@ -98,7 +88,7 @@ func TestReplaceActions(t *testing.T) { t.Errorf("ReplaceActions() unable to json file %v", err) return } - got, updated, replaceErr := ReplaceActions(string(input), actionMap) + got, updated, replaceErr := ReplaceActions(string(input), actionMap, true) // Check error if (replaceErr != nil) != tt.wantErr { @@ -125,3 +115,107 @@ func TestReplaceActions(t *testing.T) { }) } } + +func TestReplaceActionsLatestRelease(t *testing.T) { + const inputDirectory = "../../../testfiles/maintainedActions/input" + const outputDirectory = "../../../testfiles/maintainedActions/output" + + // Activate httpmock + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // Mock GitHub API responses for GetLatestRelease (GET /repos/{owner}/{repo}/releases/latest) + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/releases/latest", + httpmock.NewStringResponder(200, `{"id":1,"tag_name":"v6.1.0","name":"v6.1.0"}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/releases/latest", + httpmock.NewStringResponder(200, `{"id":2,"tag_name":"v5.3.1","name":"v5.3.1"}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/releases/latest", + httpmock.NewStringResponder(200, `{"id":3,"tag_name":"v2.0.0","name":"v2.0.0"}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/releases/latest", + httpmock.NewStringResponder(200, `{"id":4,"tag_name":"v4.0.0","name":"v4.0.0"}`)) + + tests := []struct { + name string + inputFile string + outputFile string + wantUpdated bool + wantErr bool + }{ + { + name: "one job with latest release versions", + inputFile: "oneJob_latest.yml", + outputFile: "oneJob_latest.yml", + wantUpdated: true, + wantErr: false, + }, + { + name: "no changes needed - already using maintained actions", + inputFile: "noChangesNeeded_latest.yml", + outputFile: "noChangesNeeded_latest.yml", + wantUpdated: false, + wantErr: false, + }, + { + name: "double job with latest release versions", + inputFile: "doubleJob_latest.yml", + outputFile: "doubleJob_latest.yml", + wantUpdated: true, + wantErr: false, + }, + { + name: "composite action with latest release versions", + inputFile: "compositeAction_latest.yml", + outputFile: "compositeAction_latest.yml", + wantUpdated: true, + wantErr: false, + }, + { + name: "replacement happens even when major version differs (latest release used)", + inputFile: "noMatchingMajorVersion_latest.yml", + outputFile: "noMatchingMajorVersion_latest.yml", + wantUpdated: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Read input file + input, err := ioutil.ReadFile(path.Join(inputDirectory, tt.inputFile)) + if err != nil { + t.Fatalf("error reading input file: %v", err) + } + actionMap, err := LoadMaintainedActions("maintainedActions.json") + if err != nil { + t.Errorf("ReplaceActions() unable to json file %v", err) + return + } + got, updated, replaceErr := ReplaceActions(string(input), actionMap, false) + + // Check error + if (replaceErr != nil) != tt.wantErr { + t.Errorf("ReplaceActions() error = %v, wantErr %v", replaceErr, tt.wantErr) + return + } + + // Check if updated flag matches + if updated != tt.wantUpdated { + t.Errorf("ReplaceActions() updated = %v, wantUpdated %v", updated, tt.wantUpdated) + } + + // Read expected output file + expectedOutput, err := ioutil.ReadFile(path.Join(outputDirectory, tt.outputFile)) + if err != nil { + t.Fatalf("error reading expected output file: %v", err) + } + + // Compare output with expected + if got != string(expectedOutput) { + t.Errorf("ReplaceActions() = %v, want %v", got, string(expectedOutput)) + } + }) + } +} \ No newline at end of file diff --git a/remediation/workflow/pin/pinactions.go b/remediation/workflow/pin/pinactions.go index 626f76ca9..cd0df7a54 100644 --- a/remediation/workflow/pin/pinactions.go +++ b/remediation/workflow/pin/pinactions.go @@ -228,20 +228,20 @@ func isAbsolute(ref string) bool { parts := strings.Split(ref, "@") last := parts[len(parts)-1] - if len(last) == 40 && isAllHex(last) { + if len(last) == 40 && IsAllHex(last) { return true } - if len(last) == 71 && last[:6] == "sha256" && isAllHex(last[7:]) { + if len(last) == 71 && last[:6] == "sha256" && IsAllHex(last[7:]) { return true } return false } -// isAllHex returns true if the given string is all hex characters, false +// IsAllHex returns true if the given string is all hex characters, false // otherwise. -func isAllHex(s string) bool { +func IsAllHex(s string) bool { for _, ch := range s { if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') { return false diff --git a/remediation/workflow/secureworkflow.go b/remediation/workflow/secureworkflow.go index 46dbb0c6c..c47da6328 100644 --- a/remediation/workflow/secureworkflow.go +++ b/remediation/workflow/secureworkflow.go @@ -25,6 +25,7 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d enableLogging := false addEmptyTopLevelPermissions := false skipHardenRunnerForContainers := false + replaceActionByMajorTag := false exemptedActions, pinToImmutable, maintainedActionsMap, actionCommitMap, runnerLabelMap := []string{}, false, map[string]string{}, map[string]string{}, map[string]string{} hardenRunnerConfig := hardenrunner.HardenRunnerConfig{} @@ -98,6 +99,10 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d skipHardenRunnerForContainers = true } + if queryStringParams["replaceActionByMajorTag"] == "true" { + replaceActionByMajorTag = true + } + if enableLogging { // Log query parameters paramsJSON, _ := json.MarshalIndent(queryStringParams, "", " ") @@ -151,7 +156,7 @@ func SecureWorkflow(queryStringParams map[string]string, inputYaml string, svc d } if replaceMaintainedActions { - secureWorkflowReponse.FinalOutput, replacedMaintainedActions, err = maintainedactions.ReplaceActions(secureWorkflowReponse.FinalOutput, maintainedActionsMap) + secureWorkflowReponse.FinalOutput, replacedMaintainedActions, err = maintainedactions.ReplaceActions(secureWorkflowReponse.FinalOutput, maintainedActionsMap, replaceActionByMajorTag) if err != nil { log.Printf("Error replacing maintained actions: %v", err) secureWorkflowReponse.HasErrors = true diff --git a/remediation/workflow/secureworkflow_test.go b/remediation/workflow/secureworkflow_test.go index 0c014826f..36ea88617 100644 --- a/remediation/workflow/secureworkflow_test.go +++ b/remediation/workflow/secureworkflow_test.go @@ -109,39 +109,20 @@ func TestSecureWorkflow(t *testing.T) { ]`), ) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v5.5.5", - "name": "v5.5.5", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) - - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v2.1.0", - "name": "v2.1.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) - - httpmock.RegisterResponder("GET", "https://api.github.com/repos/github/super-linter/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v4.9.0", - "name": "v4.9.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) - - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v2.1.0", - "name": "v2.1.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) - - // Mock APIs for step-security/action-semantic-pull-request + // Mock GetMajorTagIfExists calls (GET /git/ref/tags/vN) for ReplaceActions + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/git/ref/tags/v5", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v5","object":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","type":"commit"}}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/git/ref/tags/v5", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v5","object":{"sha":"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5","type":"commit"}}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/git/ref/tags/v1", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v1","object":{"sha":"f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1","type":"commit"}}`)) + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/git/ref/tags/v1", + httpmock.NewStringResponder(200, `{"ref":"refs/tags/v1","object":{"sha":"dddddddddddddddddddddddddddddddddddddddd","type":"commit"}}`)) + + // Mock PinActions calls for step-security/action-semantic-pull-request@v5 httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/action-semantic-pull-request/commits/v5", httpmock.NewStringResponder(200, `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0`)) @@ -156,36 +137,37 @@ func TestSecureWorkflow(t *testing.T) { } ]`)) - // Mock APIs for step-security/skip-duplicate-actions - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/commits/v2", - httpmock.NewStringResponder(200, `b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1`)) + // Mock PinActions calls for step-security/skip-duplicate-actions@v5 + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/commits/v5", + httpmock.NewStringResponder(200, `e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/git/matching-refs/tags/v2.", + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/skip-duplicate-actions/git/matching-refs/tags/v5.", httpmock.NewStringResponder(200, `[ { - "ref": "refs/tags/v2.1.0", + "ref": "refs/tags/v5.3.0", "object": { - "sha": "b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1", + "sha": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", "type": "commit" } } ]`)) - // Mock APIs for step-security/git-restore-mtime-action - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/commits/v2", - httpmock.NewStringResponder(200, `c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1b2`)) + // Mock PinActions calls for step-security/git-restore-mtime-action@v1 + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/commits/v1", + httpmock.NewStringResponder(200, `f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/git/matching-refs/tags/v2.", + httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/git-restore-mtime-action/git/matching-refs/tags/v1.", httpmock.NewStringResponder(200, `[ { - "ref": "refs/tags/v2.1.0", + "ref": "refs/tags/v1.1.0", "object": { - "sha": "c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1b2", + "sha": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1", "type": "commit" } } ]`)) + // Mock PinActions calls for step-security/actions-cache@v1 httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/commits/v1", httpmock.NewStringResponder(200, `d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1b2c3`)) @@ -200,14 +182,6 @@ func TestSecureWorkflow(t *testing.T) { } ]`)) - httpmock.RegisterResponder("GET", "https://api.github.com/repos/step-security/actions-cache/releases/latest", - httpmock.NewStringResponder(200, `{ - "tag_name": "v1.0.0", - "name": "v1.0.0", - "body": "Release notes", - "created_at": "2023-01-01T00:00:00Z" - }`)) - tests := []struct { fileName string wantPinnedActions bool @@ -257,11 +231,13 @@ func TestSecureWorkflow(t *testing.T) { queryParams["addHardenRunner"] = "true" queryParams["pinActions"] = "true" queryParams["addPermissions"] = "false" + queryParams["replaceActionByMajorTag"] = "true" case "compositeAction.yml": queryParams["addMaintainedActions"] = "true" queryParams["addHardenRunner"] = "false" queryParams["pinActions"] = "true" queryParams["addPermissions"] = "false" + queryParams["replaceActionByMajorTag"] = "true" } queryParams["addProjectComment"] = "false" diff --git a/testfiles/maintainedActions/input/compositeAction.yml b/testfiles/maintainedActions/input/compositeAction_latest.yml similarity index 100% rename from testfiles/maintainedActions/input/compositeAction.yml rename to testfiles/maintainedActions/input/compositeAction_latest.yml diff --git a/testfiles/maintainedActions/input/compositeAction_majorTag.yml b/testfiles/maintainedActions/input/compositeAction_majorTag.yml new file mode 100644 index 000000000..5a0348cca --- /dev/null +++ b/testfiles/maintainedActions/input/compositeAction_majorTag.yml @@ -0,0 +1,27 @@ +name: 'Test Composite Action' +description: 'Test composite action for maintained actions replacement' +branding: + icon: 'arrow-up' + color: 'blue' +inputs: + component: + description: 'Component Name' + required: true +runs: + using: 'composite' + steps: + - uses: amannn/action-semantic-pull-request@v5 + with: + types: feat,fix,chore + + - uses: fkirc/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + + - uses: chetan/git-restore-mtime-action@v1 + with: + pattern: '**/*' + + - name: Run custom script + run: echo "Running custom script" + shell: bash diff --git a/testfiles/maintainedActions/input/doubleJob.yml b/testfiles/maintainedActions/input/doubleJob_latest.yml similarity index 100% rename from testfiles/maintainedActions/input/doubleJob.yml rename to testfiles/maintainedActions/input/doubleJob_latest.yml diff --git a/testfiles/maintainedActions/input/doubleJob_majorTag.yml b/testfiles/maintainedActions/input/doubleJob_majorTag.yml new file mode 100644 index 000000000..9b4807e7c --- /dev/null +++ b/testfiles/maintainedActions/input/doubleJob_majorTag.yml @@ -0,0 +1,31 @@ +name: Test Workflow - Double Job +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + with: + types: feat,fix,chore + - uses: fkirc/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + - uses: step-security/git-restore-mtime-action@v2 + with: + pattern: '**/*' + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '16' + - uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- \ No newline at end of file diff --git a/testfiles/maintainedActions/input/noChangesNeeded.yml b/testfiles/maintainedActions/input/noChangesNeeded_latest.yml similarity index 100% rename from testfiles/maintainedActions/input/noChangesNeeded.yml rename to testfiles/maintainedActions/input/noChangesNeeded_latest.yml diff --git a/testfiles/maintainedActions/output/noChangesNeeded.yml b/testfiles/maintainedActions/input/noChangesNeeded_majorTag.yml similarity index 100% rename from testfiles/maintainedActions/output/noChangesNeeded.yml rename to testfiles/maintainedActions/input/noChangesNeeded_majorTag.yml diff --git a/testfiles/maintainedActions/input/noMatchingMajorVersion_latest.yml b/testfiles/maintainedActions/input/noMatchingMajorVersion_latest.yml new file mode 100644 index 000000000..9871d28a7 --- /dev/null +++ b/testfiles/maintainedActions/input/noMatchingMajorVersion_latest.yml @@ -0,0 +1,11 @@ +name: Test Workflow - No Matching Major Version +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: amannn/action-semantic-pull-request@v3 + with: + types: feat,fix,chore diff --git a/testfiles/maintainedActions/input/noMatchingMajorVersion_majorTag.yml b/testfiles/maintainedActions/input/noMatchingMajorVersion_majorTag.yml new file mode 100644 index 000000000..9871d28a7 --- /dev/null +++ b/testfiles/maintainedActions/input/noMatchingMajorVersion_majorTag.yml @@ -0,0 +1,11 @@ +name: Test Workflow - No Matching Major Version +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: amannn/action-semantic-pull-request@v3 + with: + types: feat,fix,chore diff --git a/testfiles/maintainedActions/input/oneJob.yml b/testfiles/maintainedActions/input/oneJob_latest.yml similarity index 100% rename from testfiles/maintainedActions/input/oneJob.yml rename to testfiles/maintainedActions/input/oneJob_latest.yml diff --git a/testfiles/maintainedActions/input/oneJob_majorTag.yml b/testfiles/maintainedActions/input/oneJob_majorTag.yml new file mode 100644 index 000000000..939d87c02 --- /dev/null +++ b/testfiles/maintainedActions/input/oneJob_majorTag.yml @@ -0,0 +1,23 @@ +name: Test Workflow +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: amannn/action-semantic-pull-request@v5 + with: + types: feat,fix,chore + - uses: fkirc/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + - uses: chetan/git-restore-mtime-action@v1 + with: + pattern: '**/*' + - uses: tespkg/actions-cache/restore@v1 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- \ No newline at end of file diff --git a/testfiles/maintainedActions/output/compositeAction_latest.yml b/testfiles/maintainedActions/output/compositeAction_latest.yml new file mode 100644 index 000000000..5f27445d8 --- /dev/null +++ b/testfiles/maintainedActions/output/compositeAction_latest.yml @@ -0,0 +1,27 @@ +name: 'Test Composite Action' +description: 'Test composite action for maintained actions replacement' +branding: + icon: 'arrow-up' + color: 'blue' +inputs: + component: + description: 'Component Name' + required: true +runs: + using: 'composite' + steps: + - uses: step-security/action-semantic-pull-request@v6 + with: + types: feat,fix,chore + + - uses: step-security/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + + - uses: step-security/git-restore-mtime-action@v2 + with: + pattern: '**/*' + + - name: Run custom script + run: echo "Running custom script" + shell: bash diff --git a/testfiles/maintainedActions/output/compositeAction.yml b/testfiles/maintainedActions/output/compositeAction_majorTag.yml similarity index 91% rename from testfiles/maintainedActions/output/compositeAction.yml rename to testfiles/maintainedActions/output/compositeAction_majorTag.yml index 200af9d36..794753f03 100644 --- a/testfiles/maintainedActions/output/compositeAction.yml +++ b/testfiles/maintainedActions/output/compositeAction_majorTag.yml @@ -18,7 +18,7 @@ runs: with: do_not_skip: '["release"]' - - uses: step-security/git-restore-mtime-action@v2 + - uses: step-security/git-restore-mtime-action@v1 with: pattern: '**/*' diff --git a/testfiles/maintainedActions/output/doubleJob_latest.yml b/testfiles/maintainedActions/output/doubleJob_latest.yml new file mode 100644 index 000000000..a9e5803bc --- /dev/null +++ b/testfiles/maintainedActions/output/doubleJob_latest.yml @@ -0,0 +1,31 @@ +name: Test Workflow - Double Job +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: step-security/action-semantic-pull-request@v6 + with: + types: feat,fix,chore + - uses: step-security/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + - uses: step-security/git-restore-mtime-action@v2 + with: + pattern: '**/*' + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '16' + - uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- \ No newline at end of file diff --git a/testfiles/maintainedActions/output/doubleJob.yml b/testfiles/maintainedActions/output/doubleJob_majorTag.yml similarity index 100% rename from testfiles/maintainedActions/output/doubleJob.yml rename to testfiles/maintainedActions/output/doubleJob_majorTag.yml diff --git a/testfiles/maintainedActions/output/noChangesNeeded_latest.yml b/testfiles/maintainedActions/output/noChangesNeeded_latest.yml new file mode 100644 index 000000000..5557b01e6 --- /dev/null +++ b/testfiles/maintainedActions/output/noChangesNeeded_latest.yml @@ -0,0 +1,17 @@ +name: Test Workflow - No Changes Needed +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: step-security/checkout@v3 + - uses: step-security/action-semantic-pull-request@v5.5.5 + with: + types: feat,fix,chore + - uses: step-security/skip-duplicate-actions@v5.3.2 + with: + do_not_skip: '["release"]' + - uses: step-security/git-restore-mtime-action@v2.1.0 + with: + pattern: '**/*' \ No newline at end of file diff --git a/testfiles/maintainedActions/output/noChangesNeeded_majorTag.yml b/testfiles/maintainedActions/output/noChangesNeeded_majorTag.yml new file mode 100644 index 000000000..5557b01e6 --- /dev/null +++ b/testfiles/maintainedActions/output/noChangesNeeded_majorTag.yml @@ -0,0 +1,17 @@ +name: Test Workflow - No Changes Needed +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: step-security/checkout@v3 + - uses: step-security/action-semantic-pull-request@v5.5.5 + with: + types: feat,fix,chore + - uses: step-security/skip-duplicate-actions@v5.3.2 + with: + do_not_skip: '["release"]' + - uses: step-security/git-restore-mtime-action@v2.1.0 + with: + pattern: '**/*' \ No newline at end of file diff --git a/testfiles/maintainedActions/output/noMatchingMajorVersion_latest.yml b/testfiles/maintainedActions/output/noMatchingMajorVersion_latest.yml new file mode 100644 index 000000000..6f4b1dfa5 --- /dev/null +++ b/testfiles/maintainedActions/output/noMatchingMajorVersion_latest.yml @@ -0,0 +1,11 @@ +name: Test Workflow - No Matching Major Version +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: step-security/action-semantic-pull-request@v6 + with: + types: feat,fix,chore diff --git a/testfiles/maintainedActions/output/noMatchingMajorVersion_majorTag.yml b/testfiles/maintainedActions/output/noMatchingMajorVersion_majorTag.yml new file mode 100644 index 000000000..9871d28a7 --- /dev/null +++ b/testfiles/maintainedActions/output/noMatchingMajorVersion_majorTag.yml @@ -0,0 +1,11 @@ +name: Test Workflow - No Matching Major Version +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: amannn/action-semantic-pull-request@v3 + with: + types: feat,fix,chore diff --git a/testfiles/maintainedActions/output/oneJob_latest.yml b/testfiles/maintainedActions/output/oneJob_latest.yml new file mode 100644 index 000000000..0cff6bf1f --- /dev/null +++ b/testfiles/maintainedActions/output/oneJob_latest.yml @@ -0,0 +1,23 @@ +name: Test Workflow +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: step-security/action-semantic-pull-request@v6 + with: + types: feat,fix,chore + - uses: step-security/skip-duplicate-actions@v5 + with: + do_not_skip: '["release"]' + - uses: step-security/git-restore-mtime-action@v2 + with: + pattern: '**/*' + - uses: step-security/actions-cache/restore@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- \ No newline at end of file diff --git a/testfiles/maintainedActions/output/oneJob.yml b/testfiles/maintainedActions/output/oneJob_majorTag.yml similarity index 91% rename from testfiles/maintainedActions/output/oneJob.yml rename to testfiles/maintainedActions/output/oneJob_majorTag.yml index e3ed9c165..c383e07ca 100644 --- a/testfiles/maintainedActions/output/oneJob.yml +++ b/testfiles/maintainedActions/output/oneJob_majorTag.yml @@ -12,7 +12,7 @@ jobs: - uses: step-security/skip-duplicate-actions@v5 with: do_not_skip: '["release"]' - - uses: step-security/git-restore-mtime-action@v2 + - uses: step-security/git-restore-mtime-action@v1 with: pattern: '**/*' - uses: step-security/actions-cache/restore@v1 diff --git a/testfiles/secureworkflow/output/compositeAction.yml b/testfiles/secureworkflow/output/compositeAction.yml index 964429bc8..234a82089 100644 --- a/testfiles/secureworkflow/output/compositeAction.yml +++ b/testfiles/secureworkflow/output/compositeAction.yml @@ -16,7 +16,7 @@ runs: with: types: feat,fix,chore - - uses: step-security/skip-duplicate-actions@b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1 # v2.1.0 + - uses: step-security/skip-duplicate-actions@e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5 # v5.3.0 with: do_not_skip: '["release"]' diff --git a/testfiles/secureworkflow/output/oneJob.yml b/testfiles/secureworkflow/output/oneJob.yml index ada285de0..6e2401966 100644 --- a/testfiles/secureworkflow/output/oneJob.yml +++ b/testfiles/secureworkflow/output/oneJob.yml @@ -14,10 +14,10 @@ jobs: - uses: step-security/action-semantic-pull-request@a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0 # v5.5.5 with: types: feat,fix,chore - - uses: step-security/skip-duplicate-actions@b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1 # v2.1.0 + - uses: step-security/skip-duplicate-actions@e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5 # v5.3.0 with: do_not_skip: '["release"]' - - uses: step-security/git-restore-mtime-action@c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0a1b2 # v2.1.0 + - uses: step-security/git-restore-mtime-action@f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1 # v1.1.0 with: pattern: '**/*'