讓你寫出更安全的 Dockerfile

Docker file 幾個小技巧,讓你寫出更安全的Container.

讓你寫出更安全的 Dockerfile

自從進入大容器時代後,Docker、K8s 已經逐漸成為開發、測試及部署時不可或缺的工具,如果突然叫我不要用 Docker,那我可能什麼都做不了,但也因為這樣,跟容器有關的攻擊越來越普遍,因此容器的安全性也越來越重要

想要從零開始建出一個容器,第一步就是要寫 Dockerfile 把你的應用包裝成 Docker image。關於怎麼產生出盡量小的 image 已經很多人寫過了,所以今天想要跟大家分享的是想要寫出一個安全的 Dockerfile,有哪些該注意的地方。

使用 stable 或 LTS 的 base image

很多人在寫 Dockerfile 並不會特別指定 base image 的版本(就懶啊,我懂 XD),譬如說想要包一個 Node API server,就直接寫 FROM node 或是 FROM node:latest

# Bad
FROM node

WORKDIR /app
COPY . .

RUN npm install
RUN npm run test

bad.Dockerfile hosted with ❤ by GitHub

但這樣可能會在哪次 build image 時就意外從 Node 14 升到 Node 16,導致部分功能直接壞掉。而且最新版本的 Node 可能有一些不為人知的 bug,需要有一些勇者去幫忙踩坑,所以除非是自己的 Side Project 想要玩玩看最新的 feature,否則直接把最新版本的 Node 用在 production 並不是個好作法

比較好的方式是先看看 Node 的 LTS(Long-Term Support) 版本是多少,像我在寫文章的當下是 v14.17.5,那就選擇 node:14 或是 node:14.17 作為 base image。

# Good
FROM node:14

WORKDIR /app
COPY . .

RUN npm install
RUN npm run test

這樣做一來是可以把版號固定在 Node 14、確保不會有大變動,二來是 LTS 的版本都會不斷推出 security fix,因此如果哪天 Node 更新到 v14.17.10,那些 security fix 也會在部署時被加進的 API server,我們只要坐等更新就好了

安裝套件時要指定版本

這點跟上面提到的不要用 latest image 有些類似,不管你是用 apt-get install 安裝 CLI 工具、用 npm install 裝函式庫、還是用 curl/wget 把東西下載回來編譯,都要盡量確保每次下載到的東西是一樣的

譬如在用 apt-get 安裝 nginx 時就可以透過 apt-get install nginx=1.14.0 來下載指定版本(有點麻煩對吧,我也覺得XD),而 npm、pip 這類的語言套件管理工具則是看官方推薦什麼方法,像 npm 就是用 package-lock.json 來鎖定套件的版本、pip 的話則是先跑 pip freeze > requirements.txt 把套件的版本凍起來,等要安裝時再跑 pip install -r requirements.txt 把原本的套件裝回來。

# Good
FROM ubuntu:20.04

RUN apt-get update
RUN apt-get install nginx=1.14.0 python=2.7.15 nodejs=12.18.2

雖然把套件版本的鎖定之後可以省下很多麻煩,但也不能一直鎖在那都不更新,所以記得偶爾去檢查一下版本是不是太舊了,如果太舊再手動把版號升上去就好了~

只 COPY 需要的東西

平常在寫 Dockerfile 時,有些人為了一時方便,會直接用 COPY . /app 把整個專案資料夾複製到 container 的 /app 資料夾內。這樣不用動太多腦筋,在 container 裡面也可以直接存取到所有檔案,但這樣的做法可以說是糟透了。

FROM node:14
WORKDIR /app

# Very BAD
COPY . .

RUN npm install
RUN npm run test
CMD ["node", "index.js"]

首先是這樣會讓 image 變得很肥(連 node_modules 都進去了能不肥嗎XD),而且一不小心就會把 .envrc 這類敏感資料一起放進去,如果哪天這個 image 被駭客拿到,裡面的 AWS 憑證、資料庫密碼等等超機密資料就會直接外洩出去,哪天突然被刪庫也是有可能的

為了避免這種事情發生,在 build image 時應該只把需要的東西複製進去,譬如說你馬上就要跑 npm install,這才把 package.jsonpackage-lock.json 放進去,而程式碼也是把真的會跑到的那些放進去就好。

FROM node:14
WORKDIR /app

# Good
COPY package.json package-lock.json ./
RUN npm install

# Good
COPY src/ index.js ./
RUN npm run test
CMD ["node", "index.js"]

而且這樣還有另外一個好處,就是如果你改了 src 裡面的程式碼,但沒有安裝新的 package(開發時大部分都是這樣吧~),因為 Docker 會自動做 cache,所以就不需要重新跑一次 npm install,會直接從第 10 行的 npm run test 開始跑,因此可以大幅縮減需要等待的時間

用 multi-stage build 捨棄不需要的檔案

這跟上一點有點類似,簡單來說就是不要留任何不需要的東西在 image 裡面(沒用的東西都給我滾),即便那是 build image 過程中產生的東西也是一樣

譬如說原本 Go API server 的 Dockerfile 可能長這樣,因為要在 build image 時編譯出執行檔,所以第 5 行的 COPY *.go 是一定要的,沒有他們就無法進行編譯。

FROM golang:1.17

WORKDIR /app

COPY *.go go.mod go.sum ./
RUN go build -o main

CMD ["./main"]

但說真的一旦編譯出執行檔之後,那些 Go 程式碼就用不到了,所以應該來個爽快的過河拆橋,用 multi-stage build 把編譯完的執行檔保留下來就好,程式碼什麼的就直接拜拜

像下面這個這個 Dockerfile 經過 multi-stage build 後 image 裡面就只有 /app/main 這個編譯好的執行檔,沒有任何程式碼以及 Go 的編譯器,非常的存粹。

# compile stage
FROM golang:1.17 as compile-env
WORKDIR /app
COPY *.go go.mod go.sum ./
RUN go build -o main

# final stage
FROM gcr.io/distroless/base
COPY --from=compile-env /app/main /app
CMD ["/app/main"]

那這樣有什麼好處呢?除了 image 可以變小很多之外,即便 image 被駭客拿到了,程式碼也不會外流出去(有很多攻擊都是拿到程式碼後從裡面找到漏洞),因此只保留執行檔可以提高安全性

除此之外,因為環境越複雜就越可能有沒發現的漏洞,而把 base image 從原本的 golang:1.17 換成 Google 提供的 distroless image 剛好可以大幅減少環境的複雜度(distroless 幾乎沒裝什麼東西,連 shell 都沒有),也就可以提高安全性

不要把敏感資料 hardcode 在 Dockerfile 裡面

我想這已經是常識等級的安全知識了,因為直接把敏感資料用 ENV 寫在 Dockerfile 裡會讓駭客輕易拿到(只要拿到 image 就可以了),所以絕對不要想不開把資料庫或任何的帳號密碼寫在裡面,ENV 頂多用來設定時區或是 NODE_ENV 這種被看光也不會出事的變數就好,不然哪天資料被偷走真的會哭出來。

FROM node:14

ENV TZ=Asia/Taipei
ENV NODE_ENV=production

# Very Bad
ENV PG_HOST=test.postgresql.com
ENV PG_USER=thisIsmyUserName
ENV PG_PASS=mySecretPa55w0rd
ENV PG_DBNAME=projectName

COPY . .
RUN npm install
CMD ["node", "index.js"]

如果說 ENV 不能放敏感資料,那這些資料究竟要怎麼被加進環境變數呢?

答案就是在 docker run 時加上 --env 或是 --env-file 把環境變數塞進去;如果是用 docker-compose 的話,則是把那些資料寫進 docker-compose.yml 的 environment 裡面這樣 container 啟動時就會讀到這些變數,而且即便 image 被偷走也不用擔心資料外洩

弱點掃描

可以做的事情都做了之後,最後就是要來用工具來做弱點掃描了。因為做弱描的工具還滿多的,這邊就介紹已經被 Docker 加進 CLI 的 Snyk,他可以把你的環境、安裝的套件丟到他們資料庫去做搜尋,看有哪些潛在的危險

譬如說我手邊有幾個多年前用 Node.js 寫的 express server,Dockerfile 長這樣(看到 9.2.0 就知道真的是多年前XD,現在都已經 Node 16 了)。

FROM node:9.2.0

COPY index.js package.json /app/
WORKDIR /app
RUN npm install && npm cache clean --force

CMD node index.js

先用 docker build . -t app 把 image 建出來後,接著就下 docker scan app 對他做掃描。因為我用的是古早古早以前的 node:9.2.0,所以光 Base Image 的部分掃出來就有 1039 個漏洞,而且其中 99 是屬於 critical 等級的,嚇都嚇死

除了告訴你有多少漏洞之外,他還會把每個漏洞給列出來(有興趣可以去讀一下那些漏洞的報告,其實都不長),並且告訴你那些漏洞分別在哪些版本修掉了

如果懶得看那些漏洞的話,也可以直接滑到最底下看他給你的建議,譬如這邊他就建議把 base image 升級到 node:16.7.0,若是不一定要 full image 的話,那 node:16-bullseye-slim 也是不錯的選項,因為安裝的東西更少,所以漏洞自然也更少。

Debian bullseye 是今年八月剛發佈的版本,可能比較不穩定

總結

今天介紹了一些在寫 Dockerfile 時的注意事項,雖然很多都是小地方,但畢竟魔鬼藏在細節裡,想要讓你的 Docker image 更安全,那就連這些小細節都不能放過。