DockerfileやComposeファイルのイメージ参照に@sha256:<digest>を自動で追加するCLIツール 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段階です。

  1. 構文チェック: FROM行に@sha256:が含まれているか
  2. 存在チェック: 記載された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 stagenameAS付きも対応
  • 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.yamldockerfile-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-practicesdocker:pinDigestsが含まれています。digest更新のみ自動マージしたい場合はdefault:automergeDigestも利用できます。

運用の流れとしては次のようになります。

  1. dockerfile-pin run --writeで既存ファイルにdigestを一括付与
  2. CIにdockerfile-pin checkを組み込み、digest未指定のFROM行がマージされないようにする
  3. 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を最新に保つ

参考