2018年 SRE Advent Calendar 2 の22日目に寄せて書きました。
1の方でも一つ投稿したのですが、マネジメント・チームビルドっぽい話になったので今回は技術よりの話を。2018年 SRE Advent Calendar 1はこちら。
SRE Advent Calendar 2018 - Qiita
今日は、下記の記事でやってみた、「CIでOSSライセンスを自動チェック」の続編になります。
上記記事では、github製の Licensed というOSSを使って、ライブラリの一覧・ライセンス情報抽出・ライセンスのOK/NG判定までを実施してみました。
しかし、Licensedでは多言語に対応しているしわ寄せが検出精度に出てしまっている印象です。検出できない形式のライセンスが多すぎて、結局ほとんど手作業でライセンスを確認、問題なかったライブラリを「除外リスト」に入れて運用する羽目に。。。
実行環境についてもRuby環境が必須のため、PythonオンリーのプロジェクトのCI環境にこのためだけにRubyを入れるのか?という話もあります。
なので今回は一旦Licensedから離れて、言語別で構わないので他のソリューションを検証し、Licensedと比較してみようと思います。
また、今回試した言語別のライセンスチェックで使用した、ライセンス一覧出力」「ライセンスチェック」のスクリプトも紹介します。
- 言語別ライセンス確認ツールの候補
- Python with pkg_resources
- JS with grunt-license-report
- Licensed との比較まとめ
- 結論
- 参考リンク
- 関連記事
言語別ライセンス確認ツールの候補
今回も ruby(gem), python(pip), js(npm) を対象とし...たかったのですが、長くなったのでrubyは省略。また気が向いたらやります。
- Python
- pkg_resources
- JS
- grunt (grunt-license-report)
Python with pkg_resources
OSS情報一覧を生成する
pipでパッケージ管理している場合、pkg_resourcesというツールでライブラリ情報をとってこれます。
これを使ってパッケージ情報一覧を出力するソースが紹介されていたので、ほぼこのまま使っちゃいます。
Pythonライブラリのライセンス情報を一括出力する方法 – つまさぽ(妻のサポート)
-> output_packages_and_licenses.py
対象のリポジトリ構成はこんな感じ。
. ├── app │ ├── front │ │ ├── main.py │ │ ├── requirements.txt │ └── test │ ├── requirements.txt │ └── sample_test.py ├── infra └── tool └── license_check ├── license_check_config.yml ├── npm └── pip ├── judge_pip_license.py ├── output_packages_and_licenses.py └── requirements.txt
ライセンスを確認したいモジュールは、 app/front/requirements.txt
とします。
tool
配下は今回のライセンスチェックのためのツールです。
まず、app/front
ディレクトリで
$ pip install -r requirements.txt -t ./site-packages
します。もし実行環境がまっさらな状態 or 本番環境相当のイメージでしたら、 -t
オプションは不要かもしれませんが、今回はこのシステム用のモジュールだけ確認したいため、-t
オプションでライブラリのインストール先を指定します。
先ほど紹介したサイトで提供されているソース(微妙に変更しています)を使って、ライブラリ情報一覧を出力します。ソースはこちら。
output_packages_and_licenses.py
#!/usr/bin/env python3 # -*- coding: utf-8 -*- ################################################################################ # # 説明 # ========== # # 現在の環境にinstallされているpythonライブラリ情報を、一括出力します # 出典: http://futago-life.com/wife-support/tech/python-lib-license.html # format: タブ区切りで下記のライブラリ情報のリストが出力されます # name, version, license, repository_url # # パラメータ # ========== # # なし # ################################################################################ import pkg_resources def get_pkg_license(pkg): ''' pkgで指定するpackageのライセンスを復帰します。 ''' try: lines = pkg.get_metadata_lines('METADATA') except: lines = pkg.get_metadata_lines('PKG-INFO') license = 'UNKNOWN' labels = ['License: ', 'Classifier: License :: OSI Approved :: '] for label in labels: for line in lines: if line.startswith(label): license = line[len(label):] break return license def get_pkg_home_page(pkg): ''' pkgで指定するpackageのHome Page URLを取得する。 ''' try: lines = pkg.get_metadata_lines('METADATA') except: lines = pkg.get_metadata_lines('PKG-INFO') label = 'Home-page: ' for line in lines: if line.startswith(label): url = line[len(label):] break return url def create_packages_and_licenses_text(): ''' pythonにインストールされているライブラリの 「Package名、版数、ライセンス名、Home Page URL」をタブ区切りで出力します。 ''' text = '' for pkg in sorted(pkg_resources.working_set, key=lambda x: str(x).lower()): text += '\t'.join([pkg.key,pkg.version,get_pkg_license(pkg),get_pkg_home_page(pkg)]) + '\n' return text if __name__ == "__main__": text = create_packages_and_licenses_text() print(text)
一時的に PYTHONPATH
を環境変数に先程指定したsite-packages
のパスを設定してからスクリプトを実行します。※既に設定されている場合は上書きのコマンドになりますのでご注意ください。
$ export PYTHONPATH={my-app_path/front/site-packages} $ cd ../../tool/license_check/pip $ python output_packages_and_licenses.py
実行結果
[name, version, license, repository_url] の情報がタブ区切りで出力されます。
boto3 1.9.68 Apache Software License https://github.com/boto/boto3 botocore 1.12.68 Apache Software License https://github.com/boto/botocore click 7.0 BSD License https://palletsprojects.com/p/click/ docutils 0.14 Python Software Foundation License http://docutils.sourceforge.net/ flask 1.0.2 BSD License https://www.palletsprojects.com/p/flask/ flask-kerberos 1.0.4 BSD License http://github.com/mkomitee/flask-kerberos itsdangerous 1.1.0 BSD License https://palletsprojects.com/p/itsdangerous/ jinja2 2.10 BSD License http://jinja.pocoo.org/ jmespath 0.9.3 MIT License https://github.com/jmespath/jmespath.py kerberos 1.3.0 Apache Software License https://github.com/apple/ccs-pykerberos markupsafe 1.1.0 BSD License https://www.palletsprojects.com/p/markupsafe/ pip 10.0.1 MIT License https://pip.pypa.io/ pycrypto 2.6.1 UNKNOWN http://www.pycrypto.org/ python-dateutil 2.7.5 BSD License https://dateutil.readthedocs.io pytz 2018.7 MIT License http://pythonhosted.org/pytz pyyaml 3.13 MIT License http://pyyaml.org/wiki/PyYAML s3transfer 0.1.13 Apache Software License https://github.com/boto/s3transfer setuptools 39.0.1 MIT License https://github.com/pypa/setuptools simplejson 3.16.0 MIT License https://github.com/simplejson/simplejson six 1.12.0 MIT License https://github.com/benjaminp/six urllib3 1.24.1 MIT License https://urllib3.readthedocs.io/ virtualenv 16.1.0 MIT License https://virtualenv.pypa.io/ werkzeug 0.14.1 BSD License https://www.palletsprojects.org/p/werkzeug/
許可していないライセンスのOSSがないかをチェック
上記のリストを
$ python output_packages_and_licenses.py > pip_licenses.txt
などと出力しておき、この出力ファイルをパースして許可していないライセンスのライブラリが紛れていないかチェックします。
上記で tool
配下に配置されているライセンスチェックのためのスクリプト群の中身はこんな感じ。
license_check_config.yml
# 許可するライセンスのリスト。以下のリストは例。 allowed: - MIT License - Apache Software License - BSD License # 許可するライセンス一覧にはないが、ライセンスに問題が無いことが確認できたライブラリ reviewed: npm: - hogehoge pip: - piyopiyo # チェック対象外のライブラリ ignored: npm: - my-app pip: - my-app
requirements.txt
PyYAML pprint
judge_pip_license.py
#!/usr/bin/env python3 # -*- coding: utf-8 -*- ################################################################################ # # 説明 # ========== # # output_packages_and_licenses.pyで作成したライブラリ情報一覧ファイルをパースし、 # 指定されたconfigファイルで許可されていないライセンスのライブラリがないかをチェックします。 # # パラメータ # ========== # # 1. パース対象のライセンス一覧ファイルパス # e.g.) pip_licenses.txt # # 2. configファイルパス # 使用を許可するライセンスや、確認済みのライブラリの設定が書かれたconfig fileのパス # e.g.) ../license_check_config.yml # # 返却値 # ========== # # exit 0: warningなしの場合 # exit 1: warningありの場合 # ################################################################################ import sys import yaml import pprint if len(sys.argv) < 3: print('Insufficient number of arguments.') sys.exit(1) target_path = sys.argv[1] config_path = sys.argv[2] with open(config_path, 'r') as f: config = yaml.load(f) libraries = {} warnings = {} with open(target_path, 'r') as f: for line in f: if len(line.rstrip().split("\t")) == 4: name = line.rstrip().split("\t")[0] license = line.rstrip().split("\t")[2] libraries[name] = license for library in libraries: if libraries[library] in config['allowed']: continue if library in config['reviewed']['pip']: continue if library in config['ignored']['pip']: continue warnings[library] = libraries[library] # output result print('warnings:') pprint.pprint(warnings) result = 'RESULT: ' + str(len(libraries)) + ' dependencies checked, ' + str(len(warnings)) + ' warnings found.' print(result) # judge if len(warnings) > 0: print('license check failed.') sys.exit(1)
必要なモジュールをinstallして、スクリプトを実行します。先程 PYTHONPATH
を環境変数に設定していた場合は、これを解除しておきます。
# 必要に応じて $ unset PYTHONPATH
$ pip install -r requirements.txt $ python judge_pip_license.py pip_licenses.txt ../license_check_config.yml
実行結果(例)
warnings: {'docutils': 'Python Software Foundation License', 'pycrypto': 'UNKNOWN'} RESULT: 23 dependencies checked, 2 warnings found. license check failed.
許可リストにないライブラリの一覧と、チェック結果のサマリとして
- チェック対象のライブラリ数
- warning数
が出力されます。また、warningがあった場合は Exit(1) で終了します。
設定ファイル license_config.yml
の reviewed, ignored に先の実行結果でwarningが出たものを追加して再実行すると、warning が 0 になり、正常終了します。
JS with grunt-license-report
※ 2019/2/6 このライブラリではなく、現在活発な別のライブラリ (license-checker) を使った方法を下記記事に書きました。grunt-license-reportは最終更新が4年前であること、grunt自身のライセンスどうなん?という話もあることから、下記記事のライブラリを使った方法の方を推奨します。
OSS情報一覧を生成する
npmでパッケージ管理している場合、gruntツールが非常に有用です。そういえば来年はいのしし年ですね。
Grunt: The JavaScript Task Runner
このツールのプラグインである grunt-license-report
を使用します。
対象のリポジトリ構成はこんな感じ。
. ├── app │ ├── front │ │ ├── main.js │ │ ├── package.json │ └── test │ ├── package.json │ └── sample_test.js ├── infra └── tool └── license_check ├── license_check_config.yml ├── npm │ ├── judge_grunt_license.js │ └── package.json └── pip
ライセンスを確認したいモジュールは、 app/front/package.json
の dependencies
とします。※package.jsonの devDependencies
は対象外とします。このあたりの話はこちら参照。
tool
配下は今回のライセンスチェックのためのツールです。
まず、app/front
ディレクトリで
$ npm install --production
します。 production
optionは、開発環境・テスト用のパッケージをintallしないため、productionコードに関係あるパッケージのみに対象を絞れます。
追加で、grunt
は主に開発用途でproduction向けのpackage一覧には入っていないはずなので、grunt関連のパッケージを別途installします。
$ npm install grunt $ npm install grunt-license-report
下記の Gruntfile.js
を作成します。
module.exports = function (grunt) { grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), "grunt-license-report": { output: { path: './report/licenses', format: 'html' } } }); grunt.loadNpmTasks('grunt-license-report'); };
この設定で、 app/front/report
ディレクトリに licenses.html
ファイル(依存パッケージも含んだOSSとライセンス・リポジトリURLの一覧)が生成されるようになります。
実行はこんな感じ。
$ $(npm bin)/grunt grunt-license-report
許可していないライセンスのOSSがないかをチェック
ここまでは grunt-license-report の使い方そのものです。ここからはCIに組み込んだりして、許可していないライセンスのOSSがないかをチェックします。
上記で tool
配下に配置されているライセンスチェックのためのスクリプト群の中身はこんな感じ。
license_config.yml
# 許可するライセンスのリスト。以下のリストは例。 allowed: - MIT - Apache-2.0 - Apache 2.0 - APACHE-2.0 - ISC - BSD - BSD-2-Clause - BSD-3-Clause # 許可するライセンス一覧にはないが、ライセンスに問題が無いことが確認できたライブラリ reviewed: npm: - hogehoge pip: - piyopiyo # チェック対象外のライブラリ ignored: npm: - my-app pip: - my-app
package.json
{ "name": "grunt-license-tools", "version": "0.0.0", "license": "UNLICENSED", "dependencies": { }, "devDependencies": { "fs": "latest", "cheerio": "latest", "js-yaml": "latest" }, "private": true }
judge_grunt_license.js
/******************************************************************** * 説明 * ========== * * grunt-license-report で作成したライセンス一覧ファイルをパースし、 * 指定されたconfigファイルで許可されていないライセンスのライブラリがないかをチェックします。 * * * パラメータ * ========== * * 1. パース対象のreportファイルパス * grunt-license-report で作成したライセンス一覧ファイルのパス * e.g.) report/licenses.html * * 2. configファイルパス * 使用を許可するライセンスや、確認済みのライブラリの設定が書かれたconfig fileのパス * e.g.) grunt_license.yml * * 返却値 * ========== * * exit 0: warningなしの場合 * exit 1: warningありの場合 * ********************************************************************/ const fs = require('fs'); const cheerio = require('cheerio'); const yaml = require('js-yaml') if (process.argv.length < 4) { throw new Error('Insufficient number of arguments.'); } const target_path = process.argv[2]; const config_path = process.argv[3]; const report_html = fs.readFileSync(target_path, 'utf-8'); let report = cheerio.load(report_html) let libraries = {}; let warnings = {}; report('tr').each(function(i, el) { $ = cheerio.load(el) let project_name = '' $('td').each(function(j) { if (j == 0) { project = $(this).text(); // eliminate path & version project_name = project.split('/').pop().split('@')[0]; } else if (j == 1) { libraries[project_name] = $(this).text(); } }); }); const config = yaml.safeLoad(fs.readFileSync(config_path), 'utf-8') Object.keys(libraries) .filter(key => !config.allowed.includes(libraries[key])) .filter(key => !config.reviewed.npm.includes(key)) .filter(key => !config.ignored.npm.includes(key)) .forEach(key => { warnings[key] = libraries[key] }); // result console.log('warnings: ') console.log(warnings) const result = 'RESULT: ' + Object.keys(libraries).length + ' dependencies checked, ' + Object.keys(warnings).length + ' warnings found.' console.log(result) // judge if (Object.keys(warnings).length > 0) { console.log('license check failed.') process.exit(1) }
必要なモジュールをinstallして、スクリプトを実行します。
$ npm install $ node judge_grunt_license.js ../../../app/front/report/licenses.html ../license_check_config.yml
実行結果(例)
warnings: { argv: 'MIT*', atob: '(MIT OR Apache-2.0)', 'css-select': 'BSD-like', domutils: 'BSD*', 'my-app': 'UNKNOWN' } RESULT: 290 dependencies checked, 5 warnings found. license check failed.
出力内容と終了コードは pip のときと同じです。
また、設定ファイル license_config.yml
の reviewed, ignored に先の実行結果でwarningが出たものを追加して再実行すると、warning が 0 になり、正常終了します。
warnings: {} RESULT: 290 dependencies checked, 0 warnings found.
Licensed との比較まとめ
検出精度
Licensed | pkg_resource(pip) | grunt(npm) | |
---|---|---|---|
ライブラリ検出数(pip) | 22 | 22 | - |
ライブラリ検出数(npm) | 57 | - | 172(うちgrunt関連7件) |
ライセンス誤検出 | 83%(pip), 7%(npm) | 4% | 0% |
npmに関しては、Licensedでは複雑な依存関係の解決が難しいといった注意書きもあったとおり、依存パッケージ数の検出数が格段にgruntのほうが高かったです。
pipに関しては、ライブラリ検出数こそ差はないものの、ライセンス認識率・誤検出率が明らかにLicesnedでは劣っており、これ以外にも複数プロジェクトで試してみましたがほぼ使い物にならないレベルでした…。
LicensedはLICENSEファイルを探してそこからライセンスを推測するロジックなのに対し、他の言語別ソリューション(grunt/pkg_resources)は、各パッケージの定義ファイルのフォーマットに沿って書かれているライセンスをそのまま取ってくるだけなので、ライセンスの認識率がLicensedのほうが大きく劣ってしまうのは必至なのかなぁと。
導入環境
Licensed | pkg_resource(pip) | grunt(npm) | |
---|---|---|---|
必要な言語環境 | ruby + {js or python} | python | js |
今回のターゲットが javascript と python だったのもあり、個人の開発環境ならまだしも、CI環境にrubyが入っている事はそうそうありません。ライセンスチェックのためだけにrubyをinstallしたりrubyが入っているimageを用意したりしないといけないことを考えると、既にサービスで使う予定の言語環境があれば動くソリューションを使ったほうが導入のしやすさで考えると格段に上です。
複数言語対応
Licensedでは一つの設定ファイル(.licensed.yml/json
)で対応している複数の言語(用ライブラリ管理)の許可ライセンスリスト、除外ライブラリなどの設定を管理できました。
しかし結局それぞれの言語でのパッケージインストール手順は必要になるので、ライセンス許可リスト・除外ライブラリの設定だけを共有し、ライセンス抽出部分は言語別ソリューションで実施する、という方法にしても、あまり手間は変わらなかったです。
あえてLicensedが複数言語に対応している事によるメリットを上げるなら、検出したライセンスの表記ゆれが少ないので許可ライセンスリストが管理しやすい点でしょうか。
一方言語別のソリューションを組み合わせてライセンスを検出すると、大文字小文字・スペース/ハイフン・省略するしない、などの表記ゆれが発生するので、表記揺れを考慮したライセンス許可リストが必要になります。まぁここは全部書いとけばいいので、今のところそんなに課題には感じていませんが。
参考:表記揺れを考慮したライセンス許可リストの例
allowed: - MIT - MIT License - Apache-2.0 - Apache 2.0 - APACHE-2.0 - Apache Software License
結論
- 言語別のソリューションのほうがLicensedと比較して明らかに検出精度が良い & 導入障壁が低い
- 特に理由がなければ言語別でライセンス情報を抽出し、結果だけを合体させたほうが良さそう
ということで前記事から一点、手のひらクルー!で申し訳ありませんが、推しの方法をLicensed -> 言語別のソリューションに変更します。前記事もちょっと書き直しました。
でも前記事の最後にも触れたとおり、Licensedはまだ発展途上の育てがいのあるOSSなので、可能性を感じた方がいらっしゃいましたら是非一緒に育てていきましょう(๑•̀ㅂ•́)و✧
参考リンク
- Pythonライブラリのライセンス情報を一括出力する方法 – つまさぽ(妻のサポート)
- npmのライブラリのライセンスが商用利用可能か調べる - Qiita
- 【いまさらですが】package.jsonのdependenciesとdevDependencies - Qiita