Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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: 2 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ A top-level workflow that does not declare the effective event is excluded befor

**🟡 Supported subset.** Calls may use a local path or a literal public GitHub reference such as `owner/repository/.github/workflows/ci.yml@v1`. A public reference resolves once per operation to an immutable commit. Nested `./.github/workflows/...` calls resolve in that pinned repository.

Like GitHub, a `./...` action inside a remote reusable workflow resolves in the caller job's workspace, not relative to the called workflow file. A remote workflow may check out its own pinned repository into a workspace subdirectory and then invoke a local action there. Buildkite binds that action to the called workflow's immutable repository source, rewrites the matching checkout to the exact commit, verifies the workspace copy against the source, and executes the verified source copy.
Comment thread
zhming0 marked this conversation as resolved.
Outdated

**✅ Supported:**

- Local `./.github/workflows/...` paths.
Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Workflow files, action metadata, event snapshots, and job plans are untrusted in

Digests and immutable source locks detect changed code. They do not make code trusted or grant credentials.

Public reusable workflows use the same bounded repository source and cache as public actions. Each requested ref resolves once per validation, compilation, or upload operation to an immutable commit and repository digest. Plans also bind each selected workflow file digest. Runtime jobs do not load remote workflow YAML from the caller workspace.
Public reusable workflows use the same bounded repository source and cache as public actions. Each requested ref resolves once per validation, compilation, or upload operation to an immutable commit and repository digest. Plans also bind each selected workflow file digest. Runtime jobs do not load remote workflow YAML from the caller workspace. Source-checked local actions use the same provenance: the runtime verifies the caller-workspace alias against the immutable action directory, then executes the verified source copy. See [Reusable workflows](compatibility.md#reusable-workflows).
Comment thread
zhming0 marked this conversation as resolved.
Outdated

Push and pull request path-filter admission uses Buildkite's reserved linked-webhook metadata only after binding it to the Buildkite repository and commit and matching local Git history. Missing, shallow, ambiguous, oversized, or mismatched evidence prevents admission. Explicit and generated snapshots cannot grant this admission. This check controls workflow selection; it does not make the selected workflow trusted.

Expand All @@ -36,7 +36,7 @@ Reusable-workflow call conditions are immutable plan guards evaluated in caller

| Credential | Current boundary |
| --- | --- |
| Repository checkout | The verified adapter checks the event repository and exact commit. Buildkite authorizes managed private access; credentials are command-scoped and not persisted. |
| Repository checkout | The verified adapter checks the event repository and exact commit. A source-checked public reusable workflow may instead anonymously check out its provenance-bound public repository at the exact commit. Buildkite authorizes managed private access only for the event repository; credentials are command-scoped and not persisted. |
| `GITHUB_TOKEN` | Supported static uses receive one short-lived token for the event repository and top-level requesting workflow permissions. Omitted workflow permissions mean exactly `contents: read`; GitHub repository and organization settings are not inherited. Reusable-workflow jobs receive the same permissions because Buildkite does not inspect called workflow permission maps. Buildkite verifies the pipeline repository, immutable commit, top-level workflow policy, and build provenance. Pull requests are limited to `contents: read`; merge queues are denied. The token is not ambient. |
| Cache token | When caching is configured, every JavaScript or Docker action lifecycle receives a fresh job-bound token. This includes compatible clients such as `actions/setup-go`, not only `actions/cache`. Shell steps do not receive it. |
| Ordinary workflow secrets | Static names are resolved with `buildkite-agent secret get` in the destination job. The job's Buildkite identity and Secret access policies are the sole authorization boundary. Values are registered with Agent and local redaction before use. |
Expand Down
32 changes: 23 additions & 9 deletions internal/action/integration/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,14 @@ func sortedCheckoutCommits() []string {
// ValidateCheckoutInputs enforces the release-specific input contract
// implemented by the tokenless event-repository checkout adapter.
func ValidateCheckoutInputs(commit string, inputs map[string]string, repository, sha string) error {
if err := ValidateCheckoutInputNames(inputs); err != nil {
return err
}
names := sortedNames(inputs)
seen := make(map[string]bool, len(names))
generation := checkoutGeneration(commit)
for _, name := range names {
value := inputs[name]
normalized := strings.ToLower(name)
if seen[normalized] {
return fmt.Errorf("duplicate case-insensitive input %q is unsupported", name)
}
seen[normalized] = true
if checkoutInputIntroduced[normalized] > generation {
return fmt.Errorf("explicit input %q is unsupported by this actions/checkout release", name)
}
Expand All @@ -137,7 +135,7 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
continue
}
case "ref":
if value == "" || ValidCheckoutSHA(value) || validCheckoutBranch(value) {
if value == "" || ValidCheckoutSHA(value) || ValidCheckoutBranch(value) {
continue
}
case "persist-credentials":
Expand Down Expand Up @@ -170,7 +168,7 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
continue
}
case "path":
if value == "" || validCheckoutPath(value) {
if value == "" || ValidCheckoutPath(value) {
continue
}
case "ssh-key", "ssh-known-hosts", "sparse-checkout":
Expand Down Expand Up @@ -199,7 +197,22 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
return nil
}

func validCheckoutBranch(value string) bool {
// ValidateCheckoutInputNames rejects names whose case-insensitive lookup would be ambiguous.
func ValidateCheckoutInputNames(inputs map[string]string) error {
names := sortedNames(inputs)
seen := make(map[string]bool, len(names))
for _, name := range names {
normalized := strings.ToLower(name)
Comment thread
zhming0 marked this conversation as resolved.
Outdated
if seen[normalized] {
return fmt.Errorf("duplicate case-insensitive input %q is unsupported", name)
}
seen[normalized] = true
}
return nil
}

// ValidCheckoutBranch reports whether value is a bounded branch-like Git ref.
func ValidCheckoutBranch(value string) bool {
if strings.HasPrefix(value, "refs/heads/") {
value = strings.TrimPrefix(value, "refs/heads/")
} else if strings.HasPrefix(value, "refs/") {
Expand Down Expand Up @@ -243,6 +256,7 @@ func ValidCheckoutSHA(value string) bool {
return true
}

func validCheckoutPath(value string) bool {
// ValidCheckoutPath reports whether value is one safe top-level checkout directory.
func ValidCheckoutPath(value string) bool {
return len(value) <= 255 && value != "." && value != ".." && !strings.EqualFold(value, ".git") && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.ContainsAny(value, "\r\n\x00") && filepath.IsLocal(value)
}
113 changes: 103 additions & 10 deletions internal/compiler/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"sort"
Expand Down Expand Up @@ -62,6 +63,8 @@ func (s PublicActionSource) Fetch(ctx context.Context, ref source.Reference) (so
type actionLockBuilder struct {
workspace string
source ActionSource
remote *RemoteWorkflowSource
remoteRoot string
nodes map[string]*actionNode
ids map[string]string
active map[string]bool
Expand Down Expand Up @@ -109,7 +112,7 @@ func validateActionResolutions(ctx context.Context, ir IR, options Options) (Pro
evidence.ActionResolutionComplete = false
continue
}
_, err := compileActionInvocations(ctx, instance.RepositoryRoot, actionSource, plan.EventServerURL(ir.Event.Provider), []string{step.Uses}, []map[string]string{step.With})
_, err := compileActionInvocationsForSource(ctx, instance.RepositoryRoot, instance.RemoteWorkflow, actionSource, plan.EventServerURL(ir.Event.Provider), []string{step.Uses}, []map[string]string{step.With})
evaluation := ActionEvaluation{Instance: instance.Key, Job: instance.LogicalJobID, Reference: step.Uses, Step: i + 1, Passed: err == nil}
evidence.Actions = append(evidence.Actions, evaluation)
if err == nil {
Expand Down Expand Up @@ -215,6 +218,10 @@ func compileActionLocks(ctx context.Context, workspace string, actionSource Acti
}

func compileActionInvocations(ctx context.Context, workspace string, actionSource ActionSource, serverURL string, refs []string, suppliedInputs []map[string]string) (actionCompilation, error) {
return compileActionInvocationsForSource(ctx, workspace, nil, actionSource, serverURL, refs, suppliedInputs)
}

func compileActionInvocationsForSource(ctx context.Context, workspace string, remote *RemoteWorkflowSource, actionSource ActionSource, serverURL string, refs []string, suppliedInputs []map[string]string) (actionCompilation, error) {
if workspace == "" {
return actionCompilation{}, fmt.Errorf("workflow path must identify a repository root")
}
Expand All @@ -225,7 +232,7 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
if err != nil {
return actionCompilation{}, fmt.Errorf("resolve workspace: %w", err)
}
b := &actionLockBuilder{workspace: abs, source: actionSource, nodes: map[string]*actionNode{}, ids: map[string]string{}, active: map[string]bool{}, caps: map[string]bool{}}
b := &actionLockBuilder{workspace: abs, source: actionSource, remote: cloneRemoteWorkflowSource(remote), nodes: map[string]*actionNode{}, ids: map[string]string{}, active: map[string]bool{}, caps: map[string]bool{}}
defer func() {
for _, materialized := range b.materialized {
materialized.Release()
Expand All @@ -234,7 +241,7 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
selectors := make([]plan.ActionSelector, 0, len(refs))
roots := make([]*actionNode, 0, len(refs))
for _, ref := range refs {
n, err := b.add(ctx, ref, 1)
n, err := b.add(ctx, ref, 1, nil)
if err != nil {
return actionCompilation{}, err
}
Expand Down Expand Up @@ -281,11 +288,11 @@ func compileActionInvocations(ctx context.Context, workspace string, actionSourc
}, nil
}

func (b *actionLockBuilder) add(ctx context.Context, raw string, depth int) (*actionNode, error) {
func (b *actionLockBuilder) add(ctx context.Context, raw string, depth int, parent *actionNode) (*actionNode, error) {
if depth > metadata.MaxNestedActionDepth {
return nil, fmt.Errorf("action nesting exceeds maximum depth %d at %q", metadata.MaxNestedActionDepth, raw)
}
key, lock, root, loadPath, err := b.describe(ctx, raw)
key, lock, root, loadPath, err := b.describe(ctx, raw, parent)
if err != nil {
return nil, fmt.Errorf("compile action %q: %w", raw, err)
}
Expand Down Expand Up @@ -355,7 +362,7 @@ func (b *actionLockBuilder) add(ctx context.Context, raw string, depth int) (*ac
if step.Uses == "" {
continue
}
child, err := b.add(ctx, step.Uses, depth+1)
child, err := b.add(ctx, step.Uses, depth+1, n)
if err != nil {
return nil, &actionChildError{child: step.Uses, err: err}
}
Expand Down Expand Up @@ -459,18 +466,28 @@ func hasActionInput(inputs map[string]string, name string) bool {
return false
}

func (b *actionLockBuilder) describe(ctx context.Context, raw string) (string, plan.ActionLock, string, string, error) {
func (b *actionLockBuilder) describe(ctx context.Context, raw string, parent *actionNode) (string, plan.ActionLock, string, string, error) {
if strings.HasPrefix(raw, "./") {
p := strings.TrimPrefix(raw, "./")
if p == "." || p != "" && (path.Clean(p) != p || strings.Contains(p, "\\") || strings.HasPrefix(p, "/")) {
return "", plan.ActionLock{}, "", "", fmt.Errorf("invalid local action path")
}
m, err := metadata.Load(b.workspace, p)
if err != nil {
if err == nil {
digest, err := source.DigestTree(m.Path)
return "workspace:" + p, plan.ActionLock{Source: "workspace", Path: p, SourceDigest: digest}, b.workspace, p, err
}
if _, statErr := os.Lstat(filepath.Join(b.workspace, filepath.FromSlash(p))); statErr == nil || !errors.Is(statErr, os.ErrNotExist) {
return "", plan.ActionLock{}, "", "", err
}
key, lock, root, loadPath, sourceErr := b.describeSourceBackedLocalAction(ctx, p, parent)
if sourceErr != nil {
return "", plan.ActionLock{}, "", "", sourceErr
}
if key == "" {
return "", plan.ActionLock{}, "", "", err
}
digest, err := source.DigestTree(m.Path)
return "workspace:" + p, plan.ActionLock{Source: "workspace", Path: p, SourceDigest: digest}, b.workspace, p, err
return key, lock, root, loadPath, nil
}
ref, err := source.Parse(raw)
if err != nil {
Expand Down Expand Up @@ -510,6 +527,82 @@ func (b *actionLockBuilder) describe(ctx context.Context, raw string) (string, p
return key, lock, repositoryRoot, ref.Path, nil
}

func (b *actionLockBuilder) describeSourceBackedLocalAction(ctx context.Context, workspacePath string, parent *actionNode) (string, plan.ActionLock, string, string, error) {
remote := b.remote
if remote == nil || b.source == nil {
return "", plan.ActionLock{}, "", "", nil
}
if parent != nil && (parent.lock.Source != "github" || parent.lock.Repository != remote.Repository || parent.lock.Commit != remote.Commit || parent.lock.SourceDigest != remote.SourceDigest) {
return "", plan.ActionLock{}, "", "", nil
}
repositoryRoot, err := b.remoteRepositoryRoot(ctx)
if err != nil {
return "", plan.ActionLock{}, "", "", err
}
parts := strings.Split(workspacePath, "/")
type match struct{ alias, path string }
var matches []match
for i := 1; i < len(parts); i++ {
candidate := strings.Join(parts[i:], "/")
hasManifest := false
for _, name := range []string{"action.yml", "action.yaml"} {
_, statErr := os.Lstat(filepath.Join(repositoryRoot, filepath.FromSlash(candidate), name))
switch {
case statErr == nil:
hasManifest = true
case errors.Is(statErr, os.ErrNotExist):
default:
return "", plan.ActionLock{}, "", "", fmt.Errorf("inspect remote workflow action candidate %q: %w", candidate, statErr)
}
}
if hasManifest {
matches = append(matches, match{alias: strings.Join(parts[:i], "/"), path: candidate})
}
}
if len(matches) == 0 {
return "", plan.ActionLock{}, "", "", nil
}
if len(matches) != 1 {
return "", plan.ActionLock{}, "", "", fmt.Errorf("local action %q matches multiple paths in immutable remote workflow source", workspacePath)
}
selected := matches[0]
if _, err := metadata.Load(repositoryRoot, selected.path); err != nil {
return "", plan.ActionLock{}, "", "", err
}
lock := plan.ActionLock{
Source: "github", Repository: remote.Repository, RequestedRef: remote.RequestedRef, Commit: remote.Commit,
Path: selected.path, WorkspaceAlias: selected.alias, SourceDigest: remote.SourceDigest,
}
b.caps["network"] = true
key := "github-workspace:" + remote.Repository + "/" + selected.path + "@" + remote.Commit + "\x00" + selected.alias
return key, lock, repositoryRoot, selected.path, nil
}

func (b *actionLockBuilder) remoteRepositoryRoot(ctx context.Context) (string, error) {
if b.remoteRoot != "" {
return b.remoteRoot, nil
}
remote := b.remote
ref, err := source.Parse(remote.Repository + "@" + remote.Commit)
if err != nil {
return "", fmt.Errorf("resolve remote workflow action source: %w", err)
}
resolved, materialized, err := b.source.Fetch(ctx, ref)
if err != nil {
return "", fmt.Errorf("resolve remote workflow action source: %w", err)
}
b.materialized = append(b.materialized, materialized)
if strings.ToLower(resolved.Commit) != remote.Commit || materialized.SourceDigest != remote.SourceDigest {
return "", fmt.Errorf("remote workflow action source does not match immutable workflow provenance")
}
repositoryRoot, err := canonicalMaterializedRepositoryRoot(materialized.RepositoryRoot)
if err != nil {
return "", fmt.Errorf("resolve remote workflow action source: %w", err)
}
b.remoteRoot = repositoryRoot
return repositoryRoot, nil
}

type memoizedActionSource struct {
source ActionSource
mu sync.Mutex
Expand Down
Loading