From b63308021a85d6cfaa28ba4820b0ad393f732187 Mon Sep 17 00:00:00 2001 From: Yu Ishikawa Date: Thu, 21 May 2026 18:58:46 +0900 Subject: [PATCH] Add opt-in min release age for GitHub Actions pin/update/upgrade. Delay resolving fresh commits and releases via -min-release-age and RATCHET_MIN_RELEASE_AGE to reduce supply-chain risk when refreshing pins. Co-authored-by: Cursor --- README.md | 24 +++++++++ command/pin.go | 20 +++++-- command/update.go | 4 +- command/upgrade.go | 22 ++++++-- resolver/actions.go | 70 +++++++++++++++++++----- resolver/actions_test.go | 4 +- resolver/policy.go | 101 ++++++++++++++++++++++++++++++++++ resolver/policy_test.go | 114 +++++++++++++++++++++++++++++++++++++++ resolver/resolver.go | 4 +- 9 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 resolver/policy.go create mode 100644 resolver/policy_test.go diff --git a/README.md b/README.md index 9857bed728..cb4f0120ee 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,30 @@ ratchet upgrade -out workflow-compiled.yml workflow.yml > [!NOTE] > Performs an `update` if the constraint ref is for a branch. +#### Min release age + +When running `pin`, `update`, or `upgrade`, you can require that GitHub Actions +commits and releases be at least a minimum age before ratchet will resolve them. +This reduces the risk of pinning to a freshly compromised release (similar to +[pnpm's minimumReleaseAge](https://pnpm.io/settings#minimumreleaseage)). + +```shell +# Only adopt versions at least 24 hours old +ratchet update -min-release-age 24h workflow.yml + +# Same via environment variable (flag overrides env default) +export RATCHET_MIN_RELEASE_AGE=24h +ratchet upgrade workflow.yml + +# Disable for an emergency refresh +ratchet update -min-release-age 0 workflow.yml +``` + +By default, min release age is **off** (`0`). It applies to **GitHub Actions +only**; container image resolution is unchanged. Pinned workflows in git are not +affected until you refresh pins. This does not protect against an attacker +repointing a tag to an old commit. + #### Lint The `lint` command reports if all versions are pinned, printing any violations, diff --git a/command/pin.go b/command/pin.go index 07a47a1fed..293db6dc41 100644 --- a/command/pin.go +++ b/command/pin.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/sethvargo/ratchet/internal/concurrency" "github.com/sethvargo/ratchet/parser" @@ -36,9 +37,10 @@ FLAGS ` type PinCommand struct { - flagConcurrency int64 - flagParser string - flagOut string + flagConcurrency int64 + flagParser string + flagOut string + flagMinReleaseAge time.Duration } func (c *PinCommand) Desc() string { @@ -57,6 +59,14 @@ func (c *PinCommand) Flags() *flag.FlagSet { f.StringVar(&c.flagParser, "parser", "actions", "parser to use") f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)") + minReleaseAge, err := resolver.MinReleaseAgeFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + f.DurationVar(&c.flagMinReleaseAge, "min-release-age", minReleaseAge, + "minimum age of GitHub Actions commit/release before pin/update (0=off); also RATCHET_MIN_RELEASE_AGE") + return f } @@ -71,7 +81,9 @@ func (c *PinCommand) Run(ctx context.Context, originalArgs []string) error { return err } - res, err := resolver.NewDefaultResolver(ctx) + res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{ + MinReleaseAge: c.flagMinReleaseAge, + }) if err != nil { return fmt.Errorf("failed to create resolver: %w", err) } diff --git a/command/update.go b/command/update.go index 9b70cbbd22..89804b0486 100644 --- a/command/update.go +++ b/command/update.go @@ -60,7 +60,9 @@ func (c *UpdateCommand) Run(ctx context.Context, originalArgs []string) error { return err } - res, err := resolver.NewDefaultResolver(ctx) + res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{ + MinReleaseAge: c.flagMinReleaseAge, + }) if err != nil { return fmt.Errorf("failed to create resolver: %w", err) } diff --git a/command/upgrade.go b/command/upgrade.go index 6f34e17dbe..9feb2c0178 100644 --- a/command/upgrade.go +++ b/command/upgrade.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/sethvargo/ratchet/internal/concurrency" "github.com/sethvargo/ratchet/parser" @@ -33,10 +34,11 @@ FLAGS ` type UpgradeCommand struct { - flagConcurrency int64 - flagParser string - flagOut string - flagPin bool + flagConcurrency int64 + flagParser string + flagOut string + flagPin bool + flagMinReleaseAge time.Duration } func (c *UpgradeCommand) Desc() string { @@ -56,6 +58,14 @@ func (c *UpgradeCommand) Flags() *flag.FlagSet { f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)") f.BoolVar(&c.flagPin, "pin", true, "pin resolved upgraded versions") + minReleaseAge, err := resolver.MinReleaseAgeFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + f.DurationVar(&c.flagMinReleaseAge, "min-release-age", minReleaseAge, + "minimum age of GitHub Actions commit/release before upgrade (0=off); also RATCHET_MIN_RELEASE_AGE") + return f } @@ -70,7 +80,9 @@ func (c *UpgradeCommand) Run(ctx context.Context, originalArgs []string) error { return err } - res, err := resolver.NewDefaultResolver(ctx) + res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{ + MinReleaseAge: c.flagMinReleaseAge, + }) if err != nil { return fmt.Errorf("failed to create resolver: %w", err) } diff --git a/resolver/actions.go b/resolver/actions.go index 51c532f525..ed915c4e0a 100644 --- a/resolver/actions.go +++ b/resolver/actions.go @@ -25,10 +25,11 @@ func NormalizeActionsRef(in string) string { // Actions resolves GitHub references. type Actions struct { client *github.Client + policy Policy } // NewActions creates a new resolver for GitHub Actions. -func NewActions(ctx context.Context) (*Actions, error) { +func NewActions(ctx context.Context, policy Policy) (*Actions, error) { httpClient := &http.Client{} if ActionsToken != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ActionsToken}) @@ -47,6 +48,7 @@ func NewActions(ctx context.Context) (*Actions, error) { return &Actions{ client: client, + policy: policy, }, nil } @@ -65,6 +67,22 @@ func (g *Actions) Resolve(ctx context.Context, value string) (string, error) { return "", fmt.Errorf("failed to get commit sha: %w", err) } + if g.policy.enabled() { + commit, _, err := g.client.Repositories.GetCommit(ctx, owner, repo, sha, nil) + if err != nil { + return "", fmt.Errorf("failed to get commit: %w", err) + } + now := time.Now() + if !commitOlderThan(commit, g.policy.MinReleaseAge, now) { + commitTime := commitDate(commit) + ago := now.Sub(commitTime).Round(time.Minute) + return "", fmt.Errorf( + "%s/%s@%s points to commit %s (%s ago), younger than min-release-age %s; wait or use -min-release-age 0", + owner, repo, ref, sha[:7], ago, g.policy.MinReleaseAge, + ) + } + } + name := owner + "/" + repo if path != "" { name = name + "/" + path @@ -98,29 +116,55 @@ func (g *Actions) LatestVersion(ctx context.Context, value string) (string, erro return value, nil } - release, _, err := g.client.Repositories.GetLatestRelease(ctx, owner, repo) - if err != nil { - return "", fmt.Errorf("failed to get latest release: %w", err) - } - name := owner + "/" + repo if path != "" { name = name + "/" + path } - version := *release.TagName - if strings.HasPrefix(ref, "v") { - refPrecision := strings.Count(githubRef.ref, ".") - for strings.Count(version, ".") < refPrecision { - version += ".0" + + var version string + if g.policy.enabled() { + releases, err := g.listReleases(ctx, owner, repo) + if err != nil { + return "", err } - versionParts := strings.Split(version, ".") - version = strings.Join(versionParts[:refPrecision+1], ".") + now := time.Now() + var ok bool + version, ok = selectEligibleRelease(releases, g.policy.MinReleaseAge, ref, now) + if !ok { + return "", fmt.Errorf( + "no release for %s/%s published at least %s ago matching %q", + owner, repo, g.policy.MinReleaseAge, ref, + ) + } + } else { + release, _, err := g.client.Repositories.GetLatestRelease(ctx, owner, repo) + if err != nil { + return "", fmt.Errorf("failed to get latest release: %w", err) + } + version = trimReleaseVersion(*release.TagName, ref) } result := fmt.Sprintf("%s@%s", name, version) return result, nil } +func (g *Actions) listReleases(ctx context.Context, owner, repo string) ([]*github.RepositoryRelease, error) { + opts := &github.ListOptions{PerPage: 100} + var all []*github.RepositoryRelease + for { + releases, resp, err := g.client.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + all = append(all, releases...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return all, nil +} + func ParseActionRef(s string) (*GitHubRef, error) { parts := strings.SplitN(s, "/", 2) if len(parts) < 2 { diff --git a/resolver/actions_test.go b/resolver/actions_test.go index 1c0079f873..df68fbebab 100644 --- a/resolver/actions_test.go +++ b/resolver/actions_test.go @@ -12,7 +12,7 @@ func TestActions_Resolve(t *testing.T) { t.Parallel() ctx := context.Background() - resolver, err := NewActions(ctx) + resolver, err := NewActions(ctx, Policy{}) if err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestActions_LatestVersion(t *testing.T) { t.Parallel() ctx := context.Background() - resolver, err := NewActions(ctx) + resolver, err := NewActions(ctx, Policy{}) if err != nil { t.Fatal(err) } diff --git a/resolver/policy.go b/resolver/policy.go new file mode 100644 index 0000000000..b449560051 --- /dev/null +++ b/resolver/policy.go @@ -0,0 +1,101 @@ +package resolver + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/google/go-github/v73/github" +) + +// Policy configures resolution behavior. Zero value disables optional checks. +type Policy struct { + MinReleaseAge time.Duration // 0 = off +} + +func (p Policy) enabled() bool { + return p.MinReleaseAge > 0 +} + +// MinReleaseAgeFromEnv reads RATCHET_MIN_RELEASE_AGE (e.g. "24h", "1440m"). +// Returns 0 if unset. +func MinReleaseAgeFromEnv() (time.Duration, error) { + v := os.Getenv("RATCHET_MIN_RELEASE_AGE") + if v == "" { + return 0, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("invalid RATCHET_MIN_RELEASE_AGE %q: %w", v, err) + } + if d < 0 { + return 0, fmt.Errorf("invalid RATCHET_MIN_RELEASE_AGE %q: must be non-negative", v) + } + return d, nil +} + +func commitDate(commit *github.RepositoryCommit) time.Time { + if commit == nil { + return time.Time{} + } + c := commit.GetCommit() + if c == nil { + return time.Time{} + } + if committer := c.GetCommitter(); committer != nil { + if d := committer.GetDate(); !d.Time.IsZero() { + return d.Time + } + } + if author := c.GetAuthor(); author != nil { + if d := author.GetDate(); !d.Time.IsZero() { + return d.Time + } + } + return time.Time{} +} + +// commitOlderThan reports whether the commit is at least minAge old relative to now. +func commitOlderThan(commit *github.RepositoryCommit, minAge time.Duration, now time.Time) bool { + t := commitDate(commit) + if t.IsZero() { + return true + } + return now.Sub(t) >= minAge +} + +func trimReleaseVersion(version, ref string) string { + if !strings.HasPrefix(ref, "v") { + return version + } + refPrecision := strings.Count(ref, ".") + for strings.Count(version, ".") < refPrecision { + version += ".0" + } + versionParts := strings.Split(version, ".") + return strings.Join(versionParts[:refPrecision+1], ".") +} + +// selectEligibleRelease returns the tag name of the newest release that satisfies +// minAge and semver trimming for ref. +func selectEligibleRelease(releases []*github.RepositoryRelease, minAge time.Duration, ref string, now time.Time) (string, bool) { + for _, rel := range releases { + if rel.GetDraft() || rel.GetPrerelease() { + continue + } + published := rel.GetPublishedAt() + if published.Time.IsZero() { + continue + } + if now.Sub(published.Time) < minAge { + continue + } + tag := rel.GetTagName() + if tag == "" { + continue + } + return trimReleaseVersion(tag, ref), true + } + return "", false +} diff --git a/resolver/policy_test.go b/resolver/policy_test.go new file mode 100644 index 0000000000..11fc8d983e --- /dev/null +++ b/resolver/policy_test.go @@ -0,0 +1,114 @@ +package resolver + +import ( + "testing" + "time" + + "github.com/google/go-github/v73/github" +) + +func TestPolicy_enabled(t *testing.T) { + t.Parallel() + + if (Policy{}).enabled() { + t.Fatal("zero policy should be disabled") + } + p := Policy{MinReleaseAge: time.Hour} + if !p.enabled() { + t.Fatal("non-zero min age should be enabled") + } +} + +func TestMinReleaseAgeFromEnv(t *testing.T) { + t.Setenv("RATCHET_MIN_RELEASE_AGE", "24h") + d, err := MinReleaseAgeFromEnv() + if err != nil { + t.Fatal(err) + } + if d != 24*time.Hour { + t.Fatalf("expected 24h, got %v", d) + } + + t.Setenv("RATCHET_MIN_RELEASE_AGE", "") + d, err = MinReleaseAgeFromEnv() + if err != nil { + t.Fatal(err) + } + if d != 0 { + t.Fatalf("expected 0, got %v", d) + } + + t.Setenv("RATCHET_MIN_RELEASE_AGE", "not-a-duration") + if _, err := MinReleaseAgeFromEnv(); err == nil { + t.Fatal("expected error for invalid duration") + } +} + +func Test_commitOlderThan(t *testing.T) { + t.Parallel() + + now := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + old := now.Add(-48 * time.Hour) + new := now.Add(-1 * time.Hour) + + oldCommit := &github.RepositoryCommit{ + Commit: &github.Commit{ + Committer: &github.CommitAuthor{Date: &github.Timestamp{Time: old}}, + }, + } + newCommit := &github.RepositoryCommit{ + Commit: &github.Commit{ + Committer: &github.CommitAuthor{Date: &github.Timestamp{Time: new}}, + }, + } + + if !commitOlderThan(oldCommit, 24*time.Hour, now) { + t.Fatal("expected old commit to pass") + } + if commitOlderThan(newCommit, 24*time.Hour, now) { + t.Fatal("expected new commit to fail") + } +} + +func Test_selectEligibleRelease(t *testing.T) { + t.Parallel() + + now := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + releases := []*github.RepositoryRelease{ + { + TagName: github.Ptr("v4.2.0"), + PublishedAt: &github.Timestamp{Time: now.Add(-1 * time.Hour)}, + }, + { + TagName: github.Ptr("v4.1.0"), + PublishedAt: &github.Timestamp{Time: now.Add(-48 * time.Hour)}, + }, + } + + tag, ok := selectEligibleRelease(releases, 24*time.Hour, "v4", now) + if !ok { + t.Fatal("expected eligible release") + } + if tag != "v4" { + t.Fatalf("expected v4, got %q", tag) + } + + _, ok = selectEligibleRelease(releases[:1], 24*time.Hour, "v4", now) + if ok { + t.Fatal("expected no eligible release when all are too new") + } +} + +func Test_trimReleaseVersion(t *testing.T) { + t.Parallel() + + if got := trimReleaseVersion("v4.2.1", "v4"); got != "v4" { + t.Fatalf("expected v4, got %q", got) + } + if got := trimReleaseVersion("v4.2.1", "v4.2"); got != "v4.2" { + t.Fatalf("expected v4.2, got %q", got) + } + if got := trimReleaseVersion("codeql-bundle-v2.15.0", "v1"); got != "codeql-bundle-v2" { + t.Fatalf("expected codeql-bundle-v2, got %q", got) + } +} diff --git a/resolver/resolver.go b/resolver/resolver.go index 93c45688c0..eaf729e647 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -31,8 +31,8 @@ type DefaultResolver struct { } // NewDefaultResolver returns the default resolver. -func NewDefaultResolver(ctx context.Context) (Resolver, error) { - actions, err := NewActions(ctx) +func NewDefaultResolver(ctx context.Context, policy Policy) (Resolver, error) { + actions, err := NewActions(ctx, policy) if err != nil { return nil, fmt.Errorf("failed to setup actions resolver: %w", err) }