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 のみを使用している状態です。
import requests
def main():
response = requests.get("https://example.com", timeout=5)
print(response.status_code)
if __name__ == "__main__":
main()
requests==2.32.3
ここから、PRで新たに fastapi と uvicorn を追加するケースを試します。
SBOMの生成と差分計算
SBOMの生成には Syft を使い、CycloneDX JSON形式で出力します。
Syftが吐き出した2つのJSONファイルを読み込み、purl をキーにして差分を取るスクリプトを用意しました。
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ブランチを別々のディレクトリにチェックアウトして、先ほどのスクリプトに渡します。
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.txt に fastapi==0.136.3 と uvicorn==0.48.0 を追加してPRを出します。
Actionsが実行されると、Step Summaryに以下のような結果が出力されました。
## 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無料枠があるみたいです!
意外と便利なんですね()

