この記事は #インフラ勉強会 Advent Calendar 17日目の記事です。 「インフラエンジニアのための」とつければ何でも良いよ!とのことなので、「インフラエンジニアのためのコンプライアンス管理」的な副題を付けておきましょうか。
Qiita版もあるらしく、こっちはまだ空きがあるかもしれません。
インフラ勉強会 Advent Calendar 2018 - Qiita
今回は、下記の記事たちでやりたい!と言っていた、OSSライセンス確認のCI組み込みをやった話を書きます。
使用しているOSSの一覧取得やライセンス確認を自動化/CI化したい話 - 好奇心の足跡
licensedでOSSのライセンスチェック - 好奇心の足跡
2018/12/22(土)更新:
「言った舌の根も乾かぬうちに」くらいのはやさですが、他のツールも試してみたろころ格段に性能が上がったのでこっちのほうがおすすめよ、的な記事を書きました。やりたいことは全く同じなのでよろしければこちらもご覧ください。
CIでOSSライセンスを自動チェック ~npm, pip編~ - 好奇心の足跡
やりたかったこと
上記の記事で背景の詳細は書いているので、もし気になる方は参照してください。
ざっくり言うと、使用しているOSSライブラリが自分たちの用途(商用利用など)・環境(頒布するなど)を許可していなかったり、コード開示を義務付けていると困るので、そういったライセンスのOSSを使っていないかを自動でチェックする仕組みを入れたいという話です。
ゴールは、CIで上記OSSのライセンスをチェックし、許可していないライセンスのOSSが使用されているのを検知したらCIを失敗させることです。
これで、ライセンスを確認せずにリリース直前まで行って「このOSS使えないわー!作り直しやー!」という悲劇や、そのままリリースしてあとから訴訟されるリスクを回避したいわけです。
今回は下記の条件でライセンスチェックをCIに組み込みました。
- チェック対象;Pythonライブラリ (
requirements.txt
で管理されている前提) - CI環境:AWSのCodeBuildを使用。メイン言語がPythonなので、Pythonランタイム(3系)を使用。
チェック対象のプロジェクトコードからCIのためのツール一式をgithubにそのまま上げているので、試しに動かすのに使っていただいても◎。
アプリの構成
わかりやすくするため、下記のようなサンプル構成にしてみます。
. ├── app │ ├── backend │ │ ├── main.py │ │ └── requirements.txt │ ├── front │ │ ├── main.py │ │ └── requirements.txt │ └── test │ ├── requirements.txt │ └── sample_test.py ├── buildspec.yml ├── infra └── tool └── ci_cd ├── .licensed.yml ├── check_license.sh ├── exec_license_check.sh └── licensed2csv.py
この中で、実際に商用環境で動作するのは myapp/app/backend
配下と myapp/app/front
配下のコードになるため、今回のライセンス確認対象のライブラリはこれらの配下にある requirements.txt
で管理されているものとします。
ちなみに、各requirements.txt
はこんな感じです。
backend/requirements.txt
PyYAML simplejson boto3 pytz mysql
front/requirements.txt
Flask PyYAML Flask-Kerberos simplejson boto3 pytz pycrypto
test/requirements.txt
(今回は使用しない)
nose requests colorama coverage
※主にライセンスの散らばり具合で適当に選んでいるので、実システムを動作させるにはおかしな並びになっているかもしれません
buildspec.yml
はCodeBuild用の設定ファイル、tool/ci_cd
配下のスクリプトたちは今回使用するスクリプトになります。
今回のコードはすべてここに置いておきました。
CodeBuildの構成・環境
今回は下記の構成・環境で作成しました。
- ソースプロバイダ:GitHub
- リポジトリURL:https://github.com/kusuwada/licensed_ci_test.git
- リポジトリ接続方法:OAuth
- 環境イメージ:マネージド型のUbuntu
- ランタイム:Python, aws/codebuild/python:3.7.1
- Buildspec:デフォルト (リポジトリのrootディレクトリに
buildspec.yml
) - アーティファクト(成果物):AmazonS3に適当なバケットを作成して指定
- 環境変数:上記アーティファクトで指定したバケットを
BUCKET_NAME
の名前の環境変数で保持 - IAM Role:自動で作成してくれるポリシーに大体ついているが、syncを使ったので
上記アーティファクトで指定したバケットへの
ListBucket
とGetObject
を追加 リソースにarn:aws:s3:::${BUCKET_NAME}
の追加
CodePipelineとの連携とかは面倒だったのでポチッと手で実行。実際組み込むときは既に回ってるCI環境に入れると思うので今回はトリガとかソースのバージョン指定(プルリク・コミット・ブランチ指定など)は無視。
各種スクリプトと解説
buildspec.yml
version: 0.2 phases: install: commands: - export BUILD_HOME_DIR=`pwd` - echo ${BUILD_HOME_DIR} - apt-get update # install ruby with rbenv - apt-get install -y openssl libssl-dev libreadline6 libreadline6-dev - mkdir ~/.rbenv - git clone https://github.com/rbenv/rbenv.git ~/.rbenv - mkdir ~/.rbenv/plugins ~/.rbenv/plugins/ruby-build - git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build - cd ~/.rbenv/plugins/ruby-build - ./install.sh - echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile - echo 'eval "$(rbenv init -)"' >> ~/.bash_profile - . ~/.bash_profile - rbenv install 2.4.0 - rbenv rehash - rbenv global 2.4.0 - ruby --version - cd ${BUILD_HOME_DIR} # install packages for licensed - apt-get install -y cmake pkg-config - rbenv exec gem install bundler - rbenv rehash # install packages for requirements - apt-get install -y mysql-server # install packages for tool - apt-get install -y bc pre_build: commands: - cd ${BUILD_HOME_DIR} build: commands: - echo "----- build start ----" - mkdir /tmp/log # exec oss license check - sh ${BUILD_HOME_DIR}/tool/ci_cd/exec_license_check.sh "${BUILD_HOME_DIR}" /tmp/log > /tmp/log/license_check_log.log # check licenses - sh ${BUILD_HOME_DIR}/tool/ci_cd/check_license.sh /tmp/log/licensed_status.log post_build: commands: - cd ${BUILD_HOME_DIR} # upload logs and oss list to s3 - aws s3 sync /tmp/log s3://${BUCKET_NAME}/ --exclude "*" --include "*.log" --include "*.csv"
PythonのRuntimeを使用したので、rubyが入っていません。今回のユースケースだけ見たら、なんで Python Runtime 使っとんねんって感じですが、プロジェクトがPythonメインだとまぁそうなりますよね。でもlicensedがrubyなんです。rubyのinstallに時間がかかるので、CI効率を上げるためにはruby(やその他諸々)をインストールしたイメージを用意しておくのもよいかと思います。イメージの管理を始めると面倒ではあるのですが。
exec_license_check.sh
#!/bin/bash set -vxeu ################################################################################ # # 説明 # ========== # # CI環境でlicenseチェックを実行するための設定をし、licensedを実行します。 # # # パラメータ # ========== # # 1. ベースディレクトリパス # プロジェクトのベースディレクトリパス # e.g.) /codebuild/output/srcxxxxxxxx/src/github.com/kusuwada/licensed_ci_test # # 2. licenseチェック実行ログ・成果物格納先へのパス # e.g.) /tmp/log # ################################################################################ : $1 $2 BASE_DIR="${1}" RESULT_DIR="${2}" PYVENV_DIR=${BASE_DIR}/license_check LICENSED_DIR=${PYVENV_DIR}/app/.licensed LIBRARY_CACHE_DIR=${LICENSED_DIR}/.licenses echo ${BASE_DIR} echo ${RESULT_DIR} mkdir ${PYVENV_DIR} virtualenv ${PYVENV_DIR} --no-site-packages cd ${PYVENV_DIR} chmod 755 bin/activate set -eu . bin/activate set +eu cp -R ${BASE_DIR}/app . cd app pip install -r ./backend/requirements.txt pip install -r ./front/requirements.txt mkdir ${LICENSED_DIR} cd ${LICENSED_DIR} pip freeze -l > requirements.txt bundle init echo "gem 'licensed', :group => 'development'" >> Gemfile bundle install --path vendor/bundle cp ${BASE_DIR}/tool/ci_cd/.licensed.yml . sed -ie "s|__SOURCE_PATH__|${LICENSED_DIR}|" .licensed.yml sed -ie "s|__CACHE_PATH__|${LIBRARY_CACHE_DIR}|" .licensed.yml sed -ie "s|__VIRTUAL_ENV_DIR__|${PYVENV_DIR}|" .licensed.yml cat .licensed.yml bundle exec licensed cache bundle exec licensed status > ${RESULT_DIR}/licensed_status.log cp ${BASE_DIR}/tool/ci_cd/licensed2csv.py ${LIBRARY_CACHE_DIR} cd ${LIBRARY_CACHE_DIR} python licensed2csv.py cp libraries.csv ${RESULT_DIR}
やっつけ感の拭えないコードですが、やりたい処理をバーっと書いたらこんな感じ。
特筆すべきは . bin/activate
の前後にある set -eu
, set +eu
。virtualenv環境のactivateについては色々イケていない議論があるようですが、その一つがこれ。実行時に未知の変数参照を行ってしまっており、bashのset option に set -eu
を設定していると落ちてしまいます。下記議論にもちらっと出てきていました。
Virtualenv's `bin/activate` is Doing It Wrong · GitHub
set -eu
コマンドについてはここがわかりやすくまとまってました。シェルスクリプトを書くときはset -euしておく - Qiita
check_license.sh
licensedの結果をチェックするスクリプトです。許可していないライセンスのライブラリが見つかった場合、CIをERRORにさせます。
#!/bin/bash set -vxeu ################################################################################ # # 説明 # ========== # # 引数で渡された licensed のstatusログファイルに記載された結果にwarningがあるかどうかを判断する。 # - 01.licensed のログファイルにある結果の部分を取得 # - 02.warningが0件でない場合は異常終了 # # # パラメータ # ========== # # 1. licensed のログファイルパス # e.g.) /tmp/log/licensed_status.log # # ################################################################################ : $1 RESULT_FILE="${1}" WARNING=`grep "warnings found" ${RESULT_FILE} | sed -e 's/.*dependencies checked, //g' | sed 's/warnings found.//g'` echo "warning count: ${WARNING}" CHECK=`echo "${WARNING} == 0" | bc` if [ ${CHECK} -eq 0 ]; then echo "license NG : ${WARNING}" exit 1 fi exit 0
.licensed.yml
まずは下記の設定でかけてみます。pipだけを対象にしたかったのでsourcesはあえて指定してみました。
__HOGEHOGE__
の変数はスクリプトで適切なものに置き換えられます。
name: myapp source_path: '__SOURCE_PATH__' cache_path: '__CACHE_PATH__' python: virtual_env_dir: '__VIRTUAL_ENV_DIR__' # 一部のsourceだけ有効にしたい場合に使用します # default(記述なし)では、発見されたすべてのsourceに対してパースします sources: rubygem: false pip: true # 使用を許可するライセンスの一覧 allowed: - mit - apache-2.0 - bsd - isc
licensed2csv.py
licensed の cache コマンドで生成される license 情報のテキスト群からサマリ情報を収集し、csvファイルで吐き出します。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import csv OUTPUT_CSV_FILE = 'libraries.csv' class Licensed: BLOCK_SEPARATOR = '---' INFO_SEPARATOR = ': ' TYPE = 'type' NAME = 'name' VERSION = 'version' HOMEPAGE = 'homepage' LICENSE = 'license' class LibInfo: def __init__(self, type=None, name=None, version=None, homepage=None, license=None): self.type = type self.name = name self.version = version self.homepage = homepage self.license = license def parse_directory(path): items = os.listdir(path) for i in items: item_path = path + os.sep + i if os.path.isfile(item_path): parse_license_file(item_path) elif os.path.isdir(item_path): parse_directory(item_path) else: continue return def parse_license_file(path): lib = {} separator_count = 0 with open(path) as f: line = f.readline() while line or separator_count < 2: if line.startswith(Licensed.BLOCK_SEPARATOR): separator_count += 1 elif len(line.split(Licensed.INFO_SEPARATOR)) >= 2: lib[line.split(Licensed.INFO_SEPARATOR)[0]] = \ line.split(Licensed.INFO_SEPARATOR)[1].strip() line = f.readline() libinfo = LibInfo(lib[Licensed.TYPE], lib[Licensed.NAME], lib[Licensed.VERSION], lib[Licensed.HOMEPAGE], lib[Licensed.LICENSE]) libinfo_list.append(libinfo) if __name__ == '__main__': libinfo_list = [] path = os.getcwd() directories = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))] for d in directories: child_dir = path + os.sep + d parse_directory(child_dir) csv_array = [[Licensed.TYPE.upper(), Licensed.NAME.upper(), Licensed.VERSION.upper(), Licensed.HOMEPAGE.upper(), Licensed.LICENSE.upper()]] for l in libinfo_list: csv_array.append([l.type, l.name, l.version, l.homepage, l.license]) with open(OUTPUT_CSV_FILE, 'w') as f: writer = csv.writer(f, lineterminator='\n') writer.writerows(csv_array)
実行結果
成果物置き場として指定したS3に、ライセンス一覧のcsvが吐き出されます。こんなイメージ。
requirements.txt に直接指定したライブラリだけでなく、依存パッケージまでカバーできていることがわかります。
また、最初の .licensed.yml
の設定でCIを実行した結果はこんな感じになりました(画像はCodeBuildのログ)。ライセンス不明のライブラリや許可リストにないライブラリが多いため、全22ライブラリ中19個もWarningが出ています。
上記のライセンス一覧のcsvを参考に、Warningが出たライブラリについて
- ライセンスはなにか(licensedで検出できていない場合)
- 許可リストにないライセンスを使用しているが問題がないか
あたりを確認していきます。ライセンスが何かを調べるには、pythonだと下記サイトが手っ取り早いでしょう。
問題がないと確認が取れたライブラリについて、除外リストに加えていきます。
.licensed.yml
name: myapp source_path: '__SOURCE_PATH__' cache_path: '__CACHE_PATH__' python: virtual_env_dir: '__VIRTUAL_ENV_DIR__' # 一部のsourceだけ有効にしたい場合に使用します # default(記述なし)では、発見されたすべてのsourceに対してパースします sources: rubygem: false pip: true # 使用を許可するライセンスの一覧 allowed: - mit - apache-2.0 - bsd - isc # レビュー済みlibraryの記載 # licensedでは検出ミスもあるため、問題ないと確認できたlibraryはここに記載する reviewed: pip: - mysqlclient # gpl-2.0 - Jinja2 # bsd - simplejson # AFL & mit - MarkupSafe # bsd - Click # bsd - urllib3 # mit - itsdangerous # bsd - Werkzeug # bsd - Flask # bsd # 無視リスト # licensedがライセンス表記を発見できなかったが、問題がないと確認できたlibrary ignored: pip: - Flask-Kerberos # bsd - boto3 # apache-2.0 - kerberos # apache-2.0 - mysql # gpl - s3transfer # apache-2.0 - docutils # bsd, gpl, public domain, gpl-3.0 - python-dateutil # apache-2.0, bsd - jmespath # mit - botocore # apache-2.0 - pycrypto # public domain
ここで見ていただいて分かる通り、licensed、まだまだ結構誤検知・検知漏れが多いのと、Pythonでよく使われている bsd
ライセンスが軒並み検知できていません(2018年12月現在)。除外リストで運用できるものの、Pythonで使うのはまだちょっと厳しいかもしれません。OSSなので、昨日の追加や修正はissue立てたりプルリク投げれば通ると思うので、皆でどんどんブラッシュアップしていきましょう!
除外リストを追加したときのCI結果です。
無事warningが全部解消され、CIがpassしました!
まとめ
3回に渡ってOSSのライセンス、とくに licensed に焦点を当てて書いてみましたが、CIに組み込むところまで持っていけてよかったです。
その一方、多言語対応の弊害か、新し目の発展途上のライブラリだからか、他のOSSライセンス抽出ソリューションと比べて、適用方法が複雑になりがち・ライセンス抽出精度が良くないといった課題が浮き彫りになってきました。
近々、言語別のソリューションで同じことを試してみて比較してみたいと思っています。
また、今回かなり無理やりインフラ勉強会のAdventCalendarに混ぜていただきましたが、ShellやYaml多めだったのでインフラエンジニアっぽい内容になった?と思うと同時に、コンプライアンス周りの施策に興味を持っていただいて、インフラエンジニア発で開発に取り込むきっかけになれば嬉しいです!
明日は suminofu_3 さんです!