diff --git a/define/build.go b/define/build.go index 635626a6439..dd49c47c1f9 100644 --- a/define/build.go +++ b/define/build.go @@ -69,6 +69,8 @@ type CommonBuildOptions struct { Ulimit []string // Volumes to bind mount into the container Volumes []string + // Secrets are the available secrets to use in a build + Secrets []string } // BuildOptions can be used to alter how an image is built. diff --git a/docs/buildah-bud.md b/docs/buildah-bud.md index 5c0fc7d86fb..6a2ed2be441 100644 --- a/docs/buildah-bud.md +++ b/docs/buildah-bud.md @@ -465,6 +465,15 @@ consult the manpages of the selected container runtime. Note: Do not pass the leading `--` to the flag. To pass the runc flag `--log-format json` to buildah bud, the option given would be `--runtime-flag log-format=json`. +**--secret**=**id=id,src=path** +Pass secret information to be used in the Containerfile for building images +in a safe way that will not end up stored in the final image, or be seen in other stages. +The secret will be mounted in the container at the default location of `/run/secrets/id`. + +To later use the secret, use the --mount flag in a `RUN` instruction: + +`RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret` + **--security-opt**=[] Security Options diff --git a/imagebuildah/executor.go b/imagebuildah/executor.go index f7754262db7..e1a1325fc76 100644 --- a/imagebuildah/executor.go +++ b/imagebuildah/executor.go @@ -119,6 +119,7 @@ type Executor struct { imageInfoCache map[string]imageTypeAndHistoryAndDiffIDs fromOverride string manifest string + secrets map[string]string } type imageTypeAndHistoryAndDiffIDs struct { @@ -166,6 +167,11 @@ func NewExecutor(store storage.Store, options define.BuildOptions, mainNode *par transientMounts = append([]Mount{Mount(mount)}, transientMounts...) } + secrets, err := parse.Secrets(options.CommonBuildOpts.Secrets) + if err != nil { + return nil, err + } + jobs := 1 if options.Jobs != nil { jobs = *options.Jobs @@ -236,6 +242,7 @@ func NewExecutor(store storage.Store, options define.BuildOptions, mainNode *par imageInfoCache: make(map[string]imageTypeAndHistoryAndDiffIDs), fromOverride: options.From, manifest: options.Manifest, + secrets: secrets, } if exec.err == nil { exec.err = os.Stderr diff --git a/imagebuildah/stage_executor.go b/imagebuildah/stage_executor.go index 576edca8731..bd4e52cac9e 100644 --- a/imagebuildah/stage_executor.go +++ b/imagebuildah/stage_executor.go @@ -411,6 +411,8 @@ func (s *StageExecutor) Run(run imagebuilder.Run, config docker.Config) error { Quiet: s.executor.quiet, NamespaceOptions: s.executor.namespaceOptions, Terminal: buildah.WithoutTerminal, + Secrets: s.executor.secrets, + RunMounts: run.Mounts, } if config.NetworkDisabled { options.ConfigureNetwork = buildah.NetworkDisabled diff --git a/pkg/cli/common.go b/pkg/cli/common.go index a5e5542edb5..1a4ef63ff84 100644 --- a/pkg/cli/common.go +++ b/pkg/cli/common.go @@ -75,6 +75,7 @@ type BudResults struct { Rm bool Runtime string RuntimeFlags []string + Secrets []string SignaturePolicy string SignBy string Squash bool @@ -207,6 +208,7 @@ func GetBudFlags(flags *BudResults) pflag.FlagSet { fs.BoolVar(&flags.Rm, "rm", true, "Remove intermediate containers after a successful build") // "runtime" definition moved to avoid name collision in podman build. Defined in cmd/buildah/bud.go. fs.StringSliceVar(&flags.RuntimeFlags, "runtime-flag", []string{}, "add global flags for the container runtime") + fs.StringArrayVar(&flags.Secrets, "secret", []string{}, "secret file to expose to the build") fs.StringVar(&flags.SignBy, "sign-by", "", "sign the image using a GPG key with the specified `FINGERPRINT`") fs.StringVar(&flags.SignaturePolicy, "signature-policy", "", "`pathname` of signature policy file (not usually used)") if err := fs.MarkHidden("signature-policy"); err != nil { @@ -245,6 +247,7 @@ func GetBudFlagsCompletions() commonComp.FlagCompletions { flagCompletion["os"] = commonComp.AutocompleteNone flagCompletion["platform"] = commonComp.AutocompleteNone flagCompletion["runtime-flag"] = commonComp.AutocompleteNone + flagCompletion["secret"] = commonComp.AutocompleteNone flagCompletion["sign-by"] = commonComp.AutocompleteNone flagCompletion["signature-policy"] = commonComp.AutocompleteNone flagCompletion["tag"] = commonComp.AutocompleteNone diff --git a/pkg/parse/parse.go b/pkg/parse/parse.go index 1e77248f1da..462ac212e3e 100644 --- a/pkg/parse/parse.go +++ b/pkg/parse/parse.go @@ -125,6 +125,8 @@ func CommonBuildOptions(c *cobra.Command) (*define.CommonBuildOptions, error) { ulimit, _ = c.Flags().GetStringSlice("ulimit") } + secrets, _ := c.Flags().GetStringArray("secret") + commonOpts := &define.CommonBuildOptions{ AddHost: addHost, CPUPeriod: cpuPeriod, @@ -142,6 +144,7 @@ func CommonBuildOptions(c *cobra.Command) (*define.CommonBuildOptions, error) { ShmSize: c.Flag("shm-size").Value.String(), Ulimit: ulimit, Volumes: volumes, + Secrets: secrets, } securityOpts, _ := c.Flags().GetStringArray("security-opt") if err := parseSecurityOpts(securityOpts, commonOpts); err != nil { @@ -1051,3 +1054,37 @@ func GetTempDir() string { } return "/var/tmp" } + +// Secrets parses the --secret flag +func Secrets(secrets []string) (map[string]string, error) { + parsed := make(map[string]string) + invalidSyntax := errors.Errorf("incorrect secret flag format: should be --secret id=foo,src=bar") + for _, secret := range secrets { + split := strings.Split(secret, ",") + if len(split) > 2 { + return nil, invalidSyntax + } + if len(split) == 2 { + id := strings.Split(split[0], "=") + src := strings.Split(split[1], "=") + if len(split) == 2 && strings.ToLower(id[0]) == "id" && strings.ToLower(src[0]) == "src" { + fullPath, err := filepath.Abs(src[1]) + if err != nil { + return nil, err + } + _, err = os.Stat(fullPath) + if err == nil { + parsed[id[1]] = fullPath + } + if err != nil { + return nil, errors.Wrap(err, "could not parse secrets") + } + } else { + return nil, invalidSyntax + } + } else { + return nil, invalidSyntax + } + } + return parsed, nil +} diff --git a/run.go b/run.go index 87685040373..efffd1f5f40 100644 --- a/run.go +++ b/run.go @@ -134,4 +134,9 @@ type RunOptions struct { DropCapabilities []string // Devices are the additional devices to add to the containers Devices define.ContainerDevices + // Secrets are the available secrets to use in a RUN + Secrets map[string]string + // RunMounts are mounts for this run. RunMounts for this run + // will not show up in subsequent runs. + RunMounts []string } diff --git a/run_linux.go b/run_linux.go index c9fbd5ccfc0..428b75c552c 100644 --- a/run_linux.go +++ b/run_linux.go @@ -246,10 +246,17 @@ rootless=%d bindFiles["/run/.containerenv"] = containerenvPath } - err = b.setupMounts(mountPoint, spec, path, options.Mounts, bindFiles, volumes, b.CommonBuildOpts.Volumes, b.CommonBuildOpts.ShmSize, namespaceOptions) + runMountTargets, err := b.setupMounts(mountPoint, spec, path, options.Mounts, bindFiles, volumes, b.CommonBuildOpts.Volumes, b.CommonBuildOpts.ShmSize, namespaceOptions, options.Secrets, options.RunMounts) if err != nil { return errors.Wrapf(err, "error resolving mountpoints for container %q", b.ContainerID) } + + defer func() { + if err := cleanupRunMounts(runMountTargets, mountPoint); err != nil { + logrus.Errorf("unabe to cleanup run mounts %v", err) + } + }() + defer b.cleanupTempVolumes() if options.CNIConfigDir == "" { @@ -403,7 +410,7 @@ func runSetupBuiltinVolumes(mountLabel, mountPoint, containerDir string, builtin return mounts, nil } -func (b *Builder) setupMounts(mountPoint string, spec *specs.Spec, bundlePath string, optionMounts []specs.Mount, bindFiles map[string]string, builtinVolumes, volumeMounts []string, shmSize string, namespaceOptions define.NamespaceOptions) error { +func (b *Builder) setupMounts(mountPoint string, spec *specs.Spec, bundlePath string, optionMounts []specs.Mount, bindFiles map[string]string, builtinVolumes, volumeMounts []string, shmSize string, namespaceOptions define.NamespaceOptions, secrets map[string]string, runFileMounts []string) (runMountTargets []string, err error) { // Start building a new list of mounts. var mounts []specs.Mount haveMount := func(destination string) bool { @@ -497,39 +504,43 @@ func (b *Builder) setupMounts(mountPoint string, spec *specs.Spec, bundlePath st // After this point we need to know the per-container persistent storage directory. cdir, err := b.store.ContainerDirectory(b.ContainerID) if err != nil { - return errors.Wrapf(err, "error determining work directory for container %q", b.ContainerID) + return nil, errors.Wrapf(err, "error determining work directory for container %q", b.ContainerID) } // Figure out which UID and GID to tell the subscriptions package to use // for files that it creates. rootUID, rootGID, err := util.GetHostRootIDs(spec) if err != nil { - return err + return nil, err } // Get the list of subscriptions mounts. subscriptionMounts := subscriptions.MountsWithUIDGID(b.MountLabel, cdir, b.DefaultMountsFilePath, mountPoint, int(rootUID), int(rootGID), unshare.IsRootless(), false) + runMounts, runTargets, err := runSetupRunMounts(runFileMounts, secrets, b.MountLabel, cdir, spec.Linux.UIDMappings, spec.Linux.GIDMappings) + if err != nil { + return nil, err + } // Add temporary copies of the contents of volume locations at the // volume locations, unless we already have something there. builtins, err := runSetupBuiltinVolumes(b.MountLabel, mountPoint, cdir, builtinVolumes, int(rootUID), int(rootGID)) if err != nil { - return err + return nil, err } // Get host UID and GID of the container process. processUID, processGID, err := util.GetHostIDs(spec.Linux.UIDMappings, spec.Linux.GIDMappings, spec.Process.User.UID, spec.Process.User.GID) if err != nil { - return err + return nil, err } // Get the list of explicitly-specified volume mounts. volumes, err := b.runSetupVolumeMounts(spec.Linux.MountLabel, volumeMounts, optionMounts, int(rootUID), int(rootGID), int(processUID), int(processGID)) if err != nil { - return err + return nil, err } - allMounts := util.SortMounts(append(append(append(append(append(volumes, builtins...), subscriptionMounts...), bindFileMounts...), specMounts...), sysfsMount...)) + allMounts := util.SortMounts(append(append(append(append(append(append(volumes, builtins...), runMounts...), subscriptionMounts...), bindFileMounts...), specMounts...), sysfsMount...)) // Add them all, in the preferred order, except where they conflict with something that was previously added. for _, mount := range allMounts { if haveMount(mount.Destination) { @@ -542,7 +553,7 @@ func (b *Builder) setupMounts(mountPoint string, spec *specs.Spec, bundlePath st // Set the list in the spec. spec.Mounts = mounts - return nil + return runTargets, nil } // addNetworkConfig copies files from host and sets them up to bind mount into container @@ -2248,3 +2259,149 @@ type runUsingRuntimeSubprocOptions struct { func init() { reexec.Register(runUsingRuntimeCommand, runUsingRuntimeMain) } + +// runSetupRunMounts sets up mounts that exist only in this RUN, not in subsequent runs +func runSetupRunMounts(mounts []string, secrets map[string]string, mountlabel string, containerWorkingDir string, uidmap []spec.LinuxIDMapping, gidmap []spec.LinuxIDMapping) ([]spec.Mount, []string, error) { + mountTargets := make([]string, 0, 10) + finalMounts := make([]specs.Mount, 0, len(mounts)) + for _, mount := range mounts { + arr := strings.SplitN(mount, ",", 2) + if len(arr) < 2 { + return nil, nil, errors.New("invalid mount syntax") + } + + kv := strings.Split(arr[0], "=") + if len(kv) != 2 || kv[0] != "type" { + return nil, nil, errors.New("invalid mount type") + } + + tokens := strings.Split(arr[1], ",") + // For now, we only support type secret. + switch kv[1] { + case "secret": + mount, err := getSecretMount(tokens, secrets, mountlabel, containerWorkingDir, uidmap, gidmap) + if err != nil { + return nil, nil, err + } + if mount != nil { + finalMounts = append(finalMounts, *mount) + mountTargets = append(mountTargets, mount.Destination) + + } + default: + return nil, nil, errors.Errorf("invalid filesystem type %q", kv[1]) + } + } + return finalMounts, mountTargets, nil +} + +func getSecretMount(tokens []string, secrets map[string]string, mountlabel string, containerWorkingDir string, uidmap []spec.LinuxIDMapping, gidmap []spec.LinuxIDMapping) (*spec.Mount, error) { + errInvalidSyntax := errors.New("secret should have syntax id=id[,target=path,required=bool,mode=uint,uid=uint,gid=uint") + + var err error + var id, target string + var required bool + var uid, gid uint32 + var mode uint32 = 400 + for _, val := range tokens { + kv := strings.SplitN(val, "=", 2) + switch kv[0] { + case "id": + id = kv[1] + case "target": + target = kv[1] + case "required": + required, err = strconv.ParseBool(kv[1]) + if err != nil { + return nil, errInvalidSyntax + } + case "mode": + mode64, err := strconv.ParseUint(kv[1], 8, 32) + if err != nil { + return nil, errInvalidSyntax + } + mode = uint32(mode64) + case "uid": + uid64, err := strconv.ParseUint(kv[1], 10, 32) + if err != nil { + return nil, errInvalidSyntax + } + uid = uint32(uid64) + case "gid": + gid64, err := strconv.ParseUint(kv[1], 10, 32) + if err != nil { + return nil, errInvalidSyntax + } + gid = uint32(gid64) + default: + return nil, errInvalidSyntax + } + } + + if id == "" { + return nil, errInvalidSyntax + } + // Default location for secretis is /run/secrets/id + if target == "" { + target = "/run/secrets/" + id + } + + src, ok := secrets[id] + if !ok { + if required { + return nil, errors.Errorf("secret required but no secret with id %s found", id) + } + return nil, nil + } + + // Copy secrets to container working dir, since we need to chmod, chown and relabel it + // for the container user and we don't want to mess with the original file + ctrFileOnHost := filepath.Join(containerWorkingDir, "secrets", id) + _, err = os.Stat(ctrFileOnHost) + if os.IsNotExist(err) { + data, err := ioutil.ReadFile(src) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(ctrFileOnHost), 0644); err != nil { + return nil, err + } + if err := ioutil.WriteFile(ctrFileOnHost, data, 0644); err != nil { + return nil, err + } + } + + if err := label.Relabel(ctrFileOnHost, mountlabel, false); err != nil { + return nil, err + } + hostUID, hostGID, err := util.GetHostIDs(uidmap, gidmap, uid, gid) + if err != nil { + return nil, err + } + if err := os.Lchown(ctrFileOnHost, int(hostUID), int(hostGID)); err != nil { + return nil, err + } + if err := os.Chmod(ctrFileOnHost, os.FileMode(mode)); err != nil { + return nil, err + } + newMount := specs.Mount{ + Destination: target, + Type: "bind", + Source: ctrFileOnHost, + Options: []string{"bind", "rprivate", "ro"}, + } + return &newMount, nil +} + +func cleanupRunMounts(paths []string, mountpoint string) error { + opts := copier.RemoveOptions{ + All: true, + } + for _, path := range paths { + err := copier.Remove(mountpoint, path, opts) + if err != nil { + return err + } + } + return nil +} diff --git a/tests/bud.bats b/tests/bud.bats index cf5e049a1a3..79d06cdbeff 100644 --- a/tests/bud.bats +++ b/tests/bud.bats @@ -2527,7 +2527,7 @@ EOF run cmp url1 url2 [[ "$status" -ne 0 ]] - # The first rounds of builds should all be different from each other, as a sanith thing. + # The first rounds of builds should all be different from each other, as a sanity thing. run cmp copy1 prev1 [[ "$status" -ne 0 ]] run cmp copy1 add1 @@ -2817,3 +2817,59 @@ _EOF run_buildah 125 bud -t test3 -f Containerfile.bad --signature-policy ${TESTSDIR}/policy.json ${TESTSDIR}/bud/copy-globs expect_output --substring 'error building.*"COPY \*foo /testdir".*no such file or directory' } + +@test "bud with containerfile secret" { + _prefetch alpine + mytmpdir=${TESTDIR}/my-dir1 + mkdir -p ${mytmpdir} + cat > $mytmpdir/mysecret << _EOF +SOMESECRETDATA +_EOF + + run_buildah bud --secret=id=mysecret,src=${mytmpdir}/mysecret --signature-policy ${TESTSDIR}/policy.json -t secretimg -f ${TESTSDIR}/bud/run-mounts/Dockerfile.secret ${TESTSDIR}/bud/run-mounts + expect_output --substring "SOMESECRETDATA" + + run_buildah from secretimg + run_buildah 1 run secretimg-working-container cat /run/secrets/mysecret + run_buildah rm -a +} + +@test "bud with containerfile secret accessed on second RUN" { + _prefetch alpine + mytmpdir=${TESTDIR}/my-dir1 + mkdir -p ${mytmpdir} + cat > $mytmpdir/mysecret << _EOF +SOMESECRETDATA +_EOF + + run_buildah 1 bud --secret=id=mysecret,src=${mytmpdir}/mysecret --signature-policy ${TESTSDIR}/policy.json -t secretimg -f ${TESTSDIR}/bud/run-mounts/Dockerfile.secret-access ${TESTSDIR}/bud/run-mounts + expect_output --substring "SOMESECRETDATA" + expect_output --substring "cat: can't open '/mysecret': No such file or directory" +} + +@test "bud with containerfile secret options" { + _prefetch alpine + mytmpdir=${TESTDIR}/my-dir1 + mkdir -p ${mytmpdir} + cat > $mytmpdir/mysecret << _EOF +SOMESECRETDATA +_EOF + + run_buildah bud --secret=id=mysecret,src=${mytmpdir}/mysecret --signature-policy ${TESTSDIR}/policy.json -t secretopts -f ${TESTSDIR}/bud/run-mounts/Dockerfile.secret-options ${TESTSDIR}/bud/run-mounts + expect_output --substring "444" + expect_output --substring "1000" + expect_output --substring "1001" +} + +@test "bud with containerfile secret not required" { + _prefetch alpine + + run_buildah bud --signature-policy ${TESTSDIR}/policy.json -t secretnotreq -f ${TESTSDIR}/bud/run-mounts/Dockerfile.secret-not-required ${TESTSDIR}/bud/run-mounts +} + +@test "bud with containerfile secret required" { + _prefetch alpine + + run_buildah 125 bud --signature-policy ${TESTSDIR}/policy.json -t secretreq -f ${TESTSDIR}/bud/run-mounts/Dockerfile.secret-required ${TESTSDIR}/bud/run-mounts + expect_output --substring "secret required but no secret with id mysecret found" +} diff --git a/tests/bud/run-mounts/Dockerfile.secret b/tests/bud/run-mounts/Dockerfile.secret new file mode 100644 index 00000000000..920663a92a1 --- /dev/null +++ b/tests/bud/run-mounts/Dockerfile.secret @@ -0,0 +1,2 @@ +FROM alpine +RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret diff --git a/tests/bud/run-mounts/Dockerfile.secret-access b/tests/bud/run-mounts/Dockerfile.secret-access new file mode 100644 index 00000000000..a0e5f8cea81 --- /dev/null +++ b/tests/bud/run-mounts/Dockerfile.secret-access @@ -0,0 +1,3 @@ +FROM alpine +RUN --mount=type=secret,id=mysecret,target=mysecret cat /mysecret +RUN cat /mysecret diff --git a/tests/bud/run-mounts/Dockerfile.secret-not-required b/tests/bud/run-mounts/Dockerfile.secret-not-required new file mode 100644 index 00000000000..8c80d927216 --- /dev/null +++ b/tests/bud/run-mounts/Dockerfile.secret-not-required @@ -0,0 +1,2 @@ +FROM alpine +RUN --mount=type=secret,id=mysecret echo "hello" diff --git a/tests/bud/run-mounts/Dockerfile.secret-options b/tests/bud/run-mounts/Dockerfile.secret-options new file mode 100644 index 00000000000..eabc44cb929 --- /dev/null +++ b/tests/bud/run-mounts/Dockerfile.secret-options @@ -0,0 +1,2 @@ +FROM alpine +RUN --mount=type=secret,id=mysecret,target=/mysecret,uid=1000,gid=1001,mode=0444 stat -c "%a" /mysecret ; ls -n /mysecret diff --git a/tests/bud/run-mounts/Dockerfile.secret-required b/tests/bud/run-mounts/Dockerfile.secret-required new file mode 100644 index 00000000000..10070e20600 --- /dev/null +++ b/tests/bud/run-mounts/Dockerfile.secret-required @@ -0,0 +1,2 @@ +FROM alpine +RUN --mount=type=secret,id=mysecret,required=true cat /run/secrets/mysecret diff --git a/tests/run.bats b/tests/run.bats index 3c4a1be8c81..220eba7c172 100644 --- a/tests/run.bats +++ b/tests/run.bats @@ -471,7 +471,7 @@ function configure_and_check_user() { cid=$output run_buildah 125 run --isolation=chroot --network=bogus $cid cat /etc/hosts expect_output "checking network namespace: stat bogus: no such file or directory" - run_buildah run --isolation=chroot --network=container $cid cat /etc/hosts + run_buildah run --isolation=chroot --network=container $cid cat /etc/hosts expect_output --substring "# Generated by Buildah" m=$(buildah mount $cid) run cat $m/etc/hosts @@ -481,7 +481,7 @@ function configure_and_check_user() { run_buildah from --quiet --pull=false --signature-policy ${TESTSDIR}/policy.json debian cid=$output - run_buildah run --isolation=chroot --network=host $cid cat /etc/hosts + run_buildah run --isolation=chroot --network=host $cid cat /etc/hosts expect_output --substring "# Generated by Buildah" m=$(buildah mount $cid) run cat $m/etc/hosts @@ -508,7 +508,7 @@ function configure_and_check_user() { run_buildah from --quiet --pull=false --signature-policy ${TESTSDIR}/policy.json alpine cid=$output - run_buildah run --isolation=chroot --network=container $cid cat /etc/resolv.conf + run_buildah run --isolation=chroot --network=container $cid cat /etc/resolv.conf expect_output --substring "nameserver" m=$(buildah mount $cid) run cat $m/etc/resolv.conf @@ -518,7 +518,7 @@ function configure_and_check_user() { run_buildah from --quiet --pull=false --signature-policy ${TESTSDIR}/policy.json alpine cid=$output - run_buildah run --isolation=chroot --network=host $cid cat /etc/resolv.conf + run_buildah run --isolation=chroot --network=host $cid cat /etc/resolv.conf expect_output --substring "nameserver" m=$(buildah mount $cid) run cat $m/etc/resolv.conf