好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

CIでOSSライセンスを自動チェック ~npm, pip編~

2018年 SRE Advent Calendar 2 の22日目に寄せて書きました。

qiita.com

1の方でも一つ投稿したのですが、マネジメント・チームビルドっぽい話になったので今回は技術よりの話を。2018年 SRE Advent Calendar 1はこちら。

SRE Advent Calendar 2018 - Qiita


今日は、下記の記事でやってみた、「CIでOSSライセンスを自動チェック」の続編になります。

kusuwada.hatenablog.com

上記記事では、github製の Licensed というOSSを使って、ライブラリの一覧・ライセンス情報抽出・ライセンスのOK/NG判定までを実施してみました。
しかし、Licensedでは多言語に対応しているしわ寄せが検出精度に出てしまっている印象です。検出できない形式のライセンスが多すぎて、結局ほとんど手作業でライセンスを確認、問題なかったライブラリを「除外リスト」に入れて運用する羽目に。。。
実行環境についてもRuby環境が必須のため、PythonオンリーのプロジェクトのCI環境にこのためだけにRubyを入れるのか?という話もあります。

なので今回は一旦Licensedから離れて、言語別で構わないので他のソリューションを検証し、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自身のライセンスどうなん?という話もあることから、下記記事のライブラリを使った方法の方を推奨します。

kusuwada.hatenablog.com

OSS情報一覧を生成する

npmでパッケージ管理している場合、gruntツールが非常に有用です。そういえば来年はいのしし年ですね。

Grunt: The JavaScript Task Runner

f:id:kusuwada:20181219005420p:plain:w100

このツールのプラグインである grunt-license-report を使用します。

grunt-license-report - npm

対象のリポジトリ構成はこんな感じ。

.
├── 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.jsondependencies とします。※package.jsondevDependencies は対象外とします。このあたりの話はこちら参照。

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

f:id:kusuwada:20181220010936p:plain

許可していないライセンスの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

今回のターゲットが javascriptpython だったのもあり、個人の開発環境ならまだしも、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なので、可能性を感じた方がいらっしゃいましたら是非一緒に育てていきましょう(๑•̀ㅂ•́)و✧

参考リンク

関連記事

kusuwada.hatenablog.com

kusuwada.hatenablog.com

kusuwada.hatenablog.com