ONBUILD Can Keep Secrets Out of Docker Images

image

Introduction

In this post I’ll share how I used Docker’s ONBUILD instruction to avoid baking in a secret and use Managed Identities instead.

The Problem

I have a custom .NET SDK base image that sets up the build environment for many applications to keep things DRY and standardized. In the Dockerfile of the base image, I create a NuGet.config file for accessing a private NuGet repository. Originally, I baked in a Personal Access Token (PAT), and used a multistage Dockerfile to avoid leaking it. That was fine, except PATs expire, and then stuff breaks.

To solve that, I decided to use Managed Identities in Azure DevOps pipelines so I never have to worry about expiring credentials. The problem with that is that the NuGet credentials for the Managed Identity aren’t known when the base image is built. What I needed was a way to have common code in the base Dockerfile, but pass in the credentials at build time. The Dockerfile ONBUILD instruction solved that problem.

Here’s a simplified version of the original Dockerfile (this sample will work, even with the dummy source URL.)

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG nugetPassword

RUN echo Other setup for build

RUN dotnet nuget add source "https://..." \
                -n MyNuget -u ignoredWhenUsingPAT --store-password-in-clear-text \
                -p ${nugetPassword}

To build the image with a super secret password:

docker build -t base -f ./Dockerfile-base --build-arg nugetPassword=Monkey123! .

That works, but there’s a security problem: ARG values are baked into the image layer metadata. Anyone who can pull the image from the registry can run docker history and see the password in plain text.

> docker history base
IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
b6437fdd4e05   3 seconds ago   RUN |1 nugetPassword=Monkey123! /bin/sh -c d…   3.87kB    buildkit.dockerfile.v0
<missing>      3 seconds ago   ARG nugetPassword=Monkey123!                    0B        buildkit.dockerfile.v0

There’s the NuGet PAT, right there in the layer history. For me it wasn’t a huge deal since you have to have access to pull the image to see it, and you’d probably have access to the NuGet feed anyway, but it’d probably be flagged in a security audit.

I got around that by using a second stage and copying the NuGet.Config from the first stage. That way the password didn’t show up in the history, but it is still in the NuGet.Config file, so someone could shell into the image and read it.

Using ONBUILD

Before diving into the specific solution, here’s a contrived example demonstrating how ONBUILD works.

Docker’s ONBUILD instruction allows you to put instructions in a base image that get added to a derived image when it is built. It’s like putting macros in the base image that are injected at the top of the derived image’s Dockerfile.

Here’s my example of using ONBUILD for compiling an app. I’ll create a base image with all the tools to “compile” the app. In this case it’s just cowsay. The base image installs it and creates a compile.sh script that runs it.

FROM ubuntu AS base

WORKDIR /app

RUN echo 'echo ">>> Compiling..."\ncat ./cowsay.txt | cowsay\necho "<<< All done!"' > compile.sh . \
    chmod +x compile.sh

RUN apt-get update && apt-get install -y cowsay && rm -rf /var/lib/apt/lists/*
ENV PATH="${PATH}:/usr/games"

ONBUILD WORKDIR /app
ONBUILD COPY ./cowsay.txt .
ONBUILD RUN ls -la
ONBUILD RUN ./compile.sh

When it is built, notice that there are no ONBUILD instructions in the log or history:

> docker build -f ./Dockerfile-base -t base-cowsay .
...
 => => transferring context: 110B
 => [2/5] WORKDIR /app
 => [3/5] COPY compile.sh .
 => [4/5] RUN chmod +x compile.sh
 => [5/5] RUN apt-get update && apt-get install -y cowsay && rm -rf /var/lib/apt/lists/*
 => exporting to image
 ...
> docker history base-cowsay
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
6eac5ad9e27e   50 minutes ago   ENV PATH=/usr/local/sbin:/usr/local/bin:/usr…   0B        buildkit.dockerfile.v0
<missing>      50 minutes ago   RUN /bin/sh -c apt-get update && apt-get ins…   52.2MB    buildkit.dockerfile.v0
<missing>      50 minutes ago   RUN /bin/sh -c chmod +x compile.sh # buildkit   73B       buildkit.dockerfile.v0
<missing>      50 minutes ago   COPY compile.sh . # buildkit                    73B       buildkit.dockerfile.v0

The derived Dockerfile looks like this:

FROM base-cowsay

RUN echo "Derived image build complete"

When it is built, we’ll see all the ONBUILD instructions executed as part of the build. It “compiles” cowsay.txt that was copied via the base image’s ONBUILD COPY ./cowsay.txt . instruction:

 => [1/2] FROM docker.io/library/base-cowsay:latest
 => [internal] load build context
 => => transferring context: 97B
 => [2/6] ONBUILD WORKDIR /app
 => [3/6] ONBUILD COPY ./cowsay.txt .
 => [4/6] ONBUILD RUN ls -la
 => [5/6] ONBUILD RUN ./compile.sh
 => [6/6] RUN echo "Derived image build complete"
 => exporting to image

Adding --progress plain to the build command shows the “compiler” output:

#9 [5/6] ONBUILD RUN ./compile.sh
#9 0.342 >>> Compiling...
#9 0.356  ______________________________________
#9 0.356 / ONBUILD instructions executed during \
#9 0.356 \ build of derived image               /
#9 0.356  --------------------------------------
#9 0.356         \   ^__^
#9 0.356          \  (oo)\_______
#9 0.356             (__)\       )\/\
#9 0.356                 ||----w |
#9 0.356                 ||     ||
#9 0.357 <<< All done!

The history shows the ONBUILD instructions as if they were part of the derived image (in reverse order):

> docker history derived-cowsay
IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
5b4167870dab   About a minute ago   RUN /bin/sh -c echo "Derived image build com…   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN /bin/sh -c ./compile.sh # buildkit          0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN /bin/sh -c ls -la # buildkit                0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY ./cowsay.txt . # buildkit                  60B       buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /app                                    0B        buildkit.dockerfile.v0

Combining ONBUILD with Build Secrets

By using ONBUILD, I can defer getting the secret until build time. Here’s the updated base Dockerfile with the nuget add now in an ONBUILD instruction, and no ARG instruction:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build

RUN echo Other setup for build

ONBUILD RUN --mount=type=secret,id=nugetPassword,env=nugetPassword \
        dotnet nuget add source "https://..." \
                -n MyNuget -u ignoredWhenUsingPAT --store-password-in-clear-text \
                -p ${nugetPassword}

The base image now only has one layer.

> docker history base-onbuild
IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
df2ae0933928   6 seconds ago   RUN /bin/sh -c echo "Other setup for build"    0B        buildkit.dockerfile.v0

The only change required to the derived Dockerfile is to change the FROM line to use base-onbuild. To build it, instead of using ARG to pass the secret, I used the --mount=type=secret parameter to get the nugetPassword passed in at build time:

export NUGET_PAT=Monkey123!
docker build -f ./Dockerfile-onbuild -t onbuild --secret id=nugetPassword,env=NUGET_PAT .

Using --secret is a topic for another post, but it is the preferred way to pass secrets into builds. See the links below for more info.

If you want to verify that the secret is set, you can add this line in the first (build) stage of the derived Dockerfile:

RUN cat /root/.nuget/NuGet/NuGet.Config

As a final note, in my Azure DevOps pipeline, setting the NUGET_PAT environment variable before the Docker@2 task did not work. Instead, I used a temporary file and --secret id=nugetPassword,src=$(Agent.TempDirectory)/nuget-password.txt

Summary

In this post, I shared how to use Docker’s ONBUILD instruction to keep secrets out of base images. This allows you to have common build logic in the base image, but defer getting secrets until build time. For my particular scenario, it allowed me to use Managed Identities in Azure DevOps pipelines without baking a PAT into the base image.