volume: mask password in cifs mount error messages
In managed environment (such as Nomad clusters), users are not always
supposed to see credentials used to mount volumes.
However, if errors occur (most commonly, misspelled mount paths), the
error messages will output the full mount command -- which might contain
a username and a password in the case of CIFS mounts.

This PR detects password=... when error messages are wrapped and masks
them with ********.

Closes https://github.com/fsouza/go-dockerclient/issues/905.
Closes https://github.com/hashicorp/nomad/issues/12296.
Closes https://github.com/moby/moby/issues/43596.

Signed-off-by: Sebastian Höffner <sebastian.hoeffner@mevis.fraunhofer.de>

<!--
Please make sure you've read and understood our contributing guidelines;
https://github.com/moby/moby/blob/master/CONTRIBUTING.md

** Make sure all your commits include a signature generated with `git commit -s` **

For additional information on our contributing process, read our contributing
guide https://docs.docker.com/opensource/code/

If this is a bug fix, make sure your description includes "fixes #xxxx", or
"closes #xxxx"

Please provide the following information:
-->

**- What I did**
In the error handler for cifs mounts, `password=...` will be masked with `********` in user facing error messages.

**- How I did it**
- Added `func getPassword(opts string) string` in line with `getAddr` (could potentially be refactored into something such as `func getOption(opt string, opts string) string`)
- Added unit tests for getPassword
- Replaced `password=[detectedPassword]` with `password=********`

**- How to verify it**
Was:
```console
$ docker volume create --driver local --opt type=cifs --opt "o=username=shoeffner,password=supersecretpassword,iocharset=utf8,file_mode=0777,dir_mode=0777" --opt device=//192.168.1.2/Public cifstest
$ docker run --rm -it -v cifstest:/themnt busybox bash
docker: Error response from daemon: error while mounting volume '/var/lib/docker/volumes/cifstest/_data': failed to mount local volume: mount //192.168.1.2/Public:/var/lib/docker/volumes/cifstest/_data, data: username=shoeffner,password=supersecretpassword,iocharset=utf8,file_mode=0777,dir_mode=0777: permission denied.
```
Is:
```console
$ docker volume create --driver local --opt type=cifs --opt "o=username=shoeffner,password=supersecretpassword,iocharset=utf8,file_mode=0777,dir_mode=0777" --opt device=//192.168.1.2/Public cifstest
$ docker run --rm -it -v cifstest:/themnt busybox bash
docker: Error response from daemon: error while mounting volume '/var/lib/docker/volumes/cifstest/_data': failed to mount local volume: mount //192.168.1.2/Public:/var/lib/docker/volumes/cifstest/_data, data: username=shoeffner,password=********,iocharset=utf8,file_mode=0777,dir_mode=0777: permission denied.
```

**- Description for the changelog**
<!--
Write a short (one line) summary that describes the changes in this
pull request for inclusion in the changelog:
-->
Passwords in user facing error messages for CIFS mounts are masked.

**- A picture of a cute animal (not mandatory but encouraged)**

![image](https://user-images.githubusercontent.com/1836815/168321285-b249cf28-c2bc-467f-a321-f90cde684fa8.png)
https://www.pexels.com/photo/cold-nature-cute-ice-52509/
volume/local/local_unix.go
//go:build linux || freebsd
// +build linux freebsd

// Package local provides the default implementation for volumes. It
// is used to mount data volume containers and directories local to
// the host server.
package local // import "github.com/docker/docker/volume/local"

import (
	"fmt"
	"net"
	"os"
	"strings"
	"syscall"
	"time"

	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/quota"
	units "github.com/docker/go-units"
	"github.com/moby/sys/mount"
	"github.com/moby/sys/mountinfo"
	"github.com/pkg/errors"
)

var (
	validOpts = map[string]struct{}{
		"type":   {}, // specify the filesystem type for mount, e.g. nfs
		"o":      {}, // generic mount options
		"device": {}, // device to mount from
		"size":   {}, // quota size limit
	}
	mandatoryOpts = map[string][]string{
		"device": {"type"},
		"type":   {"device"},
		"o":      {"device", "type"},
	}
)

type optsConfig struct {
	MountType   string
	MountOpts   string
	MountDevice string
	Quota       quota.Quota
}

func (o *optsConfig) String() string {
	return fmt.Sprintf("type='%s' device='%s' o='%s' size='%d'", o.MountType, o.MountDevice, o.MountOpts, o.Quota.Size)
}

func setOpts(v *localVolume, opts map[string]string) error {
	if len(opts) == 0 {
		return nil
	}
	err := validateOpts(opts)
	if err != nil {
		return err
	}
	v.opts = &optsConfig{
		MountType:   opts["type"],
		MountOpts:   opts["o"],
		MountDevice: opts["device"],
	}
	if val, ok := opts["size"]; ok {
		size, err := units.RAMInBytes(val)
		if err != nil {
			return err
		}
		if size > 0 && v.quotaCtl == nil {
			return errdefs.InvalidParameter(errors.Errorf("quota size requested but no quota support"))
		}
		v.opts.Quota.Size = uint64(size)
	}
	return nil
}

func validateOpts(opts map[string]string) error {
	if len(opts) == 0 {
		return nil
	}
	for opt := range opts {
		if _, ok := validOpts[opt]; !ok {
			return errdefs.InvalidParameter(errors.Errorf("invalid option: %q", opt))
		}
	}
	for opt, reqopts := range mandatoryOpts {
		if _, ok := opts[opt]; ok {
			for _, reqopt := range reqopts {
				if _, ok := opts[reqopt]; !ok {
					return errdefs.InvalidParameter(errors.Errorf("missing required option: %q", reqopt))
				}
			}
		}
	}
	return nil
}

func unmount(path string) {
	_ = mount.Unmount(path)
}

func (v *localVolume) needsMount() bool {
	if v.opts == nil {
		return false
	}
	if v.opts.MountDevice != "" || v.opts.MountType != "" {
		return true
	}
	return false
}

func (v *localVolume) mount() error {
	if v.opts.MountDevice == "" {
		return fmt.Errorf("missing device in volume options")
	}
	mountOpts := v.opts.MountOpts
	switch v.opts.MountType {
	case "nfs", "cifs":
		if addrValue := getAddress(v.opts.MountOpts); addrValue != "" && net.ParseIP(addrValue).To4() == nil {
			ipAddr, err := net.ResolveIPAddr("ip", addrValue)
			if err != nil {
				return errors.Wrapf(err, "error resolving passed in network volume address")
			}
			mountOpts = strings.Replace(mountOpts, "addr="+addrValue, "addr="+ipAddr.String(), 1)
		}
	}
	err := mount.Mount(v.opts.MountDevice, v.path, v.opts.MountType, mountOpts)
	return errors.Wrap(err, "failed to mount local volume")
}

func (v *localVolume) postMount() error {
	if v.opts == nil {
		return nil
	}
	if v.opts.Quota.Size > 0 {
		if v.quotaCtl != nil {
			err := v.quotaCtl.SetQuota(v.path, v.opts.Quota)
			if err != nil {
				return err
			}
		} else {
			return fmt.Errorf("size quota requested for volume but no quota support")
		}
	}
	return nil
}

func (v *localVolume) unmount() error {
	if v.needsMount() {
		if err := mount.Unmount(v.path); err != nil {
			if mounted, mErr := mountinfo.Mounted(v.path); mounted || mErr != nil {
				return errdefs.System(err)
			}
		}
		v.active.mounted = false
	}
	return nil
}

func (v *localVolume) CreatedAt() (time.Time, error) {
	fileInfo, err := os.Stat(v.path)
	if err != nil {
		return time.Time{}, err
	}
	sec, nsec := fileInfo.Sys().(*syscall.Stat_t).Ctim.Unix()
	return time.Unix(sec, nsec), nil
}

//go:build linux || freebsd
// +build linux freebsd

// Package local provides the default implementation for volumes. It
// is used to mount data volume containers and directories local to
// the host server.
package local // import "github.com/docker/docker/volume/local"

import (
	"fmt"
	"net"
	"os"
	"strings"
	"syscall"
	"time"

	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/quota"
	units "github.com/docker/go-units"
	"github.com/moby/sys/mount"
	"github.com/moby/sys/mountinfo"
	"github.com/pkg/errors"
)

var (
	validOpts = map[string]struct{}{
		"type":   {}, // specify the filesystem type for mount, e.g. nfs
		"o":      {}, // generic mount options
		"device": {}, // device to mount from
		"size":   {}, // quota size limit
	}
	mandatoryOpts = map[string][]string{
		"device": {"type"},
		"type":   {"device"},
		"o":      {"device", "type"},
	}
)

type optsConfig struct {
	MountType   string
	MountOpts   string
	MountDevice string
	Quota       quota.Quota
}

func (o *optsConfig) String() string {
	return fmt.Sprintf("type='%s' device='%s' o='%s' size='%d'", o.MountType, o.MountDevice, o.MountOpts, o.Quota.Size)
}

func setOpts(v *localVolume, opts map[string]string) error {
	if len(opts) == 0 {
		return nil
	}
	err := validateOpts(opts)
	if err != nil {
		return err
	}
	v.opts = &optsConfig{
		MountType:   opts["type"],
		MountOpts:   opts["o"],
		MountDevice: opts["device"],
	}
	if val, ok := opts["size"]; ok {
		size, err := units.RAMInBytes(val)
		if err != nil {
			return err
		}
		if size > 0 && v.quotaCtl == nil {
			return errdefs.InvalidParameter(errors.Errorf("quota size requested but no quota support"))
		}
		v.opts.Quota.Size = uint64(size)
	}
	return nil
}

func validateOpts(opts map[string]string) error {
	if len(opts) == 0 {
		return nil
	}
	for opt := range opts {
		if _, ok := validOpts[opt]; !ok {
			return errdefs.InvalidParameter(errors.Errorf("invalid option: %q", opt))
		}
	}
	for opt, reqopts := range mandatoryOpts {
		if _, ok := opts[opt]; ok {
			for _, reqopt := range reqopts {
				if _, ok := opts[reqopt]; !ok {
					return errdefs.InvalidParameter(errors.Errorf("missing required option: %q", reqopt))
				}
			}
		}
	}
	return nil
}

func unmount(path string) {
	_ = mount.Unmount(path)
}

func (v *localVolume) needsMount() bool {
	if v.opts == nil {
		return false
	}
	if v.opts.MountDevice != "" || v.opts.MountType != "" {
		return true
	}
	return false
}

func (v *localVolume) mount() error {
	if v.opts.MountDevice == "" {
		return fmt.Errorf("missing device in volume options")
	}
	mountOpts := v.opts.MountOpts
	switch v.opts.MountType {
	case "nfs", "cifs":
		if addrValue := getAddress(v.opts.MountOpts); addrValue != "" && net.ParseIP(addrValue).To4() == nil {
			ipAddr, err := net.ResolveIPAddr("ip", addrValue)
			if err != nil {
				return errors.Wrapf(err, "error resolving passed in network volume address")
			}
			mountOpts = strings.Replace(mountOpts, "addr="+addrValue, "addr="+ipAddr.String(), 1)
		}
	}
	err := mount.Mount(v.opts.MountDevice, v.path, v.opts.MountType, mountOpts)
	if err != nil {
		if password := getPassword(v.opts.MountOpts); password != "" {
			err = errors.New(strings.Replace(err.Error(), "password="+password, "password=********", 1))
		}
	}
	return errors.Wrap(err, "failed to mount local volume")
}

func (v *localVolume) postMount() error {
	if v.opts == nil {
		return nil
	}
	if v.opts.Quota.Size > 0 {
		if v.quotaCtl != nil {
			err := v.quotaCtl.SetQuota(v.path, v.opts.Quota)
			if err != nil {
				return err
			}
		} else {
			return fmt.Errorf("size quota requested for volume but no quota support")
		}
	}
	return nil
}

func (v *localVolume) unmount() error {
	if v.needsMount() {
		if err := mount.Unmount(v.path); err != nil {
			if mounted, mErr := mountinfo.Mounted(v.path); mounted || mErr != nil {
				return errdefs.System(err)
			}
		}
		v.active.mounted = false
	}
	return nil
}

func (v *localVolume) CreatedAt() (time.Time, error) {
	fileInfo, err := os.Stat(v.path)
	if err != nil {
		return time.Time{}, err
	}
	sec, nsec := fileInfo.Sys().(*syscall.Stat_t).Ctim.Unix()
	return time.Unix(sec, nsec), nil
}

The only concern I have with this, is that this can hide the underlying error-type, which would prevent detecting (e.g.) `errors.Is(err, fs.NotExist)`.

It looks like currently we don't do any detection of error-types, and consider any error to be a "500" (internal server error) (probably not correct either for this case 🤔 but that's a separate issue); https://github.com/moby/moby/blob/4cac624fefefb7bb6640f205587d0dc0a9651b27/volume/local/local.go#L315-L317

Let me at least get some eyes from @cpuguy83 on this as well