From acaf251f0b805aee97d6effa9d482dadd0cc3a4b Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 20 Jul 2026 18:08:33 -0700 Subject: [PATCH] policy: verify BuildKit builder images Extend the built-in policy to validate signed moby/buildkit release and floating tags before docker-container builders are created. Pull the image first, inspect it through Docker, and bind verification to the descriptor digest. Resolve signature attestations through the BuildKit API embedded in the Docker daemon. If pulling fails, use a local image while applying the same verification when the containerd image store exposes an immutable descriptor. Keep the classic image-store behavior unchanged because no descriptor is available. Allow unmanaged repositories and digest-only references unchanged. Add the allow-untrusted-image driver option as an explicit verification bypass. Document the behavior and add policy, digest-pinning, and local fallback coverage. Signed-off-by: Tonis Tiigi --- build/opt.go | 14 +-- builder/node.go | 18 +++ docs/reference/buildx.md | 8 ++ docs/reference/buildx_create.md | 12 ++ driver/bkimage/bkimage.go | 11 +- driver/docker-container/driver.go | 146 +++++++++++++++++++---- driver/docker-container/factory.go | 5 + driver/image.go | 54 +++++++++ driver/image_test.go | 47 ++++++++ driver/manager.go | 12 ++ policy/default.go | 28 +++++ policy/default.rego | 82 ++++++++++++- policy/default_test.go | 153 +++++++++++++++++++++++- policy/image_policy.go | 154 ++++++++++++++++++++++++ policy/image_policy_test.go | 184 +++++++++++++++++++++++++++++ tests/create.go | 39 ++++++ 16 files changed, 920 insertions(+), 47 deletions(-) create mode 100644 driver/image.go create mode 100644 driver/image_test.go create mode 100644 policy/image_policy.go create mode 100644 policy/image_policy_test.go diff --git a/build/opt.go b/build/opt.go index c32eb0cee..4b5246c75 100644 --- a/build/opt.go +++ b/build/opt.go @@ -65,18 +65,6 @@ var sendGitQueryAsInput = sync.OnceValue(func() bool { return false }) -// defaultPolicyEnabled reports whether the builtin default source policy is -// enabled via the BUILDX_DEFAULT_POLICY environment variable. It is opt-in -// for now; a future release may flip the default to on. -var defaultPolicyEnabled = sync.OnceValue(func() bool { - if v, ok := os.LookupEnv("BUILDX_DEFAULT_POLICY"); ok { - if vv, err := strconv.ParseBool(v); err == nil { - return vv - } - } - return false -}) - // policyExplicitlyDisabled reports whether the user passed `--policy // disabled=true`, which suppresses both user-defined and builtin default // policies. @@ -664,7 +652,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o // (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly // loaded during a build, and passes through any other source so user // policies retain full control. - if defaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) { + if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) { builtin := policyOpt{ Files: []policyFileSpec{{ Filename: policy.DefaultPolicyFilename, diff --git a/builder/node.go b/builder/node.go index abffe0ce1..95e56e3b0 100644 --- a/builder/node.go +++ b/builder/node.go @@ -8,15 +8,19 @@ import ( "github.com/containerd/platforms" "github.com/docker/buildx/driver" + "github.com/docker/buildx/policy" "github.com/docker/buildx/store" "github.com/docker/buildx/store/storeutil" + "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/dockerutil" "github.com/docker/buildx/util/imagetools" "github.com/docker/buildx/util/platformutil" "github.com/moby/buildkit/client" "github.com/moby/buildkit/util/grpcerrors" + digest "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "google.golang.org/grpc/codes" ) @@ -109,6 +113,19 @@ func (b *Builder) LoadNodes(ctx context.Context, opts ...LoadNodesOption) (_ []N } } + var imageVerifier driver.ImageVerifier + if policy.DefaultPolicyEnabled() { + pol := policy.DefaultPolicy(policy.Opt{ + Log: func(_ logrus.Level, msg string) { + logrus.Debug(msg) + }, + VerifierProvider: policy.SignatureVerifier(confutil.NewConfig(b.opts.dockerCli)), + }) + imageVerifier = func(ctx context.Context, ref string, platform *ocispecs.Platform, resolver policy.SourceMetadataResolver) (digest.Digest, error) { + return pol.CheckSource(ctx, ref, platform, resolver) + } + } + for i, n := range b.NodeGroup.Nodes { func(i int, n store.Node) { eg.Go(func() error { @@ -137,6 +154,7 @@ func (b *Builder) LoadNodes(ctx context.Context, opts ...LoadNodesOption) (_ []N Files: n.Files, DriverOpts: n.DriverOpts, Auth: imageopt.Auth, + ImageVerifier: imageVerifier, Platforms: n.Platforms, ContextPathHash: b.opts.contextPathHash, DialMeta: lno.dialMeta, diff --git a/docs/reference/buildx.md b/docs/reference/buildx.md index 0541572d0..3aa0cb70f 100644 --- a/docs/reference/buildx.md +++ b/docs/reference/buildx.md @@ -45,3 +45,11 @@ Extended build capabilities with BuildKit ### Override the configured builder instance (--builder) You can also use the `BUILDX_BUILDER` environment variable. + +### Enable the default policy + +Set `BUILDX_DEFAULT_POLICY=1` to enable Buildx's built-in source policy. The +policy verifies signed tags for images managed by Docker, including BuildKit +builder images and Dockerfile frontends. Untagged digest references and images +outside the managed repositories are allowed unchanged. Tagged references +that also contain a digest still have their release identity verified. diff --git a/docs/reference/buildx_create.md b/docs/reference/buildx_create.md index 0c8d9f0ea..666ecd95c 100644 --- a/docs/reference/buildx_create.md +++ b/docs/reference/buildx_create.md @@ -173,6 +173,18 @@ documentation for the specific driver: * [`kubernetes` driver](https://docs.docker.com/build/builders/drivers/kubernetes/) * [`remote` driver](https://docs.docker.com/build/builders/drivers/remote/) +With `BUILDX_DEFAULT_POLICY=1`, the `docker-container` driver verifies signed +`moby/buildkit` builder image tags before creating the builder. To explicitly +bypass this verification, set `allow-untrusted-image=true`. For example: + +```console +$ BUILDX_DEFAULT_POLICY=1 docker buildx create --driver docker-container \ + --driver-opt allow-untrusted-image=true +``` + +Only use this option for an image that you trust. It disables builder image +verification for the new builder node. + ### Remove a node from a builder (--leave) The `--leave` flag changes the action of the command to remove a node from a diff --git a/driver/bkimage/bkimage.go b/driver/bkimage/bkimage.go index f2443e7c1..464adfb22 100644 --- a/driver/bkimage/bkimage.go +++ b/driver/bkimage/bkimage.go @@ -1,7 +1,14 @@ package bkimage const ( - DefaultImage = "moby/buildkit:buildx-stable-1" // TODO: make this verified - QemuImage = "tonistiigi/binfmt:latest" // TODO: make this verified + DefaultImage = "moby/buildkit:buildx-stable-1" + QemuImage = "tonistiigi/binfmt:latest" // TODO: make this verified DefaultRootlessImage = DefaultImage + "-rootless" + + // TrustedRepo is the fully-qualified repository whose tags are verified + // against the builtin default policy before a builder is created from + // them. Images from other repositories pass through unverified; the + // allow-untrusted-image driver-opt is only needed when a TrustedRepo tag + // that the policy covers does not verify correctly. + TrustedRepo = "docker.io/moby/buildkit" ) diff --git a/driver/docker-container/driver.go b/driver/docker-container/driver.go index f8b803a48..2e287721b 100644 --- a/driver/docker-container/driver.go +++ b/driver/docker-container/driver.go @@ -13,12 +13,15 @@ import ( "time" cerrdefs "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/distribution/reference" "github.com/docker/buildx/driver" "github.com/docker/buildx/driver/bkimage" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/ghutil" "github.com/docker/buildx/util/imagetools" "github.com/docker/buildx/util/progress" + "github.com/docker/buildx/util/sourcemeta" "github.com/docker/cli/cli/context/docker" contextstore "github.com/docker/cli/cli/context/store" "github.com/docker/cli/opts" @@ -29,6 +32,7 @@ import ( "github.com/moby/moby/api/types/mount" dockerclient "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/security" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -43,21 +47,22 @@ type Driver struct { // if you add fields, remember to update docs: // https://github.com/docker/docs/blob/main/content/build/drivers/docker-container.md - netMode string - image string - memory opts.MemBytes - memorySwap opts.MemSwapBytes - cpuQuota int64 - cpuPeriod int64 - cpuShares int64 - cpusetCpus string - cpusetMems string - cgroupParent string - restartPolicy container.RestartPolicy - env []string - defaultLoad bool - gpus []container.DeviceRequest - writeProvenanceGHA bool + netMode string + image string + allowUntrustedImage bool + memory opts.MemBytes + memorySwap opts.MemSwapBytes + cpuQuota int64 + cpuPeriod int64 + cpuShares int64 + cpusetCpus string + cpusetMems string + cgroupParent string + restartPolicy container.RestartPolicy + env []string + defaultLoad bool + gpus []container.DeviceRequest + writeProvenanceGHA bool } func (d *Driver) IsMobyDriver() bool { @@ -92,27 +97,59 @@ func (d *Driver) create(ctx context.Context, l progress.SubLogger) error { imageName = d.image } - if err := l.Wrap("pulling image "+imageName, func() error { - ra, err := imagetools.RegistryAuthForRef(imageName, d.Auth) + imageRef := imageName + pullErr := l.Wrap("pulling image "+imageRef, func() error { + ra, err := imagetools.RegistryAuthForRef(imageRef, d.Auth) if err != nil { return err } - resp, err := d.DockerAPI.ImagePull(ctx, imageName, dockerclient.ImagePullOptions{ + resp, err := d.DockerAPI.ImagePull(ctx, imageRef, dockerclient.ImagePullOptions{ RegistryAuth: ra, }) if err != nil { return err } return resp.Wait(ctx) - }); err != nil { - // image pulling failed, check if it exists in local image store. - // if not, return pulling error. otherwise log it. - _, errInspect := d.DockerAPI.ImageInspect(ctx, imageName) - found := errInspect == nil - if !found { + }) + image, inspectErr := d.DockerAPI.ImageInspect(ctx, imageRef) + if inspectErr != nil { + if pullErr != nil { + return pullErr + } + return errors.Wrapf(inspectErr, "failed to inspect pulled image %s", imageRef) + } + + imageName = imageRef + if image.Descriptor != nil && d.ImageVerifier != nil && !d.allowUntrustedImage { + named, err := reference.ParseNormalizedNamed(imageRef) + if err != nil { + return errors.Wrapf(err, "failed to parse image reference %s", imageRef) + } + if named.Name() == bkimage.TrustedRepo { + if _, canonical := named.(reference.Canonical); !canonical { + named = reference.TagNameOnly(named) + } + pinned, err := reference.WithDigest(named, image.Descriptor.Digest) + if err != nil { + return errors.Wrapf(err, "failed to construct image reference for %s", imageRef) + } + imageName = pinned.String() + } + } + + // Policy verification requires the immutable descriptor exposed by the + // containerd image store. Classic-store images are allowed without it. + if image.Descriptor != nil { + var err error + imageName, err = d.verifiedImageRef(ctx, l, imageName) + if err != nil { + return err + } + } + if pullErr != nil { + if err := l.Wrap("using local image "+imageName, func() error { return nil }); err != nil { return err } - l.Wrap("pulling failed, using local image "+imageName, func() error { return nil }) } cfg := &container.Config{ @@ -229,6 +266,65 @@ func (d *Driver) create(ctx context.Context, l progress.SubLogger) error { }) } +// verifiedImageRef evaluates ref against the builtin default policy and +// returns the canonical reference carrying the digest that verification +// resolved. Source metadata is resolved through the BuildKit embedded in the +// Docker daemon that hosts the builder, so registry access follows the +// daemon configuration. The reference is returned unchanged when the policy +// does not apply to it: policy disabled, allow-untrusted-image set, the +// image pinned by digest without a tag, or an image outside the managed +// moby/buildkit repository (which the default policy passes through). +func (d *Driver) verifiedImageRef(ctx context.Context, l progress.SubLogger, ref string) (string, error) { + if d.ImageVerifier == nil || d.allowUntrustedImage { + return ref, nil + } + + c, err := d.buildkitClient(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to connect to BuildKit for image verification") + } + defer c.Close() + mr := sourcemeta.NewResolver(c) + defer mr.Close() + + pinned, applied, err := driver.VerifyImageRef(ctx, l, ref, d.daemonPlatform(ctx), mr, d.ImageVerifier) + if err != nil { + if !applied { + return "", err + } + return "", errors.Wrapf(err, "failed to verify image %s", ref) + } + if !applied { + return ref, nil + } + return pinned, nil +} + +// buildkitClient returns a client to the BuildKit embedded in the Docker +// daemon that hosts the builder container. +func (d *Driver) buildkitClient(ctx context.Context) (*client.Client, error) { + return client.New(ctx, "", + client.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return d.DockerAPI.DialHijack(ctx, "/grpc", "h2c", d.DialMeta) + }), + client.WithSessionDialer(func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { + return d.DockerAPI.DialHijack(ctx, "/session", proto, meta) + }), + ) +} + +// daemonPlatform returns the platform of the images the daemon pulls, +// falling back to the client platform when daemon info is unavailable. +func (d *Driver) daemonPlatform(ctx context.Context) *ocispecs.Platform { + if resp, err := d.DockerAPI.Info(ctx, dockerclient.InfoOptions{}); err == nil { + if p, err := platforms.Parse(resp.Info.OSType + "/" + resp.Info.Architecture); err == nil { + return &p + } + } + p := platforms.Normalize(platforms.DefaultSpec()) + return &p +} + func (d *Driver) wait(ctx context.Context, l progress.SubLogger) error { try := 1 for { diff --git a/driver/docker-container/factory.go b/driver/docker-container/factory.go index ee95ae673..ed3782ddf 100644 --- a/driver/docker-container/factory.go +++ b/driver/docker-container/factory.go @@ -117,6 +117,11 @@ func (f *factory) New(ctx context.Context, cfg driver.InitConfig) (driver.Driver if err != nil { return nil, err } + case k == "allow-untrusted-image": + d.allowUntrustedImage, err = strconv.ParseBool(v) + if err != nil { + return nil, err + } default: return nil, errors.Errorf("invalid driver option %s for docker-container driver", k) } diff --git a/driver/image.go b/driver/image.go new file mode 100644 index 000000000..d44f12188 --- /dev/null +++ b/driver/image.go @@ -0,0 +1,54 @@ +package driver + +import ( + "context" + + "github.com/distribution/reference" + "github.com/docker/buildx/driver/bkimage" + "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/progress" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// VerifyImageRef evaluates ref against the builtin default policy through +// verify and returns the reference pinned to the digest that verification +// resolved. The boolean result reports whether the policy applied: it is +// false, with ref returned unchanged, when there is no verifier, when ref is +// pinned by digest without a tag, or when ref is outside the managed +// moby/buildkit repository (unmanaged images pass through the default policy +// unchanged). A tagged canonical reference is still verified because its tag +// carries the release identity checked by the policy. +func VerifyImageRef(ctx context.Context, l progress.SubLogger, ref string, platform *ocispecs.Platform, resolver policy.SourceMetadataResolver, verify ImageVerifier) (string, bool, error) { + if verify == nil { + return ref, false, nil + } + named, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return "", false, errors.Wrapf(err, "failed to parse image reference %s", ref) + } + _, isCanonical := named.(reference.Canonical) + _, isTagged := named.(reference.Tagged) + if isCanonical && !isTagged { + return ref, false, nil + } + if named.Name() != bkimage.TrustedRepo { + return ref, false, nil + } + named = reference.TagNameOnly(named) + + var dgst digest.Digest + if err := l.Wrap("verifying image "+named.String(), func() error { + var err error + dgst, err = verify(ctx, named.String(), platform, resolver) + return err + }); err != nil { + return "", true, err + } + canonical, err := reference.WithDigest(named, dgst) + if err != nil { + return "", true, errors.Wrapf(err, "failed to construct canonical reference for %s", ref) + } + return canonical.String(), true, nil +} diff --git a/driver/image_test.go b/driver/image_test.go new file mode 100644 index 000000000..a40b2261a --- /dev/null +++ b/driver/image_test.go @@ -0,0 +1,47 @@ +package driver + +import ( + "context" + "testing" + + "github.com/docker/buildx/policy" + "github.com/moby/buildkit/client" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +type nopSubLogger struct{} + +func (nopSubLogger) Wrap(_ string, fn func() error) error { return fn() } +func (nopSubLogger) Log(int, []byte) {} +func (nopSubLogger) SetStatus(*client.VertexStatus) {} + +func TestVerifyImageRefTaggedCanonical(t *testing.T) { + dgst := digest.FromString("buildkit") + ref := "moby/buildkit:v0.31.2@" + dgst.String() + var verifiedRef string + + pinned, applied, err := VerifyImageRef(context.Background(), nopSubLogger{}, ref, nil, nil, func(_ context.Context, ref string, _ *ocispecs.Platform, _ policy.SourceMetadataResolver) (digest.Digest, error) { + verifiedRef = ref + return dgst, nil + }) + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, "docker.io/"+ref, verifiedRef) + require.Equal(t, "docker.io/"+ref, pinned) +} + +func TestVerifyImageRefDigestOnly(t *testing.T) { + dgst := digest.FromString("buildkit") + called := false + + pinned, applied, err := VerifyImageRef(context.Background(), nopSubLogger{}, "moby/buildkit@"+dgst.String(), nil, nil, func(_ context.Context, _ string, _ *ocispecs.Platform, _ policy.SourceMetadataResolver) (digest.Digest, error) { + called = true + return dgst, nil + }) + require.NoError(t, err) + require.False(t, applied) + require.False(t, called) + require.Equal(t, "moby/buildkit@"+dgst.String(), pinned) +} diff --git a/driver/manager.go b/driver/manager.go index 9a3e3b5d4..b9ef2c275 100644 --- a/driver/manager.go +++ b/driver/manager.go @@ -5,11 +5,13 @@ import ( "sort" "sync" + "github.com/docker/buildx/policy" "github.com/docker/cli/cli/context/store" "github.com/moby/buildkit/client" "github.com/moby/buildkit/session/auth/authprovider" "github.com/moby/buildkit/util/tracing/delegated" dockerclient "github.com/moby/moby/client" + digest "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -27,6 +29,15 @@ type BuildkitConfig struct { // Rootless bool } +// ImageVerifier validates the builder image ref against the builtin default +// policy and returns the digest that verification resolved the reference to. +// Drivers that materialize the builder from an image call it before creating +// the builder so the verified digest can be used instead of the mutable tag. +// Source metadata is resolved through the given resolver, which drivers back +// with a BuildKit instance they have access to so that registry access +// happens where the image is pulled from. +type ImageVerifier func(ctx context.Context, ref string, platform *ocispecs.Platform, resolver policy.SourceMetadataResolver) (digest.Digest, error) + type InitConfig struct { Name string EndpointAddr string @@ -36,6 +47,7 @@ type InitConfig struct { Files map[string][]byte DriverOpts map[string]string Auth authprovider.AuthConfigProvider + ImageVerifier ImageVerifier Platforms []ocispecs.Platform ContextPathHash string DialMeta map[string][]string diff --git a/policy/default.go b/policy/default.go index 188969cb6..9161f0835 100644 --- a/policy/default.go +++ b/policy/default.go @@ -2,6 +2,9 @@ package policy import ( _ "embed" + "os" + "strconv" + "sync" ) // DefaultPolicyFilename is the synthetic filename used for the embedded @@ -15,3 +18,28 @@ var defaultPolicyModule []byte func DefaultPolicyData() []byte { return defaultPolicyModule } + +// DefaultPolicy returns a Policy instance backed by the embedded default +// policy module. Any files in opt are replaced: the default policy is always +// evaluated standalone. +func DefaultPolicy(opt Opt) *Policy { + opt.Files = []File{{ + Filename: DefaultPolicyFilename, + Data: DefaultPolicyData(), + }} + return NewPolicy(opt) +} + +// DefaultPolicyEnabled reports whether the builtin default policies are +// enabled via the BUILDX_DEFAULT_POLICY environment variable. It is opt-in +// for now; a future release may flip the default to on. The gate covers both +// the default source policy applied to builds and the builder-image policy +// applied when creating container builders. +var DefaultPolicyEnabled = sync.OnceValue(func() bool { + if v, ok := os.LookupEnv("BUILDX_DEFAULT_POLICY"); ok { + if vv, err := strconv.ParseBool(v); err == nil { + return vv + } + } + return false +}) diff --git a/policy/default.rego b/policy/default.rego index 2e89c1bab..d3c9df12c 100644 --- a/policy/default.rego +++ b/policy/default.rego @@ -1,18 +1,26 @@ package docker # Default policy embedded in Buildx. It verifies trust for images shipped -# by Docker that may be implicitly loaded during a build: +# by Docker that may be implicitly loaded during a build or used to run a +# build: # # - docker/dockerfile # - docker/dockerfile-upstream # - docker/buildkit-syft-scanner +# - moby/buildkit # # Any image outside this managed set is allowed and passes through to user # policies unchanged. Access by digest is always allowed. For tag-based # access the rules below enforce a signed release from the expected GitHub # source repository using the existing docker_github_builder_signature # helper from builtins.rego. - +# +# The moby/buildkit rules also apply when Buildx pulls the image to create a +# container builder; the docker-container driver evaluates this same policy +# before creating the builder and pins the image to the digest the evaluation +# resolved. Only known tags and their variants require a matching signature; +# releases that predate signing (before v0.27.0) and unrecognized tags pass +# through like any unmanaged image. is_dockerfile if { input.image input.image.fullRepo == "docker.io/docker/dockerfile" @@ -28,6 +36,11 @@ is_syft_scanner if { input.image.fullRepo == "docker.io/docker/buildkit-syft-scanner" } +is_buildkit_image if { + input.image + input.image.fullRepo == "docker.io/moby/buildkit" +} + dockerfile_floating_tag(tag) if tag == "latest" dockerfile_floating_tag(tag) if tag == "labs" dockerfile_floating_tag(tag) if tag == "master" @@ -40,6 +53,40 @@ syft_scanner_floating_tag(tag) if tag == "latest" syft_scanner_tag_requires_sig(tag) if syft_scanner_floating_tag(tag) syft_scanner_tag_requires_sig(tag) if version_tag_ge(tag, 1, 10) +# moby/buildkit floating tags are a closed enumeration, not prefix wildcards, +# so tags like master-cache (a cache manifest, not a runnable image) are not +# subjected to a signature check they could never pass. +buildkit_floating_tag(tag) if tag in { + "latest", "latest-ubuntu", "rootless", + "master", "master-rootless", "master-ubuntu", + "nightly", "nightly-rootless", "nightly-ubuntu", +} + +buildkit_floating_tag(tag) if regex.match(`^buildx-stable-\d+(?:-rootless|-gpu)?$`, tag) + +# buildkit_release_version returns the vX.Y.Z[-rcN] release a version tag +# refers to, with the image variant suffix stripped. Release tags carry a +# signature whose source repository ref names exactly this version. +buildkit_release_version(tag) := v if { + m := regex.find_all_string_submatch_n(`^(v\d+\.\d+\.\d+(?:-rc\d+)?)(?:-rootless|-ubuntu)?$`, tag, 1) + count(m) == 1 + v := m[0][1] +} + +# v0.27.0 is the first signed moby/buildkit release. +buildkit_version_signed(version) if { + m := regex.find_all_string_submatch_n(`^v(\d+)\.(\d+)\.`, version, 1) + count(m) == 1 + to_number(m[0][1]) > 0 +} + +buildkit_version_signed(version) if { + m := regex.find_all_string_submatch_n(`^v(\d+)\.(\d+)\.`, version, 1) + count(m) == 1 + to_number(m[0][1]) == 0 + to_number(m[0][2]) >= 27 +} + default_policy_deny_msgs contains msg if { is_dockerfile @@ -59,6 +106,37 @@ default_policy_deny_msgs contains msg if { msg := sprintf("image %s is not allowed by default policy: a verified docker-github-builder signature is required for %s tag", [input.image.ref, input.image.tag]) } +default_policy_deny_msgs contains msg if { + is_buildkit_image + tag := input.image.tag + tag != "" + buildkit_floating_tag(tag) + not buildkit_floating_sig_ok + msg := sprintf("image %s is not allowed by default policy: a verified docker-github-builder signature is required for %s tag", [input.image.ref, tag]) +} + +default_policy_deny_msgs contains msg if { + is_buildkit_image + tag := input.image.tag + tag != "" + not buildkit_floating_tag(tag) + version := buildkit_release_version(tag) + buildkit_version_signed(version) + not buildkit_release_sig_ok(version) + msg := sprintf("image %s is not allowed by default policy: a verified docker-github-builder signature is required for %s tag", [input.image.ref, tag]) +} + +buildkit_floating_sig_ok if { + some sig in input.image.signatures + docker_github_builder_signature(sig, "moby/buildkit") +} + +buildkit_release_sig_ok(version) if { + some sig in input.image.signatures + docker_github_builder_signature(sig, "moby/buildkit") + sig.signer.sourceRepositoryRef == sprintf("refs/tags/%s", [version]) +} + dockerfile_sig_ok(tag) if { dockerfile_floating_tag(tag) some sig in input.image.signatures diff --git a/policy/default_test.go b/policy/default_test.go index 749accb04..625e66b03 100644 --- a/policy/default_test.go +++ b/policy/default_test.go @@ -33,11 +33,7 @@ func makeDefaultPolicy(t *testing.T, sigInfo *policytypes.SignatureInfo) *Policy }, nil } } - return NewPolicy(Opt{ - Files: []File{{ - Filename: DefaultPolicyFilename, - Data: DefaultPolicyData(), - }}, + return DefaultPolicy(Opt{ Log: func(level logrus.Level, msg string) { t.Logf("[%s] %s", level, msg) }, @@ -400,3 +396,150 @@ func TestDefaultPolicySyftScannerImages(t *testing.T) { }) } } + +func TestDefaultPolicyBuildKitImages(t *testing.T) { + testCases := []struct { + name string + sig *policytypes.SignatureInfo + ref string + allow bool + denyMsg string + }{ + { + name: "buildkit_digest_only_always_allowed", + ref: "moby/buildkit@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + allow: true, + }, + { + name: "buildkit_tagged_digest_denied", + ref: "moby/buildkit:v0.31.2@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + denyMsg: "signature is required for v0.31.2 tag", + }, + { + name: "buildkit_floating_denied_without_signature", + ref: "moby/buildkit:buildx-stable-1", + denyMsg: "signature is required for buildx-stable-1 tag", + }, + { + name: "buildkit_floating_allowed_with_signature_any_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:buildx-stable-1", + allow: true, + }, + { + name: "buildkit_floating_rootless_allowed_with_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:buildx-stable-1-rootless", + allow: true, + }, + { + name: "buildkit_floating_gpu_allowed_with_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:buildx-stable-1-gpu", + allow: true, + }, + { + name: "buildkit_latest_denied_without_signature", + ref: "moby/buildkit:latest", + denyMsg: "signature is required for latest tag", + }, + { + name: "buildkit_floating_denied_with_wrong_signature_repo", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/v0.31.2"), + ref: "moby/buildkit:buildx-stable-1", + denyMsg: "signature is required for buildx-stable-1 tag", + }, + { + name: "buildkit_release_denied_without_signature", + ref: "moby/buildkit:v0.31.2", + denyMsg: "signature is required for v0.31.2 tag", + }, + { + name: "buildkit_release_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:v0.31.2", + allow: true, + }, + { + name: "buildkit_release_rootless_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:v0.31.2-rootless", + allow: true, + }, + { + name: "buildkit_release_ubuntu_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"), + ref: "moby/buildkit:v0.31.2-ubuntu", + allow: true, + }, + { + name: "buildkit_release_rc_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.0-rc1"), + ref: "moby/buildkit:v0.31.0-rc1", + allow: true, + }, + { + name: "buildkit_release_denied_with_mismatched_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.1"), + ref: "moby/buildkit:v0.31.2", + denyMsg: "signature is required for v0.31.2 tag", + }, + { + name: "buildkit_release_denied_with_variant_in_signature_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2-rootless"), + ref: "moby/buildkit:v0.31.2-rootless", + denyMsg: "signature is required for v0.31.2-rootless tag", + }, + { + name: "buildkit_first_signed_release_requires_signature", + ref: "moby/buildkit:v0.27.0", + denyMsg: "signature is required for v0.27.0 tag", + }, + { + name: "buildkit_future_major_denied_without_signature", + ref: "moby/buildkit:v1.0.0", + denyMsg: "signature is required for v1.0.0 tag", + }, + { + name: "buildkit_old_release_allowed_unsigned", + ref: "moby/buildkit:v0.26.2", + allow: true, + }, + { + name: "buildkit_master_cache_allowed_as_unrecognized", + ref: "moby/buildkit:master-cache", + allow: true, + }, + { + name: "buildkit_bare_version_selector_allowed_as_unrecognized", + ref: "moby/buildkit:v0.8", + allow: true, + }, + { + name: "buildkit_beta_tag_allowed_as_unrecognized", + ref: "moby/buildkit:v0.13.0-beta1", + allow: true, + }, + { + name: "buildkit_docker_tag_allowed_as_unrecognized", + ref: "moby/buildkit:docker-20.10.3", + allow: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := makeDefaultPolicy(t, tc.sig) + resp := runDefaultPolicyImage(t, p, tc.ref) + if tc.allow { + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW, resp.Action) + require.Empty(t, resp.DenyMessages) + return + } + + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_DENY, resp.Action) + require.Len(t, resp.DenyMessages, 1) + require.Contains(t, resp.DenyMessages[0].Message, tc.denyMsg) + }) + } +} diff --git a/policy/image_policy.go b/policy/image_policy.go new file mode 100644 index 000000000..a9c0878d9 --- /dev/null +++ b/policy/image_policy.go @@ -0,0 +1,154 @@ +package policy + +import ( + "context" + "strings" + + "github.com/containerd/platforms" + "github.com/distribution/reference" + "github.com/docker/buildx/util/sourcemeta" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + spb "github.com/moby/buildkit/sourcepolicy/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// ImageVerificationError is returned by CheckSource when the policy denied +// the image. Any other error from CheckSource means the policy could not be +// evaluated at all (e.g. source metadata could not be resolved). +type ImageVerificationError struct { + Ref string + Messages []string +} + +func (e *ImageVerificationError) Error() string { + if len(e.Messages) == 0 { + return "image " + e.Ref + " is not allowed by policy" + } + return strings.Join(e.Messages, "; ") +} + +// CheckSource evaluates the policy against a source reference and returns +// the digest it resolved to. Source metadata, including the signature +// attestation chain, is resolved through resolver, usually a BuildKit +// gateway (see sourcemeta.NewResolver). Only docker-image sources are +// supported: source is either a plain image reference or a docker-image:// +// identifier. +func (p *Policy) CheckSource(ctx context.Context, source string, platform *ocispecs.Platform, resolver SourceMetadataResolver) (digest.Digest, error) { + refstr, ok := strings.CutPrefix(source, "docker-image://") + if !ok { + if strings.Contains(source, "://") { + return "", errors.Errorf("unsupported source %s for policy evaluation", source) + } + refstr = source + } + named, err := reference.ParseNormalizedNamed(refstr) + if err != nil { + return "", errors.Wrapf(err, "failed to parse image reference %s", refstr) + } + named = reference.TagNameOnly(named) + + if platform == nil { + pl := platforms.Normalize(platforms.DefaultSpec()) + platform = &pl + } + + srcOp := &pb.SourceOp{Identifier: "docker-image://" + named.String()} + src := &gwpb.ResolveSourceMetaResponse{Source: srcOp} + + for range maxResolveIterations { + decision, next, err := p.CheckPolicy(ctx, &policysession.CheckPolicyRequest{ + Platform: toPBPlatform(platform), + Source: src, + }) + if err != nil { + return "", err + } + if next != nil { + src, err = resolveSourceMeta(ctx, resolver, next, srcOp, platform) + if err != nil { + return "", err + } + if next.Image != nil && next.Image.AttestationChain && (src.Image == nil || src.Image.AttestationChain == nil) { + return "", errors.Errorf("no signature metadata available for %s: image is not signed or the daemon does not support resolving it", named.String()) + } + continue + } + if decision == nil { + return "", errors.New("policy returned no decision") + } + switch decision.Action { + case spb.PolicyAction_ALLOW, spb.PolicyAction_CONVERT: + return sourceDigest(ctx, resolver, src, srcOp, named, platform) + case spb.PolicyAction_DENY: + msgs := make([]string, 0, len(decision.DenyMessages)) + for _, m := range decision.DenyMessages { + if m != nil && m.Message != "" { + msgs = append(msgs, m.Message) + } + } + return "", errors.WithStack(&ImageVerificationError{Ref: named.String(), Messages: msgs}) + default: + return "", errors.Errorf("unknown policy action %s", decision.Action) + } + } + return "", errors.New("maximum attempts reached for resolving image metadata") +} + +func resolveSourceMeta(ctx context.Context, resolver SourceMetadataResolver, req *gwpb.ResolveSourceMetaRequest, srcOp *pb.SourceOp, platform *ocispecs.Platform) (*gwpb.ResolveSourceMetaResponse, error) { + if resolver == nil { + return nil, errors.New("source metadata resolver is required for policy evaluation") + } + target := srcOp + if req.Source != nil { + target = req.Source + } + resp, err := resolver.ResolveSourceMetadata(ctx, target, sourcemeta.ToResolverOpt(req, platform)) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve source metadata for %s", target.Identifier) + } + out := sourcemeta.ToGatewayMetaResponse(resp) + if out.Source == nil { + out.Source = target + } + return out, nil +} + +// sourceDigest returns the digest the allowed source resolved to so callers +// can pin it. When the policy decided without loading image metadata the +// digest comes from the reference itself or from one extra resolution. +func sourceDigest(ctx context.Context, resolver SourceMetadataResolver, src *gwpb.ResolveSourceMetaResponse, srcOp *pb.SourceOp, named reference.Named, platform *ocispecs.Platform) (digest.Digest, error) { + if src.Image != nil && src.Image.Digest != "" { + return digest.Digest(src.Image.Digest), nil + } + if canonical, ok := named.(reference.Canonical); ok { + return canonical.Digest(), nil + } + resp, err := resolveSourceMeta(ctx, resolver, &gwpb.ResolveSourceMetaRequest{ + Source: srcOp, + Image: &gwpb.ResolveSourceImageRequest{NoConfig: true}, + }, srcOp, platform) + if err != nil { + return "", err + } + if resp.Image == nil || resp.Image.Digest == "" { + return "", errors.Errorf("failed to resolve digest for %s", named.String()) + } + return digest.Digest(resp.Image.Digest), nil +} + +func toPBPlatform(p *ocispecs.Platform) *pb.Platform { + if p == nil { + return nil + } + return &pb.Platform{ + OS: p.OS, + Architecture: p.Architecture, + Variant: p.Variant, + OSVersion: p.OSVersion, + OSFeatures: p.OSFeatures, + } +} diff --git a/policy/image_policy_test.go b/policy/image_policy_test.go new file mode 100644 index 000000000..209922d0f --- /dev/null +++ b/policy/image_policy_test.go @@ -0,0 +1,184 @@ +package policy + +import ( + "context" + "testing" + + "github.com/moby/buildkit/client/llb/sourceresolver" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + policyimage "github.com/moby/policy-helpers/image" + policytypes "github.com/moby/policy-helpers/types" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +type fakeSourceResolver func(ctx context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) + +func (f fakeSourceResolver) ResolveSourceMetadata(ctx context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + return f(ctx, op, opt) +} + +// toSourceResolverChain converts a gateway attestation chain, as built by the +// shared test helpers, into the resolver-typed chain a BuildKit gateway +// returns. +func toSourceResolverChain(t *testing.T, ac *gwpb.AttestationChain) *sourceresolver.AttestationChain { + t.Helper() + out := &sourceresolver.AttestationChain{ + Root: digest.Digest(ac.Root), + ImageManifest: digest.Digest(ac.ImageManifest), + AttestationManifest: digest.Digest(ac.AttestationManifest), + Blobs: map[digest.Digest]sourceresolver.Blob{}, + } + for _, sm := range ac.SignatureManifests { + out.SignatureManifests = append(out.SignatureManifests, digest.Digest(sm)) + } + for dgst, blob := range ac.Blobs { + out.Blobs[digest.Digest(dgst)] = sourceresolver.Blob{ + Descriptor: ocispecs.Descriptor{ + MediaType: blob.Descriptor_.MediaType, + Digest: digest.Digest(blob.Descriptor_.Digest), + Size: blob.Descriptor_.Size, + }, + Data: blob.Data, + } + } + return out +} + +func signedSigVerifier(sig *policytypes.SignatureInfo) PolicyVerifierProvider { + return func() (PolicyVerifier, error) { + return &mockPolicyVerifier{ + verifyImage: func(_ context.Context, _ policyimage.ReferrersProvider, _ ocispecs.Descriptor, _ *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + return sig, nil + }, + }, nil + } +} + +func TestCheckSourceSigned(t *testing.T) { + imgDigest := digest.FromString("resolved-image") + var sawChainRequest bool + + resolver := fakeSourceResolver(func(_ context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + require.Equal(t, "docker-image://docker.io/moby/buildkit:v0.31.2", op.Identifier) + require.NotNil(t, opt.ImageOpt) + if opt.ImageOpt.AttestationChain { + sawChainRequest = true + } + return &sourceresolver.MetaResponse{ + Op: op, + Image: &sourceresolver.ResolveImageResponse{ + Digest: imgDigest, + AttestationChain: toSourceResolverChain(t, newTestAttestationChain(t)), + }, + }, nil + }) + + p := DefaultPolicy(Opt{VerifierProvider: signedSigVerifier(dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"))}) + dgst, err := p.CheckSource(context.Background(), "moby/buildkit:v0.31.2", &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, resolver) + require.NoError(t, err) + require.Equal(t, imgDigest, dgst) + require.True(t, sawChainRequest) +} + +func TestCheckSourceDenied(t *testing.T) { + newResolver := func(withSignatures bool) fakeSourceResolver { + return func(_ context.Context, op *pb.SourceOp, _ sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + ac := newTestAttestationChain(t) + if !withSignatures { + ac.SignatureManifests = nil + } + return &sourceresolver.MetaResponse{ + Op: op, + Image: &sourceresolver.ResolveImageResponse{ + Digest: digest.FromString("resolved-image"), + AttestationChain: toSourceResolverChain(t, ac), + }, + }, nil + } + } + + t.Run("unsigned-image", func(t *testing.T) { + p := DefaultPolicy(Opt{VerifierProvider: signedSigVerifier(dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"))}) + _, err := p.CheckSource(context.Background(), "moby/buildkit:v0.31.2", &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, newResolver(false)) + var verr *ImageVerificationError + require.ErrorAs(t, err, &verr) + require.ErrorContains(t, err, "signature is required for v0.31.2 tag") + }) + + t.Run("signature-ref-mismatch", func(t *testing.T) { + p := DefaultPolicy(Opt{VerifierProvider: signedSigVerifier(dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.30.0"))}) + _, err := p.CheckSource(context.Background(), "moby/buildkit:v0.31.2", &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, newResolver(true)) + var verr *ImageVerificationError + require.ErrorAs(t, err, &verr) + }) + + t.Run("floating-tag-without-signature", func(t *testing.T) { + p := DefaultPolicy(Opt{VerifierProvider: signedSigVerifier(dockerGithubBuilderSig("moby/buildkit", "refs/tags/v0.31.2"))}) + _, err := p.CheckSource(context.Background(), "moby/buildkit:buildx-stable-1", &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, newResolver(false)) + var verr *ImageVerificationError + require.ErrorAs(t, err, &verr) + require.ErrorContains(t, err, "signature is required for buildx-stable-1 tag") + }) +} + +func TestCheckSourceOldTagPinnedWithoutMetadata(t *testing.T) { + // Releases before signing do not require metadata for the decision, but + // the digest is still resolved so the caller can pin the image. + imgDigest := digest.FromString("old-release") + var calls int + + resolver := fakeSourceResolver(func(_ context.Context, op *pb.SourceOp, opt sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + calls++ + require.NotNil(t, opt.ImageOpt) + require.False(t, opt.ImageOpt.AttestationChain) + require.True(t, opt.ImageOpt.NoConfig) + return &sourceresolver.MetaResponse{ + Op: op, + Image: &sourceresolver.ResolveImageResponse{Digest: imgDigest}, + }, nil + }) + + p := DefaultPolicy(Opt{}) + dgst, err := p.CheckSource(context.Background(), "moby/buildkit:v0.26.2", nil, resolver) + require.NoError(t, err) + require.Equal(t, imgDigest, dgst) + require.Equal(t, 1, calls) +} + +func TestCheckSourceCanonicalDigestWithoutResolution(t *testing.T) { + // An untagged canonical reference passes the policy and pins to its own + // digest without any metadata resolution. + dgst := digest.FromString("pinned") + resolver := fakeSourceResolver(func(context.Context, *pb.SourceOp, sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + return nil, errors.New("unexpected resolution") + }) + + p := DefaultPolicy(Opt{}) + out, err := p.CheckSource(context.Background(), "moby/buildkit@"+dgst.String(), nil, resolver) + require.NoError(t, err) + require.Equal(t, dgst, out) +} + +func TestCheckSourceMissingChainSupport(t *testing.T) { + // A daemon that cannot resolve the attestation chain (or an image + // without one) must produce a clear failure instead of looping. + resolver := fakeSourceResolver(func(_ context.Context, op *pb.SourceOp, _ sourceresolver.Opt) (*sourceresolver.MetaResponse, error) { + return &sourceresolver.MetaResponse{ + Op: op, + Image: &sourceresolver.ResolveImageResponse{Digest: digest.FromString("resolved-image")}, + }, nil + }) + + p := DefaultPolicy(Opt{}) + _, err := p.CheckSource(context.Background(), "moby/buildkit:buildx-stable-1", nil, resolver) + require.ErrorContains(t, err, "no signature metadata available") +} + +func TestCheckSourceUnsupportedScheme(t *testing.T) { + _, err := DefaultPolicy(Opt{}).CheckSource(context.Background(), "git://github.com/moby/buildkit.git", nil, nil) + require.ErrorContains(t, err, "unsupported source") +} diff --git a/tests/create.go b/tests/create.go index 093351974..55606756b 100644 --- a/tests/create.go +++ b/tests/create.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/docker/buildx/driver" + "github.com/docker/buildx/driver/bkimage" "github.com/moby/buildkit/identity" "github.com/moby/buildkit/util/testutil/integration" "github.com/stretchr/testify/require" @@ -25,6 +26,7 @@ var createTests = []func(t *testing.T, sb integration.Sandbox){ testCreateRestartAlways, testCreateRemoteContainer, testCreateWithProvenanceGHA, + testCreateCustomImageWithDefaultPolicy, } func testCreateMemoryLimit(t *testing.T, sb integration.Sandbox) { @@ -111,6 +113,43 @@ func testCreateRemoteContainer(t *testing.T, sb integration.Sandbox) { require.Fail(t, "remote builder is not running") } +func testCreateCustomImageWithDefaultPolicy(t *testing.T, sb integration.Sandbox) { + if !isDockerContainerWorker(sb) { + t.Skip("only testing with docker-container worker") + } + + customImage := "localhost:1/buildx-test/custom-buildkit:" + identity.NewID() + var builderName string + + cmd := dockerCmd(sb, withArgs("image", "tag", bkimage.DefaultImage, customImage)) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + t.Cleanup(func() { + if builderName != "" { + out, err := rmCmd(sb, withArgs(builderName)) + require.NoError(t, err, out) + } + cmd := dockerCmd(sb, withArgs("image", "rm", customImage)) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + }) + + // images outside the managed moby/buildkit repository pass through the + // default policy, so bootstrapping a builder from a custom local image + // needs no opt-out or registry access. + out, err := createCmd(sb, + withArgs("--driver", "docker-container", "--driver-opt", "image="+customImage), + withEnv("BUILDX_DEFAULT_POLICY=1"), + ) + require.NoError(t, err, out) + builderName = strings.TrimSpace(out) + + out, err = inspectCmd(sb, withArgs(builderName, "--bootstrap"), withEnv("BUILDX_DEFAULT_POLICY=1")) + require.NoError(t, err, out) + require.Contains(t, out, "using local image "+customImage) +} + func testCreateWithProvenanceGHA(t *testing.T, sb integration.Sandbox) { if !isDockerContainerWorker(sb) { t.Skip("only testing with docker-container worker")