From 203fd8aee530a3965bb3a9a748b8c48327bc78b8 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Fri, 23 Aug 2024 16:36:23 +0300 Subject: [PATCH 1/2] bake: enable support for entitlements Add support for security.insecure and network.host entitlements via bake. User needs to confirm elevated privileges through a prompt or CLI flags. Signed-off-by: Tonis Tiigi --- bake/bake.go | 19 +++- bake/bake_test.go | 68 +++++++++++++ bake/entitlements.go | 176 ++++++++++++++++++++++++++++++++++ commands/bake.go | 61 ++++++++++-- docs/reference/buildx_bake.md | 1 + util/progress/printer.go | 9 ++ 6 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 bake/entitlements.go diff --git a/bake/bake.go b/bake/bake.go index eb1e5d5851b..fc707131d81 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -25,6 +25,7 @@ import ( "github.com/moby/buildkit/client" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/session/auth/authprovider" + "github.com/moby/buildkit/util/entitlements" "github.com/pkg/errors" "github.com/tonistiigi/go-csvvalue" "github.com/zclconf/go-cty/cty" @@ -542,7 +543,7 @@ func (c Config) newOverrides(v []string) (map[string]map[string]Override, error) o := t[kk[1]] switch keys[1] { - case "output", "cache-to", "cache-from", "tags", "platform", "secrets", "ssh", "attest": + case "output", "cache-to", "cache-from", "tags", "platform", "secrets", "ssh", "attest", "entitlements": if len(parts) == 2 { o.ArrValue = append(o.ArrValue, parts[1]) } @@ -708,6 +709,7 @@ type Target struct { ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional"` Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional"` Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"` + Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"` // IMPORTANT: if you add more fields here, do not forget to update newOverrides/AddOverrides and docs/bake-reference.md. // linked is a private field to mark a target used as a linked one @@ -732,6 +734,12 @@ func (t *Target) normalize() { t.NoCacheFilter = removeDupes(t.NoCacheFilter) t.Ulimits = removeDupes(t.Ulimits) + if t.NetworkMode != nil && *t.NetworkMode == "host" { + t.Entitlements = append(t.Entitlements, "network.host") + } + + t.Entitlements = removeDupes(t.Entitlements) + for k, v := range t.Contexts { if v == "" { delete(t.Contexts, k) @@ -831,6 +839,9 @@ func (t *Target) Merge(t2 *Target) { if t2.Description != "" { t.Description = t2.Description } + if t2.Entitlements != nil { // merge + t.Entitlements = append(t.Entitlements, t2.Entitlements...) + } t.Inherits = append(t.Inherits, t2.Inherits...) } @@ -885,6 +896,8 @@ func (t *Target) AddOverrides(overrides map[string]Override) error { t.Platforms = o.ArrValue case "output": t.Outputs = o.ArrValue + case "entitlements": + t.Entitlements = append(t.Entitlements, o.ArrValue...) case "annotations": t.Annotations = append(t.Annotations, o.ArrValue...) case "attest": @@ -1368,6 +1381,10 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { } bo.Ulimits = ulimits + for _, ent := range t.Entitlements { + bo.Allow = append(bo.Allow, entitlements.Entitlement(ent)) + } + return bo, nil } diff --git a/bake/bake_test.go b/bake/bake_test.go index db9f7ae2e59..185b528556d 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/moby/buildkit/util/entitlements" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1726,3 +1727,70 @@ func TestAnnotations(t *testing.T) { require.Len(t, bo["app"].Exports, 1) require.Equal(t, "bar", bo["app"].Exports[0].Attrs["annotation-manifest[linux/amd64].foo"]) } + +func TestHCLEntitlements(t *testing.T) { + fp := File{ + Name: "docker-bake.hcl", + Data: []byte( + `target "app" { + entitlements = ["security.insecure", "network.host"] + }`), + } + ctx := context.TODO() + m, g, err := ReadTargets(ctx, []File{fp}, []string{"app"}, nil, nil) + require.NoError(t, err) + + bo, err := TargetsToBuildOpt(m, &Input{}) + require.NoError(t, err) + + require.Equal(t, 1, len(g)) + require.Equal(t, []string{"app"}, g["default"].Targets) + + require.Equal(t, 1, len(m)) + require.Contains(t, m, "app") + require.Len(t, m["app"].Entitlements, 2) + require.Equal(t, "security.insecure", m["app"].Entitlements[0]) + require.Equal(t, "network.host", m["app"].Entitlements[1]) + + require.Len(t, bo["app"].Allow, 2) + require.Equal(t, entitlements.EntitlementSecurityInsecure, bo["app"].Allow[0]) + require.Equal(t, entitlements.EntitlementNetworkHost, bo["app"].Allow[1]) +} + +func TestEntitlementsForNetHost(t *testing.T) { + fp := File{ + Name: "docker-bake.hcl", + Data: []byte( + `target "app" { + dockerfile = "app.Dockerfile" + }`), + } + + fp2 := File{ + Name: "docker-compose.yml", + Data: []byte( + `services: + app: + build: + network: "host" +`), + } + + ctx := context.TODO() + m, g, err := ReadTargets(ctx, []File{fp, fp2}, []string{"app"}, nil, nil) + require.NoError(t, err) + + bo, err := TargetsToBuildOpt(m, &Input{}) + require.NoError(t, err) + + require.Equal(t, 1, len(g)) + require.Equal(t, []string{"app"}, g["default"].Targets) + + require.Equal(t, 1, len(m)) + require.Contains(t, m, "app") + require.Len(t, m["app"].Entitlements, 1) + require.Equal(t, "network.host", m["app"].Entitlements[0]) + + require.Len(t, bo["app"].Allow, 1) + require.Equal(t, entitlements.EntitlementNetworkHost, bo["app"].Allow[0]) +} diff --git a/bake/entitlements.go b/bake/entitlements.go new file mode 100644 index 00000000000..24add7033c2 --- /dev/null +++ b/bake/entitlements.go @@ -0,0 +1,176 @@ +package bake + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/containerd/console" + "github.com/docker/buildx/build" + "github.com/moby/buildkit/util/entitlements" + "github.com/pkg/errors" +) + +type EntitlementKey string + +const ( + EntitlementKeyNetworkHost EntitlementKey = "network.host" + EntitlementKeySecurityInsecure EntitlementKey = "security.insecure" + EntitlementKeyFSRead EntitlementKey = "fs.read" + EntitlementKeyFSWrite EntitlementKey = "fs.write" + EntitlementKeyFS EntitlementKey = "fs" + EntitlementKeyImagePush EntitlementKey = "image.push" + EntitlementKeyImageLoad EntitlementKey = "image.load" + EntitlementKeyImage EntitlementKey = "image" + EntitlementKeySSH EntitlementKey = "ssh" +) + +type EntitlementConf struct { + NetworkHost bool + SecurityInsecure bool + FSRead []string + FSWrite []string + ImagePush []string + ImageLoad []string + SSH bool +} + +func ParseEntitlements(in []string) (EntitlementConf, error) { + var conf EntitlementConf + for _, e := range in { + switch e { + case string(EntitlementKeyNetworkHost): + conf.NetworkHost = true + case string(EntitlementKeySecurityInsecure): + conf.SecurityInsecure = true + case string(EntitlementKeySSH): + conf.SSH = true + default: + k, v, _ := strings.Cut(e, "=") + switch k { + case string(EntitlementKeyFSRead): + conf.FSRead = append(conf.FSRead, v) + case string(EntitlementKeyFSWrite): + conf.FSWrite = append(conf.FSWrite, v) + case string(EntitlementKeyFS): + conf.FSRead = append(conf.FSRead, v) + conf.FSWrite = append(conf.FSWrite, v) + case string(EntitlementKeyImagePush): + conf.ImagePush = append(conf.ImagePush, v) + case string(EntitlementKeyImageLoad): + conf.ImageLoad = append(conf.ImageLoad, v) + case string(EntitlementKeyImage): + conf.ImagePush = append(conf.ImagePush, v) + conf.ImageLoad = append(conf.ImageLoad, v) + default: + return conf, errors.Errorf("uknown entitlement key %q", k) + } + + // TODO: dedupe slices and parent paths + } + } + return conf, nil +} + +func (c EntitlementConf) Validate(m map[string]build.Options) (EntitlementConf, error) { + var expected EntitlementConf + + for _, v := range m { + if err := c.check(v, &expected); err != nil { + return EntitlementConf{}, err + } + } + + return expected, nil +} + +func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) error { + for _, e := range bo.Allow { + switch e { + case entitlements.EntitlementNetworkHost: + if !c.NetworkHost { + expected.NetworkHost = true + } + case entitlements.EntitlementSecurityInsecure: + if !c.SecurityInsecure { + expected.SecurityInsecure = true + } + } + } + return nil +} + +func (c EntitlementConf) Prompt(ctx context.Context, out io.Writer) error { + var term bool + if _, err := console.ConsoleFromFile(os.Stdin); err == nil { + term = true + } + + var msgs []string + var flags []string + + if c.NetworkHost { + msgs = append(msgs, " - Running build containers that can access host network") + flags = append(flags, "network.host") + } + if c.SecurityInsecure { + msgs = append(msgs, " - Running privileged containers that can make system changes") + flags = append(flags, "security.insecure") + } + + if len(msgs) == 0 { + return nil + } + + fmt.Fprintf(out, "Your build is requesting privileges for following possibly insecure capabilities:\n\n") + for _, m := range msgs { + fmt.Fprintf(out, "%s\n", m) + } + + for i, f := range flags { + flags[i] = "--allow=" + f + } + + if term { + fmt.Fprintf(out, "\nIn order to not see this message in the future pass %q to grant requested privileges.\n", strings.Join(flags, " ")) + } else { + fmt.Fprintf(out, "\nPass %q to grant requested privileges.\n", strings.Join(flags, " ")) + } + + args := append([]string(nil), os.Args...) + if filepath.Base(args[0]) == "docker-buildx" { + args[0] = "docker" + } + idx := slices.Index(args, "bake") + + if idx != -1 { + fmt.Fprintf(out, "\nYour full command with requested privileges:\n\n") + fmt.Fprintf(out, "%s %s %s\n\n", strings.Join(args[:idx+1], " "), strings.Join(flags, " "), strings.Join(args[idx+1:], " ")) + } + + if term { + fmt.Fprintf(out, "Do you want to grant requested privileges and continue? [y/N] ") + reader := bufio.NewReader(os.Stdin) + answerCh := make(chan string, 1) + go func() { + answer, _, _ := reader.ReadLine() + answerCh <- string(answer) + close(answerCh) + }() + + select { + case <-ctx.Done(): + case answer := <-answerCh: + if strings.ToLower(string(answer)) == "y" { + return nil + } + } + } + + return errors.Errorf("additional privileges requested") +} diff --git a/commands/bake.go b/commands/bake.go index f1b7fa02217..bec1cadda07 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -49,6 +49,7 @@ type bakeOptions struct { listVars bool sbom string provenance string + allow []string builder string metadataFile string @@ -102,6 +103,11 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba } contextPathHash, _ := os.Getwd() + ent, err := bake.ParseEntitlements(in.allow) + if err != nil { + return err + } + ctx2, cancel := context.WithCancel(context.TODO()) defer cancel() @@ -138,14 +144,20 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba progressMode := progressui.DisplayMode(cFlags.progress) var printer *progress.Printer - printer, err = progress.NewPrinter(ctx2, os.Stderr, progressMode, - progress.WithDesc(progressTextDesc, progressConsoleDesc), - progress.WithMetrics(mp, attributes), - progress.WithOnClose(func() { - printWarnings(os.Stderr, printer.Warnings(), progressMode) - }), - ) - if err != nil { + + makePrinter := func() error { + var err error + printer, err = progress.NewPrinter(ctx2, os.Stderr, progressMode, + progress.WithDesc(progressTextDesc, progressConsoleDesc), + progress.WithMetrics(mp, attributes), + progress.WithOnClose(func() { + printWarnings(os.Stderr, printer.Warnings(), progressMode) + }), + ) + return err + } + + if err := makePrinter(); err != nil { return err } @@ -234,6 +246,20 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba } } + exp, err := ent.Validate(bo) + if err != nil { + return err + } + if err := exp.Prompt(ctx, &syncWriter{w: dockerCli.Err(), wait: printer.Wait}); err != nil { + return err + } + if printer.IsDone() { + // init new printer as old one was stopped to show the prompt + if err := makePrinter(); err != nil { + return err + } + } + if err := saveLocalStateGroup(dockerCli, in, targets, bo, overrides, def); err != nil { return err } @@ -412,6 +438,7 @@ func bakeCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command { flags.StringVar(&options.provenance, "provenance", "", `Shorthand for "--set=*.attest=type=provenance"`) flags.StringArrayVar(&options.overrides, "set", nil, `Override target value (e.g., "targetpattern.key=value")`) flags.StringVar(&options.callFunc, "call", "build", `Set method for evaluating build ("check", "outline", "targets")`) + flags.StringArrayVar(&options.allow, "allow", nil, "Allow build to access specified resources") flags.VarPF(callAlias(&options.callFunc, "check"), "check", "", `Shorthand for "--call=check"`) flags.Lookup("check").NoOptDefVal = "true" @@ -652,3 +679,21 @@ func immutableSort(s []string) []string { } return s } + +type syncWriter struct { + w io.Writer + once sync.Once + wait func() error +} + +func (w *syncWriter) Write(p []byte) (n int, err error) { + w.once.Do(func() { + if w.wait != nil { + err = w.wait() + } + }) + if err != nil { + return 0, err + } + return w.w.Write(p) +} diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md index 8e2245acefd..90f2c1d312d 100644 --- a/docs/reference/buildx_bake.md +++ b/docs/reference/buildx_bake.md @@ -15,6 +15,7 @@ Build from a file | Name | Type | Default | Description | |:------------------------------------|:--------------|:--------|:----------------------------------------------------------------------------------------------------| +| `--allow` | `stringArray` | | Allow build to access specified resources | | [`--builder`](#builder) | `string` | | Override the configured builder instance | | [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) | | [`--check`](#check) | `bool` | | Shorthand for `--call=check` | diff --git a/util/progress/printer.go b/util/progress/printer.go index 052019337a8..95fb680d008 100644 --- a/util/progress/printer.go +++ b/util/progress/printer.go @@ -44,6 +44,15 @@ func (p *Printer) Wait() error { return p.err } +func (p *Printer) IsDone() bool { + select { + case <-p.done: + return true + default: + return false + } +} + func (p *Printer) Pause() error { p.paused = make(chan struct{}) return p.Wait() From 5ecff53e0c481a4fc693740c324da5b079641654 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 3 Sep 2024 08:54:42 -0700 Subject: [PATCH 2/2] bake: read original command name from the env for prompt Signed-off-by: Tonis Tiigi --- bake/entitlements.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bake/entitlements.go b/bake/entitlements.go index 24add7033c2..030e812beda 100644 --- a/bake/entitlements.go +++ b/bake/entitlements.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "path/filepath" "slices" "strings" @@ -143,8 +142,8 @@ func (c EntitlementConf) Prompt(ctx context.Context, out io.Writer) error { } args := append([]string(nil), os.Args...) - if filepath.Base(args[0]) == "docker-buildx" { - args[0] = "docker" + if v, ok := os.LookupEnv("DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"); ok && v != "" { + args[0] = v } idx := slices.Index(args, "bake")