ReuseLessSoftware - ソフトウェアを「再利用しすぎない」
(wiki.alopex.li)- サプライチェーン攻撃は、ソフトウェア配布のコストが非常に低くなり、ビルド・配布の自動化が広く使われるようになったことで、より大きな問題になった
- 1970年代には再利用可能なソフトウェアを作るのが難しいソフトウェア危機があったが、今ではパッケージリポジトリとパッケージマネージャが名前とバージョンだけでコードを取得し、ビルドする
- 自動依存関係更新は CI を通じて悪意ある変更を急速に広めるようになり、巧妙なサプライチェーン攻撃は CI ランナーが実行される速度で拡散する
- すべての依存関係をプロジェクトのリポジトリに一緒に入れるベンダリングはリポジトリを大きくするが、自動変更を防ぎ、依存関係の規模とコストをより見えやすくする
- あらゆるソフトウェアに合う解決策ではないが、多くの小規模ソフトウェアは、外部で突然変わりうる依存関係を2〜3個程度まで減らす利点を得られる
問題
- サプライチェーン攻撃は、ソフトウェアや保守の本質だけが変わったからではなく、ソフトウェア共有と配布のコストモデルが非常に低くなったことで、ますます大きな問題になっている
- 配布コストが低すぎるため、多少の無駄があっても自動化を多用するようになり、自動化自体は有用である
- 数か月ごとに新たなサプライチェーン攻撃が発生し、世界中のコードの大きな部分を壊すことが起きている
どうしてこうなったのか
- 1960年代後半から1970年代初頭にかけて、人々は再利用可能なソフトウェアをどう作るかをまだよく分かっておらず、これを ソフトウェア危機 と呼んでいた
- ソフトウェア需要は指数関数的に増加したが、要求される複雑さに見合う新しいソフトウェアを作る能力の伸びはそれより遅かった
- この時期は、モジュール性や構造化プログラミングのような研究へとつながり、1990年以降に作られたほぼすべてのプログラミング言語のモジュールシステムは Modula-2 まで系譜をさかのぼれる
- 1990年代から2000年代にかけて、インターネットがより強力な解決策を生み出し、ソフトウェアのビルドと配布は安価になり、実際に使いたいソフトウェアのかなりの部分はオープンソースだった
- CPAN、CTAN、Linux ディストリビューションを土台に、多くのパッケージリポジトリとパッケージマネージャが生まれ、これらのツールはマニフェストファイル、名前、たいていは恣意的なバージョン番号だけでソフトウェアを見つけ、取得し、ビルドする
-
手作業による統合から自動依存関係へ
- 以前は、複雑なソフトウェアシステムを作る良いやり方は、動く部品を手で慎重に組み合わせることであり、Linux ディストリビューションは基本的にそれを行っている
- 2003年に SDL をすべての機能付きでビルドしようとすると数日かかるほどつらい作業だったが、そんな時代を懐かしむ必要はない
- Linux ディストリビューションが既知の基本環境として存在すれば、多くのカスタムソフトウェアは自分の世界の中で動作し、システムの他の部分をそれほど気にしなくてよい
- 他のソフトウェアと通信するときは、よく知られたプロトコルを使うファイルやネットワークソケットを通す場合が多い
- Rust や Go で最初からビルドされたり Docker コンテナとして配布されたりする良いソフトウェアが増え、こうしたソフトウェアはシステムライブラリとほとんど相互作用しない
- OS ディストリビューションが提供するソフトウェア群に合わせるのではなく、必要なライブラリをビルドシステムが直接取得する方式が広く使われている
-
逆方向の危機
- 現在は1970年代とは逆に、人々がソフトウェアを再利用しすぎた結果、プログラムが悪化する危機が生じている
- ソフトウェアの配布は依然として非常に安価だが、ソフトウェアを使うことには依然としてコストがある
- 長い間、最大のコストはソフトウェアをビルドしてコンピュータ上で動かす複雑さだったが、その問題はかなりの部分が自動化で消えた
- その結果、はるかに多くのソフトウェアをビルド・配布・利用するようになり、そのコストは依存関係地獄、肥大化、長いビルド時間、パッケージやパッケージマネージャの消失といった形で現れる
- 最大の問題は サプライチェーン攻撃 である
-
サプライチェーン攻撃の拡散構造
- サプライチェーン攻撃はオープンソースソフトウェアと同じくらい古い問題である
- 過去に Linux カーネルへ
uid == 0の代わりにuid = 0を入れようとした悪意あるパッチの試みは、野外で観測された最初の悪意あるカーネルパッチであり、サプライチェーン攻撃の試みに当たる - ここ10年でサプライチェーン攻撃がより大きく、深刻な問題になった理由は、ビルドシステムがソースコードを取得して配布することまで自動化されたためである
- CI システムは通常、あらゆるコード変更や大きな変更時に実行され、こうした変更はそのコードに依存するすべての人に自動的に利用可能になる
- 依存する側の CI システムもその変更を取り込み、新たに入った悪意あるコードを含むことになり、巧妙なサプライチェーン攻撃は CI ランナーが動く速度で山火事のように広がる
- 依存関係クールダウンのようにサプライチェーン攻撃を遅らせる方法もあるが、ポリシーや責任の所在をめぐる論争が生じる
解決策
npmやcargoのようなビルドシステムが毎回ネットワーク上の場所から依存関係を自動取得するのではなく、すべての依存関係をソフトウェアと一緒に入れる方式が要点である- プロジェクトにすべての依存関係をベンダリングし、アップストリームのソース管理内容を git リポジトリにコピーしてコミットする
- アップストリーム更新があればダウンロードして再度コピーし、手作業が面倒ならビルドツールにそれを自動化させればよい
- すでに lockfile があるなら、ソース管理内の完全なソースツリーと結び付くようにすればよい
- すべてのソースコード行を強く統制する形で所有する
-
コストとトレードオフ
- リポジトリは大きくなるが、ディスク容量は安い
- 転送コストはディスクより安くはないが、この議論では受け入れるべき要素として残る
- ビルド時間は増えそうに見えるが、どうせその依存関係群を再ビルドしていたのだから、必ずしも増えるわけではない
- コード再利用は難しくなる可能性があり、共有プロトコルライブラリを使うクライアントとサーバのようなプログラムでは実際の問題になりうる
- そうしたプログラムはすでにバージョン不一致の問題を抱えており、それに対処しなければならないので、実際には注意を払わせることが長期的に見てより悪いわけではない
-
サプライチェーン攻撃の防火帯
- 依存関係を自動更新しなければ、エコシステム内のすべてのパッケージがサプライチェーン攻撃の防火帯になる
- 同じ仕組みはバグ修正やパッチ伝播も止めるが、重要な修正なら結局は人が手動で探すことになる
- 人が探しに行かない修正は、たいてい重要ではない
- semver や「異なる二つのコードが同じように動作すべきだ」という概念をビルドシステムから捨て、すべてのバージョン番号を互いに無関係な固有のものとして扱っても、似た効果を得られる
- semver の問題は、実際の現実ではなく人の意図を表しており、それすらある程度正しく使われたときにしか機能しない点にある
- バージョン番号を固有のものとして扱う方法では、依存関係の消失、改ざん、あるいはパッケージ内容の別の形での破損といった問題は解決できない
-
依存関係の可視性
- すべての依存関係をベンダリングすると、自動変更を遅らせる以外にも、依存関係利用のコストが少し上がる
- コスト増は回復不可能なほどではなく、アップストリームコードを使うときに少しだけ考えさせる程度である
- 新しい依存関係を追加するときに「本当に必要か」を問い直させる、穏やかな仕掛けになる
- 依存関係の可視性が高まり、依存関係の背後に隠れた肥大化が見えにくくならない
- 200行程度だと思って追加した単純なライブラリが 50,000 行だったなら、立ち止まってその理由を問うべきだということがより明確になる
- 依存関係の魔法のような性質が薄れ、コードベース内のバグが他人のコードへつながる経路をより追いやすくなる
-
依存関係ツリーと共有の問題
- 何でもデフォルトでベンダリングすると、より平坦で幅広い依存関係ツリーを促す可能性がある
- C++ の Boost や Qt のような巨大ライブラリ級まで行くのは望ましくない
- そうした巨大ライブラリは、小さな C/C++ ライブラリを作って使うことがあまりにも難しいために存在している
- Boost や Qt のようなものを自分でビルド方法まで把握するより、Linux ディストリビューションのようなシステムインテグレータが一度だけやってくれるほうがよい、という前提がある
- 実際の欠点は、推移的依存関係が共有されない点である
- lib A と lib B がともに Z に依存するとき、重複排除は不可能ではないが難しくなり、人手か、より洗練されたツールが必要になる
- 推移的依存関係が共有される場合でも問題は生じ、推移的依存関係を持つこと自体が問題の一部である
- ライブラリに推移的依存関係を指定させるのは、プログラムに対する制御を他人へ渡す行為になる
分析
- すべてのソフトウェアがこの方式を使えるわけではない
- Web アプリのバックエンド配備の一部として Redis 全体をベンダリングしてビルドする方式は、特に合理的ではない
- ただし、配備が Ansible や Docker イメージなどで自動化されているなら、すでに事実上似たことをしている可能性がある
- この方式が耐えられる複雑さには上限があるが、Google や Facebook のような巨大モノレポ企業は、その上限が思ったより高いかもしれないことを示している
- ある時点で依存関係はオペレーティングシステムと接続し、オペレーティングシステム自体が問題の多い大きな依存関係である
- Web バックエンド向け unikernel の発想は魅力的だが、現実のツール面の問題があり、まだそこまで到達していない
-
Linux ディストリビューションとビルド環境
- この方式は Linux ディストリビューションや BSD のような完全な相互作用システムを作る方法ではない
- そうしたシステムには一緒に動く必要のある多くのプログラムやライブラリがあるため、別の問題になる
- この原則を最後まで押し進めると、Nix や Guix のような方式に近づく
- 「ビルド環境」を正しく組み立てなければならないという考え方は、「ソフトウェアをどうビルドするか」という問題を怠惰かつ不十分に解いたやり方に近い
- この考え方は、どこかのミニコンピュータでソフトウェアを一度ビルドしてからバイナリとして広く共有していた時代の名残である
- 今日では、1970年代よりはるかに多くのソフトウェアをその場でビルドしている
-
適用可能な範囲
- この方式は万能の解決策ではないが、多くのソフトウェアには適用でき、利点をもたらしうる
- ほとんどのソフトウェアは小規模であり、大規模プロジェクトはすでにこうした問題の多くを解決しなければならない
- 純粋な計算だけを行うか、ファイルやネットワークソケットのような基本的で移植性の高い I/O でのみ外部と接するライブラリは多い
- 圧縮ライブラリ、libcurl、TUI ライブラリ、Django のような例はベンダリング対象として扱える
- ベンダリングすれば、バージョン衝突や突然のパッチによるバグの流入のせいで、新しいシステムへの配備やビルド時に原因不明のまま壊れる事態をほぼ避けられる
- 目標は、外部で予告なく変わりうる依存関係を 200〜300 個ではなく、多くても 2〜3 個の水準まで減らすことである
結論
- 依存関係の自動更新を減らし、プロジェクト自身が依存関係のソースまで保有すれば、サプライチェーン攻撃の自動拡散を遅らせられる
- 依存関係利用のコストを少し高め、可視性を高めれば、不必要な再利用や隠れた肥大化をより見つけやすくなる
- この方式はすべてのシステムに適しているわけではないが、小規模ソフトウェアや多くのライブラリには実用的な利点がある
1件のコメント
Lobste.rsの意見
Zigパッケージマネージャは、かなりうまい折衷案だと思う
すべてのパッケージがコンテンツハッシュで固定されるので、実質的にロックファイルが常にあるようなもので、「上流リポジトリが突然悪意あるものに変わる」問題は避けられる一方で、「上流リポジトリが消える」問題は残る
ただしグローバル/ローカルの両方のキャッシュがあり、コンテンツハッシュベースでもあるので、上流リポジトリが消えたらローカルコピーのtarballを必要な場所に放り込めばよい
「ソースをベンダリングすること」と「シンプルで再利用可能なソフトウェア」の間のよい折衷に見える
すべてのソースをコンテンツアドレス指定ストアに置き、各プログラムは入力のハッシュをもとにハッシュ化すればよい
たぶんロックファイルを書き換えるか、ハッシュ衝突を見つける必要があるのだろうが、どちらも簡単には見えない
ただ、
cargoエコシステムに慣れているせいか、完全にはしっくりこない。依存関係を上げると、その推移的依存関係も特に何も言わず一緒に上がる傾向があり、意味的バージョン範囲に合う別のものまで一緒に変わるからだ「サプライチェーン攻撃」と呼ぶには、提案と対価のある署名済み契約がないので、サプライチェーンではないと思う
別の話として、依存関係が下流で変わらないことを保証するという観点では、ハッシュ入りロックファイルやGoの最小バージョン選択方式は依存関係のベンダリングと同じだ
ベンダリングには摩擦が生じるという違いは理解できるが、極端に行くと自前実装するか、もっと悪いことに依存関係をその場しのぎの生成コードにしてしまうので、ドメインの専門家が書いて十分に検証されたソフトウェアを使うほうがよいと思う
Facebookでこの手の仕事をしていたが、そこでのサードパーティ依存関係管理は誰にも勧めたくない。あるRustクレートの直接依存関係は、fbsource全体で意味的バージョン互換でない版が同時に最大2つまでしか許されない。依存関係を更新するにはfbsource全体を更新する負担を背負う必要がある
Facebookには合っているのかもしれないが、特別に優れているとも持続可能だとも思えない
「特別に優れているわけでも持続可能でもない」というのは、ポリシー自体というより規模の関数に近いのではと疑っている。複数バージョンを許すと別の問題が生じる。というのも、TypeScriptを除く現代言語のほとんどは主に、あるいは完全に名目的型付けを使っているため、破壊的変更ごとに「semver trick」を使わない限り、バージョン間で型の再利用ができなくなるからだ
Log4Shellのときも、バージョンが多く各所に散らばっていた会社のほうが、バージョン数が少ないか固定していた会社よりアップグレードに苦労していた記憶が強い
The Third Networking Truthによれば、「十分な推力があれば豚も飛ぶ。だが、それが必ずしもよい考えであることを意味しない」
Google/Facebookのようなところで語られる多くの慣行は、それらの会社が十分な推力を投入できるからこそ機能しているだけだ
たとえばそうした会社の一部では、モノレポや依存関係まわりの選択を支えるために、私の勤め先の全社員数より大きいチームを付けているのを知っている。彼らには負担できても、私たちの大半には難しい
よい見解だと思う。「すべての依存関係をベンダリングすると依存関係を使うコストが上がる」という点には強く同意する
ただしlibcurlをコピーして貼り付けるべきではない。多くのライブラリには妥当な戦略だが、敵対的入力を扱うCプログラムにはよい助言ではない。OSがlibcurlを安全に保つ以上にうまくはできない
一度も考えたことがなかった点として、aptのようなエンドユーザー向けパッケージマネージャが先に現れ、言語レベルのパッケージマネージャが後から出てきたのは、少なくとも少し奇妙だということだ
これが実際に多くの問題を生んだと思う。2000年代初頭のrubygemsを見ると、プロジェクトごとの管理ではなくシステム全体へのインストールを前提にした「Ruby向けapt」を作ろうとしていたのがかなり明白だ。その失敗の後始末としてbundlerを追加するのに何十年もかかったが、最初からプロジェクト分離の必要性を認めていればbundlerは不要だったはずだ
Pythonはいまもこの混乱の収拾中で、Perlもおそらくそうだろうが詳しくは知らない
歴史的に、パッケージマネージャはもともとシステムを構築するためのもので、そうしたシステムには複数ユーザー、デスクトップ環境、連携して動く多くのソフトウェアがあった
ソフトウェアのビルドには時間もメモリもかかり、ディスクやRAMに比べてソフトウェア量が非常に多かったので、ライブラリの再利用が重要だった
Webアプリが台頭すると、重要なコンピュータの大半は生涯で少数のプログラムしか動かさないサーバになり、ディスクやRAMも十分安くなってコードやバイナリのサイズは以前ほど重要ではなくなった
システムを作るための道具はそうした時代の変化にそこまで追随できず、その結果、ソフトウェアを作る大半の人に必要なのは共有ライブラリだらけの巨大な相互接続システムではなく、単一プログラムをうまく作るための道具になった
この歴史と並行して「Cにはまともなモジュールシステムがない」という流れもあるが、ここでは重要性は低い
間違っているかもしれないが、コピーしてきた依存関係にバグがあってもスキャナが検出できないという欠点がありそうだ
そうなると、本来なら通知を受け取れたはずの潜在的問題が静かに残ってしまうかもしれない
スキャナは問題になりうるものを示すのには非常に有用だが、スキャナが問題だと思ったものの実際にはそうでないものを直すために、予定していた作業を急に後ろ倒しにさせられると非常に厄介だ
提案どおりにソフトウェアへすべての依存関係を含め、上流のソース管理をgitリポジトリにコピーしてコミットし、手作業が面倒ならビルドツールに自動化させるなら、結局ひと回りして再びサードパーティソフトウェアを中身を見ずに取り込むことにならないか?
だがその方法では、依存関係が消えたり改ざんされたりする問題、あるいは誰かがパッケージ内容に別の形で手を加える問題は解決できない。最適化に近い話で、私には時期尚早な最適化に思える。いずれそうなるかもしれないが、出発点にすべきではない