diff --git a/docs/compatibility.md b/docs/compatibility.md index f5661da4..8d63dd0b 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 portable, top-level ASCII directory 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. + **✅ Supported:** - Local `./.github/workflows/...` paths. @@ -681,6 +683,8 @@ Mutable refs work only while they resolve to the upstream `main` snapshot or a k The adapter checks out a detached commit or static branch from the event repository at the workspace root or a clean top-level directory. It uses Buildkite repository-provider Git credentials when the job provides them; otherwise, it fetches anonymously. Credentials are scoped to each fetch command and verified submodule fetch command and are never persisted. +The table below describes event-repository checkouts. The [source-backed reusable-workflow checkout](#reusable-workflows) is the only exception: its repository, ref, and path must match immutable workflow provenance and a local-action alias. A tag checkout must use the exact `refs/tags/...` ref or commit; Buildkite rejects the bare tag because `actions/checkout` gives a same-named branch precedence. Buildkite discards its `token` input and fetches the exact commit anonymously. + | Input | Supported values | | --- | --- | | `repository` | Omitted, or the event `owner/repo`. | @@ -710,7 +714,7 @@ The `false` value and omission do not run submodule commands. The `true` value r See the [security model](security.md#checkout-and-submodules) for credential, Git, and job-isolation boundaries. -Alternate repositories, tags, non-event dynamic commits, LFS, sparse checkout, GitHub Enterprise Server, and credential persistence remain unsupported. Commit and branch checkouts remain detached and confined to the event repository. +Outside the source-backed reusable-workflow exception, alternate repositories, tags, non-event dynamic commits, LFS, sparse checkout, GitHub Enterprise Server, and credential persistence remain unsupported. Commit and branch checkouts remain detached and confined to the event repository. ### Upload artifact action diff --git a/docs/security.md b/docs/security.md index c25c096d..1451be34 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 retain the branch or tag namespace when applicable and 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). 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. @@ -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. | diff --git a/go.mod b/go.mod index e8e66624..d09dbe42 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 go.yaml.in/yaml/v4 v4.0.0-rc.3 golang.org/x/sys v0.42.0 + golang.org/x/text v0.14.0 ) require ( @@ -21,5 +22,4 @@ require ( github.com/mattn/go-shellwords v1.0.12 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.14.0 // indirect ) diff --git a/internal/action/integration/checkout.go b/internal/action/integration/checkout.go index 42c1e09c..423d0348 100644 --- a/internal/action/integration/checkout.go +++ b/internal/action/integration/checkout.go @@ -27,6 +27,10 @@ const ( CheckoutV6Commit = "d23441a48e516b6c34aea4fa41551a30e30af803" CheckoutV7InitialCommit = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" CheckoutV7Commit = "3d3c42e5aac5ba805825da76410c181273ba90b1" + + // The bounded adapter accepts 20 input names. A source checkout may also + // author token, which is discarded before adapter validation. + maxCheckoutInputNames = 21 ) var checkoutCommits = map[string]string{ @@ -118,16 +122,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) } @@ -137,7 +139,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": @@ -170,7 +172,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": @@ -199,7 +201,24 @@ 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 { + if len(inputs) > maxCheckoutInputNames { + return fmt.Errorf("more than %d explicit checkout inputs is unsupported", maxCheckoutInputNames) + } + names := sortedNames(inputs) + for index, name := range names { + for _, previous := range names[:index] { + if strings.EqualFold(name, previous) { + return fmt.Errorf("duplicate case-insensitive input %q is unsupported", name) + } + } + } + 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/") { @@ -243,6 +262,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) } diff --git a/internal/action/integration/checkout_test.go b/internal/action/integration/checkout_test.go index c4a21700..d86cbf21 100644 --- a/internal/action/integration/checkout_test.go +++ b/internal/action/integration/checkout_test.go @@ -1,6 +1,7 @@ package integration import ( + "fmt" "strings" "testing" ) @@ -77,6 +78,23 @@ func TestValidateCheckoutInputs(t *testing.T) { } } +func TestValidateCheckoutInputNamesRejectsEqualFoldCollision(t *testing.T) { + inputs := map[string]string{"repository": "one", "repo\u017Fitory": "two"} + if err := ValidateCheckoutInputNames(inputs); err == nil || !strings.Contains(err.Error(), "duplicate case-insensitive input") { + t.Fatalf("ValidateCheckoutInputNames(%#v) = %v, want duplicate-name rejection", inputs, err) + } +} + +func TestValidateCheckoutInputNamesRejectsOversizedMap(t *testing.T) { + inputs := make(map[string]string, maxCheckoutInputNames+1) + for index := range maxCheckoutInputNames + 1 { + inputs[fmt.Sprintf("input-%d", index)] = "" + } + if err := ValidateCheckoutInputNames(inputs); err == nil || !strings.Contains(err.Error(), "explicit checkout inputs") { + t.Fatalf("ValidateCheckoutInputNames(%d inputs) = %v, want input-count rejection", len(inputs), err) + } +} + func TestValidateCheckoutV3InputsRejectsLaterContract(t *testing.T) { repository, sha := "buildkite/buildkite-gha", strings.Repeat("a", 40) for _, input := range []map[string]string{ diff --git a/internal/action/source/action_resolution_snapshot.go b/internal/action/source/action_resolution_snapshot.go index e9ef2fbd..2d8c3dcb 100644 --- a/internal/action/source/action_resolution_snapshot.go +++ b/internal/action/source/action_resolution_snapshot.go @@ -42,13 +42,14 @@ type actionResolutionSnapshotCurrent struct { } type actionResolutionSnapshotEntry struct { - Schema string `json:"schema"` - Owner string `json:"owner"` - Repository string `json:"repository"` - Ref string `json:"ref"` - Commit string `json:"commit,omitempty"` - Missing bool `json:"missing,omitempty"` - ResolvedAt time.Time `json:"resolved_at"` + Schema string `json:"schema"` + Owner string `json:"owner"` + Repository string `json:"repository"` + Ref string `json:"ref"` + Commit string `json:"commit,omitempty"` + ResolvedRef string `json:"resolved_ref,omitempty"` + Missing bool `json:"missing,omitempty"` + ResolvedAt time.Time `json:"resolved_at"` } // WithActionResolutionSnapshot pins mutable refs to durable per-generation @@ -213,12 +214,16 @@ func (s *actionResolutionSnapshot) resolve(ctx context.Context, ref Reference, r return Resolved{}, err } resolved, err := resolve(ctx, ref) + if err == nil && resolved.ResolvedRef == "" { + resolved.ResolvedRef = resolved.Commit + } entry := actionResolutionSnapshotEntry{ Schema: actionResolutionSnapshotSchema, Owner: strings.ToLower(ref.Owner), Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, ResolvedAt: time.Now().UTC(), } if err == nil { entry.Commit = resolved.Commit + entry.ResolvedRef = resolved.ResolvedRef } else { var notPublic *NotPublicError if !errors.As(err, ¬Public) { @@ -292,7 +297,15 @@ func loadActionResolutionSnapshotEntry(path string, ref Reference) (Resolved, er if entry.Missing { return Resolved{}, &NotPublicError{}, true } - return Resolved{Reference: ref, Commit: entry.Commit}, nil, true + if entry.ResolvedRef == "" { + // Entries created before resolved refs were recorded retain their exact + // commit but cannot grant a qualified branch or tag checkout. + entry.ResolvedRef = entry.Commit + } + if !validResolvedRef(ref, entry.Commit, entry.ResolvedRef) { + return Resolved{}, fmt.Errorf("action resolution snapshot entry is invalid"), true + } + return Resolved{Reference: ref, Commit: entry.Commit, ResolvedRef: entry.ResolvedRef}, nil, true } func loadActionResolutionJSON(path string, value any) bool { diff --git a/internal/action/source/mutable_ref_cache.go b/internal/action/source/mutable_ref_cache.go index 9b57ef26..dd2835a4 100644 --- a/internal/action/source/mutable_ref_cache.go +++ b/internal/action/source/mutable_ref_cache.go @@ -19,12 +19,13 @@ type mutableRefCache struct { } type mutableRefEntry struct { - Schema string `json:"schema"` - Owner string `json:"owner"` - Repository string `json:"repository"` - Ref string `json:"ref"` - Commit string `json:"commit"` - ResolvedAt time.Time `json:"resolved_at"` + Schema string `json:"schema"` + Owner string `json:"owner"` + Repository string `json:"repository"` + Ref string `json:"ref"` + Commit string `json:"commit"` + ResolvedRef string `json:"resolved_ref"` + ResolvedAt time.Time `json:"resolved_at"` } func newMutableRefCache(root string, freshness time.Duration) (*mutableRefCache, error) { @@ -64,9 +65,12 @@ func (c *mutableRefCache) resolve(ctx context.Context, ref Reference, resolve fu if err != nil { return Resolved{}, err } + if resolved.ResolvedRef == "" { + resolved.ResolvedRef = resolved.Commit + } entry := mutableRefEntry{ Schema: "buildkite-gha-action-ref-resolution/v1", Owner: strings.ToLower(ref.Owner), - Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, Commit: resolved.Commit, ResolvedAt: time.Now().UTC(), + Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, Commit: resolved.Commit, ResolvedRef: resolved.ResolvedRef, ResolvedAt: time.Now().UTC(), } if err := c.store(path, entry); err != nil { return resolved, nil @@ -97,11 +101,11 @@ func (c *mutableRefCache) load(path string, ref Reference, now time.Time) (Resol if decoder.Decode(&entry) != nil || decoder.Decode(&struct{}{}) != io.EOF || entry.Schema != "buildkite-gha-action-ref-resolution/v1" || entry.Owner != strings.ToLower(ref.Owner) || entry.Repository != strings.ToLower(ref.Repository) || - entry.Ref != ref.Ref || !shaRE.MatchString(entry.Commit) || entry.ResolvedAt.After(now) || + entry.Ref != ref.Ref || !validResolvedRef(ref, entry.Commit, entry.ResolvedRef) || entry.ResolvedAt.After(now) || now.Sub(entry.ResolvedAt) >= c.freshness { return Resolved{}, false } - return Resolved{Reference: ref, Commit: entry.Commit}, true + return Resolved{Reference: ref, Commit: entry.Commit, ResolvedRef: entry.ResolvedRef}, true } func (c *mutableRefCache) store(path string, entry mutableRefEntry) error { diff --git a/internal/action/source/source.go b/internal/action/source/source.go index fedd41b3..7e2eca01 100644 --- a/internal/action/source/source.go +++ b/internal/action/source/source.go @@ -45,10 +45,12 @@ var ( // Reference is a parsed remote action reference. type Reference struct{ Owner, Repository, Path, Ref, Raw string } -// Resolved pins a requested reference to an immutable commit. +// Resolved pins a requested reference to an immutable commit and retains the +// selected branch or tag namespace when one was resolved. type Resolved struct { Reference Reference Commit string + ResolvedRef string SourceDigest string } @@ -283,7 +285,7 @@ func (r *Resolver) Resolve(ctx context.Context, ref Reference) (Resolved, error) } ref = parsed if shaRE.MatchString(ref.Ref) { - return Resolved{Reference: ref, Commit: ref.Ref}, nil + return Resolved{Reference: ref, Commit: ref.Ref, ResolvedRef: ref.Ref}, nil } if r.cfg.resolutionSnapshot != nil { return r.cfg.resolutionSnapshot.resolve(ctx, ref, r.resolveMutable) @@ -324,7 +326,7 @@ func (r *Resolver) resolveMutable(ctx context.Context, ref Reference) (Resolved, if err != nil { return Resolved{}, err } - return Resolved{Reference: ref, Commit: sha}, nil + return Resolved{Reference: ref, Commit: sha, ResolvedRef: "refs/" + kind + "/" + ref.Ref}, nil } var nf *NotPublicError if !errors.As(err, &nf) { @@ -343,8 +345,13 @@ func (r *Resolver) resolveCommit(ctx context.Context, ref Reference) (Resolved, if !shaRE.MatchString(v.SHA) { return Resolved{}, fmt.Errorf("GitHub returned malformed commit SHA") } - return Resolved{Reference: ref, Commit: v.SHA}, nil + return Resolved{Reference: ref, Commit: v.SHA, ResolvedRef: v.SHA}, nil } + +func validResolvedRef(ref Reference, commit, resolvedRef string) bool { + return shaRE.MatchString(commit) && (resolvedRef == commit || resolvedRef == "refs/tags/"+ref.Ref || resolvedRef == "refs/heads/"+ref.Ref) +} + func (r *Resolver) peel(ctx context.Context, ref Reference, typ, sha string) (string, error) { for range 5 { if !shaRE.MatchString(sha) { diff --git a/internal/action/source/source_test.go b/internal/action/source/source_test.go index 222d272d..5d5937c5 100644 --- a/internal/action/source/source_test.go +++ b/internal/action/source/source_test.go @@ -64,11 +64,31 @@ func TestResolverTagPeelingAndHeaders(t *testing.T) { } ref, _ := Parse("owner/repo@v1") got, err := r.Resolve(t.Context(), ref) - if err != nil || got.Commit != testSHA || len(calls) != 2 { + if err != nil || got.Commit != testSHA || got.ResolvedRef != "refs/tags/v1" || len(calls) != 2 { t.Fatalf("Resolve = %#v, %v; calls %v", got, err, calls) } } +func TestResolverRetainsBranchNamespace(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/git/ref/heads/") { + _, _ = fmt.Fprintf(w, `{"object":{"type":"commit","sha":"%s"}}`, testSHA) + return + } + http.NotFound(w, r) + })) + defer ts.Close() + resolver, err := NewResolver(ts.Client(), WithTestEndpoints(ts.URL)) + if err != nil { + t.Fatal(err) + } + ref, _ := Parse("owner/repo@main") + resolved, err := resolver.Resolve(t.Context(), ref) + if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/heads/main" { + t.Fatalf("Resolve() = %#v, %v", resolved, err) + } +} + func TestResolverOptionalAuthenticationAndVisibility(t *testing.T) { for _, tt := range []struct { name string @@ -230,12 +250,12 @@ func TestResolverCachesMutableRefsWithBoundedFreshness(t *testing.T) { t.Fatal(err) } resolved, err := resolver.Resolve(t.Context(), ref) - if err != nil || resolved.Commit != testSHA || calls.Load() != 1 { + if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || calls.Load() != 1 { t.Fatalf("cached Resolve() = %#v, %v; calls = %d", resolved, err, calls.Load()) } time.Sleep(60 * time.Millisecond) resolved, err = resolver.Resolve(t.Context(), ref) - if err != nil || resolved.Commit != commit || calls.Load() != 2 { + if err != nil || resolved.Commit != commit || resolved.ResolvedRef != "refs/tags/v1" || calls.Load() != 2 { t.Fatalf("revalidated Resolve() = %#v, %v; calls = %d", resolved, err, calls.Load()) } } @@ -1107,14 +1127,14 @@ func TestActionResolutionSnapshotPinsAndRefreshesMutableRefs(t *testing.T) { ref, _ := Parse("owner/repo@v1") first := newResolver(false) resolved, err := first.Resolve(t.Context(), ref) - if err != nil || resolved.Commit != testSHA || requests.Load() != 1 { + if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 1 { t.Fatalf("first resolution = %#v, %v; requests %d", resolved, err, requests.Load()) } firstGeneration := first.ResolutionSnapshotID() refreshed.Store(true) second := newResolver(false) resolved, err = second.Resolve(t.Context(), ref) - if err != nil || resolved.Commit != testSHA || requests.Load() != 1 || second.ResolutionSnapshotID() != firstGeneration { + if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 1 || second.ResolutionSnapshotID() != firstGeneration { t.Fatalf("reused resolution = %#v, %v; requests %d; generation %q", resolved, err, requests.Load(), second.ResolutionSnapshotID()) } if err := os.Remove(second.cfg.resolutionSnapshot.entryPath(ref)); err != nil { @@ -1128,7 +1148,7 @@ func TestActionResolutionSnapshotPinsAndRefreshesMutableRefs(t *testing.T) { } third := newResolver(true) resolved, err = third.Resolve(t.Context(), ref) - if err != nil || resolved.Commit != nextSHA || requests.Load() != 2 || third.ResolutionSnapshotID() == firstGeneration { + if err != nil || resolved.Commit != nextSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 2 || third.ResolutionSnapshotID() == firstGeneration { t.Fatalf("refreshed resolution = %#v, %v; requests %d; generation %q", resolved, err, requests.Load(), third.ResolutionSnapshotID()) } } diff --git a/internal/cli/validate_batch_test.go b/internal/cli/validate_batch_test.go index e0814651..326b6aea 100644 --- a/internal/cli/validate_batch_test.go +++ b/internal/cli/validate_batch_test.go @@ -26,7 +26,11 @@ type batchCountingActionSource struct { func (s *batchCountingActionSource) Fetch(_ context.Context, ref actionsource.Reference) (actionsource.Resolved, actionsource.Materialized, error) { s.calls++ commit := strings.Repeat("a", 40) - return actionsource.Resolved{Reference: ref, Commit: commit, SourceDigest: s.digest}, actionsource.Materialized{RepositoryRoot: s.root, ActionRoot: s.root, SourceDigest: s.digest}, nil + resolvedRef := "refs/tags/" + ref.Ref + if ref.Ref == commit { + resolvedRef = commit + } + return actionsource.Resolved{Reference: ref, Commit: commit, ResolvedRef: resolvedRef, SourceDigest: s.digest}, actionsource.Materialized{RepositoryRoot: s.root, ActionRoot: s.root, SourceDigest: s.digest}, nil } func TestValidateBatchWritesAndResumesAtomicReports(t *testing.T) { diff --git a/internal/compiler/actions.go b/internal/compiler/actions.go index d39bd6bd..53102279 100644 --- a/internal/compiler/actions.go +++ b/internal/compiler/actions.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "path" "path/filepath" "sort" @@ -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 @@ -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 { @@ -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") } @@ -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() @@ -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 } @@ -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) } @@ -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} } @@ -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 { @@ -510,6 +527,85 @@ 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 !plan.ValidSourceWorkspaceAlias(selected.alias) { + return "", plan.ActionLock{}, "", "", fmt.Errorf("source-backed local action workspace alias %q is not portable", selected.alias) + } + 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 @@ -520,8 +616,9 @@ type memoizedActionSource struct { } type memoizedRepositoryPin struct { - commit string - digest string + commit string + resolvedRef string + digest string } type memoizedAction struct { @@ -608,17 +705,20 @@ func (s *memoizedActionSource) Fetch(ctx context.Context, ref source.Reference) } if call.err == nil { call.resolved.Reference = ref - if pinned && (call.resolved.Commit != pin.commit || call.materialized.SourceDigest != pin.digest) { - call.materialized.Release() - call.materialized = source.Materialized{} - call.err = fmt.Errorf("repository source changed after immutable pin") + if pinned { + call.resolved.ResolvedRef = pin.resolvedRef + if call.resolved.Commit != pin.commit || call.materialized.SourceDigest != pin.digest { + call.materialized.Release() + call.materialized = source.Materialized{} + call.err = fmt.Errorf("repository source changed after immutable pin") + } } } s.mu.Lock() delete(s.active, key) if call.err == nil { if !pinned { - s.pins[repositoryKey] = memoizedRepositoryPin{commit: call.resolved.Commit, digest: call.materialized.SourceDigest} + s.pins[repositoryKey] = memoizedRepositoryPin{commit: call.resolved.Commit, resolvedRef: call.resolved.ResolvedRef, digest: call.materialized.SourceDigest} } s.cache[key] = memoizedAction{resolved: call.resolved, materialized: call.materialized} } diff --git a/internal/compiler/actions_test.go b/internal/compiler/actions_test.go index 492a07a6..fc90b31c 100644 --- a/internal/compiler/actions_test.go +++ b/internal/compiler/actions_test.go @@ -35,6 +35,24 @@ type blockingActionSource struct { release chan struct{} } +type namespaceActionSource struct { + root string + commit string + calls []source.Reference +} + +func (s *namespaceActionSource) Fetch(_ context.Context, ref source.Reference) (source.Resolved, source.Materialized, error) { + s.calls = append(s.calls, ref) + resolvedRef := s.commit + if ref.Ref == "v1" { + resolvedRef = "refs/tags/v1" + } + digest, err := source.DigestTree(s.root) + return source.Resolved{Reference: ref, Commit: s.commit, ResolvedRef: resolvedRef}, source.Materialized{ + RepositoryRoot: s.root, ActionRoot: filepath.Join(s.root, ref.Path), SourceDigest: digest, + }, err +} + func (s *blockingActionSource) Fetch(_ context.Context, ref source.Reference) (source.Resolved, source.Materialized, error) { if s.calls.Add(1) == 1 { close(s.started) @@ -536,6 +554,38 @@ runs: } } +func TestMemoizeRepositorySourceRetainsNamespaceAfterActionPathFetch(t *testing.T) { + root := t.TempDir() + writeAction(t, root, "action", "name: action\nruns:\n using: node24\n main: index.js\n") + commit := strings.Repeat("a", 40) + fake := &namespaceActionSource{root: root, commit: commit} + shared := MemoizeRepositorySource(fake) + actionRef, err := source.Parse("owner/repository/action@v1") + if err != nil { + t.Fatal(err) + } + if _, materialized, err := shared.Fetch(t.Context(), actionRef); err != nil { + t.Fatal(err) + } else { + materialized.Release() + } + repositoryRef, err := source.Parse("owner/repository@v1") + if err != nil { + t.Fatal(err) + } + resolved, materialized, err := shared.Fetch(t.Context(), repositoryRef) + if err != nil { + t.Fatal(err) + } + materialized.Release() + if resolved.Reference != repositoryRef || resolved.Commit != commit || resolved.ResolvedRef != "refs/tags/v1" { + t.Fatalf("memoized repository resolution = %#v", resolved) + } + if len(fake.calls) != 2 || fake.calls[0] != actionRef || fake.calls[1].Ref != commit || fake.calls[1].Path != "" { + t.Fatalf("underlying repository source calls = %#v", fake.calls) + } +} + func TestMemoizeActionSourceCoalescesConcurrentResolution(t *testing.T) { fake := &blockingActionSource{started: make(chan struct{}), release: make(chan struct{})} shared := MemoizeActionSource(fake) diff --git a/internal/compiler/plan_builder.go b/internal/compiler/plan_builder.go index 5bf1fe37..2e918626 100644 --- a/internal/compiler/plan_builder.go +++ b/internal/compiler/plan_builder.go @@ -197,7 +197,7 @@ func (b planBuilder) buildActions(instance JobInstance, steps []plan.Step, actio built.capabilities = capabilities return built, nil } - compiled, err := compileActionInvocations(b.ctx, instance.RepositoryRoot, b.actionSource, plan.EventServerURL(b.ir.Event.Provider), actionRefs, actionInputs) + compiled, err := compileActionInvocationsForSource(b.ctx, instance.RepositoryRoot, instance.RemoteWorkflow, b.actionSource, plan.EventServerURL(b.ir.Event.Provider), actionRefs, actionInputs) if err != nil { return built, fmt.Errorf("build plan for job %q: %w", instance.LogicalJobID, err) } @@ -217,7 +217,7 @@ func (b planBuilder) buildActions(instance JobInstance, steps []plan.Step, actio if !ok { return built, fmt.Errorf("build plan for job %q: action lock %q is missing", instance.LogicalJobID, selector.Lock) } - if err := b.validateActionAdapter(instance, stepIndex, lock, &built); err != nil { + if err := b.validateActionAdapter(instance, stepIndex, lock, compiled.locks, &built); err != nil { return built, err } } @@ -233,11 +233,15 @@ func (b planBuilder) buildActions(instance JobInstance, steps []plan.Step, actio return built, nil } -func (b planBuilder) validateActionAdapter(instance JobInstance, stepIndex int, lock plan.ActionLock, built *builtPlanActions) error { +func (b planBuilder) validateActionAdapter(instance JobInstance, stepIndex int, lock plan.ActionLock, locks []plan.ActionLock, built *builtPlanActions) error { descriptor, _ := actionintegration.Lookup(actionintegration.Identity{Source: lock.Source, Repository: lock.Repository, Path: lock.Path}) switch descriptor.Adapter { case actionintegration.AdapterCheckoutExactEventSHA: checkoutInputs := cloneMap(instance.Steps[stepIndex].With) + if err := actionintegration.ValidateCheckoutInputNames(checkoutInputs); err != nil { + span := instance.Steps[stepIndex].Span.Start + return fmt.Errorf("%s:%d:%d: checkout adapter: %w", instance.SourcePath, span.Line, span.Column, err) + } for name, value := range checkoutInputs { if !strings.EqualFold(name, "ref") { continue @@ -248,6 +252,18 @@ func (b planBuilder) validateActionAdapter(instance JobInstance, stepIndex int, checkoutInputs[name] = b.ir.Event.SHA } } + sourceInputs, sourceCheckout, err := bindRemoteWorkflowCheckoutInputs(instance.RemoteWorkflow, locks, checkoutInputs) + if err != nil { + span := instance.Steps[stepIndex].Span.Start + return fmt.Errorf("%s:%d:%d: checkout adapter: %w", instance.SourcePath, span.Line, span.Column, err) + } + if sourceCheckout { + if err := actionintegration.ValidateCheckoutInputs(lock.Commit, sourceInputs, instance.RemoteWorkflow.Repository, instance.RemoteWorkflow.Commit); err != nil { + span := instance.Steps[stepIndex].Span.Start + return fmt.Errorf("%s:%d:%d: checkout adapter: %w", instance.SourcePath, span.Line, span.Column, err) + } + return nil + } if err := actionintegration.ValidateCheckoutInputs(lock.Commit, checkoutInputs, b.ir.Event.Repository.Owner+"/"+b.ir.Event.Repository.Name, b.ir.Event.SHA); err != nil { span := instance.Steps[stepIndex].Span.Start return fmt.Errorf("%s:%d:%d: checkout adapter: %w", instance.SourcePath, span.Line, span.Column, err) @@ -272,6 +288,57 @@ func (b planBuilder) validateActionAdapter(instance JobInstance, stepIndex int, return nil } +func bindRemoteWorkflowCheckoutInputs(remote *RemoteWorkflowSource, locks []plan.ActionLock, inputs map[string]string) (map[string]string, bool, error) { + if remote == nil { + return nil, false, nil + } + value := func(wanted string) string { + for name, value := range inputs { + if strings.EqualFold(name, wanted) { + return value + } + } + return "" + } + checkoutPath := value("path") + aliasMatches := false + for _, lock := range locks { + if plan.EqualSourceWorkspaceAlias(lock.WorkspaceAlias, checkoutPath) { + aliasMatches = true + break + } + } + if !aliasMatches { + return nil, false, nil + } + if !strings.EqualFold(value("repository"), remote.Repository) { + return nil, true, fmt.Errorf("remote workflow source checkout repository does not match immutable workflow provenance") + } + if !remoteWorkflowCheckoutRefMatches(value("ref"), *remote) { + return nil, true, fmt.Errorf("remote workflow source checkout ref does not match immutable workflow provenance") + } + normalized := cloneMap(inputs) + for name := range normalized { + switch { + case strings.EqualFold(name, "token"): + delete(normalized, name) + case strings.EqualFold(name, "repository"): + normalized[name] = remote.Repository + case strings.EqualFold(name, "ref"): + normalized[name] = remote.Commit + } + } + return normalized, true, nil +} + +func remoteWorkflowCheckoutRefMatches(ref string, remote RemoteWorkflowSource) bool { + // Checkout gives a bare branch precedence over a same-named tag, while a + // reusable-workflow reference does the opposite. Bare names are safe only + // when workflow provenance selected that branch. + return ref == remote.Commit || ref == remote.ResolvedRef || + ref == remote.RequestedRef && !strings.HasPrefix(ref, "refs/") && remote.ResolvedRef == "refs/heads/"+remote.RequestedRef +} + func addContainerCapabilities(instance JobInstance, actionRefs []string, built *builtPlanActions) error { if instance.Container == nil && len(instance.Services) == 0 && instance.ServicesExpression == "" { return nil @@ -648,6 +715,6 @@ func planRemoteWorkflowSource(source *RemoteWorkflowSource) *plan.RemoteWorkflow return nil } return &plan.RemoteWorkflowSource{ - Repository: source.Repository, RequestedRef: source.RequestedRef, Commit: source.Commit, SourceDigest: source.SourceDigest, + Repository: source.Repository, RequestedRef: source.RequestedRef, ResolvedRef: source.ResolvedRef, Commit: source.Commit, SourceDigest: source.SourceDigest, } } diff --git a/internal/compiler/reusable_remote_test.go b/internal/compiler/reusable_remote_test.go index 5d69ac43..785cc011 100644 --- a/internal/compiler/reusable_remote_test.go +++ b/internal/compiler/reusable_remote_test.go @@ -63,7 +63,11 @@ func (s *fakeReusableRepositorySource) Fetch(ctx context.Context, ref actionsour if ref.Path != "" && s.driftPathDigest != "" { digest = s.driftPathDigest } - return actionsource.Resolved{Reference: ref, Commit: commit, SourceDigest: digest}, actionsource.Materialized{ + resolvedRef := "refs/tags/" + ref.Ref + if ref.Ref == commit { + resolvedRef = commit + } + return actionsource.Resolved{Reference: ref, Commit: commit, ResolvedRef: resolvedRef, SourceDigest: digest}, actionsource.Materialized{ RepositoryRoot: root, ActionRoot: filepath.Join(root, filepath.FromSlash(ref.Path)), SourceDigest: digest, }, nil } @@ -74,6 +78,49 @@ func (s *fakeReusableRepositorySource) references() []actionsource.Reference { return append([]actionsource.Reference(nil), s.calls...) } +func TestRemoteWorkflowCheckoutRefMatchesResolvedNamespace(t *testing.T) { + remote := RemoteWorkflowSource{RequestedRef: "v1", ResolvedRef: "refs/tags/v1", Commit: strings.Repeat("a", 40)} + for _, ref := range []string{"refs/tags/v1", remote.Commit} { + if !remoteWorkflowCheckoutRefMatches(ref, remote) { + t.Errorf("remoteWorkflowCheckoutRefMatches(%q) = false", ref) + } + } + for _, ref := range []string{"v1", "refs/heads/v1"} { + if remoteWorkflowCheckoutRefMatches(ref, remote) { + t.Errorf("remoteWorkflowCheckoutRefMatches(%q) accepted ambiguous or different Git namespace", ref) + } + } + branch := RemoteWorkflowSource{RequestedRef: "main", ResolvedRef: "refs/heads/main", Commit: remote.Commit} + if !remoteWorkflowCheckoutRefMatches("main", branch) { + t.Error("remoteWorkflowCheckoutRefMatches() rejected a bare resolved branch") + } +} + +func TestBindRemoteWorkflowCheckoutInputsRequiresProvenanceForBoundAlias(t *testing.T) { + remote := &RemoteWorkflowSource{ + Repository: "owner/workflows", RequestedRef: "v1", ResolvedRef: "refs/tags/v1", Commit: strings.Repeat("a", 40), + } + locks := []plan.ActionLock{ + {WorkspaceAlias: "checked-out", Path: ".github/actions/local"}, + {WorkspaceAlias: "source;dir", Path: ".github/actions/other"}, + {WorkspaceAlias: "source-ff-dir", Path: ".github/actions/folded"}, + } + for _, inputs := range []map[string]string{ + {"ref": remote.Commit, "path": "checked-out"}, + {"repository": "owner/caller", "ref": strings.Repeat("b", 40), "path": "checked-out"}, + {"repository": "owner/caller", "ref": strings.Repeat("b", 40), "path": "CHECKED-OUT"}, + {"repository": "owner/caller", "ref": strings.Repeat("b", 40), "path": "source\u037edir"}, + {"repository": "owner/caller", "ref": strings.Repeat("b", 40), "path": "source-\ufb00-dir"}, + } { + if _, bound, err := bindRemoteWorkflowCheckoutInputs(remote, locks, inputs); !bound || err == nil || !strings.Contains(err.Error(), "repository does not match immutable workflow provenance") { + t.Fatalf("bindRemoteWorkflowCheckoutInputs(%#v) = bound %t, error %v", inputs, bound, err) + } + } + if _, bound, err := bindRemoteWorkflowCheckoutInputs(remote, locks, nil); bound || err != nil { + t.Fatalf("ordinary default checkout = bound %t, error %v", bound, err) + } +} + func TestCompilePublicReusableWorkflowWithNestedPinnedLocalCall(t *testing.T) { callerRoot := t.TempDir() callerPath := writeWorkflow(t, callerRoot, "caller.yml", "on: push\njobs:\n delegated:\n uses: Octo/Workflows/.github/workflows/ci.yml@v1\n") @@ -207,6 +254,170 @@ jobs: } } +func TestCompileSLSARemoteWorkflowLocksSourceCheckedLocalActions(t *testing.T) { + callerRoot := t.TempDir() + callerPath := writeWorkflow(t, callerRoot, "caller.yml", `on: push +jobs: + hash: + runs-on: ubuntu-latest + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + steps: + - id: hash + run: echo hashes=c2hhMjU2ICBzdWJqZWN0Cg== >> "$GITHUB_OUTPUT" + call-remote: + needs: hash + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 + with: + base64-subjects: ${{ needs.hash.outputs.hashes }} +`) + remoteRoot := t.TempDir() + writeWorkflow(t, remoteRoot, "generator_generic_slsa3.yml", `on: + workflow_call: + inputs: + base64-subjects: + required: false + type: string +jobs: + detect-env: + runs-on: ubuntu-latest + outputs: + repository: ${{ steps.detect.outputs.repository }} + ref: ${{ steps.detect.outputs.ref }} + steps: + - id: detect + run: | + echo repository=slsa-framework/slsa-github-generator >> "$GITHUB_OUTPUT" + echo ref=refs/tags/v2.1.0 >> "$GITHUB_OUTPUT" + generator: + needs: detect-env + runs-on: ubuntu-latest + steps: + - name: Check out builder directly + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: slsa-framework/slsa-github-generator + ref: refs/tags/v2.1.0 + path: __DIRECT_BUILDER_CHECKOUT__ + persist-credentials: false + fetch-depth: 1 + - name: Run directly checked out local action + uses: ./__DIRECT_BUILDER_CHECKOUT__/.github/actions/direct-check + - name: Generate builder + uses: slsa-framework/slsa-github-generator/.github/actions/generate-builder@v2.1.0 + with: + repository: ${{ needs.detect-env.outputs.repository }} + ref: ${{ needs.detect-env.outputs.ref }} + - env: + UNTRUSTED_SUBJECTS: ${{ inputs.base64-subjects }} + run: test -n "$UNTRUSTED_SUBJECTS" +`) + writeAction(t, remoteRoot, ".github/actions/generate-builder", `name: Generate builder +inputs: + repository: + required: true + ref: + required: true + token: + default: ${{ github.token }} +runs: + using: composite + steps: + - uses: slsa-framework/slsa-github-generator/.github/actions/secure-builder-checkout@v2.1.0 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.ref }} + path: __BUILDER_CHECKOUT_DIR__ + - uses: ./__BUILDER_CHECKOUT_DIR__/.github/actions/privacy-check + with: + token: ${{ inputs.token }} + - uses: ./__BUILDER_CHECKOUT_DIR__/.github/actions/compute-sha256 +`) + writeAction(t, remoteRoot, ".github/actions/secure-builder-checkout", `name: Secure builder checkout +inputs: + repository: + required: true + ref: + required: true + path: + required: true + token: + default: ${{ github.token }} +runs: + using: composite + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.ref }} + token: ${{ inputs.token }} + path: ${{ inputs.path }} + persist-credentials: false + fetch-depth: 1 +`) + writeAction(t, remoteRoot, ".github/actions/privacy-check", "name: Privacy check\ninputs:\n token: {}\nruns:\n using: node20\n main: index.js\n") + writeAction(t, remoteRoot, ".github/actions/compute-sha256", "name: Compute SHA256\nruns:\n using: node20\n main: index.js\n") + writeAction(t, remoteRoot, ".github/actions/direct-check", "name: Direct check\nruns:\n using: node20\n main: index.js\n") + for _, actionPath := range []string{"privacy-check", "compute-sha256", "direct-check"} { + if err := os.WriteFile(filepath.Join(remoteRoot, ".github", "actions", actionPath, "index.js"), []byte("console.log('ok')\n"), 0o600); err != nil { + t.Fatal(err) + } + } + checkoutRoot := t.TempDir() + writeAction(t, checkoutRoot, "", "name: Checkout\nruns:\n using: node20\n main: index.js\n") + fake := newFakeReusableRepositorySource(t, map[string]string{ + "slsa-framework/slsa-github-generator": remoteRoot, + "actions/checkout": checkoutRoot, + }) + fake.commits["actions/checkout"] = "11bd71901bbe5b1630ceea73d27597364c9af683" + shared := MemoizeRepositorySource(fake) + options := defaultOptions() + options.RepositorySource = shared + options.ResolveActions = true + options.ActionSource = shared + + plans, err := compilePlansForTest(t.Context(), callerPath, readFile(t, callerPath), pushEvent(t), "0.0.0-test", testDistributionDigest, options) + if err != nil { + t.Fatal(err) + } + if len(plans) != 3 { + t.Fatalf("plans = %d, want producer, detector, and generator", len(plans)) + } + var generator plan.Job + for _, job := range plans { + if job.Workflow.LogicalJobID == "call-remote.generator" { + generator = job + } + } + if generator.Workflow.Remote == nil || generator.Workflow.Remote.Repository != "slsa-framework/slsa-github-generator" { + t.Fatalf("generator remote provenance = %#v", generator.Workflow.Remote) + } + deferred, ok := generator.DeferredInputs["base64-subjects"] + if !ok || len(deferred.Sources) != 1 || len(deferred.Outputs) != 1 || deferred.Outputs[0].Output != "hashes" { + t.Fatalf("generator deferred input = %#v", generator.DeferredInputs) + } + aliases := map[string]string{} + for _, lock := range generator.Actions { + if lock.WorkspaceAlias == "" { + continue + } + if lock.Source != "github" || lock.Repository != generator.Workflow.Remote.Repository || lock.RequestedRef != generator.Workflow.Remote.RequestedRef || lock.Commit != generator.Workflow.Remote.Commit || lock.SourceDigest != generator.Workflow.Remote.SourceDigest { + t.Fatalf("source-backed local action lost provenance: %#v", lock) + } + aliases[lock.Path] = lock.WorkspaceAlias + } + wantAlias := "__BUILDER_CHECKOUT_DIR__" + if aliases[".github/actions/privacy-check"] != wantAlias || aliases[".github/actions/compute-sha256"] != wantAlias { + t.Fatalf("source-backed local action aliases = %#v", aliases) + } + if aliases[".github/actions/direct-check"] != "__DIRECT_BUILDER_CHECKOUT__" { + t.Fatalf("direct source-backed local action aliases = %#v", aliases) + } + if _, err := os.Lstat(filepath.Join(callerRoot, wantAlias)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("caller workspace unexpectedly contains builder checkout: %v", err) + } +} + func TestCompilePinsRemoteWorkflowAndActionToOneCommit(t *testing.T) { callerRoot := t.TempDir() callerPath := writeWorkflow(t, callerRoot, "caller.yml", "on: push\njobs:\n delegated:\n uses: owner/repository/.github/workflows/ci.yml@v1\n") diff --git a/internal/compiler/reusable_source.go b/internal/compiler/reusable_source.go index 5a85da92..06763b21 100644 --- a/internal/compiler/reusable_source.go +++ b/internal/compiler/reusable_source.go @@ -26,6 +26,7 @@ const ( type RemoteWorkflowSource struct { Repository string `json:"repository"` RequestedRef string `json:"requested_ref"` + ResolvedRef string `json:"resolved_ref"` Commit string `json:"commit"` SourceDigest string `json:"source_digest"` } @@ -162,6 +163,9 @@ func (resolver *reusableResolver) loadRemoteReusableWorkflow(ctx context.Context if len(commit) != 40 || strings.Trim(commit, "0123456789abcdef") != "" { return reusableWorkflowSource{}, nil, fmt.Errorf("resolve public reusable workflow %q: source returned a non-immutable commit", uses) } + if resolved.ResolvedRef != commit && resolved.ResolvedRef != "refs/tags/"+ref.Ref && resolved.ResolvedRef != "refs/heads/"+ref.Ref { + return reusableWorkflowSource{}, nil, fmt.Errorf("resolve public reusable workflow %q: source returned invalid resolved ref provenance", uses) + } if len(materialized.SourceDigest) != 71 || !strings.HasPrefix(materialized.SourceDigest, "sha256:") || strings.Trim(materialized.SourceDigest[7:], "0123456789abcdef") != "" { return reusableWorkflowSource{}, nil, fmt.Errorf("resolve public reusable workflow %q: source returned an invalid repository digest", uses) } @@ -186,7 +190,7 @@ func (resolver *reusableResolver) loadRemoteReusableWorkflow(ctx context.Context } repository := strings.ToLower(ref.Owner + "/" + ref.Repository) remote := &RemoteWorkflowSource{ - Repository: repository, RequestedRef: ref.Ref, Commit: commit, SourceDigest: materialized.SourceDigest, + Repository: repository, RequestedRef: ref.Ref, ResolvedRef: resolved.ResolvedRef, Commit: commit, SourceDigest: materialized.SourceDigest, } return reusableWorkflowSource{ identity: reusableSourceIdentity{kind: "github", repository: repository, commit: commit, path: workflowPath}, diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 7b91c335..a13a2609 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -15,6 +15,8 @@ import ( "github.com/buildkite/buildkite-gha/internal/action/metadata" "github.com/buildkite/buildkite-gha/internal/action/source" "github.com/buildkite/buildkite-gha/internal/expression" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" ) const Schema = "https://buildkite.com/schemas/buildkite-gha/job-plan.schema.json" @@ -37,6 +39,7 @@ var containerPortPattern = regexp.MustCompile(`^(?:[1-9][0-9]{0,3}|[1-5][0-9]{4} var serviceNamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,254}$`) var githubRepositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) var githubWorkflowFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.ya?ml$`) +var sourceWorkspaceAliasCaseFold = cases.Fold() // ValidContainerImageReference reports whether image is a supported literal // Docker image reference. @@ -83,14 +86,15 @@ type ActionSelector struct { } type ActionLock struct { - ID string `json:"id"` - Source string `json:"source"` - Repository string `json:"repository,omitempty"` - RequestedRef string `json:"requested_ref,omitempty"` - Commit string `json:"commit,omitempty"` - Path string `json:"path,omitempty"` - SourceDigest string `json:"source_digest"` - Children map[string]ActionSelector `json:"children,omitempty"` + ID string `json:"id"` + Source string `json:"source"` + Repository string `json:"repository,omitempty"` + RequestedRef string `json:"requested_ref,omitempty"` + Commit string `json:"commit,omitempty"` + Path string `json:"path,omitempty"` + WorkspaceAlias string `json:"workspace_alias,omitempty"` + SourceDigest string `json:"source_digest"` + Children map[string]ActionSelector `json:"children,omitempty"` } type Compiler struct { @@ -168,6 +172,7 @@ type Workflow struct { type RemoteWorkflowSource struct { Repository string `json:"repository"` RequestedRef string `json:"requested_ref"` + ResolvedRef string `json:"resolved_ref"` Commit string `json:"commit"` SourceDigest string `json:"source_digest"` } @@ -853,7 +858,7 @@ func validateRemoteWorkflowSource(workflow Workflow) error { return nil } remote := workflow.Remote - if remote.Repository == "" || remote.Repository != strings.ToLower(remote.Repository) || len(remote.Repository) > 140 || remote.RequestedRef == "" || len(remote.RequestedRef) > 1024 || !utf8.ValidString(remote.RequestedRef) || hasControl(remote.RequestedRef) || !commitPattern.MatchString(remote.Commit) || !digestPattern.MatchString(remote.SourceDigest) { + if remote.Repository == "" || remote.Repository != strings.ToLower(remote.Repository) || len(remote.Repository) > 140 || remote.RequestedRef == "" || len(remote.RequestedRef) > 1024 || !utf8.ValidString(remote.RequestedRef) || hasControl(remote.RequestedRef) || !commitPattern.MatchString(remote.Commit) || remote.ResolvedRef != remote.Commit && remote.ResolvedRef != "refs/tags/"+remote.RequestedRef && remote.ResolvedRef != "refs/heads/"+remote.RequestedRef || !digestPattern.MatchString(remote.SourceDigest) { return fmt.Errorf("job plan remote workflow has invalid immutable source provenance") } ref, err := source.Parse(workflow.Path) @@ -1211,6 +1216,12 @@ func validateActionLocks(job Job) error { if err := validateLockIdentity(lock); err != nil { return fmt.Errorf("action lock %q: %w", lock.ID, err) } + if lock.WorkspaceAlias != "" { + remote := job.Workflow.Remote + if remote == nil || lock.Repository != remote.Repository || lock.RequestedRef != remote.RequestedRef || lock.Commit != remote.Commit || lock.SourceDigest != remote.SourceDigest { + return fmt.Errorf("action lock %q workspace alias does not match remote workflow provenance", lock.ID) + } + } for uses, child := range lock.Children { if len(uses) == 0 || len(uses) > 2048 || !utf8.ValidString(uses) || hasControl(uses) || !actionLockIDPattern.MatchString(child.Lock) { return fmt.Errorf("action lock %q has invalid child selector", lock.ID) @@ -1296,11 +1307,11 @@ func validateActionLocks(job Job) error { func validateLockIdentity(lock ActionLock) error { switch lock.Source { case "workspace": - if lock.Repository != "" || lock.RequestedRef != "" || lock.Commit != "" || lock.Path != "" && !cleanActionPath(lock.Path) { + if lock.Repository != "" || lock.RequestedRef != "" || lock.Commit != "" || lock.WorkspaceAlias != "" || lock.Path != "" && !cleanActionPath(lock.Path) { return fmt.Errorf("invalid workspace identity") } case "github": - if lock.Repository == "" || len(lock.Repository) > 140 || lock.Repository != strings.ToLower(lock.Repository) || lock.RequestedRef == "" || len(lock.RequestedRef) > 1024 || !utf8.ValidString(lock.RequestedRef) || hasControl(lock.RequestedRef) || !commitPattern.MatchString(lock.Commit) || lock.Path != "" && !cleanActionPath(lock.Path) { + if lock.Repository == "" || len(lock.Repository) > 140 || lock.Repository != strings.ToLower(lock.Repository) || lock.RequestedRef == "" || len(lock.RequestedRef) > 1024 || !utf8.ValidString(lock.RequestedRef) || hasControl(lock.RequestedRef) || !commitPattern.MatchString(lock.Commit) || lock.Path != "" && !cleanActionPath(lock.Path) || lock.WorkspaceAlias != "" && (lock.Path == "" || !ValidSourceWorkspaceAlias(lock.WorkspaceAlias)) { return fmt.Errorf("invalid GitHub identity") } r, err := source.Parse(lock.Repository + "@x") @@ -1316,7 +1327,16 @@ func validateLockIdentity(lock ActionLock) error { func validateTopLevelIdentity(uses string, lock ActionLock) error { if strings.HasPrefix(uses, "./") { path := strings.TrimPrefix(uses, "./") - if lock.Source != "workspace" || path != "" && !cleanActionPath(path) || lock.Path != path { + if path != "" && !cleanActionPath(path) { + return fmt.Errorf("local action reference does not match lock identity") + } + if lock.WorkspaceAlias != "" { + if lock.Source != "github" || path != lock.WorkspaceAlias+"/"+lock.Path { + return fmt.Errorf("local action reference does not match source-backed workspace identity") + } + return nil + } + if lock.Source != "workspace" || lock.Path != path { return fmt.Errorf("local action reference does not match lock identity") } return nil @@ -1331,7 +1351,16 @@ func validateTopLevelIdentity(uses string, lock ActionLock) error { func validateChildIdentity(parent ActionLock, uses string, child ActionLock) error { if strings.HasPrefix(uses, "./") { path := strings.TrimPrefix(uses, "./") - if path != "" && !cleanActionPath(path) || child.Source != "workspace" || child.Path != path { + if path != "" && !cleanActionPath(path) { + return fmt.Errorf("local child does not match workspace action identity") + } + if child.WorkspaceAlias != "" { + if parent.Source != "github" || child.Source != "github" || parent.Repository != child.Repository || parent.Commit != child.Commit || parent.SourceDigest != child.SourceDigest || path != child.WorkspaceAlias+"/"+child.Path { + return fmt.Errorf("local child does not match source-backed workspace identity") + } + return nil + } + if child.Source != "workspace" || child.Path != path { return fmt.Errorf("local child does not match workspace action identity") } return nil @@ -1355,6 +1384,33 @@ func cleanActionPath(value string) bool { return true } +// ValidSourceWorkspaceAlias reports whether an alias has one portable, +// filesystem-stable representation across supported Linux and macOS workers. +func ValidSourceWorkspaceAlias(value string) bool { + if value == "" || len(value) > 255 || value == "." || value == ".." || strings.EqualFold(value, ".git") { + return false + } + for i := range len(value) { + if value[i] < 0x20 || value[i] > 0x7e || value[i] == '/' || value[i] == '\\' { + return false + } + } + return true +} + +// EqualSourceWorkspaceAlias compares an immutable portable alias with a +// checkout spelling using the canonical normalization and full case folding that +// can make distinct strings name the same directory on macOS. +func EqualSourceWorkspaceAlias(alias, value string) bool { + if alias == "" || len(value) > 255 { + return false + } + canonical := func(value string) string { + return norm.NFD.String(sourceWorkspaceAliasCaseFold.String(norm.NFD.String(value))) + } + return canonical(alias) == canonical(value) +} + func hasControl(value string) bool { for _, r := range value { if r < 0x20 || r == 0x7f { diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 5ef82520..48c64679 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -32,11 +32,30 @@ func TestDecodePreservesPlanContract(t *testing.T) { validateJobPlanSchema(t, source) } +func TestEqualSourceWorkspaceAlias(t *testing.T) { + for _, test := range []struct { + alias string + value string + want bool + }{ + {alias: "source-dir", value: "SOURCE-DIR", want: true}, + {alias: "sourceKdir", value: "source\u212adir", want: true}, + {alias: "source;dir", value: "source\u037edir", want: true}, + {alias: "source-ff-dir", value: "source-\ufb00-dir", want: true}, + {alias: "", value: "", want: false}, + {alias: "source-dir", value: "other-dir", want: false}, + } { + if got := EqualSourceWorkspaceAlias(test.alias, test.value); got != test.want { + t.Errorf("EqualSourceWorkspaceAlias(%q, %q) = %t, want %t", test.alias, test.value, got, test.want) + } + } +} + func TestRemoteWorkflowSourceRoundTripAndValidation(t *testing.T) { job := validJob() job.Workflow.Path = "owner/repository/.github/workflows/ci.yml@v1" job.Workflow.Remote = &RemoteWorkflowSource{ - Repository: "owner/repository", RequestedRef: "v1", Commit: strings.Repeat("a", 40), SourceDigest: "sha256:" + strings.Repeat("b", 64), + Repository: "owner/repository", RequestedRef: "v1", ResolvedRef: "refs/tags/v1", Commit: strings.Repeat("a", 40), SourceDigest: "sha256:" + strings.Repeat("b", 64), } encoded, err := Encode(job) if err != nil { @@ -57,6 +76,8 @@ func TestRemoteWorkflowSourceRoundTripAndValidation(t *testing.T) { {name: "path repository", edit: func(job *Job) { job.Workflow.Path = "other/repository/.github/workflows/ci.yml@v1" }, want: "path does not match"}, {name: "path ref", edit: func(job *Job) { job.Workflow.Path = "owner/repository/.github/workflows/ci.yml@v2" }, want: "path does not match"}, {name: "nested path", edit: func(job *Job) { job.Workflow.Path = "owner/repository/.github/workflows/nested/ci.yml@v1" }, want: "path does not match"}, + {name: "resolved ref name", edit: func(job *Job) { job.Workflow.Remote.ResolvedRef = "refs/heads/v2" }, want: "invalid immutable source provenance"}, + {name: "invalid resolved ref", edit: func(job *Job) { job.Workflow.Remote.ResolvedRef = "refs/pull/1/head" }, want: "invalid immutable source provenance"}, {name: "commit", edit: func(job *Job) { job.Workflow.Remote.Commit = strings.Repeat("A", 40) }, want: "invalid immutable source provenance"}, {name: "tree digest", edit: func(job *Job) { job.Workflow.Remote.SourceDigest = "sha256:invalid" }, want: "invalid immutable source provenance"}, } @@ -73,6 +94,59 @@ func TestRemoteWorkflowSourceRoundTripAndValidation(t *testing.T) { } } +func TestSourceBackedWorkspaceActionRequiresRemoteWorkflowProvenance(t *testing.T) { + job := validJob() + commit := strings.Repeat("a", 40) + digest := "sha256:" + strings.Repeat("b", 64) + job.Workflow.Path = "owner/repository/.github/workflows/ci.yml@v1" + job.Workflow.Remote = &RemoteWorkflowSource{Repository: "owner/repository", RequestedRef: "v1", ResolvedRef: "refs/tags/v1", Commit: commit, SourceDigest: digest} + job.Steps = []Step{{ID: "remote", Kind: "uses", Uses: "owner/repository/root@v1", Action: &ActionSelector{Lock: "a-0000000000000001"}}} + job.Actions = []ActionLock{ + { + ID: "a-0000000000000001", Source: "github", Repository: "owner/repository", RequestedRef: "v1", Commit: commit, Path: "root", SourceDigest: digest, + Children: map[string]ActionSelector{"./checked-out/.github/actions/privacy": {Lock: "a-0000000000000002"}}, + }, + { + ID: "a-0000000000000002", Source: "github", Repository: "owner/repository", RequestedRef: "v1", Commit: commit, + Path: ".github/actions/privacy", WorkspaceAlias: "checked-out", SourceDigest: digest, + }, + } + encoded, err := Encode(job) + if err != nil { + t.Fatal(err) + } + validateJobPlanSchema(t, encoded) + + tests := []struct { + name string + edit func(*Job) + want string + }{ + {name: "missing remote workflow", edit: func(j *Job) { j.Workflow.Remote = nil }, want: "does not match remote workflow provenance"}, + {name: "different commit", edit: func(j *Job) { j.Actions[1].Commit = strings.Repeat("c", 40) }, want: "does not match remote workflow provenance"}, + {name: "workspace source", edit: func(j *Job) { j.Actions[1].Source = "workspace" }, want: "invalid workspace identity"}, + {name: "different alias", edit: func(j *Job) { j.Actions[1].WorkspaceAlias = "other" }, want: "does not match source-backed workspace identity"}, + {name: "nonportable alias", edit: func(j *Job) { j.Actions[1].WorkspaceAlias = "checkéd-out" }, want: "invalid GitHub identity"}, + {name: "different parent source", edit: func(j *Job) { j.Actions[0].SourceDigest = "sha256:" + strings.Repeat("d", 64) }, want: "does not match source-backed workspace identity"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invalid := job + invalid.Actions = append([]ActionLock(nil), job.Actions...) + test.edit(&invalid) + if err := invalid.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + }) + } + topLevel := job + topLevel.Steps = []Step{{ID: "local", Kind: "uses", Uses: "./checked-out/.github/actions/privacy", Action: &ActionSelector{Lock: "a-0000000000000002"}}} + topLevel.Actions = []ActionLock{job.Actions[1]} + if err := topLevel.Validate(); err != nil { + t.Fatalf("Validate() top-level source-backed local action error = %v", err) + } +} + func TestCallGuardPlanAndSchemaRoundTrip(t *testing.T) { job := validJob() digest := "sha256:" + strings.Repeat("1", 64) diff --git a/internal/runtime/action_execution.go b/internal/runtime/action_execution.go index 731a74a6..9eba1f19 100644 --- a/internal/runtime/action_execution.go +++ b/internal/runtime/action_execution.go @@ -1622,6 +1622,11 @@ func (r *jobRun) prepareRemoteAction(ctx context.Context, processor *commandProc if err != nil { return result, err } + if lock.WorkspaceAlias != "" { + // Source-backed local actions stay lazy until an earlier composite step + // has populated their caller-workspace path. + return result, nil + } // The native adapters replace the verified action's lifecycle as one // indivisible operation, so upstream metadata never classifies and no // upstream cleanup is registered for phases this runtime never executes. @@ -1866,6 +1871,11 @@ func (r *jobRun) runActionStep(ctx context.Context, processor *commandProcessor, return result, err } action, actionLock = resolvedAction, &lock + if lock.WorkspaceAlias != "" { + if err := verifySourceBackedWorkspaceAction(workspace, lock, action.Path); err != nil { + return result, err + } + } if usesCheckoutAdapter(lock) { inputs := evaluatedWith if inputs == nil { @@ -2068,6 +2078,30 @@ func (r *jobRun) runActionStep(ctx context.Context, processor *commandProcessor, return result, fmt.Errorf("action %q uses unsupported runtime %q", step.Uses, actionRuntime) } +func verifySourceBackedWorkspaceAction(workspace string, lock plan.ActionLock, sourcePath string) error { + localPath := lock.WorkspaceAlias + "/" + lock.Path + resolved, err := workspacePath(workspace, localPath) + if err != nil { + return fmt.Errorf("source-backed local action %q: %w", localPath, err) + } + info, err := os.Lstat(resolved) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("source-backed local action %q is unavailable in the workspace", localPath) + } + want, err := actionsource.DigestTree(sourcePath) + if err != nil { + return fmt.Errorf("digest immutable source-backed local action %q: %w", localPath, err) + } + got, err := actionsource.DigestTree(resolved) + if err != nil { + return fmt.Errorf("digest workspace source-backed local action %q: %w", localPath, err) + } + if got != want { + return fmt.Errorf("source-backed local action %q digest mismatch: immutable source has %s, workspace has %s", localPath, want, got) + } + return nil +} + func (r *jobRun) runCompositeMetadata(ctx context.Context, processor *commandProcessor, workspace string, job plan.Job, actionPath string, action metadata.Metadata, inputs map[string]string, invocationID string, jobEnv, stepEnv, lifecycleEnvOverlay map[string]string, eval expression.Context, posts *postRegistry, actions *actionLockResolver, prepared remotePreparations, actionLock *plan.ActionLock, actionStack []string) (Result, error) { result := newResult() // Keep hashFiles unavailable to composite step metadata while retaining the diff --git a/internal/runtime/actions_test.go b/internal/runtime/actions_test.go index 7051d439..e00f959a 100644 --- a/internal/runtime/actions_test.go +++ b/internal/runtime/actions_test.go @@ -405,3 +405,113 @@ runs: t.Fatalf("materializer calls/resolved = %d / %#v", materializer.calls, materializer.resolved) } } + +func TestRunJobRemoteCompositeUsesSourceBackedWorkspaceActionAfterPopulation(t *testing.T) { + workspace := t.TempDir() + remote := t.TempDir() + generateBuilder := filepath.Join(remote, ".github", "actions", "generate-builder") + privacyCheck := filepath.Join(remote, ".github", "actions", "privacy-check") + if err := os.MkdirAll(generateBuilder, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(privacyCheck, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(generateBuilder, "action.yml"), []byte(`name: Generate builder +runs: + using: composite + steps: + - shell: sh + run: | + mkdir -p "$GITHUB_WORKSPACE/__BUILDER_CHECKOUT_DIR__/.github/actions/privacy-check" + cp -R "$GITHUB_ACTION_PATH/../privacy-check/." "$GITHUB_WORKSPACE/__BUILDER_CHECKOUT_DIR__/.github/actions/privacy-check/" + - uses: ./__BUILDER_CHECKOUT_DIR__/.github/actions/privacy-check +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(privacyCheck, "action.yml"), []byte(`name: Privacy check +runs: + using: composite + steps: + - shell: sh + run: | + echo SOURCE_BACKED_LOCAL_ACTION=seen >> "$GITHUB_ENV" + echo SOURCE_BACKED_ACTION_PATH="$GITHUB_ACTION_PATH" >> "$GITHUB_ENV" +`), 0o644); err != nil { + t.Fatal(err) + } + remoteDigest := digestTree(t, remote) + commit := strings.Repeat("a", 40) + workflowDigest := "sha256:" + strings.Repeat("b", 64) + rootID, childID := "a-0000000000000001", "a-0000000000000002" + requiresMise := false + job := plan.Job{ + Schema: plan.Schema, + Compiler: plan.Compiler{ + Version: "0.0.0-test", DistributionDigest: "sha256:" + strings.Repeat("2", 64), + }, + Runtime: &plan.Runtime{DistributionDigest: "sha256:" + strings.Repeat("2", 64)}, + Workflow: plan.Workflow{ + Path: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0", Digest: workflowDigest, LogicalJobID: "call-remote.generator", + Remote: &plan.RemoteWorkflowSource{Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", ResolvedRef: "refs/tags/v2.1.0", Commit: commit, SourceDigest: remoteDigest}, + }, + Event: plan.Event{ + Provider: "github", Name: "push", PayloadDigest: "sha256:" + strings.Repeat("3", 64), Repository: "owner/project", SHA: strings.Repeat("c", 40), + }, + Target: plan.Target{StepKey: "gha-call-remote-generator", Queue: "trusted"}, + RequiredCapabilities: []string{"network"}, + Steps: []plan.Step{{ + ID: "generate-builder", Kind: "uses", Uses: "slsa-framework/slsa-github-generator/.github/actions/generate-builder@v2.1.0", Action: &plan.ActionSelector{Lock: rootID}, + }}, + Actions: []plan.ActionLock{ + { + ID: rootID, Source: "github", Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", Commit: commit, + Path: ".github/actions/generate-builder", SourceDigest: remoteDigest, + Children: map[string]plan.ActionSelector{"./__BUILDER_CHECKOUT_DIR__/.github/actions/privacy-check": {Lock: childID}}, + }, + { + ID: childID, Source: "github", Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", Commit: commit, + Path: ".github/actions/privacy-check", WorkspaceAlias: "__BUILDER_CHECKOUT_DIR__", SourceDigest: remoteDigest, + }, + }, + RequiresMise: &requiresMise, + } + materializer := &fakeActionMaterializer{result: source.Materialized{RepositoryRoot: remote, SourceDigest: remoteDigest}} + result, err := (Runner{Actions: materializer}).RunJob(t.Context(), job, workspace) + if err != nil { + t.Fatalf("RunJob() error = %v", err) + } + canonicalPrivacyCheck, err := filepath.EvalSymlinks(privacyCheck) + if err != nil { + t.Fatal(err) + } + if result.Conclusion != "success" || result.Env["SOURCE_BACKED_LOCAL_ACTION"] != "seen" || result.Env["SOURCE_BACKED_ACTION_PATH"] != canonicalPrivacyCheck { + t.Fatalf("RunJob() result = %#v", result) + } +} + +func TestSourceBackedWorkspaceActionRequiresPopulatedPath(t *testing.T) { + lock := plan.ActionLock{WorkspaceAlias: "checked-out", Path: ".github/actions/privacy-check"} + if err := verifySourceBackedWorkspaceAction(t.TempDir(), lock, t.TempDir()); err == nil || !strings.Contains(err.Error(), "is unavailable in the workspace") { + t.Fatalf("verifySourceBackedWorkspaceAction() error = %v", err) + } +} + +func TestSourceBackedWorkspaceActionRejectsTamperedPath(t *testing.T) { + workspace := t.TempDir() + sourcePath := t.TempDir() + localPath := filepath.Join(workspace, "checked-out", ".github", "actions", "privacy-check") + if err := os.MkdirAll(localPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourcePath, "action.yml"), []byte("name: immutable\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localPath, "action.yml"), []byte("name: tampered\n"), 0o644); err != nil { + t.Fatal(err) + } + lock := plan.ActionLock{WorkspaceAlias: "checked-out", Path: ".github/actions/privacy-check"} + if err := verifySourceBackedWorkspaceAction(workspace, lock, sourcePath); err == nil || !strings.Contains(err.Error(), "digest mismatch") { + t.Fatalf("verifySourceBackedWorkspaceAction() error = %v", err) + } +} diff --git a/internal/runtime/checkout.go b/internal/runtime/checkout.go index 5a7c99eb..2f56f9c0 100644 --- a/internal/runtime/checkout.go +++ b/internal/runtime/checkout.go @@ -70,16 +70,29 @@ func validCheckoutRepository(repository string) bool { } func (r Runner) runCheckout(ctx context.Context, processor *commandProcessor, workspace string, job plan.Job, commit string, inputs map[string]string) (Result, error) { - result := newResult() const adapter = "checkout adapter" - credentialed := job.HasCapability("provider-token-read") && r.RepositoryCredentials != nil - url, credentialHost, validProvider := checkoutRepositoryURL(job.Event.Provider, job.Event.Repository) + remoteInputs, remoteRefOutput, remotePinnedRef, remote, err := remoteWorkflowCheckoutInputs(job, commit, inputs) + if err != nil { + return newResult(), fmt.Errorf("%s: %w", adapter, err) + } + if remote { + return r.runCheckoutTarget(ctx, processor, workspace, commit, remoteInputs, "github", job.Workflow.Remote.Repository, job.Workflow.Remote.Commit, remoteRefOutput, remotePinnedRef, false) + } + _, _, validProvider := checkoutRepositoryURL(job.Event.Provider, job.Event.Repository) if !validProvider || !actionintegration.ValidCheckoutSHA(job.Event.SHA) { - return result, fmt.Errorf("%s requires a valid GitHub or Origin event repository and exact SHA; other event sources are unsupported", adapter) + return newResult(), fmt.Errorf("%s requires a valid GitHub or Origin event repository and exact SHA; other event sources are unsupported", adapter) } if err := actionintegration.ValidateCheckoutInputs(commit, inputs, job.Event.Repository, job.Event.SHA); err != nil { - return result, fmt.Errorf("%s: %w", adapter, err) + return newResult(), fmt.Errorf("%s: %w", adapter, err) } + credentialed := job.HasCapability("provider-token-read") && r.RepositoryCredentials != nil + return r.runCheckoutTarget(ctx, processor, workspace, commit, inputs, job.Event.Provider, job.Event.Repository, job.Event.SHA, checkoutRefOutput(inputs, job.Event.Ref), "", credentialed) +} + +func (r Runner) runCheckoutTarget(ctx context.Context, processor *commandProcessor, workspace string, commit string, inputs map[string]string, provider, repository, sha, refOutput, pinnedRef string, credentialed bool) (Result, error) { + result := newResult() + const adapter = "checkout adapter" + url, credentialHost, _ := checkoutRepositoryURL(provider, repository) inputs = checkoutInputsWithReleaseDefaults(commit, inputs) checkoutDirectory, err := prepareCheckoutDirectory(workspace, inputs) if err != nil { @@ -118,7 +131,7 @@ func (r Runner) runCheckout(ctx context.Context, processor *commandProcessor, wo if err := run(env, "remote", "add", "origin", url); err != nil { return result, err } - fetchArgs := checkoutFetchArgs(inputs, job.Event.SHA) + fetchArgs := checkoutFetchArgs(inputs, sha) if credentialed { if err := r.runRepositoryProviderCheckoutFetch(ctx, processor, checkoutDirectory, env, git, base, fetchArgs, credentialHost); err != nil { return result, fmt.Errorf("%s git fetch: %w", adapter, err) @@ -126,10 +139,15 @@ func (r Runner) runCheckout(ctx context.Context, processor *commandProcessor, wo } else if err := run(env, fetchArgs...); err != nil { return result, err } - checkoutTarget := checkoutRevision(inputs, job.Event.SHA) + checkoutTarget := checkoutRevision(inputs, sha) if err := run(env, "checkout", "--detach", checkoutTarget); err != nil { return result, err } + if pinnedRef != "" && pinnedRef != sha { + if err := run(env, "update-ref", pinnedRef, sha); err != nil { + return result, err + } + } mode := checkoutSubmoduleMode(inputs) if mode != "" { if err := r.runCheckoutSubmodules(ctx, processor, checkoutDirectory, git, env, base, checkoutFetchDepth(inputs) != "0", mode == "recursive", credentialed, credentialHost); err != nil { @@ -141,10 +159,95 @@ func (r Runner) runCheckout(ctx context.Context, processor *commandProcessor, wo if err != nil || !actionintegration.ValidCheckoutSHA(headSHA) || actionintegration.ValidCheckoutSHA(checkoutTarget) && headSHA != checkoutTarget { return result, fmt.Errorf("%s did not produce the requested detached revision", adapter) } - setCheckoutOutputs(result.Outputs, commit, checkoutRefOutput(inputs, job.Event.Ref), headSHA) + setCheckoutOutputs(result.Outputs, commit, refOutput, headSHA) return result, nil } +func remoteWorkflowCheckoutInputs(job plan.Job, commit string, inputs map[string]string) (map[string]string, string, string, bool, error) { + remote := job.Workflow.Remote + if remote != nil { + if err := actionintegration.ValidateCheckoutInputNames(inputs); err != nil { + return nil, "", "", true, err + } + } + repository := checkoutInput(inputs, "repository") + if remote == nil { + return nil, "", "", false, nil + } + ref := checkoutInput(inputs, "ref") + refMatches := remoteWorkflowRefMatches(ref, *remote) + path := checkoutInput(inputs, "path") + aliasMatches := remoteWorkflowCheckoutAlias(job.Actions, path) + if aliasMatches && !strings.EqualFold(repository, remote.Repository) { + return nil, "", "", true, fmt.Errorf("remote workflow source checkout repository does not match immutable workflow provenance") + } + if aliasMatches && !refMatches { + return nil, "", "", true, fmt.Errorf("remote workflow source checkout ref does not match immutable workflow provenance") + } + if repository == "" || !strings.EqualFold(repository, remote.Repository) || !aliasMatches && strings.EqualFold(repository, job.Event.Repository) { + return nil, "", "", false, nil + } + if !refMatches { + return nil, "", "", true, fmt.Errorf("remote workflow source checkout ref does not match immutable workflow provenance") + } + if !aliasMatches { + return nil, "", "", true, fmt.Errorf("remote workflow source checkout path does not match a source-backed local action") + } + normalized := maps.Clone(inputs) + deleteCheckoutInput(normalized, "token") + setCheckoutInput(normalized, "repository", remote.Repository) + setCheckoutInput(normalized, "ref", remote.Commit) + if err := actionintegration.ValidateCheckoutInputs(commit, normalized, remote.Repository, remote.Commit); err != nil { + return nil, "", "", true, err + } + pinnedRef := remote.ResolvedRef + if pinnedRef == remote.Commit { + pinnedRef = "" + } else if !validPinnedCheckoutRef(pinnedRef) { + return nil, "", "", true, fmt.Errorf("remote workflow source checkout ref is invalid") + } + return normalized, checkoutRefOutput(inputs, ""), pinnedRef, true, nil +} + +func remoteWorkflowRefMatches(ref string, remote plan.RemoteWorkflowSource) bool { + // Checkout gives a bare branch precedence over a same-named tag, while a + // reusable-workflow reference does the opposite. Bare names are safe only + // when workflow provenance selected that branch. + return ref == remote.Commit || ref == remote.ResolvedRef || + ref == remote.RequestedRef && !strings.HasPrefix(ref, "refs/") && remote.ResolvedRef == "refs/heads/"+remote.RequestedRef +} + +func remoteWorkflowCheckoutAlias(locks []plan.ActionLock, path string) bool { + for _, lock := range locks { + if plan.EqualSourceWorkspaceAlias(lock.WorkspaceAlias, path) { + return true + } + } + return false +} + +func validPinnedCheckoutRef(ref string) bool { + for _, prefix := range []string{"refs/heads/", "refs/tags/"} { + if strings.HasPrefix(ref, prefix) { + return actionintegration.ValidCheckoutBranch(strings.TrimPrefix(ref, prefix)) + } + } + return false +} + +func deleteCheckoutInput(inputs map[string]string, target string) { + for name := range inputs { + if strings.EqualFold(name, target) { + delete(inputs, name) + } + } +} + +func setCheckoutInput(inputs map[string]string, target, value string) { + deleteCheckoutInput(inputs, target) + inputs[target] = value +} + func setCheckoutOutputs(outputs map[string]string, commit, ref, headSHA string) { // Checkout outputs were added in v4.2.0 and aren't part of earlier contracts. if !actionintegration.CheckoutSupportsOutputs(commit) { diff --git a/internal/runtime/checkout_test.go b/internal/runtime/checkout_test.go index d98737a1..4b97cc02 100644 --- a/internal/runtime/checkout_test.go +++ b/internal/runtime/checkout_test.go @@ -203,6 +203,99 @@ func TestRepositoryProviderCheckoutCredentialArgsUseProviderHost(t *testing.T) { } } +func TestRemoteWorkflowCheckoutInputsBindImmutableSource(t *testing.T) { + commit := strings.Repeat("a", 40) + job := plan.Job{ + Workflow: plan.Workflow{Remote: &plan.RemoteWorkflowSource{ + Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", ResolvedRef: "refs/tags/v2.1.0", Commit: commit, + }}, + Event: plan.Event{Repository: "owner/caller"}, + Actions: []plan.ActionLock{ + { + Source: "github", Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", Commit: commit, + Path: ".github/actions/privacy-check", WorkspaceAlias: "__BUILDER_CHECKOUT_DIR__", + }, + { + Source: "github", Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", Commit: commit, + Path: ".github/actions/other", WorkspaceAlias: "source;dir", + }, + { + Source: "github", Repository: "slsa-framework/slsa-github-generator", RequestedRef: "v2.1.0", Commit: commit, + Path: ".github/actions/folded", WorkspaceAlias: "source-ff-dir", + }, + }, + } + inputs := map[string]string{ + "repository": "slsa-framework/slsa-github-generator", + "ref": "refs/tags/v2.1.0", + "path": "__BUILDER_CHECKOUT_DIR__", + "token": "discarded-secret", + "persist-credentials": "false", + "fetch-depth": "1", + } + normalized, refOutput, pinnedRef, remote, err := remoteWorkflowCheckoutInputs(job, actionintegration.CheckoutV4Commit, inputs) + if err != nil { + t.Fatal(err) + } + if !remote || refOutput != "refs/tags/v2.1.0" || pinnedRef != "refs/tags/v2.1.0" || checkoutInput(normalized, "repository") != job.Workflow.Remote.Repository || checkoutInput(normalized, "ref") != commit || checkoutInput(normalized, "path") != "__BUILDER_CHECKOUT_DIR__" { + t.Fatalf("remote checkout binding = %#v, %q, %q, %t", normalized, refOutput, pinnedRef, remote) + } + if checkoutInput(normalized, "token") != "" || inputs["token"] != "discarded-secret" { + t.Fatalf("remote checkout token handling mutated source or retained token: normalized=%#v source=%#v", normalized, inputs) + } + sameRepositoryJob := job + sameRepositoryJob.Event.Repository = job.Workflow.Remote.Repository + _, _, _, sameRepositoryRemote, err := remoteWorkflowCheckoutInputs(sameRepositoryJob, actionintegration.CheckoutV4Commit, inputs) + if err != nil || !sameRepositoryRemote { + t.Fatalf("same-repository remote checkout binding = %t, %v", sameRepositoryRemote, err) + } + sameRepositoryInputs := maps.Clone(inputs) + sameRepositoryInputs["ref"] = strings.Repeat("b", 40) + if _, _, _, bound, err := remoteWorkflowCheckoutInputs(sameRepositoryJob, actionintegration.CheckoutV4Commit, sameRepositoryInputs); !bound || err == nil || !strings.Contains(err.Error(), "ref does not match immutable workflow provenance") { + t.Fatalf("same-repository mismatched ref binding = %t, %v", bound, err) + } + branchJob := job + branchRemote := *job.Workflow.Remote + branchRemote.RequestedRef = "main" + branchRemote.ResolvedRef = "refs/heads/main" + branchJob.Workflow.Remote = &branchRemote + branchJob.Actions = append([]plan.ActionLock(nil), job.Actions...) + branchJob.Actions[0].RequestedRef = "main" + branchInputs := maps.Clone(inputs) + branchInputs["ref"] = "main" + _, branchOutput, branchPinnedRef, branch, err := remoteWorkflowCheckoutInputs(branchJob, actionintegration.CheckoutV4Commit, branchInputs) + if err != nil || !branch || branchOutput != "main" || branchPinnedRef != "refs/heads/main" { + t.Fatalf("branch remote checkout binding = %q, %q, %t, %v", branchOutput, branchPinnedRef, branch, err) + } + + for _, test := range []struct { + name string + inputs map[string]string + remote bool + want string + }{ + {name: "other repository remains unsupported", inputs: map[string]string{"repository": "other/repository"}}, + {name: "bound alias without repository", inputs: map[string]string{"ref": job.Workflow.Remote.Commit, "path": "__BUILDER_CHECKOUT_DIR__"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "bound alias from event repository", inputs: map[string]string{"repository": job.Event.Repository, "ref": strings.Repeat("b", 40), "path": "__BUILDER_CHECKOUT_DIR__"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "case-folded bound alias from event repository", inputs: map[string]string{"repository": job.Event.Repository, "ref": strings.Repeat("b", 40), "path": "__builder_checkout_dir__"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "unicode-folded bound alias from event repository", inputs: map[string]string{"repository": job.Event.Repository, "ref": strings.Repeat("b", 40), "path": "__BUILDER_CHEC\u212aOUT_DIR__"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "canonically normalized bound alias from event repository", inputs: map[string]string{"repository": job.Event.Repository, "ref": strings.Repeat("b", 40), "path": "source\u037edir"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "multi-rune folded bound alias from event repository", inputs: map[string]string{"repository": job.Event.Repository, "ref": strings.Repeat("b", 40), "path": "source-\ufb00-dir"}, remote: true, want: "repository does not match immutable workflow provenance"}, + {name: "different ref", inputs: map[string]string{"repository": job.Workflow.Remote.Repository, "ref": "refs/tags/v2.2.0", "path": "__BUILDER_CHECKOUT_DIR__"}, remote: true, want: "does not match immutable workflow provenance"}, + {name: "ambiguous bare tag", inputs: map[string]string{"repository": job.Workflow.Remote.Repository, "ref": "v2.1.0", "path": "__BUILDER_CHECKOUT_DIR__"}, remote: true, want: "does not match immutable workflow provenance"}, + {name: "different namespace", inputs: map[string]string{"repository": job.Workflow.Remote.Repository, "ref": "refs/heads/v2.1.0", "path": "__BUILDER_CHECKOUT_DIR__"}, remote: true, want: "does not match immutable workflow provenance"}, + {name: "unbound path", inputs: map[string]string{"repository": job.Workflow.Remote.Repository, "ref": "refs/tags/v2.1.0", "path": "other"}, remote: true, want: "does not match a source-backed local action"}, + {name: "duplicate input", inputs: map[string]string{"repository": job.Workflow.Remote.Repository, "Repository": job.Event.Repository}, remote: true, want: "duplicate case-insensitive input"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, _, gotRemote, err := remoteWorkflowCheckoutInputs(job, actionintegration.CheckoutV4Commit, test.inputs) + if gotRemote != test.remote || test.want == "" && err != nil || test.want != "" && (err == nil || !strings.Contains(err.Error(), test.want)) { + t.Fatalf("remoteWorkflowCheckoutInputs() remote/error = %t, %v, want %t / %q", gotRemote, err, test.remote, test.want) + } + }) + } +} + func TestOriginCheckoutUsesExactRemoteAndCredentialHost(t *testing.T) { workspace := t.TempDir() sha := strings.Repeat("a", 40) diff --git a/internal/runtime/workflow_test.go b/internal/runtime/workflow_test.go index 362912a3..dcfcbcb9 100644 --- a/internal/runtime/workflow_test.go +++ b/internal/runtime/workflow_test.go @@ -11,7 +11,7 @@ func TestVerifyWorkflowDoesNotReadRemoteCalleeFromCallerWorkspace(t *testing.T) job := plan.Job{Workflow: plan.Workflow{ Path: "owner/repository/.github/workflows/ci.yml@v1", Digest: "sha256:" + strings.Repeat("a", 64), Remote: &plan.RemoteWorkflowSource{ - Repository: "owner/repository", RequestedRef: "v1", Commit: strings.Repeat("b", 40), SourceDigest: "sha256:" + strings.Repeat("c", 64), + Repository: "owner/repository", RequestedRef: "v1", ResolvedRef: "refs/tags/v1", Commit: strings.Repeat("b", 40), SourceDigest: "sha256:" + strings.Repeat("c", 64), }, }} if err := verifyWorkflow(job, t.TempDir()); err != nil { diff --git a/schemas/job-plan.schema.json b/schemas/job-plan.schema.json index 1b097500..58942252 100644 --- a/schemas/job-plan.schema.json +++ b/schemas/job-plan.schema.json @@ -67,7 +67,7 @@ "compiler": {"type": "object", "additionalProperties": false, "required": ["version", "distribution_digest"], "properties": {"version": {"type": "string", "pattern": "^[ -~]{1,256}$"}, "distribution_digest": {"$ref": "#/$defs/digest"}}}, "runtime": {"type": "object", "additionalProperties": false, "required": ["distribution_digest"], "properties": {"distribution_digest": {"$ref": "#/$defs/digest"}}}, "workflow": {"type": "object", "additionalProperties": false, "required": ["path", "digest", "logical_job_id"], "properties": {"path": {"type": "string", "minLength": 1, "maxLength": 1024}, "name": {"type": "string", "maxLength": 1024}, "digest": {"$ref": "#/$defs/digest"}, "logical_job_id": {"type": "string", "minLength": 1, "maxLength": 255}, "remote": {"$ref": "#/$defs/remoteWorkflowSource"}}}, - "remoteWorkflowSource": {"type": "object", "additionalProperties": false, "required": ["repository", "requested_ref", "commit", "source_digest"], "properties": {"repository": {"$ref": "#/$defs/repository"}, "requested_ref": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^\\x00-\\x1f\\x7f]+$"}, "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "source_digest": {"$ref": "#/$defs/digest"}}}, + "remoteWorkflowSource": {"type": "object", "additionalProperties": false, "required": ["repository", "requested_ref", "resolved_ref", "commit", "source_digest"], "properties": {"repository": {"$ref": "#/$defs/repository"}, "requested_ref": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^\\x00-\\x1f\\x7f]+$"}, "resolved_ref": {"type": "string", "minLength": 1, "maxLength": 1035, "pattern": "^[^\\x00-\\x1f\\x7f]+$"}, "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "source_digest": {"$ref": "#/$defs/digest"}}}, "event": {"type": "object", "additionalProperties": false, "required": ["provider", "name", "payload_digest"], "properties": {"provider": {"enum": ["github", "cursor-origin"]}, "name": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+$", "maxLength": 128}, "payload_digest": {"$ref": "#/$defs/digest"}, "repository": {"type": "string", "maxLength": 512}, "ref": {"type": "string", "maxLength": 1024}, "head_ref": {"type": "string", "maxLength": 1024}, "base_ref": {"type": "string", "maxLength": 1024}, "sha": {"type": "string", "maxLength": 128}, "actor": {"type": "string", "maxLength": 256}}}, "target": {"type": "object", "additionalProperties": false, "required": ["step_key"], "properties": {"step_key": {"$ref": "#/$defs/buildkiteName"}, "queue": {"$ref": "#/$defs/buildkiteName"}}}, "githubEventRepository": {"type": "string", "maxLength": 140, "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", "not": {"pattern": "^(?:\\.\\.?/|[A-Za-z0-9_.-]+/\\.\\.?)$"}}, @@ -101,6 +101,7 @@ "span": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"$ref": "#/$defs/position"}, "end": {"$ref": "#/$defs/position"}}}, "selector": {"type": "object", "additionalProperties": false, "required": ["lock"], "properties": {"lock": {"type": "string", "pattern": "^a-[0-9a-f]{16}$"}}}, "path": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]{1,255}(?:/[^/\\\\\\x00-\\x1f\\x7f]{1,255})*$", "not": {"pattern": "(^|/)\\.\\.?(/|$)"}}, + "sourceWorkspaceAlias": {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[\\x20-\\x7e]+$", "not": {"pattern": "^(?:\\.{1,2}|\\.[Gg][Ii][Tt]|.*[/\\\\].*)$"}}, "repository": {"type": "string", "maxLength": 140, "pattern": "^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?/[a-z0-9](?:[a-z0-9_.-]{0,98}[a-z0-9])?$"}, "actionLock": { "type": "object", "additionalProperties": false, @@ -108,11 +109,11 @@ "properties": { "id": {"type": "string", "pattern": "^a-[0-9a-f]{16}$"}, "source": {"enum": ["workspace", "github"]}, "repository": {"$ref": "#/$defs/repository"}, "requested_ref": {"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^[^\\x00-\\x1f\\x7f]+$"}, - "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "path": {"$ref": "#/$defs/path"}, "source_digest": {"$ref": "#/$defs/digest"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "path": {"$ref": "#/$defs/path"}, "workspace_alias": {"$ref": "#/$defs/sourceWorkspaceAlias"}, "source_digest": {"$ref": "#/$defs/digest"}, "children": {"type": "object", "maxProperties": 1024, "propertyNames": {"minLength": 1, "maxLength": 2048, "pattern": "^[^\\x00-\\x1f\\x7f]+$"}, "additionalProperties": {"$ref": "#/$defs/selector"}} }, "allOf": [ - {"if": {"properties": {"source": {"const": "workspace"}}, "required": ["source"]}, "then": {"not": {"anyOf": [{"required": ["repository"]}, {"required": ["requested_ref"]}, {"required": ["commit"]}]}}}, + {"if": {"properties": {"source": {"const": "workspace"}}, "required": ["source"]}, "then": {"not": {"anyOf": [{"required": ["repository"]}, {"required": ["requested_ref"]}, {"required": ["commit"]}, {"required": ["workspace_alias"]}]}}}, {"if": {"properties": {"source": {"const": "github"}}, "required": ["source"]}, "then": {"required": ["repository", "requested_ref", "commit"]}} ] },