好奇心の足跡

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

JavaScriptコードの安全を保つSAST(静的解析)ツール ~ npm-audit, NodeJsScan, LGTM~

Python編や、SASTツールとは?についてはこちら

kusuwada.hatenablog.com

今回はJavaScriptで使えるSASTツールを紹介します。

npm-audit

npm-audit とは

npm@6で追加された機能。npm install を実行すると自動的に実行され、インストールした node_module に対し既知の脆弱製の有無の確認を実施し、レポートを出力します。レポートには、対処方法に関する情報も含まれています。
また、version-upなどで自動修正可能な場合は npm audit fix コマンドで自動的に脆弱性を修正させることも出来ます。

実行

実行は簡単。v6.0以上のversionのnpmを使って、npm install するだけです。
ちなみに npm のversion確認、versionアップは下記の通り。

$ npm --version
$ npm install -g npm

6.0以上になっていることを確認し、package.jsonのあるディレクトリで下記を実行します。

$ npm install

これだけ。
試しに、私の古〜〜〜いprojectをチェックしてみます。

https://github.com/kusuwada/node-slack-log-exporter

最後のcommitが2015年の夏ですね。

$ npm install
npm WARN deprecated slack-client@1.4.1: Use @slack/client instead, this package is no longer maintained
npm WARN deprecated coffee-script@1.9.3: CoffeeScript on NPM has moved to "coffeescript" (no hyphen)

> ws@0.4.31 install /Users/kusumoto/workspace/git/node-slack-log-exporter/node_modules/ws
> (node-gyp rebuild 2> builderror.log) || (exit 0)

npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN slack-log-exporter@0.0.1 No repository field.

added 11 packages from 10 contributors and audited 11 packages in 5.083s
found 3 vulnerabilities (1 low, 2 high)
  run `npm audit fix` to fix them, or `npm audit` for details

audit機能はここ。

added 11 packages from 10 contributors and audited 11 packages in 5.083s found 3 vulnerabilities (1 low, 2 high) run npm audit fix to fix them, or npm audit for details

11個のpackageをチェックして3つも脆弱性が見つかったんですねー。しかも2つが HIGH !
アドバイスに従って詳細を見るために npm audit してみます。

$ npm audit
                                                                                
                       === npm audit security report ===                        
                                                                                
┌──────────────────────────────────────────────────────────────────────────────┐
│                                Manual Review                                 │
│            Some vulnerabilities require your attention to resolve            │
│                                                                              │
│         Visit https://go.npm.me/audit-guide for additional guidance          │
└──────────────────────────────────────────────────────────────────────────────┘
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ High          │ Denial of Service                                            │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ ws                                                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in    │ >= 1.1.5 <2.0.0 || >=3.3.1                                   │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ slack-client                                                 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ slack-client > ws                                            │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/550                       │
└───────────────┴──────────────────────────────────────────────────────────────┘
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ High          │ DoS due to excessively large websocket message               │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ ws                                                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in    │ >=1.1.1                                                      │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ slack-client                                                 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ slack-client > ws                                            │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/120                       │
└───────────────┴──────────────────────────────────────────────────────────────┘
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Low           │ Remote Memory Disclosure                                     │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ ws                                                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in    │ >= 1.0.1                                                     │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ slack-client                                                 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ slack-client > ws                                            │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/67                        │
└───────────────┴──────────────────────────────────────────────────────────────┘
found 3 vulnerabilities (1 low, 2 high) in 11 scanned packages
  3 vulnerabilities require manual review. See the full report for details.

今回は、対応策を示せないので手動で確認してね、の項目が3つだったのでこういう出力です。
ちなみに、対応策が具体的に示せる場合はこんな感じの出力になります。

$ npm audit
                                                                                
                       === npm audit security report ===                        
                                                                                
# Run  npm install react-scripts@2.1.2  to resolve 3 vulnerabilities
SEMVER WARNING: Recommended action is a potentially breaking change
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ High          │ Missing Origin Validation                                    │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ webpack-dev-server                                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts                                                │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ react-scripts > webpack-dev-server                           │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/725                       │
└───────────────┴──────────────────────────────────────────────────────────────┘


┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Low           │ Prototype pollution                                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ merge                                                        │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts                                                │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ react-scripts > jest > jest-cli > jest-haste-map > sane >    │
│               │ exec-sh > merge                                              │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/722                       │
└───────────────┴──────────────────────────────────────────────────────────────┘


┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Low           │ Prototype pollution                                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ merge                                                        │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts                                                │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ react-scripts > jest > jest-cli > jest-runtime >             │
│               │ jest-haste-map > sane > exec-sh > merge                      │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://nodesecurity.io/advisories/722                       │
└───────────────┴──────────────────────────────────────────────────────────────┘


found 3 vulnerabilities (2 low, 1 high) in 25418 scanned packages
  3 vulnerabilities require semver-major dependency updates.

冒頭の # から始まる行が解決案として提示されています。やってみましょう。
今回は package.json では 1.1.5 が指定されていた react-scripts2.1.2 に上げることで解決するようです。

$ npm install react-scripts@2.1.2
npm WARN ajv-keywords@3.2.0 requires a peer of ajv@^6.0.0 but none is installed. You must install peer dependencies yourself.
npm WARN ...
~~中略~~

+ react-scripts@2.1.2
added 861 packages from 297 contributors, removed 211 packages, updated 192 packages, moved 22 packages and audited 49080 packages in 159.566s
found 0 vulnerabilities
$ git diff package.json
         "react": "16.4.2",
         "react-dom": "16.4.2",
-        "react-scripts": "1.1.5",
+        "react-scripts": "2.1.2",
         "react-select": "2.0.0",

package.json の方も自動で 1.1.5 -> 2.1.2 に書き換わりました。
再度 npm audit で確かめてみます。

$ npm audit
                                                                                
                       === npm audit security report ===                        
                                                                                
found 0 vulnerabilities
 in 49080 scanned packages

脆弱性が0件になりました。
せっかくなので package.json 並びに install したパッケージ群 node_modules をもとに戻し、再度 npm install を実施、今度は下記コマンドで脆弱性の自動修復を試みます。

$ npm audit fix
npm WARN ajv-keywords@3.2.0 requires a peer of ajv@^6.0.0 but none is installed. You must install peer dependencies yourself.
~~中略~~

up to date in 27.042s
fixed 0 of 3 vulnerabilities in 25418 scanned packages
  1 package update for 3 vulns involved breaking changes
  (use `npm audit fix --force` to install breaking changes; or refer to `npm audit` for steps to fix these manually)

どうやら breaking change を含んでいるようで、これだけでは修正されませんでした。
ならば npm audit fix --force で強制的に更新してしまいましょう。

$ npm audit fix --force
npm WARN using --force I sure hope you know what you are doing.
npm WARN ajv-keywords@3.2.0 requires a peer of ajv@^6.0.0 but none is installed. You must install peer dependencies yourself.~~中略~~

+ react-scripts@2.1.2
added 861 packages from 297 contributors, removed 211 packages, updated 192 packages and moved 22 packages in 114.318s
fixed 3 of 3 vulnerabilities in 25418 scanned packages
  1 package update for 3 vulns involved breaking changes
  (installed due to `--force` option)

$ npm audit
                                                                                
                       === npm audit security report ===                        
                                                                                
found 0 vulnerabilities
 in 49080 scanned packages

こちらも、無事脆弱性を含んだpackageが検出されなくなりました。
ということで、npmのバージョンを6以降に上げるだけでついてくる機能なので、是非npmのバージョンアップ + npm install 時の脆弱性発見数をチェックするようにしましょう!

簡単ですが、チェックスクリプト(shell)を書いてみました。
npm audit のログ出力仕様が変わったら使えなくなってしまいますが、その場合はエラーで落ちるようにしています。

#!/bin/bash
set -vxeu

#####################################################################
# 説明
# ==========
#
# npm audit のログファイルをパースし、
# installしたモジュールに脆弱性がないかをチェックします。
#
#
# パラメータ
# ==========
#
# 1. パース対象のlogファイルパス
#    npm audit 実行時のログファイルのパス
#    e.g.) report/npm_audit.log
#
# 返却値
# ==========
#
# exit 0: 脆弱性なしの場合
# exit 1: 脆弱性ありの場合
#
#####################################################################

: $1

NPM_AUDIT_LOG_PATH=$1

# ログファイル形式が変わっていないかを確認(見つからなければ異常終了)
vuls_line=`grep "found \d vulnerabilities " ${NPM_AUDIT_LOG_PATH}`

# 発見された脆弱性のカウント
vuls=`grep "found \d vulnerabilities " ${NPM_AUDIT_LOG_PATH} | sed -e 's/^found \(.*\) vulnerabilities .*$/\1/'`

CHECK=`echo "${vuls} == 0" | bc`

if [ ${CHECK} -eq 0 ];
then
    echo "vulnerabilities found : ${vuls}" 
    exit 1
fi

exit 0

利用方法

$ npm audit > report/npm_audit.log
$ ./check_npm_audit.sh report/npm_audit.log
(中略)
vulnerabilities found : 3
+ exit 1

3つの脆弱性が発見されていたので、数を吐いて異常終了しました。脆弱性が0の場合は正常終了します。
これをCIに組み込めば、npm install時に新たに脆弱性が見つかったモジュールがないか確認できますね。

NodeJsScan

NodeJsScan とは

Node.js アプリケーション用の、静的セキュリティコード診断(SAST)

下記GithubのREADMEより。シンプルな説明ですね。

github.com

インドのかたが作られているそうで、READMEにも随所にインド愛を感じます。ちなみにこのツールを紹介してくださったのもインドの方でした。
言語がPython。そう、Python。NodeJS用のツールなんですがPythonなんです。。。

installと実行

install

$ pip install nodejsscan

実行

$ virtualenv venv
$ source venv/bin/activate
(venv)$ nodejsscan -d . -o report

[INFO] Running Static Analyzer on - .

結果(report.json)

{
    "files": [
        {
            "/pip-selfcheckjson": "./pip-selfcheck.json"
        },
        {
            "/node-slack-log-exporter/exporterjs": "./node-slack-log-exporter/exporter.js"
        },
        {
            "/node-slack-log-exporter/slack-log-exporterjs": "./node-slack-log-exporter/slack-log-exporter.js"
        },
        .... 中略 ....
        {
            "/lib/python36/site-packages/setuptools/command/launcher manifestxml": "./lib/python3.6/site-packages/setuptools/command/launcher manifest.xml"
        }
    ],
    "good_finding": {
        "Application Related": [
            {
                "description": "Strict Mode allows you to place a program, or a function, in a \"strict\" operating context. This strict context prevents certain actions from being taken and throws more exceptions.",
                "filename": "URI.js",
                "line": 27,
                "lines": "  } else {\n      // Browser globals (root is window)\n      root.URI = factory(root.punycode, root.IPv6, root.SecondLevelDomains);\n  }\n  }(this, function(punycode, IPv6, SLD) {\n          \"use strict\";\n\n          function URI(url, base) {\n              // Allow instantiation without the 'new' keyword\n              if (!(this instanceof URI)) {",
                "path": "./node-slack-log-exporter/node_modules/date-utils/doc/scripts/URI.js",
                "sha2": "7e9cb39c2b78362775b42356a1b0f69687e8ca7e39e94eab5ab61b96b24e57c1",
                "tag": "node",
                "title": "Use Strict"
            },
            {
                "description": "Strict Mode allows you to place a program, or a function, in a \"strict\" operating context. This strict context prevents certain actions from being taken and throws more exceptions.",
                "filename": "ssl.js",
                "line": 4,
                "lines": "(function() {\n\n        \"use strict\";\n\n        var fs = require('fs');\n\n        // you'll probably load configuration from config",
                "path": "./node-slack-log-exporter/node_modules/ws/examples/ssl.js",
                "sha2": "9547563476698560dd3dcb5ec904c22c992e5b808002b5db21fb6f4557c3e336",
                "tag": "node",
                "title": "Use Strict"
            }
        ]
    },
    "missing_sec_header": {
        "Web Security": [
            {
                "description": "Remove the X-Powered-By header to prevent information gathering.",
                "tag": "web",
                "title": "Infromation Disclosure - X-Powered-By"
            }
        ]
    },
    "sec_issues": {
        "Application Related": [
            {
                "description": "SHA1 is a a weak hash which is known to have collision. Use a strong hashing function.",
                "filename": "testserver.js",
                "line": 51,
                "lines": "    throw new Error('websocket key is missing');\n    }\n\n    // calc key\n    var key = req.headers['sec-websocket-key'];\n    var shasum = crypto.createHash('sha1');\n    shasum.update(key + \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\");\n    key = shasum.digest('base64');\n\n    var headers = [",
                "path": "./node-slack-log-exporter/node_modules/ws/test/testserver.js",
                "sha2": "3b620143f49e50b0118ca30214f012da4c5626832a933ac6a8b38f9a99c5a0f9",
                "tag": "node",
                "title": "Weak Hash used - SHA1"
            },
            .... 中略 ....
            {
                "description": "MD5 is a a weak hash which is known to have collision. Use a strong hashing function.",
                "filename": "WebSocketServer.js",
                "line": 328,
                "lines": "    // handshake completion code to run once nonce has been successfully retrieved\n    var completeHandshake = function(nonce, rest) {\n            // calculate key\n            var k1 = req.headers['sec-websocket-key1'],\n                k2 = req.headers['sec-websocket-key2'],\n                md5 = crypto.createHash('md5');\n\n            [k1, k2].forEach(function(k) {\n                        var n = parseInt(k.replace(/[^\\d]/g, '')),\n                            spaces = k.replace(/[^ ]/g, '').length;",
                "path": "./node-slack-log-exporter/node_modules/ws/lib/WebSocketServer.js",
                "sha2": "c96f7508d4742c33a9d9302c2cac00dd7f268322c85e10934f779f9911eef4b4",
                "tag": "node",
                "title": "Weak Hash used - MD5"
            }
        ],
        "Remote Code Injection": [
            {
                "description": "User controlled data in 'unserialize()' or 'deserialize()' function can result in Object Injection or Remote Code Injection.",
                "filename": "rules.xml",
                "line": 50,
                "lines": "        < description > User controlled data in 'new Function()'\n        can result in Server Side Injection(SSI) or Remote Code Execution(RCE). < /description> <\n            tag > rci < /tag> <\n            /regex> <\n            regex name = \"Deserialization Remote Code Injection\" >\n            <\n            signature > (deserialize\\( | unserialize\\() < /signature> <\n                    description > User controlled data in 'unserialize()'\n                    or 'deserialize()'\n                    function can result in Object Injection or Remote Code Injection. < /description> <\n                    tag > rci < /tag> <\n                    /regex> <\n                    regex name = \"Loading of untrusted YAML can cause Remote Code Injection\" >\n                    <\n                    signature > (require\\('js-yaml'\\)\\.load\\( | yaml\\.load\\() < /signature>",
                "path": "./lib/python3.6/site-packages/core/rules.xml",
                "sha2": "9fd045538d40604a603d3f42faf05be0ca2cab8d6179828757fbc6856c3d37a0",
                "tag": "rci",
                "title": "Deserialization Remote Code Injection"
            }
        ],
        "Vulnerable Node Module": [
            {
                "description": "POST Request to Express Body Parser 'bodyParser()' can create Temporary files and consume space.",
                "filename": "rules.xml",
                "line": 18,
                "lines": "        < tag name = \"nosqli\" > NoSQL Injection < /tag> <\n            tag name = \"hhi\" > HTTP Header Injection < /tag> <\n            /tags>\n            <!-- All String Comparison Rules Go here -->\n            <\n            rule name = \"Express BodyParser Tempfile Creation Issue\" >\n            <\n            signature > bodyParser() < /signature> <\n            description > POST Request to Express Body Parser 'bodyParser()'\n        can create Temporary files and consume space. < /description> <\n            tag > module < /tag> <\n            /rule> <\n            rule name = \"Handlebars Unescaped String\" >",
                "path": "./lib/python3.6/site-packages/core/rules.xml",
                "sha2": "83490a5ed70b204a4b7e275fa929856c6ffbc09ff1b0450e6e460f83032c582e",
                "tag": "module",
                "title": "Express BodyParser Tempfile Creation Issue"
            },
            {
                "description": "POST Request to Express Body Parser 'bodyParser()' can create Temporary files and consume space.",
                "filename": "rules.xml",
                "line": 19,
                "lines": "        < tag name = \"hhi\" > HTTP Header Injection < /tag> <\n            /tags>\n            <!-- All String Comparison Rules Go here -->\n            <\n            rule name = \"Express BodyParser Tempfile Creation Issue\" >\n            <\n            signature > bodyParser() < /signature> <\n            description > POST Request to Express Body Parser 'bodyParser()'\n        can create Temporary files and consume space. < /description> <\n            tag > module < /tag> <\n            /rule> <\n            rule name = \"Handlebars Unescaped String\" >\n            <\n            signature > handlebars.SafeString( < /signature>",
                "path": "./lib/python3.6/site-packages/core/rules.xml",
                "sha2": "8f8d653295a955d1e958028dadc308cbe2a59659f93d47f0979891618e39f596",
                "tag": "module",
                "title": "Express BodyParser Tempfile Creation Issue"
            },
            {
                "description": "Handlebars SafeString will not escape the data passed through it. Untrusted user input passing through SafeString can cause XSS.",
                "filename": "rules.xml",
                "line": 23,
                "lines": "        < signature > bodyParser() < /signature> <\n            description > POST Request to Express Body Parser 'bodyParser()'\n        can create Temporary files and consume space. < /description> <\n            tag > module < /tag> <\n            /rule> <\n            rule name = \"Handlebars Unescaped String\" >\n            <\n            signature > handlebars.SafeString( < /signature> <\n                description > Handlebars SafeString will not escape the data passed through it.Untrusted user input passing through SafeString can cause XSS. < /description> <\n                tag > module < /tag> <\n                /rule>\n                <!-- All Regex Rules Go here -->",
                "path": "./lib/python3.6/site-packages/core/rules.xml",
                "sha2": "1dfbc0d394d561be3f583d9298aa9eea0a04a7897bda0800596fda04af4b3de4",
                "tag": "module",
                "title": "Handlebars Unescaped String"
            }
        ]
    },
    "total_count": {
        "good": 2,
        "mis": 1,
        "sec": 10
    },
    "vuln_count": {
        "Deserialization Remote Code Injection": 1,
        "Express BodyParser Tempfile Creation Issue": 2,
        "Handlebars Unescaped String": 1,
        "Weak Hash used - MD5": 1,
        "Weak Hash used - SHA1": 5
    }
}

なんかめちゃめちゃたくさん指摘されました。これはさっきの古い凍結プロジェクト。種類ごとに分別されており大変見やすいです。
リモートコードインジェクションとか、なかなかキャッチーなワードも出てきています。
"いやいやここはSHA1使ってもいいんだよ、別にセキュリティ関連のことがしたいわけじゃないし" 的な場合は、除外設定ができると良いのですが、除外設定についてはREADME/その他ドキュメントでは触れられていませんでした。そもそも実行オプションそんなに無いですし。

今度はWebアプリプロジェクトで実行してみました。

{
    "files": [],
    "good_finding": {},
    "missing_sec_header": {
        "Web Security": [
            {
                "description": "Content Security Policy (CSP), a mechanism web applications can use to mitigate a broad class of content injection vulnerabilities, such as cross-site scripting (XSS). CSP Header was not found.",
                "tag": "web",
                "title": "Missing Security Header - Content-Security-Policy (CSP)"
            },
            {
                "description": "X-Frame-Options (XFO) header provides protection against Clickjacking attacks.",
                "tag": "web",
                "title": "Missing Security Header - X-Frame-Options (XFO)"
            },
            {
                "description": "Strict-Transport-Security (HSTS) header enforces secure (HTTP over SSL/TLS) connections to the server.",
                "tag": "web",
                "title": "Missing Security Header - Strict-Transport-Security (HSTS)"
            },
            {
                "description": "Public-Key-Pins (HPKP) ensures that certificate is Pinned.",
                "tag": "web",
                "title": "Missing Security Header - Public-Key-Pins (HPKP)"
            },
            {
                "description": "X-XSS-Protection header set to 1 enables the Cross-site scripting (XSS) filter built into most recent web browsers.",
                "tag": "web",
                "title": "Missing Security Header - X-XSS-Protection:1"
            },
            {
                "description": "X-Content-Type-Options header prevents Internet Explorer and Google Chrome from MIME-sniffing a response away from the declared content-type.",
                "tag": "web",
                "title": "Missing Security Header - X-Content-Type-Options"
            },
            {
                "description": "X-Download-Options header set to noopen prevents IE users from directly opening and executing downloads in your site's context.",
                "tag": "web",
                "title": "Missing Security Header - X-Download-Options: noopen"
            },
            {
                "description": "JavaScript can access Cookies if they are not marked httpOnly.",
                "tag": "web",
                "title": "Missing 'httpOnly' in Cookie"
            },
            {
                "description": "Remove the X-Powered-By header to prevent information gathering.",
                "tag": "web",
                "title": "Infromation Disclosure - X-Powered-By"
            }
        ]
    },
    "sec_issues": {},
    "total_count": {
        "good": 0,
        "mis": 9,
        "sec": 0
    },
    "vuln_count": {}
}

セキュリティ強化のためのHeaderをセットし忘れているよ、という指摘が9件。
ちょうど最近別の開発チームからも「Webアプリケーション、何のヘッダを設定しておけばいいかわからない」という相談を受けたばかりですので、これは使えそう!
ただ、pythonなんですよねー。JSの解析がしたいだけなのに環境にpythonを入れなきゃいけないのって、Local開発環境でかける分には別にいいんですけど、CI/CDのときはちょっと導入障壁が上がりますかね。

LGTM

LGTMとは

LGTMって名前がもう検索させる気ないよね・・・(LGTM → Looks good to me の略)。
一応、OWASPのSASTツール一覧を載せているページJavaScript/TypeScript 対応のもので唯一記載のあったものになります。

LGTMは他のものと違ってオンラインサービスになります。一度LGTMサービスに登録したリポジトリに対して、自動でCommitをキャッチして解析を行います。オンラインサービスのため、以下の制約があります。

対象ソース管理サービス

  • Bitbucket Cloud
  • GitHub.com
  • GitLab.com

※いずれも publicなリポジトリのみ解析可能

対象言語

登録と実行

まずは、LGTMにログインします。
MyProjects ページから、 Add project で、対象のリポジトリのURLを登録します。
登録するとすぐに解析中のStatusになり、しばらくするとこのように解析結果が表示されます。

f:id:kusuwada:20190109162521p:plain

手順はこれだけ、とても簡単です。各リポジトリをクリックすると、レポートに飛ぶことができます。

f:id:kusuwada:20190109163251p:plain

今回、JSではなくてPythonコードですが、前回python編でも利用した脆弱性が含まれていることがわかっているリポジトリも解析してもらいました。(kusuwada/security_handson)
左上の方に、中で使われている言語と、それぞれの言語に対する評価が出ています。
このプロジェクトには2つAlartがあるようです。

f:id:kusuwada:20190109163542p:plain

どうやら不使用の import が怒られているだけですね。残念ながら埋め込んだ脆弱性については特に指摘がありませんでした。
もっと使えそうな機能はたくさんありそうですが、今回はここまでとします。
もう暫く待つと、履歴に基づく解析結果が見れたりするようですし、アラートの除外設定もできるようです。
LGTMのtopページを見ると、"Unparalleled security analysis" と謳っているので、セキュリティの解析には力を入れているようですが、今回の対象プロジェクトでは他のサービスのほうが検出数の面では軍配が上がりました。

参考リンク

2018年振り返り&2019年どうする?

去年出産・育休を経て復職し、育児と仕事の両立が軌道に乗ってきた一年。自分(&家族)のキャパがわからないので、少しずつ水面からそろそろ顔を出すように活動を広げていった一年。
今後の自分の進む道を考える助けになるかもということで一年を振り返ってみます。
人の前で話したり本を書いたりなど、皆さんのような輝かしい経験は特にありませんが、今までの自分と比較すると大分前進したようです。

2018年の振り返り

おそらく去年、2017年の年末も1年の振り返りをしたのでしょう。当時ブログこそ持ってたけども記事は3~4個、インプットもアウトプットも少ないなーと言う反省をした記憶があります。

反省会後、新しい試みとして現在のTwitterのアカウント @kusuwada を去年の年末開設しました。孤独に始めたセキュリティ系の技術情報を収集したり、皆どんな事してるのかな?とかどんなコミュニティがあるのかな?とか。世界が広がることを期待してはじめました。これは本当に始めて良かったです。セキュリティに限らず、自分の働いている分野に近いイベント情報・技術情報・働き方・トレンド…などなど、色んなインプットが増えました。Twitter起因で新しく興味をもつことも増えました。
参加したイベントで人と会って話ができて、一応知り合いと言えるレベルの人も出来ました。そういう意味では小さいけど前進できた一年になったかなと思います。

実はもう一つ、ブログも技術系とその他で分けて書くことをはじめました。こっちは良かったかどうかは未だわからない…。この記事自体どっちに書くべきか悩ましい…。

サマリ

  • 記事
    • 技術系:28
    • その他:8
  • イベント・カンファレンス・勉強会
    • SECCON / CODE BLUE / CTF for Girls
    • 企業Meetup × 2
    • GCPUG, 社内GCP勉強会, AWS Summit, Security JAWS
    • 社内セキュリティハンズオン 開催
  • 演奏会
    • オーケストラ × 2
    • 弦楽カルテット × 3
  • 旅行・お出かけ
    • 北欧11日間
    • 山登り(2泊)、その他日帰りハイキング・動物園・水族館・etc...

記事は本当にピンきり・玉石混交ですが、CTFの後追い記事ばかりだったのが参加記録(write-up)も入り、開発運用系の内容も書くことが出来て、どんどん楽しくなってきました。
イベントはGCP系にもっと行っていた印象だったけども、AWSと変わらなかったようです。少人数の企業Meetupは初めて参加してみたけども、少人数ならではの雰囲気・やり取りもあってよかったです。本当はもっと行きたいのがたくさんあったけど、ここは育児中、しょうがないところなので今後も少数精鋭で。
演奏会はそれに向けた合わせの練習にもかなり時間を割いており(特に土日)、他の活動とかなりトレードオフになっているため、記録として書いておきました。

1月

2017年のSECCONの復習をしばらくやってました。この頃はまだ育休→時短勤務→フルタイムの流れでフルタイムに戻したばかりだったこともあり、自分の時間をそんなに取れていなかったかと思います。

記事

2月

SECCON復習が続きます。自分には難易度高めな問題で、調べながら・Toolをinstallしながら・寝落ちしながらで、ちょいちょい進めていました。一度にたくさんの時間が取れないので、毎回前回やったのを思い出すところから始めるのも中々進まない原因だったかも。

記事

イベント・カンファレンス・勉強会

  • SECCON 決勝大会を1時間くらいのぞきに
    • この年は時間がなくてグッズを買い漁って、決勝大会の雰囲気を眺めて終了

3月

中だるみ期…なのか?アウトプットはドタバタした記録の記事のみ。今スケジュールを見返してもこれと言ったイベントはなく、夫の出張がちょいちょい入ってたのと他の月と同じくらいの週末イベント。

記事

4月

保育園のPTAになってしまう。そのせいかどうかは定かでないが、この月もOutput少なめ。何度かうちで子連れのホームパーティーしてたみたい。

記事

5月

SECCON Beginners Online に参戦。いつものOnline大会よりは解ける問題も多く、write-upや復習も捗りました。この時期、仕事でSRE活動を立ち上げたのもあってか急に意識高い系のことしだす(ポエム系記事)。

記事

イベント・カンファレンス・勉強会

  • SECCON Beginners Online 参戦

6月

SECCON Beginneersの復習記事で稼いでいる印象。

記事

イベント・カンファレンス・勉強会

  • CTF for Girls 参加

家族・趣味

  • オーケストラ演奏会本番

7月

前半はほぼ旅行。両親・兄弟家族も含めた大人6人子供3人のかつて無いどたばた旅行でした。1,2,3歳児の誰も熱を出したりしなかったのが奇跡。
後半は新しい常設CTFを見つけては手を出してみるなど。

記事

イベント・カンファレンス・勉強会

家族・趣味

8月

プールに何度か行ったり夏を満喫するのを目標に過ごしてました。学習時間はあまり取らなかった記憶。

記事

イベント・カンファレンス・勉強会

家族・趣味

  • 社内イベントで弦楽カルテット演奏

9月

このころから常設CTF攻略をほぼ毎日(30分くらいずつですが)進めたり、CTF以外のアウトプットも意識して出していくようにしたり。なにかきっかけがあったかはもはや覚えていませんが、確実に9月からアウトプットが増えています。
また、メルカリのセキュリティエンジニアMeetupに参加したのも刺激的でした。セキュリティエンジニアって身の回りにいないので「こんな人達がセキュリティエンジニアなのか」とか「こういうふうに開発にセキュリティを取り入れようとしているのか」というのが専門家から聞けたのは大きかったです。
逆に自分が普段開発・運用に携わっていて、こうしたら良いんじゃないかと思っていたことがそんなに間違っていないぞ?と思えたことも嬉しい発見でした。もしかしたらこのおかげで、自分もアウトプットしてみようと思えたのかも知れません。

記事

イベント・カンファレンス・勉強会

家族・趣味

10月

9月からの勢いで、社内で小規模ではありますがセキュリティハンズオンを開催しました。CTF for Girls形式で、簡単なサイトを攻撃して見る形でしたが、なかなか好評だったので次回もやりたい・・・と思いつつ今に至ります(ゝω・)
そしてSECCON Online予選に一人で参加。1028チーム、4347人が参加という規模だったらしい。425pt, 173位。簡単な問題しか解けなかったし半日の参加だったけど、「お祭りに参加したぞ!」感が出て楽しかった。

記事

イベント・カンファレンス・勉強会

  • 社内でセキュリティハンズオン主催
  • 社内GCP勉強会 参加
  • SECCON Online 予選 参加
  • CODE BLUE トレーニング参加

家族・趣味

  • 弦楽カルテット演奏会

11月

CODE BLUEのトレーニング・カンファレンスに参加しました。もちろん内容も大変興味があったんだけど、どんな人が来るのか見てみたい、あわよくば人のつながりを作りたい!という目的も大きかったので、色んな人とお話できて本当に良かったです。来年も是非参加したい・・・が会社からお金は諸事情により100%出ないのでどうしたものか考え中。早割のカンファレンスのみなら自費でも行けるか?

記事

イベント・カンファレンス・勉強会

12月

なんと言っても、アドベントカレンダーに登録してそれに向けてコンディションを整えていくスタイルをやってみました。アドベントカレンダー初参加でしたが、4つ記事をあげられたので、この方法向いてるかも。12月限定ですが…。
他の方の記事を見る機会も増えましたし、記事を読んでもらう機会もアクセス数を見る限り多かったみたいです。

記事

イベント・カンファレンス・勉強会

  • SECCON 2018 企業ブース巡りメインで参加

家族・趣味

2019年の目標

いい感じに盛り上がってきて2019年を迎えられそうなんですけど、この先2年ちょい、どうやって過ごすか悩みに悩んでいます。それもあって2018年の振り返りをしてみたんですけども。
実は来年の2月・3月は週3日勤務(うち1日はリモート)、4月からは産休に入り、6月に第二子出産予定。そのまま育休を2020年3月まで取得し、その後夫について1年アメリカに家族で赴任予定という。出産も赴任も100%確定じゃないので、どう転ぶかはわかりませんが9割方そうなりそう。
となると、私は丸二年(少なくとも今の)仕事はおやすみとなるわけで。まぁ「産休育休」なので育児しろよって話ではあるのですが、せっかくの長期にわたるお休み期間、何をしてどう過ごすのか。そしてその後、復職するとして何がしたいのか、どうなっていたいのか。この年末年始にゆっくりじっくり考えつつ、その後も色んな人と話をしてヒントを貰えたらなぁなんて考えてます。

なので具体的な目標に落とせていないものが多いですが、こんな感じ。

無事出産、家族皆健康!

一番大事!超大事!これ無くして他は無し!

キャリア・人生の見直し

自分のキャリア、進む方向について深く考える時間を設けたいです。また、積極的に色んな人と話す機会を設けて、色んな選択肢が見えてくるといいなと思っています。
もしかしたら来年は、エンジニアじゃなくて保育士になる!とか宇宙飛行士になる!とか言ってるかも知れません。

アメリカ生活準備

現地の情報収集を早めに。参加できそうなコミュニティを見つける。(日本人・育児・音楽・仕事・etc...)
せっかくアメリカに行くのだから、1年(引っ越しも考えると10ヶ月くらい?)という超短い期間、しかも乳幼児連れという条件ではありますが、私だってなにか得るものが欲しいと思ったりしています。
ベストは現地で働けることですが、今の会社が育児休業中になるため就業規則違反になりそうだったり、そんな短期間で雇ってくれるか?とか、子供預けたらお給料もらっても赤字じゃないか?とか色々ハードルがありそうです。事前にこの辺のリサーチと手回しをできるだけやっておきたいです。
もちろん現地で子供と(預けずという意味で)生活して、近所のイベントにちょこちょこ参加する生活も十分刺激的だと思います!

技術アウトプット

継続的にアウトプットする。
自分がどっちに進んでいくにしろ、何かしらアウトプットはしていきたいです。あとから振り返って「これが私だ!」と言えるように。

どれか一つ以上(欲張らない目標)

あとは直近、読みたい本屋みたい映画・アニメが溜まってたのでそれだけは書いておこう。

消化したい積読

見たいドラマ・映画・アニメ

おわりに

今後の身の振り方は別途たくさん考えて、調べた内容とともに書き出していきたいと思います。
が、せっかくのお休み期間、産前は特にのんびりゲームしたりゴロゴロしたりして過ごすのも全然ありだなーなんて思ってたりします。長い人生、そういう時期もあったって良いじゃない。(一人目のとき既にやってるけど…!)

一方、この一年で積み上げてきたものを少なくとも維持していきたいという思いもあります。長女の産休・育休中はどんどん仕事・技術と離れていって、インプットは育児関連の情報にどんどん偏っていった気がします。興味がそっちに大半持ってかれるんです。そこのところ、今回はバランスよく技術面のインプット・アウトプットも維持できればなぁと思っています。

f:id:kusuwada:20181228075041j:plain

※写真はスウェーデンに旅行に行ったときのもの

Pythonコードの安全を保つSAST(静的解析)ツール ~Bandit, Pyt~

Python その2 Advent Calendar 2018 16日目の投稿が空いていたので、めっちゃ日が過ぎてますが飛び込み投稿。

今回は、昨今よく聞くようになった「DevSecOps」(DevOps + Security) 活動で重要になってくる、「セキュリティテストも自動で回す」を実現するためのツールを紹介します。
DevSecOpsについてはこのあたりを参照。2018年のトレンドらしいです。

2018年のトレンドは、DevOpsにセキュリティを融合した「DevSecOps」 (1/2) - ITmedia エンタープライズ

自動セキュリティテストには SAST, DAST, IAST と呼ばれるものがあります。

  • SAST: Static Application Security Testing(静的セキュリティ検査)
    • ソースコード自体を解析・検査して脆弱性を見つけ出すもの
    • 動くコードになる前の段階から使用できる
  • DAST: Dynamic Application Security Testing(動的セキュリティ検査)
  • IAST: Interactive Application Security Testing
    • SASTとDASTの双方のメリットを兼ね合わせた手法
    • まずDASTで検査を行い、疑わしい部分をSASTで解析する

今回はSASTに焦点を当てて、Pythonで使えるSASTツール2点を紹介します。

Bandit

Banditとは

Bandit の意味は、山賊。なぜこのネーミングかは調べてません(ノ≧ڡ≦)
公式のREADMEより

Banditは、Pythonコードで共通のセキュリティ問題を見つけるために設計されたツールです。 これを行うために、Banditは各ファイルを処理し、そこからASTを作成し、ASTノードに対して適切なプラグインを実行します。 Banditはすべてのファイルのスキャンを完了すると、レポートを生成します。 BanditはもともとOpenStack Security Project内で開発され、その後PyCQAに改造されました。

github.com

Bandit 公式ドキュメント:Security/Projects/Bandit

以下、Bandit ソースリポジトリ(Github) のREADMEに使い方に沿って実施してみます。

installと実行

install: pipでinstallできちゃいます。簡単。

$ pip install bandit

実行: 基本的に bandit {テスト対象のパス} ですが、ほとんどのプロジェクトがディレクトリ構造を思っていると思うので、再帰optionの -r をつけて実行します。

$ bandit -r {path/to/your/code}

これだけ。簡単。

主要なオプション

  • html出力
    • -f html
  • 出力ファイルの指定
    • -o {output file}
  • テスト対象の指定・除外と、実施テストの指定・除外
    • .bandit ファイルをテスト対象pathに配置
    • targets: テスト対象を指定
    • exclude: テスト対象からの除外を指定
    • skips: skipするテストの指定
    • tests: 実施するテストの指定

例: (.bandit)

[bandit]
targets: /app
exclude: /test
skips: B102
tests: B101, B301

※テストのIDは、Bandit repositoryのREADMEに書いてあります

  • 除外設定
    • Banditでは警告が出るが、その行がレビューされ、偽陽性・許容可能である場合に #nosec というコメントを対象の行につけることで、banditのレポート対象外になります。

例:

self.process = subprocess.Popen('/bin/echo', shell=True)  # nosec

実行結果と分析

とあるサービスのfrontコードを解析した結果のサマリ

Code scanned:
    Total lines of code: 2754
    Total lines skipped (#nosec): 0

Run metrics:
    Total issues (by severity):
        Undefined: 0.0
        Low: 0.0
        Medium: 1.0
        High: 0.0
    Total issues (by confidence):
        Undefined: 0.0
        Low: 0.0
        Medium: 0.0
        High: 1.0
Files skipped (0):

指摘されたコード部分(severity: Medium, confidence: High)を詳しく見てみます。

yaml_load: Use of unsafe yaml load. Allows instantiation of arbitrary objects. Consider yaml.safe_load().
Test ID: B506
Severity: MEDIUM
Confidence: HIGH

yaml.load() ではなく yaml.safe_load() を使いなさいとのこと。
yaml.load()脆弱性に関してはこちら参照。

他、こんな指摘の出たプロジェクトも。(severity: High, confidence: High)

The pyCrypto library and its module RSA are no longer actively maintained and have been deprecated. Consider using pyca/cryptography library.
Test ID: B413
Severity: HIGH
Confidence: HIGH

pyCrypto.RSApyCrypto.PKCS1_OAEP など、 pyCryptモジュール自体がもはや活発にメンテナンスされていないので、代わりに pyca/cryptography を使いなさいとのこと。 RSAのサンプルコードといえば pyCrypto というくらい出回っているので、こういうツールがないとなかなか気づきにくそう。

以下、セキュリティ勉強会のために作った脆弱性のあることがわかっているWebApplicationのソースを解析した結果です。terminal上では色付きで結果を表示してくれて十分見やすいです。

f:id:kusuwada:20181223021055p:plain f:id:kusuwada:20181223021102p:plain

ちなみに、-f html オプションを付けて実行すると、下記のような出力が得られます。

f:id:kusuwada:20181223021107p:plain

Pyt

Pytとは

公式のREADMEより

理論的基礎(制御フローグラフ、不動点解析、データフロー解析)に基づくPython Webアプリケーションの静的解析

特徴:

などの検出

github.com

PytはBanditとは異なり、Webアプリケーションに特化しています。このため、一般的なWebアプリケーション機能が有する routing 機能などがあるシステムを前提にしています。
明示的に対応しているWebFrameworkのは、Flask, Djangoですが、その他のFWも -a オプションと設定ファイルの記述により利用可能です。

installと実行

insatll: こちらもpipコマンドでインストールできます。

$ pip install python-taint

setting: Banditよりちょっと複雑。

  • まずは使用しているWebFrameworkを選択します。 default は Flaskです。WebFWは -a optionで選択します。詳細はこちら

    • flask: none
    • django: -a D ※判断基準はリンク先参照
    • Every: -a E ※判断基準はリンク先参照
    • Pylons: -a P ※判断基準はリンク先参照

    今回はFlaskなのでオプション無しで実行します。

  • テスト対象の設定ファイルをカスタマイズ・設定します

    • defaultではこちらが使われます
    • 対象のmethodの取捨選択や、チェック内容をカスタマイズできます
  • 検出したい関数をカスタマイズする

上記のように、ターゲット対象・対象外のdirectoryや除外設定はbanditと同じく設定可能です。

実行: こちらもオプション無しだとシンプルで、 pyt {テスト対象のパス} です。

$ pyt (-a ADAPTOR) {path/to/your/code}

-a オプションは使用しているWebFWによっては必須なので注意です。

実行結果と分析

とあるサービスのfrontコードを解析した結果

$ pyt -r ./app/front
No vulnerabilities found.

何も検出されませんでした。
なかなか指摘が出てくるプロジェクトがなかったため、先程も使用したSQL Injection の脆弱性があるとわかっているprojectを解析。

$ pyt -r ./web_1/sqli_1/flask
2 vulnerabilities found:
Vulnerability 1:
File: ./hello.py
 > User input at line 19, source "form[":
     name = request.form['Name']
Reassigned in:
    File: ./hello.py
     > Line 23: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)
    File: ./hello.py
     > Line 23: query = ~call_3
File: ./hello.py
 > reaches line 25, sink "execute(":
    ~call_4 = ret_cursor.execute(query)
This vulnerability is unknown due to:  Label: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)

Vulnerability 2:
File: ./hello.py
 > User input at line 20, source "form[":
     password = request.form['Password']
Reassigned in:
    File: ./hello.py
     > Line 23: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)
    File: ./hello.py
     > Line 23: query = ~call_3
File: ./hello.py
 > reaches line 25, sink "execute(":
    ~call_4 = ret_cursor.execute(query)
This vulnerability is unknown due to:  Label: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)

2つ検出されました!ちょっと長くてわかり辛い。
1つ目と2つ目は namepassword かの違いなので、1つ目を上から読み解くと、

> User input at line 20, source "form[":
  password = request.form['Password']

ここでUserからの入力があるでしょ。

Reassigned in:
     > Line 23: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)

ここで代入されてるでしょ。

> reaches line 25, sink "execute(":
 ~call_4 = ret_cursor.execute(query)

そしたらここまで(ユーザーの入力が)到達しちゃうでしょ。

This vulnerability is unknown due to:  Label: ~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)

この脆弱性(のカテゴライズ)は、以下の箇所が原因で不明です。

こんな感じでしょうか。
確かに SQL Injection が起きる原因となっている箇所がリストアップされていますが、具体的な理由・修正案は出力されませんでした。

pytにはhtml形式のレポートはないようなので、json形式で出力してみます。(-j オプションを追加して実行するだけ)

{
    "generated_at": "2018-12-21T05:32:30Z",
    "vulnerabilities": [
        {
            "source": {
                "label": "name = request.form['Name']",
                "line_number": 19,
                "path": "./hello.py"
            },
            "source_trigger_word": "form[",
            "sink": {
                "label": "~call_4 = ret_cursor.execute(query)",
                "line_number": 25,
                "path": "./hello.py"
            },
            "sink_trigger_word": "execute(",
            "type": "UnknownVulnerability",
            "reassignment_nodes": [
                {
                    "label": "~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)",
                    "line_number": 23,
                    "path": "./hello.py"
                },
                {
                    "label": "query = ~call_3",
                    "line_number": 23,
                    "path": "./hello.py"
                }
            ],
            "unknown_assignment": {
                "label": "~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)",
                "line_number": 23,
                "path": "./hello.py"
            }
        },
        {
            "source": {
                "label": "password = request.form['Password']",
                "line_number": 20,
                "path": "./hello.py"
            },
            "source_trigger_word": "form[",
            "sink": {
                "label": "~call_4 = ret_cursor.execute(query)",
                "line_number": 25,
                "path": "./hello.py"
            },
            "sink_trigger_word": "execute(",
            "type": "UnknownVulnerability",
            "reassignment_nodes": [
                {
                    "label": "~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)",
                    "line_number": 23,
                    "path": "./hello.py"
                },
                {
                    "label": "query = ~call_3",
                    "line_number": 23,
                    "path": "./hello.py"
                }
            ],
            "unknown_assignment": {
                "label": "~call_3 = ret_'SELECT * FROM users WHERE name='{name}' AND password='{password}''.format(name=name, password=password)",
                "line_number": 23,
                "path": "./hello.py"
            }
        }
    ]
}

人の目で見るぶんには、特に読みやすさは変わりませんでした…。実行結果をスクリプトで解析したりなんかするぶんには使いやすそうです。

もう一つやってみます。次は XSS脆弱性があるとわかっている下記のソースです。

$ pyt -r ./web_1/xss_1/flask
1 vulnerability found:
Vulnerability 1:
File: ./hello.py
 > User input at line 14, source "form[":
     name = request.form['Name']
File: ./hello.py
 > reaches line 17, sink "render_template(":
    ~call_2 = ret_render_template('xss_form.html', name=name, secret=secret)

無事検出されました。今回は 14行目でユーザーのインプットを受け付けたものが、17行目のrender部分に直接到達していることを指摘されているようです。

ついでに最後はパストラバーサル脆弱性があるサービス。

$ pyt -r ./web_1/directory_1/flask
1 vulnerability found:
Vulnerability 1:
File: ./hello.py
 > User input at line 16, source "request.args.get(":
     ~call_1 = ret_request.args.get('image')
Reassigned in:
    File: ./hello.py
     > Line 16: image = ~call_1
    File: ./hello.py
     > Line 19: ~call_4 = ret_os.path.join(~call_5, 'static', image)
File: ./hello.py
 > reaches line 19, sink "send_file(":
    ~call_3 = ret_send_file(~call_4)

似たような感じですね。今回はURLのPathを取得する部分が User input の可能性のある場所として指摘されています。

まとめ

今回はPythonのSASTツールである Bandit と Pyt を紹介しました。
Banditのほうが結果の出力が見やすく、形式も csv, custom, html, json, screen, txt, xml, yaml とめちゃめちゃ選択肢があるため、ぱっと始めるにはとっつきやすそうです。出力内容も、Pytでは出てこなかった SQL Injection など具体的な脆弱性の名前が指摘されていました。

一方、同じコードを解析したところ、Pyt で指摘のあった内容で Bandit でも拾えなかったものもありました (XSSディレクトリトラバーサル)。SASTツールは複数のツールを導入することが脆弱性の早期発見に役立ちます。PytはBanditと比較して出力結果を読み解くのに訓練がいりそうですが、Banditとは違ったロジックで検査を行っています。
今回の2つのツールはinstallから実行まで導入障壁が少ない上に実行時間もごく短いことから、併用するのがお勧めです。

SASTツールはCIと大変相性がよく、commitやpushごとにUTやフォーマットチェックなどと合わせて実施しやすいかと思います。みなさんも是非2018年のトレンドである「DevSecOps」に乗っかって、SASTツールを開発・CIに導入してみませんか?

参考リンク

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

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

CIで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 さんです!

DevOps文化の組織にSRE活動を導入した話

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

qiita.com

もしこれから参加される場合は、2もあります。

qiita.com

今回は、自分たちの部署が2014年頃から築いてきたDevOps体制の中で、どうSREの考え方と活動を導入していったかについて書きます。同じような事情・背景の組織も多いのではないでしょうか。

DevOpsとは?SREとは?についてはたくさん記事が出ていますので、参考リンクを読んで頂ければと思います。

前置きが長くなってしまったので、お急ぎの方は「どういう体制がFitしたか」から読み始めていただくとよいかと思います。

DevOps体制を始めたきっかけ

私が今の部署に異動したのは2013年の秋頃でした。クラウドサービスを開発して運用する部署、ということでハードウェアが主戦力なうちの会社にしては大変珍しい部署で、私も組込みソフトウェアの部署から右も左もわからぬまま異動してきてワクワク・ソワソワしていたのを覚えています。
その頃は、複数のサービスを一つのシステムの上に構築しており、開発チーム複数とインフラチームが一つ存在する、典型的なDevとOpsが分離した体制でした。
組み込み系の組織だとこの体制は当たり前ですし、当時はまだまだ運用・Deploy周りは一定の技術と経験が必要とされていたので自然な流れだったのかもしれません。
しかしこの体制では、下記のような問題が出てきていました。

  • インフラチームが開発の内容・変更点を知りきれないため、Deployや運用時に問題が起きたときに適切に対処できない
  • 開発チームが多くなる/リリースが重なるとインフラチームの負荷が高まり、結果回しきれなくなってリリースが遅れる
  • 上記の問題でインフラチームのリソース調整が行われるようになった結果、修正からリリースまで2週間以上Defaultでかかるようになる
  • 開発チームとインフラチームでの設計・開発時点でのコミュニケーションがほぼないので、運用を意識した設計・開発がされず、運用が始まってから問題が発覚する
  • 結果、開発チームとインフラチームがお互いに不満を抱えたまま対立し始める

どれも「あるある」だと思うんですけど、本当に「あったあった」で、この手の話を聞くと首がもげるほど頷いちゃいます。ちなみにこの時、私は開発チームでした。

こういう背景もあり、また別のゴタゴタもあり、体制を見直そうという動きが出たのが2014年春頃。
流行りのDevOpsという言葉を使いつつインフラチームを解散し、各開発チームにインフラメンバーを送り込むということで、体制がガラリと変わりました。
この時期になると、各クラウドベンダー(主にAWS)からいろんなサービスが出てきて、インフラの仕事や運用監視も開発メンバーが担いやすくなったというのも大きかったと思います。また、新しいことをやっていくにあたって開発速度も上げていきたいし「小さくたくさん」回していきたいという開発・マネジメント陣の思いもあったかもしれません。

そんなこんなで動き出したDevOps、各開発チームの中で設計・開発・運用が閉じるようになり、開発スピードは格段に上がりました。また、チームによるかもしれませんが、私の所属していたチームでは従来のインフラメンバーがコードを書いたりテストの面倒を見たり、逆に開発チームがインフラコードを書いたりトラブルシュートの一次切り分けをしたりと、役割の流動化が進みました。
もちろん得意・不得意はありますし、皆が皆今までと違う役割を受け入れて挑戦したわけではないのですが、開発も運用もいけるメンバーが増えたというのは大きな収穫だったと思います。かく言う私もこの時期にインフラ寄りの作業を設計から実装・運用まで担当し、面白さに目覚めたわけです。

SREを導入したきっかけ

良いことばかりだし、開発速度も上がったならめでたしめでたしじゃないか!
なんですけど、DevOps体制で突っ走ってみると、問題とは言えないまでも課題が見えてきました。

  • 各開発チームが分断されてしまい、同じような機能を作ろうとしているのに設計やCIなどの環境も一から検討し直していて非効率に見える
  • 運用しているサービスのSLA/SLOの監視方法がそれぞれの開発チーム任せで、認識しているかどうか怪しい&観測方法が揃っていないので他所から聞かれたとき説明しづらい
  • 起きた問題・障害の情報が共有されておらず、同じ問題が発生しそうな設計のシステムが出来上がってたりする
  • 特にインフラ面でハマった問題の共有がチーム間でされず、同じところにハマって悩んでいることがある
  • 新しい技術・サービス(特にインフラ周り)のキャッチアップがチーム間で共有されずにそれぞれのやる気任せになっている

ほとんどの課題が他チームとコミュニケーションしたら解決しそうなんですけど、DevOps体制になってからどうしても各チームがサイロ化してしまい、偶然ランチを一緒に食べていた他チームのメンバーから共有された話で(クラウドベンダーの障害を知るなどして)助かった、的な話もよく聞くことになってしまっていたわけです。
また、チーム間での技術レベルにも差が出てきていました。新しい技術・情報を積極的に取り入れて共有する文化のあるチームでは、新しいサービスを積極的に使ったり、アプリケーションコードだけではなくインフラコードもより良い書き方を追求してブラッシュアップしていく一方、あまりそういった活動が活発でなかったり忙しすぎて目の前のタスクをさばくのに必死なチームでは古い設計・サービスを使い続けていたりCIの導入やインフラのコード化ができていなかったり。
更に、我々の部署では今後もできればあまり人数を増やさず、しかし手がけるサービスは増やしたいということで、特に運用面での効率化・Toilの削減は課題の一つでした。

SREチームの役割としては、GoogleのSRE本にも「厳密な定義はない」とありますが

  • サイトの信頼性を保証する
  • 運用業務とサイトの信頼性向上の2つの役割を担う
  • 積極的にコードを記述
  • 運用をクラウドや自動化に置き換える (Toilの削減)

このあたりがポイントのようです。
Googleが提唱した「Site Reliability Engineering(SRE)」とは|フリエン より

上記の我々の部署の課題を解決する一つとして、組織としての「SLA/SLOとその観測項目・方法」を定義し、監視していくことはSRE活動の目的にも合いそうです。
また「SREやるよ」という動きを通じて、各チームのインフラ・運用とSRE活動に関わりそうな情報全般の共有をできる場が設けられるとすると、これも我々にとってプラスになりそうです。既存の設計やコード・ツールの共有、新しい技術やサービス情報、脆弱性情報の収集・共有、起きた問題やハマりポイントとその対処法、パフォーマンスの分析と改善の取り組みなど、共有したいことは山ほどあります。

どういう体制がFitしたか

Google本でも SREチームの形はそれぞれとありますし、GoogleのSREチームの形を丸々なぞる必要はありません。SREはスーパーエンジニアの集団だという記述もよく目にしますが、"スーパーエンジニア"の集団がそろっている組織なんてそうそうないでしょう。
ということで私達は、今感じている課題をSREの活動とうまく合わせて解決に持っていく、もしくは各々の技術・知識を高める方向に持っていくことに重きを置きました。

メンバーを各チームから集ったり外から募集して「SREチーム」を新しく設立、100% SRE 業務に充てるというのも思い切りが良くて良いやり方だと思います。
一方、私達は下記のことを考慮して、SREチームの体制を検討しました。

  • 急な体制の切り替えで普段の運用や開発が停滞してしまうことを防ぎたい
  • SRE活動に興味のあるメンバーばかりではない(インフラ要因としての役割のみで満足している)
  • そもそもSRE活動、というものがうまく回る保証がない(ので実験的に始めてみたい)

ということで、こんな形になりました。

f:id:kusuwada:20181206120236p:plain
SRE体制

具体的にはチームが10前後ある中で、SREチームのコアとなるメンバーを4名確保しました。コアメンバーも各チームから選出されているのでSRE活動に充てる工数は0.5程度です。
このコアメンバーを中心に下記の活動を回します。

  • SLA/SLOの定義やその観測方法の統一化
  • 情報共有会/議論会の開催・ファシリテーター
  • 各チームのToil削減のための自動化・コード化、共通化
  • 各チームで技術的にネックになっている部分のサポート、新しい技術の導入

その他のSREメンバーは、情報の共有会に参加・発表したりSlackで情報を共有し合ったり、コアチームで作ったツールや他チームから共有されたツールを横展開したりします。

7ヶ月の活動で見えてきたこと

この体制で半年活動した時点で、大きな期間での振り返り会(KPT)を実施しました。
そこで出てきた意見も交えつつ振り返ってみます。※個人的な主観もかなり入っています。

よかったこと・継続したいこと (Keep)

  • なかなかバランスの良い体制だった
    • メンバー全員が各DevOpsチームとしても動いているため、サービス内容や開発側の事情もよくわかってSRE活動がしやすかった
    • SREとしてのみ活動していると、過去の体制のように開発と分離してしまう可能性もあったが、この体制だと大丈夫
  • 毎月一定の成果をあげてこられた
    • リリース・サービスイン・障害対応、など明確な目標が立てづらい活動のため、「アウトプット重視」を最初に掲げてスタート
    • Sprintごとに必ず自分の成果を共有サイトにアップすることを徹底、月次で成果物を簡単に紹介する会を設けた
    • SRE活動としての追加メンバーほぼなし・全員DevOpsと兼務という条件下でも、SRE活動の成果を実感できて良い取り組みだった
  • 情報共有会やSlackによるコミュニケーションによる、サイロ化したDevOpsチームのつながりが持てた
    • 他のチームでやった内容を自分のチームに持ち帰る、共有会で知った新しいサービスを試してみる、などの動きが活発に
    • それぞれのDevOpsチームの中で個別に悩んでいたところをインフラ脳が集まって議論できる場が出来て楽しい

改善したいこと (Problem)

  • Coreメンバーは SRE チームとしての工数が0.5のため、複数プロジェクト掛け持ち経験がないメンバーにとっては時間のやりくりが難しかった
    • こちらは、スケジュールのたて方・計画の仕方など工夫することにより、徐々に改善
  • もともとインフラよりのメンバーから集まっているため、バリバリコードを書くメンバーが少なく、実装を伴う作業がそのメンバーに集中しがち
    • 開発よりメンバーからSRE活動要員を調達する・既存インフラよりメンバーのコーディング力を上げる、など試み中

挑戦したいこと (Try)

  • CoreメンバーとProjectメンバーをローテーションしたい
    • Coreメンバーの活動に参加することで、より技術の向上や情報のキャッチアップが見込める
    • もともと構想にあったが、なかなか業務都合がつかなかったりで実現できていない
  • 自分たちの活動がどのように役立っているかを、何かしらの方法で見える化したい
    • 共通ツール作ったけど役に立ってる?どれくらい?など

おわりに

"SREとは"という定義にはもしかしたら厳密には当てはまらない活動かもしれませんが、私達なりの解釈で自分たちの組織に合う活動をしてみたらこうなりました。
何かしら新しい活動を始めるのに看板は大事です。そこに"SRE"という、キラキラしている企業がこぞって導入しているワードを使い、メンバーのモチベーションを高めるのも一つの方法だと思うのです。なので、SREやってみた話を聞いて「いやそんなのSREじゃねーし」と切り捨てるのではなく「そういう切り口もあったか」「この組織の課題はそこにあったのか」みたいな受け止め方も面白いんじゃないかな、なんて思う師走なのでした。
今後もSRE活動の看板のもと、メンバーの技術力・知識の向上や、失敗事例・成功事例の共有、共通化や効率化に努めていきたいと思います!

参考リンク

素材リンク

今回の図の素材は下記サイトから使わさせていただきました。

licensedでOSSのライセンスチェック

以前も下記の記事で話題にした、開発したソフトウェアで使用しているOSSのライセンス確認、面倒だけどやらなきゃですよねという話。

kusuwada.hatenablog.com

この記事ではOSSのリストアップやライセンスチェックを助けてくれるツール・サービスの紹介をしましたが、今回はここでも紹介した github 製の licensed を使ってみたので、使い方と使ってみた結果を紹介します。

github.com

機能

  • 必要なライブラリをinstallした状態で、使用しているOSSを依存関係も含めて全てチェックしてくれる
  • list: 依存関係を含めたライブラリ情報を出力
  • cache: ライセンスとメタ情報をファイルに出力
  • status: 設定に従い、許可されていないライセンスやライセンスが不明のライブラリがないかをチェックします
  • Rubyのツール、外部への通信は行わない
  • ※完全なOSSライセンスソリューションではないので、免責事項を理解して使用してください(本体READMEより)

自分が使ってると認識しており、パッケージ管理ファイルにちゃんと書いているようなライブラリのみであれば、開発規模によっては人手でOSSのリストアップとライセンス形式の調査は不可能ではないです。
が、依存関係を持つライブラリについては、依存先のライブラリも認識してなくても使用しているわけで、その辺までケアしようとすると人力でやるのは非現実的になってきます。と言うか無理。
ということで、licensedのようにライブラリの依存関係まで洗い出してくれるのは素晴らしい!

対応言語 (ライブラリ管理方式)

最新情報は こちらを参照。
2018年11月現在は、下記の言語・ライブラリ管理方式に対応。

  • Bower (bower)
  • Bundler (rubygem)
  • Cabal (cabal)
  • Go (go)
  • Go Dep (dep)
  • Manifest lists (manifests) # 自分で定義するので、基本どの言語でもかける
  • NPM (npm)
  • Pip (pip)
  • Git Submodules (git_submodule)

今回はニーズが高そうな rubygem, npm, pip について試してみました。

セットアップ

必要な環境

  • ruby
  • bundler
  • cmake
    • 公式READMEにもありますが、cmakepkg-config が必要です。
    • macOS with homebrew: brew install cmake pkg-config
    • ubuntu: apt-get install cmake pkg-config
    • centos: yum install cmake pkg-config

チェック対象の準備

rubygem

チェックしたい Gemfile のおいてあるディレクトリで下記を実施。

$ bundle install --path vendor/bundle

npm

チェックしたい package.json のおいてあるディレクトリで下記を実施。

$ npm install

pip

pip(python)の場合は少し複雑です。virtualenv環境が必要になります。
下記のコマンドでvirtualenvをインストール、virtualenv環境を設定/activateします。
VIRTUALENV_DIR はvirtualenv環境を動作させるディレクト

$ pip install virtualenv
$ virtualenv {VIRTUALENV_DIR} --no-site-packages
$ cd {VIRTUALENV_DIR}
$ source bin/activate

チェック対象リポジトリのcloneと ライブラリのinstallを実施します。
TARGET_REPO はチェック対象リポジトリ

$ git clone {TARGET_REPO}

チェックしたい requirements.txt 全てに対して下記を実施。

$ pip install -r requirements.txt

全部のライブラリをインストールできたら、下記を実施。
SOURCE_DIR は 後で .licensed.yml などの設定ファイルに書く。

$ pip freeze -l > {SOURCE_DIR}/requirements.txt

仮想環境はここで抜けて構わないのでこのあたりで

$ deactivate

licensed インストール

今回の構成はこんな感じにします。

(pythonの場合はVIRTUALENV_DIR)
└── my-app
    ├── .licensed
    │   └── .licensed.yml
    ├── app/
    │   ├── Gemfile
    │   ├── package.json
    │   └── requirements.txt
    ├── test/
    └── infra/

本番コードは app ディレクトリ配下に入っているとします。

$ cd {my-app}/.licensed
$ bundle init
$ echo "gem 'licensed', :group => 'development'" >> Gemfile
$ bundle install --path vendor/bundle

※licensedの変更に合わせて必要な環境が変わる可能性があります。 公式ページのInstallationはこちら

設定ファイルを用意します。
形式は yaml でも json でも対応しているそうですが、今回は yaml にします。
詳細は公式ページに載っているので、今回は最低限の設定にします。
設定ファイルについての詳細(公式README)はこちらを参照

source_path: '.licensed'  # ※1
cache_path: './.licensed/.licenses' # ※2

python:  # pythonの際はこの設定が必要
  virtual_env_dir: '{virtualenv_directotyの絶対パス or my-appからの相対パス}'   

# 一部のsourceだけ有効にしたい場合に使用します
# default(記述なし)では、発見されたすべてのsourceに対してパースします
sources:
  rubygem: false
  pip: true

# 以下、status 機能で使用。
# 使用を許可するライセンスの一覧。
allowed:
  - mit
  - apache-2.0
  - bsd
  - etc...

# レビュー済みlibraryの記載
# licensedでは検出ミスもあるため、問題ないと確認できたlibraryはここに記載します。
reviewed:
  rubygem:
    - bcrypt-ruby
  pip:
    - Flask     # bsd
    
# 無視リスト
# licensedがライセンス表記を発見できなかったが、問題がないと確認できたlibrary。
ignored:
  rubygem:
    - bundler
  pip:
    - pycrypto  # public domain
    - Cerberus

※1 source_pathについて

※2 cache_pathについて

  • cache機能を利用する時、ライブラリの情報を出力するパス。絶対パス or my-app からの相対パス

実行と出力イメージ

cache機能

見つかったライブラリを依存関係も含めてリストアップします。

$ cd {my-app/.licensed}
$ bundle exec licensed cache

{my-app}/.licensed/.licenses/ 配下に、発見されたsourceごとのdirectoryが作成されます。

{my-appy}/.licensed/.licenses
.
└── pip
    ├── PyYAML.txt
    ├── certifi.txt
    ├── chardet.txt
    ├── idna.txt
    ├── requests.txt
    └── urllib3.txt
└── npm
    ├── ...

それぞれのテキストにはライセンス情報が書かれています。PyYAML.txtの例。

---
type: pip
name: PyYAML
summary: YAML parser and emitter for Python
homepage: http://pyyaml.org/wiki/PyYAML
version: '3.13'
license: mit
---
Copyright (c) 2017-2018 Ingy döt Net
Copyright (c) 2006-2016 Kirill Simonov

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

これらのテキストをパースしてライセンス情報一覧を作ると、そのままどこかに提出も出来ますね。

status機能

見つかったライセンスに許可していないものが入っていないかチェックします。

$ cd {my-app/.licensed}
$ bundle exec licensed status

こんな感じで結果が出力されます。

Checking licenses for My App: 6 dependencies
FFF.FF

Warnings:

/Users/Natsumi/workspace/venv/test-licensed/.licensed/pip/certifi.txt:
  - license needs reviewed: other.

/Users/Natsumi/workspace/venv/test-licensed/.licensed/pip/chardet.txt:
  - missing license text
  - license needs reviewed: none.

/Users/Natsumi/workspace/venv/test-licensed/.licensed/pip/idna.txt:
  - license needs reviewed: other.

/Users/Natsumi/workspace/venv/test-licensed/.licensed/pip/requests.txt:
  - license needs reviewed: other.

/Users/Natsumi/workspace/venv/test-licensed/.licensed/pip/urllib3.txt:
  - license needs reviewed: other.

6 dependencies checked, 5 warnings found.

ここで warning が出たライブラリを確認し、ライセンスに問題がなければ .licensed.yml

  • reviewed
  • ignored

に追記します。本ライブラリの注釈にもあったようにlicesnedは完璧なソリューションではないですし、何ならpythonでよく使われている BSD ライセンスは検出できないなど性能はまだまだ改善の余地ありです。
今回は6つのライブラリ中1つしか認識できませんでした。(認識失敗のものは bsd や mit でした。)
reviewed, ignored リストを上手に使いつつ運用していくのが良いかと思います。

リストに入れて再度実行すると、このようなログになります。

$ bundle exec licensed status
Checking licenses for my-app: 6 dependencies
......
6 dependencies checked, 0 warnings found.

当面はこんな感じで除外リストが膨大になりそうですが、それでもぽっと入ってしまった許可していないライセンスのライブラリを、傷が浅いうちに検知できるのは魅力的だと思います。
検出の性能については、せっかくOSSとして公開されているので、皆でブラッシュアップしていきましょう!割とプルリクは受け入れてもらいやすそうに感じました。

まとめ

  • Pythonでのセットアップは少々面倒
  • 割と使われる BSD ライセンスが検出できないなど、性能はまだ改善の余地あり
  • OSSライブラリなので、是非一緒に育てていきましょう!

2018/12/22(土)更新:
他のツールも試してみたろころ格段に性能が上がったので、こっちのほうがお勧め!的な記事を書きました。やりたいことは全く同じなのでよろしければこちらもご覧ください。

kusuwada.hatenablog.com

参考リンク

OSSライブラリのライセンスをチェックしてくれるGitHub製ツール「licensed」 - Engineer's Way