dockerfile-pin: DockerfileやComposeのイメージをSHA256でピン留めするCLIツールを作った
DockerfileやComposeファイルのイメージ参照に@sha256:<digest>を自動で追加するCLIツール dockerfile-pin を作りました。
- GitHub: azu/dockerfile-pin
なぜ作ったか
trivyへのサプライチェーン攻撃などの事件を見ていると、次に狙われるのはDocker Hubかなと思ったのがきっかけです。 CIでDocker Hubへのpushをしているケースは多いので、そこに悪意あるコードが混入する事件は今後も起きるだろうと思っています。
Dockerイメージのタグ(例:node:20)はデフォルトで可変(mutable)です。同じタグ名で中身を上書きできるため、悪意ある第三者がレジストリへのアクセスを得た場合、既存タグに対して改竄されたイメージをpushできます。
Docker Hubなどのレジストリは安全とは限りません。 npmのようにトークンの制限が厳しくなっていたり、デフォルトでタグがimmutableな場所であっても、axiosのように問題が起きることはあります。 Docker HubにはImmutable tagsという機能がありますが、これはリポジトリオーナー側が設定するもので、イメージを利用する側がコントロールできるものではありません。
@sha256:<digest>を付与することで、イメージの不変性を保証できます。digestはイメージのコンテンツハッシュなので、内容が異なればdigestも変わり、改竄を検知できます。npmのlockfileがパッケージのintegrityをハッシュで固定するのと同じ考え方です。
# Before: タグのみ(可変)
FROM node:20.11.1
# After: タグ + digest(不変)
FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
タグとdigestを両方残す形式にしておくと便利です。タグは人間が読むため、digestは不変性の保証のために残します。Renovateはこの形式でタグとdigestの両方を更新できます。Dependabotもdigestが既に付いている場合はタグとdigestを同時に更新できます。
Dockerfileでは明示的にSHA256 digestを指定しないとハッシュ固定ができません。これはGitHub Actionsのuses:をコミットSHAでpin留めしていないのと同じ状態で、サプライチェーン攻撃のリスクがあります。
GitHub Actionsについてはpinactで自動化できますが、DockerfileのFROM行については同様のシンプルなツールがありませんでした。
既存ツールが不十分だった
DockerfileのSHA pinを補助する既存ツールとしてdockpinがありますが、2023年以降メンテナンスが停滞しています。
また、hadolintにはdigest pin強制ルールがなく(hadolint#773、2022年2月〜OPEN)、プラグイン機構もありません(hadolint#1001)。CIでdigestのpin漏れをチェックできるシンプルなlintツールが見当たりませんでした。
そのため、pinactのDockerfile版をイメージして、craneライブラリ(Googleが管理、メンテナンスが活発)をベースにdockerfile-pinとして自作しました。
作った後に気づきましたが、frizbeeがGitHub ActionsとDockerの両方に対応した近いツールとして存在しています。dockerfile-pinはdigestの付与に加えて、CIで新しくdigestなしのイメージが入るのを防ぐcheckコマンドがあるので、目的が少し異なる気がします。(おそらく似たことはできるはず?)
使い方
インストール
📝 主にCIとかで使いたい目的で作ったのでaquaなどのチェックサムをチェックしてインストールできる方法を推奨しています。
# curl
curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_darwin_arm64.tar.gz" | tar xz
sudo mv dockerfile-pin /usr/local/bin/
# aqua
aqua generate -i azu/dockerfile-pin
# Go
go install github.com/azu/dockerfile-pin@latest
run コマンド: digestの追加
run --writeコマンドで、DockerfileやComposeファイルのイメージ参照にSHA256 digestを追加します。
デフォルトではDry-Runになっているので --write フラグを使うと実際にファイルを書き換えます。
# ドライラン(プレビュー)
dockerfile-pin run -f Dockerfile
# 実際にファイルを書き換える
dockerfile-pin run -f Dockerfile --write
# globパターンで複数ファイルを対象にする
dockerfile-pin run --glob '**/{Dockerfile,docker-compose.yml}' --write
# 引数なしだと **/{Dockerfile,Dockerfile.*,docker-compose*.yml,docker-compose*.yaml,compose.yml,compose.yaml} を対象にします
# ドライラン
dockerfile-pin run
# Docker関係のファイルを自動的に書き換える
dockerfile-pin run --write
たとえば、次のようにタグのみの指定にdigestが追加されます。
変換前:
FROM node:20.11.1
FROM python:3.12 AS builder
変換後:
FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
FROM python:3.12@sha256:... AS builder
すでにdigestが付いているイメージはスキップされます。--updateオプションをつけると既存のdigestも更新します。
check コマンド: CIでのdigest検証
CIで使うことを想定したcheckコマンドもあります。チェックは2段階です。
- 構文チェック: FROM行に
@sha256:が含まれているか - 存在チェック: 記載されたdigestがレジストリに実際に存在するか(HEADリクエストで検証)
存在チェックがあることで、typoや削除済みdigestがdocker build時まで発覚しないという問題を防げます。
HEADリクエストを使っているため、Docker Hubのpull rate limitを消費しません。
# すべてのDockerfileをチェック(git ls-filesから自動検出)
dockerfile-pin check
# 構文チェックのみ(レジストリへのアクセスなし)
dockerfile-pin check --syntax-only
# JSON形式で出力
dockerfile-pin check --format json
# 特定のイメージを無視
dockerfile-pin check --ignore-images scratch
出力例は次のとおりです。
FAIL Dockerfile:1 FROM node:20.11.1 missing digest
OK Dockerfile:3 FROM python:3.12@sha256:abc123...
SKIP Dockerfile:5 FROM scratch scratch image
対応しているパターン
Dockerfile:
FROM image:tag— digestを追加FROM image:tag AS stagename—AS付きも対応FROM --platform=linux/amd64 image:tag—--platform付きも対応ARG VERSION=1.0+FROM image:${VERSION}— ARGにデフォルト値がある場合は展開して解決ARG BASE_IMAGE+FROM ${BASE_IMAGE}— デフォルト値がない場合はwarningでスキップFROM scratch— スキップFROM <stagename>— マルチステージビルドの参照はスキップ- プライベートレジストリ(ghcr.io, GCR, ECRなど)にも対応
docker-compose.yml:
image: node:20— digestを追加build:ディレクティブがあるサービス — スキップ
CI/CDでの利用
GitHub Actionsでの利用例です。curlでインストールする方法と、aquaを使う方法があります。aquaを使うと、dockerfile-pin自体のチェックサムを検証してインストールできます。
なお、dockerfile-pinのリリースではGitHub Releases Immutabilityを有効にしています。
curlでインストールする場合:
- run: |
curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_linux_amd64.tar.gz" | tar xz
sudo mv dockerfile-pin /usr/local/bin/
- run: dockerfile-pin check
aqua経由で利用する場合は、aqua.yamlに dockerfile-pinを入れてインストールします。
- uses: aquaproj/aqua-installer@d1fe50798dbadd4eb5b98957290ca175f6b4870f # v4.0.2
with:
aqua_version: v2.57.1
- run: dockerfile-pin check
CIでdockerfile-pin checkを実行することで、digestが付いていないイメージをプルリクエスト時に検出できます。
Renovateとの併用
初回のdigest付与はdockerfile-pin run --writeで行い、その後の継続的な更新はRenovateに委譲します。
Renovateのdocker:pinDigestsプリセットを有効にすると、image:tag@sha256:digest形式のdigestを自動更新するPRを生成してくれます。
{
"extends": ["config:best-practices"]
}
config:best-practicesにdocker:pinDigestsが含まれています。digest更新のみ自動マージしたい場合はdefault:automergeDigestも利用できます。
運用の流れとしては次のようになります。
dockerfile-pin run --writeで既存ファイルにdigestを一括付与- CIに
dockerfile-pin checkを組み込み、digest未指定のFROM行がマージされないようにする - Renovateの
docker:pinDigestsで継続的にdigestを最新に保つ
まとめ
Dockerイメージのタグはデフォルトでmutableなので、タグだけの指定ではサプライチェーン攻撃のリスクがあります。
npmのlockfileやGitHub ActionsのSHA pinと同様に、Dockerfileでも@sha256:<digest>でイメージを固定した方が良いでしょう。
既存ツール(dockpin、docker-lock)はメンテナンスが停滞しており、hadolintにもdigest pinのルールがありません。そのため、シンプルにpin付与とCIチェックを行うdockerfile-pinを作りました。
dockerfile-pin run --writeで既存ファイルにdigestを一括追加dockerfile-pin checkでCIでdigestの付け忘れを検出- Renovateと組み合わせて継続的にdigestを最新に保つ
参考
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。