How to secure your Docker images with Docker BuildKit and GitHub Actions

How to secure your Docker images with Docker BuildKit and GitHub Actions

ยท

4 min read

The issue with leaking secrets

It has been known for a long time that docker images can be prone to leaking secrets. That means in case an attacker gets access to your Docker images, they can find your secrets by inspecting the docker history to look for build arguments or look through intermediate layers to find any secret files that might be present in previous layers.

So, if you can't use ENV, ARG or COPY, how do you get the secret into your image to e.g. access a private repository via .npmrc? (I'll be using the example of accessing a private NPM repository with an access token NPM_TOKEN which is put in a .npmrc throughout this article but the solution applies to any secret.) The recommended solutions that avoid this problem are multi-stage builds and Docker Buildkit. The multi-stage build separates the Dockerfile into a builder and a runner stage where the runner stage only gets the minimal data needed to run in production and doesn't inherit any intermediate layers from the build process. You can find an example here. While this is a good solution, there are some downsides to it: the Dockerfile is more complex and the build can take a bit longer. Additionally, SonarCloud has started to look for the usage of ENV or ARG to handle secrets. It's not smart enough to identify a multi-stage build, so I explored the other option: Docker Buildkit.

Using Docker Buildkit on Desktop

The mount file option

On my machine, it was very easy to set it up as BuildKit is already enabled by default on Docker Desktop. Assuming you have the file with the secret present on the host machine like ./.npmrc, you pass it to the docker buildx build command instead of docker build with the --secret option and give it a name via the id notation:

> docker buildx build --secret id=npm,src=./.npmrc .

Then you can use it inside the Dockerfile with the --mount option by referring to the id=npm secret:

RUN --mount=type=secret,id=npm,target=./.npmrc \
    npm ci

Using the environment option

While it is documented in the Docker CLI reference documentation, it wasn't immediately obvious to me that you can use the --secret option also for environment variables and not just for files. For this, you can use --secret id=NPM_TOKEN when running docker build with BuildKit, where the id should refer to an actual environment variable that contains a secret. You can then use it with

RUN --mount=type=secret,id=NPM_TOKEN \
    NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) \
    npm ci

Note that the --mount command only puts the content of NPM_TOKEN in a file under /run/secrets. To use it as an environment file again, you must load it with cat. This way is a bit cleaner as you don't have to generate a .npmrc with the token inside but instead can keep it clean and checked into git with just

//registry.npmjs.org/:_authToken=${NPM_TOKEN}

and whatever other settings you want to have in there.

Making it work with GitHub Actions

As hinted at before, the setup is only that easy on Docker. Depending on your CI/CD provider, you may have to change the docker settings to enable it. With GitHub Actions (GHA), it's luckily quite easy as there is an action from Docker:

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

However, when you add that step in front of your docker build and docker push step, you will stumble over the fact that docker push cannot find the docker image you build previously. The reason is that for docker buildx we have to specify the export action for the build result with --output. So our build command becomes

> docker buildx build \
    --output='type=docker' \
    --secret 'id=NPM_TOKEN' \
    -t $(IMAGE):$(TAG) .

> docker push $(IMAGE):$(TAG)

And behold: We have successfully pushed an image using BuildKits secure mounts with our CI/CD pipeline ๐ŸŽ‰.

Bonus: Docker BuildKit Secret Mount in Docker Compose

You might also be using docker-compose to simplify your local developer experience. The approach above is compatible with Docker Compose. Since last summer, it supports files, externally created docker secrets or environment variables but many other resources don't mention the environment variable option yet. First, you need to add a top-level secrets object to your docker-compose.yml containing a key describing the secret you want to use. In our case, we will add the NPM_TOKEN secret, which uses the NPM_TOKEN environment variable. Then, we use it in the description of our service my-service in the build.secrets array (make sure you add the secrets under build and not under my-service. The latter will result in a run-time secret that is injected when the container is run but we need a build-time secret that is injected when we build the image). So overall, we have something like this

version: "3.8"
services:
  my-service:
    build:
      context: .
      target: builder
      secrets:
        - NPM_TOKEN
secrets:
  NPM_TOKEN:
    environment: NPM_TOKEN

And there you have it. Now you can benefit from Docker BuildKit in most common scenarios and never worry about leaking your secrets with Docker images again.

Did you find this article valuable?

Support Bijan Chokoufe Nejad by becoming a sponsor. Any amount is appreciated!