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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/agent_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type AgentConfiguration struct {
GitCloneFlags string
GitCloneMirrorFlags string
GitCleanFlags string
GitCommitVerification string
GitFetchFlags string
GitSubmodules bool
GitSubmoduleCloneConfig []string
Expand Down
1 change: 1 addition & 0 deletions agent/job_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID`
setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags)
setCheckoutEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout))
setCheckoutEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ","))
setCheckoutEnv("BUILDKITE_GIT_COMMIT_VERIFICATION", r.conf.AgentConfiguration.GitCommitVerification)

setEnv("BUILDKITE_SHELL", r.conf.AgentConfiguration.Shell)
setEnv("BUILDKITE_HOOKS_SHELL", r.conf.AgentConfiguration.HooksShell)
Expand Down
6 changes: 5 additions & 1 deletion clicommand/agent_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ type AgentStartConfig struct {
GitMirrorCheckoutMode string `cli:"git-mirror-checkout-mode"`
GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"`
GitMirrorsSkipUpdate bool `cli:"git-mirrors-skip-update"`
GitCommitVerification string `cli:"git-commit-verification"`
NoGitSubmodules bool `cli:"no-git-submodules"`
GitSubmoduleCloneConfig []string `cli:"git-submodule-clone-config"`
SkipCheckout bool `cli:"skip-checkout"`
Expand Down Expand Up @@ -376,7 +377,8 @@ var AgentStartCommand = cli.Command{
Name: "start",
Usage: "Starts a Buildkite agent",
Description: startDescription,
Flags: append(globalFlags(),
Flags: append(
globalFlags(),
cli.StringFlag{
Name: "config",
Value: "",
Expand Down Expand Up @@ -540,6 +542,7 @@ var AgentStartCommand = cli.Command{
GitCheckoutFlagsFlag,
GitCloneFlagsFlag,
GitCleanFlagsFlag,
GitCommitVerificationFlag,
GitFetchFlagsFlag,
GitCloneMirrorFlagsFlag,
GitMirrorsPathFlag,
Expand Down Expand Up @@ -1075,6 +1078,7 @@ var AgentStartCommand = cli.Command{
GitCloneFlags: cfg.GitCloneFlags,
GitCloneMirrorFlags: cfg.GitCloneMirrorFlags,
GitCleanFlags: cfg.GitCleanFlags,
GitCommitVerification: cfg.GitCommitVerification,
GitFetchFlags: cfg.GitFetchFlags,
GitSubmodules: !cfg.NoGitSubmodules,
GitSubmoduleCloneConfig: cfg.GitSubmoduleCloneConfig,
Expand Down
6 changes: 5 additions & 1 deletion clicommand/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type BootstrapConfig struct {
GitFetchFlags string `cli:"git-fetch-flags"`
GitCloneMirrorFlags string `cli:"git-clone-mirror-flags"`
GitCleanFlags string `cli:"git-clean-flags"`
GitCommitVerification string `cli:"git-commit-verification"`
GitMirrorsPath string `cli:"git-mirrors-path" normalize:"filepath"`
GitMirrorCheckoutMode string `cli:"git-mirror-checkout-mode"`
GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"`
Expand Down Expand Up @@ -248,6 +249,7 @@ var BootstrapCommand = cli.Command{
GitCloneFlagsFlag,
GitCloneMirrorFlagsFlag,
GitCleanFlagsFlag,
GitCommitVerificationFlag,
GitFetchFlagsFlag,
GitMirrorsPathFlag,
GitMirrorCheckoutModeFlag,
Expand Down Expand Up @@ -448,6 +450,7 @@ var BootstrapCommand = cli.Command{
Debug: cfg.Debug,
GitCheckoutFlags: cfg.GitCheckoutFlags,
GitCleanFlags: cfg.GitCleanFlags,
GitCommitVerification: cfg.GitCommitVerification,
GitCloneFlags: cfg.GitCloneFlags,
GitCloneMirrorFlags: cfg.GitCloneMirrorFlags,
GitFetchFlags: cfg.GitFetchFlags,
Expand Down Expand Up @@ -497,7 +500,8 @@ var BootstrapCommand = cli.Command{
defer cancel()

signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt,
signal.Notify(
signals, os.Interrupt,
syscall.SIGHUP,
syscall.SIGTERM,
syscall.SIGINT,
Expand Down
6 changes: 6 additions & 0 deletions clicommand/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ var (
// -q: quiet, only report errors
}

GitCommitVerificationFlag = cli.StringFlag{
Name: "git-commit-verification",
Usage: "Enable git commit verification",
EnvVar: "BUILDKITE_GIT_COMMIT_VERIFICATION",
}

GitFetchFlagsFlag = cli.StringFlag{
Name: "git-fetch-flags",
Value: "-v --prune",
Expand Down
1 change: 1 addition & 0 deletions env/protected.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ var protectedEnv = map[string]protection{
"BUILDKITE_BIN_PATH": {},
"BUILDKITE_BUILD_PATH": {},
"BUILDKITE_COMMAND_EVAL": {},
"BUILDKITE_GIT_COMMIT_VERIFICATION": {},
"BUILDKITE_CONFIG_PATH": {},
"BUILDKITE_CONTAINER_COUNT": {},
"BUILDKITE_HOOKS_PATH": {},
Expand Down
4 changes: 4 additions & 0 deletions internal/job/checkout.go
Comment thread
omehegan marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,10 @@ func (e *Executor) defaultCheckoutPhase(ctx context.Context) error {
return err
}

if err := e.verifyCommit(ctx); err != nil {
return err
}

gitCheckoutFlags := e.GitCheckoutFlags

if e.Commit == "HEAD" {
Expand Down
150 changes: 150 additions & 0 deletions internal/job/commit_verification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package job

import (
"context"
"errors"
"fmt"
"strings"

"github.com/buildkite/agent/v3/internal/shell"
)

// ErrCommitVerificationFailed indicates that git has definitively determined
// the commit is not on the specified branch. This is the security-relevant case.
var ErrCommitVerificationFailed = errors.New("commit verification failed")

// ErrCommitVerificationUnavailable indicates that git was unable to perform
// the ancestry check (e.g. due to repo corruption, misconfigured remotes, etc).
// This is NOT evidence of an attack — it's an infrastructure problem.
var ErrCommitVerificationUnavailable = errors.New("commit verification unavailable")

// checkCommitOnBranch performs the actual git ancestry check, handling shallow
// clones by deepening or unshallowing as needed. It returns:
// - nil if the commit is verified on the branch
// - ErrCommitVerificationFailed if the commit is definitively not on the branch
// - ErrCommitVerificationUnavailable if the check cannot be performed
func (e *Executor) checkCommitOnBranch(ctx context.Context) error {
e.shell.Commentf("Verifying commit %q is on branch %q", e.Commit, e.Branch)

// Try the ancestry check
err := e.shell.Command("git", "merge-base", "--is-ancestor", e.Commit, e.Branch).Run(ctx)
exitCode := shell.ExitCode(err)

switch exitCode {
case 0:
return nil // verified!
case 1:
return fmt.Errorf("%w: commit %q is not on branch %q", ErrCommitVerificationFailed, e.Commit, e.Branch)
case 128:
// We might have a shallow clone, try to deepen or unshallow to find the commit
output, _ := e.shell.Command("git", "rev-parse", "--is-shallow-repository").RunAndCaptureStdout(ctx)

if strings.TrimSpace(output) != "true" {
// Not shallow — this is a genuine error
return fmt.Errorf("%w: unable to verify commit %q on branch %q: %w", ErrCommitVerificationUnavailable, e.Commit, e.Branch, err)
}

// Try deepening by 50 commits first
e.shell.Commentf("Shallow clone detected, deepening by 50 commits...")
_ = e.shell.Command("git", "fetch", "--deepen=50").Run(ctx)

retryErr := e.shell.Command("git", "merge-base", "--is-ancestor", e.Commit, e.Branch).Run(ctx)
retryCode := shell.ExitCode(retryErr)

if retryCode == 0 {
return nil // Found a valid commit after deepening
}
if retryCode == 1 {
return fmt.Errorf("%w: commit %q is not on branch %q", ErrCommitVerificationFailed, e.Commit, e.Branch)
}

// Still 128 - full unshallow as last resort
e.shell.Commentf("Deepening insufficient, performing a full unshallow...")
_ = e.shell.Command("git", "fetch", "--unshallow").Run(ctx)

retryErr = e.shell.Command("git", "merge-base", "--is-ancestor", e.Commit, e.Branch).Run(ctx)
retryCode = shell.ExitCode(retryErr)

if retryCode == 0 {
return nil // Found a valid commit after unshallowing
}
if retryCode == 1 {
return fmt.Errorf("%w: commit %q is not on branch %q", ErrCommitVerificationFailed, e.Commit, e.Branch)
}

return fmt.Errorf("%w: unable to verify commit %q on branch %q after unshallowing: %w", ErrCommitVerificationUnavailable, e.Commit, e.Branch, retryErr)
default:
return fmt.Errorf("%w: unable to verify commit %q on branch %q: %w", ErrCommitVerificationUnavailable, e.Commit, e.Branch, err)
}
}

// verifyCommit is called if the user has commit verification enabled. It ensures that the commit we are
// asked to build exists and is reachable on the branch we are given.
func (e *Executor) verifyCommit(ctx context.Context) error {
// Skip if not enabled
if e.GitCommitVerification == "" {
return nil
}

// Skip if commit is HEAD (nothing to verify)
if e.Commit == "HEAD" {
return nil
}

// Skip if we haven't been given a branch - e.g. it's a tag push event
if e.Branch == "" {
return nil
}

// Skip if this is a tag build — tags are not branch-specific
if e.Tag != "" {
return nil
}

// Skip if this is a PR build — the commit may be on a merge ref, not the target branch
if e.PullRequest != "" {
return nil
}

// Skip if a custom refspec is set — the fetch may not populate standard branch refs,
// making ancestry verification unreliable
if e.RefSpec != "" {
return nil
}

// Perform the verification
err := e.checkCommitOnBranch(ctx)

// Verification passed
if err == nil {
return nil
}

// Handle verification results based on error type and mode.
//
// We distinguish between two failure types:
// - ErrCommitVerificationFailed: git definitively determined the commit is NOT
// on the branch. This is the security-relevant case (potential branch spoofing).
// - ErrCommitVerificationUnavailable: git was unable to perform the check at all
// (repo issues, corrupted refs, etc). This is NOT evidence of an attack.
//
// In strict mode, only a definitive failure (ErrCommitVerificationFailed) blocks
// the build. If verification is unavailable, we warn but allow the build to proceed.
// This avoids users disabling verification entirely due to infrastructure false
// positives, which would leave them with no protection at all.
switch e.GitCommitVerification {
case "strict":
if errors.Is(err, ErrCommitVerificationFailed) {
return err
}
// Verification was unavailable — warn but don't block
e.shell.Warningf("Commit verification unavailable: %v", err)
return nil
case "warn":
e.shell.Warningf("Commit verification failed: %v", err)
return nil
default:
e.shell.Warningf("Unknown git-commit-verification value %q, skipping verification", e.GitCommitVerification)
return nil
}
}
Loading