Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/handle_analyze_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ jobs:
err = json.Unmarshal([]byte(contentText), &insights)
require.NoError(t, err)

assert.Len(t, insights.Findings, 3, "Found findings")
assert.Len(t, insights.Findings, 1, "Found findings")

t.Logf("Successfully analyzed manifest with %d findings", len(insights.Findings))
}
Expand Down
3 changes: 3 additions & 0 deletions docs/content/en/rules/untrusted_checkout_exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ severity: error

The workflow appears to checkout untrusted code from a fork and uses a command that is known to allow code execution.

Supported `actions/checkout` releases refuse fork pull request code by default for `pull_request_target` and pull-request-originated `workflow_run` events. Poutine suppresses this finding when that guard applies. The finding remains when the checkout uses `allow-unsafe-pr-checkout: true`, a dynamic value for that input, a known vulnerable commit or a semantic version below its release line's fixed version, or the workflow also uses an event that the guard does not cover. Moving major tags from v2 onward and branch-like refs are assumed to contain the guard.

Using workflows with `pull_request_target` has the added benefit (as opposed to `pull_request`) of allowing access to secrets even in forked repositories. There can be good reasons to do so if you need to use API Keys to talk to some external services or want to interact with the GitHub API with `write` permissions. However, this comes at the cost of paying extra attention to the tools you use in your workflow.

So-called "Living Off The Pipeline" tools are common development tools (typically CLIs), commonly used in CI/CD pipelines that have lesser-known RCE-By-Design features ("foot guns") that can be abused to execute arbitrary code. These tools are often used to automate tasks such as compiling, testing, packaging, linting or scanning. The gotcha comes from the fact that many of those tools will consume unutrusted input from files on disk and when you checkout untrusted code from a fork, you are effectively allowing the attacker to control the input to those tools.
Expand Down Expand Up @@ -170,6 +172,7 @@ jobs:
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
allow-unsafe-pr-checkout: true # Explicitly disables the fork pull request guard
# (5) Persisting credentials is not necessary - Though this is not a panacea, credentials can still be dumped from memory
# (6) Checking untrusted code in default workspace path - In this scenario, it's good to explicitely define the path with untrusted code
- name: Install dependencies
Expand Down
273 changes: 270 additions & 3 deletions opa/opa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package opa

import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"strings"
"testing"

"github.com/boostsecurityio/poutine/models"
"github.com/boostsecurityio/poutine/results"
"github.com/open-policy-agent/opa/v1/ast"

"fmt"
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/require"
)

//go:embed testdata/embedded
Expand Down Expand Up @@ -158,6 +162,269 @@ func TestJobUsesSelfHostedRunner(t *testing.T) {
}
}

func TestCheckoutForkPRGuard(t *testing.T) {
o, err := NewOpa(context.TODO(), &models.Config{
Include: []models.ConfigInclude{},
})
require.NoError(t, err)

tests := []struct {
name string
input map[string]interface{}
want bool
}{
{
name: "fixed release on pull request target",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4",
nil,
nil,
),
want: true,
},
{
name: "fixed commit on pull request target",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a",
nil,
nil,
),
want: true,
},
{
name: "vulnerable commit",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2",
nil,
nil,
),
},
{
name: "vulnerable version ref",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4.3.1",
nil,
nil,
),
},
{
name: "unlisted version below fixed floor",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4.3.2",
nil,
nil,
),
},
{
name: "major minor below fixed floor",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4.3",
nil,
nil,
),
},
{
name: "major minor at fixed floor",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4.4",
nil,
nil,
),
want: true,
},
{
name: "prerelease below fixed floor",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v2.8.0-rc.1",
nil,
nil,
),
},
{
name: "build metadata at fixed floor",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v2.8.0+build.1",
nil,
nil,
),
want: true,
},
{
name: "v1 prerelease",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v1.2.3-alpha.1",
nil,
nil,
),
},
{
name: "numeric prerelease follows Rego semver",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v2.8.0-01",
nil,
nil,
),
},
{
name: "invalid empty prerelease identifier",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v2.8.0-alpha..1",
nil,
nil,
),
want: true,
},
{
name: "vulnerable v1 major ref",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v1",
nil,
nil,
),
},
{
name: "unknown version ref",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v8",
nil,
nil,
),
want: true,
},
{
name: "future semantic version ref",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v8.0.0",
nil,
nil,
),
want: true,
},
{
name: "branch ref",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@feature-branch",
nil,
nil,
),
want: true,
},
{
name: "unsafe checkout opt out",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}},
"actions/checkout@v4",
[]map[string]interface{}{{"name": "allow-unsafe-pr-checkout", "value": "true"}},
nil,
),
},
{
name: "unguarded event",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "pull_request_target"}, {"name": "issue_comment"}},
"actions/checkout@v4",
nil,
nil,
),
},
{
name: "pull request workflow run",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "workflow_run", "workflows": []string{"PR checks"}}},
"actions/checkout@v4",
nil,
[]map[string]interface{}{{
"github_actions_workflows": []map[string]interface{}{{
"name": "PR checks",
"events": []map[string]interface{}{{"name": "pull_request"}},
}},
}},
),
want: true,
},
{
name: "non pull request workflow run",
input: checkoutGuardInput(
[]map[string]interface{}{{"name": "workflow_run", "workflows": []string{"Issue checks"}}},
"actions/checkout@v4",
nil,
[]map[string]interface{}{{
"github_actions_workflows": []map[string]interface{}{{
"name": "Issue checks",
"events": []map[string]interface{}{{"name": "issue_comment"}},
}},
}},
),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got bool
err := o.Eval(
context.TODO(),
`count([true | utils.checkout_fork_pr_guard_blocks_step(input.workflow, input.step, {"issues", "issue_comment", "workflow_call"})]) > 0`,
tt.input,
&got,
)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func checkoutGuardInput(events []map[string]interface{}, uses string, with, packages []map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"workflow": map[string]interface{}{"events": events},
"step": map[string]interface{}{
"uses": uses,
"with_ref": "${{ github.event.pull_request.head.sha }}",
"with": with,
},
"packages": packages,
}
}

func TestCheckoutGuardData(t *testing.T) {
o, err := NewOpa(context.TODO(), &models.Config{
Include: []models.ConfigInclude{},
})
require.NoError(t, err)

var vulnerableSHAs []string
err = o.Eval(
context.TODO(),
`sort([sha | sha := data.poutine.checkout_guard_data.vulnerable_commit_shas[_]])`,
nil,
&vulnerableSHAs,
)
require.NoError(t, err)
assert.Len(t, vulnerableSHAs, 223)
digest := sha256.Sum256([]byte(strings.Join(vulnerableSHAs, "\n")))
assert.Equal(
t,
"017930673376b24e4bff0fa4bd8fd9c4c196da4b6cd5eb193b421032b43598c1",
hex.EncodeToString(digest[:]),
)

}

func TestWithConfig(t *testing.T) {
o, err := NewOpa(context.TODO(), &models.Config{
Include: []models.ConfigInclude{},
Expand Down
Loading
Loading