はじめに
次から次へとバグが発生するソフトウェアシステムにおいて、Coverityはいかにしてバグ発見ツールを構築し、ビジネスを確立したのだろうか。本稿では、米国Communications of the ACMの"A Few Billion Lines of Code Later: Using Static Analysis to Find Bugs in the Real World"の記事を一部転載して、バグ発見の法則について紹介する(翻訳:コベリティ日本支社)。
バグ発見の法則
バグ発見の基本法則は「チェックなければバグはなし」である。ツールがシステム、ファイル、コードパス、一定のプロパティをチェックできなければ、バグも発見できない。適切なツールがあると仮定すると、バグの勘定をまず拘束するのは、そのツールを使って一体どれだけのコードをこなせるか、ということだ。コードが10倍になれば発見されるバグも10倍になる。
この法則は、充分に簡潔に事実を述べているものと推測した。残念なことに、一見すると無効な2つの結論が、バグの勘定に厳しい一次限界を設けることとなった。
法則:見えないコードはチェックできない。「コードをチェックするには、まずコードを見つけなくてはならない」という指摘は、あまりに陳腐に聞こえるかもしれないが、膨大なコードベースでそれを首尾一貫して実施してみれば、言っていることの意味が分かるだろう。システムをチェックする上でもっとも信頼できる方法はおそらく、プログラムのビルドの過程でコードを獲得することだ。ビルドシステムは、どのファイルがシステムに含まれており、どのようにコンパイルするかを熟知している。これは簡単なことのように思われる。ところが残念なことに、その場しのぎで作られた自家製ビルドシステムがこうした情報の抽出を滞りなく行うべく充分な働きをしているかは理解しがたいということが頻繁に起こる。「そこに触れては駄目だ」という、ありがちな絶対的命令により、困難の度合いはさらに増す。
企業は通常、外部の人間が何かを修正するのを拒むものだ。彼らのコンパイラパスや破損したメイクファイル(それがある場合)を修正することはできず、自分自身のテンポラリファイル以外は、何も書いたり再設定したりすることができない。修正するにしても理解できないのが関の山なので、それはそれでよしとしよう。
そのうえ、企業はセキュリティのためにテストマシンの設定を厳格化することも多い。その結果、チェックのために与えられたビルドが最初は正常に作動しないことも珍しくなく、何か手を加えていた場合には非難されることになる。
2002年の商品化で最初の数か月に我々がとったアプローチはローテクな、ビルドコマンドの読み取り専用の再生だった。メイクを実行し、ファイルへのアウトプットを記録し、コンパイラ(gccなど)への呼び出しを書き直し、当社のチェックツールをかわりに呼び出した後にすべてを元に戻す。簡単で単純なやり方だ。このアプローチは、研究室と初期の少数の顧客では完璧に機能した。その後、我々と見込み客への間では次のような会話が交わされた。
御社のツールは、どうやって実行すればいいんでしょうか?
ただ「make」とタイプしてください。そうすれば当社がアウトプットを書き直します。
「make」ってなんですか?うちはClearCaseを使っているのですが。
えっ、ClearCaseってなんでしょう?
これは乗り越えることのできない隔たりとなった(厳密にいえば顧客は「ClearMake」を使っていたのだが、表面的には名称が似ていても、技術レベルではまったく役に立たない)。この会社はあきらめて、我々は別の企業にアプローチした。彼らは我々の手法のまた別の問題を露わにした。しかし、こうした問題について、我々は敏速に随時対処してきた。そのどれもが、アプローチの再検討を迫るほど厄介なものにはみえなかった―――少なくとも、ある大手顧客から次のようなサポート依頼の電話を受けるまでは。
「御社のツールを実行するとき、なぜLinuxをCDから再インストールしなきゃならないのですか? 」
これは実に不可解な質問だった。さぐり回ったところ、次のような一連の出来事が明らかになった。この会社のメイクは、コンパイラが実行されるディレクトリの絶対パスをプリントアウトするのに新しいフォーマットを使用していた。当社のスクリプトはそのパスを誤って解析し、空白文字を生成したが、それがUnixの「cd」(change directory)コマンドの宛先として与えられ、システムのトップレベルに変更された。これはコンパイル中に「rm -rf *"」(再帰的な削除)を実行し、テンポラリファイルをクリーンアップした。そしてビルドのプロセスはルートとして実行された。このようなイベントが組み合わされて、システム上のすべてのファイルが削除される結果となったのだ。
説明を誤って理解するとエラーが見落とされ、最悪の場合には誤検知に変えられてしまう。
我々が過去7年間に採用してきた正しいアプローチでは、ビルドのプロセスを開始し、呼び出されたシステムコールをひとつひとつ封じる。その結果、呼び出された実行ファイル、そのコマンドライン、それらが実行されるディレクトリ、コンパイラのバージョン(コンパイラとそのバグの回避策に必要)など、チェックに必要なすべてのものが見えてくる。このようなコントロールにより、すべてのソースコードの獲得と正確なチェックが容易になり、言語の「方言」をファイル単位で自動的に変更できるほどになる。
当社のツールを起動するには、ビルドコマンドを引数として呼び出すだけでよい。
cov-build<build command>
我々はこのアプローチを安全度が高いものだと思っていた。しかし残念なことに、賢明な読者ならお分かりのとおり、これにはコマンドプロンプトが必要だ。これを大手顧客に導入した直後、我々は相手先を訪問したが、そこは大企業だったので高い専門性を備えたビルドエンジニアがいた。下記の会話はそのエンジニアとの間で交わされたものだ。
御社のツールは、どうやって実行すればいいんでしょうか?
簡単ですよ。ビルドコマンドの前に「cov-build」とタイプしてください。
ビルドコマンドですって? 私は、この「GUI」ボタンを押すだけなんですが。
社会vs.技術。変えることができない社会的制約は、それがいかに破綻していようと、みっともない回避策を取らなくてはならない。よくある例は次のようなものだ。Windows上でのビルド埋め込みには、デバッガのコンパイラの実行が必要だ。残念なことに、これを行うと非常に普及しているWindows C++コンパイラのVisual Studio C++ .NET 2003が、奇妙なエラーメッセージとともに途中で終了してしまう。
ストレスを感じながら調査した結果、コンパイラにuse-after-free(解放後使用)のバグがあり、コードがMicrosoft特有のC言語拡張(#を使った命令のある種の起動)を使用したときにヒットすることが分かった。コンパイラは通常の使用では問題なく作動する。解放されたメモリを読み込むとき、オリジナルのコンテンツはまだメモリ内にあるため、すべてが正常に機能する。しかしデバッガと連動して稼働するとき、コンパイラは解放されたメモリコンテンツを解放コールのたびに不要データの値に設定する「debug malloc」を使用するよう切り替わる。後続の読み取りではこの値が返され、コンパイラは致命的エラーによりクラッシュする。つむじ曲がりの読者なら、おそらく「解決策」は想像がつくだろう。
法則:解析できないコードはチェックできない。コードを徹底的にチェックするには、そのコードのセマンティクスを細部まで理解しなくてはならない。一番基本的な要求事項は、解析を行うことだ。解析すれば問題は解決するとされる。ところがこの見方は甘いとしかいいようがなく、プログラム言語などというものが存在するという、広く信じられた神話に根差したものである。
―――そもそも、C言語は存在しない。Java、C++、C#もしかり。概念としての言語は存在するにしても、そしてそれを定義していると称される山のような書類(標準)があるにしても、標準はコンパイラではない。人々はどの言語でコードを書くのだろうか? コンパイラが受け付ける文字列で、である。そのうえ、彼らはコンパイルと保証を同一視する。コンパイラが拒否しないファイルは、そのコンテンツがコンピュータ言語学者からみると明らかに不正なものであっても「Cコード」として認定される。Cのフロントエンドを含むバグ検出ツールがこのような不正な非Cコードを与えられると拒絶する。このような問題がツールの問題となった。
問題をさらに悪化させたのは、ツールの実行に責任を持つ人は、チェック対象のコードがツールを不安定にした場合に責められる人とは別人であることが多いということだ(この人物は、分析されたソースコードやツールの作動原理を理解していないことが多い)。とりわけ、当社のツールは夜間ビルドの一環として実行されることが多いため、そのプロセスを管理するビルドエンジニアがツールの正常稼働に責任を持つことがしばしばある。多くのビルドエンジニアが考える成功の明確な基準とは「すべてのツールが正常な終了コードで処理を終了する」というものだ。彼らはCoverityのツールを、処理しなくてはいけない事柄のリストにある一業務にすぎないと捉えている。「正式」なコンパイラが受け入れたのにツールが解析エラーで拒否したコードの修正を喜んで行う人が、どれだけいるだろう。一般的に、こうした関心のなさが、彼らが責任を持つツールのあらゆる側面に対する関心のなさにつながっている。
コンパイラの多く、いやおそらくすべては標準から逸脱している。コンパイラにはバグがあり、非常に旧式であったり、プログラミング言語の仕様(C++だけではない)を誤解している人が書いていたりするし、多数の拡張もある。こうした逸脱のせいで、標準に則っていないコードが出てくる。もしコンパイラが構成概念Xを受け入れるなら、それを理解するプログラマーとコードが与えられれば、しまいにはXは拒否されずにタイプされるようになり、コードベースに収まってしまう。ところが静的ツールは不都合なことに、それを解析エラーとして警告する。
重要なマーケットには逸脱コードがあふれているため、ツールはそれを単に無視するわけにはいかない。たとえば、ある巨大ソフトウェア企業はかつて適合性を競争上のデメリットと考えていた。なぜならその企業の製品の代替品となるツールを他社が作ることを許してしまうからだ。組み込みソフトウェア企業も、その顧客がバグを嫌悪することを考えれば、ツールの素晴らしい売り込み先だ。ユーザーは自動車(あるいはそれがトースターであっても)の故障を嫌がる。残念なことに、そうしたシステムにおけるスペースの制約と、ハードウェアとの強い結び付きにより、コンパイラ拡張は、熱烈に支持、利用されている。
最後に、安全性が決定的に重要なソフトウェアシステムでは、コンパイラを変更するとコストのかかる再認定が必要となることが多い。そのため、数十年経過した古いコンパイラを使っているのをよく見かける。こうしたコンパイラが受け付けるプログラミング言語は興味深い仕様をもっているものの、最近の言語標準との強力な調和はその仕様に含まれていない。歳月が新たな問題を引き起こす。現実的にいって、コンパイラの逸脱を診断するにはそのコンパイラ製品を入手しなくてはいけない。しかし20バージョンも前のコンパイラのライセンスを、どうやって購入すればよいのだろう。あるいは、つぶれてしまった会社のコンパイラを買うには? 通常のチャネルでは無理である。我々はeBayを通して買うという手段をとったこともある。
この原動力は、安全性が決定的に重要とはいえないシステムではもっとさりげなく明らかになる。コードベースが大きくなればなるほど、営業部隊はセールスの報酬を多く受け、そのようなシステムに向けたセールスが急上昇する。大規模なコードベースは構築に時間がかかり、それが作られた当時のコンパイラに縛られており、我々が対応しなくてはいけないプログラミング言語を使っているコンパイラの平均年齢を上げてしまう。
もし逸脱によって誘発された解析エラーが、そこかしこに散らばる孤立したイベントならば、それは大したことではない。信用度の低いツールはエラーをスキップする。困ったことに障害はモジュール的でないことが多い。あまりにもよくある悲しい筋書きでは、「C」とされている重要なヘッダーファイルが、明らかに不正な非Cの構造を持っている。この構造はすべてのファイルに含まれている。もはや見込み客でなくなった相手先は、コンパイラがソースファイルを読み込むたびに絶え間ない解析エラーに見舞われ、毎回拒絶される。顧客はあざけるように「ソースコードの徹底的な分析だって? おたくのツールはコードのコンパイルすらできないじゃないですか。これでどうやってバグを見つけるんです? 」と言う。この出来事を顧客は面白いと感じて、多くの友人に触れまわるかもしれない。
ヘッダーファイルにあった一連の不良な断片。我々がネットワークの大企業で遭遇した、不正に構築されたキーヘッダーファイルの最初の事例である。
//"redefinition of parameter 'a'" void foo(int a, int a);
このプログラマーは「foo」の最初の正式パラメータを「a」と名付け、語彙のローカル性に即して次も同様に名付けた。人畜無害だ。しかし標準準拠コンパイラはどれも、このコードをはねる。当社のツールもまさしくそうだった。これでは役に立たない。ファイルをコンパイルしないならバグも見つからないが、それなら人々はツールを必要としない。そしてコンパイラが受け入れたために、見込み客は我々を責めることとなった。
次に紹介するのはこれとは逆の、いっそう深刻なケースで、プログラマーは2つの異なるものを一緒くたにしようとしていた。
typedef char int
そしてもうひとつは、言語の仕様に対しての可読性が奥の手を出した形だ。
unsigned x = 0xdead_beef
次は組み込み市場で見られた例で、ラベルが作成されているがスペースをとっていない。
void x;
もうひとつの組み込みアプリケーションの例では、スペースの出所をコントロールしている。
unsigned x @ "test";
次は、非標準的なコンストラクトのいっそう進展したケースだ。
Int16 ErrSetJump(ErrJumpBuf buf) ={ 0x4E40 + 15, 0xA085; }
ここではマシンコードの命令の16進法の値をプログラムソースとして扱っている。もっとも広く使われる拡張子のコンテストをするなら、一位はプリコンパイル済みのヘッダーに対するMicrosoftのサポートになるだろう。もっともイライラさせられるトラブルは、プリコンパイル済みヘッダーを含める前に、コンパイラがすべてのテキストをスキップすることだ。この動きが示唆するのは、下記のようなコードがすんなりとコンパイルされるということである。
I can put whatever I want here. It doesn't have to compile. If your compiler gives an error, it sucks. #include <some-precompiled- header.h>
マイクロソフトのオンザフライのヘッダー偽造が事態をさらに悪化させる。
アセンブリーのコードは、常にもっとも厄介な構成物だ。これはすでに可搬性がないため、コンパイラは奇妙な構文をほとんど意図的に使っているように見受けられ、一般的なやり方では対応が難しくなる。残念なことに、プログラマーがアセンブリーのコードを使用するのはおそらく、よく使われる機能を記述するためであり、プログラマーがそれを行う場合は、広く使われるヘッダーファイルに置かれることが多い。下記にmov命令を出す(いろいろあるうちの)2つの方法を挙げる。
// First way foo(){ _ _ asm mov eax, eab mov eax, eab; } // Second way #pragma asm _ _ asm [ mov eax, eab mov eax, eab ] #pragma end_asm
mov以外の唯一の共通点は、省略に使える共通したテキストキーが欠如しているということだ。
今まで単純な言語であるCについてだけ論じてきた。C++コンパイラの逸脱はそれに輪をかけてひどいもので、対応には大変な手がかかる。その半面、C#およびJavaは、ソースコードでなくコンパイラが生成するバイトコードを解析したため、容易だった。
非Cを、Cフロントエンドを使って構文解析するには、、プログラマーは拡張子を使う方法で問題ない。だが、実際に解析できるように対応するのはどれほど難しいのだろう? Coverityの腕利きのエンジニアたちは専従チームを作り、このありふれた、技術的には面白味のない問題に専ら取り組んだ。その仕事はついに完了しなかった。
我々は最初、この問題をだれかに押しつけようと、コードの解析にEdison Design Group(EDG)C/C++フロントエンドを使った。EDGは1989年以来、真のCコードの解析法に取り組んできており、業界のデファクト標準のフロントエンドだ。自家製のフロントエンドを構築しないと決めた人々はすべて、ほぼ確実にEDGをライセンスする。また、自家製のフロントエンドを構築した人々は、現実のコードを何度か扱ってみると、EDGをライセンスすればよかったとほぼ確実に後悔することになる。EDGは単なる仕様の互換性を目指しているわけではなく、幅広いコンパイラを網羅する、特定のバージョンに対応したバグ互換性を志向している。同社のフロントエンドは、フロントエンドの変化度合いという観点では採算性ギリギリのところでビジネスをやっているといえるだろう。
残念ながら、20年間にわたる努力にもかかわらず、EDGが現実の大規模コードベースの解析を試みるといまだに打ち負かされることを目の当たりにすれば、コンパイラライターの創造性のほどが分かる。したがって、各コンパイラに対する我々の次のステップは、個人的言語をEDGが解析可能なものに近付ける「変換器」をプログラミングすることだった。もっとも一般的な変換は、邪魔をしている構成物を単に取り除くことだ。この表は、言語の非Cの度合いを測るひとつのモノサシとして、広く使用されている18種のコンパイラが、その言語をかろうじてC言語と認識するには変換器のコードが何行必要になるかを示したものだ。変換器のコードが書かれるのは、ほとんどの場合、我々の手に負えない問題に直面したときだけだった。新しいコンパイラを「サポート済み」コンパイラのリストに加えるには、たいてい何らかの変換器を書く必要があった。ときにはセマンティクスを深く把握する必要に迫られ、EDGを直接ハッキングしなくてはならなかった。この方法は最後の手段だ。それでも、総計で(2009年初めの時点)、フロントエンドの406箇所以上に#ifdef COVERITYを置き、予期せぬ特定の構成物に対処した。
EDGはコンパイラのフロントエンドとして広く使われている。EDGベースのコンパイラを使うお客様には何の問題も起こらないと思うかもしれない。しかし、残念ながらそれは当たっていない。EDGに基づいたコンパイラはしばしば奇妙なやり方でEDGを修正する、という事実に目をつぶったとしても、ただひとつの「EDGフロントエンド」というものは存在せず、多くのバージョンや設定があり、我々が使用しているバージョン(新しいものであることが多い)とはわずかに異なる言語の変種を受け入れてしまうことがよくある。問題になんとか対処することができず、非互換性をレポートした場合、EDGがその問題を重要で修正が必要とみなせば、新しいバージョンが出る際に他のパッチとともに取り入れられる。
つまり、自分たち自身のフィックスを行うために、使用するバージョンをアップグレードしなくてはならず、アップグレードしていないそれ以外のEDGコンパイラフロントエンドとの相違をしばしば引き起こすことになり、いっそうの問題が発生する。
社会vs.技術。問題をデバッグするために、顧客のソースコードを入手することは可能だろうか? たいていの場合、答えは「ノー」だ。その結果、当社のセールスエンジニアはレポートに問題を書きこむ際、記憶に頼ることになる。その効果は、推して知るべしだ。大規模なコードの設定でのみ出現するパフォーマンスの問題の場合は、さらに厄介だ。しかし、クラシファイドシステムが引き起こす問題に比べたら、文句はいえない。誰かを現場に派遣してコードを調べましょうか? いいえ、電話で構文を読み上げますので、聞き取ってください。
(注釈付きの全編はこちらで入手できます)
“A Few Billion Lines of Code Later: Using Static Analysis to Find Bugs in the Real World”
Communications of the ACM,Vol. 53:2, c 2010 ACM, Inc.
http://doi.acm.org/10.1145/1646353.1646374
この翻訳はACMに著作権がある資料の派生物です。
ACMは翻訳をしておらず、元の出版物の正確な複製であることを保証しません。この資料に含まれる原本の知的財産は、ACMの資産です。