Merge pull request #3961 from tonistiigi/buildkit-default-policy
policy: verify BuildKit builder images
This commit is contained in:
+1
-13
@@ -65,18 +65,6 @@ var sendGitQueryAsInput = sync.OnceValue(func() bool {
|
|||||||
return false
|
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
|
// policyExplicitlyDisabled reports whether the user passed `--policy
|
||||||
// disabled=true`, which suppresses both user-defined and builtin default
|
// disabled=true`, which suppresses both user-defined and builtin default
|
||||||
// policies.
|
// policies.
|
||||||
@@ -664,7 +652,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o
|
|||||||
// (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly
|
// (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly
|
||||||
// loaded during a build, and passes through any other source so user
|
// loaded during a build, and passes through any other source so user
|
||||||
// policies retain full control.
|
// policies retain full control.
|
||||||
if defaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) {
|
if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) {
|
||||||
builtin := policyOpt{
|
builtin := policyOpt{
|
||||||
Files: []policyFileSpec{{
|
Files: []policyFileSpec{{
|
||||||
Filename: policy.DefaultPolicyFilename,
|
Filename: policy.DefaultPolicyFilename,
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import (
|
|||||||
|
|
||||||
"github.com/containerd/platforms"
|
"github.com/containerd/platforms"
|
||||||
"github.com/docker/buildx/driver"
|
"github.com/docker/buildx/driver"
|
||||||
|
"github.com/docker/buildx/policy"
|
||||||
"github.com/docker/buildx/store"
|
"github.com/docker/buildx/store"
|
||||||
"github.com/docker/buildx/store/storeutil"
|
"github.com/docker/buildx/store/storeutil"
|
||||||
|
"github.com/docker/buildx/util/confutil"
|
||||||
"github.com/docker/buildx/util/dockerutil"
|
"github.com/docker/buildx/util/dockerutil"
|
||||||
"github.com/docker/buildx/util/imagetools"
|
"github.com/docker/buildx/util/imagetools"
|
||||||
"github.com/docker/buildx/util/platformutil"
|
"github.com/docker/buildx/util/platformutil"
|
||||||
"github.com/moby/buildkit/client"
|
"github.com/moby/buildkit/client"
|
||||||
"github.com/moby/buildkit/util/grpcerrors"
|
"github.com/moby/buildkit/util/grpcerrors"
|
||||||
|
digest "github.com/opencontainers/go-digest"
|
||||||
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc/codes"
|
"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 {
|
for i, n := range b.NodeGroup.Nodes {
|
||||||
func(i int, n store.Node) {
|
func(i int, n store.Node) {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
@@ -137,6 +154,7 @@ func (b *Builder) LoadNodes(ctx context.Context, opts ...LoadNodesOption) (_ []N
|
|||||||
Files: n.Files,
|
Files: n.Files,
|
||||||
DriverOpts: n.DriverOpts,
|
DriverOpts: n.DriverOpts,
|
||||||
Auth: imageopt.Auth,
|
Auth: imageopt.Auth,
|
||||||
|
ImageVerifier: imageVerifier,
|
||||||
Platforms: n.Platforms,
|
Platforms: n.Platforms,
|
||||||
ContextPathHash: b.opts.contextPathHash,
|
ContextPathHash: b.opts.contextPathHash,
|
||||||
DialMeta: lno.dialMeta,
|
DialMeta: lno.dialMeta,
|
||||||
|
|||||||
@@ -45,3 +45,11 @@ Extended build capabilities with BuildKit
|
|||||||
### <a name="builder"></a> Override the configured builder instance (--builder)
|
### <a name="builder"></a> Override the configured builder instance (--builder)
|
||||||
|
|
||||||
You can also use the `BUILDX_BUILDER` environment variable.
|
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.
|
||||||
|
|||||||
@@ -173,6 +173,18 @@ documentation for the specific driver:
|
|||||||
* [`kubernetes` driver](https://docs.docker.com/build/builders/drivers/kubernetes/)
|
* [`kubernetes` driver](https://docs.docker.com/build/builders/drivers/kubernetes/)
|
||||||
* [`remote` driver](https://docs.docker.com/build/builders/drivers/remote/)
|
* [`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)
|
### <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
|
The `--leave` flag changes the action of the command to remove a node from a
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package bkimage
|
package bkimage
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultImage = "moby/buildkit:buildx-stable-1" // TODO: make this verified
|
DefaultImage = "moby/buildkit:buildx-stable-1"
|
||||||
QemuImage = "tonistiigi/binfmt:latest" // TODO: make this verified
|
QemuImage = "tonistiigi/binfmt:latest" // TODO: make this verified
|
||||||
DefaultRootlessImage = DefaultImage + "-rootless"
|
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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/containerd/platforms"
|
||||||
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/buildx/driver"
|
"github.com/docker/buildx/driver"
|
||||||
"github.com/docker/buildx/driver/bkimage"
|
"github.com/docker/buildx/driver/bkimage"
|
||||||
"github.com/docker/buildx/util/confutil"
|
"github.com/docker/buildx/util/confutil"
|
||||||
"github.com/docker/buildx/util/ghutil"
|
"github.com/docker/buildx/util/ghutil"
|
||||||
"github.com/docker/buildx/util/imagetools"
|
"github.com/docker/buildx/util/imagetools"
|
||||||
"github.com/docker/buildx/util/progress"
|
"github.com/docker/buildx/util/progress"
|
||||||
|
"github.com/docker/buildx/util/sourcemeta"
|
||||||
"github.com/docker/cli/cli/context/docker"
|
"github.com/docker/cli/cli/context/docker"
|
||||||
contextstore "github.com/docker/cli/cli/context/store"
|
contextstore "github.com/docker/cli/cli/context/store"
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
@@ -29,6 +32,7 @@ import (
|
|||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
dockerclient "github.com/moby/moby/client"
|
dockerclient "github.com/moby/moby/client"
|
||||||
"github.com/moby/moby/client/pkg/security"
|
"github.com/moby/moby/client/pkg/security"
|
||||||
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,21 +47,22 @@ type Driver struct {
|
|||||||
|
|
||||||
// if you add fields, remember to update docs:
|
// if you add fields, remember to update docs:
|
||||||
// https://github.com/docker/docs/blob/main/content/build/drivers/docker-container.md
|
// https://github.com/docker/docs/blob/main/content/build/drivers/docker-container.md
|
||||||
netMode string
|
netMode string
|
||||||
image string
|
image string
|
||||||
memory opts.MemBytes
|
allowUntrustedImage bool
|
||||||
memorySwap opts.MemSwapBytes
|
memory opts.MemBytes
|
||||||
cpuQuota int64
|
memorySwap opts.MemSwapBytes
|
||||||
cpuPeriod int64
|
cpuQuota int64
|
||||||
cpuShares int64
|
cpuPeriod int64
|
||||||
cpusetCpus string
|
cpuShares int64
|
||||||
cpusetMems string
|
cpusetCpus string
|
||||||
cgroupParent string
|
cpusetMems string
|
||||||
restartPolicy container.RestartPolicy
|
cgroupParent string
|
||||||
env []string
|
restartPolicy container.RestartPolicy
|
||||||
defaultLoad bool
|
env []string
|
||||||
gpus []container.DeviceRequest
|
defaultLoad bool
|
||||||
writeProvenanceGHA bool
|
gpus []container.DeviceRequest
|
||||||
|
writeProvenanceGHA bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) IsMobyDriver() bool {
|
func (d *Driver) IsMobyDriver() bool {
|
||||||
@@ -92,27 +97,59 @@ func (d *Driver) create(ctx context.Context, l progress.SubLogger) error {
|
|||||||
imageName = d.image
|
imageName = d.image
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.Wrap("pulling image "+imageName, func() error {
|
imageRef := imageName
|
||||||
ra, err := imagetools.RegistryAuthForRef(imageName, d.Auth)
|
pullErr := l.Wrap("pulling image "+imageRef, func() error {
|
||||||
|
ra, err := imagetools.RegistryAuthForRef(imageRef, d.Auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp, err := d.DockerAPI.ImagePull(ctx, imageName, dockerclient.ImagePullOptions{
|
resp, err := d.DockerAPI.ImagePull(ctx, imageRef, dockerclient.ImagePullOptions{
|
||||||
RegistryAuth: ra,
|
RegistryAuth: ra,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return resp.Wait(ctx)
|
return resp.Wait(ctx)
|
||||||
}); err != nil {
|
})
|
||||||
// image pulling failed, check if it exists in local image store.
|
image, inspectErr := d.DockerAPI.ImageInspect(ctx, imageRef)
|
||||||
// if not, return pulling error. otherwise log it.
|
if inspectErr != nil {
|
||||||
_, errInspect := d.DockerAPI.ImageInspect(ctx, imageName)
|
if pullErr != nil {
|
||||||
found := errInspect == nil
|
return pullErr
|
||||||
if !found {
|
}
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
l.Wrap("pulling failed, using local image "+imageName, func() error { return nil })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &container.Config{
|
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 {
|
func (d *Driver) wait(ctx context.Context, l progress.SubLogger) error {
|
||||||
try := 1
|
try := 1
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ func (f *factory) New(ctx context.Context, cfg driver.InitConfig) (driver.Driver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
case k == "allow-untrusted-image":
|
||||||
|
d.allowUntrustedImage, err = strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, errors.Errorf("invalid driver option %s for docker-container driver", k)
|
return nil, errors.Errorf("invalid driver option %s for docker-container driver", k)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/docker/buildx/policy"
|
||||||
"github.com/docker/cli/cli/context/store"
|
"github.com/docker/cli/cli/context/store"
|
||||||
"github.com/moby/buildkit/client"
|
"github.com/moby/buildkit/client"
|
||||||
"github.com/moby/buildkit/session/auth/authprovider"
|
"github.com/moby/buildkit/session/auth/authprovider"
|
||||||
"github.com/moby/buildkit/util/tracing/delegated"
|
"github.com/moby/buildkit/util/tracing/delegated"
|
||||||
dockerclient "github.com/moby/moby/client"
|
dockerclient "github.com/moby/moby/client"
|
||||||
|
digest "github.com/opencontainers/go-digest"
|
||||||
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@@ -27,6 +29,15 @@ type BuildkitConfig struct {
|
|||||||
// Rootless bool
|
// 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 {
|
type InitConfig struct {
|
||||||
Name string
|
Name string
|
||||||
EndpointAddr string
|
EndpointAddr string
|
||||||
@@ -36,6 +47,7 @@ type InitConfig struct {
|
|||||||
Files map[string][]byte
|
Files map[string][]byte
|
||||||
DriverOpts map[string]string
|
DriverOpts map[string]string
|
||||||
Auth authprovider.AuthConfigProvider
|
Auth authprovider.AuthConfigProvider
|
||||||
|
ImageVerifier ImageVerifier
|
||||||
Platforms []ocispecs.Platform
|
Platforms []ocispecs.Platform
|
||||||
ContextPathHash string
|
ContextPathHash string
|
||||||
DialMeta map[string][]string
|
DialMeta map[string][]string
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package policy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultPolicyFilename is the synthetic filename used for the embedded
|
// DefaultPolicyFilename is the synthetic filename used for the embedded
|
||||||
@@ -15,3 +18,28 @@ var defaultPolicyModule []byte
|
|||||||
func DefaultPolicyData() []byte {
|
func DefaultPolicyData() []byte {
|
||||||
return defaultPolicyModule
|
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
@@ -1,18 +1,26 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
# Default policy embedded in Buildx. It verifies trust for images shipped
|
# 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
|
||||||
# - docker/dockerfile-upstream
|
# - docker/dockerfile-upstream
|
||||||
# - docker/buildkit-syft-scanner
|
# - docker/buildkit-syft-scanner
|
||||||
|
# - moby/buildkit
|
||||||
#
|
#
|
||||||
# Any image outside this managed set is allowed and passes through to user
|
# Any image outside this managed set is allowed and passes through to user
|
||||||
# policies unchanged. Access by digest is always allowed. For tag-based
|
# policies unchanged. Access by digest is always allowed. For tag-based
|
||||||
# access the rules below enforce a signed release from the expected GitHub
|
# access the rules below enforce a signed release from the expected GitHub
|
||||||
# source repository using the existing docker_github_builder_signature
|
# source repository using the existing docker_github_builder_signature
|
||||||
# helper from builtins.rego.
|
# 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 {
|
is_dockerfile if {
|
||||||
input.image
|
input.image
|
||||||
input.image.fullRepo == "docker.io/docker/dockerfile"
|
input.image.fullRepo == "docker.io/docker/dockerfile"
|
||||||
@@ -28,6 +36,11 @@ is_syft_scanner if {
|
|||||||
input.image.fullRepo == "docker.io/docker/buildkit-syft-scanner"
|
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 == "latest"
|
||||||
dockerfile_floating_tag(tag) if tag == "labs"
|
dockerfile_floating_tag(tag) if tag == "labs"
|
||||||
dockerfile_floating_tag(tag) if tag == "master"
|
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 syft_scanner_floating_tag(tag)
|
||||||
syft_scanner_tag_requires_sig(tag) if version_tag_ge(tag, 1, 10)
|
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 {
|
default_policy_deny_msgs contains msg if {
|
||||||
is_dockerfile
|
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])
|
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_sig_ok(tag) if {
|
||||||
dockerfile_floating_tag(tag)
|
dockerfile_floating_tag(tag)
|
||||||
some sig in input.image.signatures
|
some sig in input.image.signatures
|
||||||
|
|||||||
+148
-5
@@ -33,11 +33,7 @@ func makeDefaultPolicy(t *testing.T, sigInfo *policytypes.SignatureInfo) *Policy
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NewPolicy(Opt{
|
return DefaultPolicy(Opt{
|
||||||
Files: []File{{
|
|
||||||
Filename: DefaultPolicyFilename,
|
|
||||||
Data: DefaultPolicyData(),
|
|
||||||
}},
|
|
||||||
Log: func(level logrus.Level, msg string) {
|
Log: func(level logrus.Level, msg string) {
|
||||||
t.Logf("[%s] %s", level, msg)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/buildx/driver"
|
"github.com/docker/buildx/driver"
|
||||||
|
"github.com/docker/buildx/driver/bkimage"
|
||||||
"github.com/moby/buildkit/identity"
|
"github.com/moby/buildkit/identity"
|
||||||
"github.com/moby/buildkit/util/testutil/integration"
|
"github.com/moby/buildkit/util/testutil/integration"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -25,6 +26,7 @@ var createTests = []func(t *testing.T, sb integration.Sandbox){
|
|||||||
testCreateRestartAlways,
|
testCreateRestartAlways,
|
||||||
testCreateRemoteContainer,
|
testCreateRemoteContainer,
|
||||||
testCreateWithProvenanceGHA,
|
testCreateWithProvenanceGHA,
|
||||||
|
testCreateCustomImageWithDefaultPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCreateMemoryLimit(t *testing.T, sb integration.Sandbox) {
|
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")
|
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) {
|
func testCreateWithProvenanceGHA(t *testing.T, sb integration.Sandbox) {
|
||||||
if !isDockerContainerWorker(sb) {
|
if !isDockerContainerWorker(sb) {
|
||||||
t.Skip("only testing with docker-container worker")
|
t.Skip("only testing with docker-container worker")
|
||||||
|
|||||||
Reference in New Issue
Block a user