From a3019c113dd859a6c7f6125b787af35a3369285c Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 10 Aug 2026 15:46:15 +0300 Subject: [PATCH 1/2] bake: enforce entitlements for rawjson progress Reject rawjson bake builds when required entitlements have not been granted. Signed-off-by: Tonis Tiigi (cherry picked from commit 8e78175d68ccc0d76c5294649527839b24e90510) (cherry picked from commit 7b6b630925963290012f13872288124b00a9d96b) --- bake/entitlements.go | 21 +++++++++++++-- bake/entitlements_test.go | 56 +++++++++++++++++++++++++++++++++++++++ commands/bake.go | 4 +-- tests/bake.go | 43 ++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/bake/entitlements.go b/bake/entitlements.go index b42ba1ab7bb9..c23cc85194aa 100644 --- a/bake/entitlements.go +++ b/bake/entitlements.go @@ -231,9 +231,19 @@ func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) erro } func (c EntitlementConf) Prompt(ctx context.Context, isRemote bool, out io.Writer) error { + return c.prompt(ctx, isRemote, out, true) +} + +func (c EntitlementConf) Check(isRemote bool) error { + return c.prompt(context.Background(), isRemote, io.Discard, false) +} + +func (c EntitlementConf) prompt(ctx context.Context, isRemote bool, out io.Writer, interactive bool) error { var term bool - if _, err := console.ConsoleFromFile(os.Stdin); err == nil { - term = true + if interactive { + if _, err := console.ConsoleFromFile(os.Stdin); err == nil { + term = true + } } var msgs []string @@ -365,6 +375,13 @@ func (c EntitlementConf) Prompt(ctx context.Context, isRemote bool, out io.Write if fsEntitlementsEnabled && !fsEntitlementsSet && len(msgsFS) != 0 { fmt.Fprintf(out, "To disable filesystem entitlements checks, you can set BUILDX_BAKE_ENTITLEMENTS_FS=0 .\n\n") } + if !interactive { + requiredFlags := flags + if fsEntitlementsEnabled { + requiredFlags = slices.Concat(requiredFlags, flagsFS) + } + return errors.Errorf("additional privileges requested: pass %q to grant requested privileges", strings.Join(requiredFlags, " ")) + } if term { fmt.Fprintf(out, "Do you want to grant requested privileges and continue? [y/N] ") diff --git a/bake/entitlements_test.go b/bake/entitlements_test.go index d1f67fe43362..11561547343f 100644 --- a/bake/entitlements_test.go +++ b/bake/entitlements_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/docker/buildx/build" @@ -441,6 +442,61 @@ func TestPromptLocalOutputDeleteCannotBeDisabledWithFSEntitlements(t *testing.T) require.Contains(t, out.String(), "--allow=buildx.local.delete") } +func TestCheckEntitlements(t *testing.T) { + t.Run("all entitlement types", func(t *testing.T) { + t.Setenv("BUILDX_BAKE_ENTITLEMENTS_FS", "1") + + err := EntitlementConf{ + NetworkHost: true, + SecurityInsecure: true, + Devices: &EntitlementsDevicesConf{ + Devices: map[string]struct{}{"vendor.com/device=foo": {}}, + }, + FSRead: []string{t.TempDir()}, + FSWrite: []string{t.TempDir()}, + SSH: true, + LocalOutputDelete: true, + }.Check(false) + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), `additional privileges requested: pass "`), err.Error()) + for _, flag := range []string{ + "--allow=network.host", + "--allow=security.insecure", + "--allow=device=vendor.com/device=foo", + "--allow=fs.read=", + "--allow=fs.write=", + "--allow=ssh", + "--allow=buildx.local.delete", + } { + require.ErrorContains(t, err, flag) + } + }) + + t.Run("filesystem checks disabled", func(t *testing.T) { + t.Setenv("BUILDX_BAKE_ENTITLEMENTS_FS", "0") + + err := EntitlementConf{ + FSRead: []string{t.TempDir()}, + FSWrite: []string{t.TempDir()}, + SSH: true, + }.Check(false) + require.NoError(t, err) + }) + + t.Run("filesystem setting does not disable other checks", func(t *testing.T) { + t.Setenv("BUILDX_BAKE_ENTITLEMENTS_FS", "0") + + err := EntitlementConf{ + NetworkHost: true, + FSRead: []string{t.TempDir()}, + SSH: true, + }.Check(false) + require.ErrorContains(t, err, "--allow=network.host") + require.NotContains(t, err.Error(), "--allow=fs.read=") + require.NotContains(t, err.Error(), "--allow=ssh") + }) +} + func TestGroupSamePaths(t *testing.T) { tests := []struct { name string diff --git a/commands/bake.go b/commands/bake.go index 906825327840..5f2381deb3f3 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -351,8 +351,8 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba return err } if progressMode == progressui.RawJSONMode { - if exp.LocalOutputDelete { - return errors.Errorf("additional privileges requested: pass %q to grant requested privileges", "--allow="+string(bake.EntitlementKeyBuildxLocalDelete)) + if err := exp.Check(url != ""); err != nil { + return err } } else { if err := exp.Prompt(ctx, url != "", &syncWriter{w: dockerCli.Err(), wait: printer.Wait}); err != nil { diff --git a/tests/bake.go b/tests/bake.go index f44da2b88d05..2e664eab89e6 100644 --- a/tests/bake.go +++ b/tests/bake.go @@ -78,6 +78,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){ testBakeDefinitionNotExistingSubdirNoParallel, testBakeDefinitionNotExistingOutsideNoParallel, testBakeDefinitionExistingOutsideNoParallel, + testBakeRawJSONEntitlementsNoParallel, testBakeDefinitionSymlinkOutsideNoParallel, testBakeDefinitionSymlinkOutsideGrantedNoParallel, testBakeSSHPathNoParallel, @@ -2151,6 +2152,48 @@ target "default" { } } +func testBakeRawJSONEntitlementsNoParallel(t *testing.T, sb integration.Sandbox) { + t.Setenv("BUILDX_BAKE_ENTITLEMENTS_FS", "1") + dockerfile := []byte(` +FROM scratch +COPY foo /foo + `) + dirSrc := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + dirDest := t.TempDir() + bakefile := fmt.Appendf(nil, ` +target "default" { + context = %q + output = ["type=local,dest=%s"] +} +`, dirSrc, dirDest) + dirSpec := tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", bakefile, 0600), + ) + + cmd := buildxCmd(sb, withDir(dirSpec), withArgs("bake", "--progress=rawjson")) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "additional privileges requested") + require.Contains(t, string(out), "--allow=fs.read=") + require.Contains(t, string(out), "--allow=fs.write=") + require.NoFileExists(t, filepath.Join(dirDest, "foo")) + + cmd = buildxCmd(sb, withDir(dirSpec), withArgs( + "bake", + "--progress=rawjson", + "--allow=fs.read="+dirSrc, + "--allow=fs.write="+dirDest, + )) + out, err = cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.FileExists(t, filepath.Join(dirDest, "foo")) +} + func testBakeDefinitionSymlinkOutsideNoParallel(t *testing.T, sb integration.Sandbox) { for _, ent := range []bool{true, false} { t.Run(fmt.Sprintf("ent=%v", ent), func(t *testing.T) { From ffbd970ac6af802699d706bb9b99b3f35c3fd0cb Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 29 Sep 2026 23:06:33 -0700 Subject: [PATCH 2/2] bake: require fs.read for implicit secret files and oci-layout contexts A secret with only an id falls back to reading the file named by the id when no environment variable with that name exists, but the entitlement check only looked at an explicit src. Resolve the source the same way BuildKit does so the implicit file read needs fs.read permission. Named contexts using oci-layout:// were treated as relative paths and resolved under the working directory, so any layout directory was allowed. Parse the reference and check the actual layout path instead. Signed-off-by: Tonis Tiigi (cherry picked from commit f7979d8a5b2e363129ef9850950cce0c5c87c031) (cherry picked from commit 69a8e58c7dee280c07caafbed5ff838ec259dd55) --- bake/bake.go | 12 +++++-- bake/entitlements.go | 11 +++--- bake/entitlements_test.go | 71 ++++++++++++++++++++++++++++++++++++++ util/buildflags/secrets.go | 15 ++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/bake/bake.go b/bake/bake.go index b626186712ba..9ae0f011adc7 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -23,6 +23,7 @@ import ( "github.com/docker/buildx/bake/hclparser" "github.com/docker/buildx/build" "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/ocilayout" "github.com/docker/buildx/util/osutil" "github.com/docker/buildx/util/platformutil" "github.com/docker/buildx/util/progress" @@ -1671,7 +1672,7 @@ func remoteURLWithSubdir(remoteURL, subdir string) string { return base + "#" + ref + ":" + subdir } -func collectLocalPaths(t build.Inputs) []string { +func collectLocalPaths(t build.Inputs) ([]string, error) { var out []string if t.ContextState == nil { if v, ok := isLocalPath(t.ContextPath); ok { @@ -1687,11 +1688,18 @@ func collectLocalPaths(t build.Inputs) []string { if v.State != nil { continue } + if ref, ok, err := ocilayout.Parse(v.Path); ok { + if err != nil { + return nil, errors.Wrapf(err, "invalid OCI layout context %q", v.Path) + } + out = append(out, ref.Path) + continue + } if v, ok := isLocalPath(v.Path); ok { out = append(out, v) } } - return out + return out, nil } func isLocalPath(p string) (string, bool) { diff --git a/bake/entitlements.go b/bake/entitlements.go index c23cc85194aa..43ad6c82f84b 100644 --- a/bake/entitlements.go +++ b/bake/entitlements.go @@ -162,7 +162,11 @@ func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) erro rwPaths := map[string]struct{}{} roPaths := map[string]struct{}{} - for _, p := range collectLocalPaths(bo.Inputs) { + localPaths, err := collectLocalPaths(bo.Inputs) + if err != nil { + return err + } + for _, p := range localPaths { roPaths[p] = struct{}{} } @@ -200,8 +204,8 @@ func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) erro } for _, secret := range bo.SecretSpecs { - if secret.FilePath != "" { - roPaths[secret.FilePath] = struct{}{} + if filePath := secret.ResolveSource().FilePath; filePath != "" { + roPaths[filePath] = struct{}{} } } @@ -216,7 +220,6 @@ func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) erro } } - var err error expected.FSRead, err = findMissingPaths(c.FSRead, roPaths) if err != nil { return err diff --git a/bake/entitlements_test.go b/bake/entitlements_test.go index 11561547343f..c96795a98939 100644 --- a/bake/entitlements_test.go +++ b/bake/entitlements_test.go @@ -108,6 +108,13 @@ func TestValidateEntitlements(t *testing.T) { require.NoError(t, err) expWd, err := filepath.EvalSymlinks(wd) require.NoError(t, err) + credentialPath := filepath.Join(dir1, "credential") + require.NoError(t, os.WriteFile(credentialPath, []byte("test credential"), 0600)) + expCredentialPath, err := filepath.EvalSymlinks(credentialPath) + require.NoError(t, err) + layoutLink := filepath.Join(dir1, "layout-link") + require.NoError(t, os.Symlink(dir2, layoutLink)) + t.Setenv("BUILDX_TEST_SECRET_SOURCE", "test credential") tcases := []struct { name string @@ -224,6 +231,70 @@ func TestValidateEntitlements(t *testing.T) { FSRead: []string{wd, dir1}, }, }, + { + name: "secret-id-file-fallback-requires-read", + opt: build.Options{ + SecretSpecs: []*buildflags.Secret{{ID: credentialPath}}, + }, + conf: EntitlementConf{FSRead: []string{wd}}, + expected: EntitlementConf{ + FSRead: []string{expCredentialPath}, + }, + }, + { + name: "secret-id-file-fallback-allowed", + opt: build.Options{ + SecretSpecs: []*buildflags.Secret{{ID: credentialPath}}, + }, + conf: EntitlementConf{FSRead: []string{wd, dir1}}, + }, + { + name: "secret-id-env-fallback-needs-no-read", + opt: build.Options{ + SecretSpecs: []*buildflags.Secret{{ID: "BUILDX_TEST_SECRET_SOURCE"}}, + }, + conf: EntitlementConf{FSRead: []string{wd}}, + }, + { + name: "explicit-secret-env-needs-no-read", + opt: build.Options{ + SecretSpecs: []*buildflags.Secret{{ID: credentialPath, Env: "BUILDX_TEST_SECRET_SOURCE"}}, + }, + conf: EntitlementConf{FSRead: []string{wd}}, + }, + { + name: "oci-layout-named-context-requires-read", + opt: build.Options{ + Inputs: build.Inputs{NamedContexts: map[string]build.NamedContext{ + "layout": {Path: "oci-layout://" + dir1 + ":latest"}, + }}, + }, + conf: EntitlementConf{FSRead: []string{wd}}, + expected: EntitlementConf{ + FSRead: []string{expDir1}, + }, + }, + { + name: "oci-layout-named-context-allowed", + opt: build.Options{ + Inputs: build.Inputs{NamedContexts: map[string]build.NamedContext{ + "layout": {Path: "oci-layout://" + dir1 + ":latest"}, + }}, + }, + conf: EntitlementConf{FSRead: []string{wd, dir1}}, + }, + { + name: "oci-layout-named-context-symlink-requires-destination", + opt: build.Options{ + Inputs: build.Inputs{NamedContexts: map[string]build.NamedContext{ + "layout": {Path: "oci-layout://" + layoutLink + ":latest"}, + }}, + }, + conf: EntitlementConf{FSRead: []string{wd, dir1}}, + expected: EntitlementConf{ + FSRead: []string{expDir2}, + }, + }, { name: "SecretFromEscapeLink", opt: build.Options{ diff --git a/util/buildflags/secrets.go b/util/buildflags/secrets.go index 994fc416a129..4ce97e6d86dc 100644 --- a/util/buildflags/secrets.go +++ b/util/buildflags/secrets.go @@ -2,6 +2,7 @@ package buildflags import ( "encoding/json" + "os" "strings" "github.com/pkg/errors" @@ -35,6 +36,20 @@ type Secret struct { Env string `json:"env,omitempty"` } +// ResolveSource returns a copy with the source selected using BuildKit's +// default secret source rules. +func (s *Secret) ResolveSource() *Secret { + resolved := *s + if resolved.Env == "" && resolved.FilePath == "" { + if _, ok := os.LookupEnv(resolved.ID); ok { + resolved.Env = resolved.ID + } else { + resolved.FilePath = resolved.ID + } + } + return &resolved +} + func (s *Secret) Equal(other *Secret) bool { return s.ID == other.ID && s.FilePath == other.FilePath && s.Env == other.Env }