diff --git a/command_test.go b/command_test.go index 180914f9..7201e10d 100644 --- a/command_test.go +++ b/command_test.go @@ -114,7 +114,7 @@ dependencies: - github.com/actions/setup-go@v6:sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c `) - stdout, stderr, err := runCommandWithHTTP(t, reg, + stdout, stderr, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), "check", "--json", "valid,errors,warnings", workflowPath, ) require.NoError(t, err) @@ -287,6 +287,27 @@ dependencies: const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n" +// reachableFunc returns a checkReachFn that reports all commits as reachable. +func reachableFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) { + return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) { + return resolver.Reachable, "ancestor of " + ref + } +} + +// unreachableFunc returns a checkReachFn that reports all commits as unreachable. +func unreachableFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) { + return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) { + return resolver.Unreachable, "commit is not an ancestor of " + ref + } +} + +// unknownReachFunc returns a checkReachFn that reports unknown (clone failure). +func unknownReachFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) { + return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) { + return resolver.ReachabilityUnknown, "clone failed" + } +} + func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any { return map[string]any{ "nameWithOwner": nameWithOwner, @@ -312,11 +333,22 @@ func writeTempWorkflow(t *testing.T, body string) string { } func runCommandWithHTTP(t *testing.T, rt http.RoundTripper, args ...string) (string, string, error) { + return runCommandWithHTTPAndReach(t, rt, nil, args...) +} + +func runCommandWithHTTPAndReach(t *testing.T, rt http.RoundTripper, reachFn func(string, string, string, string) (resolver.ReachabilityStatus, string), args ...string) (string, string, error) { t.Helper() oldResolver := newResolver newResolver = func(hostname string) (*resolver.Resolver, error) { - return resolver.NewWithTransport(hostname, rt) + r, err := resolver.NewWithTransport(hostname, rt) + if err != nil { + return nil, err + } + if reachFn != nil { + r.SetCheckReachabilityFunc(reachFn) + } + return r, nil } defer func() { newResolver = oldResolver @@ -348,3 +380,253 @@ func runCommandWithHTTP(t *testing.T, rt http.RoundTripper, args ...string) (str return string(stdoutBytes), string(stderrBytes), runErr } + +// ========================================================================== +// Supply Chain Attack Reachability Tests +// +// These tests model real-world attacks where tag mutation or fork-network +// injection was used to compromise GitHub Actions. The reachability check +// should catch cases where a pinned SHA exists in the GitHub fork network +// but is NOT on the canonical repository's ref lineage. +// +// References: +// - tj-actions/changed-files (CVE-2025-30066): tag v44 pointed to malicious commit from fork +// - reviewdog/action-setup: tag mutation via compromised PAT +// - xygeni/xygeni-action: C2 reverse shell backdoor via tag poisoning +// - aquasecurity/trivy-action: scanner-to-stealer tag manipulation +// ========================================================================== + +// TestCheck_TjActionsChangedFiles_TagMutationAttack models the March 2025 +// tj-actions/changed-files attack (CVE-2025-30066) where attackers +// compromised a maintainer PAT and force-pushed tag v44 to a malicious +// commit. The malicious commit is NOT reachable from the legitimate tag. +// TestCheck_TamperedAndUnreachable verifies that when a pinned SHA differs +// from live resolution AND the old SHA is unreachable, both errors are reported. +func TestCheck_TamperedAndUnreachable(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + pinnedSHA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + liveSHA := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + reg.Register( + httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("example/action", liveSHA, nodeActionYAML), + }, + }), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 + +# Automatically generated and managed by: gh actions-pin --write +dependencies: + - github.com/example/action@v1:sha1-`+pinnedSHA+` +`) + + stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), + "check", "--json", "valid,errors", workflowPath, + ) + require.NoError(t, err, "JSON mode communicates errors in payload") + + var payload struct { + Valid bool `json:"valid"` + Errors []validationError `json:"errors"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.False(t, payload.Valid) + + errorTypes := map[string]bool{} + for _, e := range payload.Errors { + errorTypes[e.Type] = true + } + assert.True(t, errorTypes["TAMPERED"], "should detect SHA changed: %+v", payload.Errors) + assert.True(t, errorTypes["UNREACHABLE"], "should detect unreachable commit: %+v", payload.Errors) +} + +// TestCheck_UnreachableOnly verifies that when a pinned SHA matches live +// resolution but is not reachable from the ref, an UNREACHABLE error is reported. +func TestCheck_UnreachableOnly(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + sha := "cccccccccccccccccccccccccccccccccccccccc" + + reg.Register( + httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("example/action", sha, nodeActionYAML), + }, + }), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 + +# Automatically generated and managed by: gh actions-pin --write +dependencies: + - github.com/example/action@v1:sha1-`+sha+` +`) + + stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), + "check", "--json", "valid,errors", workflowPath, + ) + require.NoError(t, err, "JSON mode communicates errors in payload") + + var payload struct { + Valid bool `json:"valid"` + Errors []validationError `json:"errors"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.False(t, payload.Valid) + + hasUnreachable := false + for _, e := range payload.Errors { + if e.Type == "UNREACHABLE" { + hasUnreachable = true + } + } + assert.True(t, hasUnreachable, "should detect unreachable commit: %+v", payload.Errors) +} + +// TestCheck_ReachabilityUnknown verifies that when the reachability check +// cannot complete, validation passes with a warning. +func TestCheck_ReachabilityUnknown(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + sha := "dddddddddddddddddddddddddddddddddddddddd" + + reg.Register( + httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("example/action", sha, nodeActionYAML), + }, + }), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 + +# Automatically generated and managed by: gh actions-pin --write +dependencies: + - github.com/example/action@v1:sha1-`+sha+` +`) + + stdout, _, err := runCommandWithHTTPAndReach(t, reg, unknownReachFunc(), + "check", "--json", "valid,errors,warnings", workflowPath, + ) + require.NoError(t, err, "unknown reachability should not fail the check") + + var payload struct { + Valid bool `json:"valid"` + Errors []validationError `json:"errors"` + Warnings []string `json:"warnings"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.True(t, payload.Valid, "valid should be true when reachability is unknown") + assert.Empty(t, payload.Errors) + assert.NotEmpty(t, payload.Warnings, "should have a reachability warning") + assert.Contains(t, payload.Warnings[0], "reachability check inconclusive") +} + +// TestCheck_Reachable verifies the happy path: pinned SHA matches live +// resolution and is reachable — validation passes with no errors or warnings. +func TestCheck_Reachable(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + sha := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + + reg.Register( + httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("example/action", sha, nodeActionYAML), + }, + }), + ) + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 + +# Automatically generated and managed by: gh actions-pin --write +dependencies: + - github.com/example/action@v1:sha1-`+sha+` +`) + + stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(), + "check", "--json", "valid,errors,warnings", workflowPath, + ) + require.NoError(t, err) + + var payload struct { + Valid bool `json:"valid"` + Errors []validationError `json:"errors"` + Warnings []string `json:"warnings"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + assert.True(t, payload.Valid) + assert.Empty(t, payload.Errors) + assert.Empty(t, payload.Warnings) +} + +// TestPin_UnreachableWarnsOnly verifies that an unreachable SHA during pin +// warns on stderr but does not block the operation. +func TestPin_UnreachableWarnsOnly(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + sha := "ffffffffffffffffffffffffffffffffffffffff" + + reg.Register( + httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`), + httpmock.JSONResponse(map[string]any{ + "data": map[string]any{ + "a0": testRepoResponse("example/action", sha, nodeActionYAML), + }, + }), + ) + + workflowPath := writeTempWorkflow(t, ` +name: ci +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 +`) + + _, stderr, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), "--diff", workflowPath) + require.NoError(t, err, "pin should succeed even with unreachable warning") + assert.Contains(t, stderr, "NOT reachable") + assert.Contains(t, stderr, "fork-network injection") +} diff --git a/internal/httpmock/httpmock.go b/internal/httpmock/httpmock.go index d976c06e..fb341d48 100644 --- a/internal/httpmock/httpmock.go +++ b/internal/httpmock/httpmock.go @@ -147,6 +147,31 @@ func GraphQLQuery(body string, cb func(query string, variables map[string]any)) } } +// REST matches a request by method and URL path pattern (regex). +func REST(method, pathPattern string) Matcher { + re := regexp.MustCompile(pathPattern) + + return func(req *http.Request) bool { + if !strings.EqualFold(req.Method, method) { + return false + } + return re.MatchString(req.URL.Path) + } +} + +// StatusResponse returns a response with the given status code and empty body. +func StatusResponse(code int) Responder { + return func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: code, + Header: http.Header{}, + Body: io.NopCloser(bytes.NewBuffer(nil)), + Request: req, + Status: fmt.Sprintf("%d", code), + }, nil + } +} + func decodeJSONBody(req *http.Request, dest any) error { b, err := readBody(req) if err != nil { diff --git a/internal/resolver/reachability_integration_test.go b/internal/resolver/reachability_integration_test.go new file mode 100644 index 00000000..cabb6e07 --- /dev/null +++ b/internal/resolver/reachability_integration_test.go @@ -0,0 +1,147 @@ +//go:build integration + +// Integration tests for reachability checks using the GitHub Compare API. +// Requires: network access, GH_TOKEN or gh CLI auth. +// Fixtures: +// - nodeselector/actions-test-fixtures: tag v1 on HEAD (ea53476), orphan-poison branch (614a37a) +// - choam-io/actions-test-fixtures-fork: fork with attacker-payload branch (7b403c9) +// +// Run: go test -tags integration -run TestIntegration ./internal/resolver/ + +package resolver + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + fixtureOwner = "nodeselector" + fixtureRepo = "actions-test-fixtures" + + // HEAD of main, also where tag v1 points + headSHA = "ea53476fdc172d8552df5af9658a45a367e4f41d" + // Parent of HEAD — in v1's lineage but not at HEAD (tag-drift scenario) + parentSHA = "38b3412adcb7afb4a061c519513e45cbaf4a1cec" + // Root commit of main (oldest ancestor) + rootSHA = "5f13f2a16a43112afcd6e1bcc29c418176894d53" + // Orphan commit on orphan-poison branch (no common ancestor with main) + orphanSHA = "614a37a63d1a75476792a8781b55983a9d9bcb80" + // A SHA that doesn't exist anywhere + fakeSHA = "0000000000000000000000000000000000000000" + // Commit on choam-io/actions-test-fixtures-fork attacker-payload branch + // This SHA exists in the fork network but NOT in the upstream repo's lineage + forkAttackerSHA = "7b403c9ec14bd3ae0bbf793c2bee8815a7ac920a" +) + +func skipWithoutAuth(t *testing.T) { + t.Helper() + if os.Getenv("GH_TOKEN") == "" && os.Getenv("GITHUB_TOKEN") == "" { + if _, err := os.Stat(os.ExpandEnv("$HOME/.config/gh/hosts.yml")); err != nil { + t.Skip("Skipping integration test: no GH_TOKEN or gh auth configured") + } + } +} + +func newLiveResolver(t *testing.T) *Resolver { + t.Helper() + r, err := New("github.com") + require.NoError(t, err) + return r +} + +// TestIntegration_Reachable_HeadSHA verifies that the HEAD commit (where v1 +// points) is reported as reachable via the Compare API merge-base identity check. +func TestIntegration_Reachable_HeadSHA(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, headSHA, "v1") + assert.Equal(t, Reachable, result.Status, "HEAD SHA should be reachable from v1: %+v", result) +} + +// TestIntegration_Reachable_Ancestor verifies that the root commit of main +// is reachable from v1 (it's an ancestor). +func TestIntegration_Reachable_Ancestor(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, rootSHA, "v1") + assert.Equal(t, Reachable, result.Status, "root commit should be ancestor of v1: %+v", result) +} + +// TestIntegration_Reachable_NotAtHead_ButInLineage simulates tag drift: the +// pinned SHA was once at the tag's HEAD but the tag has since moved forward. +// The pinned SHA (parent of current HEAD) should still be reachable. +func TestIntegration_Reachable_NotAtHead_ButInLineage(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, parentSHA, "v1") + assert.Equal(t, Reachable, result.Status, + "commit behind HEAD should still be reachable from v1 (tag drift): %+v", result) +} + +// TestIntegration_Unreachable_OrphanCommit verifies that a commit on an orphan +// branch (no common ancestor with main) is detected as unreachable from v1. +func TestIntegration_Unreachable_OrphanCommit(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, orphanSHA, "v1") + assert.Equal(t, Unreachable, result.Status, "orphan commit should not be reachable from v1: %+v", result) +} + +// TestIntegration_Unreachable_NonexistentSHA verifies that a completely +// fabricated SHA is detected as unreachable. +func TestIntegration_Unreachable_NonexistentSHA(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, fakeSHA, "v1") + assert.Equal(t, Unreachable, result.Status, "fake SHA should be unreachable: %+v", result) +} + +// TestIntegration_Unreachable_ForkNetworkInjection is the KEY test proving +// the Compare API merge-base identity check detects fork-network injection. +// +// The Compare API operates on the fork-network-shared object store, so the +// fork commit IS visible. However, the merge_base_commit for a fork commit +// will NOT be the fork SHA itself — it will be the actual common ancestor in +// the upstream history. This mismatch (merge_base != pinnedSHA) is the signal +// that detects the imposter commit. +func TestIntegration_Unreachable_ForkNetworkInjection(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, forkAttackerSHA, "v1") + assert.Equal(t, Unreachable, result.Status, + "fork-network SHA should NOT be reachable via merge-base identity check: %+v", result) +} + +// TestIntegration_SHAAsRef_ReturnsUnknown verifies that when the ref is itself +// a raw SHA (the anti-pattern), we return Unknown with guidance to pin to a tag. +func TestIntegration_SHAAsRef_ReturnsUnknown(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + result := r.CheckReachability(fixtureOwner, fixtureRepo, headSHA, headSHA) + assert.Equal(t, ReachabilityUnknown, result.Status, + "SHA-as-ref should return Unknown: %+v", result) + assert.Contains(t, result.Detail, "pin to a tag") +} + +// TestIntegration_CacheConsistency verifies that repeated calls return +// the same result and hit the cache on the second call. +func TestIntegration_CacheConsistency(t *testing.T) { + skipWithoutAuth(t) + r := newLiveResolver(t) + + r1 := r.CheckReachability(fixtureOwner, fixtureRepo, headSHA, "v1") + r2 := r.CheckReachability(fixtureOwner, fixtureRepo, headSHA, "v1") + assert.Equal(t, r1.Status, r2.Status) + assert.Equal(t, "cached", r2.Detail, "second call should come from cache") +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index b2f95ac1..a9870c6a 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "regexp" "strconv" "strings" @@ -15,6 +16,20 @@ import ( "github.com/github/gh-actions-pin/internal/lockfile" ) +// ReachabilityStatus represents the result of a commit reachability check. +type ReachabilityStatus string + +const ( + // Reachable means the SHA is confirmed on the ref's lineage. + Reachable ReachabilityStatus = "reachable" + // Unreachable means the SHA is confirmed NOT on the ref's lineage + // (e.g. it exists only in a fork network). + Unreachable ReachabilityStatus = "unreachable" + // ReachabilityUnknown means the check could not be completed + // (timeout, rate limit, API error). + ReachabilityUnknown ReachabilityStatus = "unknown" +) + // DefaultMaxRecursionDepth matches the runner's composite action recursion limit. const DefaultMaxRecursionDepth = 10 @@ -26,13 +41,28 @@ type resolvedEntry struct { actionYML string } +// ReachabilityResult holds the outcome of a single reachability check. +type ReachabilityResult struct { + Owner string + Repo string + Ref string + SHA string + DepKey string // full dependency key (e.g. "actions/cache/save@v4") + Status ReachabilityStatus + Detail string // human-readable detail (e.g. compare status or error) +} + // Resolver resolves action refs to commit SHAs. type Resolver struct { client *api.GraphQLClient + restClient *api.RESTClient hostname string MaxRecursionDepth int cache map[string]resolvedEntry latestRefCache map[string]string + reachCache map[string]ReachabilityStatus + // checkReachFn overrides the default REST-based reachability check (for tests). + checkReachFn func(owner, repo, sha, ref string) (ReachabilityStatus, string) } // New creates a resolver using the authenticated gh context. @@ -53,12 +83,19 @@ func NewWithOptions(opts api.ClientOptions) (*Resolver, error) { return nil, err } + restClient, err := api.NewRESTClient(opts) + if err != nil { + return nil, err + } + return &Resolver{ client: client, + restClient: restClient, hostname: hostname, MaxRecursionDepth: DefaultMaxRecursionDepth, cache: make(map[string]resolvedEntry), latestRefCache: make(map[string]string), + reachCache: make(map[string]ReachabilityStatus), }, nil } @@ -78,6 +115,139 @@ func (r *Resolver) Hostname() string { return r.hostname } +// SetCheckReachabilityFunc overrides the default REST-based reachability check. +// Intended for tests. +func (r *Resolver) SetCheckReachabilityFunc(fn func(owner, repo, sha, ref string) (ReachabilityStatus, string)) { + r.checkReachFn = fn +} + +// isSHARef returns true if the ref looks like a full commit SHA (40 hex chars). +var shaRefRE = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +// CheckReachability verifies that a resolved SHA is on the lineage of the +// given ref within the repository. This catches fork-network injection where +// a SHA exists in GitHub's shared object store but is not actually part of +// the canonical repository's history. +// +// Uses the GitHub Compare API and checks merge_base identity: +// - merge_base == pinnedSHA → Reachable (SHA is a true ancestor of ref) +// - merge_base != pinnedSHA → Unreachable (fork/imposter commit) +// - 404 (no common ancestor or not found) → Unreachable +// - 403/429 (rate limit) or other error → Unknown +// +// When ref is itself a raw SHA (the "uses: owner/repo@SHA" anti-pattern), +// the compare becomes {sha}...{sha} which trivially returns "identical" and +// cannot detect fork commits. In this case, a warning is returned instead. +func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityResult { + result := ReachabilityResult{ + Owner: owner, + Repo: repo, + Ref: ref, + SHA: sha, + } + + cacheKey := owner + "/" + repo + "/" + sha + "/" + ref + if status, ok := r.reachCache[cacheKey]; ok { + result.Status = status + result.Detail = "cached" + return result + } + + // Allow tests to inject a fake implementation + if r.checkReachFn != nil { + result.Status, result.Detail = r.checkReachFn(owner, repo, sha, ref) + if result.Status != ReachabilityUnknown { + r.reachCache[cacheKey] = result.Status + } + return result + } + + // SHA-as-ref anti-pattern: compare/{sha}...{sha} is trivially identical + // and cannot detect fork commits. Warn the user. + if shaRefRE.MatchString(ref) { + result.Status = ReachabilityUnknown + result.Detail = "ref is a raw SHA — reachability cannot be verified; pin to a tag instead" + return result + } + + status, detail := r.apiReachabilityCheck(owner, repo, sha, ref) + result.Status = status + result.Detail = detail + if result.Status != ReachabilityUnknown { + r.reachCache[cacheKey] = result.Status + } + return result +} + +// compareResponse is the subset of the GitHub Compare API response we need. +type compareResponse struct { + MergeBaseCommit struct { + SHA string `json:"sha"` + } `json:"merge_base_commit"` + Status string `json:"status"` +} + +// apiReachabilityCheck uses the GitHub Compare API to verify that sha is an +// ancestor of ref. The key insight: merge_base(ancestor, descendant) == ancestor. +// If the merge_base is NOT the pinned SHA, the commit lives on the fork network. +func (r *Resolver) apiReachabilityCheck(owner, repo, sha, ref string) (ReachabilityStatus, string) { + path := fmt.Sprintf("repos/%s/%s/compare/%s...%s", + owner, repo, url.PathEscape(sha), url.PathEscape(ref)) + + var resp compareResponse + err := r.restClient.Get(path, &resp) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch { + case httpErr.StatusCode == http.StatusNotFound: + return Unreachable, "no common ancestor or commit not found" + case httpErr.StatusCode == http.StatusForbidden || httpErr.StatusCode == http.StatusTooManyRequests: + detail := fmt.Sprintf("rate limited (HTTP %d)", httpErr.StatusCode) + if reset := httpErr.Headers.Get("X-RateLimit-Reset"); reset != "" { + detail += "; resets at " + reset + } + return ReachabilityUnknown, detail + default: + return ReachabilityUnknown, fmt.Sprintf("API error (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + } + } + return ReachabilityUnknown, err.Error() + } + + if resp.MergeBaseCommit.SHA == sha { + return Reachable, "ancestor of " + ref + " (compare: " + resp.Status + ")" + } + return Unreachable, fmt.Sprintf("merge base is %s, not the pinned SHA — likely a fork-network commit", resp.MergeBaseCommit.SHA[:12]) +} + +// CheckReachabilityAll runs reachability checks on a batch of dependencies, +// deduplicating by owner/repo/sha/ref. +func (r *Resolver) CheckReachabilityAll(deps []lockfile.Dependency) []ReachabilityResult { + var results []ReachabilityResult + seen := make(map[string]bool) + + for _, dep := range deps { + parts := strings.SplitN(dep.NWO, "/", 3) + if len(parts) < 2 { + continue + } + owner, repo := parts[0], parts[1] + + key := dep.NWO + "/" + dep.SHA + "/" + dep.Ref + if seen[key] { + continue + } + seen[key] = true + + result := r.CheckReachability(owner, repo, dep.SHA, dep.Ref) + result.DepKey = dep.Key() + results = append(results, result) + } + + return results +} + // LatestRef returns the highest stable tag for an action repository. func (r *Resolver) LatestRef(owner, repo string) (string, error) { key := owner + "/" + repo diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 5fdb11e2..8c70f4fe 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -131,6 +131,7 @@ func TestResolveAllRecursiveWithCacheAndCompositeExpansion(t *testing.T) { }, }, latestRefCache: map[string]string{}, + reachCache: map[string]ReachabilityStatus{}, } r.cache["owner/composite@v1"] = resolvedEntry{ @@ -173,6 +174,7 @@ func TestResolveAllRecursiveRespectsMaxDepth(t *testing.T) { }, }, latestRefCache: map[string]string{}, + reachCache: map[string]ReachabilityStatus{}, } _, err := r.ResolveAllRecursive([]lockfile.ActionRef{{Owner: "owner", Repo: "composite", Ref: "v1"}}) @@ -285,3 +287,197 @@ func TestResolveAllRecursiveWithHTTPTransport(t *testing.T) { t.Fatalf("expected composite dep to be present, got %+v", deps) } } + +func TestCheckReachability_Reachable(t *testing.T) { + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + checkReachFn: func(owner, repo, sha, ref string) (ReachabilityStatus, string) { + return Reachable, "ancestor of " + ref + }, + } + result := r.CheckReachability("actions", "checkout", "abc123", "v6") + if result.Status != Reachable { + t.Fatalf("expected Reachable, got %s (%s)", result.Status, result.Detail) + } +} + +func TestCheckReachability_Unreachable(t *testing.T) { + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + checkReachFn: func(owner, repo, sha, ref string) (ReachabilityStatus, string) { + return Unreachable, "commit is not an ancestor of " + ref + }, + } + result := r.CheckReachability("evil", "repo", "deadbeef", "v1") + if result.Status != Unreachable { + t.Fatalf("expected Unreachable, got %s (%s)", result.Status, result.Detail) + } +} + +func TestCheckReachability_Unknown(t *testing.T) { + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + checkReachFn: func(owner, repo, sha, ref string) (ReachabilityStatus, string) { + return ReachabilityUnknown, "clone failed" + }, + } + result := r.CheckReachability("actions", "checkout", "abc123", "v6") + if result.Status != ReachabilityUnknown { + t.Fatalf("expected Unknown, got %s (%s)", result.Status, result.Detail) + } +} + +func TestCheckReachability_CachesResults(t *testing.T) { + calls := 0 + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + checkReachFn: func(owner, repo, sha, ref string) (ReachabilityStatus, string) { + calls++ + return Reachable, "ancestor of " + ref + }, + } + + r1 := r.CheckReachability("actions", "checkout", "abc123", "v6") + r2 := r.CheckReachability("actions", "checkout", "abc123", "v6") + + if r1.Status != Reachable || r2.Status != Reachable { + t.Fatalf("expected both calls to return Reachable, got %s and %s", r1.Status, r2.Status) + } + if r2.Detail != "cached" { + t.Fatalf("expected second call to be cached, got detail %q", r2.Detail) + } + if calls != 1 { + t.Fatalf("expected checkReachFn called once, got %d", calls) + } +} + +func TestCheckReachabilityAll_DeduplicatesRequests(t *testing.T) { + calls := 0 + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + checkReachFn: func(owner, repo, sha, ref string) (ReachabilityStatus, string) { + calls++ + return Reachable, "ancestor of " + ref + }, + } + + deps := []lockfile.Dependency{ + {NWO: "actions/checkout", Ref: "v6", SHA: "aaa"}, + {NWO: "actions/checkout", Ref: "v6", SHA: "aaa"}, // duplicate + {NWO: "actions/setup-go", Ref: "v6", SHA: "bbb"}, + } + + results := r.CheckReachabilityAll(deps) + if len(results) != 2 { + t.Fatalf("expected 2 unique results, got %d: %+v", len(results), results) + } + if calls != 2 { + t.Fatalf("expected 2 calls (deduped), got %d", calls) + } +} + +func TestCheckReachability_SHAAsRef_ReturnsUnknown(t *testing.T) { + r := &Resolver{ + reachCache: map[string]ReachabilityStatus{}, + } + sha := "abc123abc123abc123abc123abc123abc123abc1" + result := r.CheckReachability("actions", "checkout", sha, sha) + if result.Status != ReachabilityUnknown { + t.Fatalf("expected Unknown for SHA-as-ref, got %s (%s)", result.Status, result.Detail) + } + if !strings.Contains(result.Detail, "pin to a tag") { + t.Fatalf("expected detail to mention tag pinning, got %q", result.Detail) + } +} + +func TestApiReachabilityCheck_Reachable(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/actions/checkout/compare/"), + httpmock.JSONResponse(map[string]any{ + "status": "ahead", + "merge_base_commit": map[string]any{ + "sha": "abc123abc123abc123abc123abc123abc123abc1", + }, + }), + ) + + r, err := NewWithTransport("github.com", reg) + if err != nil { + t.Fatal(err) + } + + result := r.CheckReachability("actions", "checkout", "abc123abc123abc123abc123abc123abc123abc1", "v6") + if result.Status != Reachable { + t.Fatalf("expected Reachable, got %s (%s)", result.Status, result.Detail) + } + reg.Verify(t) +} + +func TestApiReachabilityCheck_Unreachable_ForkCommit(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/actions/checkout/compare/"), + httpmock.JSONResponse(map[string]any{ + "status": "behind", + "merge_base_commit": map[string]any{ + "sha": "different_sha_000000000000000000000000000", + }, + }), + ) + + r, err := NewWithTransport("github.com", reg) + if err != nil { + t.Fatal(err) + } + + result := r.CheckReachability("actions", "checkout", "abc123abc123abc123abc123abc123abc123abc1", "v6") + if result.Status != Unreachable { + t.Fatalf("expected Unreachable, got %s (%s)", result.Status, result.Detail) + } + if !strings.Contains(result.Detail, "fork-network") { + t.Fatalf("expected detail to mention fork-network, got %q", result.Detail) + } + reg.Verify(t) +} + +func TestApiReachabilityCheck_Unreachable_404(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/actions/checkout/compare/"), + httpmock.StatusResponse(404), + ) + + r, err := NewWithTransport("github.com", reg) + if err != nil { + t.Fatal(err) + } + + result := r.CheckReachability("actions", "checkout", "abc123abc123abc123abc123abc123abc123abc1", "v6") + if result.Status != Unreachable { + t.Fatalf("expected Unreachable for 404, got %s (%s)", result.Status, result.Detail) + } + reg.Verify(t) +} + +func TestApiReachabilityCheck_Unknown_RateLimit(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/actions/checkout/compare/"), + httpmock.StatusResponse(429), + ) + + r, err := NewWithTransport("github.com", reg) + if err != nil { + t.Fatal(err) + } + + result := r.CheckReachability("actions", "checkout", "abc123abc123abc123abc123abc123abc123abc1", "v6") + if result.Status != ReachabilityUnknown { + t.Fatalf("expected Unknown for rate limit, got %s (%s)", result.Status, result.Detail) + } + if !strings.Contains(result.Detail, "rate limited") { + t.Fatalf("expected detail to mention rate limit, got %q", result.Detail) + } + reg.Verify(t) +} diff --git a/root.go b/root.go index cc11fef8..cc990490 100644 --- a/root.go +++ b/root.go @@ -246,6 +246,7 @@ func newCheckCmd() *cobra.Command { MISSING - uses: ref has no dependencies: entry STALE - dependencies: entry is no longer discoverable SHA_MISMATCH - uses: ref looks like a SHA but resolves elsewhere + UNREACHABLE - SHA is not on the ref's lineage (possible fork-network injection) `), Example: heredoc.Doc(` # Verify all workflows @@ -527,6 +528,24 @@ func pinOneFile(opts *pinOptions, workflowPath string, r *resolver.Resolver) err return fmt.Errorf("%d action ref(s) have SHA-like names that point to different commits", len(mismatches)) } + // Reachability check on freshly resolved deps (warns, does not block) + reachResults := r.CheckReachabilityAll(deps) + for _, rr := range reachResults { + depID := rr.DepKey + if depID == "" { + depID = fmt.Sprintf("%s/%s@%s", rr.Owner, rr.Repo, rr.Ref) + } + switch rr.Status { + case resolver.Unreachable: + fmt.Fprintf(os.Stderr, "warning: %s: SHA %s is NOT reachable from ref (%s)\n", + depID, rr.SHA[:12], rr.Detail) + fmt.Fprintf(os.Stderr, " This may indicate a fork-network injection attack.\n") + case resolver.ReachabilityUnknown: + fmt.Fprintf(os.Stderr, "warning: %s: reachability check inconclusive (%s)\n", + depID, rr.Detail) + } + } + if len(opts.Actions) > 0 && len(existingDeps) > 0 { deps = mergeTargetedDeps(existingDeps, deps, opts.Actions) } else if len(existingDeps) > 0 && opts.Write { @@ -785,6 +804,29 @@ func validateOneFile(workflowPath string, r *resolver.Resolver) (*validationResu }) } + // Reachability: verify pinned SHAs are on the ref's lineage in the + // canonical repository, not injected from a fork network. + fmt.Fprintf(os.Stderr, "Checking commit reachability for %d dependency(ies)...\n", len(existingDeps)) + reachResults := r.CheckReachabilityAll(existingDeps) + for _, rr := range reachResults { + depID := rr.DepKey + if depID == "" { + depID = fmt.Sprintf("%s/%s@%s", rr.Owner, rr.Repo, rr.Ref) + } + switch rr.Status { + case resolver.Unreachable: + result.Valid = false + result.Errors = append(result.Errors, validationError{ + Type: "UNREACHABLE", + Dependency: depID, + Details: fmt.Sprintf("SHA %s is not reachable from ref %s (%s)", rr.SHA[:12], rr.Ref, rr.Detail), + }) + case resolver.ReachabilityUnknown: + result.Warnings = append(result.Warnings, + fmt.Sprintf("%s: reachability check inconclusive (%s)", depID, rr.Detail)) + } + } + if result.Valid { fmt.Fprintf(os.Stderr, "%s valid\n", workflowPath) }