Skip to content

Commit c16ae9f

Browse files
authored
feat(docs): document build secrets (#401)
1 parent 457b73f commit c16ae9f

File tree

8 files changed

+183
-220
lines changed

8 files changed

+183
-220
lines changed

docs/build-secrets.md

+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Build Secrets
2+
3+
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:
4+
* the secrets should not be present in the built image.
5+
* the secrets should not be accessible in the container after its build has concluded.
6+
7+
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.
8+
9+
## Example
10+
11+
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.
12+
13+
First, start a local docker registry, so that we can push and inspect the built image:
14+
```bash
15+
docker run --rm -d -p 5000:5000 --name envbuilder-registry registry:2
16+
```
17+
18+
Then, prepare the files to build our container.
19+
```bash
20+
mkdir test-build-secrets
21+
cd test-build-secrets
22+
cat << EOF > Dockerfile
23+
FROM alpine:latest
24+
25+
RUN --mount=type=secret,id=TEST_BUILD_SECRET_A,env=TEST_BUILD_SECRET_A echo -n \$TEST_BUILD_SECRET_A | sha256sum > /foo_secret_hash.txt
26+
RUN --mount=type=secret,id=TEST_BUILD_SECRET_B,dst=/tmp/bar.secret cat /tmp/bar.secret | sha256sum > /bar_secret_hash.txt
27+
EOF
28+
cat << EOF > devcontainer.json
29+
{
30+
"build": {
31+
"dockerfile": "Dockerfile"
32+
}
33+
}
34+
EOF
35+
echo 'runtime-secret-a' > runtime-secret.txt
36+
```
37+
38+
The Dockerfile requires two build secrets: `TEST_BUILD_SECRET_A` and `TEST_BUILD_SECRET_B`. Their values are arbitrarily set to `secret-foo` and `secret-bar` by the command below. Building the container image writes the checksums for these secrets to disk. This illustrates that the secrets can be used in the build to enact side effects without exposing the secrets themselves.
39+
40+
Execute the build using this command:
41+
```bash
42+
docker run -it --rm \
43+
-e ENVBUILDER_BUILD_SECRETS='TEST_BUILD_SECRET_A=secret-foo,TEST_BUILD_SECRET_B=secret-bar' \
44+
-e ENVBUILDER_INIT_SCRIPT='/bin/sh' \
45+
-e ENVBUILDER_CACHE_REPO=$(docker inspect envbuilder-registry | jq -r '.[].NetworkSettings.IPAddress'):5000/test-container \
46+
-e ENVBUILDER_PUSH_IMAGE=1 \
47+
-v $PWD:/workspaces/empty \
48+
-v $PWD/runtime-secret.txt:/runtime-secret.txt \
49+
ghcr.io/coder/envbuilder:latest
50+
```
51+
52+
This will result in a shell session inside the built container.
53+
You can now verify three things:
54+
55+
Firstly, 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`:
56+
```bash
57+
cat /proc/self/environ | tr '\0' '\n'
58+
printenv
59+
```
60+
Expected output:
61+
```bash
62+
/workspaces/empty # cat /proc/self/environ | tr '\0' '\n'
63+
HOSTNAME=c0b0ee3d5564
64+
SHLVL=2
65+
HOME=/root
66+
TERM=xterm
67+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
68+
DEVCONTAINER_CONFIG=/workspaces/empty/devcontainer.json
69+
ENVBUILDER=true
70+
TS_DEBUG_TRIM_WIREGUARD=false
71+
PWD=/workspaces/empty
72+
DEVCONTAINER=true
73+
/workspaces/empty # printenv
74+
HOSTNAME=c0b0ee3d5564
75+
SHLVL=2
76+
HOME=/root
77+
TERM=xterm
78+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
79+
DEVCONTAINER_CONFIG=/workspaces/empty/devcontainer.json
80+
ENVBUILDER=true
81+
TS_DEBUG_TRIM_WIREGUARD=false
82+
PWD=/workspaces/empty
83+
DEVCONTAINER=true
84+
/workspaces/empty #
85+
```
86+
87+
Secondly, the secrets were still useful during the build. The following commands show that the secrets had side effects inside the build, without remaining in the image:
88+
```bash
89+
echo -n "secret-foo" | sha256sum
90+
cat /foo_secret_hash.txt
91+
echo -n "secret-bar" | sha256sum
92+
cat /bar_secret_hash.txt
93+
```
94+
95+
Notice that the first two checksums match and that the last two checksums match. Expected output:
96+
```
97+
/workspaces/empty # echo -n "secret-foo" | sha256sum
98+
9a888f08a057159d2ea8fb69d38c9a25e367d7ca3128035b7f6dee2ca988c3d8 -
99+
/workspaces/empty # cat /foo_secret_hash.txt
100+
9a888f08a057159d2ea8fb69d38c9a25e367d7ca3128035b7f6dee2ca988c3d8 -
101+
/workspaces/empty # echo -n "secret-bar" | sha256sum
102+
fb1c9d1220e429b30c60d028b882f735b5af72d7b5496d9202737fe9f1d38289 -
103+
/workspaces/empty # cat /bar_secret_hash.txt
104+
fb1c9d1220e429b30c60d028b882f735b5af72d7b5496d9202737fe9f1d38289 -
105+
/workspaces/empty #
106+
```
107+
108+
Thirdly, the runtime secret that was mounted as a volume is still mounted into the container and accessible. This is why volumes are inappropriate analogues to native docker build secrets. However, notice further down that this runtime secret volume's contents are not present in the built image. It is therefore safe to mount a volume into envbuilder for use during runtime without fear that it will be present in the image that envbuilder builds.
109+
110+
Finally, exit the container:
111+
```bash
112+
exit
113+
```
114+
115+
### Verifying that images are secret free
116+
To verify that the built image doesn't contain build secrets, run the following:
117+
118+
```bash
119+
docker pull localhost:5000/test-container:latest
120+
docker save -o test-container.tar localhost:5000/test-container
121+
mkdir -p test-container
122+
tar -xf test-container.tar -C test-container/
123+
cd test-container
124+
# Scan image layers for secrets:
125+
find . -type f | xargs tar -xOf 2>/dev/null | strings | grep -rn "secret-foo"
126+
find . -type f | xargs tar -xOf 2>/dev/null | strings | grep -rn "secret-bar"
127+
find . -type f | xargs tar -xOf 2>/dev/null | strings | grep -rn "runtime-secret"
128+
# Scan image manifests for secrets:
129+
find . -type f | xargs -n1 grep -rnI 'secret-foo'
130+
find . -type f | xargs -n1 grep -rnI 'secret-bar'
131+
find . -type f | xargs -n1 grep -rnI 'runtime-secret'
132+
cd ../
133+
```
134+
135+
The output of all find/grep commands should be empty.
136+
To verify that it scans correctly, replace "secret-foo" with "envbuilder" and rerun the commands. It should find strings related to Envbuilder that are not secrets.
137+
138+
### Cleanup
139+
140+
Having verified that no secrets were included in the image, we can now delete the artifacts that we saved to disk and remove the containers.
141+
```bash
142+
cd ../
143+
rm -r test-build-secrets
144+
docker stop envbuilder-registry
145+
```
146+
147+
## Security and Production Use
148+
The example above ignores various security concerns for the sake of simple illustration. To use build secrets securely, consider these factors:
149+
150+
### Build Secret Purpose and Management
151+
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.
152+
153+
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.
154+
155+
### Who should be able to access build secrets, when and where?
156+
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.
157+
158+
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.
159+
160+
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.
161+
162+
### Container Management beyond Envbuilder's control
163+
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.
164+
165+
When building a system that uses Envbuilder, ensure that your platform does not expose unintended secret information to the container.

docs/env-variables.md

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
| `--force-safe` | `ENVBUILDER_FORCE_SAFE` | | Ignores any filesystem safety checks. This could cause serious harm to your system! This is used in cases where bypass is needed to unblock customers. |
2222
| `--insecure` | `ENVBUILDER_INSECURE` | | Bypass TLS verification when cloning and pulling from container registries. |
2323
| `--ignore-paths` | `ENVBUILDER_IGNORE_PATHS` | | The comma separated list of paths to ignore when building the workspace. |
24+
| `--build-secrets` | `ENVBUILDER_BUILD_SECRETS` | | The list of secret environment variables to use when building the image. |
2425
| `--skip-rebuild` | `ENVBUILDER_SKIP_REBUILD` | | Skip 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 started for the first time. |
2526
| `--git-url` | `ENVBUILDER_GIT_URL` | | The URL of a Git repository containing a Devcontainer or Docker image to clone. This is optional. |
2627
| `--git-clone-depth` | `ENVBUILDER_GIT_CLONE_DEPTH` | | The depth to use when cloning the Git repository. |

envbuilder.go

+3-5
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,6 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
530530
destinations = append(destinations, opts.CacheRepo)
531531
}
532532

533-
buildSecrets := options.GetBuildSecrets(os.Environ())
534-
// Ensure that build secrets do not make it into the runtime environment or the setup script:
535-
options.ClearBuildSecretsFromProcessEnvironment()
536-
537533
kOpts := &config.KanikoOptions{
538534
// Boilerplate!
539535
CustomPlatform: platforms.Format(platforms.Normalize(platforms.DefaultSpec())),
@@ -558,7 +554,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
558554
},
559555
ForceUnpack: true,
560556
BuildArgs: buildParams.BuildArgs,
561-
BuildSecrets: buildSecrets,
557+
BuildSecrets: opts.BuildSecrets,
562558
CacheRepo: opts.CacheRepo,
563559
Cache: opts.CacheRepo != "" || opts.BaseImageCacheDir != "",
564560
DockerfilePath: buildParams.DockerfilePath,
@@ -1258,6 +1254,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
12581254
if opts.CacheRepo != "" {
12591255
destinations = append(destinations, opts.CacheRepo)
12601256
}
1257+
12611258
kOpts := &config.KanikoOptions{
12621259
// Boilerplate!
12631260
CustomPlatform: platforms.Format(platforms.Normalize(platforms.DefaultSpec())),
@@ -1282,6 +1279,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
12821279
},
12831280
ForceUnpack: true,
12841281
BuildArgs: buildParams.BuildArgs,
1282+
BuildSecrets: opts.BuildSecrets,
12851283
CacheRepo: opts.CacheRepo,
12861284
Cache: opts.CacheRepo != "" || opts.BaseImageCacheDir != "",
12871285
DockerfilePath: buildParams.DockerfilePath,

integration/integration_test.go

+1-31
Original file line numberDiff line numberDiff line change
@@ -1116,36 +1116,6 @@ func TestUnsetOptionsEnv(t *testing.T) {
11161116
}
11171117
}
11181118

1119-
func TestUnsetSecretEnvs(t *testing.T) {
1120-
t.Parallel()
1121-
1122-
// Ensures that a Git repository with a devcontainer.json is cloned and built.
1123-
srv := gittest.CreateGitServer(t, gittest.Options{
1124-
Files: map[string]string{
1125-
".devcontainer/devcontainer.json": `{
1126-
"name": "Test",
1127-
"build": {
1128-
"dockerfile": "Dockerfile"
1129-
},
1130-
}`,
1131-
".devcontainer/Dockerfile": "FROM " + testImageAlpine + "\nENV FROM_DOCKERFILE=foo",
1132-
},
1133-
})
1134-
ctr, err := runEnvbuilder(t, runOpts{env: []string{
1135-
envbuilderEnv("GIT_URL", srv.URL),
1136-
envbuilderEnv("GIT_PASSWORD", "supersecret"),
1137-
options.EnvWithBuildSecretPrefix("FOO", "foo"),
1138-
envbuilderEnv("INIT_SCRIPT", "env > /root/env.txt && sleep infinity"),
1139-
}})
1140-
require.NoError(t, err)
1141-
1142-
output := execContainer(t, ctr, "cat /root/env.txt")
1143-
envsAvailableToInitScript := strings.Split(strings.TrimSpace(output), "\n")
1144-
1145-
leftoverBuildSecrets := options.GetBuildSecrets(envsAvailableToInitScript)
1146-
require.Empty(t, leftoverBuildSecrets, "build secrets should not be available to init script")
1147-
}
1148-
11491119
func TestBuildSecrets(t *testing.T) {
11501120
t.Parallel()
11511121

@@ -1175,7 +1145,7 @@ func TestBuildSecrets(t *testing.T) {
11751145
ctr, err := runEnvbuilder(t, runOpts{env: []string{
11761146
envbuilderEnv("GIT_URL", srv.URL),
11771147
envbuilderEnv("GIT_PASSWORD", "supersecret"),
1178-
options.EnvWithBuildSecretPrefix("FOO", buildSecretVal),
1148+
envbuilderEnv("BUILD_SECRETS", fmt.Sprintf("FOO=%s", buildSecretVal)),
11791149
}})
11801150
require.NoError(t, err)
11811151

options/options.go

+9
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ type Options struct {
8888
// IgnorePaths is the comma separated list of paths to ignore when building
8989
// the workspace.
9090
IgnorePaths []string
91+
// BuildSecrets is the list of secret environment variables to use when
92+
// building the image.
93+
BuildSecrets []string
9194
// SkipRebuild skips building if the MagicFile exists. This is used to skip
9295
// building when a container is restarting. e.g. docker stop -> docker start
9396
// This value can always be set to true - even if the container is being
@@ -323,6 +326,12 @@ func (o *Options) CLI() serpent.OptionSet {
323326
Description: "The comma separated list of paths to ignore when " +
324327
"building the workspace.",
325328
},
329+
{
330+
Flag: "build-secrets",
331+
Env: WithEnvPrefix("BUILD_SECRETS"),
332+
Value: serpent.StringArrayOf(&o.BuildSecrets),
333+
Description: "The list of secret environment variables to use " + "when building the image.",
334+
},
326335
{
327336
Flag: "skip-rebuild",
328337
Env: WithEnvPrefix("SKIP_REBUILD"),

options/secrets.go

-46
This file was deleted.

0 commit comments

Comments
 (0)