hamakou108 blog

写真の EXIF データのチェックの仕組みを CI に組み込んだ

Cover Image for 写真の EXIF データのチェックの仕組みを CI に組み込んだ

このブログで公開する写真にセンシティブな EXIF データが含まれていないかチェックする CI を TypeScript と GitHub Actions で構築した。この仕組みについて紹介したい。

環境

  • TypeScript 5.7.2
  • exiftool-vendored 29.0.0
  • tsx 4.19.2

EXIF データとは何か

EXIF (Exchangeable Image File Format) データとは、写真ファイルに埋め込まれたメタデータのことを指す。カメラの機種、撮影日時、位置情報 (GPS) などの情報が含まれることが多い。一般的な画像ビューアや写真編集ソフトを使えば、これらのデータを確認できる。

なぜ EXIF データをチェックするのか

X や Instagram などのサービスでは、写真をアップロードすると自動的に EXIF データが削除されるようになっている。一方、このブログは Next.js で構築されており、画像などのアセットは Vercel にそのままデプロイされる。これらの画像ファイルに含まれる EXIF データを Next.js や Vercel が気を利かせて自動的に削除してくれるなんてことはないため、うっかりすると公開した写真からプライバシーに関わる情報が露呈してしまう可能性がある。

これまで画像ファイルをデプロイする際は、 EXIF データが含まれていないか毎回目視でチェックしていた。こんな運用では、いつミスするか分かったものではない。

EXIF データをチェックする CI の構築

というわけで、公開する写真に含まれる EXIF データのチェックを CI で実行することにした。 EXIF データをチェックするスクリプトを TypeScript で書き、 GitHub Actions でそのスクリプトを実行する。

EXIF データをチェックするスクリプト

EXIF データをチェックするスクリプトの内容は以下のとおり。

このスクリプトでは、exiftool-vendored を利用し、対象ディレクトリ内の画像ファイルを解析する。特定の EXIF データが含まれていた場合はエラーと判定し、異常終了する。

チェック対象の EXIF データは以下のとおり。

  • GPSLatitude / GPSLongitude / GPSAltitude: 位置情報 (GPS) に関するデータ
  • DateTimeOriginal: 撮影日時

これらの情報から写真を撮影した場所や時間を特定されるリスクがあるため、ブログ上に公開する画像ファイルから取り除きたい。スクリプト上では sensitiveKeys に指定している。

const sensitiveKeys = [
  'GPSLatitude',
  'GPSLongitude',
  'GPSAltitude',
  'DateTimeOriginal',
]

checkExif 関数は、画像ファイルに含まれる EXIF データを解析して上記のデータが含まれていないかチェックする。

export async function checkExif(dir: string): Promise<ExifResult[]> {
  const result: ExifResult[] = []
  const files = findImageFiles(dir)

  for (const file of files) {
    const exifData = await exiftool.read(file)
    const foundKeys = Object.keys(exifData).filter((key) =>
      sensitiveKeys.includes(key),
    )
    if (foundKeys.length > 0) {
      result.push({ file, detectedKeys: foundKeys })
    }
  }

  return result
}

findImageFiles 関数は、指定されたディレクトリ以下の画像ファイルを再帰的に検索し、ファイルのパスをリストにして返す。

function findImageFiles(dir: string): string[] {
  const files: string[] = []
  const items = fs.readdirSync(dir, { withFileTypes: true })

  for (const item of items) {
    const fullPath = path.join(dir, item.name)
    if (item.isDirectory()) {
      files.push(...findImageFiles(fullPath))
    } else if (/\.(jpg|jpeg|png)$/i.test(item.name)) {
      files.push(fullPath)
    }
  }

  return files
}

tsx を使ったスクリプト実行

TypeScript のトランスパイルについてあまり考えたくなかったので、 TypeScript のコードを手軽に実行できる tsx を利用した。ただし、 Node v23.6.0 からデフォルト設定のまま TypeScript をそのまま実行できるようになった ^1 ようなので、そのうち置き換える予定だ。

GitHub Actions への組み込み

最後に、 Pull Request の作成時に自動でスクリプトが実行されるように GitHub Actions に組み込んだ。

name: CI

on:
  push:
    branches: main
  pull_request:

jobs:
  check-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['22.10.0']
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - name: Upgrade yarn
        run: |
          corepack enable
          yarn set version berry
      - name: Install dependencies
        run: yarn install --immutable
      - name: Check exif data
        run: yarn run check-exif

なお、 EXIF データの削除は手動作業で行うようにしている。

exiftool -all= -overwrite_original my_private_photo.jpg

終わりに

こうして私のプライバシーは保護されたのだった (?)。まあ普段から無闇に写真を公開しないように気をつけていれば、こうやって頑張る必要もないのかもしれないとも思った。

あと本題から逸れるが、 Gist のコードを記事に埋め込むのに地味に実装コストが掛かった。そのうちブログか Zenn で実装方法を紹介しようと思う。