Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 284 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 <workflow-path>
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 <workflow-path>
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 <workflow-path>
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 <workflow-path>
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")
}
25 changes: 25 additions & 0 deletions internal/httpmock/httpmock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading