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")