Ops.Cafe
Enhancing Security Through Minimalism and Simple Configuration

Enhancing Security Through Minimalism and Simple Configuration

Posted on
- 9 min read
Originally published on:
linkedin.com

When I started learning about Docker, back in 2016, I found it was quite common to compare Docker to Virtual Machine (VM) environments. I am guilty of doing this myself, when teaching it to colleagues. While these comparisons highlight certain similarities, particularly in terms of environment isolation, I've come to acknowledge the fundamental differences in their underlying technologies.

One significant misconception I had was the notion that, similar to a VM, a Docker container required a complete OS file system. This led me to choose fully-featured base Docker images, like Ubuntu or Alpine, even though I rarely utilised the majority of tools bundled with these images. This preference was rooted in my persistent mental comparison with VMs, and the nuanced topic of optimizing Docker images was rarely discussed, often remaining at the surface level.

Looking from a security perspective I am seeing that the vast majority of Docker images are using default configuration settings. One very important configuration that I believe is under utilised is the user assigned to a container at runtime. In the defaults settings, a Docker container will run the application that was packaged in a Docker image using the user with the highest permissions - the root user for Linux containers. This action can inadvertently grant an attacker access to essential tools, potentially aiding the attacker in escalating privileges and circumventing the isolating environment that Docker aims to provide.

As such, I started looking into ways to mitigate the security risks. Thus, in this article I will explore two ways to make your Docker images more resilient to misconfiguration and attacks. Building a minimalistic Docker image

A Docker image is a lightweight package system composed from multiple layers, containing everything necessary for running software — from code and runtime to libraries and system tools. Essentially, it functions as a snapshot of a file system, laying the foundation for a Docker container.

To construct Docker images, developers use a set of instructions specified in a file, which by default is named Dockerfile. This file houses a series of commands that dictate the construction process of the image. When building a Docker image the resulting package encapsulates the application and its dependencies into a snapshot of a file system. This makes it effortless to store and deploy and ensures consistent execution across multiple environments. As long as the image is not modified, the packaged application will always have the exact same execution environment.

The image build process typically begins with the 'FROM' command, indicating the base layer. In more recent Docker versions multiple 'FROM' instructions can be found in the same Dockerfile facilitating the creation of a multi-stage build process. In multi-stage build scenarios, the 'FROM' and 'COPY' instructions can also reference the output from a previous build stage.

An quick example of a Dockerfile with multiple build stage is:

FROM {base_image}:{base_tag} as build_layer

... // additional instructions here

FROM build_layer as final_version

... // copy only the necessary files from the previous build

This gives us the flexibility and ease of managing a single Dockerfile to construct images for various purposes, such as development, testing, and production versions without incorporating tools that are only required during the build process. Where does Docker Scratch fit in?

Docker Scratch is not a specific image, but rather a template for building single purpose minimal Docker images. When referring to a Docker image built from Scratch, it means the image is essentially empty, without any pre-installed operating system, libraries, or binaries, enforcing the inclusion of only the essential components required for the application to run. This minimalistic approach allows developers to create highly specialized and lightweight images tailored to the specific needs of their applications. Providing developers precise control over the image contents, reducing potential security vulnerabilities and ensuring a minimal attack surface.

Docker Scratch, by virtue of its minimalistic nature, contributes to enhanced security by eliminating unnecessary components and dependencies, making it a good choice for security-focused containerization strategies. Leveraging build steps and Scratch

Now that we talked about Docker build steps and Docker Scratch let's build a simple Go web service image that will use HTTPS with self-signed certificates in order to showcase a few advantages and disadvantages of using Docker Scratch.

To follow along, just clone the repository for this article:

git clone https://github.com/Hexagonal-Software/scratch-demo.git

In the Dockerfile found in the repository, and displayed in the next section, there are two build steps:

# base step to build the executable
FROM golang:latest as build_phase

COPY ./ /project

WORKDIR /project

RUN CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o dist/demoexe .
RUN chmod +x ./dist/demoexe

# build the production final image
FROM scratch as PROD

LABEL "Maintainer"="Denis Rendler <[email protected]>" \
      "App"="Scratch Demo"

# copy self-signed certs
COPY --chown=1000 ./server.key /scratch-demo/server.key
COPY --chown=1000 ./server.crt /scratch-demo/server.crt

# copy the executable from previous step
COPY --from=build_phase /project//dist/demoexe /scratch-demo/demoexe
# necessary in order to enable https
COPY --from=build_phase /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

WORKDIR /scratch-demo

ARG https_port=443
EXPOSE 443/tcp

USER 1000:1000

ENTRYPOINT ["/scratch-demo/demoexe"]

  • the first step, called 'build_phase', starts with the base image of Go in order to use Go tools to build our executable. As these tools are only necessary during the building of our application they will serve no purpose after this stage.

  • the second step copies the newly generated executable, the required system files to enable SSL and configures the image to start our executable when the image is used for running a container. You can also notice that this step uses 'scratch' in the FROM instruction.

To build the image, run the following command:

docker build -t scratch-demo:0.1 -f Dockerfile .

Now, to run a container use the following command:

docker run --rm --name=scratch-demo -dti -p 443:443 scratch-demo:0.1

If you open a browser and go to https://localhost you should see our scratch-demo welcome message. Benefits and trade-offs of Scratch based images

In addition to the immediate advantages of a minimal image that is faster to distribute, there's also an inherent improvement in security.

If an attacker manages to exploit a vulnerability within the packaged application to gain a remote code execution inside the infrastructure layer, escaping the container constraints becomes more challenging due to the absence of any tools within the container that could assist in further escalating the intrusion.

Line 31 in the Dockerfile, introduces an additional security measure by setting the default user for running the container as an user with ID 1000. This step further restricts the actions available to a potential attacker, bolstering the overall security posture of the containerized environment. By using the USER instruction, we switch the default user context running the application inside the container to a less privileged user.

During my years working with Docker I found that while Docker Scratch based images offer advantages, such as minimalism and enhanced security, they also come with certain considerations and pitfalls, in which I spent quite some time before I was able to find a solution, some of which I will enumerate next:

  1. Dependency Management Challenges: Creating a Docker image from Scratch means starting with a bare minimum, which may require explicit inclusion of all dependencies. An example of which can be seen in this article's Dockerfile when we manually copied the system SSL certificates, which are generally already present in other images. This can lead to increased complexity in managing dependencies manually, especially for applications with intricate requirements.

  2. Compatibility Issues: Some applications may assume the presence of specific libraries or binaries that are absent in a Scratch based image. Ensuring compatibility might demand additional effort, potentially making it less convenient for certain use cases.

  3. Limited Debugging Capabilities: Debugging inside a minimalistic container can be challenging, as Scratch images lack many tools typically available in more feature-rich base images. For example, trying to execute commands in the container using a 'docker exec' command might lead to error messages like: 'executable file not found'. This is because no other application, not even a shell is installed automatically in a Scratch based image.

  4. Increased Development Time: Constructing a Docker image from Scratch demands a more hands-on approach, specifying each component and dependency manually. This meticulous process may extend development time compared to using more comprehensive base images.

  5. Learning Curve for Beginners: For those new to Docker or containerization, starting with Scratch might pose a steeper learning curve. Beginners may find it easier to work with more complete base images before transitioning to Scratch-based images.

For example, a common use case is if you would like to explore the file system on which the container is running on, for a Scratch based image you will need to make use of the 'docker cp' command to dump the file system as a tar archive, instead of just being able to use system commands like 'ls'. Leading to beginners having to learn more advanced Docker concepts for doing smaller tasks. Seeking the balance between functionality, efficiency and security when building Docker images

By using Docker Scratch, developers gain the power to fine tune images to the specific needs of their applications. The intentional exclusion of unnecessary tools and the ability to set stringent user constraints contribute to a containerized environment that stands resilient against potential security threats. Incorporating the USER instruction in Dockerfiles, as seen in our example above, allows developers to restrict the default container's execution to a non-root user, further fortifying the security posture.

However, as with any technology, Docker Scratch is not a one-size-fits-all solution. It demands a thoughtful consideration of trade-offs and potential challenges. Developers must navigate the intricacies of managing dependencies, address compatibility concerns, and strike a balance between minimalism and practicality.

When you are considering packaging an application as a Docker image, the choice of a base image plays a great role in shaping the efficiency and security of the application.

I hope that through this article I was able to spark your curiosity on the immediate benefits of a smaller image footprint for faster deployments which are complemented by the robust security posture achieved through deliberate omission of unnecessary components.