2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub ActionsでSBOM差分を出して、PRの依存関係変更を見える化してみた

2
Last updated at Posted at 2026-05-29

GitHub ActionsでSBOM差分を出して、PRの依存関係変更を見える化してみた

SBOMは脆弱性管理などの文脈で最近よく話題にあがりますね!
ただ、実際にCIに組み込んでみても「生成して終わり」になりがちです。
Pull Request(PR)のタイミングでSBOMを生成し、mainブランチとの差分を取れば、依存関係の変化を追跡する仕組みが作れるのではないかと考えました。

処理の全体像はシンプルです。baseブランチとPRブランチそれぞれのSBOMを生成し、追加・削除されたコンポーネントを比較します。最後にその結果をGitHub ActionsのStep Summaryに出力します。
(結果をファイルとして保存する手法も考えましたが、管理ファイルが増える点を考慮して避けました。)

GitHub Actionsを今後触ることになりそうだったので練習も兼ねて、、、

なお、今回は純粋に「依存関係の増減」を見ることが目的なので、脆弱性スキャンやライセンス判定は組み込んでいません。

検証用プロジェクトの準備

Pythonの小さなプロジェクトを用意しました。依存管理には requirements.txt を使います。
mainブランチでは requests のみを使用している状態です。

app/main.py
import requests

def main():
    response = requests.get("https://example.com", timeout=5)
    print(response.status_code)

if __name__ == "__main__":
    main()
requirements.txt
requests==2.32.3

ここから、PRで新たに fastapiuvicorn を追加するケースを試します。

SBOMの生成と差分計算

SBOMの生成には Syft を使い、CycloneDX JSON形式で出力します。

Syftが吐き出した2つのJSONファイルを読み込み、purl をキーにして差分を取るスクリプトを用意しました。

scripts/sbom_diff.py
import json
import sys
from pathlib import Path

def component_key(component):
    """Return a stable key for comparing SBOM components."""
    purl = component.get("purl")
    if purl:
        return purl

    name = component.get("name", "unknown")
    version = component.get("version", "unknown")
    return f"{name}@{version}"

def load_components(path):
    """Load CycloneDX components from a JSON SBOM file."""
    data = json.loads(Path(path).read_text())

    components = {}
    for component in data.get("components", []):
        # GitHub Actionsのチェックアウト先ディレクトリの違いによる
        # 単なるファイルの差分ノイズを除外する
        if component.get("type") == "file":
            continue

        key = component_key(component)
        components[key] = {
            "name": component.get("name", ""),
            "version": component.get("version", ""),
            "type": component.get("type", ""),
            "purl": component.get("purl", ""),
        }

    return components

def markdown_table(title, rows):
    lines = []
    lines.append(f"### {title}")
    lines.append("")

    if not rows:
        lines.append("_None_")
        lines.append("")
        return "\n".join(lines)

    lines.append("| name | version | type | purl |")
    lines.append("|---|---|---|---|")

    for row in sorted(rows, key=lambda x: (x["name"], x["version"], x["purl"])):
        lines.append(
            f"| {row['name']} | {row['version']} | {row['type']} | `{row['purl']}` |"
        )

    lines.append("")
    return "\n".join(lines)

def main():
    if len(sys.argv) != 3:
        print("Usage: python scripts/sbom_diff.py <base-sbom.json> <head-sbom.json>")
        sys.exit(1)

    base_path = sys.argv[1]
    head_path = sys.argv[2]

    base = load_components(base_path)
    head = load_components(head_path)

    added_keys = set(head) - set(base)
    removed_keys = set(base) - set(head)

    added = [head[key] for key in added_keys]
    removed = [base[key] for key in removed_keys]

    output = []
    output.append("## SBOM Diff")
    output.append("")
    output.append(f"- Added components: **{len(added)}**")
    output.append(f"- Removed components: **{len(removed)}**")
    output.append("")
    output.append(markdown_table("Added components", added))
    output.append(markdown_table("Removed components", removed))

    print("\n".join(output))

if __name__ == "__main__":
    main()

ワークフローの定義

Pull Request時に動作するGitHub Actionsのワークフローです。
baseブランチとPRブランチを別々のディレクトリにチェックアウトして、先ほどのスクリプトに渡します。

.github/workflows/sbom-diff.yml
name: SBOM Diff

on:
  pull_request:
    branches:
      - main

permissions:
  contents: read

jobs:
  sbom-diff:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v4
        with:
          path: head

      - name: Checkout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.base_ref }}
          path: base

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Resolve dependencies (pip freeze)
        run: |
          mkdir -p base-resolved head-resolved
          
          python -m venv base-venv
          base-venv/bin/pip install -r base/requirements.txt
          base-venv/bin/pip freeze > base-resolved/requirements.txt

          python -m venv head-venv
          head-venv/bin/pip install -r head/requirements.txt
          head-venv/bin/pip freeze > head-resolved/requirements.txt

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Generate SBOM for base branch
        run: syft base-resolved/requirements.txt -o cyclonedx-json=base-sbom.json

      - name: Generate SBOM for PR branch
        run: syft head-resolved/requirements.txt -o cyclonedx-json=head-sbom.json

      - name: Run SBOM diff
        run: |
          python head/scripts/sbom_diff.py base-sbom.json head-sbom.json > sbom-diff.md

      - name: Write summary
        run: |
          cat sbom-diff.md >> "$GITHUB_STEP_SUMMARY"

このワークフローでは、PR側のrequirements.txtをもとにpip installを実行します。
そのため、外部からのPRを受け付けるリポジトリで使う場合は、Actionsの権限やsecretsの扱いに注意が必要です。

実際に動かしてみた結果

requirements.txtfastapi==0.136.3uvicorn==0.48.0 を追加してPRを出します。

image.png

Actionsが実行されると、Step Summaryに以下のような結果が出力されました。

image.png

## SBOM Diff

- Added components: **12**
- Removed components: **0**

### Added components

| name | version | type | purl |
|---|---|---|---|
| annotated-doc | 0.0.4 | library | `pkg:pypi/annotated-doc@0.0.4` |
| annotated-types | 0.7.0 | library | `pkg:pypi/annotated-types@0.7.0` |
| anyio | 4.13.0 | library | `pkg:pypi/anyio@4.13.0` |
| click | 8.4.1 | library | `pkg:pypi/click@8.4.1` |
| fastapi | 0.136.3 | library | `pkg:pypi/fastapi@0.136.3` |
| h11 | 0.16.0 | library | `pkg:pypi/h11@0.16.0` |
| pydantic | 2.13.4 | library | `pkg:pypi/pydantic@2.13.4` |
| pydantic-core | 2.46.4 | library | `pkg:pypi/pydantic-core@2.46.4` |
| starlette | 1.2.0 | library | `pkg:pypi/starlette@1.2.0` |
| typing-extensions | 4.15.0 | library | `pkg:pypi/typing-extensions@4.15.0` |
| typing-inspection | 0.4.2 | library | `pkg:pypi/typing-inspection@0.4.2` |
| uvicorn | 0.48.0 | library | `pkg:pypi/uvicorn@0.48.0` |

### Removed components

_None_

直接追加したのは2つのパッケージだけですが、推移的依存関係のパッケージも一斉に追加されていることが分かります。

↓推移的依存関係について

ここで先ほど定義したワークフローのYAMLを振り返ってみると、単純に requirements.txt を読み込ませるのではなく、一度仮想環境にパッケージをインストールし、pip freeze で出力した結果をSyftに渡していました。
実は Syft が単なる requirements.txt をそのままスキャンした場合、そこに書かれている直属のパッケージ(今回なら2つ)しか拾ってくれません。推移的依存関係まで漏れなく抽出して可視化するためには、このように「一度実際に依存解決させた結果」を渡す工夫が必要です

普段のコードレビューでは requirements.txt の変更行を数行確認するだけで済ませてしまいがちです。しかし、裏側で引き込まれたコンポーネントが可視化されると、PRによってプロジェクトに持ち込まれた「本当の部品数」を直感的に把握できると思います。

感想

実のところ、GitHub Actionsに触れたのは今回が初めてでした。
なんと、private repository向けに月2,000分のGitHub Actions無料枠があるみたいです!
意外と便利なんですね()

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?