npmとyarnの脆弱性とpostinstall

フロントエンドエキスパートチームの小林(@koba04)です。

先日、npmから脆弱性についての発表がありました。 調べていく中でいくつか思うところがあったので解説も兼ねて書いていきたいと思います。

The npm Blog — Binary Planting with the npm CLI

npmの利用者としてやるべきことは、

  • npmのバージョンを6.13.4以上にあげる
  • yarnのバージョンを1.21.1以上にあげる

です。 npmのバージョンが6.13.4になったNodeもv8, v10, v12, v13系でそれぞれリリースされたので、そちらを利用することも可能です (yarnのバージョンは別途あげる必要があります)。

nodejs.org

npmによる発表では、今回発表された脆弱性は2件あるため、それぞれ個別に考えます。

binに任意のパスを指定出来る件

npmパッケージはpackage.jsonbinフィールドに、インストールすることで使えるようになるコマンドを指定出来ます。 例えば、eslintの場合は下記のように指定されているため、インストールするとeslintコマンドがnpm-scriptsやnpx経由で利用可能になります。

github.com

パッケージをグローバルにインストールしない場合、このコマンドはnode_modules/.binに配置されます。 npm-scriptsの実行時はここにPATHが通っているため、npm-scriptsではeslintコマンドとして実行できます。

今回は脆弱性の話なので詳しく解説しませんが、binに興味持った人は下記のドキュメントを参照してください。

docs.npmjs.com

今回の脆弱性はここのコマンド名の部分に、../../cdなどと指定したら…?という話です。 現状では特にコマンド名に対するチェックが行われていなかったため、インストールするだけで任意のファイルを置き換えることが可能であり、これが脆弱性であると判定されました。

npmでは、この辺りのcommitで修正されています(内部で利用している別パッケージの修正)

github.com

yarnでは、この辺りのcommitで修正されています

github.com

この脆弱性は、孫依存のような開発者自身が明示的に指定せずにインストールされる依存パッケージからでも悪用可能なため、対応すべきです。

グローバルにインストールしたパッケージが別のコマンドを上書きできる件

npmはプロジェクトローカルにパッケージをインストールするだけでなく--global(-g)フラグを使うことでグローバルにパッケージをインストールできます。 yarnの場合は、globalコマンドを利用します。

グローバルにパッケージをインストールした場合、パッケージが提供するコマンドのシンボリックリンクが/usr/local/bin以下に作成されます(作成される場所は実行環境により異なります)。 /usr/local/binといえば、他にもHomebrewなどでインストールしたパッケージのコマンドも配置される場所です。

この脆弱性は、グローバルにインストールされたパッケージが、binに指定しているコマンドによってすでにある別のコマンドを上書きできるというものです。 例えばjqをHomebrewか何かでインストールしている状態で、悪意のあるnpmパッケージをグローバルにインストールした場合、そのbinフィールドに"jq" : "evil.js"のように書かれていると、jqコマンドが上書きされてしまうということです。

グローバルにインストールすること自体がそんなに多くないのと、よくわからないnpmパッケージをグローバルにインストールすることはほとんどないと思うので、1つめのものに比べると深刻度は低いかなと思います。

npmでは、この辺りのcommitで修正しています。

github.com

対応方法としては、すでにコマンドが存在する場合、シンボリックリンク先が今からインストールしようとしているものと同じかどうか確認しています。

yarnでは、これに対する修正はまだ行われていません。 なので、下記のissueでどうするのか聞いてみました。

github.com

上記のIssueによると、yarnでは今回の件を下記の3つに分けて考えています。

  • Binary planting
  • Out-of-tree execution
  • Binary overlap

Binary plantingは1つ目に紹介した脆弱性で、前述した通りyarnは1.21.1で修正済みです。

Out-of-tree executionは、binフィールドのコマンド名の部分ではなく、ファイルパスを指定する側に/home/foo/bar のように指定することで任意のポイントに対するシンボリックリンクを作成できるというものです。

    {
      "bin": {
        "foo": "/home/foo/bar"
      }
    }

これについては、Binary plantingの対応と同時に対応されています。 ただしこちらに関しては、脆弱性ではなくbugfixという扱いにしています。

Binary overlapは2つ目に紹介した件であり、yarnでは対応されていないものです。 対応しない理由としては、上記のIssueでは下記のように説明されています。

  • 過去にBinary overlapを意図的にやっていたパッケージがあり、その挙動に対してBreaking Changeとなるため
  • これらのパッケージは、ユーザーが意図してインストールしたパッケージであるため

そのため、特にグローバルなパッケージをインストールする場合には、注意深く行う必要があります。

上記のIssueによると、yarnはv2でbinスクリプトはNodeを通じてのみ実行可能にすることを計画しているようです。 これにより、Node上でサンドボックス環境を構築してその上で実行させることが可能にできる可能性があります。 yarnはNodeがこのようなセキュリティポリシーを実装することを期待しています。

postinstallとinstall

今回の脆弱性についてはここまでですが、前述したIssueではpostinstallの危険性についても言及されています。

npmでは、publishやpackなど、任意のタイミングで呼ばれるscriptをnpm-scriptsとして登録出来る仕組みがあります。 どのようなscriptを登録出来るのかは下記のページより確認可能です。

docs.npmjs.com

その中に、installpostinstallがあります。 これらは利用者がパッケージをインストールする際に実行されるスクリプトです。 つまり、ただnpm installするだけでインストールしたパッケージに対して任意のスクリプト実行を許可していることになります。 そのため、結果的に悪意のあるパッケージが依存関係に含まれてしまった場合には、npm installするだけで悪意のあるスクリプトを実行されてしまいます。

つまり、今回のような脆弱性を利用せずとも悪意のあるパッケージはうまく依存関係に含まれることに成功すれば任意のスクリプトをユーザー環境で実行できます。 …。

そのための対策として、npm-scriptsの実行をしないための--ignore-scriptsというオプションがあります。

docs.npmjs.com

これを利用することでインストール時に任意のスクリプトの実行を拒否することが可能です。 ただ、これを使うためにはなぜinstallpostinstallが使われているのかを理解する必要があります。 現状、下記の2点の用途で利用されていることが多いと思います。

  1. ユーザー環境に応じた処理
    • C, C++などのコードを含んだネイティブモジュールのビルド
    • 環境に応じたバイナリのダウンロード
  2. Fundingのお願い

1のケースで言えば、fseventspuppeteerなどがよく目にケースではないでしょうか。 ただ、fseventsに関しては下記のPRでinstallスクリプトを実行しないようになっているので、最新版だとインストールスクリプトは実行されません。

github.com

puppeteerでは下記のスクリプトでchromiumのバイナリをダウンロードします。

github.com

2.のケースは、最近npm installnpm ciの際に大量のfundingのお願いのメッセージを目にすることがあるのではないでしょうか? これは、多くの場合postinstallを使ってconsole.logでメッセージを出力することで実現されています。

ではこれらをなくすことが出来るのかということですが、まず2のケースについては言えば、npm v6.13からサポートされているfundingのフィールドを使うことで、npm fundコマンドで出力することができます。

The npm Blog — Updates to Community, Docs & more...

npm installでパッケージをインストールした時にnpm fundに対応したパッケージが依存にあると、下記のような形で出力されます。

:
added 15 packages from 17 contributors and audited 15 packages in 1.574s

2 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

npm fundを実行すると下記のような形で出力されます。

% npm fund
test@1.0.0
└─┬ fetch-mock@8.2.0-beta.2
  ├── type: charity
  ├── url: https://www.justgiving.com/refugee-support-europe
  └─┬ core-js@3.6.0
    ├── type: opencollective
    └── url: https://opencollective.com/core-js

今回の脆弱性により、多くのユーザーはnpm 6.13.4以上を利用するようになるため、npm fundコマンドが利用可能になります。 そのため、postinstallではなくnpm fundを使うための準備が整ったとも言えます。

yarnは現在fundコマンドをサポートしていませんが、前述したIssueによるとv2には実装してバックポートすることが検討されているようです。

1のケースについては、パフォーマンス目的でC, C++で書いている場合には、WebAssemblyで置き換えることが可能かもしれません。 ただ、NodeがサポートしていないネイティブのAPIを使いたい場合には、現時点ではまだ使い続ける必要がありそうです。 将来的にはこれについてもWebAssemblyで解決出来る可能性はあるかもしれませんが。

そもそもの現状として、どんなスクリプトが自分のプロジェクトのnpm install時に実行されているのか把握している人は少ないのではないでしょうか? インストール済みのパッケージでinstallまたはpostinstallのスクリプトがあるものを出力するためのnpm packageを作ってみたので興味ある人は自身のプロジェクトで実行してみてください。

% npx install-scripts

github.com

こちらは社内のプロジェクトで実行した結果です。 この中で動作に必要なscriptはfseventspuppeteerだけでした。他は全てfunding関連です。

    core-js
      scripts:
        postinstall: node scripts/postinstall || echo "ignore"
      paths:
        node_modules/@babel/polyfill/node_modules/core-js/package.json
        node_modules/@storybook/addon-actions/node_modules/core-js/package.json
        node_modules/@storybook/addon-knobs/node_modules/core-js/package.json
        node_modules/@storybook/addon-links/node_modules/core-js/package.json
        node_modules/@storybook/addon-viewport/node_modules/core-js/package.json
        node_modules/@storybook/addons/node_modules/core-js/package.json
        node_modules/@storybook/api/node_modules/core-js/package.json
        node_modules/@storybook/channel-postmessage/node_modules/core-js/package.json
        node_modules/@storybook/channels/node_modules/core-js/package.json
        node_modules/@storybook/client-api/node_modules/core-js/package.json
        node_modules/@storybook/client-logger/node_modules/core-js/package.json
        node_modules/@storybook/components/node_modules/core-js/package.json
        node_modules/@storybook/core/node_modules/core-js/package.json
        node_modules/@storybook/core-events/node_modules/core-js/package.json
        node_modules/@storybook/node-logger/node_modules/core-js/package.json
        node_modules/@storybook/react/node_modules/core-js/package.json
        node_modules/@storybook/router/node_modules/core-js/package.json
        node_modules/@storybook/theming/node_modules/core-js/package.json
        node_modules/@storybook/ui/node_modules/core-js/package.json
        node_modules/fetch-mock/node_modules/core-js/package.json
        node_modules/lazy-universal-dotenv/node_modules/core-js/package.json
        node_modules/simplebar/node_modules/core-js/package.json
        node_modules/wait-on/node_modules/core-js/package.json
    core-js-pure
      scripts:
        postinstall: node scripts/postinstall || echo "ignore"
      paths:
        node_modules/core-js-pure/package.json
    fetch-mock
      scripts:
        postinstall: node scripts/support-fetch-mock.js
      paths:
        node_modules/fetch-mock/package.json
    fsevents
      scripts:
        install: node install
      paths:
        node_modules/fsevents/package.json
    puppeteer
      scripts:
        install: node install.js
      paths:
        node_modules/puppeteer/package.json
    styled-components
      scripts:
        postinstall: node ./scripts/postinstall.js || exit 0
      paths:
        node_modules/styled-components/package.json
  • fetch-mockは最新版ではすでにfundを使うようになっています
  • core-jsについてはこちらで議論されていますが、今のところ削除する予定はなさそうです
  • styled-componentsについては過去に議論されたIssueがなかったので確認してみました

下記のように組み合わせることで必要なスクリプトのみを実行することも可能です。

    % npm ci --ignore-scripts
    % npx install-scripts

yarnはpostinstallについて、既存のエコシステムを壊してしまうのでv2で全て無効にすることは難しいですが、v3では考えているようです。 ただし、現時点でも手動でホワイトリスト管理によって許可されたscriptのみ実行可能にすることは可能だとしています。

実際にpostinstallinstallを調べてみた結果としては、そこまで多くないなという感想だったので--ignore-scriptsを使った運用するのも可能であるように感じました。 そのためにも、今度はpackageをホワイトリストで指定して実行できるnpm packageを作ってみようかなと思いました。 ただこれ自体はnpm自体が対応すべき問題であると感じるので、Feature Requestを送りました。

github.com

以前には下記のevent-streamに対する問題がありましたが、現状ではインストールするパッケージを全て把握することは現実的ではありません。

The npm Blog — Details about the event-stream incident

今回の脆弱性を通じて、npmの現状や将来の方向性などを考える機会になればいいなと思ってこの記事を書きました。


サイボウズでは、脆弱性があった時にcommit log読んで原因を探求したくなるエンジニアを募集しています!

cybozu.co.jp