好奇心の足跡

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

CI+LicensedでOSSのライセンスを自動チェック

この記事は #インフラ勉強会 Advent Calendar 17日目の記事です。 「インフラエンジニアのための」とつければ何でも良いよ!とのことなので、「インフラエンジニアのためのコンプライアンス管理」的な副題を付けておきましょうか。

adventar.org

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 配下のスクリプトたちは今回使用するスクリプトになります。

今回のコードはすべてここに置いておきました。

github.com

CodeBuildの構成・環境

今回は下記の構成・環境で作成しました。

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が吐き出されます。こんなイメージ。

f:id:kusuwada:20181214014019p:plain

requirements.txt に直接指定したライブラリだけでなく、依存パッケージまでカバーできていることがわかります。

また、最初の .licensed.yml の設定でCIを実行した結果はこんな感じになりました(画像はCodeBuildのログ)。ライセンス不明のライブラリや許可リストにないライブラリが多いため、全22ライブラリ中19個もWarningが出ています。

f:id:kusuwada:20181214014036p:plain

上記のライセンス一覧のcsvを参考に、Warningが出たライブラリについて

  • ライセンスはなにか(licensedで検出できていない場合)
  • 許可リストにないライセンスを使用しているが問題がないか

あたりを確認していきます。ライセンスが何かを調べるには、pythonだと下記サイトが手っ取り早いでしょう。

pypi.org

問題がないと確認が取れたライブラリについて、除外リストに加えていきます。

.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立てたりプルリク投げれば通ると思うので、皆でどんどんブラッシュアップしていきましょう!

github.com

除外リストを追加したときのCI結果です。

f:id:kusuwada:20181214014041p:plain

無事warningが全部解消され、CIがpassしました!

まとめ

3回に渡ってOSSのライセンス、とくに licensed に焦点を当てて書いてみましたが、CIに組み込むところまで持っていけてよかったです。
その一方、多言語対応の弊害か、新し目の発展途上のライブラリだからか、他のOSSライセンス抽出ソリューションと比べて、適用方法が複雑になりがち・ライセンス抽出精度が良くないといった課題が浮き彫りになってきました。
近々、言語別のソリューションで同じことを試してみて比較してみたいと思っています。

また、今回かなり無理やりインフラ勉強会のAdventCalendarに混ぜていただきましたが、ShellやYaml多めだったのでインフラエンジニアっぽい内容になった?と思うと同時に、コンプライアンス周りの施策に興味を持っていただいて、インフラエンジニア発で開発に取り込むきっかけになれば嬉しいです!

明日は suminofu_3 さんです!