@@ -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
288288const nodeActionYAML = "name: Test Action\n runs:\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+
290311func 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
314335func 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+ }
0 commit comments