Skip to content

Commit 73dbd1b

Browse files
committed
fix: pinning makes this simple
1 parent 7cafd09 commit 73dbd1b

4 files changed

Lines changed: 208 additions & 74 deletions

File tree

internal/resolver/reachability_integration_test.go

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//go:build integration
22

3-
// Integration tests for reachability checks using git bare clones.
4-
// Requires: git CLI, network access, GH_TOKEN or gh CLI auth.
3+
// Integration tests for reachability checks using the GitHub Compare API.
4+
// Requires: network access, GH_TOKEN or gh CLI auth.
55
// Fixtures:
66
// - nodeselector/actions-test-fixtures: tag v1 on HEAD (ea53476), orphan-poison branch (614a37a)
77
// - choam-io/actions-test-fixtures-fork: fork with attacker-payload branch (7b403c9)
@@ -24,14 +24,16 @@ const (
2424

2525
// HEAD of main, also where tag v1 points
2626
headSHA = "ea53476fdc172d8552df5af9658a45a367e4f41d"
27+
// Parent of HEAD — in v1's lineage but not at HEAD (tag-drift scenario)
28+
parentSHA = "38b3412adcb7afb4a061c519513e45cbaf4a1cec"
2729
// Root commit of main (oldest ancestor)
2830
rootSHA = "5f13f2a16a43112afcd6e1bcc29c418176894d53"
2931
// Orphan commit on orphan-poison branch (no common ancestor with main)
3032
orphanSHA = "614a37a63d1a75476792a8781b55983a9d9bcb80"
3133
// A SHA that doesn't exist anywhere
3234
fakeSHA = "0000000000000000000000000000000000000000"
3335
// Commit on choam-io/actions-test-fixtures-fork attacker-payload branch
34-
// This SHA exists in the fork network but NOT in the upstream bare clone
36+
// This SHA exists in the fork network but NOT in the upstream repo's lineage
3537
forkAttackerSHA = "7b403c9ec14bd3ae0bbf793c2bee8815a7ac920a"
3638
)
3739

@@ -48,13 +50,11 @@ func newLiveResolver(t *testing.T) *Resolver {
4850
t.Helper()
4951
r, err := New("github.com")
5052
require.NoError(t, err)
51-
// Use a temp dir so integration tests don't pollute the real cache
52-
r.CacheDir = t.TempDir()
5353
return r
5454
}
5555

5656
// TestIntegration_Reachable_HeadSHA verifies that the HEAD commit (where v1
57-
// points) is reported as reachable via git merge-base.
57+
// points) is reported as reachable via the Compare API merge-base identity check.
5858
func TestIntegration_Reachable_HeadSHA(t *testing.T) {
5959
skipWithoutAuth(t)
6060
r := newLiveResolver(t)
@@ -73,6 +73,18 @@ func TestIntegration_Reachable_Ancestor(t *testing.T) {
7373
assert.Equal(t, Reachable, result.Status, "root commit should be ancestor of v1: %+v", result)
7474
}
7575

76+
// TestIntegration_Reachable_NotAtHead_ButInLineage simulates tag drift: the
77+
// pinned SHA was once at the tag's HEAD but the tag has since moved forward.
78+
// The pinned SHA (parent of current HEAD) should still be reachable.
79+
func TestIntegration_Reachable_NotAtHead_ButInLineage(t *testing.T) {
80+
skipWithoutAuth(t)
81+
r := newLiveResolver(t)
82+
83+
result := r.CheckReachability(fixtureOwner, fixtureRepo, parentSHA, "v1")
84+
assert.Equal(t, Reachable, result.Status,
85+
"commit behind HEAD should still be reachable from v1 (tag drift): %+v", result)
86+
}
87+
7688
// TestIntegration_Unreachable_OrphanCommit verifies that a commit on an orphan
7789
// branch (no common ancestor with main) is detected as unreachable from v1.
7890
func TestIntegration_Unreachable_OrphanCommit(t *testing.T) {
@@ -94,21 +106,32 @@ func TestIntegration_Unreachable_NonexistentSHA(t *testing.T) {
94106
}
95107

96108
// TestIntegration_Unreachable_ForkNetworkInjection is the KEY test proving
97-
// git-based reachability is superior to the GitHub compare API.
98-
//
99-
// The compare API treats the entire fork network as one graph, so a commit
100-
// pushed to choam-io/actions-test-fixtures-fork is visible via the upstream
101-
// compare endpoint and appears "reachable" (behind). This is a false negative.
109+
// the Compare API merge-base identity check detects fork-network injection.
102110
//
103-
// A bare clone of upstream-only excludes fork objects, so the attacker SHA
104-
// from the fork won't exist in the clone → detected as UNREACHABLE.
111+
// The Compare API operates on the fork-network-shared object store, so the
112+
// fork commit IS visible. However, the merge_base_commit for a fork commit
113+
// will NOT be the fork SHA itself — it will be the actual common ancestor in
114+
// the upstream history. This mismatch (merge_base != pinnedSHA) is the signal
115+
// that detects the imposter commit.
105116
func TestIntegration_Unreachable_ForkNetworkInjection(t *testing.T) {
106117
skipWithoutAuth(t)
107118
r := newLiveResolver(t)
108119

109120
result := r.CheckReachability(fixtureOwner, fixtureRepo, forkAttackerSHA, "v1")
110121
assert.Equal(t, Unreachable, result.Status,
111-
"fork-network SHA should NOT be reachable from upstream bare clone: %+v", result)
122+
"fork-network SHA should NOT be reachable via merge-base identity check: %+v", result)
123+
}
124+
125+
// TestIntegration_SHAAsRef_ReturnsUnknown verifies that when the ref is itself
126+
// a raw SHA (the anti-pattern), we return Unknown with guidance to pin to a tag.
127+
func TestIntegration_SHAAsRef_ReturnsUnknown(t *testing.T) {
128+
skipWithoutAuth(t)
129+
r := newLiveResolver(t)
130+
131+
result := r.CheckReachability(fixtureOwner, fixtureRepo, headSHA, headSHA)
132+
assert.Equal(t, ReachabilityUnknown, result.Status,
133+
"SHA-as-ref should return Unknown: %+v", result)
134+
assert.Contains(t, result.Detail, "pin to a tag")
112135
}
113136

114137
// TestIntegration_CacheConsistency verifies that repeated calls return

internal/resolver/resolver.go

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import (
77
"errors"
88
"fmt"
99
"net/http"
10-
"os"
11-
"os/exec"
12-
"path/filepath"
1310
"regexp"
1411
"strconv"
1512
"strings"
@@ -56,15 +53,13 @@ type ReachabilityResult struct {
5653
// Resolver resolves action refs to commit SHAs.
5754
type Resolver struct {
5855
client *api.GraphQLClient
56+
restClient *api.RESTClient
5957
hostname string
6058
MaxRecursionDepth int
6159
cache map[string]resolvedEntry
6260
latestRefCache map[string]string
6361
reachCache map[string]ReachabilityStatus
64-
// CacheDir is the directory for bare git clones used in reachability checks.
65-
// Defaults to ~/.actions-lockfile/cache.
66-
CacheDir string
67-
// checkReachFn overrides the default git-based reachability check (for tests).
62+
// checkReachFn overrides the default REST-based reachability check (for tests).
6863
checkReachFn func(owner, repo, sha, ref string) (ReachabilityStatus, string)
6964
}
7065

@@ -86,20 +81,19 @@ func NewWithOptions(opts api.ClientOptions) (*Resolver, error) {
8681
return nil, err
8782
}
8883

89-
homeDir, err := os.UserHomeDir()
84+
restClient, err := api.NewRESTClient(opts)
9085
if err != nil {
91-
homeDir = os.TempDir()
86+
return nil, err
9287
}
93-
cacheDir := filepath.Join(homeDir, ".actions-lockfile", "cache")
9488

9589
return &Resolver{
9690
client: client,
91+
restClient: restClient,
9792
hostname: hostname,
9893
MaxRecursionDepth: DefaultMaxRecursionDepth,
9994
cache: make(map[string]resolvedEntry),
10095
latestRefCache: make(map[string]string),
10196
reachCache: make(map[string]ReachabilityStatus),
102-
CacheDir: cacheDir,
10397
}, nil
10498
}
10599

@@ -119,21 +113,29 @@ func (r *Resolver) Hostname() string {
119113
return r.hostname
120114
}
121115

122-
// SetCheckReachabilityFunc overrides the default git-based reachability check.
116+
// SetCheckReachabilityFunc overrides the default REST-based reachability check.
123117
// Intended for tests.
124118
func (r *Resolver) SetCheckReachabilityFunc(fn func(owner, repo, sha, ref string) (ReachabilityStatus, string)) {
125119
r.checkReachFn = fn
126120
}
127121

122+
// isSHARef returns true if the ref looks like a full commit SHA (40 hex chars).
123+
var shaRefRE = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
124+
128125
// CheckReachability verifies that a resolved SHA is on the lineage of the
129126
// given ref within the repository. This catches fork-network injection where
130127
// a SHA exists in GitHub's shared object store but is not actually part of
131128
// the canonical repository's history.
132129
//
133-
// Uses a bare blobless clone of the upstream repo and git merge-base:
134-
// - exit 0 from merge-base --is-ancestor → Reachable
135-
// - exit 1 → Unreachable (SHA not an ancestor of ref)
136-
// - clone/fetch failure → Unknown
130+
// Uses the GitHub Compare API and checks merge_base identity:
131+
// - merge_base == pinnedSHA → Reachable (SHA is a true ancestor of ref)
132+
// - merge_base != pinnedSHA → Unreachable (fork/imposter commit)
133+
// - 404 (no common ancestor or not found) → Unreachable
134+
// - 403/429 (rate limit) or other error → Unknown
135+
//
136+
// When ref is itself a raw SHA (the "uses: owner/repo@SHA" anti-pattern),
137+
// the compare becomes {sha}...{sha} which trivially returns "identical" and
138+
// cannot detect fork commits. In this case, a warning is returned instead.
137139
func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityResult {
138140
result := ReachabilityResult{
139141
Owner: owner,
@@ -158,7 +160,15 @@ func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityR
158160
return result
159161
}
160162

161-
status, detail := r.gitReachabilityCheck(owner, repo, sha, ref)
163+
// SHA-as-ref anti-pattern: compare/{sha}...{sha} is trivially identical
164+
// and cannot detect fork commits. Warn the user.
165+
if shaRefRE.MatchString(ref) {
166+
result.Status = ReachabilityUnknown
167+
result.Detail = "ref is a raw SHA — reachability cannot be verified; pin to a tag instead"
168+
return result
169+
}
170+
171+
status, detail := r.apiReachabilityCheck(owner, repo, sha, ref)
162172
result.Status = status
163173
result.Detail = detail
164174
if result.Status != ReachabilityUnknown {
@@ -167,55 +177,45 @@ func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityR
167177
return result
168178
}
169179

170-
// ensureBareClone clones or fetches a bare blobless repo into the cache dir.
171-
func (r *Resolver) ensureBareClone(owner, repo string) (string, error) {
172-
repoDir := filepath.Join(r.CacheDir, owner, repo+".git")
173-
cloneURL := fmt.Sprintf("https://%s/%s/%s.git", r.hostname, owner, repo)
174-
175-
if _, err := os.Stat(filepath.Join(repoDir, "HEAD")); err == nil {
176-
// Already cloned — fetch latest refs
177-
cmd := exec.Command("git", "-C", repoDir, "fetch", "--quiet", "--tags", "--force")
178-
if out, err := cmd.CombinedOutput(); err != nil {
179-
return "", fmt.Errorf("git fetch failed: %s: %w", strings.TrimSpace(string(out)), err)
180-
}
181-
return repoDir, nil
182-
}
183-
184-
if err := os.MkdirAll(filepath.Dir(repoDir), 0o755); err != nil {
185-
return "", err
186-
}
187-
cmd := exec.Command("git", "clone", "--filter=blob:none", "--bare", "--quiet", cloneURL, repoDir)
188-
if out, err := cmd.CombinedOutput(); err != nil {
189-
return "", fmt.Errorf("git clone failed: %s: %w", strings.TrimSpace(string(out)), err)
190-
}
191-
return repoDir, nil
180+
// compareResponse is the subset of the GitHub Compare API response we need.
181+
type compareResponse struct {
182+
MergeBaseCommit struct {
183+
SHA string `json:"sha"`
184+
} `json:"merge_base_commit"`
185+
Status string `json:"status"`
192186
}
193187

194-
// gitReachabilityCheck uses a bare clone and merge-base --is-ancestor to verify
195-
// that sha is an ancestor of ref.
196-
func (r *Resolver) gitReachabilityCheck(owner, repo, sha, ref string) (ReachabilityStatus, string) {
197-
repoDir, err := r.ensureBareClone(owner, repo)
188+
// apiReachabilityCheck uses the GitHub Compare API to verify that sha is an
189+
// ancestor of ref. The key insight: merge_base(ancestor, descendant) == ancestor.
190+
// If the merge_base is NOT the pinned SHA, the commit lives on the fork network.
191+
func (r *Resolver) apiReachabilityCheck(owner, repo, sha, ref string) (ReachabilityStatus, string) {
192+
path := fmt.Sprintf("repos/%s/%s/compare/%s...%s", owner, repo, sha, ref)
193+
194+
var resp compareResponse
195+
err := r.restClient.Get(path, &resp)
198196
if err != nil {
197+
var httpErr *api.HTTPError
198+
if errors.As(err, &httpErr) {
199+
switch {
200+
case httpErr.StatusCode == http.StatusNotFound:
201+
return Unreachable, "no common ancestor or commit not found"
202+
case httpErr.StatusCode == http.StatusForbidden || httpErr.StatusCode == http.StatusTooManyRequests:
203+
detail := fmt.Sprintf("rate limited (HTTP %d)", httpErr.StatusCode)
204+
if reset := httpErr.Headers.Get("X-RateLimit-Reset"); reset != "" {
205+
detail += "; resets at " + reset
206+
}
207+
return ReachabilityUnknown, detail
208+
default:
209+
return ReachabilityUnknown, fmt.Sprintf("API error (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
210+
}
211+
}
199212
return ReachabilityUnknown, err.Error()
200213
}
201214

202-
cmd := exec.Command("git", "-C", repoDir, "merge-base", "--is-ancestor", sha, ref)
203-
out, err := cmd.CombinedOutput()
204-
if err == nil {
205-
return Reachable, "ancestor of " + ref
206-
}
207-
208-
// exit 1 = not ancestor, exit 128 = SHA unknown
209-
var exitErr *exec.ExitError
210-
if errors.As(err, &exitErr) {
211-
switch exitErr.ExitCode() {
212-
case 1:
213-
return Unreachable, "commit is not an ancestor of " + ref
214-
case 128:
215-
return Unreachable, "commit not found in repository: " + strings.TrimSpace(string(out))
216-
}
215+
if resp.MergeBaseCommit.SHA == sha {
216+
return Reachable, "ancestor of " + ref + " (compare: " + resp.Status + ")"
217217
}
218-
return ReachabilityUnknown, err.Error()
218+
return Unreachable, fmt.Sprintf("merge base is %s, not the pinned SHA — likely a fork-network commit", resp.MergeBaseCommit.SHA[:12])
219219
}
220220

221221
// CheckReachabilityAll runs reachability checks on a batch of dependencies,

0 commit comments

Comments
 (0)