Dockerfile Security Best Practices

Container security is a broad problem space and there are many low hanging fruits one can harvest to mitigate risks. A good starting point is to follow some rules when writing Dockerfiles.

I’ve compiled a list of common security issues and how to avoid them. For every issue I’ve also written an Open Policy Agent (OPA) rule ready to be used to statically analyze your Dockerfiles with conftest. You can’t shift more left than this!

You can find the .rego rule set in this repository. I appreciate feedback and contributions.

Do not store secrets in environment variables

Secrets distribution is a hairy problem and it’s easy to do it wrong. For containerized applications one can surface them either from the filesystem by mounting volumes or more handily through environment variables.

Using ENV to store secrets is bad practice because Dockerfiles are usually distributed with the application, so there is no difference from hard coding secrets in code.

How to detect it:

secrets_env = [
    "passwd",
    "password",
    "pass",
 #  "pwd", can't use this one   
    "secret",
    "key",
    "access",
    "api_key",
    "apikey",
    "token",
    "tkn"
]

deny[msg] {    
    input[i].Cmd == "env"
    val := input[i].Value
    contains(lower(val[_]), secrets_env[_])
    msg = sprintf("Line %d: Potential secret in ENV key found: %s", [i, val])
}

Only use trusted base images

Supply chain attacks for containerized application will also come from the hierarchy of layers used to build the container itself.

The main culprit is obviously the base image used. Untrusted base images are a high risk and whenever possible should be avoided.

Docker provides a set of official base images for most used operating systems and apps. By using them, we minimize risk of compromise by leveraging some sort of shared responsibility with Docker itself.

How to detect it:

deny[msg] {
    input[i].Cmd == "from"
    val := split(input[i].Value[0], "/")
    count(val) > 1
    msg = sprintf("Line %d: use a trusted base image", [i])
}

This rule is tuned towards DockerHub’s official images. It’s very dumb since I’m only detecting the absence of a namespace.

The definition of trust depends on your context: change this rule accordingly.

Do not use ‘latest’ tag for base image

Pinning the version of your base images will give you some peace of mind with regards to the predictability of the containers you are building.

If you rely on latest you might silently inherit updated packages that in the best worst case might impact your application reliability, in the worst worst case might introduce a vulnerability.

How to detect it:

deny[msg] {
    input[i].Cmd == "from"
    val := split(input[i].Value[0], ":")
    contains(lower(val[1]), "latest"])
    msg = sprintf("Line %d: do not use 'latest' tag for base images", [i])
}

Avoid curl bashing

Pulling stuff from internet and piping it into a shell is as bad as it could be. Unfortunately it’s a widespread solution to streamline installations of software.

wget https://cloudberry.engineering/absolutely-trustworthy.sh | sh

The risk is the same framed for supply chain attacks and it boils down to trust. If you really have to curl bash, do it right:

  • use a trusted source
  • use a secure connection
  • verify the authenticity and integrity of what you download

How to detect it:

deny[msg] {
    input[i].Cmd == "run"
    val := concat(" ", input[i].Value)
    matches := regex.find_n("(curl|wget)[^|^>]*[|>]", lower(val), -1)
    count(matches) > 0
    msg = sprintf("Line %d: Avoid curl bashing", [i])
}

Do not upgrade your system packages

This might be a bit of a stretch but the reasoning is the following: you want to pin the version of your software dependencies, if you do apt-get upgrade you will effectively upgrade them all to the latest version.

If you do upgrade and you are using the latest tag for the base image, you amplify the unpredictability of your dependencies tree.

What you want to do is to pin the base image version and just apt/apk update.

How to detect it:

upgrade_commands = [
    "apk upgrade",
    "apt-get upgrade",
    "dist-upgrade",
]

deny[msg] {
    input[i].Cmd == "run"
    val := concat(" ", input[i].Value)
    contains(val, upgrade_commands[_])
    msg = sprintf(“Line: %d: Do not upgrade your system packages", [i])
}

Do not use ADD if possible

One little feature of the ADD command is that you can point it to a remote url and it will fetch the content at building time:

ADD https://cloudberry.engineering/absolutely-trust-me.tar.gz

Ironically the official docs suggest to use curl bashing instead.

From a security perspective the same advice applies: don’t. Get whatever content you need before, verify it and then COPY. But if you really have to, use trusted sources over secure connections.

Note: if you have a fancy build system that dynamically generate Dockerfiles, then ADD is effectively a sink asking to be exploited.

How to detect it:

deny[msg] {
    input[i].Cmd == "add"
    msg = sprintf("Line %d: Use COPY instead of ADD", [i])
}

Do not root

Root in a container is the same root as on the host machine, but restricted by the docker daemon configuration. No matter the limitations, if an actor breaks out of the container he will still be able to find a way to get full access to the host.

Of course this is not ideal and your threat model can’t ignore the risk posed by running as root.

As such is best to always specify a user:

USER hopefullynotroot

Note that explicitly setting a user in the Dockerfile is just one layer of defence and won’t solve the whole running as root problem.

Instead one can — and should — adopt a defence in depth approach and mitigate further across the whole stack: strictly configure the docker daemon or use a rootless container solution, restrict the runtime configuration (prohibit --privileged if possible, etc), and so on.

How to detect it:

any_user {
    input[i].Cmd == "user"
 }

deny[msg] {
    not any_user
    msg = "Do not run as root, use USER instead"
}

Do not sudo

As a corollary to do not root, you shall not sudo either.

Even if you run as a user make sure the user is not in the sudoers club.

deny[msg] {
    input[i].Cmd == "run"
    val := concat(" ", input[i].Value)
    contains(lower(val), "sudo")
    msg = sprintf("Line %d: Do not use 'sudo' command", [i])
}

Acknowledgements

This work has been inspired and is an iteration on prior art from Madhu Akula.