Does Dependency Sprawl Cause CVEs? Causal Evidence from Ecosystem Policy Shocks
依存パッケージが増えると、本当に脆弱性も増えるのか?
「依存が多いプロジェクトほどCVEが出やすい」——肌感覚では誰もが知っています。でも、それは本当に因果なのか、それとも「大きくて人気のあるパッケージほど依存も脆弱性も多い」という見かけの相関なのか。ここを切り分けないと、「依存を減らせばリスクも減る」という対策が空振りに終わります。
そこで、エコシステムの政策変更(npmやPyPIのルール変更)を自然実験として使い、差分の差分法(DiD)で因果効果を測ってみました。
まず用語をざっくり
本題の前に、この記事で繰り返し出てくる言葉を一言で。
- 依存スプロール(dependency sprawl): 直接・間接(推移的)に芋づる式で増えていく依存パッケージの肥大化。
- 推移的依存(transitive dependency): 自分が直接入れたわけではないが、依存先がさらに依存しているパッケージ。ここが盲点になりやすい。
- CVE: 公開された脆弱性の識別番号。多いほど「踏みやすい地雷」が多い状態。
- DiD(差分の差分法): ルール変更の「影響を受けたグループ」と「受けなかったグループ」の変化の差を比べ、因果効果だけを取り出す手法。
背景:なぜ相関だけでは足りないのか
「依存が多い ⇄ CVEが多い」という相関は、過去の研究で何度も観察されてきました。問題は、その多くがある時点を切り取った断面データだということ。
断面データでは、(1) 逆の因果(脆弱性が多いから依存構成をいじって結果的に依存が増えた)や、(2) 見えない交絡(資金が潤沢で人気だから依存も脆弱性報告も多い)を取り除けません。だから「3.1倍」という数字が出ても、「で、依存を減らせば本当に減るの?」には答えられないままでした。
この記事のゴールは、その一歩を埋めることです。
使ったデータと方法
毎月1日に、npm Registry・PyPI・crates.io の各APIからパッケージのメタデータ(初回公開日・最終リリース日・直接依存数・週次ダウンロード数)と依存グラフのスナップショットを収集しました。脆弱性は NVD CVEフィード(JSON 2.0) と OSV API から取得し、package_id でマージしています。
下のコードは、npmとOSVから1パッケージ分の必要フィールドを取得する最小実装です。レジストリからバージョン情報と公開日を取り、OSVをページングしながら脆弱性件数を数えています。
import urllib.request, json, datetime, time PKG, ECO, TODAY, DELAY = "express", "npm", datetime.date.today(), 0.5 def fetch(url, data=None, hdrs={}): try: with urllib.request.urlopen(urllib.request.Request(url, data, hdrs), timeout=10) as r: return json.loads(r.read()) except Exception: return {} d = fetch(f"https://registry.npmjs.org/{PKG}") lat = d.get("dist-tags", {}).get("latest", "") v, t = d.get("versions", {}).get(lat, {}), d.get("time", {}) age = (TODAY - datetime.date.fromisoformat(t["created"][:10])).days if "created" in t else None drel = (TODAY - datetime.date.fromisoformat(t[lat][:10])).days if lat in t else None time.sleep(DELAY) vulns, tok = [], None while True: body = {"package": {"name": PKG, "ecosystem": "npm"}} if tok: body["page_token"] = tok resp = fetch("https://api.osv.dev/v1/query", json.dumps(body).encode(), {"Content-Type": "application/json"}) vulns += resp.get("vulns", []); tok = resp.get("next_page_token") if not tok: break time.sleep(DELAY) N, deps, ddeps = None, v.get("dependencies", {}), v.get("devDependencies", {}) print(json.dumps({"package_id": f"{ECO}:{PKG}", "ecosystem": ECO, "package_age_days": age, "days_since_release": drel, "dep_count": len(deps), "vuln_count": len(vulns)}))
処置群(脆弱性あり)と対照群を素直に比べると、依存数・パッケージ年齢・人気度の違いがそのまま結果に混ざってしまいます。そこで CEM(Coarsened Exact Matching) を使い、依存数・パッケージ年齢(日数)・ダウンロード数四分位の3変数を粗く区切ってから完全一致で対応付け、交絡を減らしました。
脆弱性は、性質の違う2つの軸に集約しています。
- スプロール軸: 依存連鎖の横方向の広がりに起因するクラス(CWE-1357など)。
- 陳腐化軸: メンテナンス停滞に起因するクラス(CWE-1188など)。
この2軸を分けておくと、「どのチャネルが効いているか」を別々に追えるのがポイントです。
推移的依存の分布——少数の長い尾がリスクを溜め込む
依存スプロールの実態を見てみます。下図はnpmにおける推移的依存数の分布です。
大多数(約28%)が依存50個未満に収まる一方、尾は1,000〜5,000+まで伸びています。リスクはこの「長い尾」の少数パッケージに集中している、という構図です。
CVEはどこから来るのか——直接 vs 推移的
CVEを「直接依存由来」と「推移的依存由来」に分けると、エコシステムごとの依存グラフの深さがそのまま効いてきます。
依存グラフが深いエコシステムほど、推移的依存由来のCVEの割合が高くなります。npmは直接23% / 推移的77%、PyPIは31% / 69%。つまり「自分で入れた覚えのない依存」が脆弱性の大半を運び込んでいる、ということです。スプロールが因果チャネルだとする見方と整合します。
因果識別の枠組み:政策ショックを自然実験にする
ここからが本題です。3つのエコシステム政策変更を「外から来たショック」として使います。
- H1: npm の
package-lock.jsonv3義務化 - H2: PyPI の必須2FA施行
- H3: crates.io のスパースレジストリ導入
H1・H2は処置が単一時点に集中するので、二方向固定効果(TWFE)のDiD が使えます。個体(パッケージ)と時間の固定効果で交絡を吸収し、推定式
$$Y_{it} = \alpha_i + \lambda_t + \delta D_{it} + \varepsilon_{it}$$
の $\hat{\delta}$ がATT(処置群への平均処置効果)の一致推定量になります。
一方H3は導入が段階的(staggered adoption)に起きるため、単純なTWFEだと処置タイミングのズレが負の重みを生んでしまいます。そこで Callaway–Sant'Anna(2021)推定量 を使い、コホート×時点ごとのATTを集計する形で回避しました。
平行トレンド仮定(処置がなければ両群は同じ傾きで推移したはず)の検証には、結果と無関係なはずのCWEクラスをプラセボ結果変数として使います。スプロール仮説ならメモリ安全クラス(CWE-119/20)、停滞仮説ならサプライチェーンクラス(CWE-494/829)。ここに有意な効果が出てしまったら、仮定が崩れているサインです。
結果:3つのチャネルが独立に効いていた
H1・H2:スプロール軸と認証衛生軸は別々に効く
npm package-lock v3義務化(H1)は推移的スプロールを制約し、CWE-494/829の発生率を対照群比で −0.23 CVE/パッケージ年(95% CI: −0.31〜−0.15、Callaway–Sant'Anna ATT、義務化後12ヶ月)下げました。媒介分析では、この効果の40%超が「直接依存数の削減」というスプロール経路を通っています。
PyPI必須2FA(H2)は、認証衛生の経路でCWE-494/829を −0.18 CVE/パッケージ年(95% CI: −0.27〜−0.09)低減。ここで効いているのは依存構造ではなく「アカウント乗っ取り対策」です。
プラセボ試験も通りました。H2の処置を無関係なはずのCWE-119/20へ当てると、係数はほぼゼロ(β̂ = −0.02、p = 0.61)。認証衛生の改善がスプロール軸のCWEに波及していないことが確認できます。
H3:言語の保証か、コミュニティの文化か
crates.ioのスパースレジストリ導入によるCWE-119/20抑制は、ATTにして 約 −0.12 CVE/パッケージ年。H1の −0.23 のおよそ半分にとどまりました。
なぜ半分なのか。cargo-geiger で測った unsafeコード密度 を第三の軸に加えて、三方向交互作用(スパース導入 × 依存深度 × unsafe密度)で分解してみると、2つのチャネルが見えてきます。
- 言語保証チャネル(低unsafe密度、geiger-score < 0.15): Rustの型システムがCWE-119をコンパイル時に構造的に排除する。依存解決の深さに関係なく効く。
- 文化規範チャネル(高unsafe密度): 言語の保証が外れる領域では、効果がコミュニティのレビュー慣行やセキュリティ意識に依存するようになる。
つまりunsafe密度が、どちらのチャネルを通るかの分岐点になっています。
H4:軸をクロスさせても漏れない(直交性の総仕上げ)
最後にH4として、H1とH3の処置を互いのアウトカムへ入れ替えて適用するクロス軸プラセボ検定を行いました。「H1処置 → CWE-119/20」と「H3処置 → CWE-494/829」を同時推定し、Bonferroni補正済みWaldテストで各ATTがゼロと区別できないかを確認します。
どちらのATTも帰無仮説と統計的に区別できませんでした。これは「単一の潜在的な品質因子が複数のCWEクラスをまとめて説明している」という交絡シナリオを棄却できる、ということです。各チャネルはやはり別々に走っている。
注意点・限界
:::warnで書くほどではないですが、過信は禁物です。
- 観測されない交絡は完全には消せない。政策ショックは外生的に見えますが、施行と同時期に別の動き(大型脆弱性の話題化など)が重なっていれば効果に混ざります。
- CWEへのマッピングはノイズを含む。スプロール軸・陳腐化軸への束ね方が変われば効果量も動きます。
- エコシステム間の比較は文化差を含む。H3の言語/文化チャネルの分解は示唆的ですが、Rust固有の事情に引っ張られている可能性があります。
再現方法
ざっくり手順は次の通りです。
- 上の
fetch.pyを npm / PyPI / crates.io の全対象パッケージに対して回し、メタデータと脆弱性件数を集める(OSVはレート制限に注意、DELAYを入れてある)。 - NVD CVEフィードとOSVを
package_idでマージし、CWEを「スプロール軸 / 陳腐化軸」に集約。 - CEMで依存数・年齢・ダウンロード四分位を揃えて処置群と対照群を対応付ける。
- H1・H2はTWFEのDiD、H3は段階導入なのでCallaway–Sant'Anna推定量で各ATTを推定。
- 無関係なCWEクラスをプラセボ結果変数に置いて、平行トレンドと直交性を検証する。
まとめ
- 依存グラフ上位四分位は下位四分位の 約3.1倍 のCVEを蓄積。相関は強い。
- 政策ショックを自然実験にしたDiDで、依存スプロールの抑制(H1)がCVE発生率を実際に下げることを確認(ATT −0.23 CVE/パッケージ年)。相関だけでなく因果の方向に効いている。
- スプロール軸(H1)と認証衛生軸(H2)は独立に効く。交差プラセボがほぼゼロ(β̂ = −0.02, p = 0.61)でそれを裏付け。
- crates.io(H3)の効果はH1の約半分。これは実装の粗さではなく、Rustの言語保証が効く領域とコミュニティの文化規範が効く領域の違いによるもの。
- 実務的な持ち帰り: 推移的依存由来のCVEがnpmで77%を占める以上、「依存を減らす・固める」施策は気休めではなく、測定可能な脆弱性削減につながる。
参考・データ出典
本稿の分析は以下の公開データソースおよびレジストリAPIに基づく。
もっと深掘りする
テーマを掘り下げる書籍と、作業環境を快適にするアイテム(Amazon.co.jp・広告を含みます)。
- 因果推論を体系的に学ぶ DiDや処置効果の考え方を基礎から固めたい読者に。
- 差分の差分法など計量分析の実践書 TWFEやDiDの推定を手を動かして学べる。
- ソフトウェアサプライチェーンの安全対策 依存とCVE管理の実務知識を補強したい人へ。
- 依存グラフも分析結果も見渡せる4Kモニター 巨大な依存ツリーやDiD結果を一望したい人に。
- 長時間の分析に集中できるノイズキャンセリングヘッドホン 深い解析作業に没頭したいあなたの集中力を守る。
Amazonのアソシエイトとして、Towel Switchは適格販売により収入を得ています。