As security practices in software teams are increasingly shifting left, there’s been a trend for developers to get involved in the security of their applications. The most popular way to develop, build, test and run applications in modern software lifecycles is through the use of containers, of which Docker has made hugely popular, especially with orchestration tools like kubernetes becoming prolific in the container workload space. Security is often an afterthought when building applications however this sentiment is beginning to change with security becoming a built-in staple from the start.
Application security is a huge undertaking with many facets to consider. Some examples include:
- 3rd party libraries
- the platform the code is running on
- access to and from the host
- downstream or upstream services the application talks to
- secrets management, such as API keys, database passwords
The list is big. In this post, we’re going to hone in on the application container specifically. The post is aimed at reducing the attack surface of your container as much as possible and limiting how escalation can work in the container, tools that would be used by an attacker and injection of malicious code. Whilst it’s written with the aim for go code, you can of course apply this to other programming languages as well.
At it’s core, a multi stage docker build is essentially enabling you to end up with a single docker image, taking only what you need between stages. It looks like two dockerfiles smashed together into one.
Let’s describe what will happen in our build.
- In the first stage, we’ll use alpine base image for Go
- We then setup a working directory for our output
- We copy over the current directory our application lives in to the working directory in the previous step.
go buildwe build and compile the application to get our binary, and name it
- Now that we’ve got our binary we don’t need the building image anymore, nor the golang tools, so we can throw that away, and focus on creating our production-ready container.
- We start with
scratchas a base image, which is literally nothing. I particularly like Snyk’s definition of the scratch image:
The scratch image is the smallest possible image for docker. Actually, by itself it is empty (in that it doesn’t contain any folders or files) and is the starting point for building out images. In order to run binary files on a scratch image, your executables need to be statically compiled and self-contained. This means there is no compiler in the image so you’re left with just system calls. You also wouldn’t be able to login to the container either as there isn’t a shell unless you explicitly add one.
- From scratch, we use the latest alpine images to copy over the ca-certificates.crt file to the /etc/ssl/certs directory. Note that you don’t actually need this step if you’re not using HTTPS traffic.
- Then we’re copying the output of the build directory to the root of the scratch image, whilst setting the ownership.
- The next stage sets the user account to be a non-root user. If someone was to attack the container and escape the app running, they would not be the root user of the container.
- The last two steps just set the port to expose and the container’s entrypoint, which is the name we gave our binary in step 4.
FROM golang:1.18-alpine as builder WORKDIR /build ADD . . RUN CGO_ENABLED=0 go build -o app_name -buildvcs=false FROM scratch COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --chown=0:0 --from=builder /build/* / USER 65534 EXPOSE 8080:8080 ENTRYPOINT [ "/app_name" ]
There’s quite a few neat things going on here, my favourite is probably the use of the scratch container, once we have a built binary. Of course, this means that the binary needs to be self-sufficient. The only thing you have are system calls. There isn’t even a shell. Additionally, the container is wildly smaller than what you may be using now since it’s really only containing a binary of the app and a .crt file.