Dockerfile安全最佳实践

容器安全涉及问题很多,有许多“唾手可得”的方案能用来降低风险。不过,一个好的开始是编写 Dockerfile 文件时遵循一些规则。

在本文,我列出了一些常见的安全问题和如何规避它们。对于每一个问题,我还写了一个开放策略代理(Open Policy Agent,OPA)规则来使用 conftest 静态分析你的 Dockerfile 文件。

https://www.openpolicyagent.org/
https://conftest.dev/

你可以在这个库找到.rego规则集。

https://github.com/gbrindisi/dockerfile-security

密钥部署是一个很棘手的问题,而且很容易出错。对于容器化的应用程序,可以通过挂载卷从文件系统中显示它们,也可以更方便地通过环境变量显示。

使用ENV来存储密钥通常是不好的,因为 Dockerfile 文件通常与应用程序一起部署,因此这与在代码中硬编码密钥没有什么差别。

如何检测这一点:

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])
}

针对容器化应用程序的攻击链也来自构建容器本身所使用的层次结构。其中,主要的罪魁祸首明显是使用的根镜像。不受信的根镜像是一个高风险,任何时候都应该避免使用。

Docker 为大多数使用的操作系统和应用程序提供了一组官方根镜像。使用这些镜像,我们通过 Docker 自身分担的一些责任降低了协议风险。

https://docs.docker.com/docker-hub/official_images/

如何检测这一点:

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])
}

这条规则针对的是 DockerHub 的官方镜像。由于我只检测到了 namespace 的缺失,这是非常愚蠢的。

信任的定义取决于你的上下文:可以相应地更改这条规则。

固定基础镜像的版本将使你对正在构建的容器的预期比较安心。

如果你依赖最新的(latest)版本,你可能会不知不觉地继承更新包,这在最好的坏情况下可能会影响你应用程序的可靠性,在最差的坏情况下可能会引入一个漏洞。

如何检测这一点:

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])
}

从互联网上拉取东西,并通过管道将它放到一个 shell 脚本中是非常糟糕的。不幸的是,这是一个比较广泛应用的方案来流式安装软件。

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

供应链攻击的风险与此相同,归根结底就是信任。如果你不得不使用 curl 命令,就请正确使用:

  • 使用可信来源
  • 使用安全连接
  • 验证下载内容的真实性和完整性

如何检测这一点:

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])
}

这可能有点儿牵强,但理由如下:你想要固定你的软件依赖的版本,如果你运行 apt-get upgrade,你会将它们都更新到最新的版本。

如果你做了更新,而且你对根镜像使用 latest 标签,那么你就放大了你的依赖树的不确定性。

你要做的是固定根镜像的版本,并且只运行 apt/apk update

如何检测这一点:

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])
}

ADD命令的一个小功能是,将它指向一个远程 url,然后它会在构建时获取 url 的内容:

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

比较讽刺的是,官方文档建议使用 curl 命令来代替它。

从安全角度来看,不要这么做。事先获取你需要的内容,对其进行验证,然后COPY。但是,如果你真的需要,在安全连接上使用可靠信源。

注意:如果你有一个奇特的构建系统,动态生成 Dockerfile 文件,那么ADD肯定会被使用到。

如何检测这一点:

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

容器中的 root 和主机上的 root 相同,但会受到 docker 守护程序配置的限制。无论有什么限制,如果一个人突破了容器,他也能够找到一种方法来获取访问主机的完整权限。

当然,这是不理想的,你的威胁模型不能忽视作为 root 用户运行所带来的风险。

因此,最好始终指定一个用户:

USER hopefullynotroot

需要注意的是,在 Dockerfile 中明确设定一个用户只是一层防线,不会解决所有以 root 用户运行所带来的问题。

相反,我们可以——也应该采用层次防御方案并在整个堆栈中一步步缓解:严格配置 docker 守护进程或者使用一个非 root 容器方案,限制运行时配置(如果可能的话,禁止--privileged)等等。

如果检测这一点:

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

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

既然不能使用 root 用户,那你自然也不能使用 sudo 命令。

即使你作为一个普通用户运行,也要确保这个用户不属于sudoers

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])
}

https://mp.weixin.qq.com/s/mRA3KCVYsT9eASfIz0oEQw - Dockerfile安全最佳实践
https://cloudberry.engineering/article/dockerfile-security-best-practices/ - Docker Security Best Practices from the Dockerfile