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 <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-07-20 18:22:41 -07:00
parent 8035347c81
commit acaf251f0b
16 changed files with 920 additions and 47 deletions
+1 -13
View File
@@ -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,
+18
View File
@@ -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,
+8
View File
@@ -45,3 +45,11 @@ Extended build capabilities with BuildKit
### <a name="builder"></a> 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.
+12
View File
@@ -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.
### <a name="leave"></a> Remove a node from a builder (--leave)
The `--leave` flag changes the action of the command to remove a node from a
+9 -2
View File
@@ -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"
)
+121 -25
View File
@@ -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 {
+5
View File
@@ -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)
}
+54
View File
@@ -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
}
+47
View File
@@ -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)
}
+12
View File
@@ -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
+28
View File
@@ -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
})
+80 -2
View File
@@ -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
+148 -5
View File
@@ -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)
})
}
}
+154
View File
@@ -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,
}
}
+184
View File
@@ -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")
}
+39
View File
@@ -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")