Secure-by-design Docker Compose
Illustration of Xenia (the real Linux mascot) at her computer, surrounded by retro computing stuff and a stack of mangas behind her. Source at end

Secure-by-design Docker Compose

You may or may not be aware but Docker has some security options by default, which is good. You may also be aware that many Docker images on Docker Hub are woefully insecure, and provide very little or no meaningful security boundaries between the code running in those images and the machines they run on.

Thankfully, Docker provides some features that can help mitigate some of these, and you can leverage them in your Compose files without having to rebuild images!

Let's look at some low hanging fruit in that regard. We will look at how to tighten the default options for your containers, and for each one I will show you how to loosen those options as needed.

Note: This guide is aimed at developers who do not specialize in container development. If you're a security engineer or work on containerization runtimes directly, this is probably stuff you already know

First, here's the TLDR:

# compose.yml
# Define a custom YAML anchor with all the hardening options
x-common-hardening-opts: &hardening
  security_opt:
    - "no-new-privileges=true"
  cap_drop:
    - ALL
  user: "${UID:-1000}:${GID:-1000}"
  runtime: runsc

services:
  my_service:
    <<: *hardening # Include the anchor        

Let's break this down:

YAML Anchors

Firstly, let's look at that syntax. I'm describing a YAML anchor which is basically a reusable snippet, and I include it on every service I run. This is how I achieve the "by default" part of "secure by default".

You can read more about anchors here if you're a robot or here if you're a person.

security_opt

This parameters lets you set seccomp options for the container. The `no-new-privileges=true` option prevents the processes in the container from switching users, which will make privilege escalation much harder.

You can read more about the options available in Docker here.

Caveats: If processes in the container need to switch users at runtime, they won't be able to.

To disable this:

# If your container needs to change users, disable this option like so
service:
  my_service:
    <<: *hardening
    security_opt: []        

cap_drop:

Docker runs on Linux, always (on Windows, Docker actually runs a virtual machine on which it runs its containers). The Linux kernel has a notion of capabilities which define what a process may or may not do on a system. Some of these are used to leverage attacks on a system.

Docker makes some assumptions about what capabilities your average container needs. You can see the capabilities they add by default here: https://github.com/moby/moby/blob/master/oci/caps/defaults.go#L6-L19

That said, if we want a secure by design method of deploying Compose projects, we should follow the least privilege principal. The `cap_drop` feature comes in handy here, allowing us to drop capabilities from the container, and in this case we are dropping ALL capabilities.

Since many processes won't work with zero capabilities, the idea is to try to run the containers with none, then re-enable them as needed. For example, Caddy needs the following:

service:
  caddy:
    <<: *hardening
    cap_add:
      - NET_RAW
      - NET_BIND_SERVICE
      - NET_ADMIN        

In this way, the container only has the minimum set of capabilities it needs to do its job.

Caveats: Enumerating over the default enabled capabilities and adding them one-by-one is tedious.

To disable this:

# If you want the default capabilities enabled on a service
service:
  my_service:
    <<: *hardening
    cap_drop: []        

Running as anybody other than root

Many Docker images run as the root user when their containers run code. This is obviously woefully poor practice for security concerns.

The macro above sets the user to the `$UID` and `$GID` of the environment Docker's running in, with `1000:1000` as the defaults (by default this is the first user on the system - usually your user).

Since Docker Compose picks up environment variables from dotenv files found in the project's work directory, you can easily update them like that:

# .env
UID=65532
GID=65532        

With this, Docker will run processes with the `$UID` and `$GID` set to 65532

Caveats: Some images don't play nicely when running as unexpected users. They might try to interact with parts of the filesystem they ship with that have restricted privileges.

To disable:

# If want to let the container run as the user it was designed to run as (or root)
service:
  my_service:
    <<: *hardening
    user: ""        

runtime: runsc (gVisor)

As mentioned earlier, Docker provides very little meaningful security boundaries, and one reason for that is that process in the containers interact directly with the host kernel. If there's an exploitable bug in the host's kernel, a process in your container and try to conduct a container escape leveraging it.

That's where gVisor comes in. Google created this alternative runtime for Docker that secure shims syscalls to the kernel. Processes no longer interact directly with the host kernel, but with the shims provided by gVisor.

A diagram visualizing how gVisor acts as a boundary between containers and the host kernel

To install gVisor, run the following commands (as documented in their getting started page):

To download and install the latest release manually follow these steps:
(
  set -e
  ARCH=$(uname -m)
  URL=https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}
  wget ${URL}/runsc ${URL}/runsc.sha512 \
    ${URL}/containerd-shim-runsc-v1 ${URL}/containerd-shim-runsc-v1.sha512
  sha512sum -c runsc.sha512 \
    -c containerd-shim-runsc-v1.sha512
  rm -f *.sha512
  chmod a+rx runsc containerd-shim-runsc-v1
  sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin
)
        
To install gVisor as a Docker runtime, run the following commands:
/usr/local/bin/runsc install        

Then restart the Docker service.

gVisor is its own beast and going into detail about that here is out of scope, but I will say it has a lot of limitations with regard to networking and particularly DNS.

Caveats: DNS can be a nightmare

To disable and just use the default runtime:

# If want to let the container run as the user it was designed to run as (or root)
service:
  my_service:
    <<: *hardening
    runtime: default-runtime        

Conclusion

Docker bad. Just kidding (sorta), but it is important to be mindful of what you're running on your system, that Docker isn't a security boundary, and that you have mitigation tactics you can employ to protect your system.

Good luck!


Cover by lizzieliz: source

Dan Matheson

Kubernetes Specialist | Platform Engineering Consultant | Bar Owner at Turbo Haüs | Technically a Cyborg

4 个月

Very well written and informative thanks for putting this together

Martin ▽ Sweeny

Software / Ops / Security / FOSS

4 个月

If you think this article was interesting, you might find something else interesting: me! And, interestingly enough, I'm looking for work! So if your team is looking for developer who is mindful about security or is just really funny, you should hit me up ??

Thea Silayro

Sr. Community Manager @ G2i │ Promoting developer health through community

4 个月

So well-written, Martin ▽ Sweeny!

Martin ▽ Sweeny

Software / Ops / Security / FOSS

4 个月

If anybody has anything they'd like to share - especially in the way of corrections or improvements - feel free to correct me :)

要查看或添加评论,请登录

社区洞察

其他会员也浏览了