Upgrade to Pro — share decks privately, control downloads, hide ads and more …

3年間お世話になった社内製PHP互換パッチに別れを告げるまで 〜 静的解析とリスク分析で進めた...

Avatar for reomin reomin
October 02, 2026

3年間お世話になった社内製PHP互換パッチに別れを告げるまで 〜 静的解析とリスク分析で進めたレガシー対応 〜

PHP 8.0で文字列と数値のあいまい比較の挙動が変更され、0 == "foo" は true から false を返すようになりました。

この変更に対し、20年以上開発が続く当社の巨大なコードベースでは影響範囲をコードレビューやテストだけで把握しきれませんでした。
そこでPHP本体のCソースコードを修正し、PHP 7.4と8.0の比較処理を同時に実行して結果の差異を検知する互換パッチを構築することで、安全にバージョンアップを進めました。
詳細は当社 akamah の過去の登壇資料をご覧ください: https://speakerdeck.com/akamah/20nian-mononoju-da-regasipurodakutowo-php-8-dot-0niatupudetositaji-nodui-ce-tode-raretazhi-jian?slide=38

しかし導入から3年、お世話になった互換パッチ自体が次のバージョンアップを阻む技術的負債になっていました。残ったあいまい比較を全件レビューするのは現実的ではありません。

そこで nikic/php-parser で数万箇所のあいまい比較を抽出し、両辺の型が静的に決まらないものや外部入力由来の変数を含むものをパターン分類し、アプリケーションのどこで使われているかと業務影響の大きさを軸にリスクを分類し、確認すべき数百件まで絞り込んで互換パッチを外していきました。

仕様変更のリスクをどう見極め、技術的負債を外していったか。
影響範囲が見えないとき、どう安全に前へ進むか。リスク分析で前へ進んだ実践をお話しします!!

Avatar for reomin

reomin

October 02, 2026

More Decks by reomin

Other Decks in Programming

Transcript

  1. 自己紹介 reomin @i_moqa reomin • 2024年からサイボウズ株式会社で QA エンジニアをやっています – OSS

    更新(PHP・MySQL・その他ライブラリ)と セキュリティ維持など – 現在は PHP 8.5 対応と PHP 9 に向けた準備をしています • 愛媛に来るのは2回目。前回は道後温泉本館が改修中でした 2
  2. 今日話すこと • 4年前に入れた自社製 PHP 互換パッチを外す過程と、外した後の話 – PHP 本体にパッチを当てた背景 – 不具合の記録と

    AST を使って、あいまい比較の候補を検出・集計した – たくさんある候補を、リスクベースドテストで優先度付けして絞り込んだ • (時間があったら)どのようなテストを実施したか https://blog.cybozu.io/entry/2025/08/04/100000 4
  3. 01 背景 PHP 8.0 へのアップデート(4年前) • • • 2022年、Garoon で使っている

    PHP 7.4 → 8.0 にアップデートしました PHP 8.0 では、互換性に影響する仕様変更がいくつか入りました (例:非推奨機能の削除 など) その中でも今日は、比較演算子の変更に注目します https://speakerdeck.com/akamah/20nian-mononoju-da-regasipurodakutowo-php-8-dot-0niatupudetositaji-nodui-ce-tode-raretazhi-jian 7
  4. 01 背景 PHP の比較演算子 • あいまい比較(== != <> < <=

    > >= <=>):型が違うと、型をそろえてから値を比べる • 厳密な比較(=== !==):型をそろえず、型と値の両方が一致するかを比べる https://www.php.net/manual/ja/language.operators.comparison.php 8
  5. 01 背景 PHP 8.0 であいまい比較の結果が変わった • PHP7.4:文字列を必ず数値に変換して比較する('12abc' → 12、'hoge' →

    0 ) • PHP 8.0 以降:文字列が数値として読める場合だけ、数値で比較する。読めない場合は、数値を文 字列に変換して文字列同士で比較する // 比較結果:7.4 → 8.0 以降 '12abc' == 12 // true '1a' == 1 // true 'hoge' == 0 // true '' == 0 // true '25.0' '42' == 25 == '42 ' → false → false → false → false 7.4 は '12abc' を 12 にして比較 7.4 は '1a' を 1 にして比較 7.4 は 'hoge' を 0 にして比較 7.4 は '' を 0 にして比較 // true → true // false → true 数値形式なので、どちらも数値で比較 8.0 から末尾空白ありも数値形式 RFC: https://wiki.php.net/rfc/string_to_number_comparison https://wiki.php.net/rfc/saner-numeric-strings 9
  6. 01 背景 Garoon の開発状況 • PHP 4 の時代から続く20年もののソースコード。== 等のあいまい比較を多用 •

    超巨大なコードベース(PHP だけで約120万行) • ユニットテストのカバレッジは約20% 2022年、PHP 8.0 移行時点 コードの保守だけでも負担が大きい 11
  7. 01 背景 挙動は 8.0 のまま、差異だけを警告するパッチを入れた • • • • Garoon

    では PHP 本体を自前ビルドしている PHP 内部であいまい比較を行う処理(Zend/zend_operators.c)を改造 PHP 8.0 に PHP 7.4 の比較処理を移植し、両方の結果を比べる 結果が異なれば E_WARNING を発生させるようにした 移植された PHP 7.4 の比較処理 結果が異なれば警告 オリジナルの PHP 8.0 の比較処理 12
  8. 01 背景 差異が出ても、不具合になるとは限らない • 4年間の運用で、「差異が出る」≠「不具合になる」ことが見えてきた → この例では、分岐がずれても不正な値はどちらの経路でもバリデーションで止まる $status = $_POST['status'];

    // 外部入力 if ($status == 0) { … $this->validateStatus($status); } else { … $this->validateStatus($status); } // 'hoge' のとき 7.4: true → 8.0: false // どちらに進んでも // 不正な値はここで止まる 15
  9. 01 背景 パッチを外したい理由が溜まってきた 抱え続けるのがつらい • PHP アップデートのたびに、C言語 のパッチを追従させる作業が発生する • パッチを知らないメンバーが増えた。C言語

    で保守できる人は数人 • PHP 9 を見据えると、本体改造を抱え続けたくない 今なら外せる • 4年間で知見が溜まったメンバーが在籍しているうちに外したい • その知見から、テスト対象を絞っても良さそうと判断できた 16
  10. 02 外す根拠を数える 何が難しいのか • 歴史のあるアプリで品質要求は高い。既存機能は守らなければならないが、それを確かめる手段がない 外すと失うもの 差異の検知手段 挙動は 8.x のまま。外しても動きは変わらない

    分からないこと 影響を受ける比較が、どこに・いくつ残っているか 4年分の警告は、実際に通った経路しか教えてくれない できないこと 全アプリで「不具合ゼロ」を証明すること 120万行・全アプリの動作保証は現実的ではない → 問題は「外すと壊れるか」ではなく、「検知なしでも大丈夫と言えるか」 19
  11. 02 外す根拠を数える ① 文字列検索(grep) • 誤検出も取りこぼしもあり、正しく数えられない switch ($status) { case

    "active": break; } // 内部では == 比較 in_array(0, ["a", "b"]); // 7.4: true 8.0: false • コメントや文字列リテラル内の == も拾ってしまう • == を書かない比較(switch 等)は拾えない 例)switch の case は、== を書かずに内部であいまい比較を使用している 23
  12. 02 外す根拠を数える ② PHPStan カスタムルール • 型宣言・PHPDoc がなく mixed だらけで、型では候補を除外できなかった

    $left = $scope->getType($node->left); // 型宣言・PHPDoc なし → mixed $right = $scope->getType($node->right); if ($left->isInteger()->yes() && $right->isInteger()->yes()) { return []; // int 同士など、型で安全と分かる組だけ除外したい } // 実際は mixed ばかりで除外できず、ほぼ全件が候補に残る return [RuleErrorBuilder::message('loose')->identifier('garoon.loose')->build()]; • 型宣言・PHPDoc がない箇所や、独自 フレームワーク経由の値は mixed になり、判定できない 24
  13. 02 外す根拠を数える ③ AST(抽象構文木)で数える AST = ソースコードを部品に分解した木構造 • コメントや文字列は演算子ノードにならない →

    grep の誤検出を防げる if ($status == 1) { ... } • switch の case も Stmt_Case ノードとして見つ けられる → grep の取りこぼしを防げる Stmt_If cond: Expr_BinaryOp_Equal left: Expr_Variable right: Scalar_Int // ← 対象 • 型推論をしないので、PHPDoc に依存しない → PHPStan で mixed になって判定できない問題 を避けられる 27
  14. 02 外す根拠を数える ③ PHP-Parser を使う • PHP-Parser = PHP で書かれた

    PHP のパーサ。 PHP のソースコード ↓ パーサが読み込んで解析 AST(部品に分解された木構造) • 起票済みの不具合の比較をパターン化し(左右のノードの組み 合わせ)、同じパターンの箇所を割り出す • AST では型までは分からないため、構文上の候補を列挙するた めに使う // 起票済みの不具合と同じパターン // == != <> 変数 vs 数値リテラル 変数 vs 変数 変数 vs 定数・クラス定数 配列要素 vs 数値リテラル・変数 // < > 変数・配列要素 vs 数値リテラル // switch 変数・配列要素 の case 定数 // 対象外 === !== nikic/PHP-Parser https://github.com/nikic/PHP-Parser 28
  15. 02 外す根拠を数える ③ AST:実装(抜粋) • 各ファイルをパース → AST を JSON

    に変換 → 配列として再帰的に走査 → 起票済みの不具 合と同じパターンを出力するようにした。 // 例:「変数 == 数値リテラル」は起票済みの不具合と同じパターン if ($value['nodeType'] === 'Expr_BinaryOp_Equal' && $value['left']['nodeType'] === 'Expr_Variable' && $value['right']['nodeType'] === 'Scalar_Int') { // 該当したら「ファイル:行」を記録 $result[] = "{$filepath}:{$value['attributes']['startLine']}"; } // 逆順・変数 vs 定数・switch の case なども同様に照合 29
  16. 02 外す根拠を数える 潜在的な問題の実態が見えた • 起票済みと同じパターン:全体で約 10,000 箇所(test / vendor 配下は除外)

    • 最も多いアプリ A に約 1,800 箇所。これまでの不具合修正の傾向とも一致した → アプリ A で確かめれば、パッチを外す根拠になると考えた 30
  17. 02 外す根拠を数える アプリ A だけで判断できる理由 • 候補も不具合の修正も、アプリ A が一番多い →

    一番問題が起きやすい場所を重点的に調べる • 外しても挙動は変わらない → 2022 年から 8.x で本番運用している。なくなるのは警告だけ • 調べきれなかったところは監視を続ける → アプリ A で問題が見つからなければ、残るリスクは受け入れられると判断した 31
  18. 02 外す根拠を数える アプリ A で「外してよい条件」を文章にした • アプリ A の主要機能(テスト対象機能)で、以下の 2

    つを確かめられれば外してよいとした 条件 1 致命的な不具合が 0 件 例:主要な業務フローが止まらない 条件 2 セキュリティ問題が 0 件 例:権限の越境、危険な値の保存がない 32
  19. 03 テスト対象をさらに絞る リスクベースドテスト • リスクベースドテストの考え方を取り入れることにした • 加重やスコア式は入れず、チームで説明できて優先順位が決まることを重視した リスク = 発生可能性の代理指標

    × 影響の大きさ 発生可能性 = あいまい比較の候補数(AST の集計結果をそのまま使う) 影響の大きさ = ファイルの責務から決めた 高・中・低 ISTQB 用語集:リスクベースドテスト https://glossary.istqb.org/ja_JP/term/risk-based-testing 34
  20. 03 テスト対象をさらに絞る 影響度は、ファイルの責務から決めた • 仕様書を作成し、アプリごとにどのファイルで何が実装されているか(責務)を洗い出した • まず、主要機能を実装しているファイルに絞った → 112 ファイル・約

    590 箇所 主要機能 = ユーザーが日常的に使う業務フロー、不具合のデータが多い機能 • その責務に則って、ファイル単位で影響度(高・中・低)をつけた 影響度 定義 高 ユーザーが操作できなくなる致命的な影響 中 直接操作には影響しないが、支障が出る可能性 低 内部処理や限定的な条件下のみ 35
  21. 03 テスト対象をさらに絞る 候補数 × 影響度で、調査の優先順位を決めた • ファイルごとに、候補数(少・中・多)と影響度(高・中・低)をマトリクスに置いて優先度を決めた • 候補が 3

    個以上あるファイル、または影響度「高」のファイルは、その時点で調査対象にした 影響度 ↑ 高 最優先 中 低 少 中 多 優先度 ファイル数 箇所数 最優先 15 約 90 高 9 約 100 中以下 88 約 400 発生可能性(候補数) ▪ 最優先 ▪ 高 ▪ 中以下 36
  22. 03 テスト対象をさらに絞る 絞り込みの全体像 • 最終的に以下のように絞り込んだ 段階 候補数 絞り込みの基準 Garoon 全体

    約10,000 箇所 起票済みの不具合と同じパターン アプリ A(今回の対象) 約1,800 箇所 アプリ単位:候補数が最も多い テスト対象(112 ファイル) 約 590 箇所 主要機能に限定 うち最優先・高(24 ファイル) 約 190 箇所 候補数 × 影響度で限定 37
  23. 04 外せることを確かめる コード調査(最優先・高の約 190 箇所) 1 箇所ごとに、次のステップで確認した 1. データフローの追跡(DB に繋がるか)

    2. 比較結果による処理影響の解析 3. セキュリティ観点の入力値想定 4. PHP 7.4 / 8.0 以降の差分テストケース生成 // 例:$status == 0 に渡す入力値 // 入力値 7.4 → 8.0 以降 '0a' // true → false 数字で始まる 'hoge' // true → false 数字がない '' // true → false 空文字 '0.0' // true → true 数値形式 // 例:$status == '0'(文字列同士) '0 ' // false → true 末尾空白 比較結果で分岐する処理を追い、DB に不整合な値が入るものを高リスクとした → 致命的な不具合は見つからなかった(条件 1 を満たした) 39
  24. 04 外せることを確かめる パラメータ改ざん調査 • • 検証環境で、主要画面のリクエスト(GET / POST)を手動と Burp Suite

    で改ざんして送信 一般ユーザー・管理者など役割ごとに試し、改ざんした値と結果をすべて記録 権限の越境や、危険な値の保存は確認されなかった(条件 2 を満たした) 40
  25. 04 外せることを確かめる 条件を満たしたので、パッチを外す判断が下せた 外してよい条件 確かめ方 結果 条件 1 致命的な不具合がない コード調査(約

    190 箇所を精読) 0件 条件 2 セキュリティ問題がない パラメータ改ざん調査(検証環境) 0件 4年間お世話になった自社製互換パッチに、別れを告げることができた 41