Preventing secret leaks in Docker Images
Over the years, I’ve seen multiple teams get caught out by their approach to using Docker Images. There are quite a few quirks to dockerfiles that don’t entirely make sense initially, especially around layers and how you can chain certain things together to build more efficient images. This does occasionally also mean that secrets end up in the built images, which is not great.
COPY
The primary culprit of things existing in Docker Images that have no business being there is the use of COPY . /app. It’s less of an issue with multi-stage builds, but from what I’ve seen around Python and Node images, people seem to use a single stage. Without a .dockerignore file, many hidden files and directories will be copied to the image; which can lead to things like .env and .git being accessible if the app or web server have been mis-configured.
Instead, always specify exactly which files and folders to copy in your Dockerfile.
SECRETS
In the Node related images, I found teams were using internal NPM packages which require authentication to pull them down. We store the NPM token in CI as a global variable and pass it to the Dockerfile as a build argument.
An example of what that command looks like is: docker build -t base -f Dockerfile --build-arg NPM_TOKEN=$NPM_TOKEN .
Then, in the dockerfile, the argument (line 2) is used to set the environment variable (line 3).
FROM node:alpine
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN"
RUN npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN npm install
When you build it locally, I’ve noticed Docker outputs warnings for this, but that was not always the case.
2 warnings found (use docker –debug to expand):
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG “NPM_TOKEN”) (line 5)
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV “NPM_TOKEN”) (line 6)
Using a tool like dive, we can look into the built docker image and see what happened in each layer. On the left-hand side, we can see that the RUN command has used the NPM_TOKEN environment. On the right-hand side, we can see that the npm set command created a file under /root/.npmrc which we can also extract out of the layer to retrieve the token.
FIX
There are a few options for preventing leaking these secrets, both as environment variables and as files.
Multi-stage builds
Multi-stage builds offer a better way to prevent accidental inclusion of unwanted files and folders in the image.
docker build -t multistage -f Dockerfile.multistage --build-arg NPM_TOKEN=npm_XXXXX .
The first half of the dockerfile is exactly the same as the original one. The difference is that we have a second stage we copy the contents of /app/node_modules from the previous stage into the new one.
FROM node:alpine AS builder
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN"
RUN npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN npm install
FROM node:alpine AS runner
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
Though Docker also outputs warnings for this, as we are still using ARGs and ENVs for sensitive data.
2 warnings found (use docker –debug to expand):
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG “NPM_TOKEN”) (line 4)
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV “NPM_TOKEN”) (line 5)
When looking through dive again, we can see that we are no longer leaking the NPM_TOKEN environment variable and the /root/.npmrc.
Mounting a secret as an environment variable
A better solution is to use secret mounts. This allows you to pass sensitive information into your images without the risk of having it persist there.
For the environment variable approach, we can use the --secret flag and use the env= option to specify what to pass in.
export NPM_TOKEN=npm_XXXXX
docker build -t secret -f Dockerfile.secret --secret id=npm,env=NPM_TOKEN .
In our Dockerfile, we can then use --mount=type=secret, as part of the RUN command to safely pass the secret in. As we’re doing npm set, to not have it write the .npmrc file were chaining a rm command; there might be a better way to do this though I haven’t looked into it.
FROM node:alpine
RUN --mount=type=secret,id=npm,env=NPM_TOKEN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN" && npm whoami && rm -rf /root/.npmrc
COPY package.json package-lock.json /app/
WORKDIR /app
RUN --mount=type=secret,id=npm,env=NPM_TOKEN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN" && npm install && rm -rf /root/.npmrc
If we look at dive, we can see that the NPM_TOKEN does not show up in the build layers nor do we see anything out of the ordinary in the layer contents on the right.
Mounting a secret as a file
In this scenario, using NPM, the simplest solution is to mount the secret as a file. Each of the previous Dockerfiles included npm set, which created a .npmrc file. But we can create the file outside of the docker build and then use the --mount=type-secret, option again.
echo "//registry.npmjs.org/:_authToken=npm_XXXXX" > .npmrc
docker build -t secretfile -f Dockerfile.secretfile --secret id=npm,src=.npmrc .
This time we use src= in the flag option and then in the dockerfile we can specify a target of where to mount that file. In this example dockerfile, which runs as root, we mount the local .npmrc file to /root/.npmrc during the RUN step.
It also means we now run the NPM commands, such as whoami or install without having to configure the registry and credentials with an additional command.
FROM node:alpine
RUN --mount=type=secret,id=npm,target=/root/.npmrc npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN --mount=type=secret,id=npm,target=/root/.npmrc npm install
Similar to the secret environment option, we don’t see the secret mounted in the layer contents when looking through dive.