Skip to content

feat(docs): document build secrets #401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Nov 4, 2024
Merged
89 changes: 89 additions & 0 deletions docs/build-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Build Secrets

Envbuilder supports [build secrets](https://docs.docker.com/reference/dockerfile/#run---mounttypesecret). Build secrets are useful when you need to use sensitive information during the image build process and:
* the secrets should not be present in the built image.
* the secrets should not be accessible in the container after its build has concluded.

If your Dockerfile contains directives of the form `RUN --mount=type=secret,...`, Envbuilder will attempt to mount build secrets as specified in the directive. Unlike the `docker build` command, Envbuilder does not support the `--secret` flag. Instead, Envbuilder collects build secrets from the `ENVBUILDER_BUILD_SECRETS` environment variable. These build secrets will not be present in any cached layers or images that are pushed to an image repository. Nor will they be available at run time.

## Example

To illustrate build secrets in Envbuilder, let's build, push and run a container locally. These concepts will transfer to Kubernetes or other containerised environments. Note that this example is for illustrative purposes only and is not fit for production use. Production considerations are discussed in the next section.

First, start a local docker registry, so that we can push and inspect the built image:
```bash
docker run --rm -d -p 5000:5000 --name Envbuilder-registry registry:2
```

Then, build an image based on this Dockerfile:

```Dockerfile
FROM alpine:latest

RUN --mount=type=secret,id=FOO,env cat $FOO > /foo_secret_hash.txt
RUN --mount=type=secret,id=BAR,dst=/tmp/bar.secret cat /tmp/bar.secret > /bar_secret_hash.txt
```
using this command:
```bash
docker run -it --rm \
-e ENVBUILDER_BUILD_SECRETS='FOO=envbuilder-test-secret-foo,BAR=envbuilder-test-secret-bar' \
-e ENVBUILDER_INIT_SCRIPT='/bin/sh' \
-e ENVBUILDER_CACHE_REPO=$(docker inspect Envbuilder-registry | jq -r '.[].NetworkSettings.IPAddress'):5000/test-container \
-e ENVBUILDER_PUSH_IMAGE=1 \
-v $PWD:/workspaces/empty \
ghcr.io/coder/Envbuilder:latest
```

This will result in a shell session inside the built container.
You can now verify two things:
* The secrets provided to build are not available once the container is running. They are no longer on disk, nor are they in the process environment, or in `/proc/self/environ`.
* The secrets were still useful during the build. The following comnmands show that the secrets had side effects inside the build, without remaining in the image:
```bash
cat /foo_secret_hash.txt
cat /bar_secret_hash.txt
```

### Verifying that images are secret free
To verify that the build image doesn't contain build secrets, run the following:

```bash
docker pull localhost:5000/test-container:latest
docker save -o test-container.tar localhost:5000/test-container
mkdir -p test-container
tar -xf test-container.tar -C test-container/
cd test-container
# Scan image layers for secrets:
find . -type f | xargs tar -xOf 2>/dev/null | strings | grep -rin "envbuilder-test-secret"
# Scan image manifests for secrets:
find . -type f | xargs -n1 grep -rinI 'envbuilder-test-secret'
cd ../
```

The output of both find/grep commands should be empty.
To verify that it scans correctly, replace "envbuilder-test-secret" with "Envbuilder" and rerun the commands. It should find strings related to Envbuilder that are not secrets.

Having verified that no secrets were included in the image, we can now delete the artifacts that we saved to disk.
```bash
rm -r test-container
rm -r test-container.tar
```

## Security and Production Use
The example above ignores various security concerns for the sake of simple illustration. To use build secrets securely, consider these factors:

### Build Secret Purpose and Management
Build secrets are meant for use cases where the secret should not be accessible from the built image, nor from the running container. If you need the secret at runtime, use a volume instead. Volumes that are mounted into a container will not be included in the final image, but still be available at runtime.

Build secrets are only protected if they are not copied or moved from their location as designated in the `RUN` directive. If a build secret is used, care should be taken to ensure that it is not copied or otherwise persisted into an image layer beyond the control of Envbuilder.

### Who should be able to access build secrets, when and where?
The secure way to use build secrets with Envbuilder is to deny users access to the platform that hosts Envbuilder. Only grant access to the Envbuilder container once it has concluded its build, using a trusted non-platform channel like ssh or the coder agent running inside the container. Once control has been handed to such a runtime container process, Envbuilder will have cleared all secrets that it set from the container.

Anyone with sufficient access to attach directly to the container (eg. using `kubectl`), will be able to read build secrets if they attach to the container before it has concluded its build. Anyone with sufficient access to the platform that hosts the Envbuilder container will also be able to read these build secrets from where the platform stores them. This is true for other build systems, and containerised software in general.

If secrets should be accessible at runtime, do not use build secrets. Rather, mount the secret data using a volume or environment variable. Envbuilder will not include mounted volumes in the image that it pushes to any cache repositories, but they will still be available to users that connect to the container.

### Container Management beyond Envbuilder's control
Container orchestration systems mount certain artifacts into containers for various reasons. It is possible that some of these might grant indirect access to build secrets. Consider kubernetes. It will mount a service account token into running containers. Depending on the access granted to this service account token, it may be possible to read build secrets and other sensitive data using the kubernetes API. This should not be possible by default, but Envbuilder cannot provide such a guarantee.

When building a system that uses Envbuilder, ensure that your platform does not expose unintended secret information to the container.
8 changes: 3 additions & 5 deletions envbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,10 +531,6 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
destinations = append(destinations, opts.CacheRepo)
}

buildSecrets := options.GetBuildSecrets(os.Environ())
// Ensure that build secrets do not make it into the runtime environment or the setup script:
options.ClearBuildSecretsFromProcessEnvironment()

kOpts := &config.KanikoOptions{
// Boilerplate!
CustomPlatform: platforms.Format(platforms.Normalize(platforms.DefaultSpec())),
Expand All @@ -559,7 +555,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
},
ForceUnpack: true,
BuildArgs: buildParams.BuildArgs,
BuildSecrets: buildSecrets,
BuildSecrets: opts.BuildSecrets,
CacheRepo: opts.CacheRepo,
Cache: opts.CacheRepo != "" || opts.BaseImageCacheDir != "",
DockerfilePath: buildParams.DockerfilePath,
Expand Down Expand Up @@ -1267,6 +1263,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
if opts.CacheRepo != "" {
destinations = append(destinations, opts.CacheRepo)
}

kOpts := &config.KanikoOptions{
// Boilerplate!
CustomPlatform: platforms.Format(platforms.Normalize(platforms.DefaultSpec())),
Expand All @@ -1291,6 +1288,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
},
ForceUnpack: true,
BuildArgs: buildParams.BuildArgs,
BuildSecrets: opts.BuildSecrets,
CacheRepo: opts.CacheRepo,
Cache: opts.CacheRepo != "" || opts.BaseImageCacheDir != "",
DockerfilePath: buildParams.DockerfilePath,
Expand Down
32 changes: 1 addition & 31 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1113,36 +1113,6 @@ func TestUnsetOptionsEnv(t *testing.T) {
}
}

func TestUnsetSecretEnvs(t *testing.T) {
t.Parallel()

// Ensures that a Git repository with a devcontainer.json is cloned and built.
srv := gittest.CreateGitServer(t, gittest.Options{
Files: map[string]string{
".devcontainer/devcontainer.json": `{
"name": "Test",
"build": {
"dockerfile": "Dockerfile"
},
}`,
".devcontainer/Dockerfile": "FROM " + testImageAlpine + "\nENV FROM_DOCKERFILE=foo",
},
})
ctr, err := runEnvbuilder(t, runOpts{env: []string{
envbuilderEnv("GIT_URL", srv.URL),
envbuilderEnv("GIT_PASSWORD", "supersecret"),
options.EnvWithBuildSecretPrefix("FOO", "foo"),
envbuilderEnv("INIT_SCRIPT", "env > /root/env.txt && sleep infinity"),
}})
require.NoError(t, err)

output := execContainer(t, ctr, "cat /root/env.txt")
envsAvailableToInitScript := strings.Split(strings.TrimSpace(output), "\n")

leftoverBuildSecrets := options.GetBuildSecrets(envsAvailableToInitScript)
require.Empty(t, leftoverBuildSecrets, "build secrets should not be available to init script")
}

func TestBuildSecrets(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -1172,7 +1142,7 @@ func TestBuildSecrets(t *testing.T) {
ctr, err := runEnvbuilder(t, runOpts{env: []string{
envbuilderEnv("GIT_URL", srv.URL),
envbuilderEnv("GIT_PASSWORD", "supersecret"),
options.EnvWithBuildSecretPrefix("FOO", buildSecretVal),
envbuilderEnv("BUILD_SECRETS", fmt.Sprintf("FOO=%s", buildSecretVal)),
}})
require.NoError(t, err)

Expand Down
9 changes: 9 additions & 0 deletions options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ type Options struct {
// IgnorePaths is the comma separated list of paths to ignore when building
// the workspace.
IgnorePaths []string
// BuildSecrets is the list of secret environment variables to use when
// building the image.
BuildSecrets []string
// SkipRebuild skips building if the MagicFile exists. This is used to skip
// building when a container is restarting. e.g. docker stop -> docker start
// This value can always be set to true - even if the container is being
Expand Down Expand Up @@ -323,6 +326,12 @@ func (o *Options) CLI() serpent.OptionSet {
Description: "The comma separated list of paths to ignore when " +
"building the workspace.",
},
{
Flag: "build-secrets",
Env: WithEnvPrefix("BUILD_SECRETS"),
Value: serpent.StringArrayOf(&o.BuildSecrets),
Description: "The list of secret environment variables to use " + "when building the image.",
},
{
Flag: "skip-rebuild",
Env: WithEnvPrefix("SKIP_REBUILD"),
Expand Down
46 changes: 0 additions & 46 deletions options/secrets.go

This file was deleted.

138 changes: 0 additions & 138 deletions options/secrets_test.go

This file was deleted.

4 changes: 4 additions & 0 deletions options/testdata/options.golden
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ OPTIONS:
WorkspaceFolder. This path MUST be relative to the WorkspaceFolder
path into which the repo is cloned.

--build-secrets string-array, $ENVBUILDER_BUILD_SECRETS
The list of secret environment variables to use when building the
image.

--cache-repo string, $ENVBUILDER_CACHE_REPO
The name of the container registry to push the cache image to. If this
is empty, the cache will not be pushed.
Expand Down
Loading