SCANOSS pre-commit:コードスキャンでコピーレフト混入を検出する

Hero banner: dark blue gradient with Japanese headline about stopping OSS contamination using pre-commit and a friendly green cartoon monster on the right.

これまでの記事では、SCANOSSのローカルスキャン(CLI)GitHub Actionsでの自動化モノレポ対応を紹介してきました。CIに最終ゲートを置けたら、次に出てくるのが「CIが落ちてから直すのではなく、コミットの時点でローカルに気づきたい」というニーズです。

本記事では、SCANOSSのソースコードスキャンを pre-commit に組み込み、OSS由来コードの混入をコミット時点で検出する構成を紹介します。記事は次の3つの流れで進めます。

  1. なぜpre-commitでOSSライセンスをチェックするのか(SCANOSSで何を止めたいか)
  2. SCANOSSをpre-commitに組み込む(公式hookと自作hook)
  3. pre-commitとCIの役割分担

この記事でわかること:

  • なぜCIだけでなくpre-commitでもソースコードをスキャンするのか
  • pre-commitで検出対象を「コード照合」に絞る理由
  • SCANOSS公式hookの設定・できること・できないことと、自作hookという選択
  • delta(変更ファイル単位)スキャンとKEY未設定時の扱いによる軽量化

前提・検証環境

  • 本記事の自作hookは、SCANOSS Premium のAPI KEY を前提とします(理由は後述)。一方、後述の公式hookは OSSKB(無料)でも動きます。
  • 検証はすべて scanoss-py 1.52.1 を本リポジトリで実行した実測です(2026-06-13 時点)。本記事に出てくる終了コード・出力・所要時間などの数値は、この環境での実測値です。
  • SCANOSS および scanoss-py、公式hookは更新されうるため、導入前に最新の挙動・バージョンを確認してください。

なぜpre-commitでOSSライセンスをチェックするのか

SCANOSSで防ぐ2つのOSSリスク

SCANOSS(SCA: Software Composition Analysis)を使う目的は、大きく2つのリスクを防ぐことです。

防ぎたいリスク内容
ライセンス違反コピーレフト等のOSSが混入し、義務(ソース開示・表示)を果たさないまま配布してしまう
出所不明(未宣言OSS)SBOMに載らないコード・依存が紛れ込み、ライセンスも脆弱性も追跡できなくなる

これらの検出は、実装上は次の3種類に分かれます。CIではこの3種類すべてを回しています(GitHub Actionsでの自動化モノレポ対応の記事を参照)。

検出の種類内容
コード照合コードのスニペット照合。OSS由来のコード片が混ざっていないか
依存の宣言突合マニフェストのOSSがbom.includeに登録済みか
依存のライセンス種別その依存がGPL等のコピーレフトか

このうち「依存の宣言突合」と「依存のライセンス種別」は、依存(ライブラリ)に対する検出です。「コード照合」だけが、自分のコードそのものに対する検出になります。

コードへの「混入」は依存利用とは別物

pre-commitで何を見るかを決める前に、1つだけ押さえておきたい点があります。同じOSSでも「依存として使う」のと「自分のコードに取り込む」のとでは、意味が違う ということです。

OSSの入り方法的な意味(概略)扱い
依存(ライブラリ)として使う使うだけなら、表示義務などで済むことが多い許容しうる(CI側で管理)
自分のコードに貼り付けて取り込む取り込み=結合・改変扱いとなり、ソース開示など重い義務が発生しうる止めたい(これが「混入」)

ライセンス種別ごとの具体的な義務(GPLとLGPLの違いなど)は本記事の主題ではないため踏み込みません。ここで必要なのは「依存として使う分には許容できても、コードへの混入は別問題」という一点です。

pre-commitで止めたいのは、この 「混入」 です。したがって、pre-commitで見るのはコード照合に絞ります。理由は「処理が軽いから」ではなく、コード照合が混入の発生源だからです。

  • OSSコードの混入が起きる瞬間は「コードを書く・貼る、その時」です。これはコード照合でしか捕まえられず、最も上流で止められます。
  • 依存に関わる2つ(依存の宣言突合・依存のライセンス種別)は、マニフェストを編集するという意識的な操作で発生し、構造的に見落としにくいものです。かつ「依存として使う分には許容する」という管理が必要になるため、コミットの一瞬で一律に止めるよりも、CI側で扱う方が適しています。

加えて、AIでコードを書く比重が上がるほど、この観点は重要になります。AIは学習データ由来のOSSコードをそのまま出力することがあり、書いた本人に「これは拾ってきたコードだ」という自覚がないまま混入する可能性があります。生成量も多く、目視レビューで追い切れません。コミットの瞬間に機械が照合するpre-commitは、AI生成コードに対する現実的なガードレールとして機能します。

検出結果を判断するのは開発者

もう1つ、pre-commitをローカルに置く理由があります。スキャン結果は、それ自体が答えではないという点です。

検出された結果を見て、どう対応するかを決めるのは人間(開発者)の仕事です。

  • 検出が妥当な場合 → コードを直す、あるいは正規の依存として取り込む
  • 意図と違う検出の場合 → 誤検出として扱い、設定で除外する

どちらに倒すかは、そのコードをなぜ・どこから持ってきたかを知っている開発者本人にしか判断できません。スキャンは判断材料を提示するところまでで、その先は必ず人間が引き取ります。

問題は、その判断をいつ・どこで引き取るかです。

  • プルリクエストを出した後(CI)で受け取る場合、判断のためにプルリクエストを発行し、CIを実行し、結果が返るのを待つ、という工程を経てから手元に戻ってきます。判断のたびに往復が発生します。
  • コミットの瞬間(pre-commit)で受け取れば、往復はありません。コードを書いた直後、文脈が最も濃い手元に結果が出るので、その場で対応を決められます。

つまり、速度そのものが目的なのではなく、「判断を手元に置く」ことの結果として速度が効いてきます。逆に言えば、遅いpre-commitはgit commit --no-verifyで外されるようになり、「判断を手元に置く」という目的ごと壊れてしまいます。軽量であることは必須条件です。

SCANOSSをpre-commitに組み込む(公式hookと自作hook)

SCANOSSのコードスキャンをpre-commitに乗せる手段は、大きく2つあります。SCANOSSが提供する公式hookと、自作hookです。まず公式hookを見て、その制約を確認したうえで自作hookに進みます。

公式pre-commit hookを使う

SCANOSSは公式のpre-commit hook(scanoss/pre-commit-hooks)を提供しています。提供されるhookはscanoss-check-undeclared-codeの1種類です。

導入手順

設定は.pre-commit-config.yamlに次を追加するだけです。

repos:
  - repo: https://github.com/scanoss/pre-commit-hooks
    rev: v0.4.0          # rev: v0 とすると major 系の最新に追従
    hooks:
      - id: scanoss-check-undeclared-code

追加後、pre-commit installでhookを有効化します。API KEYは OSSKB(無料)では任意で、Premium を使う場合は環境変数SCANOSS_API_KEYで渡します(本記事のCI側Secret名SCANOSS_KEYとは別名なので注意してください)。

できること

設定した公式hookは、コミット時に次のように動きます。

  • staged(ステージ済み)ファイルだけをSCANOSSのCLI scanoss-py でスキャンし、未識別(undeclared)のコンポーネントを検出するとコミットをブロックします。
  • スキャン対象は変更ファイル単位(CIのdeltaスキャンと同じ粒度)で、軽量です。
  • リポジトリを追加するだけで使え、スクリプトの保守が不要です。

未宣言OSSのシフトレフトだけが目的であれば、この公式hookを入れるのが最も手軽です。

できないこと(コピーレフト・モノレポ)

一方で、本記事の狙い(コードのコピーレフト混入を、モノレポのコンポーネント単位で検出する)に対しては、公式hookには現状いくつかの制約があります(いずれも 2026-06-13 時点で公開リポジトリのソースから確認した事実です)。公式hookは更新されうるため、導入前に最新の挙動を確認してください。

できないこと内容
コピーレフト判定公式hookは未識別(undeclared)専用で、コピーレフトライセンスの混入は判定しない
スキャン対象の絞り込みpass_filenames: falseで、hook自身がgit diff --stagedを実行し、常にstaged全体を対象にする
コンポーネント別設定単一のscanoss.jsonを前提とするため、モノレポでコンポーネントごとのscanoss.jsonを使い分けられない

コピーレフトまで検出したい、コンポーネントごとのscanoss.jsonを効かせたい、という要件には、現状は自作hookが必要になります。

龍ちゃん
龍ちゃん

SCANOSSは結構活発に開発がされているので、この辺は更新される可能性が大いにあります!もし回収されたらこちらも併せて更新する予定です。

自作hookでコピーレフトを検出する

公式hookで足りない部分(コピーレフト判定とコンポーネント別設定)は、自作hookで補います。

スキャンスクリプトとhook登録

自作hookは scanoss-py(本記事の検証は 1.52.1)を直接呼び出します。処理は単純です。staged のコードファイルをscanoss-py scanに渡してスキャンし、その結果をscanoss-py inspect copyleftに通して、コピーレフトが検出されたらコミットを中断します。

なお、この構成は SCANOSS Premium のAPI KEY を前提にしています。--filesで対象を指定し、かつscanoss.jsonのスニペット照合を有効にする組み合わせは、無料エンドポイント(OSSKB)に投げると400 Bad Requestで弾かれるためです(2026-06-13 時点・scanoss-py 1.52.1 で確認)。KEY は--key "$SCANOSS_KEY"で渡します。

# staged ファイルをスキャン(Premium KEY 経路)
scanoss-py scan --files <staged files> \
  --settings <component>/scanoss.json \
  --key "$SCANOSS_KEY" \
  --output result.json

# 検出結果からコピーレフトを判定(検出時は非ゼロ終了)
scanoss-py inspect copyleft --input result.json

scanoss-py inspect copyleftは、結果にコピーレフトライセンスが含まれていれば非ゼロで終了します(scanoss-py 1.52.1での実測値は、検出時にexit 2、未検出時にexit 0)。この終了コードを拾ってコミットを止めるだけです。

なお、上記はあくまで処理の核(scaninspect copyleft)を抜き出したものです。実際の hook スクリプトには、これに加えて KEY 未設定時のスキップ・スキャン対象外パス(node_modules等)の除外・終了コードを拾ってコミットを止める処理 が必要です(後述のとおり、コンポーネントの振り分けは.pre-commit-config.yaml側、これらの処理はスクリプト側で実装します)。

このスクリプトを用意し、.pre-commit-config.yamlにローカルhookとして登録します(設定キーは pre-commit公式ドキュメント を参照)。ここで一つ設計上の選択があります。「どのコンポーネントを、どのscanoss.jsonで見るか」の振り分けを、スクリプト内に書かず、pre-commitのfiles:(発火条件)に持たせることです。コンポーネントごとにhookを1つずつ立て、argsでそのコンポーネントのscanoss.jsonを渡し、files:でそのコンポーネント配下のコードだけを発火条件にします。

- repo: local
  hooks:
    - id: scanoss-code-slides
      name: SCANOSS code scan (slides)
      entry: scripts/scanoss-precommit-scan.sh
      args: ['application/slides/scanoss.json']        # このhookが使う設定
      language: script
      files: '^application/slides/.+\.(py|js|ts|tsx|vue|jsx|mjs)$'   # 発火条件=ルーティング
      pass_filenames: true
      require_serial: true
    - id: scanoss-code-tools
      name: SCANOSS code scan (tools)
      entry: scripts/scanoss-precommit-scan.sh
      args: ['application/tools/scanoss.json']
      language: script
      files: '^application/tools/.+\.py$'
      pass_filenames: true
      require_serial: true

こうすると、コンポーネントが増えてもスクリプトには手を入れず、hookを1つ足すだけで済みます。振り分けロジックがコードに散らばらず、.pre-commit-config.yamlを見れば「どのパスが、どのscanoss.jsonで見られるか」を一覧できます。モノレポでコンポーネントごとに設定を使い分けたい、という公式hookでできなかった要件は、この形で満たせます。

ここまでに出てきたファイルの配置は次のとおりです。スクリプトは1本で全コンポーネントが共有し、コンポーネント固有なのは各scanoss.jsonだけ、という形になります。

リポジトリルート/
├── .pre-commit-config.yaml         # hook登録(コンポーネントごとにrouting)
├── .env                            # SCANOSS_KEY を置く(.gitignore 対象)
├── scripts/
│   └── scanoss-precommit-scan.sh   # ゲート本体(全hookが共有して呼ぶ・付録に全体)
└── application/
    ├── slides/
    │   └── scanoss.json            # slides 用スキャン設定
    └── tools/
        └── scanoss.json            # tools 用スキャン設定

役割で分けると、scripts/scanoss-precommit-scan.sh(処理)・.pre-commit-config.yaml(振り分け)・各scanoss.json(コンポーネントごとのスキャン設定)・.env(KEY)の4種類です。コンポーネントを増やすときに触るのは、scanoss.jsonの追加と.pre-commit-config.yamlへのhook追記だけで、スクリプトは変わりません。

この構成には、軽量に保つための工夫も入っています。

  • files:で発火条件をコンポーネント配下のコード拡張子に限定します。READMEや画像だけのコミットでは動きません。
  • pass_filenames: trueにより、staged ファイルのパスがhookに渡され、変更ファイルだけをスキャンします(deltaスキャン)。argsで渡したscanoss.jsonが、そのコンポーネントの設定として適用されます。
  • SCANOSS_KEYが未設定の場合は警告を出してスキップ(exit 0)します。KEYが全員に行き渡る前から強制すると、KEYのない開発者のコミットがすべて止まり、これもまた--no-verifyでの回避につながるためです。KEYは.env(gitignore対象)や環境変数で渡し、リポジトリにはコミットしません。.envを使う場合は、スクリプト内でset -a; source .env; set +aとして読み込むか、direnvなどで環境変数として自動展開します。

もう1つ、運用上の原則があります。コードスキャンでは、検出された結果をいったんすべて受け止めることです。

前述のとおり、検出が妥当か誤検出かを判断するのは開発者です。その判断材料を取りこぼさないために、コードスキャンの段階では結果を絞り込まず、まず全部出します。コードへの混入は、最初から「このライセンスは気にしない」といった絞り込みをかけてしまうと、本来気づきたかったものまで検出される前に消えてしまいます。混入を見るスキャンは検出を抑制しない素の状態に保ち、出てきた結果を開発者が一つずつ判断する——という形にします。

コミットが止まる様子

コピーレフトライセンスを持つOSSのコードを含むファイルをステージし、コミットしようとした場合の挙動を確認します。ここでは、コピーレフトライセンス(GPL-3.0)を選択肢に持つOSSのコードをファイルに取り込んだ状態で、hookを実行しました。

SCANOSS code scan (slides)...............................................Failed
- hook id: scanoss-code-slides
- exit code: 1

  inspect copyleft -> application/slides
1 component(s) with copyleft licenses were found.

{
  "components": [
    {
      "purl": "pkg:github/stuk/jszip",
      "licenses": [
        {
          "spdxid": "GPL-3.0-only",
          "copyleft": true,
          "source": "component_declared"
        }
      ],
      "status": "pending"
    }
  ]
}

::error::Copyleft policy violation in application/slides

hookがFailed(exit 1)となり、コミットは作成されません。出力のexit code: 1はhook全体の終了コードで、スクリプトがinspect copyleftの非ゼロ終了(検出時 exit 2)を受け取って返したものです。出力には「どのコンポーネントの、どのライセンスが」検出されたかが表示されます。あとは開発者がその場で判断します。取り込んだコードを別の実装に置き換えるのか、あるいは妥当な検出として正規に扱うのか。判断に必要な材料が、コミットの瞬間に手元へ出ています。

プルリクエストを発行してCIの結果を数分待ってから手元に戻して判断する、という往復が、コミットの一瞬での判断に変わります。なお、コードスキャン(scan + inspect copyleft)にかかる時間は1ファイルあたり数秒程度で(本環境での実測は約5秒)、コミット体験を壊さない範囲に収まっています。

検出されたときの対応

コミットが止まったら、まず出力のコンポーネント(purl)とライセンスを確認し、その内容に応じて対応します。対応は確認した結果で分かれます。以下は対応の例です。

確認した結果(例)対応
表示義務だけで使えるライセンスだった(MIT 等)クレジット表示などの義務を満たしたうえで、そのまま利用する
誤検出だと判別できた(無関係なコードが偶然一致した)誤検出として設定で許容する。「なぜ誤検出と判断したか」を記録に残す
公開義務が伴うライセンスだった(GPL 等のソース開示義務)原則そのコードは使わず、自前で書き直す/別実装に置き換える。どうしても使うならライセンス担当に確認のうえ正規に対応する

上記は対応の例です。ライセンスの種類や配布の有無によって義務は異なるため、最終的な可否はライセンス担当に確認してください。

いずれの場合も、起点は「検出をいったん受け止めて、開発者が中身を確認する」ことです。pre-commitは、この確認と対応を、文脈が一番濃いコミットの瞬間に手元で行えるようにします。

pre-commitとCIの役割分担

最後に、pre-commitとCIの関係を整理します。両者はどちらが上位ということではなく、役割が異なる別々の仕組みです。どちらも必要です。

pre-commitCI
役割判断を開発者の手元・コミット時点に前倒しし、その場で気づかせるチームの権威的・最終ゲートとして違反を確実に止める
対象コード照合(混入の発生源)コード照合・依存の宣言突合・依存のライセンス種別
強制力--no-verifyで飛ばせる(気づきが役割)飛ばせない(必ず通過する)

pre-commitは--no-verifyで誰でも飛ばせるため、これ単体では違反を確実に止める保証にはなりません。確実に止める最終ゲートはCI側に必要で、pre-commitはその判定を手元で前倒しに気づかせる役割に徹します。だからこそ、両者は同じ基準で揃えておきたいところです。pre-commitで通ったものがCIで落ちる(あるいはその逆)が頻発すると、pre-commitは信用されなくなり、--no-verifyで外されて形骸化します。ポリシー(コピーレフトの扱いなど)はCIと揃え、pre-commitはCIの判定を手元で「予習」する位置づけにします。

何をどちらに担わせるかも切り分けます。混入(コードへの取り込み)は発生源で止めたいので、pre-commitで前倒しに見ます。一方、依存をどこまで許容するか(弱コピーレフトの依存を認めるか等)の判断は、承認の記録や台帳を伴うため、CI側に集約します。pre-commitのコードスキャンは検出をいったんすべて出して開発者に委ねる、というのも、この切り分けの裏返しです。

役割を混同して片方を省く(「pre-commitを入れたからCIは不要」「CIがあるから手元は不要」)と、どちらの利点も失われます。pre-commitで手元の混入に気づき、CIで確実に止める。この2段構えが基本です。

付録:完全なhook構成(delta+モノレポ対応)

パート2では処理の核(scaninspect copyleft)だけを示しました。ここでは、それをKEY未設定時のスキップ・除外・終了コードゲートまで含めて1本にまとめた、実際に動くスクリプトの全体を示します(copyleft検出に絞った構成。scanoss-py 1.52.1 で動作確認)。これは、パート2の.pre-commit-config.yamlでコンポーネントごとに登録した各hookが呼ぶ本体です。両者を組み合わせて動きます。

振り分け(どのコンポーネントを見るか)はpre-commitのfiles:が担うので、スクリプトはコンポーネントを意識しません。第1引数でscanoss.jsonのパスを受け取り、残りの引数で渡されたstagedファイルを、その設定でスキャンするだけです。

#!/usr/bin/env bash
# SCANOSS code scan (delta): 渡された staged コードを、指定の scanoss.json で copyleft 検査する。
# 対象コンポーネントの振り分け(発火条件)は .pre-commit-config.yaml の files: が担い、
# 使う scanoss.json は args で渡される。$1=scanoss.json のパス / $2..=staged ファイル。
set -euo pipefail

settings="$1"; shift

# .env があれば読み込む(SCANOSS_KEY をここに置く運用を想定。.env は gitignore 対象)
if [[ -f .env ]]; then
  set -a; source .env; set +a
fi

# KEY 未設定なら止めずにスキップ(KEY 未配布の開発者のコミットを止めないため)
if [[ -z "${SCANOSS_KEY:-}" ]]; then
  echo "::warning::SCANOSS_KEY 未設定のため code scan をスキップします" >&2
  exit 0
fi

# 渡された staged ファイルが無ければ何もしない
[[ $# -eq 0 ]] && exit 0

# --files には scanoss.json の skip.patterns が効かないため、
# 生成物・vendored パスはここで除外する。
declare -a files=()
for f in "$@"; do
  case "$f" in
    */node_modules/*|*/dist/*|*/archive/*) continue ;;
    *) files+=("$f") ;;
  esac
done
[[ ${#files[@]} -eq 0 ]] && exit 0

result=$(mktemp); trap 'rm -f "$result"' EXIT

# staged ファイルだけをスキャン(delta)。Premium KEY 経路。
if ! scanoss-py scan --files "${files[@]}" \
       --settings "$settings" \
       --key "$SCANOSS_KEY" \
       --output "$result"; then
  echo "::error::scan failed ($settings)" >&2
  exit 1
fi

# copyleft 検出時は非ゼロ終了(1.52.1 では exit 2)。終了コードをそのままゲートにする。
if ! scanoss-py inspect copyleft --input "$result"; then
  echo "::error::copyleft policy violation ($settings)" >&2
  exit 1
fi

.pre-commit-config.yaml(パート2)とこのスクリプトで、本文で説明した要素が一通りそろいます。

本文の主張どこで満たすか
delta(変更ファイル単位)pass_filenames: true で渡る staged ファイルだけを --files に渡す
モノレポ対応コンポーネントごとに hook を立て、files: で振り分け・args で各 scanoss.json を渡す(.pre-commit-config.yaml側)
--no-verify化を防ぐ軽さfiles: で発火条件を限定し、KEY 未設定は warning→skip、生成物パスは除外する
検出をいったん受け止めるcopyleft 用 hook には除外(特定ライセンス/PURLの許容)を持ち込まず、素のスキャンに保つ
コミットを止めるinspect copyleft の非ゼロ終了(exit 2)を拾い、スクリプト全体を非ゼロで終了

まとめ

SCANOSSのソースコードスキャンをpre-commitに組み込み、OSS由来コードの混入をコミット時点で検出する構成を紹介しました。

項目ポイント
pre-commitの目的スキャン結果を判断するのは開発者。その判断を、文脈が濃いコミットの瞬間・手元で引き取れるようにする
検出対象コード照合(混入の発生源)に絞る。依存の突合・ライセンス種別はCIに残す
公式hookとの違い公式hookは未識別(undeclared)専用。コピーレフト判定とコンポーネント別設定が必要なら自作hook
軽量化delta(変更ファイル単位)スキャン、発火条件の限定、KEY未設定時のスキップで--no-verify化を防ぐ
検出の扱いコードスキャンは結果を絞り込まず、いったんすべて受け止めて開発者が判断する
CIとの関係pre-commitとCIは役割の異なる両輪。同じ基準で揃え、どちらも残す

pre-commitで手元の混入に気づき、CIで確実に止める。この2段構えで、ローカルからCIまでSCANOSSの運用が一通りつながります。

参考資料

関連リンク

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です