前言
之前在那些年我們寫錯的 Dockerfile裡,記錄過一次 container image 的瘦身經驗。這次要處理的也是 image,不過目標換成了減少已知漏洞,以及把 SBOM 一起交付出去。
這類工作如果只看需求,很容易覺得就是換個 base image,再加一個掃描工具。但實際碰到的程式並不只有 Go,還有 C/C++ 的 shared library。換掉 base 之後,原本可以執行的 binary 不一定還能執行,amd64 建得過,也不代表 arm64 沒問題。
而把程式跑起來之後,還有另一半工作:掃描的是哪個 image?報告放在哪裡?別人拿到 image 的時候,能不能找到對應的軟體清單?
這篇整理我在這次實作中處理的幾個問題。範例使用通用的應用程式名稱,建置流程也經過簡化,省略產品架構、內部路徑與發佈環境。
換 base image 之前,先處理 native library
這次採用的是基於 Wolfi 的 Chainguard image。Wolfi 使用 apk 管理套件,但它不是 Alpine,不能因為指令都是 apk add,就把兩邊的套件混著裝,這點官方文件也有特別提醒。
對這個有 native dependency 的案例,我需要沿著 glibc 的建置環境往下檢查。只把最終 Dockerfile 的 FROM 改掉,無法解決既有 .so 的相容性問題;編譯時用到的 headers、連結時找到的 library,以及執行時載入的版本,都得對得起來。
所以處理順序是先回到上游,讓 native library 在目標環境裡重新編譯,再建置依賴它的服務,最後才組裝 runtime image。
以下是簡化後的產物流向,並不是原專案的部署架構:
flowchart LR
Native["編譯 native library"] --> App["建置應用程式"]
App --> Package["打包應用程式"]
Package --> Image["組裝 runtime image"]
Image --> SBOM["產生 SBOM"]
SBOM --> Scan["比對已知漏洞"]
Scan --> Attach["附掛至 registry"]
這裡我把 builder 跟 runtime 分開。compiler、headers 與測試工具留在 builder,runtime 安裝執行需要的套件,再放入應用程式。這跟 image 瘦身的方向一致,不過少裝套件只是減少需要維護的範圍,已經編進 binary 的依賴仍然要另外處理。
套件裝了,library 卻不在
遷移時遇到一個容易誤判的狀況:套件管理器顯示安裝成功,程式卻還是找不到 Boost 的 shared library。
原因是當時使用的 boost meta package 只有 SBOM,並沒有程式需要的 .so。必須明確安裝對應的 runtime package,例如:
RUN apk add --no-cache \
libboost-filesystem \
libboost-program_options
套件名稱看起來很完整,內容卻跟想像的不一樣。這時繼續重裝同一個 package 沒有用,得回頭看 binary 到底需要什麼,以及套件實際提供了什麼。
我會先用 readelf 看動態連結資訊:
readelf -d ./app | grep -E 'NEEDED|SONAME|RPATH|RUNPATH'
NEEDED 可以看到需要的 library 名稱,RPATH 與 RUNPATH 則有助於排查搜尋路徑。接著在 runtime 環境裡用 ldd 檢查是否找得到這些依賴。
兩者用途不同。readelf 列出依賴,不表示 runtime 一定載得到;ldd 沒出現 not found,也還沒驗證程式執行後透過 dlopen 才載入的 library。最後仍然需要啟動測試。
還有一個容易漏掉的地方:如果檢查寫在 runtime stage,但 CI 只建置用來匯出檔案的 stage,那段檢查根本不會執行。Dockerfile 裡有寫,不代表這次 build 有跑到。
amd64 過了,arm64 卻連結失敗
另一個問題出在 C++ library 的開發套件與 runtime 版本不同步。
當時其中一個架構的套件庫先提供了較新的 headers,但 runtime library 還沒有跟上。編譯器看得懂新的函式宣告,到了 linker 要找實作時,舊的 .so 沒有那個 symbol,最後以 undefined reference 結束。
這種問題如果只看自己的程式碼,很難看出原因。程式沒改,另一個架構也能過,看起來像是 CI 又在鬧脾氣,實際上是兩邊取得的套件版本不同。
當時的處理是先固定到兩個架構都已確認能連結的開發套件版本,讓建置恢復穩定,再追蹤上游套件更新。不過舊 revision 不一定會一直留在套件庫,固定版本之後還是得安排更新,不能放著就忘了。
我之前在建構多平台的 container image裡介紹過 Buildx。這次更直接體會到,多平台除了產生一個包含兩種架構的 manifest,還要各自驗證編譯與執行結果。工具幫你排程 build,不會順便幫你修 ABI。
讓應用程式進入套件清單
應用程式的交付方式也會影響後續盤點。這次我使用 Melange,把編譯好的 binary 和 shared library 打包成 APK,再透過 apk add 安裝到 runtime image。
會多做這一步,是因為我希望自己的應用程式也能被套件管理器管理,讓後續產生的 SBOM 能列出它的套件名稱與版本。如果只是 cp 或 Dockerfile 的 COPY,檔案雖然進去了,APK 套件資料庫卻不會因此多出一筆安裝紀錄。
這個差異可以從 Syft 的辨識方式看出來:它的 apk-db-cataloger 會讀取 lib/apk/db/installed,從安裝紀錄取得套件資訊與所屬檔案,見 Syft 支援的套件來源。透過 apk add 安裝後,應用程式就能沿用這條原生的盤點路徑,不必期待工具從一個自建 binary 猜出它叫什麼、是哪個版本。
不過,這不代表直接複製的檔案一定不會出現在 SBOM。Syft 還有其他 catalogers,例如從 Go binary 的 build information 辨識 module。cp 做不到的是建立套件管理器的安裝紀錄,不能把它延伸成「掃描器完全看不到這些檔案」。
但這裡有個限制:把一堆檔案包成 APK,不會自動得到裡面所有第三方 library 的完整 SBOM。
尤其是自行編譯的 C/C++ 成分,如果沒有留下可辨識的來源與版本資訊,掃描工具可能只看見外層應用程式套件。這部分仍然需要從上游建置時保留資訊,不能期待最後掃一次 image 就全部補齊。
同樣地,打包時如果沒有產生套件依賴資訊,runtime 的 library 清單還是得自己維護。SONAME 需要的檔名、library search path,以及實際安裝的套件,都是交付內容的一部分。
SBOM 要掃最後交付的 image
映像組裝可能會經過好幾個 stage,也可能使用 Bake 的 named contexts 取得其他建置產物。不管中間怎麼拆,最後一層只要又安裝了套件、複製了 binary,內容就跟上游不同了。
所以我把掃描點放在最終 image 推送到 registry 之後。這裡產生的是交付映像的成分清單,沒有把所有 builder 的套件合併進來。建置環境的依賴可以另外盤點,但不應混成同一份報告,讓人分不清楚哪些東西真的交付了。
工具的分工很直接:Syft 產生 SBOM,Grype 讀取 SBOM,再比對已知漏洞。我選擇輸出 CycloneDX JSON,並對每個平台分別執行。
下面以 amd64 為例,IMAGE_REF 使用已解析的 repository@sha256:...:
syft "registry:${IMAGE_REF}" \
--platform linux/amd64 \
-o cyclonedx-json=app-amd64.cdx.json
grype sbom:app-amd64.cdx.json \
--by-cve \
-o json \
--file app-amd64.grype.json
registry: 指定從 registry 取得 image,避免掃到本機另一份同名內容。Grype 再讀取剛才產生的 SBOM,方便保留這次比對使用的成分清單。相關輸入與格式可以參考 Anchore 的文件。
arm64 也要產生自己的一份。不同架構可能取得不同版本的套件,native binary 也不同,不能拿 amd64 的報告代表整個多平台 image。
除了保留完整 JSON,我也把漏洞項目轉成 CSV,方便需要人工處理時查看 package、installed version、severity 與 fixed version。從 SBOM 擷取出的 PURL 則可以用來比較套件變化,但它只是摘要,不能代替完整文件。
不把所有邏輯塞進 YAML
這段流程需要處理平台迴圈、外部工具錯誤、報告解析、檔名與摘要。全部寫在 GitHub Actions 的 shell block 裡,後面很難測,也不方便在本機重跑。
我的做法是把這些邏輯放到 Go CLI,composite action 只負責準備工具與傳入參數。產生報告和附掛文件分成不同操作,讓本機除錯與 CI 使用同一套邏輯。
這樣需要調整報告格式時,不必連 workflow 一起改;遇到錯誤,也可以先在本機針對 CLI 排查。工具版本則明確固定,升級時重新驗證輸入與輸出格式。
掃描有過,不代表沒有漏洞
接上 CI 之後,還需要決定什麼情況要讓流程失敗。
這裡我把「產生報告」和「阻擋發佈」分開。定期盤點可以先保留結果,發佈流程則依需求設定 severity 門檻。不過,這兩種結果不能混著解讀:只阻擋 High 與 Critical,代表 Low 和 Medium 仍然可以通過。
如果需求是沒有任何已回報漏洞,就要檢查完整報告的 matches,不能用通過某個 severity 門檻來代替。
設計 gate 時,也要讓報告保存步驟在掃描失敗時能夠執行。如果沿用 CI 預設的成功條件,前一步失敗就可能跳過 upload。報告產生了沒有、upload 有沒有執行,都要分開看,否則最需要除錯的那次,反而沒有東西可查。
把 SBOM 一起交給使用者
只把 SBOM 留在 CI artifact,對拿到 image 的人不太方便。他可能沒有 CI 權限,也不一定知道該去哪一次 build 找文件。
我用 ORAS 把 SBOM 與漏洞報告附掛在 registry 裡,透過 image digest 建立關聯。以單份 SBOM 為例:
# IMAGE_REF 是已解析的 repository@sha256:...,檔案使用相對路徑。
oras attach \
--artifact-type application/vnd.cyclonedx+json \
"${IMAGE_REF}" \
app-amd64.cdx.json:application/vnd.cyclonedx+json
取用時,先查詢這個 image 的 referrers,再依 artifact digest 下載:
oras discover "${IMAGE_REF}"
oras pull "${ARTIFACT_REF}"
ORAS attach 與 discover 的文件有更完整的參數。多平台映像還需要決定附件掛在 image index,還是各平台的 manifest;掛在同一個 subject 時,檔名就要能清楚區分平台。
上傳成功,下載卻失敗
這裡踩到的坑跟 SBOM 格式無關,是檔案路徑。
ORAS 會把檔案路徑放進 org.opencontainers.image.title annotation,下載時用來決定檔名。如果把 runner 上的絕對路徑傳進去,除了把主機目錄記進 artifact,也可能讓下載端因路徑安全檢查而拒絕寫入。
所以我在附掛前限制使用相對路徑,並檢查檔案存在且非空。驗證也不能停在 attach 成功,要真的跑過 discover 和 pull,確認使用者取得的文件可以正常下載。
Tag 與 digest 要分清楚
另一個需要注意的是掃描和附掛之間的時間差。
假設掃描時用的是 app:latest,報告產生後這個 tag 被更新,接著再用相同 tag 附掛,文件就可能掛到另一個 image 上。每一條指令都成功,整組資料卻對不起來。
因此前面的示例直接使用 digest。掃描、產生報告與附掛需要共用同一個不可變參照,而不是每一步重新解析一次 tag。
跨 registry 複製時也是一樣,除了確認 image 內容,還要確認附件有沒有過去。映像複製成功,不代表 SBOM 和簽章都會一起送達;它們要各自檢查。
順帶一提,附掛 SBOM 不等於簽署 SBOM。image 有簽章,也不能直接推論旁邊的報告同樣經過簽章驗證。
Zero CVE 之後呢
以我的使用經驗,這套流程在多數時間可以維持沒有已回報 CVE 的狀態,但很難把它掛成持續零漏洞的保證。今天的 image 沒有被回報漏洞,明天漏洞資料庫更新,結果就可能不同。
而且漏洞不一定來自自己加進去的程式,base image 預裝的 library 也可能有 CVE。即使用最新的 Chainguard image,上游仍可能還沒有可用的修補;官方也有列出 pending-upstream 的狀態。這時反覆重建也不會把漏洞變不見,得追蹤修補進度,或評估移除、替換受影響的依賴。各版本的當下結果可以從 wolfi-base 的漏洞頁面查看,還是要連同 tag 與支援狀態一起看。
比較精確的說法是:某個 digest、某個平台,在指定工具、資料庫與設定下,沒有回報已知漏洞。尤其 native library 如果缺少可辨識的版本資訊,零 match 也可能是工具沒有辨識到,需要回頭檢查 SBOM 的內容。
所以後續還需要定期重新比對、更新依賴與重建映像,再比較新舊結果。比較時也要看清楚數字的定義:Grype 的 match 數量不一定等於獨立 CVE 數量,同一個漏洞影響不同套件,可能出現多筆。
固定 base image digest 可以幫助追查建置來源,但也表示不會自動取得新的 base。何況後面的 apk add 如果仍從持續更新的套件庫安裝,光固定 base 還不足以重現整個建置。要把哪些輸入固定、何時更新,都需要配合維護流程處理。
把更新拆成兩條流程
我把定期維護拆成兩條:digest 變了就更新 pin;掃到漏洞才重建,比較改善後再開修補 PR。這樣不必為了更新 base,每次都跑完整輪建置。
flowchart TD
A["定期檢查"] --> B["比較 base digest"]
B -->|有變更| C["更新 pin,開 PR"]
A --> D["掃描現有 image"]
D -->|有漏洞| E["用最新 base 重建各架構"]
E --> F["比較新舊掃描結果"]
F -->|有改善| G["開修補 PR,附上比較報告"]
「有改善」是指 match 總數下降,且各架構的筆數都不增加,不要求一次歸零。沒有變更或改善就結束,PR 後續仍走原本的檢查與交付流程。
後續想整理的打包流程
這次先用 Melange 解決 APK 的打包,但如果同一個應用程式還需要提供 .deb 與 .rpm,我不太想為了三種格式,各維護一套檔案清單、版本設定與打包腳本。改個安裝路徑要去三個地方同步,光想就覺得遲早會漏。
後續我有考慮改用 nFPM。它可以獨立執行,也是 GoReleaser 整合的套件打包工具,支援產生 .deb、.apk 與 .rpm。對這種已經編譯好產物、再組成安裝套件的用途,可以用一份設定共用套件資訊與檔案清單,再處理各格式的差異。
當然,統一打包工具不代表同一支 native binary 就能到處跑。不同發行版的依賴套件名稱、安裝腳本與 ABI 還是要分別處理,nFPM 也提供各格式的 overrides。這部分目前還是後續整理的方向,要先驗證產出的套件在目標環境能正常安裝、執行,以及被 SBOM 工具辨識,再決定是否替換現有流程。
寫在最後
這次工作花時間的地方,包含了追查 native library 的相依、處理兩種架構的版本差異,以及讓掃描報告能對應到實際交付的 image。Syft 和 Grype 的指令反而只是其中一小段。
我把這些步驟整理進可重跑的工具與 CI,後面更新 base 或調整套件時,就能沿用同一套檢查,不必每次重新翻一次手動操作紀錄。遇到新的漏洞,也能從保留下來的 SBOM 與報告開始查。
對我來說,這比做完一次遷移更值得留下來。畢竟依賴還會繼續更新,下次的 undefined reference 也不會因為這篇寫完就放過我。