package bake import ( "bufio" "cmp" "context" "fmt" "io" "io/fs" "os" "path/filepath" "slices" "strconv" "strings" "syscall" "github.com/containerd/console" "github.com/docker/buildx/build" "github.com/docker/buildx/util/osutil" "github.com/moby/buildkit/util/entitlements" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tonistiigi/go-csvvalue" ) type EntitlementKey string const ( EntitlementKeyNetworkHost EntitlementKey = "network.host" EntitlementKeySecurityInsecure EntitlementKey = "security.insecure" EntitlementKeyDevice EntitlementKey = "device" 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 Devices *EntitlementsDevicesConf FSRead []string FSWrite []string ImagePush []string ImageLoad []string SSH bool } type EntitlementsDevicesConf struct { All bool Devices map[string]struct{} } 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(EntitlementKeyDevice): if v == "" { conf.Devices = &EntitlementsDevicesConf{All: true} continue } fields, err := csvvalue.Fields(v, nil) if err != nil { return EntitlementConf{}, errors.Wrapf(err, "failed to parse device entitlement %q", v) } if conf.Devices == nil { conf.Devices = &EntitlementsDevicesConf{} } if conf.Devices.Devices == nil { conf.Devices.Devices = make(map[string]struct{}, 0) } conf.Devices.Devices[fields[0]] = struct{}{} 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("unknown entitlement key %q", k) } } } 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 { k, rest, _ := strings.Cut(e, "=") switch k { case entitlements.EntitlementDevice.String(): if rest == "" { if c.Devices == nil || !c.Devices.All { expected.Devices = &EntitlementsDevicesConf{All: true} } continue } fields, err := csvvalue.Fields(rest, nil) if err != nil { return errors.Wrapf(err, "failed to parse device entitlement %q", rest) } if expected.Devices == nil { expected.Devices = &EntitlementsDevicesConf{} } if expected.Devices.Devices == nil { expected.Devices.Devices = make(map[string]struct{}, 0) } expected.Devices.Devices[fields[0]] = struct{}{} } switch e { case entitlements.EntitlementNetworkHost.String(): if !c.NetworkHost { expected.NetworkHost = true } case entitlements.EntitlementSecurityInsecure.String(): if !c.SecurityInsecure { expected.SecurityInsecure = true } } } rwPaths := map[string]struct{}{} roPaths := map[string]struct{}{} for _, p := range collectLocalPaths(bo.Inputs) { roPaths[p] = struct{}{} } for _, p := range bo.ExportsLocalPathsTemporary { rwPaths[p] = struct{}{} } for _, ce := range bo.CacheTo { if ce.Type == "local" { if dest, ok := ce.Attrs["dest"]; ok { rwPaths[dest] = struct{}{} } } } for _, ci := range bo.CacheFrom { if ci.Type == "local" { if src, ok := ci.Attrs["src"]; ok { roPaths[src] = struct{}{} } } } for _, secret := range bo.SecretSpecs { if secret.FilePath != "" { roPaths[secret.FilePath] = struct{}{} } } for _, ssh := range bo.SSHSpecs { for _, p := range ssh.Paths { roPaths[p] = struct{}{} } if len(ssh.Paths) == 0 { if !c.SSH { expected.SSH = true } } } var err error expected.FSRead, err = findMissingPaths(c.FSRead, roPaths) if err != nil { return err } expected.FSWrite, err = findMissingPaths(c.FSWrite, rwPaths) if err != nil { return err } return nil } func (c EntitlementConf) Prompt(ctx context.Context, isRemote bool, out io.Writer) error { var term bool if _, err := console.ConsoleFromFile(os.Stdin); err == nil { term = true } var msgs []string var flags []string // these warnings are currently disabled to give users time to update var msgsFS []string var flagsFS []string if c.NetworkHost { msgs = append(msgs, " - Running build containers that can access host network") flags = append(flags, string(EntitlementKeyNetworkHost)) } if c.SecurityInsecure { msgs = append(msgs, " - Running privileged containers that can make system changes") flags = append(flags, string(EntitlementKeySecurityInsecure)) } if c.Devices != nil { if c.Devices.All { msgs = append(msgs, " - Access to CDI devices") flags = append(flags, string(EntitlementKeyDevice)) } else { for d := range c.Devices.Devices { msgs = append(msgs, fmt.Sprintf(" - Access to device %s", d)) flags = append(flags, string(EntitlementKeyDevice)+"="+d) } } } if c.SSH { msgsFS = append(msgsFS, " - Forwarding default SSH agent socket") flagsFS = append(flagsFS, string(EntitlementKeySSH)) } roPaths, rwPaths, commonPaths := groupSamePaths(c.FSRead, c.FSWrite) wd, err := os.Getwd() if err != nil { return errors.Wrap(err, "failed to get current working directory") } wd, err = filepath.EvalSymlinks(wd) if err != nil { return errors.Wrap(err, "failed to evaluate working directory") } roPaths = toRelativePaths(roPaths, wd) rwPaths = toRelativePaths(rwPaths, wd) commonPaths = toRelativePaths(commonPaths, wd) if len(commonPaths) > 0 { for _, p := range commonPaths { msgsFS = append(msgsFS, fmt.Sprintf(" - Read and write access to path %s", p)) flagsFS = append(flagsFS, string(EntitlementKeyFS)+"="+p) } } if len(roPaths) > 0 { for _, p := range roPaths { msgsFS = append(msgsFS, fmt.Sprintf(" - Read access to path %s", p)) flagsFS = append(flagsFS, string(EntitlementKeyFSRead)+"="+p) } } if len(rwPaths) > 0 { for _, p := range rwPaths { msgsFS = append(msgsFS, fmt.Sprintf(" - Write access to path %s", p)) flagsFS = append(flagsFS, string(EntitlementKeyFSWrite)+"="+p) } } if len(msgs) == 0 && len(msgsFS) == 0 { return nil } fmt.Fprintf(out, "Your build is requesting privileges for following possibly insecure capabilities:\n\n") for _, m := range slices.Concat(msgs, msgsFS) { fmt.Fprintf(out, "%s\n", m) } for i, f := range flags { flags[i] = "--allow=" + f } for i, f := range flagsFS { flagsFS[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(slices.Concat(flags, flagsFS), " ")) } else { fmt.Fprintf(out, "\nPass %q to grant requested privileges.\n", strings.Join(slices.Concat(flags, flagsFS), " ")) } args := slices.Clone(os.Args) if v, ok := os.LookupEnv("DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"); ok && v != "" { args[0] = v } 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(slices.Concat(flags, flagsFS), " "), strings.Join(args[idx+1:], " ")) } fsEntitlementsEnabled := true if isRemote { if v, ok := os.LookupEnv("BAKE_ALLOW_REMOTE_FS_ACCESS"); ok { vv, err := strconv.ParseBool(v) if err != nil { return errors.Wrapf(err, "failed to parse BAKE_ALLOW_REMOTE_FS_ACCESS value %q", v) } fsEntitlementsEnabled = !vv } } v, fsEntitlementsSet := os.LookupEnv("BUILDX_BAKE_ENTITLEMENTS_FS") if fsEntitlementsSet { vv, err := strconv.ParseBool(v) if err != nil { return errors.Wrapf(err, "failed to parse BUILDX_BAKE_ENTITLEMENTS_FS value %q", v) } fsEntitlementsEnabled = vv } if !fsEntitlementsEnabled && len(msgs) == 0 { return nil } 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 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") } func isParentOrEqualPath(p, parent string) bool { if p == parent || parent == "/" { return true } if strings.HasPrefix(p, filepath.Clean(parent+string(filepath.Separator))) { return true } return false } func findMissingPaths(set []string, paths map[string]struct{}) ([]string, error) { set, allowAny, err := evaluatePaths(set) if err != nil { return nil, err } else if allowAny { return nil, nil } paths, err = evaluateToExistingPaths(paths) if err != nil { return nil, err } paths, err = dedupPaths(paths) if err != nil { return nil, err } out := make([]string, 0, len(paths)) loop0: for p := range paths { for _, c := range set { if isParentOrEqualPath(p, c) { continue loop0 } } out = append(out, p) } if len(out) == 0 { return nil, nil } slices.Sort(out) return out, nil } func dedupPaths(in map[string]struct{}) (map[string]struct{}, error) { arr := make([]string, 0, len(in)) for p := range in { arr = append(arr, filepath.Clean(p)) } slices.SortFunc(arr, func(a, b string) int { return cmp.Compare(len(a), len(b)) }) m := make(map[string]struct{}, len(arr)) loop0: for _, p := range arr { for parent := range m { if strings.HasPrefix(p, parent+string(filepath.Separator)) { continue loop0 } } m[p] = struct{}{} } return m, nil } func toRelativePaths(in []string, wd string) []string { out := make([]string, 0, len(in)) for _, p := range in { rel, err := filepath.Rel(wd, p) if err == nil { // allow up to one level of ".." in the path if !strings.HasPrefix(rel, ".."+string(filepath.Separator)+"..") { out = append(out, rel) continue } } out = append(out, p) } return out } func groupSamePaths(in1, in2 []string) ([]string, []string, []string) { if in1 == nil || in2 == nil { return in1, in2, nil } slices.Sort(in1) slices.Sort(in2) common := []string{} i, j := 0, 0 for i < len(in1) && j < len(in2) { switch { case in1[i] == in2[j]: common = append(common, in1[i]) i++ j++ case in1[i] < in2[j]: i++ default: j++ } } in1 = removeCommonPaths(in1, common) in2 = removeCommonPaths(in2, common) return in1, in2, common } func removeCommonPaths(in, common []string) []string { filtered := make([]string, 0, len(in)) commonIndex := 0 for _, path := range in { if commonIndex < len(common) && path == common[commonIndex] { commonIndex++ continue } filtered = append(filtered, path) } return filtered } func evaluatePaths(in []string) ([]string, bool, error) { out := make([]string, 0, len(in)) allowAny := false for _, p := range in { if p == "*" { allowAny = true continue } v, err := filepath.Abs(p) if err != nil { logrus.Warnf("failed to evaluate entitlement path %q: %v", p, err) continue } v, rest, err := evaluateToExistingPath(v) if err != nil { return nil, false, errors.Wrapf(err, "failed to evaluate path %q", p) } v, err = osutil.GetLongPathName(v) if err != nil { return nil, false, errors.Wrapf(err, "failed to evaluate path %q", p) } if rest != "" { v = filepath.Join(v, rest) } out = append(out, v) } return out, allowAny, nil } func evaluateToExistingPaths(in map[string]struct{}) (map[string]struct{}, error) { m := make(map[string]struct{}, len(in)) for p := range in { v, _, err := evaluateToExistingPath(p) if err != nil { return nil, errors.Wrapf(err, "failed to evaluate path %q", p) } v, err = osutil.GetLongPathName(v) if err != nil { return nil, errors.Wrapf(err, "failed to evaluate path %q", p) } m[v] = struct{}{} } return m, nil } func evaluateToExistingPath(in string) (string, string, error) { in, err := filepath.Abs(in) if err != nil { return "", "", err } volLen := volumeNameLen(in) pathSeparator := string(os.PathSeparator) if volLen < len(in) && os.IsPathSeparator(in[volLen]) { volLen++ } vol := in[:volLen] dest := vol linksWalked := 0 var end int for start := volLen; start < len(in); start = end { for start < len(in) && os.IsPathSeparator(in[start]) { start++ } end = start for end < len(in) && !os.IsPathSeparator(in[end]) { end++ } if end == start { break } else if in[start:end] == "." { continue } else if in[start:end] == ".." { var r int for r = len(dest) - 1; r >= volLen; r-- { if os.IsPathSeparator(dest[r]) { break } } if r < volLen || dest[r+1:] == ".." { if len(dest) > volLen { dest += pathSeparator } dest += ".." } else { dest = dest[:r] } continue } if len(dest) > volumeNameLen(dest) && !os.IsPathSeparator(dest[len(dest)-1]) { dest += pathSeparator } dest += in[start:end] fi, err := os.Lstat(dest) if err != nil { // If the component doesn't exist, return the last valid path if os.IsNotExist(err) { for r := len(dest) - 1; r >= volLen; r-- { if os.IsPathSeparator(dest[r]) { return dest[:r], in[start:], nil } } return vol, in[start:], nil } return "", "", err } if fi.Mode()&fs.ModeSymlink == 0 { if !fi.Mode().IsDir() && end < len(in) { return "", "", syscall.ENOTDIR } continue } linksWalked++ if linksWalked > 255 { return "", "", errors.New("too many symlinks") } link, err := os.Readlink(dest) if err != nil { return "", "", err } in = link + in[end:] v := volumeNameLen(link) if v > 0 { if v < len(link) && os.IsPathSeparator(link[v]) { v++ } vol = link[:v] dest = vol end = len(vol) } else if len(link) > 0 && os.IsPathSeparator(link[0]) { dest = link[:1] end = 1 vol = link[:1] volLen = 1 } else { var r int for r = len(dest) - 1; r >= volLen; r-- { if os.IsPathSeparator(dest[r]) { break } } if r < volLen { dest = vol } else { dest = dest[:r] } end = 0 } } return filepath.Clean(dest), "", nil } func volumeNameLen(s string) int { return len(filepath.VolumeName(s)) }