Skip to content

Commit fbc661f

Browse files
authored
Merge pull request #2 from github/ns/reachability-checks
feat: add commit reachability checks
2 parents 90e6937 + 2bba3eb commit fbc661f

6 files changed

Lines changed: 864 additions & 2 deletions

File tree

command_test.go

Lines changed: 284 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ dependencies:
114114
- github.com/actions/setup-go@v6:sha1-4a3601121dd01d1626a1e23e37211e3254c1c06c
115115
`)
116116

117-
stdout, stderr, err := runCommandWithHTTP(t, reg,
117+
stdout, stderr, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(),
118118
"check", "--json", "valid,errors,warnings", workflowPath,
119119
)
120120
require.NoError(t, err)
@@ -287,6 +287,27 @@ dependencies:
287287

288288
const nodeActionYAML = "name: Test Action\nruns:\n using: node20\n"
289289

290+
// reachableFunc returns a checkReachFn that reports all commits as reachable.
291+
func reachableFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) {
292+
return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) {
293+
return resolver.Reachable, "ancestor of " + ref
294+
}
295+
}
296+
297+
// unreachableFunc returns a checkReachFn that reports all commits as unreachable.
298+
func unreachableFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) {
299+
return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) {
300+
return resolver.Unreachable, "commit is not an ancestor of " + ref
301+
}
302+
}
303+
304+
// unknownReachFunc returns a checkReachFn that reports unknown (clone failure).
305+
func unknownReachFunc() func(string, string, string, string) (resolver.ReachabilityStatus, string) {
306+
return func(owner, repo, sha, ref string) (resolver.ReachabilityStatus, string) {
307+
return resolver.ReachabilityUnknown, "clone failed"
308+
}
309+
}
310+
290311
func testRepoResponse(nameWithOwner, oid, actionYAML string) map[string]any {
291312
return map[string]any{
292313
"nameWithOwner": nameWithOwner,
@@ -312,11 +333,22 @@ func writeTempWorkflow(t *testing.T, body string) string {
312333
}
313334

314335
func runCommandWithHTTP(t *testing.T, rt http.RoundTripper, args ...string) (string, string, error) {
336+
return runCommandWithHTTPAndReach(t, rt, nil, args...)
337+
}
338+
339+
func runCommandWithHTTPAndReach(t *testing.T, rt http.RoundTripper, reachFn func(string, string, string, string) (resolver.ReachabilityStatus, string), args ...string) (string, string, error) {
315340
t.Helper()
316341

317342
oldResolver := newResolver
318343
newResolver = func(hostname string) (*resolver.Resolver, error) {
319-
return resolver.NewWithTransport(hostname, rt)
344+
r, err := resolver.NewWithTransport(hostname, rt)
345+
if err != nil {
346+
return nil, err
347+
}
348+
if reachFn != nil {
349+
r.SetCheckReachabilityFunc(reachFn)
350+
}
351+
return r, nil
320352
}
321353
defer func() {
322354
newResolver = oldResolver
@@ -348,3 +380,253 @@ func runCommandWithHTTP(t *testing.T, rt http.RoundTripper, args ...string) (str
348380

349381
return string(stdoutBytes), string(stderrBytes), runErr
350382
}
383+
384+
// ==========================================================================
385+
// Supply Chain Attack Reachability Tests
386+
//
387+
// These tests model real-world attacks where tag mutation or fork-network
388+
// injection was used to compromise GitHub Actions. The reachability check
389+
// should catch cases where a pinned SHA exists in the GitHub fork network
390+
// but is NOT on the canonical repository's ref lineage.
391+
//
392+
// References:
393+
// - tj-actions/changed-files (CVE-2025-30066): tag v44 pointed to malicious commit from fork
394+
// - reviewdog/action-setup: tag mutation via compromised PAT
395+
// - xygeni/xygeni-action: C2 reverse shell backdoor via tag poisoning
396+
// - aquasecurity/trivy-action: scanner-to-stealer tag manipulation
397+
// ==========================================================================
398+
399+
// TestCheck_TjActionsChangedFiles_TagMutationAttack models the March 2025
400+
// tj-actions/changed-files attack (CVE-2025-30066) where attackers
401+
// compromised a maintainer PAT and force-pushed tag v44 to a malicious
402+
// commit. The malicious commit is NOT reachable from the legitimate tag.
403+
// TestCheck_TamperedAndUnreachable verifies that when a pinned SHA differs
404+
// from live resolution AND the old SHA is unreachable, both errors are reported.
405+
func TestCheck_TamperedAndUnreachable(t *testing.T) {
406+
reg := &httpmock.Registry{}
407+
defer reg.Verify(t)
408+
409+
pinnedSHA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
410+
liveSHA := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
411+
412+
reg.Register(
413+
httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`),
414+
httpmock.JSONResponse(map[string]any{
415+
"data": map[string]any{
416+
"a0": testRepoResponse("example/action", liveSHA, nodeActionYAML),
417+
},
418+
}),
419+
)
420+
421+
workflowPath := writeTempWorkflow(t, `
422+
name: ci
423+
on: push
424+
jobs:
425+
test:
426+
runs-on: ubuntu-latest
427+
steps:
428+
- uses: example/action@v1
429+
430+
# Automatically generated and managed by: gh actions-pin --write <workflow-path>
431+
dependencies:
432+
- github.com/example/action@v1:sha1-`+pinnedSHA+`
433+
`)
434+
435+
stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(),
436+
"check", "--json", "valid,errors", workflowPath,
437+
)
438+
require.NoError(t, err, "JSON mode communicates errors in payload")
439+
440+
var payload struct {
441+
Valid bool `json:"valid"`
442+
Errors []validationError `json:"errors"`
443+
}
444+
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
445+
assert.False(t, payload.Valid)
446+
447+
errorTypes := map[string]bool{}
448+
for _, e := range payload.Errors {
449+
errorTypes[e.Type] = true
450+
}
451+
assert.True(t, errorTypes["TAMPERED"], "should detect SHA changed: %+v", payload.Errors)
452+
assert.True(t, errorTypes["UNREACHABLE"], "should detect unreachable commit: %+v", payload.Errors)
453+
}
454+
455+
// TestCheck_UnreachableOnly verifies that when a pinned SHA matches live
456+
// resolution but is not reachable from the ref, an UNREACHABLE error is reported.
457+
func TestCheck_UnreachableOnly(t *testing.T) {
458+
reg := &httpmock.Registry{}
459+
defer reg.Verify(t)
460+
461+
sha := "cccccccccccccccccccccccccccccccccccccccc"
462+
463+
reg.Register(
464+
httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`),
465+
httpmock.JSONResponse(map[string]any{
466+
"data": map[string]any{
467+
"a0": testRepoResponse("example/action", sha, nodeActionYAML),
468+
},
469+
}),
470+
)
471+
472+
workflowPath := writeTempWorkflow(t, `
473+
name: ci
474+
on: push
475+
jobs:
476+
test:
477+
runs-on: ubuntu-latest
478+
steps:
479+
- uses: example/action@v1
480+
481+
# Automatically generated and managed by: gh actions-pin --write <workflow-path>
482+
dependencies:
483+
- github.com/example/action@v1:sha1-`+sha+`
484+
`)
485+
486+
stdout, _, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(),
487+
"check", "--json", "valid,errors", workflowPath,
488+
)
489+
require.NoError(t, err, "JSON mode communicates errors in payload")
490+
491+
var payload struct {
492+
Valid bool `json:"valid"`
493+
Errors []validationError `json:"errors"`
494+
}
495+
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
496+
assert.False(t, payload.Valid)
497+
498+
hasUnreachable := false
499+
for _, e := range payload.Errors {
500+
if e.Type == "UNREACHABLE" {
501+
hasUnreachable = true
502+
}
503+
}
504+
assert.True(t, hasUnreachable, "should detect unreachable commit: %+v", payload.Errors)
505+
}
506+
507+
// TestCheck_ReachabilityUnknown verifies that when the reachability check
508+
// cannot complete, validation passes with a warning.
509+
func TestCheck_ReachabilityUnknown(t *testing.T) {
510+
reg := &httpmock.Registry{}
511+
defer reg.Verify(t)
512+
513+
sha := "dddddddddddddddddddddddddddddddddddddddd"
514+
515+
reg.Register(
516+
httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`),
517+
httpmock.JSONResponse(map[string]any{
518+
"data": map[string]any{
519+
"a0": testRepoResponse("example/action", sha, nodeActionYAML),
520+
},
521+
}),
522+
)
523+
524+
workflowPath := writeTempWorkflow(t, `
525+
name: ci
526+
on: push
527+
jobs:
528+
test:
529+
runs-on: ubuntu-latest
530+
steps:
531+
- uses: example/action@v1
532+
533+
# Automatically generated and managed by: gh actions-pin --write <workflow-path>
534+
dependencies:
535+
- github.com/example/action@v1:sha1-`+sha+`
536+
`)
537+
538+
stdout, _, err := runCommandWithHTTPAndReach(t, reg, unknownReachFunc(),
539+
"check", "--json", "valid,errors,warnings", workflowPath,
540+
)
541+
require.NoError(t, err, "unknown reachability should not fail the check")
542+
543+
var payload struct {
544+
Valid bool `json:"valid"`
545+
Errors []validationError `json:"errors"`
546+
Warnings []string `json:"warnings"`
547+
}
548+
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
549+
assert.True(t, payload.Valid, "valid should be true when reachability is unknown")
550+
assert.Empty(t, payload.Errors)
551+
assert.NotEmpty(t, payload.Warnings, "should have a reachability warning")
552+
assert.Contains(t, payload.Warnings[0], "reachability check inconclusive")
553+
}
554+
555+
// TestCheck_Reachable verifies the happy path: pinned SHA matches live
556+
// resolution and is reachable — validation passes with no errors or warnings.
557+
func TestCheck_Reachable(t *testing.T) {
558+
reg := &httpmock.Registry{}
559+
defer reg.Verify(t)
560+
561+
sha := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
562+
563+
reg.Register(
564+
httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`),
565+
httpmock.JSONResponse(map[string]any{
566+
"data": map[string]any{
567+
"a0": testRepoResponse("example/action", sha, nodeActionYAML),
568+
},
569+
}),
570+
)
571+
workflowPath := writeTempWorkflow(t, `
572+
name: ci
573+
on: push
574+
jobs:
575+
test:
576+
runs-on: ubuntu-latest
577+
steps:
578+
- uses: example/action@v1
579+
580+
# Automatically generated and managed by: gh actions-pin --write <workflow-path>
581+
dependencies:
582+
- github.com/example/action@v1:sha1-`+sha+`
583+
`)
584+
585+
stdout, _, err := runCommandWithHTTPAndReach(t, reg, reachableFunc(),
586+
"check", "--json", "valid,errors,warnings", workflowPath,
587+
)
588+
require.NoError(t, err)
589+
590+
var payload struct {
591+
Valid bool `json:"valid"`
592+
Errors []validationError `json:"errors"`
593+
Warnings []string `json:"warnings"`
594+
}
595+
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
596+
assert.True(t, payload.Valid)
597+
assert.Empty(t, payload.Errors)
598+
assert.Empty(t, payload.Warnings)
599+
}
600+
601+
// TestPin_UnreachableWarnsOnly verifies that an unreachable SHA during pin
602+
// warns on stderr but does not block the operation.
603+
func TestPin_UnreachableWarnsOnly(t *testing.T) {
604+
reg := &httpmock.Registry{}
605+
defer reg.Verify(t)
606+
607+
sha := "ffffffffffffffffffffffffffffffffffffffff"
608+
609+
reg.Register(
610+
httpmock.GraphQL(`repository\(owner: "example", name: "action"\)`),
611+
httpmock.JSONResponse(map[string]any{
612+
"data": map[string]any{
613+
"a0": testRepoResponse("example/action", sha, nodeActionYAML),
614+
},
615+
}),
616+
)
617+
618+
workflowPath := writeTempWorkflow(t, `
619+
name: ci
620+
on: push
621+
jobs:
622+
test:
623+
runs-on: ubuntu-latest
624+
steps:
625+
- uses: example/action@v1
626+
`)
627+
628+
_, stderr, err := runCommandWithHTTPAndReach(t, reg, unreachableFunc(), "--diff", workflowPath)
629+
require.NoError(t, err, "pin should succeed even with unreachable warning")
630+
assert.Contains(t, stderr, "NOT reachable")
631+
assert.Contains(t, stderr, "fork-network injection")
632+
}

internal/httpmock/httpmock.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,31 @@ func GraphQLQuery(body string, cb func(query string, variables map[string]any))
147147
}
148148
}
149149

150+
// REST matches a request by method and URL path pattern (regex).
151+
func REST(method, pathPattern string) Matcher {
152+
re := regexp.MustCompile(pathPattern)
153+
154+
return func(req *http.Request) bool {
155+
if !strings.EqualFold(req.Method, method) {
156+
return false
157+
}
158+
return re.MatchString(req.URL.Path)
159+
}
160+
}
161+
162+
// StatusResponse returns a response with the given status code and empty body.
163+
func StatusResponse(code int) Responder {
164+
return func(req *http.Request) (*http.Response, error) {
165+
return &http.Response{
166+
StatusCode: code,
167+
Header: http.Header{},
168+
Body: io.NopCloser(bytes.NewBuffer(nil)),
169+
Request: req,
170+
Status: fmt.Sprintf("%d", code),
171+
}, nil
172+
}
173+
}
174+
150175
func decodeJSONBody(req *http.Request, dest any) error {
151176
b, err := readBody(req)
152177
if err != nil {

0 commit comments

Comments
 (0)